diff --git a/CHANGELOG.md b/CHANGELOG.md index 63cf7224..cef8568d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.19.1 + +### Fixes + +* Fixed "Add an expense" Siri Shortcut struggling with non-US formatted numbers +* Other minor fixes + ## 0.19.0 ### New features diff --git a/RELEASE_TEMPLATE.md b/RELEASE_TEMPLATE.md index 4c137b25..27ccb4ed 100644 --- a/RELEASE_TEMPLATE.md +++ b/RELEASE_TEMPLATE.md @@ -7,12 +7,29 @@ data loss under any circumstances! ## What's Changed -{{changelog}} +### New features + +* Added a Siri integration (intent), fix [#657](https://github.com/flow-mn/flow/issues/657) +* Persian (Iran) support thanks to @arefbhrn[https://github.com/arefbhrn] +* Location tags are suggested if you're within 50m of the tag. Requires location + to be enabled. Closes [#648](https://github.com/flow-mn/flow/issues/648) +* Now it's possible to duplicate transfers + +### Changes + +* Added an option to show/hide external source (Eny, Siri) in transactions +* Enhanced home tab pending transactions timeframe options, closes [#666](https://github.com/flow-mn/flow/issues/666) +* Home tab pending transaction group now shows sum and count of transactions + +### Fixes + +* Action arrows are now correctly displayed in RTL languages (Arabic, Persian) + IDK why it was flipped, probably Flutter update did something. ## Download -* [Google Play](https://play.google.com/store/apps/details?id=mn.flow.flow?utm_source=gh-release-{{version}}) for Android -* [App Store](https://apps.apple.com/mn/app/flow-expense-tracker/id6477741670?utm_source=gh-release-{{version}}) for iOS +* [Google Play](https://play.google.com/store/apps/details?id=mn.flow.flow?utm_source=gh-release-0-19-0) for Android +* [App Store](https://apps.apple.com/mn/app/flow-expense-tracker/id6477741670?utm_source=gh-release-0-19-0) for iOS Also: diff --git a/assets/l10n/ar.json b/assets/l10n/ar.json index 7e9da657..d0363285 100644 --- a/assets/l10n/ar.json +++ b/assets/l10n/ar.json @@ -42,6 +42,7 @@ "category.delete.description": "حذف هذه الفئة سيترك {transactionCount} معاملة بدون فئة. هذا الإجراء لا يمكن التراجع عنه!", "category.name": "اسم الفئة", "category.new": "إضافة فئة", + "category.new.success": "تم إنشاء فئة جديدة بنجاح", "category.none": "لا فئة", "category.skip": "بدون فئة", "contributors": "المساهمون", @@ -248,6 +249,7 @@ "general.edit": "تحرير", "general.enabled": "مُفعّل", "general.flow": "Flow", + "general.new": "جديد", "general.nextNDays": "الأيام القادمة {n}", "general.paste": "لصق", "general.save": "حفظ", diff --git a/assets/l10n/cs_CZ.json b/assets/l10n/cs_CZ.json index e9cc0741..867768a5 100644 --- a/assets/l10n/cs_CZ.json +++ b/assets/l10n/cs_CZ.json @@ -42,6 +42,7 @@ "category.delete.description": "Smazáním této kategorie zůstane {transactionCount} transakcí bez kategorie. Tato akce je nevratná!", "category.name": "Název kategorie", "category.new": "Nová kategorie", + "category.new.success": "Nová kategorie byla úspěšně vytvořena.", "category.none": "Žádná kategorie", "category.skip": "Bez kategorie", "contributors": "Přispěvatelé", @@ -248,6 +249,7 @@ "general.edit": "Upravit", "general.enabled": "Zapnuto", "general.flow": "Tok", + "general.new": "Nový", "general.nextNDays": "Dalších {n} dní", "general.paste": "Vložit", "general.save": "Uložit", diff --git a/assets/l10n/de_DE.json b/assets/l10n/de_DE.json index c465451e..c589903c 100644 --- a/assets/l10n/de_DE.json +++ b/assets/l10n/de_DE.json @@ -42,6 +42,7 @@ "category.delete.description": "Wenn du diese Kategorie löschst, haben {transactionCount} Buchungen keine Kategorie mehr. Das kann nicht rückgängig gemacht werden!", "category.name": "Name der Kategorie", "category.new": "Kategorie hinzufügen", + "category.new.success": "Neue Kategorie erfolgreich erstellt.", "category.none": "Keine Kategorie", "category.skip": "Keine Kategorie", "contributors": "Mitwirkende", @@ -248,6 +249,7 @@ "general.edit": "Bearbeiten", "general.enabled": "Aktiviert", "general.flow": "Flow", + "general.new": "Neu", "general.nextNDays": "Die nächsten {n} Tag(e)", "general.paste": "Einfügen", "general.save": "Speichern", diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 77653671..eff2c391 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -42,6 +42,7 @@ "category.delete.description": "Deleting this category will leave {transactionCount} transactions with no category. This action is irreversible!", "category.name": "Category name", "category.new": "Add a category", + "category.new.success": "Successfully created a new category", "category.none": "No category", "category.skip": "No category", "contributors": "Contributors", @@ -248,6 +249,7 @@ "general.edit": "Edit", "general.enabled": "Enabled", "general.flow": "Flow", + "general.new": "New", "general.nextNDays": "Next {n} day(s)", "general.paste": "Paste", "general.save": "Save", diff --git a/assets/l10n/es_ES.json b/assets/l10n/es_ES.json index 0c49cf76..67ac13f2 100644 --- a/assets/l10n/es_ES.json +++ b/assets/l10n/es_ES.json @@ -42,6 +42,7 @@ "category.delete.description": "Eliminar esta categoría dejará {transactionCount} transacciones sin categoría. ¡Esta acción es irreversible!", "category.name": "Nombre de la categoría", "category.new": "Añadir una categoría", + "category.new.success": "Se ha creado correctamente una nueva categoría", "category.none": "Sin categoría", "category.skip": "Sin categoría", "contributors": "Colaboradores", @@ -248,6 +249,7 @@ "general.edit": "Editar", "general.enabled": "Activado", "general.flow": "Flow", + "general.new": "Nuevo", "general.nextNDays": "Próximos {n} día(s)", "general.paste": "Pegar", "general.save": "Guardar", diff --git a/assets/l10n/fa_IR.json b/assets/l10n/fa_IR.json index 1bcc31d4..1039ee24 100644 --- a/assets/l10n/fa_IR.json +++ b/assets/l10n/fa_IR.json @@ -42,6 +42,7 @@ "category.delete.description": "با حذف این دسته‌بندی، {transactionCount} تراکنش بدون دسته‌بندی می‌ماند. این کار غیرقابل بازگشت است!", "category.name": "نام دسته‌بندی", "category.new": "افزودن دسته‌بندی", + "category.new.success": "دسته‌بندی جدید با موفقیت ایجاد شد", "category.none": "بدون دسته‌بندی", "category.skip": "بدون دسته‌بندی", "contributors": "مشارکت‌کنندگان", @@ -248,6 +249,7 @@ "general.edit": "ویرایش", "general.enabled": "فعال", "general.flow": "Flow", + "general.new": "جدید", "general.nextNDays": "{n} روز آینده", "general.paste": "چسباندن", "general.save": "ذخیره", diff --git a/assets/l10n/fr_FR.json b/assets/l10n/fr_FR.json index 3e2b41be..17c5d513 100644 --- a/assets/l10n/fr_FR.json +++ b/assets/l10n/fr_FR.json @@ -42,6 +42,7 @@ "category.delete.description": "Supprimer cette catégorie laissera {transactionCount} transactions sans catégorie. Cette action est irréversible!", "category.name": "Nom de la catégorie", "category.new": "Ajouter une catégorie", + "category.new.success": "Nouvelle catégorie créée avec succès", "category.none": "Pas de catégorie", "category.skip": "Pas de catégorie", "contributors": "Contributeurs", @@ -248,6 +249,7 @@ "general.edit": "Modifier", "general.enabled": "Activé", "general.flow": "Flow", + "general.new": "Nouveau", "general.nextNDays": "Prochain(s) {n} jour(s)", "general.paste": "Coller", "general.save": "Enregistrer", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index e8de6fe8..963d4b84 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -42,6 +42,7 @@ "category.delete.description": "Eliminando questa categoria, {transactionCount} transazioni rimarranno senza categoria. Questa azione è irreversibile!", "category.name": "Nome della categoria", "category.new": "Aggiungi una categoria", + "category.new.success": "Categoria creata con successo", "category.none": "Nessuna categoria", "category.skip": "Nessuna categoria", "contributors": "Collaboratori", @@ -248,6 +249,7 @@ "general.edit": "Modifica", "general.enabled": "Abilitato", "general.flow": "Flusso", + "general.new": "Nuovo", "general.nextNDays": "Prossimi {n} giorni", "general.paste": "Incolla", "general.save": "Salva", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 0e8635d9..1f689bff 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -42,6 +42,7 @@ "category.delete.description": "Энэ ангиллыг устгавал холбоотой {transactionCount} гүйлгээг ангилалгүй болохыг анхаарна уу. Энэ үйлдлийг буцаах боломжгүй юм!", "category.name": "Нэр", "category.new": "Ангилал үүсгэх", + "category.new.success": "Шинэ ангилал амжилттай үүслээ", "category.none": "Ангилалгүй", "category.skip": "Ангилалгүй", "contributors": "Хувь нэмэр оруулсан", @@ -248,6 +249,7 @@ "general.edit": "Засварлах", "general.enabled": "Идэвхтэй", "general.flow": "Урсгал", + "general.new": "Шинэ", "general.nextNDays": "Ирэх {n} хоног", "general.paste": "Буулгах", "general.save": "Хадгалах", diff --git a/assets/l10n/ru_RU.json b/assets/l10n/ru_RU.json index 1bb91adc..6c8136b3 100644 --- a/assets/l10n/ru_RU.json +++ b/assets/l10n/ru_RU.json @@ -42,6 +42,7 @@ "category.delete.description": "Удаление этой категории оставит {transactionCount} транзакций без категории. Это действие необратимо!", "category.name": "Название категории", "category.new": "Добавить категорию", + "category.new.success": "Новая категория успешно создана", "category.none": "Без категории", "category.skip": "Без категории", "contributors": "Участники", @@ -248,6 +249,7 @@ "general.edit": "Редактировать", "general.enabled": "Включено", "general.flow": "Flow", + "general.new": "Создать", "general.nextNDays": "Следующие {n} дн.", "general.paste": "Вставить", "general.save": "Сохранить", diff --git a/assets/l10n/tr_TR.json b/assets/l10n/tr_TR.json index de5331d3..240a60f2 100644 --- a/assets/l10n/tr_TR.json +++ b/assets/l10n/tr_TR.json @@ -42,6 +42,7 @@ "category.delete.description": "Bu kategoriyi silmek, işlemleri kategorisiz bırakır {transactionCount} . Bu eylem geri alınamaz!", "category.name": "Kategori adı", "category.new": "Kategori ekleme", + "category.new.success": "Yeni kategori başarıyla oluşturuldu", "category.none": "Kategori yok", "category.skip": "Kategori yok", "contributors": "Katkıda bulunanlar", @@ -248,6 +249,7 @@ "general.edit": "Düzenlemek", "general.enabled": "Etkin", "general.flow": "Akış", + "general.new": "Yeni", "general.nextNDays": "Sonraki {n} gün", "general.paste": "Yapıştır", "general.save": "Kaydetmek", diff --git a/assets/l10n/uk_UA.json b/assets/l10n/uk_UA.json index 3935894d..327ee10b 100644 --- a/assets/l10n/uk_UA.json +++ b/assets/l10n/uk_UA.json @@ -42,6 +42,7 @@ "category.delete.description": "Видалення цієї категорії залишить {transactionCount} транзакцій без категорії. Ця дія незворотна!", "category.name": "Назва категорії", "category.new": "Додати категорію", + "category.new.success": "Успішно створено нову категорію", "category.none": "Без категорії", "category.skip": "Без категорії", "contributors": "Учасники", @@ -248,6 +249,7 @@ "general.edit": "Редагувати", "general.enabled": "Увімкнено", "general.flow": "Flow", + "general.new": "Новий", "general.nextNDays": "Наступні {n} дн.", "general.paste": "Вставити", "general.save": "Зберегти", diff --git a/ios/Runner/RecordTransactionIntent.swift b/ios/Runner/RecordTransactionIntent.swift index 97dc990d..a7f71d41 100644 --- a/ios/Runner/RecordTransactionIntent.swift +++ b/ios/Runner/RecordTransactionIntent.swift @@ -8,21 +8,38 @@ struct RecordTransactionIntent: AppIntent { var account: String @Parameter(title: "Amount", description: "Expense amount. Sign doesn't matter.") - var amount: Double + var amount: String @Parameter(title: "Category", description: "Exact name, or UUID of the target account.") var category: String - + @Parameter(title: "Notes", description: "Transaction notes. Markdown supported.") var notes: String - + @Parameter(title: "Title", description: "Transaction title.") var title: String static var openAppWhenRun = false func perform() async throws -> some IntentResult & ProvidesDialog { - let tx = RecordedTransaction(type: .expense, amount: amount, title: title, fromAccount: account, category: category, notes: notes) + let formatter = NumberFormatter() + formatter.locale = Locale.current + formatter.numberStyle = .decimal + + let parsedAmount: Double + if let number = formatter.number(from: amount) { + parsedAmount = number.doubleValue + } else { + let digitsOnly = amount.replacingOccurrences(of: "[^0-9]", with: "", options: .regularExpression) + let hadDecimalSeparator = amount.contains(formatter.decimalSeparator) + guard let fallbackNumber = Double(digitsOnly) else { + throw NSError(domain: "InvalidAmount", code: 1) + } + parsedAmount = fallbackNumber / (hadDecimalSeparator ? 100 : 1) + } + let tx = RecordedTransaction( + type: .expense, amount: parsedAmount, title: title, fromAccount: account, category: category, + notes: notes) try RecordedTransactionService.append(tx) return .result(dialog: "Expense recorded ✅") } diff --git a/lib/data/multi_filter.dart b/lib/data/multi_filter.dart new file mode 100644 index 00000000..f7ff08ef --- /dev/null +++ b/lib/data/multi_filter.dart @@ -0,0 +1,78 @@ +import "package:flow/entity/_base.dart"; +import "package:flutter/foundation.dart"; +import "package:json_annotation/json_annotation.dart"; + +typedef Comparer = bool Function(T a, T b); + +/// Used to filter items based on a whitelist or blacklist of items. +/// +/// Only [MultiFilter] can be serialized +class MultiFilter { + final bool whitelist; + + final List items; + + @JsonKey(includeFromJson: false, includeToJson: false) + final Comparer? comparer; + + const MultiFilter({ + required this.whitelist, + required this.items, + this.comparer, + }); + const MultiFilter.keepNothing() + : items = const [], + whitelist = true, + comparer = _alwaysFalse; + const MultiFilter.keepEverything() + : items = const [], + whitelist = false, + comparer = _alwaysFalse; + const MultiFilter.whitelist(this.items, {this.comparer}) : whitelist = true; + const MultiFilter.blacklist(this.items, {this.comparer}) : whitelist = false; + + static bool _alwaysFalse(dynamic a, dynamic b) => false; + static bool flowDefaultComparer(dynamic a, dynamic b) { + if (a is EntityBase && b is EntityBase) { + return a.uuid == b.uuid; + } + + if (a is List && b is List) { + return listEquals(a, b); + } + + if (a is Set && b is Set) { + return setEquals(a, b); + } + + if (a is Map && b is Map) { + return mapEquals(a, b); + } + + return a == b; + } + + /// Always outputs the items to keep. + List filter(Iterable input) { + final Comparer comparer = this.comparer ?? flowDefaultComparer; + return input.where((element) { + final bool contains = items.any((item) => comparer(item, element)); + return whitelist ? contains : !contains; + }).toList(); + } + + List mappedFilter(Iterable input, T Function(K) mapper) { + final Comparer comparer = this.comparer ?? flowDefaultComparer; + return input.where((element) { + final T mapped = mapper(element); + final bool contains = items.any((item) => comparer(item, mapped)); + return whitelist ? contains : !contains; + }).toList(); + } + + bool contains(T item) { + final Comparer comparer = this.comparer ?? flowDefaultComparer; + final bool contains = items.any((i) => comparer(i, item)); + return whitelist ? contains : !contains; + } +} diff --git a/lib/data/string_multi_filter.dart b/lib/data/string_multi_filter.dart new file mode 100644 index 00000000..5921f256 --- /dev/null +++ b/lib/data/string_multi_filter.dart @@ -0,0 +1,53 @@ +import "package:flow/data/multi_filter.dart"; +import "package:flutter/foundation.dart"; +import "package:json_annotation/json_annotation.dart"; + +part "string_multi_filter.g.dart"; + +@JsonSerializable(explicitToJson: true) +class StringMultiFilter extends MultiFilter { + static bool _stringComparer(String a, String b) => a == b; + + const StringMultiFilter({required super.whitelist, required super.items}) + : super(comparer: _stringComparer); + const StringMultiFilter.keepNothing() : super.keepNothing(); + const StringMultiFilter.keepEverything() : super.keepEverything(); + const StringMultiFilter.whitelist(super.items) + : super.whitelist(comparer: _stringComparer); + const StringMultiFilter.blacklist(super.items) + : super.blacklist(comparer: _stringComparer); + + factory StringMultiFilter.fromJson(Map json) => + _$StringMultiFilterFromJson(json); + Map toJson() => _$StringMultiFilterToJson(this); + + static StringMultiFilter? fromJsonOrList(dynamic json) { + if (json == null) return null; + + if (json is Iterable) { + return StringMultiFilter.whitelist(json.map((e) => e as String).toList()); + } + + return StringMultiFilter.fromJson(json as Map); + } + + @override + int get hashCode => Object.hash(whitelist, items.toSet()); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + if (other case StringMultiFilter stringMultiFilter) { + return stringMultiFilter.whitelist == whitelist && + setEquals(stringMultiFilter.items.toSet(), items.toSet()); + } + + if (other case MultiFilter stringMultiFilter) { + return stringMultiFilter.whitelist == whitelist && + setEquals(stringMultiFilter.items.toSet(), items.toSet()); + } + + return false; + } +} diff --git a/lib/data/string_multi_filter.g.dart b/lib/data/string_multi_filter.g.dart new file mode 100644 index 00000000..d7b0c2cd --- /dev/null +++ b/lib/data/string_multi_filter.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'string_multi_filter.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StringMultiFilter _$StringMultiFilterFromJson(Map json) => + StringMultiFilter( + whitelist: json['whitelist'] as bool, + items: (json['items'] as List).map((e) => e as String).toList(), + ); + +Map _$StringMultiFilterToJson(StringMultiFilter instance) => + {'whitelist': instance.whitelist, 'items': instance.items}; diff --git a/lib/data/transaction_filter.dart b/lib/data/transaction_filter.dart index 722ca54c..2d9e4427 100644 --- a/lib/data/transaction_filter.dart +++ b/lib/data/transaction_filter.dart @@ -1,5 +1,6 @@ import "dart:convert"; +import "package:flow/data/string_multi_filter.dart"; import "package:flow/data/transactions_filter/group_range.dart"; import "package:flow/data/transactions_filter/search_data.dart"; import "package:flow/data/transactions_filter/sort_field.dart"; @@ -7,6 +8,9 @@ import "package:flow/data/transactions_filter/time_range.dart"; import "package:flow/entity/transaction.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/services/accounts.dart"; +import "package:flow/services/categories.dart"; +import "package:flow/services/transaction_tag.dart"; import "package:flow/utils/json/time_range_converter.dart"; import "package:flow/utils/utils.dart"; import "package:flutter/foundation.dart" hide Category; @@ -34,14 +38,17 @@ class TransactionFilter implements Jasonable { final List? types; - final List? categories; - final List? accounts; - final List? tags; + @JsonKey(fromJson: StringMultiFilter.fromJsonOrList) + final StringMultiFilter? categories; + @JsonKey(fromJson: StringMultiFilter.fromJsonOrList) + final StringMultiFilter? accounts; + @JsonKey(fromJson: StringMultiFilter.fromJsonOrList) + final StringMultiFilter? tags; /// When true, matches transactions that have all of the specified [tags]. /// /// When false, matches transactions that have at least one of the specified [tags]. - final bool requireAllTags; + final bool tagsAndRule; final bool? hasAttachments; @@ -76,7 +83,7 @@ class TransactionFilter implements Jasonable { this.currencies, this.extraTag, this.hasAttachments, - this.requireAllTags = false, + this.tagsAndRule = false, this.includeDeleted = false, this.sortDescending = true, this.searchData = const TransactionSearchData(), @@ -94,20 +101,22 @@ class TransactionFilter implements Jasonable { required Set categories, required Set tags, }) { - if (this.accounts?.isNotEmpty == true && - this.accounts!.any((accountUuid) => !accounts.contains(accountUuid))) { + if (this.accounts?.items.isNotEmpty == true && + this.accounts!.items.any( + (accountUuid) => !accounts.contains(accountUuid), + )) { return false; } - if (this.categories?.isNotEmpty == true && - this.categories!.any( + if (this.categories?.items.isNotEmpty == true && + this.categories!.items.any( (categoryUuid) => !categories.contains(categoryUuid), )) { return false; } - if (this.tags?.isNotEmpty == true && - this.tags!.any((tag) => !tags.contains(tag))) { + if (this.tags?.items.isNotEmpty == true && + this.tags!.items.any((tag) => !tags.contains(tag))) { return false; } @@ -126,95 +135,6 @@ class TransactionFilter implements Jasonable { return predicates; } - List get predicates { - final List predicates = []; - - if (uuids?.isNotEmpty == true) { - predicates.add((Transaction t) => uuids!.any((uuid) => t.uuid == uuid)); - } - - if (range case TimeRange filterTimeRange) { - predicates.add( - (Transaction t) => filterTimeRange.contains(t.transactionDate), - ); - } - - if (types?.isNotEmpty == true) { - predicates.add((Transaction t) => types!.contains(t.type)); - } - - predicates.add(searchData.predicate); - - if (categories?.isNotEmpty == true) { - predicates.add( - (Transaction t) => - categories!.any((category) => t.categoryUuid == category), - ); - } - - if (accounts?.isNotEmpty == true) { - predicates.add( - (Transaction t) => accounts!.any((account) => t.accountUuid == account), - ); - } - - if (tags?.isNotEmpty == true) { - if (requireAllTags) { - predicates.add( - (Transaction t) => - tags!.every((tag) => t.tags.any((e) => e.uuid == tag)), - ); - } else { - predicates.add( - (Transaction t) => - tags!.any((tag) => t.tags.any((e) => e.uuid == tag)), - ); - } - } - - if (minAmount != null) { - predicates.add((Transaction t) => t.amount >= minAmount!); - } - - if (maxAmount != null) { - predicates.add((Transaction t) => t.amount <= maxAmount!); - } - - if (currencies?.isNotEmpty == true) { - predicates.add((Transaction t) => currencies!.contains(t.currency)); - } - - if (isPending != null) { - predicates.add((Transaction t) { - if (isPending!) { - return t.isPending == true; - } else { - return t.isPending == null || !t.isPending!; - } - }); - } - - if (hasAttachments != null) { - if (hasAttachments!) { - predicates.add((Transaction t) => t.attachments.isNotEmpty); - } else { - predicates.add((Transaction t) => t.attachments.isEmpty); - } - } - - if (extraTag != null) { - predicates.add((Transaction t) => t.extraTags.contains(extraTag)); - } - - if (includeDeleted != true) { - predicates.add( - (Transaction t) => t.isDeleted == null || t.isDeleted == false, - ); - } - - return predicates; - } - /// Here, we don't have any fancy fuzzy finding, so /// [ignoreKeywordFilter] is enabled by default. /// @@ -223,6 +143,16 @@ class TransactionFilter implements Jasonable { QueryBuilder queryBuilder({bool ignoreKeywordFilter = true}) { final List> conditions = []; + final List? accountUuids = accounts?.filter( + AccountsService().getAllUuidsSync(), + ); + final List? categoryUuids = categories?.filter( + CategoriesService().getAllUuidsSync(), + ); + final List? tagUuids = tags?.filter( + TransactionTagService().getAllUuidsSync(), + ); + if (uuids?.isNotEmpty == true) { conditions.add(Transaction_.uuid.oneOf(uuids!)); } @@ -251,12 +181,12 @@ class TransactionFilter implements Jasonable { conditions.add(searchFilter); } - if (categories?.isNotEmpty == true) { - conditions.add(Transaction_.categoryUuid.oneOf(categories!)); + if (categoryUuids != null) { + conditions.add(Transaction_.categoryUuid.oneOf(categoryUuids)); } - if (accounts?.isNotEmpty == true) { - conditions.add(Transaction_.accountUuid.oneOf(accounts!)); + if (accountUuids != null) { + conditions.add(Transaction_.accountUuid.oneOf(accountUuids)); } if (minAmount != null) { @@ -303,16 +233,19 @@ class TransactionFilter implements Jasonable { conditions.isNotEmpty ? conditions.reduce((a, b) => a & b) : null, ); - if (tags != null && tags!.isNotEmpty) { - if (requireAllTags) { - for (final tag in tags!) { + if (tagUuids != null) { + if (tagsAndRule) { + for (final tag in tagUuids) { filtered.linkMany( Transaction_.tags, TransactionTag_.uuid.equals(tag), ); } } else { - filtered.linkMany(Transaction_.tags, TransactionTag_.uuid.oneOf(tags!)); + filtered.linkMany( + Transaction_.tags, + TransactionTag_.uuid.oneOf(tagUuids), + ); } } @@ -397,19 +330,19 @@ class TransactionFilter implements Jasonable { count++; } - if (!setEquals(categories?.toSet(), other.categories?.toSet())) { + if (categories != other.categories) { count++; } - if (!setEquals(accounts?.toSet(), other.accounts?.toSet())) { + if (accounts != other.accounts) { count++; } - if (!setEquals(tags?.toSet(), other.tags?.toSet())) { + if (tags != other.tags) { count++; } - if (requireAllTags != other.requireAllTags) { + if (tagsAndRule != other.tagsAndRule) { count++; } @@ -432,9 +365,9 @@ class TransactionFilter implements Jasonable { Optional>? types, Optional? range, TransactionSearchData? searchData, - Optional>? categories, - Optional>? accounts, - Optional>? tags, + Optional? categories, + Optional? accounts, + Optional? tags, Optional? sortDescending, TransactionSortField? sortBy, Optional? groupBy, @@ -444,7 +377,7 @@ class TransactionFilter implements Jasonable { Optional>? currencies, Optional? extraTag, Optional? hasAttachments, - Optional? requireAllTags, + Optional? tagsAndRule, }) { return TransactionFilter( types: types != null ? types.value : this.types, @@ -464,7 +397,7 @@ class TransactionFilter implements Jasonable { hasAttachments: hasAttachments != null ? hasAttachments.value : this.hasAttachments, - requireAllTags: requireAllTags?.value ?? this.requireAllTags, + tagsAndRule: tagsAndRule?.value ?? this.tagsAndRule, ); } @@ -506,11 +439,11 @@ class TransactionFilter implements Jasonable { other.includeDeleted == includeDeleted && other.isPending == isPending && other.extraTag == extraTag && + other.types == types && + other.categories == categories && + other.accounts == accounts && setEquals(other.uuids?.toSet(), uuids?.toSet()) && - setEquals(other.currencies?.toSet(), currencies?.toSet()) && - setEquals(other.types?.toSet(), types?.toSet()) && - setEquals(other.categories?.toSet(), categories?.toSet()) && - setEquals(other.accounts?.toSet(), accounts?.toSet()); + setEquals(other.currencies?.toSet(), currencies?.toSet()); } factory TransactionFilter.fromJson(Map json) => diff --git a/lib/data/transaction_filter.g.dart b/lib/data/transaction_filter.g.dart index f1d6c635..0c2baef2 100644 --- a/lib/data/transaction_filter.g.dart +++ b/lib/data/transaction_filter.g.dart @@ -10,13 +10,9 @@ TransactionFilter _$TransactionFilterFromJson( Map json, ) => TransactionFilter( uuids: (json['uuids'] as List?)?.map((e) => e as String).toList(), - categories: (json['categories'] as List?) - ?.map((e) => e as String) - .toList(), - accounts: (json['accounts'] as List?) - ?.map((e) => e as String) - .toList(), - tags: (json['tags'] as List?)?.map((e) => e as String).toList(), + categories: StringMultiFilter.fromJsonOrList(json['categories']), + accounts: StringMultiFilter.fromJsonOrList(json['accounts']), + tags: StringMultiFilter.fromJsonOrList(json['tags']), range: json['range'] == null ? null : TransactionFilterTimeRange.fromJson(json['range'] as String), @@ -31,7 +27,7 @@ TransactionFilter _$TransactionFilterFromJson( .toList(), extraTag: json['extraTag'] as String?, hasAttachments: json['hasAttachments'] as bool?, - requireAllTags: json['requireAllTags'] as bool? ?? false, + tagsAndRule: json['tagsAndRule'] as bool? ?? false, includeDeleted: json['includeDeleted'] as bool? ?? false, sortDescending: json['sortDescending'] as bool? ?? true, searchData: json['searchData'] == null @@ -54,10 +50,10 @@ Map _$TransactionFilterToJson( 'uuids': instance.uuids, 'searchData': instance.searchData.toJson(), 'types': instance.types?.map((e) => _$TransactionTypeEnumMap[e]!).toList(), - 'categories': instance.categories, - 'accounts': instance.accounts, - 'tags': instance.tags, - 'requireAllTags': instance.requireAllTags, + 'categories': instance.categories?.toJson(), + 'accounts': instance.accounts?.toJson(), + 'tags': instance.tags?.toJson(), + 'tagsAndRule': instance.tagsAndRule, 'hasAttachments': instance.hasAttachments, 'sortDescending': instance.sortDescending, 'sortBy': _$TransactionSortFieldEnumMap[instance.sortBy]!, diff --git a/lib/data/transaction_programmable_object.dart b/lib/data/transaction_programmable_object.dart index e5ac355f..870a62ad 100644 --- a/lib/data/transaction_programmable_object.dart +++ b/lib/data/transaction_programmable_object.dart @@ -2,6 +2,7 @@ import "package:flow/data/money.dart"; import "package:flow/entity/transaction/type.dart"; import "package:flow/services/user_preferences.dart"; import "package:flow/utils/loose_parsers.dart"; +import "package:flow/utils/money_parsing.dart"; import "package:flow/utils/utils.dart"; import "package:uuid/uuid.dart"; @@ -155,6 +156,13 @@ class TransactionProgrammableObject { json["amount"] = -(amount.toDouble().abs()); } + if (json["amount"] case String amountString) { + final double? amount = parseMoneyString(text: amountString); + if (amount != null) { + json["amount"] = -(amount.abs()); + } + } + return parse(json.cast()); } catch (e) { return null; diff --git a/lib/entity/budget.dart b/lib/entity/budget.dart index a83fe785..401c3ef7 100644 --- a/lib/entity/budget.dart +++ b/lib/entity/budget.dart @@ -28,7 +28,8 @@ class Budget implements EntityBase { @Unique() String name; - /// [moment_dart](https://pub.dev/packages/moment_dart)'s [TimeRange] compliant string + /// [moment_dart](https://pub.dev/packages/moment_dart)'s [TimeRange] + /// compliant string String range; @Transient() @@ -36,26 +37,31 @@ class Budget implements EntityBase { set timeRange(TimeRange value) => range = value.toString(); + /// When [true], and [timeRange] is [PageableRange], it will automatically + /// create a new budget for the next period when the current one expires. + bool renewAutomatically; + double amount; String currency; @JsonKey(includeFromJson: false, includeToJson: false) - final category = ToOne(); + final categories = ToMany(); @Transient() - String? _categoryUuid; + List? _categoriesUuids; - String? get categoryUuid => _categoryUuid ?? category.target?.uuid; + List? get categoriesUuids => + _categoriesUuids ?? categories.map((e) => e.uuid).toList(); - set categoryUuid(String? value) { - _categoryUuid = value; + set categoriesUuids(List? newTagUuids) { + _categoriesUuids = newTagUuids ?? []; } - /// This won't be saved until you call `Box.put()` - void setCategory(Category? newCategory) { - category.target = newCategory; - categoryUuid = newCategory?.uuid; + void setCategories(List? newCategories) { + categories.clear(); + categories.addAll(newCategories ?? []); + categoriesUuids = categories.map((e) => e.uuid).toList(); } Budget({ @@ -64,6 +70,7 @@ class Budget implements EntityBase { required this.amount, required this.currency, required this.range, + this.renewAutomatically = true, DateTime? createdDate, }) : createdDate = createdDate ?? DateTime.now(), uuid = const Uuid().v4(); diff --git a/lib/entity/budget.g.dart b/lib/entity/budget.g.dart index e24d4918..7026adf2 100644 --- a/lib/entity/budget.g.dart +++ b/lib/entity/budget.g.dart @@ -12,6 +12,7 @@ Budget _$BudgetFromJson(Map json) => amount: (json['amount'] as num).toDouble(), currency: json['currency'] as String, range: json['range'] as String, + renewAutomatically: json['renewAutomatically'] as bool? ?? true, createdDate: _$JsonConverterFromJson( json['createdDate'], const UTCDateTimeConverter().fromJson, @@ -21,7 +22,9 @@ Budget _$BudgetFromJson(Map json) => ..timeRange = const TimeRangeConverter().fromJson( json['timeRange'] as String, ) - ..categoryUuid = json['categoryUuid'] as String?; + ..categoriesUuids = (json['categoriesUuids'] as List?) + ?.map((e) => e as String) + .toList(); Map _$BudgetToJson(Budget instance) => { 'uuid': instance.uuid, @@ -29,9 +32,10 @@ Map _$BudgetToJson(Budget instance) => { 'name': instance.name, 'range': instance.range, 'timeRange': const TimeRangeConverter().toJson(instance.timeRange), + 'renewAutomatically': instance.renewAutomatically, 'amount': instance.amount, 'currency': instance.currency, - 'categoryUuid': instance.categoryUuid, + 'categoriesUuids': instance.categoriesUuids, }; Value? _$JsonConverterFromJson( diff --git a/lib/objectbox/objectbox-model.json b/lib/objectbox/objectbox-model.json index e17bce61..7a03b93a 100644 --- a/lib/objectbox/objectbox-model.json +++ b/lib/objectbox/objectbox-model.json @@ -513,7 +513,7 @@ }, { "id": "11:4948078457888921031", - "lastPropertyId": "9:6886515900911773491", + "lastPropertyId": "11:3812796204565944657", "name": "Budget", "properties": [ { @@ -552,25 +552,28 @@ "type": 9 }, { - "id": "7:801892318627771901", - "name": "categoryId", - "indexId": "21:7291423328418584896", - "type": 11, - "flags": 520, - "relationTarget": "Category" + "id": "9:6886515900911773491", + "name": "range", + "type": 9 }, { - "id": "8:4590726328503721316", - "name": "categoryUuid", - "type": 9 + "id": "10:2444273280228107480", + "name": "renewAutomatically", + "type": 1 }, { - "id": "9:6886515900911773491", - "name": "range", - "type": 9 + "id": "11:3812796204565944657", + "name": "categoriesUuids", + "type": 30 } ], - "relations": [] + "relations": [ + { + "id": "4:5665142201815113360", + "name": "categories", + "targetId": "2:649350347514211469" + } + ] }, { "id": "12:800756592587838565", @@ -792,7 +795,7 @@ ], "lastEntityId": "15:3741443681678089583", "lastIndexId": "27:5707692371585154920", - "lastRelationId": "3:8693268912561290427", + "lastRelationId": "4:5665142201815113360", "lastSequenceId": "0:0", "modelVersion": 5, "modelVersionParserMinimum": 5, @@ -801,7 +804,9 @@ 2857566645668229410, 268813570801700112 ], - "retiredIndexUids": [], + "retiredIndexUids": [ + 7291423328418584896 + ], "retiredPropertyUids": [ 620570223027064518, 4256690661297760083, @@ -848,7 +853,9 @@ 6033810133674409127, 610924910099589699, 3063095817126288040, - 2071092278574175844 + 2071092278574175844, + 801892318627771901, + 4590726328503721316 ], "retiredRelationUids": [ 552720950599490473 diff --git a/lib/objectbox/objectbox.g.dart b/lib/objectbox/objectbox.g.dart index c65d886f..af65cad6 100644 --- a/lib/objectbox/objectbox.g.dart +++ b/lib/objectbox/objectbox.g.dart @@ -634,7 +634,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(11, 4948078457888921031), name: 'Budget', - lastPropertyId: const obx_int.IdUid(9, 6886515900911773491), + lastPropertyId: const obx_int.IdUid(11, 3812796204565944657), flags: 0, properties: [ obx_int.ModelProperty( @@ -676,28 +676,31 @@ final _entities = [ flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(7, 801892318627771901), - name: 'categoryId', - type: 11, - flags: 520, - indexId: const obx_int.IdUid(21, 7291423328418584896), - relationField: 'category', - relationTarget: 'Category', + id: const obx_int.IdUid(9, 6886515900911773491), + name: 'range', + type: 9, + flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(8, 4590726328503721316), - name: 'categoryUuid', - type: 9, + id: const obx_int.IdUid(10, 2444273280228107480), + name: 'renewAutomatically', + type: 1, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(9, 6886515900911773491), - name: 'range', - type: 9, + id: const obx_int.IdUid(11, 3812796204565944657), + name: 'categoriesUuids', + type: 30, flags: 0, ), ], - relations: [], + relations: [ + obx_int.ModelRelation( + id: const obx_int.IdUid(4, 5665142201815113360), + name: 'categories', + targetId: const obx_int.IdUid(2, 649350347514211469), + ), + ], backlinks: [], ), obx_int.ModelEntity( @@ -998,14 +1001,14 @@ obx_int.ModelDefinition getObjectBoxModel() { entities: _entities, lastEntityId: const obx_int.IdUid(15, 3741443681678089583), lastIndexId: const obx_int.IdUid(27, 5707692371585154920), - lastRelationId: const obx_int.IdUid(3, 8693268912561290427), + lastRelationId: const obx_int.IdUid(4, 5665142201815113360), lastSequenceId: const obx_int.IdUid(0, 0), retiredEntityUids: const [ 3796819593314794683, 2857566645668229410, 268813570801700112, ], - retiredIndexUids: const [], + retiredIndexUids: const [7291423328418584896], retiredPropertyUids: const [ 620570223027064518, 4256690661297760083, @@ -1053,6 +1056,8 @@ obx_int.ModelDefinition getObjectBoxModel() { 610924910099589699, 3063095817126288040, 2071092278574175844, + 801892318627771901, + 4590726328503721316, ], retiredRelationUids: const [552720950599490473], modelVersion: 5, @@ -1824,8 +1829,10 @@ obx_int.ModelDefinition getObjectBoxModel() { ), Budget: obx_int.EntityDefinition( model: _entities[7], - toOneRelations: (Budget object) => [object.category], - toManyRelations: (Budget object) => {}, + toOneRelations: (Budget object) => [], + toManyRelations: (Budget object) => { + obx_int.RelInfo.toMany(4, object.id): object.categories, + }, getId: (Budget object) => object.id, setId: (Budget object, int id) { object.id = id; @@ -1834,20 +1841,24 @@ obx_int.ModelDefinition getObjectBoxModel() { final uuidOffset = fbb.writeString(object.uuid); final nameOffset = fbb.writeString(object.name); final currencyOffset = fbb.writeString(object.currency); - final categoryUuidOffset = object.categoryUuid == null - ? null - : fbb.writeString(object.categoryUuid!); final rangeOffset = fbb.writeString(object.range); - fbb.startTable(10); + final categoriesUuidsOffset = object.categoriesUuids == null + ? null + : fbb.writeList( + object.categoriesUuids! + .map(fbb.writeString) + .toList(growable: false), + ); + fbb.startTable(12); fbb.addInt64(0, object.id); fbb.addOffset(1, uuidOffset); fbb.addInt64(2, object.createdDate.millisecondsSinceEpoch); fbb.addOffset(3, nameOffset); fbb.addFloat64(4, object.amount); fbb.addOffset(5, currencyOffset); - fbb.addInt64(6, object.category.targetId); - fbb.addOffset(7, categoryUuidOffset); fbb.addOffset(8, rangeOffset); + fbb.addBool(9, object.renewAutomatically); + fbb.addOffset(10, categoriesUuidsOffset); fbb.finish(fbb.endTable()); return object.id; }, @@ -1875,6 +1886,12 @@ obx_int.ModelDefinition getObjectBoxModel() { final rangeParam = const fb.StringReader( asciiOptimization: true, ).vTableGet(buffer, rootOffset, 20, ''); + final renewAutomaticallyParam = const fb.BoolReader().vTableGet( + buffer, + rootOffset, + 22, + false, + ); final createdDateParam = DateTime.fromMillisecondsSinceEpoch( const fb.Int64Reader().vTableGet(buffer, rootOffset, 8, 0), ); @@ -1885,21 +1902,21 @@ obx_int.ModelDefinition getObjectBoxModel() { amount: amountParam, currency: currencyParam, range: rangeParam, + renewAutomatically: renewAutomaticallyParam, createdDate: createdDateParam, ) ..uuid = const fb.StringReader( asciiOptimization: true, ).vTableGet(buffer, rootOffset, 6, '') - ..categoryUuid = const fb.StringReader( - asciiOptimization: true, - ).vTableGetNullable(buffer, rootOffset, 18); - object.category.targetId = const fb.Int64Reader().vTableGet( - buffer, - rootOffset, - 16, - 0, + ..categoriesUuids = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGetNullable(buffer, rootOffset, 24); + obx_int.InternalToManyAccess.setRelInfo( + object.categories, + store, + obx_int.RelInfo.toMany(4, object.id), ); - object.category.attach(store); return object; }, ), @@ -2711,20 +2728,25 @@ class Budget_ { _entities[7].properties[5], ); - /// See [Budget.category]. - static final category = obx.QueryRelationToOne( + /// See [Budget.range]. + static final range = obx.QueryStringProperty( _entities[7].properties[6], ); - /// See [Budget.categoryUuid]. - static final categoryUuid = obx.QueryStringProperty( + /// See [Budget.renewAutomatically]. + static final renewAutomatically = obx.QueryBooleanProperty( _entities[7].properties[7], ); - /// See [Budget.range]. - static final range = obx.QueryStringProperty( + /// See [Budget.categoriesUuids]. + static final categoriesUuids = obx.QueryStringVectorProperty( _entities[7].properties[8], ); + + /// see [Budget.categories] + static final categories = obx.QueryRelationToMany( + _entities[7].relations[0], + ); } /// [RecurringTransaction] entity fields to define ObjectBox queries. diff --git a/lib/prefs/transitive.dart b/lib/prefs/transitive.dart index 5aa11f61..2cb5988e 100644 --- a/lib/prefs/transitive.dart +++ b/lib/prefs/transitive.dart @@ -2,6 +2,7 @@ import "dart:async"; import "dart:convert"; import "package:flow/data/prefs/frecency.dart"; +import "package:flow/data/string_multi_filter.dart"; import "package:flow/data/transaction_filter.dart"; import "package:flow/data/transactions_filter/time_range.dart"; import "package:flow/entity/account.dart"; @@ -206,7 +207,7 @@ class TransitiveLocalPreferences { for (final category in categories) { try { final TransactionFilter filter = TransactionFilter( - categories: [category.uuid], + categories: StringMultiFilter.whitelist([category.uuid]), range: TransactionFilterTimeRange.fromTimeRange( (Moment.now() - const Duration(days: 180)).rangeTo(Moment.now()), ), @@ -250,7 +251,7 @@ class TransitiveLocalPreferences { for (final account in accounts) { try { final TransactionFilter filter = TransactionFilter( - accounts: [account.uuid], + accounts: StringMultiFilter.whitelist([account.uuid]), range: TransactionFilterTimeRange.fromTimeRange( (Moment.now() - const Duration(days: 180)).rangeTo(Moment.now()), ), @@ -296,7 +297,7 @@ class TransitiveLocalPreferences { for (final tag in tags) { try { final TransactionFilter filter = TransactionFilter( - tags: [tag.uuid], + tags: StringMultiFilter.whitelist([tag.uuid]), range: TransactionFilterTimeRange.fromTimeRange( (Moment.now() - const Duration(days: 180)).rangeTo(Moment.now()), ), diff --git a/lib/routes/account/account_edit_page.dart b/lib/routes/account/account_edit_page.dart index 183d0660..0c0dac30 100644 --- a/lib/routes/account/account_edit_page.dart +++ b/lib/routes/account/account_edit_page.dart @@ -3,6 +3,7 @@ import "dart:developer"; import "package:flow/data/flow_icon.dart"; import "package:flow/data/money.dart"; +import "package:flow/data/string_multi_filter.dart"; import "package:flow/data/transaction_filter.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/backup_entry.dart"; @@ -640,7 +641,7 @@ class _AccountEditPageState extends State { if (_currentlyEditing == null) return; final TransactionFilter filter = TransactionFilter( - accounts: [_currentlyEditing!.uuid], + accounts: StringMultiFilter.whitelist([_currentlyEditing!.uuid]), ); final int txnCount = TransactionsService().countMany(filter); diff --git a/lib/routes/account_page.dart b/lib/routes/account_page.dart index 59edc6f0..9d4e599a 100644 --- a/lib/routes/account_page.dart +++ b/lib/routes/account_page.dart @@ -2,6 +2,7 @@ import "package:auto_size_text/auto_size_text.dart"; import "package:flow/data/exchange_rates.dart"; import "package:flow/data/multi_currency_flow.dart"; import "package:flow/data/single_currency_flow.dart"; +import "package:flow/data/string_multi_filter.dart"; import "package:flow/data/transaction_filter.dart"; import "package:flow/data/transactions_filter/time_range.dart"; import "package:flow/entity/account.dart"; @@ -63,7 +64,7 @@ class _AccountPageState extends State { bool busy = false; QueryBuilder qb(TimeRange range) => TransactionFilter( - accounts: [account!.uuid], + accounts: StringMultiFilter.whitelist([account!.uuid]), range: TransactionFilterTimeRange.fromTimeRange(range), sortBy: TransactionSortField.transactionDate, sortDescending: true, diff --git a/lib/routes/category/category_edit_page.dart b/lib/routes/category/category_edit_page.dart index dd95823d..48fc8c88 100644 --- a/lib/routes/category/category_edit_page.dart +++ b/lib/routes/category/category_edit_page.dart @@ -1,6 +1,7 @@ import "dart:async"; import "package:flow/data/flow_icon.dart"; +import "package:flow/data/string_multi_filter.dart"; import "package:flow/data/transaction_filter.dart"; import "package:flow/entity/category.dart"; import "package:flow/form_validators.dart"; @@ -180,6 +181,8 @@ class _CategoryEditPageState extends State { ObjectBox().box().putAsync(category, mode: PutMode.insert), ); + context.showToast(text: "category.new.success".t(context)); + context.pop(); } @@ -246,7 +249,7 @@ class _CategoryEditPageState extends State { if (_currentlyEditing == null) return; final TransactionFilter filter = TransactionFilter( - categories: [_currentlyEditing.uuid], + categories: StringMultiFilter.whitelist([_currentlyEditing.uuid]), ); final int txnCount = TransactionsService().countMany(filter); diff --git a/lib/routes/category_page.dart b/lib/routes/category_page.dart index d77ec2a4..4aa18c84 100644 --- a/lib/routes/category_page.dart +++ b/lib/routes/category_page.dart @@ -2,6 +2,7 @@ import "package:auto_size_text/auto_size_text.dart"; import "package:flow/data/exchange_rates.dart"; import "package:flow/data/multi_currency_flow.dart"; import "package:flow/data/single_currency_flow.dart"; +import "package:flow/data/string_multi_filter.dart"; import "package:flow/data/transaction_filter.dart"; import "package:flow/data/transactions_filter/time_range.dart"; import "package:flow/entity/category.dart"; @@ -62,7 +63,7 @@ class _CategoryPageState extends State { QueryBuilder qb(TimeRange range) => TransactionFilter( range: TransactionFilterTimeRange.fromTimeRange(range), - categories: [category!.uuid], + categories: StringMultiFilter.whitelist([category!.uuid]), sortBy: TransactionSortField.transactionDate, sortDescending: true, ).queryBuilder(); diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index 0c116a34..1fcbe63b 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -243,7 +243,7 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { ); final bool shouldCombineTransferIfNeeded = - currentFilter.accounts?.isNotEmpty != true; + currentFilter.accounts == null; final String primaryCurrency = UserPreferencesService().primaryCurrency; diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index baa8b1b6..8a88481f 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -51,6 +51,16 @@ class _ProfileTabState extends State { leading: const Icon(Symbols.category_rounded), onTap: () => context.push("/categories"), ), + // ListTile( + // title: Text("budgets".t(context)), + // leading: const Icon(Symbols.money_bag_rounded), + // onTap: () => context.push("/budgets"), + // ), + // ListTile( + // title: Text("goals".t(context)), + // leading: const Icon(Symbols.savings_rounded), + // onTap: () => context.push("/goals"), + // ), ListTile( title: Text("transaction.tags".t(context)), leading: const Icon(Symbols.style_rounded), diff --git a/lib/routes/transaction_page.dart b/lib/routes/transaction_page.dart index e292fc38..f1791c11 100644 --- a/lib/routes/transaction_page.dart +++ b/lib/routes/transaction_page.dart @@ -32,6 +32,7 @@ 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"; import "package:flow/routes/transaction_page/title_input.dart"; import "package:flow/services/accounts.dart"; @@ -866,7 +867,6 @@ class _TransactionPageState extends State { await showModalBottomSheet>( context: context, builder: (context) => SelectCategorySheet( - categories: categories, currentlySelectedCategoryId: _selectedCategory?.id, showTrailing: widget.isNewTransaction, ), @@ -1001,12 +1001,17 @@ class _TransactionPageState extends State { return true; } - void _setupRecurring() { - _recurrence ??= Recurrence( - range: transactionDate.rangeToMax(), - rules: [MonthlyRecurrenceRule(day: transactionDate.day)], + void _setupRecurring() async { + final Recurrence? result = await showModalBottomSheet( + context: context, + builder: (context) => SelectRecurrenceSheet( + initialValue: _recurrence, + startBounds: transactionDate.rangeToMax(), + ), ); + _recurrence ??= result; + setState(() {}); } diff --git a/lib/routes/transaction_page/select_category_sheet.dart b/lib/routes/transaction_page/select_category_sheet.dart index d22c9df4..58cc7a97 100644 --- a/lib/routes/transaction_page/select_category_sheet.dart +++ b/lib/routes/transaction_page/select_category_sheet.dart @@ -1,3 +1,4 @@ +import "package:flow/providers/categories_provider.dart"; import "package:flow/entity/category.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/utils/optional.dart"; @@ -13,7 +14,6 @@ import "package:material_symbols_icons/symbols.dart"; /// Pops with [ValueOr] class SelectCategorySheet extends StatefulWidget { - final List categories; final int? currentlySelectedCategoryId; /// Defaults to [true] when there are more than 6 categories. @@ -23,7 +23,6 @@ class SelectCategorySheet extends StatefulWidget { const SelectCategorySheet({ super.key, - required this.categories, this.currentlySelectedCategoryId, this.showSearchBar, this.showTrailing = true, @@ -38,16 +37,20 @@ class _SelectCategorySheetState extends State { @override Widget build(BuildContext context) { - final bool showSearchBar = - widget.showSearchBar ?? widget.categories.length > 6; - - final List results = simpleSortByQuery(widget.categories, _query); + final List categories = CategoriesProvider.of(context).categories; + final bool showSearchBar = widget.showSearchBar ?? categories.length > 6; + final List results = simpleSortByQuery(categories, _query); return ModalSheet.scrollable( title: Text("transaction.edit.selectCategory".t(context)), trailing: ModalOverflowBar( alignment: .end, children: [ + TextButton.icon( + onPressed: () => context.push("/category/new"), + icon: const Icon(Symbols.add_rounded), + label: Text("general.new".t(context)), + ), TextButton.icon( onPressed: () => context.pop(const Optional(null)), icon: const Icon(Symbols.block_rounded, fill: 0.0), diff --git a/lib/routes/transaction_page/select_recurrence_sheet.dart b/lib/routes/transaction_page/select_recurrence_sheet.dart new file mode 100644 index 00000000..c14cb5c1 --- /dev/null +++ b/lib/routes/transaction_page/select_recurrence_sheet.dart @@ -0,0 +1,58 @@ +import "package:flow/l10n/extensions.dart"; +import "package:flow/routes/transaction_page/select_recurrence.dart"; +import "package:flow/widgets/general/modal_overflow_bar.dart"; +import "package:flow/widgets/general/modal_sheet.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons/symbols.dart"; +import "package:moment_dart/moment_dart.dart"; +import "package:recurrence/recurrence.dart"; + +/// Pops with a [Recurrence] if the user saves, otherwise pops with `null` +class SelectRecurrenceSheet extends StatefulWidget { + final Recurrence? initialValue; + + final TimeRange? startBounds; + + const SelectRecurrenceSheet({super.key, this.initialValue, this.startBounds}); + + @override + State createState() => _SelectRecurrenceSheetState(); +} + +class _SelectRecurrenceSheetState extends State { + Recurrence? _recurrence; + + @override + void initState() { + super.initState(); + _recurrence = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + return ModalSheet.scrollable( + title: Text("transaction.recurring.setup".t(context)), + trailing: ModalOverflowBar( + alignment: .end, + children: [ + TextButton.icon( + onPressed: () => context.pop(), + icon: const Icon(Symbols.close_rounded), + label: Text("general.cancel".t(context)), + ), + TextButton.icon( + onPressed: () => context.pop(_recurrence), + icon: const Icon(Symbols.check_rounded), + label: Text("general.save".t(context)), + ), + ], + ), + child: SelectRecurrence( + onChanged: (value) => setState(() => _recurrence = value), + startBounds: widget.startBounds, + initialValue: widget.initialValue, + ), + ); + } +} diff --git a/lib/routes/transaction_tag_page.dart b/lib/routes/transaction_tag_page.dart index b8f30093..35dd5a94 100644 --- a/lib/routes/transaction_tag_page.dart +++ b/lib/routes/transaction_tag_page.dart @@ -2,6 +2,7 @@ import "dart:io"; import "package:flow/constants.dart"; import "package:flow/data/flow_icon.dart"; +import "package:flow/data/string_multi_filter.dart"; import "package:flow/data/transaction_filter.dart"; import "package:flow/entity/transaction/tag_type.dart"; import "package:flow/entity/transaction_tag.dart"; @@ -478,7 +479,7 @@ class _TransactionTagPageState extends State { if (_currentlyEditing == null) return; final TransactionFilter filter = TransactionFilter( - tags: [_currentlyEditing!.uuid], + tags: StringMultiFilter.whitelist([_currentlyEditing!.uuid]), ); final int txnCount = TransactionsService().countMany(filter); diff --git a/lib/services/accounts.dart b/lib/services/accounts.dart index c2a8d7ee..b0cee333 100644 --- a/lib/services/accounts.dart +++ b/lib/services/accounts.dart @@ -24,6 +24,11 @@ class AccountsService { return ObjectBox().box().getAllAsync(); } + List getAllUuidsSync() { + final List accounts = ObjectBox().box().getAll(); + return accounts.map((account) => account.uuid).toList(); + } + Future findOne(dynamic identifier) async { if (identifier is int) { return await getOne(identifier); diff --git a/lib/services/budget.dart b/lib/services/budget.dart new file mode 100644 index 00000000..c88ecab0 --- /dev/null +++ b/lib/services/budget.dart @@ -0,0 +1,9 @@ +class BudgetService { + static BudgetService? _instance; + + factory BudgetService() => _instance ??= BudgetService._internal(); + + BudgetService._internal() { + // Constructor + } +} diff --git a/lib/services/categories.dart b/lib/services/categories.dart index 64a40d86..30ad824f 100644 --- a/lib/services/categories.dart +++ b/lib/services/categories.dart @@ -22,6 +22,11 @@ class CategoriesService { return ObjectBox().box().get(id); } + List getAllUuidsSync() { + final List categories = ObjectBox().box().getAll(); + return categories.map((category) => category.uuid).toList(); + } + Future> getAll() async { return ObjectBox().box().getAllAsync(); } diff --git a/lib/services/integrations/eny.dart b/lib/services/integrations/eny.dart index 1adffc90..b9b826ce 100644 --- a/lib/services/integrations/eny.dart +++ b/lib/services/integrations/eny.dart @@ -311,19 +311,21 @@ class EnyService { if (UserPreferencesService().createTransactionsPerItemInScans) { final TransactionMultiProgrammableObject? parsed = TransactionMultiProgrammableObject.fromEnyJson(enySuccessResult); - parsed?.save( - extensions: [ - EnyReceipt( - uuid: const Uuid().v4(), - enyImageUrl: enySuccessResult["imageUrl"] as String?, - enyReceiptId: id, - partOfMultiTransaction: true, - ), - ], - isPendingOverride: markPendingAllTransactions - ? true - : shouldMarkPending(parsed.t.firstOrNull?.transactionDate), - ); + for (final transaction in parsed?.t ?? []) { + transaction?.save( + extensions: [ + EnyReceipt( + uuid: const Uuid().v4(), + enyImageUrl: enySuccessResult["imageUrl"] as String?, + enyReceiptId: id, + partOfMultiTransaction: true, + ), + ], + isPendingOverride: markPendingAllTransactions + ? true + : shouldMarkPending(transaction.transactionDate), + ); + } completed = parsed != null && parsed.t.isNotEmpty; succeeded = completed; diff --git a/lib/services/transaction_tag.dart b/lib/services/transaction_tag.dart new file mode 100644 index 00000000..082a2505 --- /dev/null +++ b/lib/services/transaction_tag.dart @@ -0,0 +1,32 @@ +import "package:flow/entity/transaction_tag.dart"; +import "package:flow/objectbox.dart"; + +class TransactionTagService { + static TransactionTagService? _instance; + + factory TransactionTagService() => + _instance ??= TransactionTagService._internal(); + + TransactionTagService._internal() { + // Constructor + } + + Future getOne(int id) async { + return ObjectBox().box().getAsync(id); + } + + TransactionTag? getOneSync(int id) { + return ObjectBox().box().get(id); + } + + List getAllUuidsSync() { + final List transactionTags = ObjectBox() + .box() + .getAll(); + return transactionTags.map((tag) => tag.uuid).toList(); + } + + Future> getAll() async { + return ObjectBox().box().getAllAsync(); + } +} diff --git a/lib/sync/export/export_pdf.dart b/lib/sync/export/export_pdf.dart index 5a9e07bb..8e0a0411 100644 --- a/lib/sync/export/export_pdf.dart +++ b/lib/sync/export/export_pdf.dart @@ -70,9 +70,13 @@ Future generatePDFContent({ }; final TransactionFilter filter = TransactionFilter( - accounts: options.whitelistedAccounts - ?.map((account) => account.uuid) - .toList(), + accounts: options.whitelistedAccounts == null + ? null + : .whitelist( + options.whitelistedAccounts! + .map((account) => account.uuid) + .toList(), + ), range: TransactionFilterTimeRange.fromTimeRange(options.timeRange), isPending: false, includeDeleted: false, diff --git a/lib/utils/extensions/transaction_filter.dart b/lib/utils/extensions/transaction_filter.dart index 7e04a012..b44b3465 100644 --- a/lib/utils/extensions/transaction_filter.dart +++ b/lib/utils/extensions/transaction_filter.dart @@ -6,12 +6,21 @@ import "package:flow/entity/transaction/type.dart"; import "package:flow/entity/transaction_tag.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/l10n/named_enum.dart"; +import "package:flow/services/accounts.dart"; +import "package:flow/services/categories.dart"; import "package:flow/utils/time_and_range.dart"; import "package:flutter/material.dart"; import "package:moment_dart/moment_dart.dart"; extension TransactionFilterHelpers on TransactionFilter { String summary(BuildContext context) { + final int? accountsCount = accounts + ?.filter(AccountsService().getAllUuidsSync()) + .length; + final int? categoriesCount = categories + ?.filter(CategoriesService().getAllUuidsSync()) + .length; + final List parts = []; if (searchData.keyword != null && searchData.keyword!.isNotEmpty) { parts.add('"${searchData.keyword}"'); @@ -25,15 +34,15 @@ extension TransactionFilterHelpers on TransactionFilter { ), ); - if (accounts?.isNotEmpty == true) { + if (accountsCount != null) { parts.add( - "transactions.query.filter.accounts.n".t(context, accounts!.length), + "transactions.query.filter.accounts.n".t(context, accountsCount), ); } - if (categories?.isNotEmpty == true) { + if (categoriesCount != null) { parts.add( - "transactions.query.filter.categories.n".t(context, categories!.length), + "transactions.query.filter.categories.n".t(context, categoriesCount), ); } diff --git a/lib/widgets/default_transaction_filter_head.dart b/lib/widgets/default_transaction_filter_head.dart index 637df808..d96e2565 100644 --- a/lib/widgets/default_transaction_filter_head.dart +++ b/lib/widgets/default_transaction_filter_head.dart @@ -14,7 +14,6 @@ import "package:flow/providers/accounts_provider.dart"; import "package:flow/providers/categories_provider.dart"; import "package:flow/providers/transaction_tags_provider.dart"; import "package:flow/services/currency_registry.dart"; -import "package:flow/utils/extensions.dart"; import "package:flow/utils/optional.dart"; import "package:flow/widgets/sheets/select_multi_currency_sheet.dart"; import "package:flow/widgets/sheets/select_multi_transaction_type_sheet.dart"; @@ -173,24 +172,15 @@ class _DefaultTransactionsFilterHeadState avatar: const Icon(Symbols.wallet_rounded), onSelect: onSelectAccounts, defaultValue: widget.defaultFilter.accounts - ?.map( - (uuid) => activeAccounts.firstWhere( - (account) => account.uuid == uuid, - ), + ?.mappedFilter(activeAccounts, (account) => account.uuid) + .toSet(), + value: _filter.accounts + ?.mappedFilter( + AccountsProvider.of(context).activeAccounts, + (account) => account.uuid, ) + .nonNulls .toSet(), - value: _filter.accounts?.isNotEmpty == true - ? _filter.accounts - ?.map( - (uuid) => AccountsProvider.of(context) - .activeAccounts - .firstWhereOrNull( - (account) => account.uuid == uuid, - ), - ) - .nonNulls - .toSet() - : null, ), if (categories != null) TransactionFilterChip>( @@ -198,22 +188,11 @@ class _DefaultTransactionsFilterHeadState avatar: const Icon(Symbols.category_rounded), onSelect: onSelectCategories, defaultValue: widget.defaultFilter.categories - ?.map( - (uuid) => categories.firstWhere( - (category) => category.uuid == uuid, - ), - ) + ?.mappedFilter(categories, (category) => category.uuid) + .toSet(), + value: _filter.categories + ?.mappedFilter(categories, (category) => category.uuid) .toSet(), - value: _filter.categories?.isNotEmpty == true - ? _filter.categories - ?.map( - (uuid) => categories.firstWhereOrNull( - (category) => category.uuid == uuid, - ), - ) - .nonNulls - .toSet() - : null, ), if (tags != null && tags.isNotEmpty == true) TransactionFilterChip>( @@ -221,20 +200,11 @@ class _DefaultTransactionsFilterHeadState avatar: const Icon(Symbols.style_rounded), onSelect: onSelectTags, defaultValue: widget.defaultFilter.tags - ?.map( - (uuid) => tags.firstWhere((tag) => tag.uuid == uuid), - ) + ?.mappedFilter(tags, (tag) => tag.uuid) + .toSet(), + value: _filter.tags + ?.mappedFilter(tags, (tag) => tag.uuid) .toSet(), - value: _filter.tags?.isNotEmpty == true - ? _filter.tags - ?.map( - (uuid) => tags.firstWhereOrNull( - (tag) => tag.uuid == uuid, - ), - ) - .nonNulls - .toSet() - : null, ), TransactionFilterChip( translationKey: "transactions.query.filter.hasAttachments", @@ -300,11 +270,17 @@ class _DefaultTransactionsFilterHeadState } void onSelectAccounts() async { + final List? allActiveAccounts = AccountsProvider.of(context).ready + ? AccountsProvider.of(context).activeAccounts + : null; + final List? accounts = await showModalBottomSheet>( context: context, builder: (context) => SelectMultiAccountSheet( accounts: ObjectBox().getAccounts(), - selectedUuids: filter.accounts, + selectedUuids: filter.accounts?.filter( + allActiveAccounts?.map((account) => account.uuid).toList() ?? [], + ), ), isScrollControlled: true, ); @@ -312,19 +288,27 @@ class _DefaultTransactionsFilterHeadState if (accounts != null) { setState(() { filter = filter.copyWithOptional( - accounts: Optional(accounts.map((account) => account.uuid).toList()), + accounts: Optional( + .whitelist(accounts.map((account) => account.uuid).toList()), + ), ); }); } } void onSelectCategories() async { + final List? allCategories = CategoriesProvider.of(context).ready + ? CategoriesProvider.of(context).categories + : null; + final List? categories = await showModalBottomSheet>( context: context, builder: (context) => SelectMultiCategorySheet( categories: ObjectBox().getCategories(), - selectedUuids: filter.categories, + selectedUuids: filter.categories?.filter( + allCategories?.map((category) => category.uuid).toList() ?? [], + ), ), isScrollControlled: true, ); @@ -333,7 +317,7 @@ class _DefaultTransactionsFilterHeadState setState(() { filter = filter.copyWithOptional( categories: Optional( - categories.map((category) => category.uuid).toList(), + .whitelist(categories.map((category) => category.uuid).toList()), ), ); }); @@ -350,7 +334,9 @@ class _DefaultTransactionsFilterHeadState context: context, builder: (context) => SelectTransactionTagsSheet( tags: allTags, - initialTagUuids: filter.tags, + initialTagUuids: filter.tags?.filter( + allTags.map((tag) => tag.uuid).toList(), + ), ), isScrollControlled: true, ); @@ -358,7 +344,7 @@ class _DefaultTransactionsFilterHeadState if (tags != null) { setState(() { filter = filter.copyWithOptional( - tags: Optional(tags.map((tag) => tag.uuid).toList()), + tags: Optional(.whitelist(tags.map((tag) => tag.uuid).toList())), ); }); } diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index 0104e489..08ceac8b 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -83,12 +83,14 @@ class TransactionListTile extends StatelessWidget { return Container(); } - final String resolvedTitle = - transaction.title ?? + final String resolvedTitle = switch (transaction.title) { + String title when title.isNotEmpty => title, + _ => ((effectiveTheme.useCategoryNameForUntitledTransactionsOrDefault ? transaction.category.target?.name : null) ?? - "transaction.fallbackTitle".t(context)); + "transaction.fallbackTitle".t(context)), + }; final Transfer? transfer = transaction.isTransfer ? transaction.extensions.transfer diff --git a/pubspec.yaml b/pubspec.yaml index b2c194bf..169964fc 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.19.0+326" +version: "0.19.1+328" environment: sdk: ">=3.10.0 <4.0.0" diff --git a/test/playground.dart b/test/playground.dart new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/playground.dart @@ -0,0 +1 @@ + diff --git a/test/unit/data/multi_filter_test.dart b/test/unit/data/multi_filter_test.dart new file mode 100644 index 00000000..01d67a2a --- /dev/null +++ b/test/unit/data/multi_filter_test.dart @@ -0,0 +1,110 @@ +import "package:flow/data/multi_filter.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + test("MultiFilter filter method works correctly", () { + final filter = MultiFilter.whitelist([1, 2, 3]); + final input = [1, 2, 3, 4, 5]; + final result = filter.filter(input); + expect(result, [1, 2, 3]); + }); + + test("MultiFilter blacklist works correctly", () { + final filter = MultiFilter.blacklist([1, 2, 3]); + final input = [1, 2, 3, 4, 5]; + final result = filter.filter(input); + expect(result, [4, 5]); + }); + + test("MultiFilter with custom comparer works correctly", () { + final filter = MultiFilter.whitelist([ + "run", + ], comparer: (a, b) => a.length == b.length); + final input = ["cat", "dog", "apple", "banana"]; + final result = filter.filter(input); + expect(result, ["cat", "dog"]); + }); + + test( + "MultiFilter with empty items should return all input for whitelist", + () { + final filter = MultiFilter.whitelist([]); + final input = [1, 2, 3]; + final result = filter.filter(input); + expect(result, []); + }, + ); + + test( + "MultiFilter with empty items should return all input for blacklist", + () { + final filter = MultiFilter.blacklist([]); + final input = [1, 2, 3]; + final result = filter.filter(input); + expect(result, [1, 2, 3]); + }, + ); + + test("MultiFilter with all items should return empty for whitelist", () { + final filter = MultiFilter.whitelist([1, 2, 3]); + final input = [1, 2, 3]; + final result = filter.filter(input); + expect(result, [1, 2, 3]); + }); + + test("MultiFilter with all items should return empty for blacklist", () { + final filter = MultiFilter.blacklist([1, 2, 3]); + final input = [1, 2, 3]; + final result = filter.filter(input); + expect(result, []); + }); + + test("MultiFilter with duplicate items in whitelist", () { + final filter = MultiFilter.whitelist([1, 1, 2, 2, 3]); + final input = [1, 2, 3, 4, 5]; + final result = filter.filter(input); + expect(result, [1, 2, 3]); + }); + + test("MultiFilter with duplicate items in blacklist", () { + final filter = MultiFilter.blacklist([1, 1, 2, 2, 3]); + final input = [1, 2, 3, 4, 5]; + final result = filter.filter(input); + expect(result, [4, 5]); + }); + + test("MultiFilter set to keep nothing", () { + final filter = MultiFilter.keepNothing(); + final input = [1, 2, 3, 4, 5]; + final result = filter.filter(input); + expect(result, []); + }); + + test("MultiFilter set to keep everything", () { + final filter = MultiFilter.keepEverything(); + final input = [1, 2, 3, 4, 5]; + final result = filter.filter(input); + expect(result, [1, 2, 3, 4, 5]); + }); + + test("MultiFilter with empty input list", () { + final filter = MultiFilter.whitelist([1, 2, 3]); + final input = []; + final result = filter.filter(input); + expect(result, []); + }); + + test("MultiFilter with mixed types in input and whitelist", () { + final filter = MultiFilter.whitelist([1, "two", 3]); + final input = [1, "two", 3, 4, "five"]; + final result = filter.filter(input); + expect(result, [1, "two", 3]); + }); + + test("MultiFilter with mixed types in input and blacklist", () { + final filter = MultiFilter.blacklist([1, "two", 3]); + final input = [1, "two", 3, 4, "five"]; + final result = filter.filter(input); + expect(result, [4, "five"]); + }); +} diff --git a/test/unit/utils/money_parsing_test.dart b/test/unit/utils/money_parsing_test.dart index 0fce6c96..f9cb67d2 100644 --- a/test/unit/utils/money_parsing_test.dart +++ b/test/unit/utils/money_parsing_test.dart @@ -13,6 +13,9 @@ void main() { Intl.defaultLocale = "en_US"; expect(parseMoneyString(text: "€1.234,56"), 1234.56); expect(parseMoneyString(text: " 7890 "), 7890.0); + expect(parseMoneyString(text: "0.01"), 0.01); + expect(parseMoneyString(text: "-50.99"), -50.99); + expect(parseMoneyString(text: "999999.99"), 999999.99); }); test("Invalid money strings return null", () { @@ -20,6 +23,55 @@ void main() { expect(parseMoneyString(text: ""), null); expect(parseMoneyString(text: null), null); expect(parseMoneyString(text: "12.34.56"), null); + expect(parseMoneyString(text: "12.34.56.90"), null); + expect(parseMoneyString(text: r"$$$"), null); + }); + + test("Edge cases with different formats", () { + expect(parseMoneyString(text: "0"), 0.0); + expect(parseMoneyString(text: ".50"), 0.50); + expect(parseMoneyString(text: "100."), 100.0); + Intl.defaultLocale = "de_DE"; + expect(parseMoneyString(text: "1.234,56"), 1234.56); + expect(parseMoneyString(text: ",56"), 0.56); + Intl.defaultLocale = "en_US"; + }); + + test("Locale specific parsing for European formats", () { + Intl.defaultLocale = "de_DE"; + expect(parseMoneyString(text: "1.234.567,89"), 1234567.89); + expect(parseMoneyString(text: "999,99"), 999.99); + Intl.defaultLocale = "fr_FR"; + expect(parseMoneyString(text: "1 234,56"), 1234.56); + expect(parseMoneyString(text: "50,5"), 50.5); + Intl.defaultLocale = "it_IT"; + expect(parseMoneyString(text: "1.234,56"), 1234.56); + Intl.defaultLocale = "en_US"; + }); + + test("Locale specific parsing for Asian formats", () { + Intl.defaultLocale = "ja_JP"; + expect(parseMoneyString(text: "1,234.56"), 1234.56); + Intl.defaultLocale = "zh_CN"; + expect(parseMoneyString(text: "1,234.56"), 1234.56); + Intl.defaultLocale = "en_US"; + expect(parseMoneyString(text: "1,234.56"), 1234.56); + }); + + test("Locale specific parsing for Arabic formats", () { + Intl.defaultLocale = "ar_EG"; + expect(parseMoneyString(text: "1.234,56"), 1234.56); + expect(parseMoneyString(text: "999.99"), 999.99); + Intl.defaultLocale = "ar_SA"; + expect(parseMoneyString(text: "1,234.56"), 1234.56); + Intl.defaultLocale = "en_US"; + }); + + test("Locale specific parsing for Persian formats", () { + Intl.defaultLocale = "fa_IR"; + expect(parseMoneyString(text: "1,234.56"), 1234.56); + expect(parseMoneyString(text: "999.99"), 999.99); + Intl.defaultLocale = "en_US"; }); }); }