diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..50e20c45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,777 @@ +# AI rules for Flutter + +You are an expert in Flutter and Dart development. Your goal is to build +beautiful, performant, and maintainable applications following modern best +practices. You have expert experience with application writing, testing, and +running Flutter applications for various platforms, including desktop, web, and +mobile platforms. + +## Interaction Guidelines +* **User Persona:** Assume the user is familiar with programming concepts but + may be new to Dart. +* **Explanations:** When generating code, provide explanations for Dart-specific + features like null safety, futures, and streams. +* **Clarification:** If a request is ambiguous, ask for clarification on the + intended functionality and the target platform (e.g., command-line, web, + server). +* **Dependencies:** When suggesting new dependencies from `pub.dev`, explain + their benefits. +* **Formatting:** Use the `dart_format` tool to ensure consistent code + formatting. +* **Fixes:** Use the `dart_fix` tool to automatically fix many common errors, + and to help code conform to configured analysis options. +* **Linting:** Use the Dart linter with a recommended set of rules to catch + common issues. Use the `analyze_files` tool to run the linter. + +## Project Structure +* **Standard Structure:** Assumes a standard Flutter project structure with + `lib/main.dart` as the primary application entry point. + +## Flutter style guide +* **SOLID Principles:** Apply SOLID principles throughout the codebase. +* **Concise and Declarative:** Write concise, modern, technical Dart code. + Prefer functional and declarative patterns. +* **Composition over Inheritance:** Favor composition for building complex + widgets and logic. +* **Immutability:** Prefer immutable data structures. Widgets (especially + `StatelessWidget`) should be immutable. +* **State Management:** Separate ephemeral state and app state. Use a state + management solution for app state to handle the separation of concerns. +* **Widgets are for UI:** Everything in Flutter's UI is a widget. Compose + complex UIs from smaller, reusable widgets. +* **Navigation:** Use a modern routing package like `auto_route` or `go_router`. + For more guidelines around navigation, see the section on [routing](#routing). + +## Package Management +* **Pub Tool:** To manage packages, use the `pub` tool, if available. +* **External Packages:** If a new feature requires an external package, use the + `pub_dev_search` tool, if it is available. Otherwise, identify the most + suitable and stable package from pub.dev. +* **Adding Dependencies:** To add a regular dependency, use the `pub` tool, if + it is available. Otherwise, run `flutter pub add `. +* **Adding Dev Dependencies:** To add a development dependency, use the `pub` + tool, if it is available, with `dev:`. Otherwise, run `flutter + pub add dev:`. +* **Dependency Overrides:** To add a dependency override, use the `pub` tool, if + it is available, with `override::1.0.0`. Otherwise, run `flutter + pub add override::1.0.0`. +* **Removing Dependencies:** To remove a dependency, use the `pub` tool, if it + is available. Otherwise, run `dart pub remove `. + +## Code Quality +* **Code structure:** Adhere to maintainable code structure and separation of + concerns (e.g., UI logic separate from business logic). +* **Naming conventions:** Avoid abbreviations and use meaningful, consistent, + descriptive names for variables, functions, and classes. +* **Conciseness:** Write code that is as short as it can be while remaining + clear. +* **Simplicity:** Write straightforward code. Code that is clever or + obscure is difficult to maintain. +* **Error Handling:** Anticipate and handle potential errors. Don't let your + code fail silently. +* **Styling:** + * Line length: Lines should be 80 characters or fewer. + * Use `PascalCase` for classes, `camelCase` for + members/variables/functions/enums, and `snake_case` for files. +* **Functions:** + * Keep functions short and with a single purpose. + Strive for less than 20 lines. +* **Testing:** Write code with testing in mind. Use the `file`, `process`, and + `platform` packages, if appropriate, so you can inject in-memory and fake + versions of the objects. +* **Logging:** Use the `logging` package instead of `print`. + +## Dart Best Practices +* **Effective Dart:** Follow the official Effective Dart guidelines + (https://dart.dev/effective-dart) +* **Class Organization:** Define related classes within the same library file. + For large libraries, export smaller, private libraries from a single top-level + library. +* **Library Organization:** Group related libraries in the same folder. +* **API Documentation:** Add documentation comments to all public APIs, + including classes, constructors, methods, and top-level functions. +* **Comments:** Write clear comments for complex or non-obvious code. Avoid + over-commenting. +* **Trailing Comments:** Don't add trailing comments. +* **Async/Await:** Ensure proper use of `async`/`await` for asynchronous + operations with robust error handling. + * Use `Future`s, `async`, and `await` for asynchronous operations. + * Use `Stream`s for sequences of asynchronous events. +* **Null Safety:** Write code that is soundly null-safe. Leverage Dart's null + safety features. Avoid `!` unless the value is guaranteed to be non-null. +* **Pattern Matching:** Use pattern matching features where they simplify the + code. +* **Records:** Use records to return multiple types in situations where defining + an entire class is cumbersome. +* **Switch Statements:** Prefer using exhaustive `switch` statements or + expressions, which don't require `break` statements. +* **Exception Handling:** Use `try-catch` blocks for handling exceptions, and + use exceptions appropriate for the type of exception. Use custom exceptions + for situations specific to your code. +* **Arrow Functions:** Use arrow syntax for simple one-line functions. + +## Flutter Best Practices +* **Immutability:** Widgets (especially `StatelessWidget`) are immutable; when + the UI needs to change, Flutter rebuilds the widget tree. +* **Composition:** Prefer composing smaller widgets over extending existing + ones. Use this to avoid deep widget nesting. +* **Private Widgets:** Use small, private `Widget` classes instead of private + helper methods that return a `Widget`. +* **Build Methods:** Break down large `build()` methods into smaller, reusable + private Widget classes. +* **List Performance:** Use `ListView.builder` or `SliverList` for long lists to + create lazy-loaded lists for performance. +* **Isolates:** Use `compute()` to run expensive calculations in a separate + isolate to avoid blocking the UI thread, such as JSON parsing. +* **Const Constructors:** Use `const` constructors for widgets and in `build()` + methods whenever possible to reduce rebuilds. +* **Build Method Performance:** Avoid performing expensive operations, like + network calls or complex computations, directly within `build()` methods. + +## API Design Principles +When building reusable APIs, such as a library, follow these principles. + +* **Consider the User:** Design APIs from the perspective of the person who will + be using them. The API should be intuitive and easy to use correctly. +* **Documentation is Essential:** Good documentation is a part of good API + design. It should be clear, concise, and provide examples. + +## Application Architecture +* **Separation of Concerns:** Aim for separation of concerns similar to MVC/MVVM, with defined Model, + View, and ViewModel/Controller roles. +* **Logical Layers:** Organize the project into logical layers: + * Presentation (widgets, screens) + * Domain (business logic classes) + * Data (model classes, API clients) + * Core (shared classes, utilities, and extension types) +* **Feature-based Organization:** For larger projects, organize code by feature, + where each feature has its own presentation, domain, and data subfolders. This + improves navigability and scalability. + +## Lint Rules + +Include the package in the `analysis_options.yaml` file. Use the following +`analysis_options.yaml` file as a starting point: + +```yaml +include: package:flutter_lints/flutter.yaml + +linter: + rules: + # Add additional lint rules here: + # avoid_print: false + # prefer_single_quotes: true +``` + +### State Management +* **Built-in Solutions:** Prefer Flutter's built-in state management solutions. + Do not use a third-party package unless explicitly requested. +* **Streams:** Use `Streams` and `StreamBuilder` for handling a sequence of + asynchronous events. +* **Futures:** Use `Futures` and `FutureBuilder` for handling a single + asynchronous operation that will complete in the future. +* **ValueNotifier:** Use `ValueNotifier` with `ValueListenableBuilder` for + simple, local state that involves a single value. + + ```dart + // Define a ValueNotifier to hold the state. + final ValueNotifier _counter = ValueNotifier(0); + + // Use ValueListenableBuilder to listen and rebuild. + ValueListenableBuilder( + valueListenable: _counter, + builder: (context, value, child) { + return Text('Count: $value'); + }, + ); + ``` + +* **ChangeNotifier:** For state that is more complex or shared across multiple + widgets, use `ChangeNotifier`. +* **ListenableBuilder:** Use `ListenableBuilder` to listen to changes from a + `ChangeNotifier` or other `Listenable`. +* **MVVM:** When a more robust solution is needed, structure the app using the + Model-View-ViewModel (MVVM) pattern. +* **Dependency Injection:** Use simple manual constructor dependency injection + to make a class's dependencies explicit in its API, and to manage dependencies + between different layers of the application. +* **Provider:** If a dependency injection solution beyond manual constructor + injection is explicitly requested, `provider` can be used to make services, + repositories, or complex state objects available to the UI layer without tight + coupling (note: this document generally defaults against third-party packages + for state management unless explicitly requested). + +### Data Flow +* **Data Structures:** Define data structures (classes) to represent the data + used in the application. +* **Data Abstraction:** Abstract data sources (e.g., API calls, database + operations) using Repositories/Services to promote testability. + +### Routing +* **GoRouter:** Use the `go_router` package for declarative navigation, deep + linking, and web support. +* **GoRouter Setup:** To use `go_router`, first add it to your `pubspec.yaml` + using the `pub` tool's `add` command. + + ```dart + // 1. Add the dependency + // flutter pub add go_router + + // 2. Configure the router + final GoRouter _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomeScreen(), + routes: [ + GoRoute( + path: 'details/:id', // Route with a path parameter + builder: (context, state) { + final String id = state.pathParameters['id']!; + return DetailScreen(id: id); + }, + ), + ], + ), + ], + ); + + // 3. Use it in your MaterialApp + MaterialApp.router( + routerConfig: _router, + ); + ``` +* **Authentication Redirects:** Configure `go_router`'s `redirect` property to + handle authentication flows, ensuring users are redirected to the login screen + when unauthorized, and back to their intended destination after successful + login. + +* **Navigator:** Use the built-in `Navigator` for short-lived screens that do + not need to be deep-linkable, such as dialogs or temporary views. + + ```dart + // Push a new screen onto the stack + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const DetailsScreen()), + ); + + // Pop the current screen to go back + Navigator.pop(context); + ``` + +### Data Handling & Serialization +* **JSON Serialization:** Use `json_serializable` and `json_annotation` for + parsing and encoding JSON data. +* **Field Renaming:** When encoding data, use `fieldRename: FieldRename.snake` + to convert Dart's camelCase fields to snake_case JSON keys. + + ```dart + // In your model file + import 'package:json_annotation/json_annotation.dart'; + + part 'user.g.dart'; + + @JsonSerializable(fieldRename: FieldRename.snake) + class User { + final String firstName; + final String lastName; + + User({required this.firstName, required this.lastName}); + + factory User.fromJson(Map json) => _$UserFromJson(json); + Map toJson() => _$UserToJson(this); + } + ``` + + +### Logging +* **Structured Logging:** Use the `log` function from `dart:developer` for + structured logging that integrates with Dart DevTools. + + ```dart + import 'dart:developer' as developer; + + // For simple messages + developer.log('User logged in successfully.'); + + // For structured error logging + try { + // ... code that might fail + } catch (e, s) { + developer.log( + 'Failed to fetch data', + name: 'myapp.network', + level: 1000, // SEVERE + error: e, + stackTrace: s, + ); + } + ``` + +## Code Generation +* **Build Runner:** If the project uses code generation, ensure that + `build_runner` is listed as a dev dependency in `pubspec.yaml`. +* **Code Generation Tasks:** Use `build_runner` for all code generation tasks, + such as for `json_serializable`. +* **Running Build Runner:** After modifying files that require code generation, + run the build command: + + ```shell + dart run build_runner build --delete-conflicting-outputs + ``` + +## Testing +* **Running Tests:** To run tests, use the `run_tests` tool if it is available, + otherwise use `flutter test`. +* **Unit Tests:** Use `package:test` for unit tests. +* **Widget Tests:** Use `package:flutter_test` for widget tests. +* **Integration Tests:** Use `package:integration_test` for integration tests. +* **Assertions:** Prefer using `package:checks` for more expressive and readable + assertions over the default `matchers`. + +### Testing Best practices +* **Convention:** Follow the Arrange-Act-Assert (or Given-When-Then) pattern. +* **Unit Tests:** Write unit tests for domain logic, data layer, and state + management. +* **Widget Tests:** Write widget tests for UI components. +* **Integration Tests:** For broader application validation, use integration + tests to verify end-to-end user flows. +* **integration_test package:** Use the `integration_test` package from the + Flutter SDK for integration tests. Add it as a `dev_dependency` in + `pubspec.yaml` by specifying `sdk: flutter`. +* **Mocks:** Prefer fakes or stubs over mocks. If mocks are absolutely + necessary, use `mockito` or `mocktail` to create mocks for dependencies. While + code generation is common for state management (e.g., with `freezed`), try to + avoid it for mocks. +* **Coverage:** Aim for high test coverage. + +## Visual Design & Theming +* **UI Design:** Build beautiful and intuitive user interfaces that follow + modern design guidelines. +* **Responsiveness:** Ensure the app is mobile responsive and adapts to + different screen sizes, working perfectly on mobile and web. +* **Navigation:** If there are multiple pages for the user to interact with, + provide an intuitive and easy navigation bar or controls. +* **Typography:** Stress and emphasize font sizes to ease understanding, e.g., + hero text, section headlines, list headlines, keywords in paragraphs. +* **Background:** Apply subtle noise texture to the main background to add a + premium, tactile feel. +* **Shadows:** Multi-layered drop shadows create a strong sense of depth; cards + have a soft, deep shadow to look "lifted." +* **Icons:** Incorporate icons to enhance the user’s understanding and the + logical navigation of the app. +* **Interactive Elements:** Buttons, checkboxes, sliders, lists, charts, graphs, + and other interactive elements have a shadow with elegant use of color to + create a "glow" effect. + +### Theming +* **Centralized Theme:** Define a centralized `ThemeData` object to ensure a + consistent application-wide style. +* **Light and Dark Themes:** Implement support for both light and dark themes, + ideal for a user-facing theme toggle (`ThemeMode.light`, `ThemeMode.dark`, + `ThemeMode.system`). +* **Color Scheme Generation:** Generate harmonious color palettes from a single + color using `ColorScheme.fromSeed`. + + ```dart + final ThemeData lightTheme = ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.deepPurple, + brightness: Brightness.light, + ), + // ... other theme properties + ); + ``` +* **Color Palette:** Include a wide range of color concentrations and hues in + the palette to create a vibrant and energetic look and feel. +* **Component Themes:** Use specific theme properties (e.g., `appBarTheme`, + `elevatedButtonTheme`) to customize the appearance of individual Material + components. +* **Custom Fonts:** For custom fonts, use the `google_fonts` package. Define a + `TextTheme` to apply fonts consistently. + + ```dart + // 1. Add the dependency + // flutter pub add google_fonts + + // 2. Define a TextTheme with a custom font + final TextTheme appTextTheme = TextTheme( + displayLarge: GoogleFonts.oswald(fontSize: 57, fontWeight: FontWeight.bold), + titleLarge: GoogleFonts.roboto(fontSize: 22, fontWeight: FontWeight.w500), + bodyMedium: GoogleFonts.openSans(fontSize: 14), + ); + ``` + +### Assets and Images +* **Image Guidelines:** If images are needed, make them relevant and meaningful, + with appropriate size, layout, and licensing (e.g., freely available). Provide + placeholder images if real ones are not available. +* **Asset Declaration:** Declare all asset paths in your `pubspec.yaml` file. + + ```yaml + flutter: + uses-material-design: true + assets: + - assets/images/ + ``` + +* **Local Images:** Use `Image.asset` for local images from your asset + bundle. + + ```dart + Image.asset('assets/images/placeholder.png') + ``` +* **Network images:** Use NetworkImage for images loaded from the network. +* **Cached images:** For cached images, use NetworkImage a package like + `cached_network_image`. +* **Custom Icons:** Use `ImageIcon` to display an icon from an `ImageProvider`, + useful for custom icons not in the `Icons` class. +* **Network Images:** Use `Image.network` to display images from a URL, and + always include `loadingBuilder` and `errorBuilder` for a better user + experience. + + ```dart + // When using network images, always provide an errorBuilder. + Image.network( + 'https://picsum.photos/200/300', + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return const Center(child: CircularProgressIndicator()); + }, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.error); + }, + ) + ``` + +## UI Theming and Styling Code + +* **Responsiveness:** Use `LayoutBuilder` or `MediaQuery` to create responsive + UIs. +* **Text:** Use `Theme.of(context).textTheme` for text styles. +* **Text Fields:** Configure `textCapitalization`, `keyboardType`, and + `placeholder`. + +## Material Theming Best Practices + +### Embrace `ThemeData` and Material 3 + +* **Use `ColorScheme.fromSeed()`:** Use this to generate a complete, harmonious + color palette for both light and dark modes from a single seed color. +* **Define Light and Dark Themes:** Provide both `theme` and `darkTheme` to your + `MaterialApp` to support system brightness settings seamlessly. +* **Centralize Component Styles:** Customize specific component themes (e.g., + `elevatedButtonTheme`, `cardTheme`, `appBarTheme`) within `ThemeData` to + ensure consistency. +* **Dark/Light Mode and Theme Toggle:** Implement support for both light and + dark themes using `theme` and `darkTheme` properties of `MaterialApp`. The + `themeMode` property can be dynamically controlled (e.g., via a + `ChangeNotifierProvider`) to allow for toggling between `ThemeMode.light`, + `ThemeMode.dark`, or `ThemeMode.system`. + +```dart +// main.dart +MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.deepPurple, + brightness: Brightness.light, + ), + textTheme: const TextTheme( + displayLarge: TextStyle(fontSize: 57.0, fontWeight: FontWeight.bold), + bodyMedium: TextStyle(fontSize: 14.0, height: 1.4), + ), + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.deepPurple, + brightness: Brightness.dark, + ), + ), + home: const MyHomePage(), +); +``` + +### Implement Design Tokens with `ThemeExtension` + +For custom styles that aren't part of the standard `ThemeData`, use +`ThemeExtension` to define reusable design tokens. + +* **Create a Custom Theme Extension:** Define a class that extends + `ThemeExtension` and include your custom properties. +* **Implement `copyWith` and `lerp`:** These methods are required for the + extension to work correctly with theme transitions. +* **Register in `ThemeData`:** Add your custom extension to the `extensions` + list in your `ThemeData`. +* **Access Tokens in Widgets:** Use `Theme.of(context).extension()!` + to access your custom tokens. + +```dart +// 1. Define the extension +@immutable +class MyColors extends ThemeExtension { + const MyColors({required this.success, required this.danger}); + + final Color? success; + final Color? danger; + + @override + ThemeExtension copyWith({Color? success, Color? danger}) { + return MyColors(success: success ?? this.success, danger: danger ?? this.danger); + } + + @override + ThemeExtension lerp(ThemeExtension? other, double t) { + if (other is! MyColors) return this; + return MyColors( + success: Color.lerp(success, other.success, t), + danger: Color.lerp(danger, other.danger, t), + ); + } +} + +// 2. Register it in ThemeData +theme: ThemeData( + extensions: const >[ + MyColors(success: Colors.green, danger: Colors.red), + ], +), + +// 3. Use it in a widget +Container( + color: Theme.of(context).extension()!.success, +) +``` + +### Styling with `WidgetStateProperty` + +* **`WidgetStateProperty.resolveWith`:** Provide a function that receives a + `Set` and returns the appropriate value for the current state. +* **`WidgetStateProperty.all`:** A shorthand for when the value is the same for + all states. + +```dart +// Example: Creating a button style that changes color when pressed. +final ButtonStyle myButtonStyle = ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.pressed)) { + return Colors.green; // Color when pressed + } + return Colors.red; // Default color + }, + ), +); +``` + +## Layout Best Practices + +### Building Flexible and Overflow-Safe Layouts + +#### For Rows and Columns + +* **`Expanded`:** Use to make a child widget fill the remaining available space + along the main axis. +* **`Flexible`:** Use when you want a widget to shrink to fit, but not + necessarily grow. Don't combine `Flexible` and `Expanded` in the same `Row` or + `Column`. +* **`Wrap`:** Use when you have a series of widgets that would overflow a `Row` + or `Column`, and you want them to move to the next line. + +#### For General Content + +* **`SingleChildScrollView`:** Use when your content is intrinsically larger + than the viewport, but is a fixed size. +* **`ListView` / `GridView`:** For long lists or grids of content, always use a + builder constructor (`.builder`). +* **`FittedBox`:** Use to scale or fit a single child widget within its parent. +* **`LayoutBuilder`:** Use for complex, responsive layouts to make decisions + based on the available space. + +### Layering Widgets with Stack + +* **`Positioned`:** Use to precisely place a child within a `Stack` by anchoring it to the edges. +* **`Align`:** Use to position a child within a `Stack` using alignments like `Alignment.center`. + +### Advanced Layout with Overlays + +* **`OverlayPortal`:** Use this widget to show UI elements (like custom + dropdowns or tooltips) "on top" of everything else. It manages the + `OverlayEntry` for you. + + ```dart + class MyDropdown extends StatefulWidget { + const MyDropdown({super.key}); + + @override + State createState() => _MyDropdownState(); + } + + class _MyDropdownState extends State { + final _controller = OverlayPortalController(); + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _controller, + overlayChildBuilder: (BuildContext context) { + return const Positioned( + top: 50, + left: 10, + child: Card( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Text('I am an overlay!'), + ), + ), + ); + }, + child: ElevatedButton( + onPressed: _controller.toggle, + child: const Text('Toggle Overlay'), + ), + ); + } + } + ``` + +## Color Scheme Best Practices + +### Contrast Ratios + +* **WCAG Guidelines:** Aim to meet the Web Content Accessibility Guidelines + (WCAG) 2.1 standards. +* **Minimum Contrast:** + * **Normal Text:** A contrast ratio of at least **4.5:1**. + * **Large Text:** (18pt or 14pt bold) A contrast ratio of at least **3:1**. + +### Palette Selection + +* **Primary, Secondary, and Accent:** Define a clear color hierarchy. +* **The 60-30-10 Rule:** A classic design rule for creating a balanced color scheme. + * **60%** Primary/Neutral Color (Dominant) + * **30%** Secondary Color + * **10%** Accent Color + +### Complementary Colors + +* **Use with Caution:** They can be visually jarring if overused. +* **Best Use Cases:** They are excellent for accent colors to make specific + elements pop, but generally poor for text and background pairings as they can + cause eye strain. + +### Example Palette + +* **Primary:** #0D47A1 (Dark Blue) +* **Secondary:** #1976D2 (Medium Blue) +* **Accent:** #FFC107 (Amber) +* **Neutral/Text:** #212121 (Almost Black) +* **Background:** #FEFEFE (Almost White) + +## Font Best Practices + +### Font Selection + +* **Limit Font Families:** Stick to one or two font families for the entire + application. +* **Prioritize Legibility:** Choose fonts that are easy to read on screens of + all sizes. Sans-serif fonts are generally preferred for UI body text. +* **System Fonts:** Consider using platform-native system fonts. +* **Google Fonts:** For a wide selection of open-source fonts, use the + `google_fonts` package. + +### Hierarchy and Scale + +* **Establish a Scale:** Define a set of font sizes for different text elements + (e.g., headlines, titles, body text, captions). +* **Use Font Weight:** Differentiate text effectively using font weights. +* **Color and Opacity:** Use color and opacity to de-emphasize less important + text. + +### Readability + +* **Line Height (Leading):** Set an appropriate line height, typically **1.4x to + 1.6x** the font size. +* **Line Length:** For body text, aim for a line length of **45-75 characters**. +* **Avoid All Caps:** Do not use all caps for long-form text. + +### Example Typographic Scale + +```dart +// In your ThemeData +textTheme: const TextTheme( + displayLarge: TextStyle(fontSize: 57.0, fontWeight: FontWeight.bold), + titleLarge: TextStyle(fontSize: 22.0, fontWeight: FontWeight.bold), + bodyLarge: TextStyle(fontSize: 16.0, height: 1.5), + bodyMedium: TextStyle(fontSize: 14.0, height: 1.4), + labelSmall: TextStyle(fontSize: 11.0, color: Colors.grey), +), +``` + +## Documentation + +* **`dartdoc`:** Write `dartdoc`-style comments for all public APIs. + + +### Documentation Philosophy + +* **Comment wisely:** Use comments to explain why the code is written a certain + way, not what the code does. The code itself should be self-explanatory. +* **Document for the user:** Write documentation with the reader in mind. If you + had a question and found the answer, add it to the documentation where you + first looked. This ensures the documentation answers real-world questions. +* **No useless documentation:** If the documentation only restates the obvious + from the code's name, it's not helpful. Good documentation provides context + and explains what isn't immediately apparent. +* **Consistency is key:** Use consistent terminology throughout your + documentation. + +### Commenting Style + +* **Use `///` for doc comments:** This allows documentation generation tools to + pick them up. +* **Start with a single-sentence summary:** The first sentence should be a + concise, user-centric summary ending with a period. +* **Separate the summary:** Add a blank line after the first sentence to create + a separate paragraph. This helps tools create better summaries. +* **Avoid redundancy:** Don't repeat information that's obvious from the code's + context, like the class name or signature. +* **Don't document both getter and setter:** For properties with both, only + document one. The documentation tool will treat them as a single field. + +### Writing Style + +* **Be brief:** Write concisely. +* **Avoid jargon and acronyms:** Don't use abbreviations unless they are widely + understood. +* **Use Markdown sparingly:** Avoid excessive markdown and never use HTML for + formatting. +* **Use backticks for code:** Enclose code blocks in backtick fences, and + specify the language. + +### What to Document + +* **Public APIs are a priority:** Always document public APIs. +* **Consider private APIs:** It's a good idea to document private APIs as well. +* **Library-level comments are helpful:** Consider adding a doc comment at the + library level to provide a general overview. +* **Include code samples:** Where appropriate, add code samples to illustrate usage. +* **Explain parameters, return values, and exceptions:** Use prose to describe + what a function expects, what it returns, and what errors it might throw. +* **Place doc comments before annotations:** Documentation should come before + any metadata annotations. + +## Accessibility (A11Y) +Implement accessibility features to empower all users, assuming a wide variety +of users with different physical abilities, mental abilities, age groups, +education levels, and learning styles. + +* **Color Contrast:** Ensure text has a contrast ratio of at least **4.5:1** + against its background. +* **Dynamic Text Scaling:** Test your UI to ensure it remains usable when users + increase the system font size. +* **Semantic Labels:** Use the `Semantics` widget to provide clear, descriptive + labels for UI elements. +* **Screen Reader Testing:** Regularly test your app with TalkBack (Android) and + VoiceOver (iOS). \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0245e238..fc07de9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## 0.21.0 +### New features + +* Now it's possible to multi-select transactions and perform bulk actions. + Click on the leading (account/category icon) to start multi-selecting. +* + +## 0.21.0 + Special thanks to [@PawiX25](https://github.com/PawiX25) for the new widget! ### New features diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index 27b5a0bc..e9ada935 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -250,6 +250,7 @@ "general.enabled": "مُفعّل", "general.flow": "Flow", "general.new": "جديد", + "general.next": "التالي", "general.nextNDays": "الأيام القادمة {n}", "general.paste": "لصق", "general.save": "حفظ", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "تم اختيار {n} (من أصل {total})", "sync.export.pdf.categories": "الفئات", "sync.export.pdf.categories.selected": "المختارة {n} (من أصل {total})", + "sync.export.pdf.generatedAt": "تم الإنشاء في", "sync.export.pdf.header": "Flow - السجلات المالية الشخصية (غير رسمية، {range})", "sync.export.pdf.notice[0]": "تم الإنشاء بواسطة ", "sync.export.pdf.notice[1]": ". هذا ليس مستندًا قانونيًا. هذا ليس بيانًا ماليًا. هذه ليست إيصالًا. هذا لا يمثل الواقع بأي شكل من الأشكال. هذا مخصص للاستخدام الشخصي فقط.", @@ -623,11 +625,11 @@ "tabs.home.reminders.turnOnICloudSync.subtitle": "نسخ احتياطي موثوق ومجاني لبياناتك", "tabs.home.totalBalance": "الرصيد الإجمالي", "tabs.home.transactionsCount": "{count} معاملة", - "tabs.home.transactionsCount.zero": "{count} معاملات", - "tabs.home.transactionsCount.one": "{count} معاملة", - "tabs.home.transactionsCount.two": "{count} معاملتين", "tabs.home.transactionsCount.few": "{count} معاملات", "tabs.home.transactionsCount.many": "{count} معاملة", + "tabs.home.transactionsCount.one": "{count} معاملة", + "tabs.home.transactionsCount.two": "{count} معاملتين", + "tabs.home.transactionsCount.zero": "{count} معاملات", "tabs.profile": "الملف الشخصي", "tabs.profile.backup": "النسخ الاحتياطي", "tabs.profile.community": "المجتمع", @@ -667,6 +669,29 @@ "transaction.actions": "الإجراءات", "transaction.attachments": "المرفقات", "transaction.attachments.warning": "ستشغل المرفقات مساحة قدرها {size} في النسخ الاحتياطية لديك. إذا كنت تستخدم نسخًا احتياطية سحابية (مثل iCloud)، فسيزيد ذلك من المساحة المستخدمة.", + "transaction.bulk.changeAccount": "تغيير الحساب", + "transaction.bulk.changeAccount.confirm": "تغيير حساب {} معاملة؟", + "transaction.bulk.changeCategory": "تغيير الفئة", + "transaction.bulk.changeCategory.confirm": "تغيير فئة {} معاملة؟", + "transaction.bulk.clear": "مسح التحديد", + "transaction.bulk.confirmAll": "تأكيد الكل", + "transaction.bulk.confirmAll.confirm": "تأكيد {} معاملة؟", + "transaction.bulk.confirmed.success": "تم تأكيد {} معاملة", + "transaction.bulk.confirmed.success.one": "تم تأكيد معاملة واحدة", + "transaction.bulk.delete": "حذف", + "transaction.bulk.delete.confirm": "حذف {} معاملة؟", + "transaction.bulk.deleted.success": "تم نقل {} معاملة إلى سلة المهملات", + "transaction.bulk.deleted.success.one": "تم نقل معاملة واحدة إلى سلة المهملات", + "transaction.bulk.disabled.currencies": "غير متاح عند اختلاف العملات", + "transaction.bulk.disabled.transfers": "غير متاح عند تحديد التحويلات", + "transaction.bulk.recover": "استعادة", + "transaction.bulk.recover.confirm": "استعادة {} معاملة؟", + "transaction.bulk.recovered.success": "تم استعادة {} معاملة", + "transaction.bulk.recovered.success.one": "تم استعادة معاملة واحدة", + "transaction.bulk.selectAll": "تحديد الكل", + "transaction.bulk.selected": "{} محددة", + "transaction.bulk.updated.success": "تم تحديث {} معاملة", + "transaction.bulk.updated.success.one": "تم تحديث معاملة واحدة", "transaction.createdDate": "تم الإنشاء في", "transaction.date": "تاريخ المعاملة", "transaction.delete": "حذف المعاملة", @@ -739,11 +764,11 @@ "transactions.batch.importN": "استيراد {n} من المعاملات", "transactions.batch.review": "يرجى مراجعة المعاملات", "transactions.count": "{} معاملة", - "transactions.count.zero": "{} معاملات", - "transactions.count.one": "{} معاملة", - "transactions.count.two": "{} معاملتين", "transactions.count.few": "{} معاملات", "transactions.count.many": "{} معاملة", + "transactions.count.one": "{} معاملة", + "transactions.count.two": "{} معاملتين", + "transactions.count.zero": "{} معاملات", "transactions.pending": "المعاملات المعلقة", "transactions.query.clearAll": "مسح الفلاتر", "transactions.query.clearSelection": "مسح الاختيارات", @@ -778,4 +803,4 @@ "transactions.query.noResult": "لا توجد معاملات للعرض", "transactions.query.noResult.description": "حاول تحديث الفلاتر", "visitGitHubRepo": "زيارة المستودع على جيثب" -} +} \ No newline at end of file diff --git a/assets/l10n/be_BY.json b/assets/l10n/be_BY.json index 8e41ecc4..2b1000bd 100644 --- a/assets/l10n/be_BY.json +++ b/assets/l10n/be_BY.json @@ -250,6 +250,7 @@ "general.enabled": "Уключана", "general.flow": "Flow", "general.new": "Новы", + "general.next": "Далей", "general.nextNDays": "Наступныя {n} дзён", "general.paste": "Уставіць", "general.save": "Захаваць", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Выбрана {n} (з {total})", "sync.export.pdf.categories": "Катэгорыі", "sync.export.pdf.categories.selected": "Выбрана {n} (з {total})", + "sync.export.pdf.generatedAt": "Сфарміравана ў", "sync.export.pdf.header": "Flow - Асабістыя фінансавыя запісы (неафіцыйна, {range})", "sync.export.pdf.notice[0]": "Згенеравана праз ", "sync.export.pdf.notice[1]": ". Гэта не юрыдычны дакумент. Гэта не фінансавая справаздача. Гэта не чэк. Гэта ні ў якім разе не адлюстраванне рэальнасці. Дакумент прызначаны толькі для асабістага выкарыстання.", @@ -623,9 +625,9 @@ "tabs.home.reminders.turnOnICloudSync.subtitle": "Надзейна і бясплатна захоўвайце свае даныя", "tabs.home.totalBalance": "Агульны баланс", "tabs.home.transactionsCount": "{count} транзакцый", - "tabs.home.transactionsCount.one": "{count} транзакцыя", "tabs.home.transactionsCount.few": "{count} транзакцыі", "tabs.home.transactionsCount.many": "{count} транзакцый", + "tabs.home.transactionsCount.one": "{count} транзакцыя", "tabs.profile": "Профіль", "tabs.profile.backup": "Рэзервовая копія", "tabs.profile.community": "Супольнасць", @@ -665,6 +667,52 @@ "transaction.actions": "Дзеянні", "transaction.attachments": "Прымацаваныя файлы", "transaction.attachments.warning": "Укладанне(і) зойме {size} месца ў вашых рэзервовых копіях. Калі вы выкарыстоўваеце воблачныя копіі (напр., iCloud), гэта павялічыць аб'ём выкарыстоўваемага месца.", + "transaction.bulk.changeAccount": "Змяніць рахунак", + "transaction.bulk.changeAccount.confirm": "Змяніць рахунак для {} транзакцый?", + "transaction.bulk.changeAccount.confirm.few": "Змяніць рахунак для {} транзакцый?", + "transaction.bulk.changeAccount.confirm.many": "Змяніць рахунак для {} транзакцый?", + "transaction.bulk.changeAccount.confirm.one": "Змяніць рахунак для {} транзакцыі?", + "transaction.bulk.changeCategory": "Змяніць катэгорыю", + "transaction.bulk.changeCategory.confirm": "Змяніць катэгорыю для {} транзакцый?", + "transaction.bulk.changeCategory.confirm.few": "Змяніць катэгорыю для {} транзакцый?", + "transaction.bulk.changeCategory.confirm.many": "Змяніць катэгорыю для {} транзакцый?", + "transaction.bulk.changeCategory.confirm.one": "Змяніць катэгорыю для {} транзакцыі?", + "transaction.bulk.clear": "Ачысціць выбар", + "transaction.bulk.confirmAll": "Пацвердзіць усе", + "transaction.bulk.confirmAll.confirm": "Пацвердзіць {} транзакцый?", + "transaction.bulk.confirmAll.confirm.few": "Пацвердзіць {} транзакцыі?", + "transaction.bulk.confirmAll.confirm.many": "Пацвердзіць {} транзакцый?", + "transaction.bulk.confirmAll.confirm.one": "Пацвердзіць {} транзакцыю?", + "transaction.bulk.confirmed.success": "Пацверджана {} транзакцый", + "transaction.bulk.confirmed.success.few": "Пацверджана {} транзакцыі", + "transaction.bulk.confirmed.success.many": "Пацверджана {} транзакцый", + "transaction.bulk.confirmed.success.one": "Пацверджана {} транзакцыя", + "transaction.bulk.delete": "Выдаліць", + "transaction.bulk.delete.confirm": "Выдаліць {} транзакцый?", + "transaction.bulk.delete.confirm.few": "Выдаліць {} транзакцыі?", + "transaction.bulk.delete.confirm.many": "Выдаліць {} транзакцый?", + "transaction.bulk.delete.confirm.one": "Выдаліць {} транзакцыю?", + "transaction.bulk.deleted.success": "Перамешчана {} транзакцый у кошык", + "transaction.bulk.deleted.success.few": "Перамешчана {} транзакцыі ў кошык", + "transaction.bulk.deleted.success.many": "Перамешчана {} транзакцый у кошык", + "transaction.bulk.deleted.success.one": "Перамешчана {} транзакцыя ў кошык", + "transaction.bulk.disabled.currencies": "Недаступна, калі ў выбары розныя валюты", + "transaction.bulk.disabled.transfers": "Недаступна, калі выбраны пераводы", + "transaction.bulk.recover": "Аднавіць", + "transaction.bulk.recover.confirm": "Аднавіць {} транзакцый?", + "transaction.bulk.recover.confirm.few": "Аднавіць {} транзакцыі?", + "transaction.bulk.recover.confirm.many": "Аднавіць {} транзакцый?", + "transaction.bulk.recover.confirm.one": "Аднавіць {} транзакцыю?", + "transaction.bulk.recovered.success": "Адноўлена {} транзакцый", + "transaction.bulk.recovered.success.few": "Адноўлена {} транзакцыі", + "transaction.bulk.recovered.success.many": "Адноўлена {} транзакцый", + "transaction.bulk.recovered.success.one": "Адноўлена {} транзакцыя", + "transaction.bulk.selectAll": "Выбраць усе", + "transaction.bulk.selected": "Выбрана {}", + "transaction.bulk.updated.success": "Абноўлена {} транзакцый", + "transaction.bulk.updated.success.few": "Абноўлена {} транзакцыі", + "transaction.bulk.updated.success.many": "Абноўлена {} транзакцый", + "transaction.bulk.updated.success.one": "Абноўлена {} транзакцыя", "transaction.createdDate": "Створана ў", "transaction.date": "Дата транзакцыі", "transaction.delete": "Выдаліць транзакцыю", @@ -737,9 +785,9 @@ "transactions.batch.importN": "Імпартаваць {n} транзакцый", "transactions.batch.review": "Калі ласка, праверце транзакцыі", "transactions.count": "{} транзакцый", - "transactions.count.one": "{} транзакцыя", "transactions.count.few": "{} транзакцыі", "transactions.count.many": "{} транзакцый", + "transactions.count.one": "{} транзакцыя", "transactions.pending": "Транзакцыі ў чаканні", "transactions.query.clearAll": "Ачысціць фільтры", "transactions.query.clearSelection": "Ачысціць выбар", @@ -774,4 +822,4 @@ "transactions.query.noResult": "Няма транзакцый для паказу", "transactions.query.noResult.description": "Паспрабуйце абнавіць фільтры", "visitGitHubRepo": "Наведаць рэпазіторый на GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index b8c8120b..c51b5594 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -250,6 +250,7 @@ "general.enabled": "Zapnuto", "general.flow": "Tok", "general.new": "Nový", + "general.next": "Další", "general.nextNDays": "Dalších {n} dní", "general.paste": "Vložit", "general.save": "Uložit", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Vybráno {n} z {total}", "sync.export.pdf.categories": "Kategorie", "sync.export.pdf.categories.selected": "Vybráno {n} (z {total})", + "sync.export.pdf.generatedAt": "Vygenerováno", "sync.export.pdf.header": "Flow - Osobní finanční záznamy (neoficiální, {range})", "sync.export.pdf.notice[0]": "Vytvořeno pomocí ", "sync.export.pdf.notice[1]": ". Toto není právní dokument, finanční výkaz ani účtenka. Je určeno pouze pro osobní použití.", @@ -623,8 +625,8 @@ "tabs.home.reminders.turnOnICloudSync.subtitle": "Spolehlivě a zdarma zálohujte svá data.", "tabs.home.totalBalance": "Celkový zůstatek", "tabs.home.transactionsCount": "{count} transakcí", - "tabs.home.transactionsCount.one": "{count} transakce", "tabs.home.transactionsCount.few": "{count} transakce", + "tabs.home.transactionsCount.one": "{count} transakce", "tabs.profile": "Profil", "tabs.profile.backup": "Zálohování a synchronizace", "tabs.profile.community": "Komunita", @@ -664,6 +666,43 @@ "transaction.actions": "Akce", "transaction.attachments": "Přílohy", "transaction.attachments.warning": "Příloha(y) zabere {size} místa ve vašich zálohách. Pokud používáte cloudové zálohy (např. iCloud), zvýší to využité místo.", + "transaction.bulk.changeAccount": "Změnit účet", + "transaction.bulk.changeAccount.confirm": "Změnit účet u {} transakcí?", + "transaction.bulk.changeAccount.confirm.few": "Změnit účet u {} transakcí?", + "transaction.bulk.changeAccount.confirm.one": "Změnit účet u {} transakce?", + "transaction.bulk.changeCategory": "Změnit kategorii", + "transaction.bulk.changeCategory.confirm": "Změnit kategorii u {} transakcí?", + "transaction.bulk.changeCategory.confirm.few": "Změnit kategorii u {} transakcí?", + "transaction.bulk.changeCategory.confirm.one": "Změnit kategorii u {} transakce?", + "transaction.bulk.clear": "Vymazat výběr", + "transaction.bulk.confirmAll": "Potvrdit vše", + "transaction.bulk.confirmAll.confirm": "Potvrdit {} transakcí?", + "transaction.bulk.confirmAll.confirm.few": "Potvrdit {} transakce?", + "transaction.bulk.confirmAll.confirm.one": "Potvrdit {} transakci?", + "transaction.bulk.confirmed.success": "Potvrzeno {} transakcí", + "transaction.bulk.confirmed.success.few": "Potvrzeny {} transakce", + "transaction.bulk.confirmed.success.one": "Potvrzena {} transakce", + "transaction.bulk.delete": "Smazat", + "transaction.bulk.delete.confirm": "Smazat {} transakcí?", + "transaction.bulk.delete.confirm.few": "Smazat {} transakce?", + "transaction.bulk.delete.confirm.one": "Smazat {} transakci?", + "transaction.bulk.deleted.success": "Přesunuto {} transakcí do koše", + "transaction.bulk.deleted.success.few": "Přesunuty {} transakce do koše", + "transaction.bulk.deleted.success.one": "Přesunuta {} transakce do koše", + "transaction.bulk.disabled.currencies": "Nedostupné, když výběr obsahuje různé měny", + "transaction.bulk.disabled.transfers": "Nedostupné, když jsou vybrány převody", + "transaction.bulk.recover": "Obnovit", + "transaction.bulk.recover.confirm": "Obnovit {} transakcí?", + "transaction.bulk.recover.confirm.few": "Obnovit {} transakce?", + "transaction.bulk.recover.confirm.one": "Obnovit {} transakci?", + "transaction.bulk.recovered.success": "Obnoveno {} transakcí", + "transaction.bulk.recovered.success.few": "Obnoveny {} transakce", + "transaction.bulk.recovered.success.one": "Obnovena {} transakce", + "transaction.bulk.selectAll": "Vybrat vše", + "transaction.bulk.selected": "Vybráno {}", + "transaction.bulk.updated.success": "Aktualizováno {} transakcí", + "transaction.bulk.updated.success.few": "Aktualizovány {} transakce", + "transaction.bulk.updated.success.one": "Aktualizována {} transakce", "transaction.createdDate": "Vytvořeno", "transaction.date": "Datum transakce", "transaction.delete": "Smazat transakci", @@ -736,8 +775,8 @@ "transactions.batch.importN": "Importovat {n} transakcí", "transactions.batch.review": "Prosím zkontrolujte transakce", "transactions.count": "{count} transakcí", - "transactions.count.one": "{} transakce", "transactions.count.few": "{} transakce", + "transactions.count.one": "{} transakce", "transactions.pending": "Čekající transakce", "transactions.query.clearAll": "Vymazat filtry", "transactions.query.clearSelection": "Zrušit výběr", @@ -772,4 +811,4 @@ "transactions.query.noResult": "Nebyly nalezeny žádné transakce.", "transactions.query.noResult.description": "Zkuste upravit filtry.", "visitGitHubRepo": "Navštivte repozitář na GitHubu" -} +} \ No newline at end of file diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index ba32252c..b18c4fff 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -250,6 +250,7 @@ "general.enabled": "Aktiviert", "general.flow": "Flow", "general.new": "Neu", + "general.next": "Weiter", "general.nextNDays": "Die nächsten {n} Tag(e)", "general.paste": "Einfügen", "general.save": "Speichern", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Ausgewählt {n} (von {total})", "sync.export.pdf.categories": "Kategorien", "sync.export.pdf.categories.selected": "Ausgewählt: {n} (von {total})", + "sync.export.pdf.generatedAt": "Erstellt am", "sync.export.pdf.header": "Flow - Persönliche Finanzaufzeichnungen (inoffiziell, {range})", "sync.export.pdf.notice[0]": "Erstellt von ", "sync.export.pdf.notice[1]": ". Dies ist kein rechtliches Dokument. Dies ist keine Finanzaufstellung. Dies ist keine Quittung. Dies stellt in keiner Weise die Realität dar. Es ist ausschließlich für den persönlichen Gebrauch bestimmt.", @@ -663,6 +665,34 @@ "transaction.actions": "Aktionen", "transaction.attachments": "Dateianhänge", "transaction.attachments.warning": "Dateianhänge belegen {size} Speicherplatz in den Backups. Bei Cloud-Backups (z. B. iCloud) erhöht sich der benötigte Speicherplatz.", + "transaction.bulk.changeAccount": "Konto ändern", + "transaction.bulk.changeAccount.confirm": "Konto von {} Buchungen ändern?", + "transaction.bulk.changeAccount.confirm.one": "Konto von {} Buchung ändern?", + "transaction.bulk.changeCategory": "Kategorie ändern", + "transaction.bulk.changeCategory.confirm": "Kategorie von {} Buchungen ändern?", + "transaction.bulk.changeCategory.confirm.one": "Kategorie von {} Buchung ändern?", + "transaction.bulk.clear": "Auswahl aufheben", + "transaction.bulk.confirmAll": "Alle bestätigen", + "transaction.bulk.confirmAll.confirm": "{} Buchungen bestätigen?", + "transaction.bulk.confirmAll.confirm.one": "{} Buchung bestätigen?", + "transaction.bulk.confirmed.success": "{} Buchungen bestätigt", + "transaction.bulk.confirmed.success.one": "{} Buchung bestätigt", + "transaction.bulk.delete": "Löschen", + "transaction.bulk.delete.confirm": "{} Buchungen löschen?", + "transaction.bulk.delete.confirm.one": "{} Buchung löschen?", + "transaction.bulk.deleted.success": "{} Buchungen in den Papierkorb verschoben", + "transaction.bulk.deleted.success.one": "{} Buchung in den Papierkorb verschoben", + "transaction.bulk.disabled.currencies": "Bei gemischten Währungen nicht verfügbar", + "transaction.bulk.disabled.transfers": "Bei ausgewählten Transfers nicht verfügbar", + "transaction.bulk.recover": "Wiederherstellen", + "transaction.bulk.recover.confirm": "{} Buchungen wiederherstellen?", + "transaction.bulk.recover.confirm.one": "{} Buchung wiederherstellen?", + "transaction.bulk.recovered.success": "{} Buchungen wiederhergestellt", + "transaction.bulk.recovered.success.one": "{} Buchung wiederhergestellt", + "transaction.bulk.selectAll": "Alle auswählen", + "transaction.bulk.selected": "{} ausgewählt", + "transaction.bulk.updated.success": "{} Buchungen aktualisiert", + "transaction.bulk.updated.success.one": "{} Buchung aktualisiert", "transaction.createdDate": "Erstellt am", "transaction.date": "Datum der Buchung", "transaction.delete": "Buchung löschen", @@ -770,4 +800,4 @@ "transactions.query.noResult": "Keine Buchungen zum Anzeigen.", "transactions.query.noResult.description": "Versuche, die Filter anzupassen.", "visitGitHubRepo": "Repo auf GitHub besuchen" -} +} \ No newline at end of file diff --git a/assets/l10n/en.json b/assets/l10n/en.json index e66d85e0..ff0339ff 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -250,6 +250,7 @@ "general.enabled": "Enabled", "general.flow": "Flow", "general.new": "New", + "general.next": "Next", "general.nextNDays": "Next {n} day(s)", "general.paste": "Paste", "general.save": "Save", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Selected {n} (out of {total})", "sync.export.pdf.categories": "Categories", "sync.export.pdf.categories.selected": "Selected {n} (out of {total})", + "sync.export.pdf.generatedAt": "Generated at", "sync.export.pdf.header": "Flow - Personal financial records (unofficial, {range})", "sync.export.pdf.notice[0]": "Generated by ", "sync.export.pdf.notice[1]": ". This is not a legal document. This is not a financial statement. This is not a receipt. This is not a representation of reality in any way or form. This is meant for personal use only.", @@ -663,6 +665,29 @@ "transaction.actions": "Actions", "transaction.attachments": "File attachments", "transaction.attachments.warning": "Attachment(s) will take {size} space in your backups. If you use cloud backups (i.e., iCloud), this will increase the space used.", + "transaction.bulk.changeAccount": "Change account", + "transaction.bulk.changeAccount.confirm": "Change account of {} transactions?", + "transaction.bulk.changeCategory": "Change category", + "transaction.bulk.changeCategory.confirm": "Change category of {} transactions?", + "transaction.bulk.clear": "Clear selection", + "transaction.bulk.confirmAll": "Confirm all", + "transaction.bulk.confirmAll.confirm": "Confirm {} transactions?", + "transaction.bulk.confirmed.success": "Confirmed {} transactions", + "transaction.bulk.confirmed.success.one": "Confirmed {} transaction", + "transaction.bulk.delete": "Delete", + "transaction.bulk.delete.confirm": "Delete {} transactions?", + "transaction.bulk.deleted.success": "Moved {} transactions to trash bin", + "transaction.bulk.deleted.success.one": "Moved {} transaction to trash bin", + "transaction.bulk.disabled.currencies": "Not available when selection mixes currencies", + "transaction.bulk.disabled.transfers": "Not available when transfers are selected", + "transaction.bulk.recover": "Recover", + "transaction.bulk.recover.confirm": "Recover {} transactions?", + "transaction.bulk.recovered.success": "Recovered {} transactions", + "transaction.bulk.recovered.success.one": "Recovered {} transaction", + "transaction.bulk.selectAll": "Select all", + "transaction.bulk.selected": "{} selected", + "transaction.bulk.updated.success": "Updated {} transactions", + "transaction.bulk.updated.success.one": "Updated {} transaction", "transaction.createdDate": "Created at", "transaction.date": "Transaction date", "transaction.delete": "Delete transaction", @@ -770,4 +795,4 @@ "transactions.query.noResult": "No transactions to show", "transactions.query.noResult.description": "Try updating the filters", "visitGitHubRepo": "Visit repo on GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index afac4ad5..24a1476a 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -250,6 +250,7 @@ "general.enabled": "Activado", "general.flow": "Flow", "general.new": "Nuevo", + "general.next": "Siguiente", "general.nextNDays": "Próximos {n} día(s)", "general.paste": "Pegar", "general.save": "Guardar", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Seleccionadas {n} (de {total})", "sync.export.pdf.categories": "Categorías", "sync.export.pdf.categories.selected": "Seleccionadas {n} (de {total})", + "sync.export.pdf.generatedAt": "Generado el", "sync.export.pdf.header": "Flow - Registros financieros personales (no oficial, {range})", "sync.export.pdf.notice[0]": "Generado por ", "sync.export.pdf.notice[1]": ". Este no es un documento legal. Esto no es un estado financiero. Esto no es un recibo. Esto no representa la realidad de ninguna manera o forma. Está destinado únicamente para uso personal.", @@ -663,6 +665,35 @@ "transaction.actions": "Acciones", "transaction.attachments": "Archivos adjuntos", "transaction.attachments.warning": "Los archivos adjuntos ocuparán {size} en tus copias de seguridad. Si usas copias de seguridad en la nube (p. ej., iCloud), esto aumentará el espacio utilizado.", + "transaction.bulk.changeAccount": "Cambiar cuenta", + "transaction.bulk.changeAccount.confirm": "¿Cambiar la cuenta de {} transacciones?", + "transaction.bulk.changeAccount.confirm.one": "¿Cambiar la cuenta de {} transacción?", + "transaction.bulk.changeCategory": "Cambiar categoría", + "transaction.bulk.changeCategory.confirm": "¿Cambiar la categoría de {} transacciones?", + "transaction.bulk.changeCategory.confirm.one": "¿Cambiar la categoría de {} transacción?", + "transaction.bulk.clear": "Borrar selección", + "transaction.bulk.confirmAll": "Confirmar todo", + "transaction.bulk.confirmAll.confirm": "¿Confirmar {} transacciones?", + "transaction.bulk.confirmAll.confirm.one": "¿Confirmar {} transacción?", + "transaction.bulk.confirmed.success": "{} transacciones confirmadas", + "transaction.bulk.confirmed.success.one": "{} transacción confirmada", + "transaction.bulk.delete": "Eliminar", + "transaction.bulk.delete.confirm": "¿Eliminar {} transacciones?", + "transaction.bulk.delete.confirm.one": "¿Eliminar {} transacción?", + "transaction.bulk.deleted.success": "{} transacciones movidas a la papelera", + "transaction.bulk.deleted.success.one": "{} transacción movida a la papelera", + "transaction.bulk.disabled.currencies": "No disponible cuando la selección mezcla monedas", + "transaction.bulk.disabled.transfers": "No disponible cuando hay transferencias seleccionadas", + "transaction.bulk.recover": "Recuperar", + "transaction.bulk.recover.confirm": "¿Recuperar {} transacciones?", + "transaction.bulk.recover.confirm.one": "¿Recuperar {} transacción?", + "transaction.bulk.recovered.success": "{} transacciones recuperadas", + "transaction.bulk.recovered.success.one": "{} transacción recuperada", + "transaction.bulk.selectAll": "Seleccionar todo", + "transaction.bulk.selected": "{} seleccionadas", + "transaction.bulk.selected.one": "{} seleccionada", + "transaction.bulk.updated.success": "{} transacciones actualizadas", + "transaction.bulk.updated.success.one": "{} transacción actualizada", "transaction.createdDate": "Creada el", "transaction.date": "Fecha de la transacción", "transaction.delete": "Eliminar transacción", @@ -770,4 +801,4 @@ "transactions.query.noResult": "No hay transacciones para mostrar", "transactions.query.noResult.description": "Intenta actualizar los filtros", "visitGitHubRepo": "Visitar repositorio en GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index d9bcb082..66915f0a 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -250,6 +250,7 @@ "general.enabled": "فعال", "general.flow": "Flow", "general.new": "جدید", + "general.next": "بعدی", "general.nextNDays": "{n} روز آینده", "general.paste": "چسباندن", "general.save": "ذخیره", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "{n} انتخاب شد (از {total})", "sync.export.pdf.categories": "دسته‌بندی‌ها", "sync.export.pdf.categories.selected": "{n} انتخاب شد (از {total})", + "sync.export.pdf.generatedAt": "ایجاد شده در", "sync.export.pdf.header": "Flow - سوابق مالی شخصی (غیررسمی، {range})", "sync.export.pdf.notice[0]": "تولید شده توسط ", "sync.export.pdf.notice[1]": ". این سند قانونی نیست. این یک صورت‌حساب مالی نیست. این رسید نیست. این هیچ ادعایی درباره واقعیت به هیچ شکل و صورتی ندارد. صرفاً برای استفاده شخصی است.", @@ -623,6 +625,7 @@ "tabs.home.reminders.turnOnICloudSync.subtitle": "پشتیبان‌گیری قابل‌اعتماد و رایگان از داده‌ها", "tabs.home.totalBalance": "موجودی کل", "tabs.home.transactionsCount": "{count} تراکنش", + "tabs.home.transactionsCount.one": "{count} تراکنش", "tabs.profile": "پروفایل", "tabs.profile.backup": "پشتیبان", "tabs.profile.community": "جامعه", @@ -662,6 +665,29 @@ "transaction.actions": "اقدامات", "transaction.attachments": "پیوست‌های فایل", "transaction.attachments.warning": "پیوست(ها) در پشتیبان‌های شما {size} فضا می‌گیرند. اگر از پشتیبان ابری (مثل iCloud) استفاده کنید، فضای مصرفی افزایش می‌یابد.", + "transaction.bulk.changeAccount": "تغییر حساب", + "transaction.bulk.changeAccount.confirm": "تغییر حساب {} تراکنش؟", + "transaction.bulk.changeCategory": "تغییر دسته", + "transaction.bulk.changeCategory.confirm": "تغییر دسته {} تراکنش؟", + "transaction.bulk.clear": "پاک کردن انتخاب", + "transaction.bulk.confirmAll": "تأیید همه", + "transaction.bulk.confirmAll.confirm": "تأیید {} تراکنش؟", + "transaction.bulk.confirmed.success": "{} تراکنش تأیید شد", + "transaction.bulk.confirmed.success.one": "{} تراکنش تأیید شد", + "transaction.bulk.delete": "حذف", + "transaction.bulk.delete.confirm": "حذف {} تراکنش؟", + "transaction.bulk.deleted.success": "{} تراکنش به سطل زباله منتقل شد", + "transaction.bulk.deleted.success.one": "{} تراکنش به سطل زباله منتقل شد", + "transaction.bulk.disabled.currencies": "وقتی انتخاب شامل ارزهای مختلف باشد در دسترس نیست", + "transaction.bulk.disabled.transfers": "وقتی انتقال‌ها انتخاب شده‌اند در دسترس نیست", + "transaction.bulk.recover": "بازیابی", + "transaction.bulk.recover.confirm": "بازیابی {} تراکنش؟", + "transaction.bulk.recovered.success": "{} تراکنش بازیابی شد", + "transaction.bulk.recovered.success.one": "{} تراکنش بازیابی شد", + "transaction.bulk.selectAll": "انتخاب همه", + "transaction.bulk.selected": "{} انتخاب شده", + "transaction.bulk.updated.success": "{} تراکنش به‌روزرسانی شد", + "transaction.bulk.updated.success.one": "{} تراکنش بروزرسانی شد", "transaction.createdDate": "ایجاد شده در", "transaction.date": "تاریخ تراکنش", "transaction.delete": "حذف تراکنش", @@ -734,6 +760,7 @@ "transactions.batch.importN": "ایمپورت {n} تراکنش", "transactions.batch.review": "لطفاً تراکنش‌ها را بررسی کنید", "transactions.count": "{} تراکنش", + "transactions.count.one": "{} تراکنش", "transactions.pending": "تراکنش‌های در انتظار", "transactions.query.clearAll": "پاک کردن فیلترها", "transactions.query.clearSelection": "پاک کردن انتخاب‌ها", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index be34f669..379891ef 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -250,6 +250,7 @@ "general.enabled": "Activé", "general.flow": "Flow", "general.new": "Nouveau", + "general.next": "Suivant", "general.nextNDays": "Prochain(s) {n} jour(s)", "general.paste": "Coller", "general.save": "Enregistrer", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Sélectionné {n} (sur {total})", "sync.export.pdf.categories": "Catégories", "sync.export.pdf.categories.selected": "Sélectionné(s) {n} (sur {total})", + "sync.export.pdf.generatedAt": "Généré le", "sync.export.pdf.header": "Flow - Registres financiers personnels (non officiel, {range})", "sync.export.pdf.notice[0]": "Généré par ", "sync.export.pdf.notice[1]": ". Ce document n'est pas légal. Ce n'est pas un relevé financier. Ce n'est pas un reçu. Ce document ne représente en aucun cas la réalité. Il est destiné uniquement à un usage personnel.", @@ -663,6 +665,35 @@ "transaction.actions": "Actions", "transaction.attachments": "Pièces jointes", "transaction.attachments.warning": "Les pièces jointes occuperont {size} d'espace dans vos sauvegardes. Si vous utilisez des sauvegardes cloud (par ex. iCloud), cela augmentera l'espace utilisé.", + "transaction.bulk.changeAccount": "Changer de compte", + "transaction.bulk.changeAccount.confirm": "Changer le compte de {} transactions?", + "transaction.bulk.changeAccount.confirm.one": "Changer le compte de {} transaction?", + "transaction.bulk.changeCategory": "Changer de catégorie", + "transaction.bulk.changeCategory.confirm": "Changer la catégorie de {} transactions?", + "transaction.bulk.changeCategory.confirm.one": "Changer la catégorie de {} transaction?", + "transaction.bulk.clear": "Effacer la sélection", + "transaction.bulk.confirmAll": "Tout confirmer", + "transaction.bulk.confirmAll.confirm": "Confirmer {} transactions?", + "transaction.bulk.confirmAll.confirm.one": "Confirmer {} transaction?", + "transaction.bulk.confirmed.success": "{} transactions confirmées", + "transaction.bulk.confirmed.success.one": "{} transaction confirmée", + "transaction.bulk.delete": "Supprimer", + "transaction.bulk.delete.confirm": "Supprimer {} transactions?", + "transaction.bulk.delete.confirm.one": "Supprimer {} transaction?", + "transaction.bulk.deleted.success": "{} transactions déplacées dans la corbeille", + "transaction.bulk.deleted.success.one": "{} transaction déplacée dans la corbeille", + "transaction.bulk.disabled.currencies": "Indisponible si la sélection mélange des devises", + "transaction.bulk.disabled.transfers": "Indisponible lorsque des virements sont sélectionnés", + "transaction.bulk.recover": "Restaurer", + "transaction.bulk.recover.confirm": "Restaurer {} transactions?", + "transaction.bulk.recover.confirm.one": "Restaurer {} transaction?", + "transaction.bulk.recovered.success": "{} transactions restaurées", + "transaction.bulk.recovered.success.one": "{} transaction restaurée", + "transaction.bulk.selectAll": "Tout sélectionner", + "transaction.bulk.selected": "{} sélectionnées", + "transaction.bulk.selected.one": "{} sélectionnée", + "transaction.bulk.updated.success": "{} transactions mises à jour", + "transaction.bulk.updated.success.one": "{} transaction mise à jour", "transaction.createdDate": "Créé le", "transaction.date": "Date de la transaction", "transaction.delete": "Supprimer la transaction", @@ -770,4 +801,4 @@ "transactions.query.noResult": "Aucune transaction à afficher", "transactions.query.noResult.description": "Essayez de mettre à jour les filtres", "visitGitHubRepo": "Visitez le dépôt sur GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 9daacbcb..1e8ca72e 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -250,6 +250,7 @@ "general.enabled": "Abilitato", "general.flow": "Flusso", "general.new": "Nuovo", + "general.next": "Avanti", "general.nextNDays": "Prossimi {n} giorni", "general.paste": "Incolla", "general.save": "Salva", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Selezionati {n} (su {total})", "sync.export.pdf.categories": "Categorie", "sync.export.pdf.categories.selected": "Selezionate {n} (di {total})", + "sync.export.pdf.generatedAt": "Generato il", "sync.export.pdf.header": "Flow - Registri finanziari personali (non ufficiale, {range})", "sync.export.pdf.notice[0]": "Generato da ", "sync.export.pdf.notice[1]": ". Questo non è un documento legale. Questo non è un estratto conto. Questa non è una ricevuta. Questo non rappresenta in alcun modo la realtà. È destinato esclusivamente a uso personale.", @@ -663,6 +665,35 @@ "transaction.actions": "Azioni", "transaction.attachments": "File allegati", "transaction.attachments.warning": "Gli allegati occuperanno {size} di spazio nei tuoi backup. Se utilizzi i backup nel cloud (ad es. iCloud), lo spazio utilizzato aumenterà.", + "transaction.bulk.changeAccount": "Cambia conto", + "transaction.bulk.changeAccount.confirm": "Cambiare il conto di {} transazioni?", + "transaction.bulk.changeAccount.confirm.one": "Cambiare il conto di {} transazione?", + "transaction.bulk.changeCategory": "Cambia categoria", + "transaction.bulk.changeCategory.confirm": "Cambiare la categoria di {} transazioni?", + "transaction.bulk.changeCategory.confirm.one": "Cambiare la categoria di {} transazione?", + "transaction.bulk.clear": "Cancella selezione", + "transaction.bulk.confirmAll": "Conferma tutto", + "transaction.bulk.confirmAll.confirm": "Confermare {} transazioni?", + "transaction.bulk.confirmAll.confirm.one": "Confermare {} transazione?", + "transaction.bulk.confirmed.success": "{} transazioni confermate", + "transaction.bulk.confirmed.success.one": "{} transazione confermata", + "transaction.bulk.delete": "Elimina", + "transaction.bulk.delete.confirm": "Eliminare {} transazioni?", + "transaction.bulk.delete.confirm.one": "Eliminare {} transazione?", + "transaction.bulk.deleted.success": "{} transazioni spostate nel cestino", + "transaction.bulk.deleted.success.one": "{} transazione spostata nel cestino", + "transaction.bulk.disabled.currencies": "Non disponibile con valute miste nella selezione", + "transaction.bulk.disabled.transfers": "Non disponibile con trasferimenti selezionati", + "transaction.bulk.recover": "Ripristina", + "transaction.bulk.recover.confirm": "Ripristinare {} transazioni?", + "transaction.bulk.recover.confirm.one": "Ripristinare {} transazione?", + "transaction.bulk.recovered.success": "{} transazioni ripristinate", + "transaction.bulk.recovered.success.one": "{} transazione ripristinata", + "transaction.bulk.selectAll": "Seleziona tutto", + "transaction.bulk.selected": "{} selezionate", + "transaction.bulk.selected.one": "{} selezionata", + "transaction.bulk.updated.success": "{} transazioni aggiornate", + "transaction.bulk.updated.success.one": "{} transazione aggiornata", "transaction.createdDate": "Creata il", "transaction.date": "Data della transazione", "transaction.delete": "Elimina transazione", @@ -770,4 +801,4 @@ "transactions.query.noResult": "Nessuna transazione da mostrare", "transactions.query.noResult.description": "Prova ad aggiornare i filtri", "visitGitHubRepo": "Visita la repo su GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 84c5b0a2..4f03d505 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -250,6 +250,7 @@ "general.enabled": "Идэвхтэй", "general.flow": "Урсгал", "general.new": "Шинэ", + "general.next": "Дараах", "general.nextNDays": "Ирэх {n} хоног", "general.paste": "Буулгах", "general.save": "Хадгалах", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Сонгосон {n}, (нийт {total})", "sync.export.pdf.categories": "Ангилалууд", "sync.export.pdf.categories.selected": "Сонгосон {n}, (нийт {total})", + "sync.export.pdf.generatedAt": "Үүсгэсэн огноо", "sync.export.pdf.header": "Flow - Гүйлгээний түүх (албан бус , {range})", "sync.export.pdf.notice[0]": "Үүсгэсэн: ", "sync.export.pdf.notice[1]": ". Энэ нь хууль ёсны баримт бичиг биш юм. Энэ нь санхүүгийн тайлан биш юм. Энэ нь ямар нэгэн байдлаар бодит байдлыг илэрхийлэхгүй болно. Зөвхөн хувийн хэрэглээнд зориулагдсан болно.", @@ -623,6 +625,7 @@ "tabs.home.reminders.turnOnICloudSync.subtitle": "Чухал мэдээллээ үнэгүй, найдвартай хадгалах", "tabs.home.totalBalance": "Нийт үлдэгдэл", "tabs.home.transactionsCount": "{count} гүйлгээ", + "tabs.home.transactionsCount.one": "{count} гүйлгээ", "tabs.profile": "Бүртгэл", "tabs.profile.backup": "Нөөцлөх", "tabs.profile.community": "Холбоо", @@ -662,6 +665,29 @@ "transaction.actions": "Үйлдлүүд", "transaction.attachments": "Файл хасвралтууд", "transaction.attachments.warning": "Хавсаргасан файлууд таны нөөцөд нийт {size} зай эзэлнэ. Хэрэв та цахим санруу нөөцөө хадаглдаг бол ашиглалтыг нэмэгдүүлнэ гэсэн үг.", + "transaction.bulk.changeAccount": "Дансыг өөрчлөх", + "transaction.bulk.changeAccount.confirm": "{} гүйлгээний дансыг өөрчлөх үү?", + "transaction.bulk.changeCategory": "Ангиллыг өөрчлөх", + "transaction.bulk.changeCategory.confirm": "{} гүйлгээний ангиллыг өөрчлөх үү?", + "transaction.bulk.clear": "Сонголтыг арилгах", + "transaction.bulk.confirmAll": "Бүгдийг батлах", + "transaction.bulk.confirmAll.confirm": "{} гүйлгээг батлах уу?", + "transaction.bulk.confirmed.success": "{} гүйлгээг баталлаа", + "transaction.bulk.confirmed.success.one": "{} гүйлгээг баталгаажууллаа", + "transaction.bulk.delete": "Устгах", + "transaction.bulk.delete.confirm": "{} гүйлгээг устгах уу?", + "transaction.bulk.deleted.success": "{} гүйлгээг хогийн сав руу шилжүүллээ", + "transaction.bulk.deleted.success.one": "{} гүйлгээг хогийн сав руу шилжүүлсэн", + "transaction.bulk.disabled.currencies": "Сонголтод өөр валют байгаа үед боломжгүй", + "transaction.bulk.disabled.transfers": "Шилжүүлэг сонгогдсон үед боломжгүй", + "transaction.bulk.recover": "Сэргээх", + "transaction.bulk.recover.confirm": "{} гүйлгээг сэргээх үү?", + "transaction.bulk.recovered.success": "{} гүйлгээг сэргээлээ", + "transaction.bulk.recovered.success.one": "{} гүйлгээг сэргээсэн", + "transaction.bulk.selectAll": "Бүгдийг сонгох", + "transaction.bulk.selected": "{} сонгогдсон", + "transaction.bulk.updated.success": "{} гүйлгээг шинэчиллээ", + "transaction.bulk.updated.success.one": "{} гүйлгээг шинэчилсэн", "transaction.createdDate": "Үүсгэсэн огноо", "transaction.date": "Гүйлгээний огноо", "transaction.delete": "Гүйлгээг устгах", @@ -734,6 +760,7 @@ "transactions.batch.importN": "{n} гүйлгээ нэмэх", "transactions.batch.review": "Гүйлгээнүүдийг хянана уу", "transactions.count": "{} гүйлгээ", + "transactions.count.one": "{} гүйлгээ", "transactions.pending": "Хүлээгдэж буй гүйлгээнүүд", "transactions.query.clearAll": "Шүүлтүүрийг цэвэрлэх", "transactions.query.clearSelection": "Сонголтуудыг цэвэрлэх", diff --git a/assets/l10n/pl_PL.json b/assets/l10n/pl_PL.json index f88b84ea..80066851 100644 --- a/assets/l10n/pl_PL.json +++ b/assets/l10n/pl_PL.json @@ -250,6 +250,7 @@ "general.enabled": "Włączone", "general.flow": "Przepływ", "general.new": "Nowy", + "general.next": "Dalej", "general.nextNDays": "Kolejne {n} dni", "general.paste": "Wklej", "general.save": "Zapisz", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Wybrano {n} (z {total})", "sync.export.pdf.categories": "Kategorie", "sync.export.pdf.categories.selected": "Wybrano {n} (z {total})", + "sync.export.pdf.generatedAt": "Wygenerowano", "sync.export.pdf.header": "Flow - Osobisty rejestr finansów (nieoficjalny, {range})", "sync.export.pdf.notice[0]": "Wygenerowano przez ", "sync.export.pdf.notice[1]": ". Dokument ten nie ma wartości prawnej ani księgowej. Nie jest dowodem zakupu ani obrazem rzeczywistości w jakiejkolwiek formie. Został przygotowany wyłącznie do prywatnego użytku.", @@ -623,9 +625,9 @@ "tabs.home.reminders.turnOnICloudSync.subtitle": "Niezawodnie twórz darmowe kopie zapasowe danych", "tabs.home.totalBalance": "Łączne saldo", "tabs.home.transactionsCount": "{count} transakcji", - "tabs.home.transactionsCount.one": "{count} transakcja", "tabs.home.transactionsCount.few": "{count} transakcje", "tabs.home.transactionsCount.many": "{count} transakcji", + "tabs.home.transactionsCount.one": "{count} transakcja", "tabs.profile": "Profil", "tabs.profile.backup": "Kopie zapasowe", "tabs.profile.community": "Społeczność", @@ -665,6 +667,46 @@ "transaction.actions": "Akcje", "transaction.attachments": "Załączniki plików", "transaction.attachments.warning": "Załącznik(i) zajmą {size} miejsca w Twojej kopii zapasowej. Jeżeli korzystasz z kopii w chmurze (np. iCloud), zwiększy to zużycie pamięci.", + "transaction.bulk.changeAccount": "Zmień konto", + "transaction.bulk.changeAccount.confirm": "Zmienić konto dla {} transakcji?", + "transaction.bulk.changeCategory": "Zmień kategorię", + "transaction.bulk.changeCategory.confirm": "Zmienić kategorię dla {} transakcji?", + "transaction.bulk.clear": "Wyczyść zaznaczenie", + "transaction.bulk.confirmAll": "Potwierdź wszystkie", + "transaction.bulk.confirmAll.confirm": "Potwierdzić {} transakcji?", + "transaction.bulk.confirmAll.confirm.few": "Potwierdzić {} transakcje?", + "transaction.bulk.confirmAll.confirm.many": "Potwierdzić {} transakcji?", + "transaction.bulk.confirmAll.confirm.one": "Potwierdzić {} transakcję?", + "transaction.bulk.confirmed.success": "Potwierdzono {} transakcji", + "transaction.bulk.confirmed.success.few": "Potwierdzono {} transakcje", + "transaction.bulk.confirmed.success.many": "Potwierdzono {} transakcji", + "transaction.bulk.confirmed.success.one": "Potwierdzono {} transakcję", + "transaction.bulk.delete": "Usuń", + "transaction.bulk.delete.confirm": "Usunąć {} transakcji?", + "transaction.bulk.delete.confirm.few": "Usunąć {} transakcje?", + "transaction.bulk.delete.confirm.many": "Usunąć {} transakcji?", + "transaction.bulk.delete.confirm.one": "Usunąć {} transakcję?", + "transaction.bulk.deleted.success": "Przeniesiono {} transakcji do kosza", + "transaction.bulk.deleted.success.few": "Przeniesiono {} transakcje do kosza", + "transaction.bulk.deleted.success.many": "Przeniesiono {} transakcji do kosza", + "transaction.bulk.deleted.success.one": "Przeniesiono {} transakcję do kosza", + "transaction.bulk.disabled.currencies": "Niedostępne, gdy zaznaczenie zawiera różne waluty", + "transaction.bulk.disabled.transfers": "Niedostępne, gdy zaznaczono przelewy", + "transaction.bulk.recover": "Przywróć", + "transaction.bulk.recover.confirm": "Przywrócić {} transakcji?", + "transaction.bulk.recover.confirm.few": "Przywrócić {} transakcje?", + "transaction.bulk.recover.confirm.many": "Przywrócić {} transakcji?", + "transaction.bulk.recover.confirm.one": "Przywrócić {} transakcję?", + "transaction.bulk.recovered.success": "Przywrócono {} transakcji", + "transaction.bulk.recovered.success.few": "Przywrócono {} transakcje", + "transaction.bulk.recovered.success.many": "Przywrócono {} transakcji", + "transaction.bulk.recovered.success.one": "Przywrócono {} transakcję", + "transaction.bulk.selectAll": "Zaznacz wszystkie", + "transaction.bulk.selected": "Zaznaczono {}", + "transaction.bulk.updated.success": "Zaktualizowano {} transakcji", + "transaction.bulk.updated.success.few": "Zaktualizowano {} transakcje", + "transaction.bulk.updated.success.many": "Zaktualizowano {} transakcji", + "transaction.bulk.updated.success.one": "Zaktualizowano {} transakcję", "transaction.createdDate": "Utworzono", "transaction.date": "Data transakcji", "transaction.delete": "Usuń transakcję", @@ -737,9 +779,9 @@ "transactions.batch.importN": "Importuj {n} transakcji", "transactions.batch.review": "Przejrzyj swoje transakcje przed zapisaniem", "transactions.count": "{} transakcji", - "transactions.count.one": "{} transakcja", "transactions.count.few": "{} transakcje", "transactions.count.many": "{} transakcji", + "transactions.count.one": "{} transakcja", "transactions.pending": "Oczekujące transakcje", "transactions.query.clearAll": "Wyczyść filtry", "transactions.query.clearSelection": "Odznacz wszystko", @@ -774,4 +816,4 @@ "transactions.query.noResult": "Brak transakcji do wyświetlenia", "transactions.query.noResult.description": "Spróbuj zaktualizować filtry", "visitGitHubRepo": "Odwiedź repozytorium GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 064897e5..b2e273f7 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -250,6 +250,7 @@ "general.enabled": "Включено", "general.flow": "Flow", "general.new": "Создать", + "general.next": "Далее", "general.nextNDays": "Следующие {n} дн.", "general.paste": "Вставить", "general.save": "Сохранить", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Выбрано {n} (из {total})", "sync.export.pdf.categories": "Категории", "sync.export.pdf.categories.selected": "Выбрано {n} из {total}", + "sync.export.pdf.generatedAt": "Создано}", "sync.export.pdf.header": "Flow - Личные финансовые записи (неофициально, {range})", "sync.export.pdf.notice[0]": "Создано с помощью ", "sync.export.pdf.notice[1]": ". Это не является юридическим документом. Это не является финансовым отчетом. Это не является квитанцией. Это не представляет реальность ни в каком виде. Это предназначено только для личного использования.", @@ -623,9 +625,9 @@ "tabs.home.reminders.turnOnICloudSync.subtitle": "Надежное бесплатное резервное копирование данных", "tabs.home.totalBalance": "Общий баланс", "tabs.home.transactionsCount": "{count} транзакций", - "tabs.home.transactionsCount.one": "{count} транзакция", "tabs.home.transactionsCount.few": "{count} транзакции", "tabs.home.transactionsCount.many": "{count} транзакций", + "tabs.home.transactionsCount.one": "{count} транзакция", "tabs.profile": "Профиль", "tabs.profile.backup": "Резервное копирование", "tabs.profile.community": "Сообщество", @@ -665,6 +667,52 @@ "transaction.actions": "Действия", "transaction.attachments": "Файловые вложения", "transaction.attachments.warning": "Вложения займут {size} места в ваших резервных копиях. Если вы используете облачные резервные копии (например, iCloud), это увеличит используемое пространство.", + "transaction.bulk.changeAccount": "Изменить счёт", + "transaction.bulk.changeAccount.confirm": "Изменить счёт у {} транзакций?", + "transaction.bulk.changeAccount.confirm.few": "Изменить счёт у {} транзакций?", + "transaction.bulk.changeAccount.confirm.many": "Изменить счёт у {} транзакций?", + "transaction.bulk.changeAccount.confirm.one": "Изменить счёт у {} транзакции?", + "transaction.bulk.changeCategory": "Изменить категорию", + "transaction.bulk.changeCategory.confirm": "Изменить категорию у {} транзакций?", + "transaction.bulk.changeCategory.confirm.few": "Изменить категорию у {} транзакций?", + "transaction.bulk.changeCategory.confirm.many": "Изменить категорию у {} транзакций?", + "transaction.bulk.changeCategory.confirm.one": "Изменить категорию у {} транзакции?", + "transaction.bulk.clear": "Очистить выбор", + "transaction.bulk.confirmAll": "Подтвердить всё", + "transaction.bulk.confirmAll.confirm": "Подтвердить {} транзакций?", + "transaction.bulk.confirmAll.confirm.few": "Подтвердить {} транзакции?", + "transaction.bulk.confirmAll.confirm.many": "Подтвердить {} транзакций?", + "transaction.bulk.confirmAll.confirm.one": "Подтвердить {} транзакцию?", + "transaction.bulk.confirmed.success": "Подтверждено {} транзакций", + "transaction.bulk.confirmed.success.few": "Подтверждено {} транзакции", + "transaction.bulk.confirmed.success.many": "Подтверждено {} транзакций", + "transaction.bulk.confirmed.success.one": "Подтверждена {} транзакция", + "transaction.bulk.delete": "Удалить", + "transaction.bulk.delete.confirm": "Удалить {} транзакций?", + "transaction.bulk.delete.confirm.few": "Удалить {} транзакции?", + "transaction.bulk.delete.confirm.many": "Удалить {} транзакций?", + "transaction.bulk.delete.confirm.one": "Удалить {} транзакцию?", + "transaction.bulk.deleted.success": "Перемещено в корзину {} транзакций", + "transaction.bulk.deleted.success.few": "Перемещено в корзину {} транзакции", + "transaction.bulk.deleted.success.many": "Перемещено в корзину {} транзакций", + "transaction.bulk.deleted.success.one": "Перемещена в корзину {} транзакция", + "transaction.bulk.disabled.currencies": "Недоступно при разных валютах в выборе", + "transaction.bulk.disabled.transfers": "Недоступно при выбранных переводах", + "transaction.bulk.recover": "Восстановить", + "transaction.bulk.recover.confirm": "Восстановить {} транзакций?", + "transaction.bulk.recover.confirm.few": "Восстановить {} транзакции?", + "transaction.bulk.recover.confirm.many": "Восстановить {} транзакций?", + "transaction.bulk.recover.confirm.one": "Восстановить {} транзакцию?", + "transaction.bulk.recovered.success": "Восстановлено {} транзакций", + "transaction.bulk.recovered.success.few": "Восстановлено {} транзакции", + "transaction.bulk.recovered.success.many": "Восстановлено {} транзакций", + "transaction.bulk.recovered.success.one": "Восстановлена {} транзакция", + "transaction.bulk.selectAll": "Выбрать все", + "transaction.bulk.selected": "Выбрано {}", + "transaction.bulk.updated.success": "Обновлено {} транзакций", + "transaction.bulk.updated.success.few": "Обновлено {} транзакции", + "transaction.bulk.updated.success.many": "Обновлено {} транзакций", + "transaction.bulk.updated.success.one": "Обновлена {} транзакция", "transaction.createdDate": "Создано в", "transaction.date": "Дата транзакции", "transaction.delete": "Удалить транзакцию", @@ -737,9 +785,9 @@ "transactions.batch.importN": "Импортировать {n} транзакций", "transactions.batch.review": "Пожалуйста, проверьте транзакции", "transactions.count": "{} транзакций", - "transactions.count.one": "{} транзакция", "transactions.count.few": "{} транзакции", "transactions.count.many": "{} транзакций", + "transactions.count.one": "{} транзакция", "transactions.pending": "Ожидающие транзакции", "transactions.query.clearAll": "Очистить фильтры", "transactions.query.clearSelection": "Очистить выделение", @@ -774,4 +822,4 @@ "transactions.query.noResult": "Нет транзакций для отображения", "transactions.query.noResult.description": "Попробуйте обновить фильтры", "visitGitHubRepo": "Посетить репозиторий на GitHub" -} +} \ No newline at end of file diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index fe33cbc7..f73ebec5 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -250,6 +250,7 @@ "general.enabled": "Etkin", "general.flow": "Akış", "general.new": "Yeni", + "general.next": "İleri", "general.nextNDays": "Sonraki {n} gün", "general.paste": "Yapıştır", "general.save": "Kaydetmek", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Seçilen {n} (toplam {total})", "sync.export.pdf.categories": "Kategoriler", "sync.export.pdf.categories.selected": "Seçilen {n} (toplam {total} içinden)", + "sync.export.pdf.generatedAt": "Oluşturulma tarihi", "sync.export.pdf.header": "Flow - Kişisel finansal kayıtlar (resmi olmayan, {range})", "sync.export.pdf.notice[0]": "Tarafından oluşturuldu ", "sync.export.pdf.notice[1]": ". Bu bir yasal belge değildir. Bu bir finansal tablo değildir. Bu bir makbuz değildir. Bu hiçbir şekilde gerçeği temsil etmez. Bu yalnızca kişisel kullanım için tasarlanmıştır.", @@ -623,6 +625,7 @@ "tabs.home.reminders.turnOnICloudSync.subtitle": "Verilerinizi ücretsiz ve güvenilir şekilde yedekleyin", "tabs.home.totalBalance": "Toplam bilanço", "tabs.home.transactionsCount": "{count} Hareket", + "tabs.home.transactionsCount.one": "{count} işlem", "tabs.profile": "Profil", "tabs.profile.backup": "Yedek", "tabs.profile.community": "Topluluk", @@ -662,6 +665,29 @@ "transaction.actions": "Eylemler", "transaction.attachments": "Dosya ekleri", "transaction.attachments.warning": "Ekler yedeklerinizde {size} yer kaplayacaktır. Bulut yedeklemeleri (örn. iCloud) kullanıyorsanız, bu kullanılan alanı artıracaktır.", + "transaction.bulk.changeAccount": "Hesabı değiştir", + "transaction.bulk.changeAccount.confirm": "{} işlemin hesabı değiştirilsin mi?", + "transaction.bulk.changeCategory": "Kategoriyi değiştir", + "transaction.bulk.changeCategory.confirm": "{} işlemin kategorisi değiştirilsin mi?", + "transaction.bulk.clear": "Seçimi temizle", + "transaction.bulk.confirmAll": "Tümünü onayla", + "transaction.bulk.confirmAll.confirm": "{} işlem onaylansın mı?", + "transaction.bulk.confirmed.success": "{} işlem onaylandı", + "transaction.bulk.confirmed.success.one": "{} işlem onaylandı", + "transaction.bulk.delete": "Sil", + "transaction.bulk.delete.confirm": "{} işlem silinsin mi?", + "transaction.bulk.deleted.success": "{} işlem çöp kutusuna taşındı", + "transaction.bulk.deleted.success.one": "{} işlem çöp kutusuna taşındı", + "transaction.bulk.disabled.currencies": "Seçim birden fazla para birimi içerdiğinde kullanılamaz", + "transaction.bulk.disabled.transfers": "Transferler seçildiğinde kullanılamaz", + "transaction.bulk.recover": "Geri yükle", + "transaction.bulk.recover.confirm": "{} işlem geri yüklensin mi?", + "transaction.bulk.recovered.success": "{} işlem geri yüklendi", + "transaction.bulk.recovered.success.one": "{} işlem geri yüklendi", + "transaction.bulk.selectAll": "Tümünü seç", + "transaction.bulk.selected": "{} seçildi", + "transaction.bulk.updated.success": "{} işlem güncellendi", + "transaction.bulk.updated.success.one": "{} işlem güncellendi", "transaction.createdDate": "Şurada oluşturuldu:", "transaction.date": "İşlem tarihi", "transaction.delete": "İşlemi sil", @@ -734,6 +760,7 @@ "transactions.batch.importN": "{n} işlemi içe aktar", "transactions.batch.review": "Lütfen işlemleri gözden geçirin", "transactions.count": "{} İşlemler", + "transactions.count.one": "{} işlem", "transactions.pending": "Bekleyen işlemler", "transactions.query.clearAll": "Filtreleri temizle", "transactions.query.clearSelection": "Seçimleri temizle", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 85988f8b..a96f9928 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -250,6 +250,7 @@ "general.enabled": "Увімкнено", "general.flow": "Flow", "general.new": "Новий", + "general.next": "Далі", "general.nextNDays": "Наступні {n} дн.", "general.paste": "Вставити", "general.save": "Зберегти", @@ -557,6 +558,7 @@ "sync.export.pdf.accounts.selected": "Вибрано {n} (з {total})", "sync.export.pdf.categories": "Категорії", "sync.export.pdf.categories.selected": "Вибрано {n} (із {total})", + "sync.export.pdf.generatedAt": "Згенеровано", "sync.export.pdf.header": "Flow - Особисті фінансові записи (неофіційно, {range})", "sync.export.pdf.notice[0]": "Створено за допомогою ", "sync.export.pdf.notice[1]": ". Це не є юридичним документом. Це не є фінансовою звітністю. Це не є квитанцією. Це жодним чином не відображає реальність. Це призначено лише для особистого використання.", @@ -623,9 +625,9 @@ "tabs.home.reminders.turnOnICloudSync.subtitle": "Надійно й безкоштовно створюйте резервні копії своїх даних", "tabs.home.totalBalance": "Загальний баланс", "tabs.home.transactionsCount": "{count} транзакцій", - "tabs.home.transactionsCount.one": "{count} транзакція", "tabs.home.transactionsCount.few": "{count} транзакції", "tabs.home.transactionsCount.many": "{count} транзакцій", + "tabs.home.transactionsCount.one": "{count} транзакція", "tabs.profile": "Профіль", "tabs.profile.backup": "Резервне копіювання", "tabs.profile.community": "Спільнота", @@ -665,6 +667,52 @@ "transaction.actions": "Дії", "transaction.attachments": "Файлові вкладення", "transaction.attachments.warning": "Вкладення займатимуть {size} місця у ваших резервних копіях. Якщо ви використовуєте хмарні резервні копії (наприклад, iCloud), це збільшить обсяг використаного місця.", + "transaction.bulk.changeAccount": "Змінити рахунок", + "transaction.bulk.changeAccount.confirm": "Змінити рахунок для {} транзакцій?", + "transaction.bulk.changeAccount.confirm.few": "Змінити рахунок для {} транзакцій?", + "transaction.bulk.changeAccount.confirm.many": "Змінити рахунок для {} транзакцій?", + "transaction.bulk.changeAccount.confirm.one": "Змінити рахунок для {} транзакції?", + "transaction.bulk.changeCategory": "Змінити категорію", + "transaction.bulk.changeCategory.confirm": "Змінити категорію для {} транзакцій?", + "transaction.bulk.changeCategory.confirm.few": "Змінити категорію для {} транзакцій?", + "transaction.bulk.changeCategory.confirm.many": "Змінити категорію для {} транзакцій?", + "transaction.bulk.changeCategory.confirm.one": "Змінити категорію для {} транзакції?", + "transaction.bulk.clear": "Очистити вибір", + "transaction.bulk.confirmAll": "Підтвердити всі", + "transaction.bulk.confirmAll.confirm": "Підтвердити {} транзакцій?", + "transaction.bulk.confirmAll.confirm.few": "Підтвердити {} транзакції?", + "transaction.bulk.confirmAll.confirm.many": "Підтвердити {} транзакцій?", + "transaction.bulk.confirmAll.confirm.one": "Підтвердити {} транзакцію?", + "transaction.bulk.confirmed.success": "Підтверджено {} транзакцій", + "transaction.bulk.confirmed.success.few": "Підтверджено {} транзакції", + "transaction.bulk.confirmed.success.many": "Підтверджено {} транзакцій", + "transaction.bulk.confirmed.success.one": "Підтверджено {} транзакцію", + "transaction.bulk.delete": "Видалити", + "transaction.bulk.delete.confirm": "Видалити {} транзакцій?", + "transaction.bulk.delete.confirm.few": "Видалити {} транзакції?", + "transaction.bulk.delete.confirm.many": "Видалити {} транзакцій?", + "transaction.bulk.delete.confirm.one": "Видалити {} транзакцію?", + "transaction.bulk.deleted.success": "Переміщено до кошика {} транзакцій", + "transaction.bulk.deleted.success.few": "Переміщено до кошика {} транзакції", + "transaction.bulk.deleted.success.many": "Переміщено до кошика {} транзакцій", + "transaction.bulk.deleted.success.one": "Переміщено до кошика {} транзакцію", + "transaction.bulk.disabled.currencies": "Недоступно, коли вибір містить різні валюти", + "transaction.bulk.disabled.transfers": "Недоступно, коли вибрано перекази", + "transaction.bulk.recover": "Відновити", + "transaction.bulk.recover.confirm": "Відновити {} транзакцій?", + "transaction.bulk.recover.confirm.few": "Відновити {} транзакції?", + "transaction.bulk.recover.confirm.many": "Відновити {} транзакцій?", + "transaction.bulk.recover.confirm.one": "Відновити {} транзакцію?", + "transaction.bulk.recovered.success": "Відновлено {} транзакцій", + "transaction.bulk.recovered.success.few": "Відновлено {} транзакції", + "transaction.bulk.recovered.success.many": "Відновлено {} транзакцій", + "transaction.bulk.recovered.success.one": "Відновлено {} транзакцію", + "transaction.bulk.selectAll": "Вибрати всі", + "transaction.bulk.selected": "Вибрано {}", + "transaction.bulk.updated.success": "Оновлено {} транзакцій", + "transaction.bulk.updated.success.few": "Оновлено {} транзакції", + "transaction.bulk.updated.success.many": "Оновлено {} транзакцій", + "transaction.bulk.updated.success.one": "Оновлено {} транзакцію", "transaction.createdDate": "Створено о", "transaction.date": "Дата транзакції", "transaction.delete": "Видалити транзакцію", @@ -737,9 +785,9 @@ "transactions.batch.importN": "Імпортувати {n} транзакцій", "transactions.batch.review": "Будь ласка, перегляньте транзакції", "transactions.count": "{} транзакцій", - "transactions.count.one": "{} транзакція", "transactions.count.few": "{} транзакції", "transactions.count.many": "{} транзакцій", + "transactions.count.one": "{} транзакція", "transactions.pending": "Очікувані транзакції", "transactions.query.clearAll": "Очистити фільтри", "transactions.query.clearSelection": "Очистити виділення", @@ -774,4 +822,4 @@ "transactions.query.noResult": "Немає транзакцій для відображення", "transactions.query.noResult.description": "Спробуйте оновити фільтри", "visitGitHubRepo": "Відвідати репозиторій на GitHub" -} +} \ No newline at end of file diff --git a/lib/data/flow_standard_report.dart b/lib/data/flow_standard_report.dart index 082cead5..f736b6b6 100644 --- a/lib/data/flow_standard_report.dart +++ b/lib/data/flow_standard_report.dart @@ -165,7 +165,7 @@ class FlowStandardReport { int uncountableDays = 0; - for (int offset = previous.duration.inDays; offset > 0; offset--) { + for (int offset = previous.duration.inDays - 1; offset >= 0; offset--) { if (previousDailyExpenditure![offset] == null || previousDailyExpenditure![offset] == 0.0) { uncountableDays++; diff --git a/lib/data/prefs/frecency_group.dart b/lib/data/prefs/frecency_group.dart index 7ee50025..401eb3fe 100644 --- a/lib/data/prefs/frecency_group.dart +++ b/lib/data/prefs/frecency_group.dart @@ -1,5 +1,4 @@ import "package:flow/data/prefs/frecency.dart"; -import "package:flow/utils/utils.dart"; import "package:json_annotation/json_annotation.dart"; part "frecency_group.g.dart"; @@ -10,8 +9,10 @@ class FrecencyGroup { const FrecencyGroup(this.data); - double getScore(String uuid) => - data.firstWhereOrNull((element) => element.uuid == uuid)?.score ?? 0.0; + double getScore(String uuid) => data + .where((element) => element.uuid == uuid) + .map((element) => element.score) + .fold(0.0, (a, b) => a + b); factory FrecencyGroup.fromJson(Map json) => _$FrecencyGroupFromJson(json); diff --git a/lib/graceful_migrations.dart b/lib/graceful_migrations.dart index 19b554a7..d009b4f1 100644 --- a/lib/graceful_migrations.dart +++ b/lib/graceful_migrations.dart @@ -131,7 +131,7 @@ void migrateRemoveTitleFromUntitledTransactions() async { } } -void migrateExtraKeyIndexing() async { +Future migrateExtraKeyIndexing() async { const String migrationUuid = "80323fa8-861c-4483-86db-4b66be64a499"; try { @@ -219,7 +219,7 @@ void migrateThemePrefsToDb() async { } } -void migrateGeoExtensionToLocation() async { +Future migrateGeoExtensionToLocation() async { const String migrationUuid = "2d592b08-96e0-4ba7-b5de-3bc1a28edace"; try { diff --git a/lib/main.dart b/lib/main.dart index ec88eb70..e860b7be 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -111,7 +111,10 @@ void main() async { await ObjectBox().updateAccountOrderList(ignoreIfNoUnsetValue: true); startupLog.fine("Updating account order list"); - initializeNotifications(); + // Await so the plugin is ready before TransactionsService listeners can + // fire (FlowState.initState wires _synchronizePlannedNotifications), which + // otherwise hits NotificationsService.pluginInstance before it's set. + await initializeNotifications(); startupLog.fine("Clearing stale transactions from trash bin"); unawaited( @@ -139,16 +142,10 @@ void main() async { startupLog.severe("Failed to initialize SyncService", e, stackTrace); } - try { - startupLog.fine("Initializing RecurringTransactionsService"); - RecurringTransactionsService(); - } catch (e, stackTrace) { - startupLog.severe( - "Failed to initialize RecurringTransactionsService", - e, - stackTrace, - ); - } + // RecurringTransactionsService is intentionally NOT instantiated here. + // Eager construction used to run _synchronizeAll() in the constructor, + // racing with first-frame work. The first sync is now triggered from + // FlowState.initState's post-frame callback (alongside migrations). TransactionsService().addListener(() => WidgetSummarySync.sync()); @@ -158,7 +155,7 @@ void main() async { Moment.minValueUtc = DateTime.utc(0); Moment.maxValueUtc = DateTime.utc(4000); } catch (e) { - // Silent fail + startupLog.warning("Failed to set Moment min/max values", e); } startupLog.fine("Finally telling Flutter to run the app widget"); @@ -193,6 +190,15 @@ class FlowState extends State { ShakeDetector? detector; + /// Debounces `_synchronizePlannedNotifications` so a burst of + /// `TransactionsService` updates (recurring catch-up, bulk import, etc.) + /// coalesces into one `synchronizeNotifications` call instead of N + /// overlapping `clearByType + reschedule` cycles that fight each other. + Timer? _notificationsSyncDebounce; + static const Duration _notificationsSyncDebounceWindow = Duration( + milliseconds: 250, + ); + @override void initState() { super.initState(); @@ -219,10 +225,28 @@ class FlowState extends State { SchedulerBinding.instance.addPostFrameCallback((_) { migrateRemoveTitleFromUntitledTransactions(); - migrateExtraKeyIndexing(); migratePrimaryCurrencyToDb(); migrateThemePrefsToDb(); migratePrivacyPreferencesToUserPreferences(); + migrateHomePendingTransactionsRange(); + + // Geo migration queries `extraTag: "hasExtension:..."`, which is only + // populated by the extra-key indexing migration. Chain them so geo + // doesn't run before its data dependency. + unawaited( + migrateExtraKeyIndexing().then((_) => migrateGeoExtensionToLocation()), + ); + + // First recurring-transactions sync; deferred to here so it doesn't + // compete with startup work or first-frame rendering. + unawaited( + RecurringTransactionsService().synchronizeAll().catchError((error) { + mainLogger.severe( + "First recurring-transactions sync failed", + error, + ); + }), + ); unawaited(SiriPendingService().resolveSiriTransactions()); }); @@ -262,6 +286,7 @@ class FlowState extends State { ExchangeRatesService().exchangeRatesCache.removeListener(_syncWidgets); TransactionsService().removeListener(_synchronizePlannedNotifications); + _notificationsSyncDebounce?.cancel(); _appLifeCycleListener.dispose(); @@ -440,8 +465,11 @@ class FlowState extends State { } void _synchronizePlannedNotifications() { - TransactionsService().synchronizeNotifications().catchError((error) { - startupLog.severe("Failed to synchronize notifications", error); + _notificationsSyncDebounce?.cancel(); + _notificationsSyncDebounce = Timer(_notificationsSyncDebounceWindow, () { + TransactionsService().synchronizeNotifications().catchError((error) { + startupLog.severe("Failed to synchronize notifications", error); + }); }); } @@ -517,7 +545,7 @@ void initializePackageVersion() async { } } -void initializeNotifications() async { +Future initializeNotifications() async { assert(LocalPreferences().runtimeType == LocalPreferences); await NotificationsService().initialize(); diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index fde8357e..9abe421f 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -136,16 +136,24 @@ extension MainActions on ObjectBox { return accounts; } - List getCategories([bool sortByFrecency = true]) { + List getCategories({ + bool sortByFrecency = true, + TransactionType? type, + }) { final List categories = box().getAll(); if (sortByFrecency) { + final List frecencyKeys = + TransitiveLocalPreferences.categoryFrecencyTypesFor(type); + final FrecencyGroup frecencyGroup = FrecencyGroup( categories - .map( - (category) => TransitiveLocalPreferences().getFrecencyData( - "category", - category.uuid, + .expand( + (category) => frecencyKeys.map( + (key) => TransitiveLocalPreferences().getFrecencyData( + key, + category.uuid, + ), ), ) .nonNulls @@ -1120,9 +1128,18 @@ extension AccountActions on Account { }), ); if (category != null) { + final TransactionType resolvedType = value.type; + final String categoryFrecencyKey = switch (resolvedType) { + TransactionType.income || TransactionType.expense => + TransitiveLocalPreferences.categoryFrecencyType(resolvedType), + // Transfers don't carry a category in normal flows, so this branch + // is a safety fallback — bucket it with expenses. + TransactionType.transfer => TransitiveLocalPreferences + .categoryFrecencyType(TransactionType.expense), + }; unawaited( TransitiveLocalPreferences() - .updateFrecencyData("category", category.uuid) + .updateFrecencyData(categoryFrecencyKey, category.uuid) .then((_) {}) .catchError((e, stackTrace) { _log.warning( diff --git a/lib/prefs/transitive.dart b/lib/prefs/transitive.dart index 2cb5988e..a6ef978d 100644 --- a/lib/prefs/transitive.dart +++ b/lib/prefs/transitive.dart @@ -7,6 +7,7 @@ import "package:flow/data/transaction_filter.dart"; import "package:flow/data/transactions_filter/time_range.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction_tag.dart"; import "package:flow/objectbox.dart"; import "package:flow/prefs/local_preferences.dart"; @@ -31,6 +32,12 @@ class TransitiveLocalPreferences { late final DateTimeSettingsEntry transitiveLastTimeFrecencyUpdated; + /// Forces a one-shot rebuild of category frecency the first time the app + /// runs after the typed-frecency change, so existing users get + /// `category:income` / `category:expense` records backfilled immediately + /// instead of waiting for the next daily reevaluation rollover. + late final BoolSettingsEntry categoryFrecencyTypedBuilt; + late final DateTimeSettingsEntry lastAutoBackupRanAt; late final PrimitiveSettingsEntry lastAutoBackupPath; @@ -73,6 +80,14 @@ class TransitiveLocalPreferences { preferences: _prefs, ); + categoryFrecencyTypedBuilt = BoolSettingsEntry( + // Versioned key: bump the suffix whenever the reevaluation logic + // changes in a way that needs to be re-run for existing users. + key: "transitive.categoryFrecencyTypedBuilt.v2", + preferences: _prefs, + initialValue: false, + ); + lastAutoBackupRanAt = DateTimeSettingsEntry( key: "transitive.lastAutoBackupRanAt", preferences: _prefs, @@ -139,7 +154,7 @@ class TransitiveLocalPreferences { try { unawaited(sessionPrivacyMode.set(LocalPreferences().privacyMode.get())); } catch (e) { - // Silent fail + _log.warning("Failed to seed sessionPrivacyMode from privacyMode", e); } try { @@ -147,16 +162,42 @@ class TransitiveLocalPreferences { !transitiveLastTimeFrecencyUpdated.get()!.isAtSameDayAs( Moment.now(), )) { - unawaited(_reevaluateCategoryFrecency()); + unawaited( + _reevaluateCategoryFrecency().then( + (_) => categoryFrecencyTypedBuilt.set(true), + ), + ); unawaited(_reevaluateAccountFrecency()); unawaited(_reevaluateTransactionTagFrecency()); unawaited(transitiveLastTimeFrecencyUpdated.set(DateTime.now())); + } else if (!categoryFrecencyTypedBuilt.get()) { + unawaited( + _reevaluateCategoryFrecency().then( + (_) => categoryFrecencyTypedBuilt.set(true), + ), + ); } } catch (e) { - // Silent fail + _log.warning("Failed to reevaluate frecency caches", e); } } + static String categoryFrecencyType(TransactionType type) => + "category:${type.value}"; + + /// Frecency storage keys to consult for category ranking. When [type] is + /// null (e.g. bulk-change-category, or a transfer where categories aren't + /// typed), combine income and expense so categories still rank by overall + /// usage. + static List categoryFrecencyTypesFor(TransactionType? type) { + return switch (type) { + TransactionType.income || TransactionType.expense => [ + categoryFrecencyType(type!), + ], + _ => const ["category:income", "category:expense"], + }; + } + Future setFrecencyData( String type, String uuid, @@ -204,8 +245,17 @@ class TransitiveLocalPreferences { return; } + const List typesToBuild = [ + TransactionType.income, + TransactionType.expense, + ]; + for (final category in categories) { try { + // TransactionFilter.types is a post-query Dart predicate — it is + // ignored by ObjectBox count()/findFirst(). Query once per category + // and partition by Transaction.type in memory so the income vs + // expense split is actually applied. final TransactionFilter filter = TransactionFilter( categories: StringMultiFilter.whitelist([category.uuid]), range: TransactionFilterTimeRange.fromTimeRange( @@ -215,22 +265,38 @@ class TransitiveLocalPreferences { sortDescending: true, ); - final int useCount = TransactionsService().countMany(filter); - final DateTime lastUsed = - TransactionsService().findFirstSync(filter)?.transactionDate ?? - DateTime.fromMillisecondsSinceEpoch(0); + final List recent = TransactionsService().findManySync( + filter, + ); - unawaited( - setFrecencyData( - "category", - category.uuid, - FrecencyData( - uuid: category.uuid, - lastUsed: lastUsed, - useCount: useCount, + for (final type in typesToBuild) { + final List typed = recent + .where((t) => t.type == type) + .toList(); + + if (typed.isEmpty) { + unawaited( + setFrecencyData( + categoryFrecencyType(type), + category.uuid, + null, + ), + ); + continue; + } + + unawaited( + setFrecencyData( + categoryFrecencyType(type), + category.uuid, + FrecencyData( + uuid: category.uuid, + lastUsed: typed.first.transactionDate, + useCount: typed.length, + ), ), - ), - ); + ); + } } catch (e, stackTrace) { _log.warning( "Failed to build category FrecencyData for $category", diff --git a/lib/providers/categories_provider.dart b/lib/providers/categories_provider.dart index 69f170fa..7b6c5cae 100644 --- a/lib/providers/categories_provider.dart +++ b/lib/providers/categories_provider.dart @@ -2,6 +2,7 @@ import "dart:async"; import "package:flow/data/prefs/frecency_group.dart"; import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction/type.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/objectbox.g.dart"; import "package:flow/prefs/transitive.dart"; @@ -34,27 +35,7 @@ class _CategoriesProviderScopeState extends State { void onData(Query query) { setState(() { - final List found = query.find(); - - final FrecencyGroup frecencyGroup = FrecencyGroup( - found - .map( - (category) => TransitiveLocalPreferences().getFrecencyData( - "category", - category.uuid, - ), - ) - .nonNulls - .toList(), - ); - - found.sort( - (a, b) => frecencyGroup - .getScore(b.uuid) - .compareTo(frecencyGroup.getScore(a.uuid)), - ); - - _categories = found; + _categories = query.find(); }); } @@ -74,7 +55,41 @@ class CategoriesProvider extends InheritedWidget { bool get ready => _categories != null; - List get categories => _categories ?? []; + /// Categories sorted by combined (income + expense) frecency. Use + /// [categoriesFor] when the transaction's type is known to get a list + /// ordered by usage within that type only. + List get categories => categoriesFor(null); + + /// Returns categories sorted by frecency restricted to [type]. Pass null + /// when the type is unknown or mixed (bulk edits, transfers) — it falls + /// back to combined ranking. + List categoriesFor(TransactionType? type) { + final List list = _categories ?? const []; + if (list.isEmpty) return list; + + final List frecencyKeys = + TransitiveLocalPreferences.categoryFrecencyTypesFor(type); + + final FrecencyGroup frecencyGroup = FrecencyGroup( + list + .expand( + (category) => frecencyKeys.map( + (key) => TransitiveLocalPreferences().getFrecencyData( + key, + category.uuid, + ), + ), + ) + .nonNulls + .toList(), + ); + + return [...list]..sort( + (a, b) => frecencyGroup + .getScore(b.uuid) + .compareTo(frecencyGroup.getScore(a.uuid)), + ); + } List get uuids => categories.map((category) => category.uuid).toList(); diff --git a/lib/routes/account_page.dart b/lib/routes/account_page.dart index 9d4e599a..d6bff117 100644 --- a/lib/routes/account_page.dart +++ b/lib/routes/account_page.dart @@ -27,6 +27,8 @@ import "package:flow/widgets/no_result.dart"; import "package:flow/widgets/rates_missing_error_box.dart"; import "package:flow/widgets/time_range_selector.dart"; import "package:flow/widgets/transactions_date_header.dart"; +import "package:flow/widgets/transactions_selection_controller.dart"; +import "package:flow/widgets/transactions_selection_scope.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:material_symbols_icons/symbols.dart"; @@ -74,12 +76,28 @@ class _AccountPageState extends State { late TimeRange range; + late final TransactionsSelectionController _selection; + @override void initState() { super.initState(); account = ObjectBox().box().get(widget.accountId); range = widget.initialRange ?? TimeRange.thisMonth(); + _selection = TransactionsSelectionController(); + _selection.addListener(_onSelectionChanged); + } + + @override + void dispose() { + _selection.removeListener(_onSelectionChanged); + _selection.dispose(); + super.dispose(); + } + + void _onSelectionChanged() { + if (!mounted) return; + setState(() {}); } @override @@ -209,8 +227,11 @@ class _AccountPageState extends State { ), ], ), - body: SafeArea( - child: switch (busy) { + body: TransactionsSelectionScope( + controller: _selection, + visibleTransactions: transactions ?? const [], + child: SafeArea( + child: switch (busy) { true => Padding( padding: headerPaddingOutOfList, child: Column( @@ -231,6 +252,7 @@ class _AccountPageState extends State { ), _ => GroupedTransactionsListView( listType: GroupedTransactionsListViewType.reorderable, + selectionController: _selection, mainHeader: header, transactions: grouped, pendingTransactions: pendingTransactionsGrouped, @@ -254,7 +276,8 @@ class _AccountPageState extends State { ); }, ), - }, + }, + ), ), ); }, diff --git a/lib/routes/category_page.dart b/lib/routes/category_page.dart index 4aa18c84..2ec08687 100644 --- a/lib/routes/category_page.dart +++ b/lib/routes/category_page.dart @@ -27,6 +27,8 @@ import "package:flow/widgets/no_result.dart"; import "package:flow/widgets/rates_missing_error_box.dart"; import "package:flow/widgets/time_range_selector.dart"; import "package:flow/widgets/transactions_date_header.dart"; +import "package:flow/widgets/transactions_selection_controller.dart"; +import "package:flow/widgets/transactions_selection_scope.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; import "package:material_symbols_icons/symbols.dart"; @@ -72,12 +74,28 @@ class _CategoryPageState extends State { late TimeRange range; + late final TransactionsSelectionController _selection; + @override void initState() { super.initState(); category = ObjectBox().box().get(widget.categoryId); range = widget.initialRange ?? TimeRange.thisMonth(); + _selection = TransactionsSelectionController(); + _selection.addListener(_onSelectionChanged); + } + + @override + void dispose() { + _selection.removeListener(_onSelectionChanged); + _selection.dispose(); + super.dispose(); + } + + void _onSelectionChanged() { + if (!mounted) return; + setState(() {}); } @override @@ -202,8 +220,11 @@ class _CategoryPageState extends State { ), ], ), - body: SafeArea( - child: switch (busy) { + body: TransactionsSelectionScope( + controller: _selection, + visibleTransactions: transactions ?? const [], + child: SafeArea( + child: switch (busy) { true => Padding( padding: headerPaddingOutOfList, child: Column( @@ -223,6 +244,7 @@ class _CategoryPageState extends State { ), ), _ => GroupedTransactionsListView( + selectionController: _selection, mainHeader: header, transactions: grouped, pendingTransactions: pendingTransactionsGrouped, @@ -244,7 +266,8 @@ class _CategoryPageState extends State { ); }, ), - }, + }, + ), ), ); }, diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index dce2dae6..d1ddd569 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -11,6 +11,7 @@ import "package:flow/entity/transaction_filter_preset.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/prefs/local_preferences.dart"; +import "package:flow/routes/home_page.dart"; import "package:flow/services/actionable_notifications.dart"; import "package:flow/services/exchange_rates.dart"; import "package:flow/services/user_preferences.dart"; @@ -27,6 +28,8 @@ import "package:flow/widgets/home/home/no_transactions.dart"; import "package:flow/widgets/internal_notifications/internal_notification_section.dart"; import "package:flow/widgets/rates_missing_error_box.dart"; import "package:flow/widgets/transactions_date_header.dart"; +import "package:flow/widgets/transactions_selection_controller.dart"; +import "package:flow/widgets/transactions_selection_scope.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; import "package:moment_dart/moment_dart.dart"; @@ -73,9 +76,19 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { late final bool noTransactionsAtAll; + late final TransactionsSelectionController _selection; + + StreamSubscription>? _currentTransactionsSub; + StreamSubscription>? _pendingTransactionsSub; + List? _currentTransactions; + List? _pendingTransactions; + bool _readyToSubscribe = false; + @override void initState() { super.initState(); + _selection = TransactionsSelectionController(); + _selection.addListener(_onSelectionChanged); _updatePlannedTransactionDays(); _rawUpdateDefaultFilter(); @@ -101,10 +114,17 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { _updateActionableNotification(); ExchangeRatesService().getPrimaryCurrencyRates(); + + _readyToSubscribe = true; + _subscribeToTransactions(); } @override void dispose() { + _currentTransactionsSub?.cancel(); + _pendingTransactionsSub?.cancel(); + _selection.removeListener(_onSelectionChanged); + _selection.dispose(); _listener.dispose(); _timer.cancel(); UserPreferencesService().valueNotifier.removeListener( @@ -119,107 +139,127 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { super.dispose(); } + /// Resubscribe to ObjectBox transaction streams using the current filter + /// values. Cheap to call repeatedly; we only re-open queries when filter + /// values actually changed upstream (e.g. via [onChanged], date rollover, + /// or planned-range preference). The old StreamBuilders rebuilt and + /// resubscribed on every frame — this avoids that. + void _subscribeToTransactions() { + if (!_readyToSubscribe) return; + + _currentTransactionsSub?.cancel(); + _currentTransactionsSub = normalizedCurrentFilter + .queryBuilder() + .watch(triggerImmediately: true) + .map((event) => event.find()) + .listen((txns) { + if (!mounted) return; + setState(() { + _currentTransactions = txns; + }); + }); + + _pendingTransactionsSub?.cancel(); + _pendingTransactionsSub = pendingTransactionsFilter + .queryBuilder() + .watch(triggerImmediately: true) + .map((event) => event.find()) + .listen((txns) { + if (!mounted) return; + setState(() { + _pendingTransactions = txns; + }); + }); + } + @override Widget build(BuildContext context) { super.build(context); final bool isFilterModified = currentFilter != defaultFilter; + final List? currentTxns = _currentTransactions; + final List? pendingTxns = _pendingTransactions; + final bool hasCurrent = currentTxns != null; - return StreamBuilder( - stream: normalizedCurrentFilter - .queryBuilder() - .watch(triggerImmediately: true) - .map((event) => event.find()), - builder: (context, currentTransactionsSnapshot) { - return StreamBuilder>( - key: ValueKey(dateKey), - stream: pendingTransactionsFilter - .queryBuilder() - .watch(triggerImmediately: true) - .map((event) => event.find()), - builder: (context, pendingTransactionsSnapshot) { - final DateTime now = Moment.now().startOfNextMinute(); - final TimeRange cutoffPlanned = _plannedTransactionsTimeRange.range( - homeTimeRange: currentFilter.range?.range, - ); - - final List transactions = [ - ...?pendingTransactionsSnapshot.data?.where( - (transaction) => - pendingTransactionsFilter.postPredicates.every( - (predicate) => predicate(transaction), - ) && - normalizedCurrentFilter.range?.range?.contains( - transaction.transactionDate, - ) != - true, - ), - ...?currentTransactionsSnapshot.data?.where( - (transaction) => normalizedCurrentFilter.postPredicates.every( - (predicate) => predicate(transaction), - ), - ), - ]; - - if (currentFilter.range?.range?.contains(now) == true) { - transactions.removeWhere((transaction) { - if (transaction.transactionDate <= now) return false; - - return transaction.transactionDate > cutoffPlanned.to; - }); - } + final DateTime now = Moment.now().startOfNextMinute(); + final TimeRange cutoffPlanned = _plannedTransactionsTimeRange.range( + homeTimeRange: currentFilter.range?.range, + ); - final Widget header = DefaultTransactionsFilterHead( - defaultFilter: defaultFilter, - current: currentFilter, - onChanged: (value) { - setState(() { - currentFilter = value; - }); - }, - ); + final List transactions = [ + ...?pendingTxns?.where( + (transaction) => + pendingTransactionsFilter.postPredicates.every( + (predicate) => predicate(transaction), + ) && + normalizedCurrentFilter.range?.range?.contains( + transaction.transactionDate, + ) != + true, + ), + ...?currentTxns?.where( + (transaction) => normalizedCurrentFilter.postPredicates.every( + (predicate) => predicate(transaction), + ), + ), + ]; + + if (currentFilter.range?.range?.contains(now) == true) { + transactions.removeWhere((transaction) { + if (transaction.transactionDate <= now) return false; + + return transaction.transactionDate > cutoffPlanned.to; + }); + } + + final Widget header = DefaultTransactionsFilterHead( + defaultFilter: defaultFilter, + current: currentFilter, + onChanged: (value) { + setState(() { + currentFilter = value; + }); + _subscribeToTransactions(); + }, + ); - return CustomScrollView( - primary: true, - slivers: [ - PinnedHeaderSliver( - child: Container( - color: context.colorScheme.surface, - child: SafeArea( - bottom: false, - child: Column( - children: [ - const Frame.standalone( - withSurface: true, - child: GreetingsBar(), - ), - header, - ], - ), + return TransactionsSelectionScope( + controller: _selection, + visibleTransactions: transactions, + child: CustomScrollView( + primary: true, + slivers: [ + PinnedHeaderSliver( + child: Container( + color: context.colorScheme.surface, + child: SafeArea( + bottom: false, + child: Column( + children: [ + const Frame.standalone( + withSurface: true, + child: GreetingsBar(), ), - ), - ), - - switch (( - transactions.length, - currentTransactionsSnapshot.hasData, - )) { - (0, true) => SliverFillRemaining( - child: NoTransactions(isFilterModified: isFilterModified), - ), - (_, true) => buildGroupedList(context, now, transactions), - (_, false) => const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ), - }, - SliverToBoxAdapter( - child: SafeArea(child: const SizedBox(height: 96.0)), + header, + ], ), - ], - ); + ), + ), + ), + switch ((transactions.length, hasCurrent)) { + (0, true) => SliverFillRemaining( + child: NoTransactions(isFilterModified: isFilterModified), + ), + (_, true) => buildGroupedList(context, now, transactions), + (_, false) => const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ), }, - ); - }, + SliverToBoxAdapter( + child: SafeArea(child: const SizedBox(height: 96.0)), + ), + ], + ), ); } @@ -285,6 +325,7 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { return GroupedTransactionsListView( listType: GroupedTransactionsListViewType.sliverReorderable, + selectionController: _selection, mainHeader: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -361,14 +402,25 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { _plannedTransactionsTimeRange = UserPreferencesService().homePendingTransactionsTimeRange; setState(() {}); + _subscribeToTransactions(); } void refreshDateKeyAndDefaultFilter() { if (!mounted) return; _rawUpdateDefaultFilter(); + final DateTime newDateKey = Moment.startOfToday(); + // Always rebuild so `now` (used in build() to partition pending vs. + // current transactions and to compute the planned-window cutoff) and + // the `isFilterModified` indicator stay fresh. Only re-open the + // ObjectBox query streams when the day actually rolled over — they + // don't depend on `now`. + final bool dayChanged = newDateKey != dateKey; setState(() { - dateKey = Moment.startOfToday(); + dateKey = newDateKey; }); + if (dayChanged) { + _subscribeToTransactions(); + } } void _rawUpdateDefaultFilter() { @@ -386,6 +438,14 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { setState(() {}); } + void _onSelectionChanged() { + if (!mounted) return; + + HomePage.of(context).toggleNavVisibility(!_selection.active, 0); + + setState(() {}); + } + @override bool get wantKeepAlive => true; } diff --git a/lib/routes/home_page.dart b/lib/routes/home_page.dart index 94248f4b..9038d084 100644 --- a/lib/routes/home_page.dart +++ b/lib/routes/home_page.dart @@ -3,9 +3,8 @@ import "dart:developer"; import "package:flow/data/flow_button_type.dart"; import "package:flow/data/flow_notification_payload.dart"; -import "package:flow/entity/account.dart"; -import "package:flow/objectbox.dart"; import "package:flow/prefs/local_preferences.dart"; +import "package:flow/providers/accounts_provider.dart"; import "package:flow/routes.dart"; import "package:flow/routes/home/accounts_tab.dart"; import "package:flow/routes/home/home_tab.dart"; @@ -32,10 +31,13 @@ class HomePage extends StatefulWidget { const HomePage({super.key}); @override - State createState() => _HomePageState(); + State createState() => HomePageState(); + + static HomePageState of(BuildContext context) => + context.findAncestorStateOfType()!; } -class _HomePageState extends State +class HomePageState extends State with SingleTickerProviderStateMixin { late final TabController _tabController; @@ -43,6 +45,13 @@ class _HomePageState extends State late int _currentIndex; + final Map _hideBottomNav = { + 0: false, + 1: false, + 2: false, + 3: false, + }; + bool _navigationListenerRegistered = false; @override @@ -125,6 +134,8 @@ class _HomePageState extends State @override Widget build(BuildContext context) { + final bool hideBottomNav = _hideBottomNav[_currentIndex] ?? false; + return ExternalToastsHandler( child: CallbackShortcuts( bindings: { @@ -157,18 +168,26 @@ class _HomePageState extends State left: 0.0, right: 0.0, child: SafeArea( - child: Frame( - child: Stack( - alignment: Alignment.center, - children: [ - Navbar( - onTap: (i) => _navigateTo(i), - activeIndex: _currentIndex, + child: IgnorePointer( + ignoring: hideBottomNav, + child: AnimatedSlide( + offset: Offset(0.0, hideBottomNav ? 2.0 : 0.0), + duration: const Duration(milliseconds: 100), + child: Frame( + child: Stack( + alignment: Alignment.center, + children: [ + Navbar( + onTap: (i) => _navigateTo(i), + activeIndex: _currentIndex, + ), + NewTransactionButton( + onActionTap: (type) => + _newTransactionPage(type), + ), + ], ), - NewTransactionButton( - onActionTap: (type) => _newTransactionPage(type), - ), - ], + ), ), ), ), @@ -197,8 +216,14 @@ class _HomePageState extends State } void _newTransactionPage(FlowButtonType? type) { - // Generally, this wouldn't happen in production environment - if (ObjectBox().box().count(limit: 1) == 0) { + // Generally, this wouldn't happen in production environment. + // Read via AccountsProvider so we don't hit ObjectBox on every FAB tap. + // Treat the not-yet-ready state as "no accounts" too — opening + // TransactionPage without a resolved account list would land the user + // in a broken form. Original code did a synchronous count() so it was + // never ambiguous. + final accountsProvider = AccountsProvider.of(context); + if (!accountsProvider.ready || accountsProvider.allAccounts.isEmpty) { context.push("/account/new"); return; } @@ -260,4 +285,15 @@ class _HomePageState extends State return false; } } + + void showNav(int index) => toggleNavVisibility(true, index); + void hideNav(int index) => toggleNavVisibility(false, index); + + void toggleNavVisibility(bool show, int index) { + _hideBottomNav[index] = !show; + + if (!mounted) return; + + setState(() {}); + } } diff --git a/lib/routes/setup/setup_onboarding_page.dart b/lib/routes/setup/setup_onboarding_page.dart index 63f0e0a4..1aeec01d 100644 --- a/lib/routes/setup/setup_onboarding_page.dart +++ b/lib/routes/setup/setup_onboarding_page.dart @@ -16,10 +16,13 @@ import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/setup/icloud_backup_picker_sheet.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; +import "package:logging/logging.dart"; import "package:material_symbols_icons/symbols.dart"; import "package:moment_dart/moment_dart.dart"; import "package:simple_icons/simple_icons.dart"; +final Logger _log = Logger("SetupOnboardingPage"); + class SetupOnboardingPage extends StatefulWidget { const SetupOnboardingPage({super.key}); @@ -125,7 +128,7 @@ class _SetupOnboardingPageState extends State { timer.cancel(); } } catch (e) { - // silent fail + _log.warning("Failed to cancel iCloud-sync wait timer", e); } } @@ -138,7 +141,7 @@ class _SetupOnboardingPageState extends State { ICloudSyncer().initialUpdateReceived.addListener(listener); return await completer.future; } catch (e) { - // silent fail + _log.warning("iCloud initial-sync wait failed", e); } finally { timer.cancel(); ICloudSyncer().initialUpdateReceived.removeListener(listener); diff --git a/lib/routes/transaction_batch_import_page.dart b/lib/routes/transaction_batch_import_page.dart index 3040b4ad..bf84b710 100644 --- a/lib/routes/transaction_batch_import_page.dart +++ b/lib/routes/transaction_batch_import_page.dart @@ -6,7 +6,7 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox/actions.dart"; import "package:flow/providers/accounts_provider.dart"; import "package:flow/routes/transaction_page/section.dart"; -import "package:flow/routes/transaction_page/select_account_sheet.dart"; +import "package:flow/widgets/sheets/select_account_sheet.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/theme/helpers.dart"; import "package:flow/utils/utils.dart"; diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index 1adb043d..9152577c 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -29,8 +29,6 @@ import "package:flow/routes/transaction_page/section.dart"; import "package:flow/routes/transaction_page/sections/description_section.dart"; import "package:flow/routes/transaction_page/sections/files_section.dart"; import "package:flow/routes/transaction_page/sections/tags_section.dart"; -import "package:flow/routes/transaction_page/select_account_sheet.dart"; -import "package:flow/routes/transaction_page/select_category_sheet.dart"; import "package:flow/routes/transaction_page/select_recurrence.dart"; import "package:flow/routes/transaction_page/select_recurrence_sheet.dart"; import "package:flow/routes/transaction_page/select_recurring_update_mode_sheet.dart"; @@ -51,6 +49,8 @@ import "package:flow/widgets/general/info_text.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flow/widgets/location_picker_sheet.dart"; import "package:flow/widgets/open_street_map.dart"; +import "package:flow/widgets/sheets/select_account_sheet.dart"; +import "package:flow/widgets/sheets/select_category_sheet.dart"; import "package:flow/widgets/sheets/select_transaction_tags_sheet.dart"; import "package:flow/widgets/transaction/imported_from_eny.dart"; import "package:flow/widgets/transaction/imported_from_siri.dart"; @@ -110,6 +110,12 @@ class _TransactionPageState extends State { Geo? _geo; bool _geoHandpicked = false; + /// Device's current location, fetched independently of [_geo]. + /// + /// Used to surface nearby tag suggestions even when editing an existing + /// transaction whose saved location differs from where the user is now. + Geo? _deviceGeo; + bool locationFailed = false; dynamic error; @@ -242,9 +248,9 @@ class _TransactionPageState extends State { _mapController = enableGeo ? MapController() : null; - if (widget.isNewTransaction) { - tryFetchLocation(); + tryFetchLocation(); + if (widget.isNewTransaction) { SchedulerBinding.instance.addPostFrameCallback((timeStamp) { _orchestrateFlow(transactionEntryFlow); }); @@ -445,6 +451,7 @@ class _TransactionPageState extends State { selectedTags: _selectedTags, onTagsChanged: onTagsChanged, location: _geo, + deviceLocation: _deviceGeo, ), DescriptionSection( value: _descriptionMarkdown, @@ -654,7 +661,10 @@ class _TransactionPageState extends State { void tryFetchLocation() { if (Platform.isLinux) return; if (LocalPreferences().enableGeo.get() != true) return; - if (LocalPreferences().autoAttachTransactionGeo.get() != true) return; + + final bool autoAttach = + widget.isNewTransaction && + LocalPreferences().autoAttachTransactionGeo.get() == true; Geolocator.getLastKnownPosition() .then((lastKnown) { @@ -662,12 +672,14 @@ class _TransactionPageState extends State { return; } - if (_geo != null) { - // In case we already have a location, don't override with less accurate one - return; - } + final Geo geo = Geo.fromPosition(lastKnown); + _deviceGeo = geo; - _geo = Geo.fromPosition(lastKnown); + // Only seed the transaction's location from a less-accurate + // last-known fix when we'd otherwise have nothing. + if (autoAttach && _geo == null) { + _geo = geo; + } if (mounted) setState(() => {}); }) @@ -677,7 +689,11 @@ class _TransactionPageState extends State { Geolocator.getCurrentPosition() .then((current) { - _geo = Geo.fromPosition(current); + final Geo geo = Geo.fromPosition(current); + _deviceGeo = geo; + if (autoAttach) { + _geo = geo; + } }) .catchError((e, stackTrace) { locationFailed = true; @@ -869,6 +885,7 @@ class _TransactionPageState extends State { builder: (context) => SelectCategorySheet( currentlySelectedCategoryId: _selectedCategory?.id, showTrailing: widget.isNewTransaction, + transactionType: _transactionType, ), isScrollControlled: true, ); diff --git a/lib/routes/transaction_page/sections/tags_section.dart b/lib/routes/transaction_page/sections/tags_section.dart index 5c4e6d59..dac10f3d 100644 --- a/lib/routes/transaction_page/sections/tags_section.dart +++ b/lib/routes/transaction_page/sections/tags_section.dart @@ -17,28 +17,44 @@ class TagsSection extends StatelessWidget { final VoidCallback selectTags; final ValueChanged> onTagsChanged; - /// Used for suggesting nearby tags based on the transaction's location. + /// Transaction's saved location, used for suggesting nearby tags. final Geo? location; + /// Device's current location, used in addition to [location] so that + /// suggestions reflect both where the transaction happened and where the + /// user is now (useful when editing an older transaction in a new place). + final Geo? deviceLocation; + const TagsSection({ super.key, this.selectedTags, required this.selectTags, required this.onTagsChanged, this.location, + this.deviceLocation, }); @override Widget build(BuildContext context) { - final List? suggestedGeoTags = switch (location - ?.toLatLngPosition()) { - LatLng latLng => TransactionTagsProvider.of( - context, - ).getCloseGeoTags(latLng, exclusionList: selectedTags), - _ => null, - }; + final TransactionTagsProvider provider = TransactionTagsProvider.of( + context, + ); + + final List suggestionOrigins = [ + ?location?.toLatLngPosition(), + ?deviceLocation?.toLatLngPosition(), + ]; + + final Set seen = {}; + final List suggestedGeoTags = suggestionOrigins + .expand( + (origin) => + provider.getCloseGeoTags(origin, exclusionList: selectedTags), + ) + .where((tag) => seen.add(tag.uuid)) + .toList(); - final bool hasSuggestedGeoTags = suggestedGeoTags?.isNotEmpty == true; + final bool hasSuggestedGeoTags = suggestedGeoTags.isNotEmpty; return Section( title: "transaction.tags".t(context), @@ -61,7 +77,7 @@ class TagsSection extends StatelessWidget { onPressed: selectTags, title: "transaction.tags.add".t(context), ), - ...?suggestedGeoTags?.map( + ...suggestedGeoTags.map( (tag) => TransactionTagChip( tag: tag, selected: false, diff --git a/lib/routes/transactions_page.dart b/lib/routes/transactions_page.dart index f35f85d1..12b66766 100644 --- a/lib/routes/transactions_page.dart +++ b/lib/routes/transactions_page.dart @@ -18,6 +18,8 @@ import "package:flow/widgets/grouped_transactions_list_view.dart"; import "package:flow/widgets/rates_missing_error_box.dart"; import "package:flow/widgets/time_range_selector.dart"; import "package:flow/widgets/transactions_date_header.dart"; +import "package:flow/widgets/transactions_selection_controller.dart"; +import "package:flow/widgets/transactions_selection_scope.dart"; import "package:flutter/material.dart"; import "package:material_symbols_icons/symbols.dart"; import "package:moment_dart/moment_dart.dart"; @@ -113,6 +115,8 @@ class _TransactionsPageState extends State { late final bool showExchangeRatesMissingWarning; + late final TransactionsSelectionController _selection; + @override void initState() { super.initState(); @@ -120,73 +124,97 @@ class _TransactionsPageState extends State { showExchangeRatesMissingWarning = TransitiveLocalPreferences().usesNonPrimaryCurrency.get() && ExchangeRatesService().getPrimaryCurrencyRates() == null; + _selection = TransactionsSelectionController(); + _selection.addListener(_onSelectionChanged); + } + + @override + void dispose() { + _selection.removeListener(_onSelectionChanged); + _selection.dispose(); + super.dispose(); + } + + void _onSelectionChanged() { + if (!mounted) return; + setState(() {}); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: widget.title == null ? null : Text(widget.title!)), - body: SafeArea( - child: CustomScrollView( - slivers: [ - PinnedHeaderSliver( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (showExchangeRatesMissingWarning) RatesMissingErrorBox(), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Frame( - child: TimeRangeSelector( - initialValue: _timeRange, - onChanged: (newRange) { - setState(() { - _timeRange = newRange; - }); - }, - ), + body: StreamBuilder>( + stream: widget + .queryFn(_timeRange) + .watch(triggerImmediately: true) + .map((event) => event.find()), + builder: (context, snapshot) { + final List visible = snapshot.data ?? const []; + + return TransactionsSelectionScope( + controller: _selection, + visibleTransactions: visible, + child: SafeArea( + child: CustomScrollView( + slivers: [ + PinnedHeaderSliver( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showExchangeRatesMissingWarning) + RatesMissingErrorBox(), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Frame( + child: TimeRangeSelector( + initialValue: _timeRange, + onChanged: (newRange) { + setState(() { + _timeRange = newRange; + }); + }, + ), + ), + ), + ], ), ), - ], - ), - ), - SliverFillRemaining( - child: StreamBuilder>( - stream: widget - .queryFn(_timeRange) - .watch(triggerImmediately: true) - .map((event) => event.find()), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Spinner.center(); - } - - if (snapshot.requireData.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "transactions.query.noResult".t(context), - textAlign: TextAlign.center, - style: context.textTheme.headlineSmall, - ), - const SizedBox(height: 8.0), - FlowIcon( - FlowIconData.icon(Symbols.family_star_rounded), - size: 128.0, - color: context.colorScheme.primary, + SliverFillRemaining( + child: Builder( + builder: (context) { + if (!snapshot.hasData) { + return const Spinner.center(); + } + + if (snapshot.requireData.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "transactions.query.noResult".t(context), + textAlign: TextAlign.center, + style: context.textTheme.headlineSmall, + ), + const SizedBox(height: 8.0), + FlowIcon( + FlowIconData.icon( + Symbols.family_star_rounded, + ), + size: 128.0, + color: context.colorScheme.primary, + ), + const SizedBox(height: 8.0), + ], + ), ), - const SizedBox(height: 8.0), - ], - ), - ), - ); - } + ); + } - final DateTime now = DateTime.now().startOfNextMinute(); + final DateTime now = DateTime.now().startOfNextMinute(); final Map> transactions = snapshot.requireData @@ -221,29 +249,38 @@ class _TransactionsPageState extends State { previousValue + element.length, ); - return GroupedTransactionsListView( - transactions: transactions, - pendingTransactions: pendingTransactions, - headerBuilder: (pendingGroup, range, transactions) => - TransactionListDateHeader( - pendingGroup: pendingGroup, - range: range, + return GroupedTransactionsListView( + selectionController: _selection, transactions: transactions, - ), - pendingDivider: WavyDivider(), - mainHeader: Frame( - child: Text( - "transactions.count".t(context, totalTransactionsCount), - style: context.textTheme.bodyMedium?.semi(context), - ), + pendingTransactions: pendingTransactions, + headerBuilder: (pendingGroup, range, transactions) => + TransactionListDateHeader( + pendingGroup: pendingGroup, + range: range, + transactions: transactions, + ), + pendingDivider: WavyDivider(), + mainHeader: Frame( + child: Text( + "transactions.count".t( + context, + totalTransactionsCount, + ), + style: context.textTheme.bodyMedium?.semi( + context, + ), + ), + ), + mainHeaderPadding: EdgeInsets.zero, + ); + }, ), - mainHeaderPadding: EdgeInsets.zero, - ); - }, + ), + ], ), ), - ], - ), + ); + }, ), ); } diff --git a/lib/services/actionable_notifications.dart b/lib/services/actionable_notifications.dart index ff5099d1..42499204 100644 --- a/lib/services/actionable_notifications.dart +++ b/lib/services/actionable_notifications.dart @@ -10,8 +10,11 @@ import "package:flow/services/user_preferences.dart"; import "package:flow/utils/should_execute_scheduled_task.dart"; import "package:flutter/foundation.dart"; import "package:in_app_review/in_app_review.dart"; +import "package:logging/logging.dart"; import "package:moment_dart/moment_dart.dart"; +final Logger _log = Logger("ActionableNotificationsService"); + class ActionableNotificationsService { static ActionableNotificationsService? _instance; @@ -84,7 +87,7 @@ class ActionableNotificationsService { ); } } catch (e) { - // Silent fail + _log.warning("Failed to evaluate RateApp actionable notification", e); } } @@ -107,7 +110,10 @@ class ActionableNotificationsService { ); } } catch (e) { - // Silent fail + _log.warning( + "Failed to evaluate StarOnGitHub actionable notification", + e, + ); } if (_notifications.value.isNotEmpty) { @@ -152,7 +158,7 @@ class ActionableNotificationsService { } } } catch (e) { - // Silent fail + _log.warning("Failed to evaluate AutoBackupReminder", e); } } } diff --git a/lib/services/notifications.dart b/lib/services/notifications.dart index 2ddaec38..084d9f4a 100644 --- a/lib/services/notifications.dart +++ b/lib/services/notifications.dart @@ -72,7 +72,7 @@ class NotificationsService { try { tz.initializeTimeZones(); } catch (e) { - // silent fail + _log.warning("Failed to initialize timezone database", e); } try { @@ -167,7 +167,7 @@ class NotificationsService { try { await pluginInstance.cancelAll(); } catch (e) { - // Silent fail + _log.warning("Failed to cancel all notifications", e); } } diff --git a/lib/services/recurring_transactions.dart b/lib/services/recurring_transactions.dart index 03566734..930e0fc5 100644 --- a/lib/services/recurring_transactions.dart +++ b/lib/services/recurring_transactions.dart @@ -31,9 +31,17 @@ class RecurringTransactionsService { factory RecurringTransactionsService() => _instance ??= RecurringTransactionsService._internal(); - RecurringTransactionsService._internal() { - _synchronizeAll(); - } + RecurringTransactionsService._internal(); + + /// Trigger a synchronization of all active recurring transactions. + /// Per-row writes are idempotent (each occurrence's `nextOccurrence` + /// guard prevents duplicate transactions), but concurrent invocations + /// will both walk the active set and may do redundant work — callers + /// should serialize where it matters. Intentionally NOT called from the + /// constructor: `FlowState.initState`'s post-frame callback decides when + /// to do the first sync so it doesn't race with first-frame rendering or + /// other startup work. + Future synchronizeAll() => _synchronizeAll(); Future _synchronize( RecurringTransaction recurringTransaction, { @@ -318,7 +326,15 @@ class RecurringTransactionsService { ObjectBox().box().put(recurringTransaction); - _synchronizeAll(); + unawaited( + synchronizeAll().catchError((error, stackTrace) { + _log.severe( + "Sync after createFromTransaction failed", + error, + stackTrace, + ); + }), + ); return recurringTransaction; } diff --git a/lib/services/sync/icloud_syncer.dart b/lib/services/sync/icloud_syncer.dart index 3ebe837c..dcef768b 100644 --- a/lib/services/sync/icloud_syncer.dart +++ b/lib/services/sync/icloud_syncer.dart @@ -159,7 +159,10 @@ class ICloudSyncer implements Syncer { } }) .catchError((error) { - // silent fail + _log.warning( + "iCloud download progress callback failed", + error, + ); }), ); diff --git a/lib/sync/export/export_pdf.dart b/lib/sync/export/export_pdf.dart index 8e0a0411..e9b61fa3 100644 --- a/lib/sync/export/export_pdf.dart +++ b/lib/sync/export/export_pdf.dart @@ -37,21 +37,20 @@ class ExportPdfOptions { Future generatePDFContent({ required ExportPdfOptions options, }) async { + final List loadedAssets = await Future.wait([ + rootBundle.load("assets/fonts/NotoEmoji-Regular.ttf"), + rootBundle.load("assets/fonts/NotoSansArabic-Regular.ttf"), + rootBundle.load("assets/fonts/NotoSansHebrew-Regular.ttf"), + rootBundle.load("assets/fonts/NotoSans-Regular.ttf"), + rootBundle.load("assets/images/flow.png"), + ]); final List fontFallbacks = [ - pw.Font.ttf(await rootBundle.load("assets/fonts/NotoEmoji-Regular.ttf")), - pw.Font.ttf( - await rootBundle.load("assets/fonts/NotoSansArabic-Regular.ttf"), - ), - pw.Font.ttf( - await rootBundle.load("assets/fonts/NotoSansHebrew-Regular.ttf"), - ), + pw.Font.ttf(loadedAssets[0]), + pw.Font.ttf(loadedAssets[1]), + pw.Font.ttf(loadedAssets[2]), ]; - final pw.Font defaultFont = pw.Font.ttf( - await rootBundle.load("assets/fonts/NotoSans-Regular.ttf"), - ); - final Uint8List imageBytes = await rootBundle - .load("assets/images/flow.png") - .then((value) => value.buffer.asUint8List()); + final pw.Font defaultFont = pw.Font.ttf(loadedAssets[3]); + final Uint8List imageBytes = loadedAssets[4].buffer.asUint8List(); final [ List accounts, @@ -94,6 +93,7 @@ Future generatePDFContent({ transactions.retainWhere( (transaction) => + transaction.categoryUuid == null || whitelistedCategoriesUuids.contains(transaction.categoryUuid), ); } @@ -250,6 +250,11 @@ Future generatePDFContent({ useRelative: false, ); + final String generatedAt = DateTime.now().format( + payload: "LLL", + forceLocal: true, + ); + pw.Widget footer(context) => pw.Container( width: double.infinity, margin: pw.EdgeInsets.only(top: 16.0), @@ -327,17 +332,39 @@ Future generatePDFContent({ pw.Text("Flow"), ], ), - pw.Column( + pw.Row( mainAxisSize: .min, + crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Opacity( - opacity: 0.5, - child: pw.Text( - "sync.export.pdf.timeRange".tr(), - style: fineTextStyle.copyWith(), - ), + pw.Column( + mainAxisSize: .min, + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Opacity( + opacity: 0.5, + child: pw.Text( + "sync.export.pdf.generatedAt".tr(), + style: fineTextStyle.copyWith(), + ), + ), + pw.Text(generatedAt), + ], + ), + pw.SizedBox(width: 16.0), + pw.Column( + mainAxisSize: .min, + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Opacity( + opacity: 0.5, + child: pw.Text( + "sync.export.pdf.timeRange".tr(), + style: fineTextStyle.copyWith(), + ), + ), + pw.Text(options.timeRange.format(useRelative: false)), + ], ), - pw.Text(options.timeRange.format(useRelative: false)), ], ), ], diff --git a/lib/sync/import/external/ivy_wallet_csv.dart b/lib/sync/import/external/ivy_wallet_csv.dart index 26740596..e5dae565 100644 --- a/lib/sync/import/external/ivy_wallet_csv.dart +++ b/lib/sync/import/external/ivy_wallet_csv.dart @@ -55,13 +55,19 @@ class IvyWalletCsvImporter extends Importer { showShareDialog: false, type: BackupEntryType.preImport, ).then((value) => safetyBackupFilePath = value.filePath); - } catch (e) { + } catch (e, stackTrace) { if (!ignoreSafetyBackupFail) { throw const ImportException( "Safety backup failed, aborting mission", l10nKey: "error.sync.safetyBackupFailed", ); } + _log.severe( + "Safety backup failed but ignoreSafetyBackupFail=true; " + "proceeding to erase main data with no backup on disk", + e, + stackTrace, + ); } try { diff --git a/lib/sync/import/import_csv.dart b/lib/sync/import/import_csv.dart index 92bb2524..51b806e2 100644 --- a/lib/sync/import/import_csv.dart +++ b/lib/sync/import/import_csv.dart @@ -54,13 +54,19 @@ class ImportCSV extends Importer { showShareDialog: false, type: BackupEntryType.preImport, ).then((value) => safetyBackupFilePath = value.filePath); - } catch (e) { + } catch (e, stackTrace) { if (!ignoreSafetyBackupFail) { throw const ImportException( "Safety backup failed, aborting mission", l10nKey: "error.sync.safetyBackupFailed", ); } + _log.severe( + "Safety backup failed but ignoreSafetyBackupFail=true; " + "proceeding to erase main data with no backup on disk", + e, + stackTrace, + ); } try { diff --git a/lib/sync/import/import_v1.dart b/lib/sync/import/import_v1.dart index d4e71887..30778d97 100644 --- a/lib/sync/import/import_v1.dart +++ b/lib/sync/import/import_v1.dart @@ -45,7 +45,7 @@ class ImportV1 extends Importer { showShareDialog: false, type: BackupEntryType.preImport, ).then((value) => safetyBackupFilePath = value.filePath); - } catch (e) { + } catch (e, stackTrace) { if (!ignoreSafetyBackupFail) { throw const ImportException( "Safety backup failed, aborting mission", @@ -53,6 +53,12 @@ class ImportV1 extends Importer { versionCode: 1, ); } + _log.severe( + "Safety backup failed but ignoreSafetyBackupFail=true; " + "proceeding to erase main data with no backup on disk", + e, + stackTrace, + ); } try { diff --git a/lib/sync/import/import_v2.dart b/lib/sync/import/import_v2.dart index 9e55c2ea..32b88dec 100644 --- a/lib/sync/import/import_v2.dart +++ b/lib/sync/import/import_v2.dart @@ -59,13 +59,19 @@ class ImportV2 extends Importer { showShareDialog: false, type: BackupEntryType.preImport, ).then((value) => safetyBackupFilePath = value.filePath); - } catch (e) { + } catch (e, stackTrace) { if (!ignoreSafetyBackupFail) { throw const ImportException( "Safety backup failed, aborting mission", l10nKey: "error.sync.safetyBackupFailed", ); } + _log.severe( + "Safety backup failed but ignoreSafetyBackupFail=true; " + "proceeding to erase main data with no backup on disk", + e, + stackTrace, + ); } try { diff --git a/lib/sync/model/external/ivy/parsers.dart b/lib/sync/model/external/ivy/parsers.dart index 78ed6e9f..e87cd3ee 100644 --- a/lib/sync/model/external/ivy/parsers.dart +++ b/lib/sync/model/external/ivy/parsers.dart @@ -1,6 +1,9 @@ +import "package:logging/logging.dart"; import "package:moment_dart/moment_dart.dart"; import "package:uuid/uuid.dart"; +final Logger _log = Logger("IvyParsers"); + String? parseOptionalString(dynamic x) { if (x is! String) return null; @@ -67,7 +70,7 @@ DateTime parseDate(dynamic x) { try { return Moment.parse(x); } catch (e) { - // Silent fail + _log.fine("Moment.parse failed, trying regex fallback", e); } try { diff --git a/lib/utils/csv_parser.dart b/lib/utils/csv_parser.dart index 16735b1b..2b596b1c 100644 --- a/lib/utils/csv_parser.dart +++ b/lib/utils/csv_parser.dart @@ -4,23 +4,29 @@ import "dart:typed_data"; import "package:charset/charset.dart"; import "package:csv/csv.dart"; import "package:flow/utils/line_break_normalizer.dart"; +import "package:logging/logging.dart"; + +final Logger _log = Logger("CsvParser"); Future> parseCsvFromFile(File file) async { final Uint8List bytes = file.readAsBytesSync(); String? parsed; + // Each decoder is tried in turn; logged at `fine` because non-matching + // encodings are expected to throw — only the failure of all four is + // user-visible (the throw at the bottom of this function). try { parsed = utf8.decode(bytes); } catch (e) { - // Silent fail + _log.fine("utf8 decode failed, trying next encoding", e); } if (parsed == null) { try { parsed = utf16.decode(bytes); } catch (e) { - // Silent fail + _log.fine("utf16 decode failed, trying next encoding", e); } } @@ -28,7 +34,7 @@ Future> parseCsvFromFile(File file) async { try { parsed = utf32.decode(bytes); } catch (e) { - // Silent fail + _log.fine("utf32 decode failed, trying next encoding", e); } } @@ -36,7 +42,7 @@ Future> parseCsvFromFile(File file) async { try { parsed = latin1.decode(bytes); } catch (e) { - // Silent fail + _log.fine("latin1 decode failed", e); } } diff --git a/lib/utils/extensions/transaction.dart b/lib/utils/extensions/transaction.dart index 0540fd47..f5ea09bd 100644 --- a/lib/utils/extensions/transaction.dart +++ b/lib/utils/extensions/transaction.dart @@ -1,8 +1,12 @@ +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; import "package:flow/entity/recurring_transaction.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/entity/transaction/extensions/default/recurring.dart"; import "package:flow/entity/transaction/extensions/default/transfer.dart"; import "package:flow/l10n/extensions.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/objectbox.g.dart"; import "package:flow/routes/transaction_page/select_recurring_update_mode_sheet.dart"; import "package:flow/services/recurring_transactions.dart"; import "package:flow/services/transactions.dart"; @@ -25,12 +29,11 @@ extension TransactionHelpers on Transaction { bool holdable([DateTime? anchor]) { if (isDeleted == true) return false; - if (isPending != true) return false; + if (isPending == true) return false; return transactionDate.isFutureAnchored( - anchor ?? Moment.now().startOfMinute(), - ) && - isPending == null; + anchor ?? Moment.now().startOfMinute(), + ); } Future _moveToTrashBinRecurring(BuildContext context) async { @@ -209,3 +212,151 @@ extension TransactionHelpers on Transaction { } } } + +/// Bulk operations applied via a single `putMany`, so watchers emit once. +class BulkTransactions { + static final Logger _log = Logger("BulkTransactions"); + + /// Moves every transaction (and its transfer partner) to the trash bin. + static int moveToTrashBin(Iterable transactions) { + final List list = transactions + .where((t) => t.isDeleted != true) + .toList(); + if (list.isEmpty) return 0; + final DateTime now = DateTime.now(); + final List toUpdate = []; + final Set seen = {}; + + for (final Transaction t in list) { + if (!seen.add(t.uuid)) continue; + t.deletedDate = now; + t.isDeleted = true; + toUpdate.add(t); + final Transaction? partner = TransactionsService() + .findTransferRelatedTransactionSync(t); + if (partner != null && seen.add(partner.uuid)) { + partner.deletedDate = now; + partner.isDeleted = true; + toUpdate.add(partner); + } + } + + try { + ObjectBox().box().putMany(toUpdate, mode: PutMode.update); + } catch (e, stackTrace) { + _log.severe("Bulk move-to-trash failed", e, stackTrace); + } + return list.length; + } + + /// Recovers every transaction (and its transfer partner) from the trash bin. + static int recoverFromTrashBin(Iterable transactions) { + final List list = transactions + .where((t) => t.isDeleted == true) + .toList(); + if (list.isEmpty) return 0; + final List toUpdate = []; + final Set seen = {}; + + for (final Transaction t in list) { + if (!seen.add(t.uuid)) continue; + t.isDeleted = false; + toUpdate.add(t); + // Partner is also in the trash; default findTransferRelatedTransactionSync + // skips deleted rows, so opt in. + final Transaction? partner = TransactionsService() + .findTransferRelatedTransactionSync(t, includeDeleted: true); + if (partner != null && seen.add(partner.uuid)) { + partner.isDeleted = false; + toUpdate.add(partner); + } + } + + try { + ObjectBox().box().putMany(toUpdate, mode: PutMode.update); + } catch (e, stackTrace) { + _log.severe("Bulk recover failed", e, stackTrace); + } + return list.length; + } + + /// Confirms every transaction (and its transfer partner), leaving pending. + static int confirm( + Iterable transactions, { + bool updateTransactionDate = true, + }) { + final List list = transactions.toList(); + if (list.isEmpty) return 0; + final DateTime now = DateTime.now(); + final List toUpdate = []; + final Set seen = {}; + + for (final Transaction t in list) { + if (!seen.add(t.uuid)) continue; + t.isPending = false; + if (updateTransactionDate && + !t.extraTags.contains(Transaction.importedFromSiriTag)) { + t.transactionDate = now; + } + toUpdate.add(t); + final Transaction? partner = TransactionsService() + .findTransferRelatedTransactionSync(t); + if (partner != null && seen.add(partner.uuid)) { + partner.isPending = false; + if (updateTransactionDate && + !partner.extraTags.contains(Transaction.importedFromSiriTag)) { + partner.transactionDate = now; + } + toUpdate.add(partner); + } + } + + try { + ObjectBox().box().putMany(toUpdate, mode: PutMode.update); + } catch (e, stackTrace) { + _log.severe("Bulk confirm failed", e, stackTrace); + } + return list.length; + } + + /// Sets [category] on every non-transfer transaction. + static int setCategory( + Iterable transactions, + Category? category, + ) { + final List list = transactions + .where((t) => !t.isTransfer) + .toList(); + if (list.isEmpty) return 0; + + for (final t in list) { + t.setCategory(category); + } + + try { + ObjectBox().box().putMany(list, mode: PutMode.update); + } catch (e, stackTrace) { + _log.severe("Bulk set category failed", e, stackTrace); + } + return list.length; + } + + /// Sets [account] on every non-transfer transaction whose currency matches. + static int setAccount(Iterable transactions, Account account) { + final List list = transactions + .where((t) => !t.isTransfer && t.currency == account.currency) + .toList(); + if (list.isEmpty) return 0; + + for (final t in list) { + t.setAccount(account); + } + + try { + ObjectBox().box().putMany(list, mode: PutMode.update); + } catch (e, stackTrace) { + _log.severe("Bulk set account failed", e, stackTrace); + } + return list.length; + } +} diff --git a/lib/widgets/export/export_success.dart b/lib/widgets/export/export_success.dart index e4128d94..74d4ea7f 100644 --- a/lib/widgets/export/export_success.dart +++ b/lib/widgets/export/export_success.dart @@ -12,8 +12,11 @@ import "package:flow/widgets/general/info_text.dart"; import "package:flutter/gestures.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; +import "package:logging/logging.dart"; import "package:material_symbols_icons/symbols.dart"; +final Logger _log = Logger("ExportSuccess"); + class ExportSuccess extends StatelessWidget { final ExportMode mode; final Function(BuildContext context) shareFn; @@ -100,7 +103,7 @@ class ExportSuccess extends StatelessWidget { context.showToast(text: "general.copy.success".t(context)); } } catch (e) { - // Silent fail + _log.warning("Failed to copy export file path to clipboard", e); } } } diff --git a/lib/widgets/grouped_transactions_list_view.dart b/lib/widgets/grouped_transactions_list_view.dart index aca2c043..11c46a22 100644 --- a/lib/widgets/grouped_transactions_list_view.dart +++ b/lib/widgets/grouped_transactions_list_view.dart @@ -6,6 +6,7 @@ import "package:flow/services/transactions.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/utils/utils.dart"; import "package:flow/widgets/transaction_list_tile.dart"; +import "package:flow/widgets/transactions_selection_controller.dart"; import "package:flutter/material.dart"; import "package:flutter_slidable/flutter_slidable.dart"; import "package:moment_dart/moment_dart.dart"; @@ -71,6 +72,11 @@ class GroupedTransactionsListView extends StatefulWidget { final GroupedTransactionsListViewType listType; + /// When provided, tiles render with selection affordances and tapping the + /// leading icon (or any tap when selection is active) toggles selection. + /// Reordering is suppressed while selection is active. + final TransactionsSelectionController? selectionController; + const GroupedTransactionsListView({ super.key, required this.transactions, @@ -87,6 +93,7 @@ class GroupedTransactionsListView extends StatefulWidget { this.mainHeaderPadding, this.shouldCombineTransferIfNeeded = false, this.listType = GroupedTransactionsListViewType.list, + this.selectionController, }); @override @@ -109,6 +116,16 @@ class _GroupedTransactionsListViewState _privacyModeUpdate, ); UserPreferencesService().valueNotifier.addListener(_rerender); + widget.selectionController?.addListener(_rerender); + } + + @override + void didUpdateWidget(covariant GroupedTransactionsListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectionController != widget.selectionController) { + oldWidget.selectionController?.removeListener(_rerender); + widget.selectionController?.addListener(_rerender); + } } @override @@ -118,6 +135,7 @@ class _GroupedTransactionsListViewState ); UserPreferencesService().valueNotifier.removeListener(_rerender); + widget.selectionController?.removeListener(_rerender); super.dispose(); } @@ -167,6 +185,7 @@ class _GroupedTransactionsListViewState (Transaction transaction) => ReorderableDelayedDragStartListener( index: index, key: ValueKey(transaction.uuid), + enabled: widget.selectionController?.active != true, child: TransactionListTile( key: ValueKey(transaction.id), combineTransfers: combineTransfers, @@ -185,6 +204,13 @@ class _GroupedTransactionsListViewState duplicateFn: () => transaction.duplicate(), overrideObscure: widget.overrideObscure, groupRange: widget.groupBy, + selectionActive: widget.selectionController?.active ?? false, + selected: + widget.selectionController?.contains(transaction.uuid) ?? + false, + onSelectionToggle: widget.selectionController == null + ? null + : () => widget.selectionController!.toggle(transaction), ), ), (_) => Container(), @@ -225,6 +251,7 @@ class _GroupedTransactionsListViewState } void _onReorder(int oldIndex, int newIndex, List flattened) { + if (widget.selectionController?.active == true) return; if (oldIndex == newIndex) return; final a = flattened[oldIndex]; diff --git a/lib/widgets/internal_notifications/auto_backup_reminder.dart b/lib/widgets/internal_notifications/auto_backup_reminder.dart index 1cc19519..9e1bbd48 100644 --- a/lib/widgets/internal_notifications/auto_backup_reminder.dart +++ b/lib/widgets/internal_notifications/auto_backup_reminder.dart @@ -4,9 +4,12 @@ import "package:flow/prefs/local_preferences.dart"; import "package:flow/utils/utils.dart"; import "package:flow/widgets/internal_notifications/internal_notification_list_tile.dart"; import "package:flutter/material.dart"; +import "package:logging/logging.dart"; import "package:material_symbols_icons/symbols.dart"; import "package:moment_dart/moment_dart.dart"; +final Logger _log = Logger("AutoBackupReminder"); + class AutoBackupReminderNotification extends StatelessWidget { final AutoBackupReminder notification; final VoidCallback? onDismiss; @@ -39,7 +42,10 @@ class AutoBackupReminderNotification extends StatelessWidget { .set(notification.payload!.filePath) .then((_) {}) .catchError((e) { - // Silent fail + _log.warning( + "Failed to persist lastSavedAutoBackupPath", + e, + ); }); context.showFileShareSheet( diff --git a/lib/widgets/select_bulk_transactions_action_sheet.dart b/lib/widgets/select_bulk_transactions_action_sheet.dart new file mode 100644 index 00000000..37743595 --- /dev/null +++ b/lib/widgets/select_bulk_transactions_action_sheet.dart @@ -0,0 +1,96 @@ +import "package:flow/l10n/extensions.dart"; +import "package:flow/widgets/general/directional_chevron.dart"; +import "package:flow/widgets/general/modal_sheet.dart"; +import "package:flow/widgets/transactions_selection_controller.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons/symbols.dart"; + +enum TransactionsBulkAction { + confirmAll, + delete, + recover, + changeCategory, + changeAccount, +} + +/// Pops with [TransactionsBulkAction] +class SelectBulkTransactionsActionSheet extends StatelessWidget { + final TransactionsSelectionController controller; + + const SelectBulkTransactionsActionSheet({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final bool blockMutations = controller.hasAnyTransfer; + final bool blockAccountForCurrency = controller.currencies.length > 1; + + final String? mutationDisabledHint = blockMutations + ? "transaction.bulk.disabled.transfers".t(context) + : null; + final String? accountDisabledHint = + mutationDisabledHint ?? + (blockAccountForCurrency + ? "transaction.bulk.disabled.currencies".t(context) + : null); + + return ModalSheet.scrollable( + title: Text( + "transaction.bulk.selected".t(context, controller.count), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (controller.allPending && controller.count > 0) + ListTile( + leading: const Icon(Symbols.check_rounded), + title: Text("transaction.bulk.confirmAll".t(context)), + trailing: const LeChevron(), + onTap: () => context.pop(TransactionsBulkAction.confirmAll), + ), + if (controller.allDeleted) + ListTile( + leading: const Icon(Symbols.restore_page_rounded), + title: Text("transaction.bulk.recover".t(context)), + trailing: const LeChevron(), + onTap: () => context.pop(TransactionsBulkAction.recover), + ) + else + ListTile( + leading: const Icon(Symbols.delete_forever_rounded), + title: Text("transaction.bulk.delete".t(context)), + trailing: const LeChevron(), + onTap: () => context.pop(TransactionsBulkAction.delete), + ), + ListTile( + leading: const Icon(Symbols.label_rounded), + title: Text("transaction.bulk.changeCategory".t(context)), + subtitle: mutationDisabledHint == null + ? null + : Text(mutationDisabledHint), + trailing: blockMutations ? null : const LeChevron(), + enabled: !blockMutations, + onTap: () => context.pop(TransactionsBulkAction.changeCategory), + ), + ListTile( + leading: const Icon(Symbols.account_balance_wallet_rounded), + title: Text("transaction.bulk.changeAccount".t(context)), + subtitle: accountDisabledHint == null + ? null + : Text(accountDisabledHint), + trailing: (blockMutations || blockAccountForCurrency) + ? null + : const LeChevron(), + enabled: !(blockMutations || blockAccountForCurrency), + onTap: () => context.pop(TransactionsBulkAction.changeAccount), + ), + ], + ), + ), + ); + } +} diff --git a/lib/routes/transaction_page/select_account_sheet.dart b/lib/widgets/sheets/select_account_sheet.dart similarity index 100% rename from lib/routes/transaction_page/select_account_sheet.dart rename to lib/widgets/sheets/select_account_sheet.dart diff --git a/lib/routes/transaction_page/select_category_sheet.dart b/lib/widgets/sheets/select_category_sheet.dart similarity index 84% rename from lib/routes/transaction_page/select_category_sheet.dart rename to lib/widgets/sheets/select_category_sheet.dart index 58cc7a97..978e9a7d 100644 --- a/lib/routes/transaction_page/select_category_sheet.dart +++ b/lib/widgets/sheets/select_category_sheet.dart @@ -1,5 +1,6 @@ import "package:flow/providers/categories_provider.dart"; import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction/type.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/utils/optional.dart"; import "package:flow/utils/simple_query_sorter.dart"; @@ -21,11 +22,18 @@ class SelectCategorySheet extends StatefulWidget { final bool showTrailing; + /// Categories are sorted by frecency for this transaction type so that — + /// for example — an income category isn't surfaced first while logging an + /// expense. Defaults to [TransactionType.expense] since that's the + /// overwhelmingly common case for unspecified callers (e.g. bulk edits). + final TransactionType transactionType; + const SelectCategorySheet({ super.key, this.currentlySelectedCategoryId, this.showSearchBar, this.showTrailing = true, + this.transactionType = TransactionType.expense, }); @override @@ -37,7 +45,9 @@ class _SelectCategorySheetState extends State { @override Widget build(BuildContext context) { - final List categories = CategoriesProvider.of(context).categories; + final List categories = CategoriesProvider.of( + context, + ).categoriesFor(widget.transactionType); final bool showSearchBar = widget.showSearchBar ?? categories.length > 6; final List results = simpleSortByQuery(categories, _query); diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index 08ceac8b..f6b6f22a 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -47,6 +47,17 @@ class TransactionListTile extends StatelessWidget { /// Defaults to [TransactionGroupRange.day] final TransactionGroupRange? groupRange; + /// When true, the list is in selection mode. Tapping the row toggles + /// selection instead of navigating, and slidable actions are suppressed. + final bool selectionActive; + + /// Whether this transaction is currently in the selection set. + final bool selected; + + /// Called when the user taps to toggle selection. When non-null, tapping + /// the leading icon always toggles regardless of [selectionActive]. + final VoidCallback? onSelectionToggle; + const TransactionListTile({ super.key, required this.transaction, @@ -59,6 +70,9 @@ class TransactionListTile extends StatelessWidget { this.dismissibleKey, this.overrideObscure, this.theme, + this.selectionActive = false, + this.selected = false, + this.onSelectionToggle, }); @override @@ -68,14 +82,15 @@ class TransactionListTile extends StatelessWidget { theme ?? TransactionListTileThemeData.fallback; - final bool showPendingConfirmation = - confirmFn != null && transaction.confirmable(); + final bool isLivePending = + transaction.isPending == true && transaction.isDeleted != true; + + final bool showPendingConfirmation = confirmFn != null && isLivePending; final bool showDuplicateButton = transaction.isDeleted != true && duplicateFn != null; final bool showHoldButton = confirmFn != null && transaction.holdable(); - final bool showConfirmButton = - confirmFn != null && transaction.confirmable(); + final bool showConfirmButton = confirmFn != null && isLivePending; if ((combineTransfers || showPendingConfirmation) && transaction.isTransfer && @@ -154,11 +169,27 @@ class TransactionListTile extends StatelessWidget { ) : null); + final Widget visualLeading = selected + ? FlowIcon(FlowIconData.icon(Symbols.check_rounded), plated: true) + : buildLeading(context, effectiveTheme); + + final Widget leading = onSelectionToggle != null + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onSelectionToggle!(), + child: visualLeading, + ) + : visualLeading; + final Widget listTile = Material( type: MaterialType.card, - color: kTransparent, + color: selected + ? context.colorScheme.primary.withAlpha(0x20) + : kTransparent, child: InkWell( - onTap: () => context.push("/transaction/${transaction.id}"), + onTap: selectionActive + ? (onSelectionToggle ?? () {}) + : () => context.push("/transaction/${transaction.id}"), child: Padding( padding: effectiveTheme.paddingOrDefault, child: Column( @@ -167,7 +198,7 @@ class TransactionListTile extends StatelessWidget { crossAxisAlignment: .start, spacing: effectiveTheme.spacingOrDefault, children: [ - buildLeading(context, effectiveTheme), + leading, Expanded( child: Column( mainAxisSize: MainAxisSize.min, @@ -301,6 +332,10 @@ class TransactionListTile extends StatelessWidget { ), ]; + if (selectionActive) { + return KeyedSubtree(key: dismissibleKey, child: listTile); + } + return DirectionalSlidable( key: dismissibleKey, groupTag: "transaction_list_tile", @@ -370,3 +405,4 @@ class TransactionListTile extends StatelessWidget { ), ); } + diff --git a/lib/widgets/transactions_selection_bar.dart b/lib/widgets/transactions_selection_bar.dart new file mode 100644 index 00000000..460fb704 --- /dev/null +++ b/lib/widgets/transactions_selection_bar.dart @@ -0,0 +1,67 @@ +import "package:flow/l10n/extensions.dart"; +import "package:flow/theme/helpers.dart"; +import "package:flow/widgets/transactions_selection_controller.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons/symbols.dart"; + +/// Persistent bottom overlay shown while [controller.active]. +class TransactionsSelectionBottomBar extends StatelessWidget { + final TransactionsSelectionController controller; + final VoidCallback onClose; + final VoidCallback onNext; + final VoidCallback onSelectAll; + + const TransactionsSelectionBottomBar({ + super.key, + required this.controller, + required this.onClose, + required this.onNext, + required this.onSelectAll, + }); + + @override + Widget build(BuildContext context) { + final bool hasSelection = controller.count > 0; + + return Material( + color: context.colorScheme.surfaceContainerHigh, + elevation: 8.0, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 8.0, + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Symbols.close_rounded), + tooltip: "general.cancel".t(context), + onPressed: onClose, + ), + const SizedBox(width: 4.0), + Expanded( + child: Text( + "transaction.bulk.selected".t(context, controller.count), + style: context.textTheme.titleMedium?.semi(context), + ), + ), + IconButton( + icon: const Icon(Symbols.select_all_rounded), + tooltip: "transaction.bulk.selectAll".t(context), + onPressed: onSelectAll, + ), + const SizedBox(width: 4.0), + FilledButton.icon( + onPressed: hasSelection ? onNext : null, + icon: const Icon(Symbols.arrow_forward_rounded), + label: Text("general.next".t(context)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/transactions_selection_controller.dart b/lib/widgets/transactions_selection_controller.dart new file mode 100644 index 00000000..3cf3875b --- /dev/null +++ b/lib/widgets/transactions_selection_controller.dart @@ -0,0 +1,126 @@ +import "package:flow/entity/transaction.dart"; +import "package:flutter/foundation.dart"; + +/// Tracks transactions selected for bulk operations. +class TransactionsSelectionController extends ChangeNotifier { + final Set _uuids = {}; + + bool _hasAnyTransfer = false; + bool _hasAnyDeleted = false; + bool _hasAnyAlive = false; + bool _hasAnyPending = false; + bool _hasAnyNonPending = false; + Set _currencies = {}; + + bool get active => _uuids.isNotEmpty; + int get count => _uuids.length; + Set get selectedUuids => Set.unmodifiable(_uuids); + + bool get hasAnyTransfer => _hasAnyTransfer; + bool get allDeleted => _hasAnyDeleted && !_hasAnyAlive; + bool get allPending => _hasAnyPending && !_hasAnyNonPending; + + Set get currencies => Set.unmodifiable(_currencies); + + bool contains(String uuid) => _uuids.contains(uuid); + + void toggle(Transaction transaction) { + final String uuid = transaction.uuid; + final String? partnerUuid = + transaction.extensions.transfer?.relatedTransactionUuid; + + if (_uuids.contains(uuid)) { + _uuids.remove(uuid); + if (partnerUuid != null) _uuids.remove(partnerUuid); + } else { + _uuids.add(uuid); + if (partnerUuid != null) _uuids.add(partnerUuid); + } + + notifyListeners(); + } + + void addAll(Iterable transactions) { + for (final Transaction t in transactions) { + _uuids.add(t.uuid); + final String? partnerUuid = t.extensions.transfer?.relatedTransactionUuid; + if (partnerUuid != null) _uuids.add(partnerUuid); + } + notifyListeners(); + } + + void clear() { + if (_uuids.isEmpty) return; + _uuids.clear(); + _hasAnyTransfer = false; + _hasAnyDeleted = false; + _hasAnyAlive = false; + _hasAnyPending = false; + _hasAnyNonPending = false; + _currencies = {}; + notifyListeners(); + } + + /// Refreshes derived flags from the most recent visible transactions. + void recomputeFromVisible(Iterable visible) { + bool transfer = false; + bool deleted = false; + bool alive = false; + bool pending = false; + bool nonPending = false; + final Set currencies = {}; + final Set stillVisible = {}; + + for (final Transaction t in visible) { + if (!_uuids.contains(t.uuid)) continue; + stillVisible.add(t.uuid); + + if (t.isTransfer) transfer = true; + if (t.isDeleted == true) { + deleted = true; + } else { + alive = true; + } + if (t.isPending == true && t.isDeleted != true) { + pending = true; + } else { + nonPending = true; + } + currencies.add(t.currency); + } + + bool changed = false; + if (stillVisible.length != _uuids.length) { + _uuids + ..clear() + ..addAll(stillVisible); + changed = true; + } + if (transfer != _hasAnyTransfer) { + _hasAnyTransfer = transfer; + changed = true; + } + if (deleted != _hasAnyDeleted) { + _hasAnyDeleted = deleted; + changed = true; + } + if (alive != _hasAnyAlive) { + _hasAnyAlive = alive; + changed = true; + } + if (pending != _hasAnyPending) { + _hasAnyPending = pending; + changed = true; + } + if (nonPending != _hasAnyNonPending) { + _hasAnyNonPending = nonPending; + changed = true; + } + if (!setEquals(currencies, _currencies)) { + _currencies = currencies; + changed = true; + } + + if (changed) notifyListeners(); + } +} diff --git a/lib/widgets/transactions_selection_scope.dart b/lib/widgets/transactions_selection_scope.dart new file mode 100644 index 00000000..768c97e3 --- /dev/null +++ b/lib/widgets/transactions_selection_scope.dart @@ -0,0 +1,275 @@ +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/prefs/local_preferences.dart"; +import "package:flow/providers/accounts_provider.dart"; +import "package:flow/utils/utils.dart"; +import "package:flow/widgets/select_bulk_transactions_action_sheet.dart"; +import "package:flow/widgets/sheets/select_account_sheet.dart"; +import "package:flow/widgets/sheets/select_category_sheet.dart"; +import "package:flow/widgets/transactions_selection_bar.dart"; +import "package:flow/widgets/transactions_selection_controller.dart"; +import "package:flutter/material.dart"; + +/// Wraps [child] with the bulk-selection bottom bar, action picker, and +/// `PopScope` exit handling. Pages own the [controller] and feed the +/// currently visible transactions; everything else is handled here. +class TransactionsSelectionScope extends StatefulWidget { + final TransactionsSelectionController controller; + final List visibleTransactions; + final Widget child; + + const TransactionsSelectionScope({ + super.key, + required this.controller, + required this.visibleTransactions, + required this.child, + }); + + @override + State createState() => + _TransactionsSelectionScopeState(); +} + +class _TransactionsSelectionScopeState + extends State { + @override + void initState() { + super.initState(); + widget.controller.addListener(_onSelectionChanged); + } + + @override + void didUpdateWidget(covariant TransactionsSelectionScope oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onSelectionChanged); + widget.controller.addListener(_onSelectionChanged); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onSelectionChanged); + super.dispose(); + } + + void _onSelectionChanged() { + if (!mounted) return; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + // Deferred to post-frame: recomputeFromVisible may notifyListeners. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + widget.controller.recomputeFromVisible(widget.visibleTransactions); + }); + + final bool active = widget.controller.active; + final double barInset = active + ? MediaQuery.viewPaddingOf(context).bottom + _kBarContentHeight + : 0.0; + + return PopScope( + canPop: !active, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && active) widget.controller.clear(); + }, + child: Stack( + children: [ + MediaQuery.removePadding( + context: context, + removeBottom: active, + child: Padding( + padding: EdgeInsets.only(bottom: barInset), + child: widget.child, + ), + ), + if (active) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: TransactionsSelectionBottomBar( + controller: widget.controller, + onClose: widget.controller.clear, + onNext: _onNext, + onSelectAll: () => + widget.controller.addAll(widget.visibleTransactions), + ), + ), + ], + ), + ); + } + + static const double _kBarContentHeight = 64.0; + + List _selectedFromVisible() { + final Set uuids = widget.controller.selectedUuids; + return widget.visibleTransactions + .where((t) => uuids.contains(t.uuid)) + .toList(); + } + + Future _onNext() async { + final TransactionsBulkAction? action = + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => SelectBulkTransactionsActionSheet( + controller: widget.controller, + ), + ); + if (action == null || !mounted) return; + switch (action) { + case TransactionsBulkAction.confirmAll: + await _bulkConfirm(); + case TransactionsBulkAction.delete: + await _bulkDelete(); + case TransactionsBulkAction.recover: + await _bulkRecover(); + case TransactionsBulkAction.changeCategory: + await _bulkChangeCategory(); + case TransactionsBulkAction.changeAccount: + await _bulkChangeAccount(); + } + } + + Future _confirmIfBulk( + int count, + String titleKey, { + bool isDestructive = false, + }) async { + if (count <= 1) return true; + final bool? ok = await context.showConfirmationSheet( + title: titleKey.t(context, count), + isDeletionConfirmation: isDestructive, + ); + return ok == true; + } + + Future _bulkConfirm() async { + final List selected = _selectedFromVisible(); + if (selected.isEmpty || !widget.controller.allPending) return; + if (!await _confirmIfBulk( + selected.length, + "transaction.bulk.confirmAll.confirm", + )) { + return; + } + final bool updateDate = LocalPreferences() + .pendingTransactions + .updateDateUponConfirmation + .get(); + final int count = BulkTransactions.confirm( + selected, + updateTransactionDate: updateDate, + ); + widget.controller.clear(); + if (!mounted) return; + context.showToast( + text: "transaction.bulk.confirmed.success".t(context, count), + ); + } + + Future _bulkDelete() async { + final List selected = _selectedFromVisible(); + if (selected.isEmpty) return; + if (!await _confirmIfBulk( + selected.length, + "transaction.bulk.delete.confirm", + isDestructive: true, + )) { + return; + } + final int count = BulkTransactions.moveToTrashBin(selected); + widget.controller.clear(); + if (!mounted) return; + context.showToast( + text: "transaction.bulk.deleted.success".t(context, count), + ); + } + + Future _bulkRecover() async { + final List selected = _selectedFromVisible(); + if (selected.isEmpty) return; + if (!await _confirmIfBulk( + selected.length, + "transaction.bulk.recover.confirm", + )) { + return; + } + final int count = BulkTransactions.recoverFromTrashBin(selected); + widget.controller.clear(); + if (!mounted) return; + context.showToast( + text: "transaction.bulk.recovered.success".t(context, count), + ); + } + + Future _bulkChangeCategory() async { + final List selected = _selectedFromVisible(); + if (selected.isEmpty || widget.controller.hasAnyTransfer) return; + + final Optional? result = await showModalBottomSheet< + Optional + >( + context: context, + builder: (context) => const SelectCategorySheet(), + isScrollControlled: true, + ); + if (result == null || !mounted) return; + if (!await _confirmIfBulk( + selected.length, + "transaction.bulk.changeCategory.confirm", + )) { + return; + } + + final int count = BulkTransactions.setCategory(selected, result.value); + widget.controller.clear(); + if (!mounted) return; + context.showToast( + text: "transaction.bulk.updated.success".t(context, count), + ); + } + + Future _bulkChangeAccount() async { + final List selected = _selectedFromVisible(); + if (selected.isEmpty || + widget.controller.hasAnyTransfer || + widget.controller.currencies.length != 1) { + return; + } + + final String currency = widget.controller.currencies.first; + final List candidates = AccountsProvider.of(context) + .activeAccounts + .where((a) => a.currency == currency) + .toList(); + + final Account? result = await showModalBottomSheet( + context: context, + builder: (context) => SelectAccountSheet(accounts: candidates), + isScrollControlled: true, + ); + if (result == null || !mounted) return; + if (!await _confirmIfBulk( + selected.length, + "transaction.bulk.changeAccount.confirm", + )) { + return; + } + + final int count = BulkTransactions.setAccount(selected, result); + widget.controller.clear(); + if (!mounted) return; + context.showToast( + text: "transaction.bulk.updated.success".t(context, count), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 20dc8d62..51d33a1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.21.0+342" +version: "0.22.0+344" environment: sdk: ">=3.10.0 <4.0.0" diff --git a/test/backup/v2_roundtrip_test.dart b/test/backup/v2_roundtrip_test.dart new file mode 100644 index 00000000..97a5c224 --- /dev/null +++ b/test/backup/v2_roundtrip_test.dart @@ -0,0 +1,182 @@ +import "dart:convert"; +import "dart:io"; + +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/sync/export/export_v2.dart"; +import "package:flow/sync/model/model_v2.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path/path.dart" as path; + +import "../database_test.dart" show objectboxTestRootDir; +import "../objectbox_erase.dart"; +import "v1_populate.dart"; + +/// Roundtrip the v2 export through `generateBackupJSONContentV2` → +/// `SyncModelV2.fromJson` and assert that every account, category, and +/// transaction survives serialization. Catches the common regression where +/// an entity field is added but `JsonSerializable` codegen isn't re-run, +/// or where the export pipeline forgets to include a new field. +void main() async { + group("Sync V2: JSON export/import roundtrip", () { + const int dummyTransactionCount = 50; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Pre-clean so a crashed prior run doesn't bias the dataset. + // `populateDummyData` short-circuits when an "Alpha" account already + // exists; without the wipe, re-runs would append on top of stale data + // and the test would still pass for the wrong reason. + final Directory previous = Directory( + path.join(objectboxTestRootDir().path, "sync/v2"), + ); + if (previous.existsSync()) { + previous.deleteSync(recursive: true); + } + + await ObjectBox.initialize( + customDirectory: objectboxTestRootDir().path, + subdirectory: "sync/v2", + ); + + await populateDummyData(dummyTransactionCount); + }); + + test("Generated JSON parses back into SyncModelV2", () async { + final String jsonContent = await generateBackupJSONContentV2(); + final Map decoded = + jsonDecode(jsonContent) as Map; + + expect(decoded["versionCode"], 2); + expect(decoded.containsKey("transactions"), isTrue); + expect(decoded.containsKey("accounts"), isTrue); + expect(decoded.containsKey("categories"), isTrue); + + // The actual deserialization — this is what import_v2 will do. + final SyncModelV2 parsed = SyncModelV2.fromJson(decoded); + + expect(parsed.versionCode, 2); + expect(parsed.accounts, isNotEmpty); + expect(parsed.categories, isNotEmpty); + expect(parsed.transactions, isNotEmpty); + }); + + test( + "Exported entity counts match what's in the ObjectBox store", + () async { + final int expectedAccounts = ObjectBox().box().count(); + final int expectedCategories = ObjectBox().box().count(); + final int expectedTransactions = ObjectBox().box().count(); + + final SyncModelV2 parsed = SyncModelV2.fromJson( + jsonDecode(await generateBackupJSONContentV2()) + as Map, + ); + + expect(parsed.accounts.length, expectedAccounts); + expect(parsed.categories.length, expectedCategories); + expect(parsed.transactions.length, expectedTransactions); + }, + ); + + test( + "Every account uuid + name + currency survives roundtrip", + () async { + final List originals = await ObjectBox() + .box() + .getAllAsync(); + final SyncModelV2 parsed = SyncModelV2.fromJson( + jsonDecode(await generateBackupJSONContentV2()) + as Map, + ); + + final Map byUuid = { + for (final a in parsed.accounts) a.uuid: a, + }; + + for (final original in originals) { + final Account? roundtripped = byUuid[original.uuid]; + expect( + roundtripped, + isNotNull, + reason: "Account ${original.uuid} (${original.name}) lost", + ); + expect(roundtripped!.name, original.name); + expect(roundtripped.currency, original.currency); + } + }, + ); + + test( + "Every category uuid + name survives roundtrip", + () async { + final List originals = await ObjectBox() + .box() + .getAllAsync(); + final SyncModelV2 parsed = SyncModelV2.fromJson( + jsonDecode(await generateBackupJSONContentV2()) + as Map, + ); + + final Map byUuid = { + for (final c in parsed.categories) c.uuid: c, + }; + + for (final original in originals) { + final Category? roundtripped = byUuid[original.uuid]; + expect( + roundtripped, + isNotNull, + reason: "Category ${original.uuid} (${original.name}) lost", + ); + expect(roundtripped!.name, original.name); + } + }, + ); + + test( + "Every transaction uuid + amount + currency + date survives roundtrip", + () async { + final List originals = await ObjectBox() + .box() + .getAllAsync(); + final SyncModelV2 parsed = SyncModelV2.fromJson( + jsonDecode(await generateBackupJSONContentV2()) + as Map, + ); + + final Map byUuid = { + for (final t in parsed.transactions) t.uuid: t, + }; + + for (final original in originals) { + final Transaction? roundtripped = byUuid[original.uuid]; + expect( + roundtripped, + isNotNull, + reason: "Transaction ${original.uuid} lost", + ); + expect(roundtripped!.amount, original.amount); + expect(roundtripped.currency, original.currency); + // Compare ISO 8601 strings to avoid microsecond drift across JSON + // boundaries. + expect( + roundtripped.transactionDate.toUtc().toIso8601String(), + original.transactionDate.toUtc().toIso8601String(), + ); + } + }, + ); + + tearDownAll(() async { + await testCleanupObject( + instance: ObjectBox(), + directory: ObjectBox.appDataDirectory, + cleanUp: true, + ); + }); + }); +}