From bc51609f8537f9b42bebb3197bb0bba90d3f1516 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 28 Jun 2026 02:19:57 +0300 Subject: [PATCH] step big --- lib/app/router.dart | 5 + lib/core/constants.dart | 90 +++ lib/core/l10n/app_strings.dart | 33 + lib/data/database/app_database.dart | 46 +- lib/data/database/app_database.g.dart | 754 ++++-------------- lib/data/database/tables.dart | 13 +- .../repositories/category_repository.dart | 60 ++ lib/features/add_transaction/provider.dart | 7 +- .../widgets/category_picker.dart | 142 ++-- lib/features/categories/screen.dart | 199 +---- .../categories/widgets/stats_hero_card.dart | 138 ---- lib/features/dashboard/provider.dart | 30 - lib/features/dashboard/screen.dart | 12 - .../dashboard/widgets/budget_progress.dart | 187 ----- .../dashboard/widgets/transaction_tile.dart | 10 +- .../categories/category_editor_sheet.dart | 617 ++++++++++++++ .../categories/category_manager_screen.dart | 268 +++++++ lib/features/settings/provider.dart | 28 - lib/features/settings/screen.dart | 15 +- .../settings/widgets/budget_section.dart | 219 ----- .../settings/widgets/categories_section.dart | 75 ++ .../settings/widgets/currency_section.dart | 5 - lib/shared/models/app_category.dart | 31 + lib/shared/providers/category_provider.dart | 173 ++++ lib/shared/services/storage_service.dart | 18 - lib/shared/services/translation_service.dart | 107 +++ 26 files changed, 1757 insertions(+), 1525 deletions(-) create mode 100644 lib/data/repositories/category_repository.dart delete mode 100644 lib/features/categories/widgets/stats_hero_card.dart delete mode 100644 lib/features/dashboard/widgets/budget_progress.dart create mode 100644 lib/features/settings/categories/category_editor_sheet.dart create mode 100644 lib/features/settings/categories/category_manager_screen.dart delete mode 100644 lib/features/settings/widgets/budget_section.dart create mode 100644 lib/features/settings/widgets/categories_section.dart create mode 100644 lib/shared/models/app_category.dart create mode 100644 lib/shared/providers/category_provider.dart create mode 100644 lib/shared/services/translation_service.dart diff --git a/lib/app/router.dart b/lib/app/router.dart index e3f8ebb..10b0b2d 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -6,6 +6,7 @@ import '../features/dashboard/screen.dart'; import '../features/add_transaction/screen.dart'; import '../features/categories/screen.dart'; import '../features/settings/screen.dart'; +import '../features/settings/categories/category_manager_screen.dart'; import '../shared/models/transaction.dart'; final _shellKey = GlobalKey(); @@ -44,6 +45,10 @@ final appRouter = GoRouter( return AddTransactionScreen(initial: transaction); }, ), + GoRoute( + path: '/settings/categories', + builder: (context, state) => const CategoryManagerScreen(), + ), ], ); diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 6147868..dd51f45 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -71,8 +71,98 @@ class AppCategories { 'Refund': Color(0xFFB4FF69), 'Other': Color(0xFFB469FF), }; + + static const iconNames = { + 'Food': 'restaurant', + 'Transport': 'car', + 'Shopping': 'shopping_bag', + 'Health': 'heart', + 'Entertainment': 'movie', + 'Salary': 'work', + 'Freelance': 'laptop', + 'Gift': 'gift', + 'Investment': 'trending_up', + 'Refund': 'money', + 'Other': 'category', + }; + + static const ruLabels = { + 'Food': 'Еда', + 'Transport': 'Транспорт', + 'Shopping': 'Покупки', + 'Entertainment': 'Развлечения', + 'Health': 'Здоровье', + 'Housing': 'Жильё', + 'Education': 'Образование', + 'Travel': 'Путешествия', + 'Salary': 'Зарплата', + 'Freelance': 'Фриланс', + 'Investment': 'Инвестиции', + 'Gift': 'Подарок', + 'Refund': 'Возврат', + 'Other': 'Другое', + 'Utilities': 'Коммунальные', + 'Clothing': 'Одежда', + 'Sports': 'Спорт', + 'Beauty': 'Красота', + 'Pets': 'Питомцы', + 'Business': 'Бизнес', + 'Savings': 'Накопления', + }; } +const Map kCategoryIcons = { + 'restaurant': Icons.restaurant_rounded, + 'car': Icons.directions_car_rounded, + 'shopping_bag': Icons.shopping_bag_rounded, + 'heart': Icons.favorite_rounded, + 'movie': Icons.movie_rounded, + 'work': Icons.work_rounded, + 'laptop': Icons.laptop_rounded, + 'gift': Icons.card_giftcard_rounded, + 'trending_up': Icons.trending_up_rounded, + 'money': Icons.payments_rounded, + 'category': Icons.category_rounded, + 'home': Icons.home_rounded, + 'school': Icons.school_rounded, + 'flight': Icons.flight_rounded, + 'fitness': Icons.fitness_center_rounded, + 'pets': Icons.pets_rounded, + 'coffee': Icons.local_cafe_rounded, + 'grocery': Icons.local_grocery_store_rounded, + 'phone': Icons.smartphone_rounded, + 'bolt': Icons.bolt_rounded, + 'water': Icons.water_drop_rounded, + 'savings': Icons.savings_rounded, + 'card': Icons.credit_card_rounded, + 'games': Icons.sports_esports_rounded, + 'music': Icons.music_note_rounded, + 'book': Icons.menu_book_rounded, + 'medical': Icons.medical_services_rounded, + 'child': Icons.child_care_rounded, + 'build': Icons.build_rounded, + 'beauty': Icons.spa_rounded, +}; + +IconData categoryIconByName(String? name) { + return kCategoryIcons[name] ?? Icons.category_rounded; +} + +const List kCategoryColors = [ + Color(0xFFFF8C69), + Color(0xFF69B4FF), + Color(0xFFFFD369), + Color(0xFF69FFB4), + Color(0xFFFF69B4), + Color(0xFF4CAF8C), + Color(0xFFFFB469), + Color(0xFFB4FF69), + Color(0xFFB469FF), + Color(0xFF7C6DED), + Color(0xFFE05C6B), + Color(0xFF4DD0E1), +]; + enum AmountFormat { commasDot, spacesDot, plain } extension AmountFormatExt on AmountFormat { diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 341daad..4b2c436 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -169,6 +169,7 @@ class AppStrings { 'Freelance': 'Фриланс', 'Investment': 'Инвестиции', 'Gift': 'Подарок', + 'Refund': 'Возврат', 'Other': 'Другое', 'Utilities': 'Коммунальные', 'Clothing': 'Одежда', @@ -220,5 +221,37 @@ class AppStrings { String get accountPlaceholder => _ru ? 'Счёт' : 'Account'; String get saveError => _ru ? 'Ошибка сохранения' : 'Save error'; + String get manageCategories => _ru ? 'Категории' : 'Categories'; + String get manageCategoriesSubtitle => _ru + ? 'Создавайте и редактируйте свои категории' + : 'Create and edit your own categories'; + String get customCategories => _ru ? 'Свои категории' : 'Custom categories'; + String get defaultCategories => + _ru ? 'Стандартные категории' : 'Default categories'; + String get newCategory => _ru ? 'Новая категория' : 'New category'; + String get nameEn => _ru ? 'Название (EN)' : 'Name (EN)'; + String get nameRu => _ru ? 'Название (RU)' : 'Name (RU)'; + String get nameEnHint => _ru ? 'Например, Coffee' : 'e.g. Coffee'; + String get nameRuHint => _ru ? 'Например, Кофе' : 'e.g. Кофе'; + String get autoTranslate => _ru ? 'Автоперевод' : 'Auto-translate'; + String get applyTranslation => _ru ? 'Применить' : 'Apply'; + String get translating => _ru ? 'Перевод...' : 'Translating...'; + String get translationFailed => + _ru ? 'Не удалось перевести' : 'Translation failed'; + String get categoryNameRequired => + _ru ? 'Введите название' : 'Enter a name'; + String get deleteCategoryConfirm => + _ru ? 'Удалить эту категорию?' : 'Delete this category?'; + String get deleteCategoryWarning => _ru + ? 'Категория будет удалена. Прошлые транзакции сохранятся.' + : 'The category will be removed. Past transactions are kept.'; + String get noCustomCategories => + _ru ? 'Вы ещё не добавили категории' : 'No custom categories yet'; + String get noCustomCategoriesHint => _ru + ? 'Нажмите +, чтобы создать свою категорию' + : 'Tap + to create your own category'; + String get categoryType => _ru ? 'Тип' : 'Type'; + String get categorySaved => _ru ? 'Категория сохранена' : 'Category saved'; + String get dateLocale => _ru ? 'ru_RU' : 'en_US'; } diff --git a/lib/data/database/app_database.dart b/lib/data/database/app_database.dart index 72b7b36..8d19205 100644 --- a/lib/data/database/app_database.dart +++ b/lib/data/database/app_database.dart @@ -8,12 +8,12 @@ import 'tables.dart'; part 'app_database.g.dart'; -@DriftDatabase(tables: [Transactions, Categories, Budgets, ExchangeRates, Accounts]) +@DriftDatabase(tables: [Transactions, Categories, ExchangeRates, Accounts]) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 5; + int get schemaVersion => 6; @override MigrationStrategy get migration => MigrationStrategy( @@ -50,6 +50,28 @@ class AppDatabase extends _$AppDatabase { print('Migration: Error adding account_id column: $e'); } } + + if (from < 6) { + await customStatement('DROP TABLE IF EXISTS budgets'); + try { + final columns = await customSelect( + 'PRAGMA table_info(categories)', + ).get(); + final names = columns.map((row) => row.data['name']).toSet(); + if (!names.contains('label_en')) { + await customStatement( + 'ALTER TABLE categories ADD COLUMN label_en TEXT', + ); + } + if (!names.contains('label_ru')) { + await customStatement( + 'ALTER TABLE categories ADD COLUMN label_ru TEXT', + ); + } + } catch (e) { + print('Migration: Error updating categories table: $e'); + } + } }, ); @@ -149,6 +171,12 @@ class AppDatabase extends _$AppDatabase { return select(categories).get(); } + Stream> watchAllCategories() { + return (select(categories) + ..orderBy([(c) => OrderingTerm.asc(c.createdAt)])) + .watch(); + } + Future> getCategoriesByType(String type) { return (select(categories)..where((c) => c.type.equals(type))).get(); } @@ -165,20 +193,6 @@ class AppDatabase extends _$AppDatabase { return (delete(categories)..where((c) => c.id.equals(id))).go(); } - Future getBudget(int month, int year) { - return (select(budgets) - ..where((b) => b.month.equals(month) & b.year.equals(year))) - .getSingleOrNull(); - } - - Future upsertBudget(BudgetsCompanion budget) { - return into(budgets).insertOnConflictUpdate(budget); - } - - Future deleteBudget(int id) { - return (delete(budgets)..where((b) => b.id.equals(id))).go(); - } - Future getExchangeRate(String from, String to) { return (select(exchangeRates) ..where((r) => r.fromCurrency.equals(from) & r.toCurrency.equals(to))) diff --git a/lib/data/database/app_database.g.dart b/lib/data/database/app_database.g.dart index d40031a..62bb944 100644 --- a/lib/data/database/app_database.g.dart +++ b/lib/data/database/app_database.g.dart @@ -745,6 +745,28 @@ class $CategoriesTable extends Categories type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _labelEnMeta = const VerificationMeta( + 'labelEn', + ); + @override + late final GeneratedColumn labelEn = GeneratedColumn( + 'label_en', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _labelRuMeta = const VerificationMeta( + 'labelRu', + ); + @override + late final GeneratedColumn labelRu = GeneratedColumn( + 'label_ru', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _iconMeta = const VerificationMeta('icon'); @override late final GeneratedColumn icon = GeneratedColumn( @@ -795,6 +817,8 @@ class $CategoriesTable extends Categories id, name, type, + labelEn, + labelRu, icon, color, isDefault, @@ -831,6 +855,18 @@ class $CategoriesTable extends Categories } else if (isInserting) { context.missing(_typeMeta); } + if (data.containsKey('label_en')) { + context.handle( + _labelEnMeta, + labelEn.isAcceptableOrUnknown(data['label_en']!, _labelEnMeta), + ); + } + if (data.containsKey('label_ru')) { + context.handle( + _labelRuMeta, + labelRu.isAcceptableOrUnknown(data['label_ru']!, _labelRuMeta), + ); + } if (data.containsKey('icon')) { context.handle( _iconMeta, @@ -876,6 +912,14 @@ class $CategoriesTable extends Categories DriftSqlType.string, data['${effectivePrefix}type'], )!, + labelEn: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}label_en'], + ), + labelRu: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}label_ru'], + ), icon: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}icon'], @@ -905,6 +949,8 @@ class Category extends DataClass implements Insertable { final int id; final String name; final String type; + final String? labelEn; + final String? labelRu; final String? icon; final String? color; final bool isDefault; @@ -913,6 +959,8 @@ class Category extends DataClass implements Insertable { required this.id, required this.name, required this.type, + this.labelEn, + this.labelRu, this.icon, this.color, required this.isDefault, @@ -924,6 +972,12 @@ class Category extends DataClass implements Insertable { map['id'] = Variable(id); map['name'] = Variable(name); map['type'] = Variable(type); + if (!nullToAbsent || labelEn != null) { + map['label_en'] = Variable(labelEn); + } + if (!nullToAbsent || labelRu != null) { + map['label_ru'] = Variable(labelRu); + } if (!nullToAbsent || icon != null) { map['icon'] = Variable(icon); } @@ -940,6 +994,12 @@ class Category extends DataClass implements Insertable { id: Value(id), name: Value(name), type: Value(type), + labelEn: labelEn == null && nullToAbsent + ? const Value.absent() + : Value(labelEn), + labelRu: labelRu == null && nullToAbsent + ? const Value.absent() + : Value(labelRu), icon: icon == null && nullToAbsent ? const Value.absent() : Value(icon), color: color == null && nullToAbsent ? const Value.absent() @@ -958,6 +1018,8 @@ class Category extends DataClass implements Insertable { id: serializer.fromJson(json['id']), name: serializer.fromJson(json['name']), type: serializer.fromJson(json['type']), + labelEn: serializer.fromJson(json['labelEn']), + labelRu: serializer.fromJson(json['labelRu']), icon: serializer.fromJson(json['icon']), color: serializer.fromJson(json['color']), isDefault: serializer.fromJson(json['isDefault']), @@ -971,6 +1033,8 @@ class Category extends DataClass implements Insertable { 'id': serializer.toJson(id), 'name': serializer.toJson(name), 'type': serializer.toJson(type), + 'labelEn': serializer.toJson(labelEn), + 'labelRu': serializer.toJson(labelRu), 'icon': serializer.toJson(icon), 'color': serializer.toJson(color), 'isDefault': serializer.toJson(isDefault), @@ -982,6 +1046,8 @@ class Category extends DataClass implements Insertable { int? id, String? name, String? type, + Value labelEn = const Value.absent(), + Value labelRu = const Value.absent(), Value icon = const Value.absent(), Value color = const Value.absent(), bool? isDefault, @@ -990,6 +1056,8 @@ class Category extends DataClass implements Insertable { id: id ?? this.id, name: name ?? this.name, type: type ?? this.type, + labelEn: labelEn.present ? labelEn.value : this.labelEn, + labelRu: labelRu.present ? labelRu.value : this.labelRu, icon: icon.present ? icon.value : this.icon, color: color.present ? color.value : this.color, isDefault: isDefault ?? this.isDefault, @@ -1000,6 +1068,8 @@ class Category extends DataClass implements Insertable { id: data.id.present ? data.id.value : this.id, name: data.name.present ? data.name.value : this.name, type: data.type.present ? data.type.value : this.type, + labelEn: data.labelEn.present ? data.labelEn.value : this.labelEn, + labelRu: data.labelRu.present ? data.labelRu.value : this.labelRu, icon: data.icon.present ? data.icon.value : this.icon, color: data.color.present ? data.color.value : this.color, isDefault: data.isDefault.present ? data.isDefault.value : this.isDefault, @@ -1013,6 +1083,8 @@ class Category extends DataClass implements Insertable { ..write('id: $id, ') ..write('name: $name, ') ..write('type: $type, ') + ..write('labelEn: $labelEn, ') + ..write('labelRu: $labelRu, ') ..write('icon: $icon, ') ..write('color: $color, ') ..write('isDefault: $isDefault, ') @@ -1022,8 +1094,17 @@ class Category extends DataClass implements Insertable { } @override - int get hashCode => - Object.hash(id, name, type, icon, color, isDefault, createdAt); + int get hashCode => Object.hash( + id, + name, + type, + labelEn, + labelRu, + icon, + color, + isDefault, + createdAt, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -1031,6 +1112,8 @@ class Category extends DataClass implements Insertable { other.id == this.id && other.name == this.name && other.type == this.type && + other.labelEn == this.labelEn && + other.labelRu == this.labelRu && other.icon == this.icon && other.color == this.color && other.isDefault == this.isDefault && @@ -1041,6 +1124,8 @@ class CategoriesCompanion extends UpdateCompanion { final Value id; final Value name; final Value type; + final Value labelEn; + final Value labelRu; final Value icon; final Value color; final Value isDefault; @@ -1049,6 +1134,8 @@ class CategoriesCompanion extends UpdateCompanion { this.id = const Value.absent(), this.name = const Value.absent(), this.type = const Value.absent(), + this.labelEn = const Value.absent(), + this.labelRu = const Value.absent(), this.icon = const Value.absent(), this.color = const Value.absent(), this.isDefault = const Value.absent(), @@ -1058,6 +1145,8 @@ class CategoriesCompanion extends UpdateCompanion { this.id = const Value.absent(), required String name, required String type, + this.labelEn = const Value.absent(), + this.labelRu = const Value.absent(), this.icon = const Value.absent(), this.color = const Value.absent(), this.isDefault = const Value.absent(), @@ -1068,6 +1157,8 @@ class CategoriesCompanion extends UpdateCompanion { Expression? id, Expression? name, Expression? type, + Expression? labelEn, + Expression? labelRu, Expression? icon, Expression? color, Expression? isDefault, @@ -1077,6 +1168,8 @@ class CategoriesCompanion extends UpdateCompanion { if (id != null) 'id': id, if (name != null) 'name': name, if (type != null) 'type': type, + if (labelEn != null) 'label_en': labelEn, + if (labelRu != null) 'label_ru': labelRu, if (icon != null) 'icon': icon, if (color != null) 'color': color, if (isDefault != null) 'is_default': isDefault, @@ -1088,6 +1181,8 @@ class CategoriesCompanion extends UpdateCompanion { Value? id, Value? name, Value? type, + Value? labelEn, + Value? labelRu, Value? icon, Value? color, Value? isDefault, @@ -1097,6 +1192,8 @@ class CategoriesCompanion extends UpdateCompanion { id: id ?? this.id, name: name ?? this.name, type: type ?? this.type, + labelEn: labelEn ?? this.labelEn, + labelRu: labelRu ?? this.labelRu, icon: icon ?? this.icon, color: color ?? this.color, isDefault: isDefault ?? this.isDefault, @@ -1116,6 +1213,12 @@ class CategoriesCompanion extends UpdateCompanion { if (type.present) { map['type'] = Variable(type.value); } + if (labelEn.present) { + map['label_en'] = Variable(labelEn.value); + } + if (labelRu.present) { + map['label_ru'] = Variable(labelRu.value); + } if (icon.present) { map['icon'] = Variable(icon.value); } @@ -1137,6 +1240,8 @@ class CategoriesCompanion extends UpdateCompanion { ..write('id: $id, ') ..write('name: $name, ') ..write('type: $type, ') + ..write('labelEn: $labelEn, ') + ..write('labelRu: $labelRu, ') ..write('icon: $icon, ') ..write('color: $color, ') ..write('isDefault: $isDefault, ') @@ -1146,400 +1251,6 @@ class CategoriesCompanion extends UpdateCompanion { } } -class $BudgetsTable extends Budgets with TableInfo<$BudgetsTable, Budget> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $BudgetsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'PRIMARY KEY AUTOINCREMENT', - ), - ); - static const VerificationMeta _amountMeta = const VerificationMeta('amount'); - @override - late final GeneratedColumn amount = GeneratedColumn( - 'amount', - aliasedName, - false, - type: DriftSqlType.double, - requiredDuringInsert: true, - ); - static const VerificationMeta _categoryIdMeta = const VerificationMeta( - 'categoryId', - ); - @override - late final GeneratedColumn categoryId = GeneratedColumn( - 'category_id', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _monthMeta = const VerificationMeta('month'); - @override - late final GeneratedColumn month = GeneratedColumn( - 'month', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ); - static const VerificationMeta _yearMeta = const VerificationMeta('year'); - @override - late final GeneratedColumn year = GeneratedColumn( - 'year', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ); - static const VerificationMeta _createdAtMeta = const VerificationMeta( - 'createdAt', - ); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime, - ); - @override - List get $columns => [ - id, - amount, - categoryId, - month, - year, - createdAt, - ]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'budgets'; - @override - VerificationContext validateIntegrity( - Insertable instance, { - bool isInserting = false, - }) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('amount')) { - context.handle( - _amountMeta, - amount.isAcceptableOrUnknown(data['amount']!, _amountMeta), - ); - } else if (isInserting) { - context.missing(_amountMeta); - } - if (data.containsKey('category_id')) { - context.handle( - _categoryIdMeta, - categoryId.isAcceptableOrUnknown(data['category_id']!, _categoryIdMeta), - ); - } - if (data.containsKey('month')) { - context.handle( - _monthMeta, - month.isAcceptableOrUnknown(data['month']!, _monthMeta), - ); - } else if (isInserting) { - context.missing(_monthMeta); - } - if (data.containsKey('year')) { - context.handle( - _yearMeta, - year.isAcceptableOrUnknown(data['year']!, _yearMeta), - ); - } else if (isInserting) { - context.missing(_yearMeta); - } - if (data.containsKey('created_at')) { - context.handle( - _createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), - ); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - Budget map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return Budget( - id: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, - amount: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}amount'], - )!, - categoryId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}category_id'], - ), - month: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}month'], - )!, - year: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}year'], - )!, - createdAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}created_at'], - )!, - ); - } - - @override - $BudgetsTable createAlias(String alias) { - return $BudgetsTable(attachedDatabase, alias); - } -} - -class Budget extends DataClass implements Insertable { - final int id; - final double amount; - final String? categoryId; - final int month; - final int year; - final DateTime createdAt; - const Budget({ - required this.id, - required this.amount, - this.categoryId, - required this.month, - required this.year, - required this.createdAt, - }); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['amount'] = Variable(amount); - if (!nullToAbsent || categoryId != null) { - map['category_id'] = Variable(categoryId); - } - map['month'] = Variable(month); - map['year'] = Variable(year); - map['created_at'] = Variable(createdAt); - return map; - } - - BudgetsCompanion toCompanion(bool nullToAbsent) { - return BudgetsCompanion( - id: Value(id), - amount: Value(amount), - categoryId: categoryId == null && nullToAbsent - ? const Value.absent() - : Value(categoryId), - month: Value(month), - year: Value(year), - createdAt: Value(createdAt), - ); - } - - factory Budget.fromJson( - Map json, { - ValueSerializer? serializer, - }) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return Budget( - id: serializer.fromJson(json['id']), - amount: serializer.fromJson(json['amount']), - categoryId: serializer.fromJson(json['categoryId']), - month: serializer.fromJson(json['month']), - year: serializer.fromJson(json['year']), - createdAt: serializer.fromJson(json['createdAt']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'amount': serializer.toJson(amount), - 'categoryId': serializer.toJson(categoryId), - 'month': serializer.toJson(month), - 'year': serializer.toJson(year), - 'createdAt': serializer.toJson(createdAt), - }; - } - - Budget copyWith({ - int? id, - double? amount, - Value categoryId = const Value.absent(), - int? month, - int? year, - DateTime? createdAt, - }) => Budget( - id: id ?? this.id, - amount: amount ?? this.amount, - categoryId: categoryId.present ? categoryId.value : this.categoryId, - month: month ?? this.month, - year: year ?? this.year, - createdAt: createdAt ?? this.createdAt, - ); - Budget copyWithCompanion(BudgetsCompanion data) { - return Budget( - id: data.id.present ? data.id.value : this.id, - amount: data.amount.present ? data.amount.value : this.amount, - categoryId: data.categoryId.present - ? data.categoryId.value - : this.categoryId, - month: data.month.present ? data.month.value : this.month, - year: data.year.present ? data.year.value : this.year, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - ); - } - - @override - String toString() { - return (StringBuffer('Budget(') - ..write('id: $id, ') - ..write('amount: $amount, ') - ..write('categoryId: $categoryId, ') - ..write('month: $month, ') - ..write('year: $year, ') - ..write('createdAt: $createdAt') - ..write(')')) - .toString(); - } - - @override - int get hashCode => - Object.hash(id, amount, categoryId, month, year, createdAt); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is Budget && - other.id == this.id && - other.amount == this.amount && - other.categoryId == this.categoryId && - other.month == this.month && - other.year == this.year && - other.createdAt == this.createdAt); -} - -class BudgetsCompanion extends UpdateCompanion { - final Value id; - final Value amount; - final Value categoryId; - final Value month; - final Value year; - final Value createdAt; - const BudgetsCompanion({ - this.id = const Value.absent(), - this.amount = const Value.absent(), - this.categoryId = const Value.absent(), - this.month = const Value.absent(), - this.year = const Value.absent(), - this.createdAt = const Value.absent(), - }); - BudgetsCompanion.insert({ - this.id = const Value.absent(), - required double amount, - this.categoryId = const Value.absent(), - required int month, - required int year, - this.createdAt = const Value.absent(), - }) : amount = Value(amount), - month = Value(month), - year = Value(year); - static Insertable custom({ - Expression? id, - Expression? amount, - Expression? categoryId, - Expression? month, - Expression? year, - Expression? createdAt, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (amount != null) 'amount': amount, - if (categoryId != null) 'category_id': categoryId, - if (month != null) 'month': month, - if (year != null) 'year': year, - if (createdAt != null) 'created_at': createdAt, - }); - } - - BudgetsCompanion copyWith({ - Value? id, - Value? amount, - Value? categoryId, - Value? month, - Value? year, - Value? createdAt, - }) { - return BudgetsCompanion( - id: id ?? this.id, - amount: amount ?? this.amount, - categoryId: categoryId ?? this.categoryId, - month: month ?? this.month, - year: year ?? this.year, - createdAt: createdAt ?? this.createdAt, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (amount.present) { - map['amount'] = Variable(amount.value); - } - if (categoryId.present) { - map['category_id'] = Variable(categoryId.value); - } - if (month.present) { - map['month'] = Variable(month.value); - } - if (year.present) { - map['year'] = Variable(year.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('BudgetsCompanion(') - ..write('id: $id, ') - ..write('amount: $amount, ') - ..write('categoryId: $categoryId, ') - ..write('month: $month, ') - ..write('year: $year, ') - ..write('createdAt: $createdAt') - ..write(')')) - .toString(); - } -} - class $ExchangeRatesTable extends ExchangeRates with TableInfo<$ExchangeRatesTable, ExchangeRate> { @override @@ -2291,7 +2002,6 @@ abstract class _$AppDatabase extends GeneratedDatabase { $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $TransactionsTable transactions = $TransactionsTable(this); late final $CategoriesTable categories = $CategoriesTable(this); - late final $BudgetsTable budgets = $BudgetsTable(this); late final $ExchangeRatesTable exchangeRates = $ExchangeRatesTable(this); late final $AccountsTable accounts = $AccountsTable(this); @override @@ -2301,7 +2011,6 @@ abstract class _$AppDatabase extends GeneratedDatabase { List get allSchemaEntities => [ transactions, categories, - budgets, exchangeRates, accounts, ]; @@ -2654,6 +2363,8 @@ typedef $$CategoriesTableCreateCompanionBuilder = Value id, required String name, required String type, + Value labelEn, + Value labelRu, Value icon, Value color, Value isDefault, @@ -2664,6 +2375,8 @@ typedef $$CategoriesTableUpdateCompanionBuilder = Value id, Value name, Value type, + Value labelEn, + Value labelRu, Value icon, Value color, Value isDefault, @@ -2694,6 +2407,16 @@ class $$CategoriesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get labelEn => $composableBuilder( + column: $table.labelEn, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get labelRu => $composableBuilder( + column: $table.labelRu, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get icon => $composableBuilder( column: $table.icon, builder: (column) => ColumnFilters(column), @@ -2739,6 +2462,16 @@ class $$CategoriesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get labelEn => $composableBuilder( + column: $table.labelEn, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get labelRu => $composableBuilder( + column: $table.labelRu, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get icon => $composableBuilder( column: $table.icon, builder: (column) => ColumnOrderings(column), @@ -2778,6 +2511,12 @@ class $$CategoriesTableAnnotationComposer GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); + GeneratedColumn get labelEn => + $composableBuilder(column: $table.labelEn, builder: (column) => column); + + GeneratedColumn get labelRu => + $composableBuilder(column: $table.labelRu, builder: (column) => column); + GeneratedColumn get icon => $composableBuilder(column: $table.icon, builder: (column) => column); @@ -2822,6 +2561,8 @@ class $$CategoriesTableTableManager Value id = const Value.absent(), Value name = const Value.absent(), Value type = const Value.absent(), + Value labelEn = const Value.absent(), + Value labelRu = const Value.absent(), Value icon = const Value.absent(), Value color = const Value.absent(), Value isDefault = const Value.absent(), @@ -2830,6 +2571,8 @@ class $$CategoriesTableTableManager id: id, name: name, type: type, + labelEn: labelEn, + labelRu: labelRu, icon: icon, color: color, isDefault: isDefault, @@ -2840,6 +2583,8 @@ class $$CategoriesTableTableManager Value id = const Value.absent(), required String name, required String type, + Value labelEn = const Value.absent(), + Value labelRu = const Value.absent(), Value icon = const Value.absent(), Value color = const Value.absent(), Value isDefault = const Value.absent(), @@ -2848,6 +2593,8 @@ class $$CategoriesTableTableManager id: id, name: name, type: type, + labelEn: labelEn, + labelRu: labelRu, icon: icon, color: color, isDefault: isDefault, @@ -2875,215 +2622,6 @@ typedef $$CategoriesTableProcessedTableManager = Category, PrefetchHooks Function() >; -typedef $$BudgetsTableCreateCompanionBuilder = - BudgetsCompanion Function({ - Value id, - required double amount, - Value categoryId, - required int month, - required int year, - Value createdAt, - }); -typedef $$BudgetsTableUpdateCompanionBuilder = - BudgetsCompanion Function({ - Value id, - Value amount, - Value categoryId, - Value month, - Value year, - Value createdAt, - }); - -class $$BudgetsTableFilterComposer - extends Composer<_$AppDatabase, $BudgetsTable> { - $$BudgetsTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get amount => $composableBuilder( - column: $table.amount, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get categoryId => $composableBuilder( - column: $table.categoryId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get month => $composableBuilder( - column: $table.month, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get year => $composableBuilder( - column: $table.year, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, - builder: (column) => ColumnFilters(column), - ); -} - -class $$BudgetsTableOrderingComposer - extends Composer<_$AppDatabase, $BudgetsTable> { - $$BudgetsTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get amount => $composableBuilder( - column: $table.amount, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get categoryId => $composableBuilder( - column: $table.categoryId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get month => $composableBuilder( - column: $table.month, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get year => $composableBuilder( - column: $table.year, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, - builder: (column) => ColumnOrderings(column), - ); -} - -class $$BudgetsTableAnnotationComposer - extends Composer<_$AppDatabase, $BudgetsTable> { - $$BudgetsTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get amount => - $composableBuilder(column: $table.amount, builder: (column) => column); - - GeneratedColumn get categoryId => $composableBuilder( - column: $table.categoryId, - builder: (column) => column, - ); - - GeneratedColumn get month => - $composableBuilder(column: $table.month, builder: (column) => column); - - GeneratedColumn get year => - $composableBuilder(column: $table.year, builder: (column) => column); - - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); -} - -class $$BudgetsTableTableManager - extends - RootTableManager< - _$AppDatabase, - $BudgetsTable, - Budget, - $$BudgetsTableFilterComposer, - $$BudgetsTableOrderingComposer, - $$BudgetsTableAnnotationComposer, - $$BudgetsTableCreateCompanionBuilder, - $$BudgetsTableUpdateCompanionBuilder, - (Budget, BaseReferences<_$AppDatabase, $BudgetsTable, Budget>), - Budget, - PrefetchHooks Function() - > { - $$BudgetsTableTableManager(_$AppDatabase db, $BudgetsTable table) - : super( - TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$BudgetsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$BudgetsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$BudgetsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: - ({ - Value id = const Value.absent(), - Value amount = const Value.absent(), - Value categoryId = const Value.absent(), - Value month = const Value.absent(), - Value year = const Value.absent(), - Value createdAt = const Value.absent(), - }) => BudgetsCompanion( - id: id, - amount: amount, - categoryId: categoryId, - month: month, - year: year, - createdAt: createdAt, - ), - createCompanionCallback: - ({ - Value id = const Value.absent(), - required double amount, - Value categoryId = const Value.absent(), - required int month, - required int year, - Value createdAt = const Value.absent(), - }) => BudgetsCompanion.insert( - id: id, - amount: amount, - categoryId: categoryId, - month: month, - year: year, - createdAt: createdAt, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - ), - ); -} - -typedef $$BudgetsTableProcessedTableManager = - ProcessedTableManager< - _$AppDatabase, - $BudgetsTable, - Budget, - $$BudgetsTableFilterComposer, - $$BudgetsTableOrderingComposer, - $$BudgetsTableAnnotationComposer, - $$BudgetsTableCreateCompanionBuilder, - $$BudgetsTableUpdateCompanionBuilder, - (Budget, BaseReferences<_$AppDatabase, $BudgetsTable, Budget>), - Budget, - PrefetchHooks Function() - >; typedef $$ExchangeRatesTableCreateCompanionBuilder = ExchangeRatesCompanion Function({ Value id, @@ -3497,8 +3035,6 @@ class $AppDatabaseManager { $$TransactionsTableTableManager(_db, _db.transactions); $$CategoriesTableTableManager get categories => $$CategoriesTableTableManager(_db, _db.categories); - $$BudgetsTableTableManager get budgets => - $$BudgetsTableTableManager(_db, _db.budgets); $$ExchangeRatesTableTableManager get exchangeRates => $$ExchangeRatesTableTableManager(_db, _db.exchangeRates); $$AccountsTableTableManager get accounts => diff --git a/lib/data/database/tables.dart b/lib/data/database/tables.dart index f90e1d5..7a9463e 100644 --- a/lib/data/database/tables.dart +++ b/lib/data/database/tables.dart @@ -21,22 +21,15 @@ class Transactions extends Table { class Categories extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get name => text().withLength(min: 1, max: 50)(); - TextColumn get type => text()(); + TextColumn get type => text()(); + TextColumn get labelEn => text().nullable()(); + TextColumn get labelRu => text().nullable()(); TextColumn get icon => text().nullable()(); TextColumn get color => text().nullable()(); BoolColumn get isDefault => boolean().withDefault(const Constant(false))(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); } -class Budgets extends Table { - IntColumn get id => integer().autoIncrement()(); - RealColumn get amount => real()(); - TextColumn get categoryId => text().nullable()(); - IntColumn get month => integer()(); - IntColumn get year => integer()(); - DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); -} - class ExchangeRates extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get fromCurrency => text()(); diff --git a/lib/data/repositories/category_repository.dart b/lib/data/repositories/category_repository.dart new file mode 100644 index 0000000..063e9a2 --- /dev/null +++ b/lib/data/repositories/category_repository.dart @@ -0,0 +1,60 @@ +import 'package:drift/drift.dart'; +import '../database/app_database.dart'; + +class CategoryRepository { + final AppDatabase _db; + + CategoryRepository(this._db); + + Stream> watchAll() { + return _db.watchAllCategories(); + } + + Future> getAll() { + return _db.getAllCategories(); + } + + Future add({ + required String name, + required String type, + required String labelEn, + required String labelRu, + required String iconName, + required int colorValue, + }) { + return _db.insertCategory( + CategoriesCompanion.insert( + name: name, + type: type, + labelEn: Value(labelEn), + labelRu: Value(labelRu), + icon: Value(iconName), + color: Value(colorValue.toString()), + isDefault: const Value(false), + ), + ); + } + + Future updateFields( + int id, { + required String type, + required String labelEn, + required String labelRu, + required String iconName, + required int colorValue, + }) { + return (_db.update(_db.categories)..where((c) => c.id.equals(id))).write( + CategoriesCompanion( + type: Value(type), + labelEn: Value(labelEn), + labelRu: Value(labelRu), + icon: Value(iconName), + color: Value(colorValue.toString()), + ), + ); + } + + Future delete(int id) { + return _db.deleteCategory(id); + } +} diff --git a/lib/features/add_transaction/provider.dart b/lib/features/add_transaction/provider.dart index c9c25a1..278abed 100644 --- a/lib/features/add_transaction/provider.dart +++ b/lib/features/add_transaction/provider.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/constants.dart'; +import '../../shared/models/app_category.dart'; import '../../shared/models/transaction.dart'; +import '../../shared/providers/category_provider.dart'; class AddTransactionState { final double? amount; @@ -133,10 +135,11 @@ class AddTransactionNotifier extends Notifier { } final availableCategoriesProvider = Provider.autoDispose - .family, Transaction?>((ref, initial) { + .family, Transaction?>((ref, initial) { final type = ref.watch( addTransactionProvider(initial).select((s) => s.type), ); - return AppCategories.forType(type); + if (type == TransactionType.transfer) return const []; + return ref.watch(categoryCatalogProvider).forType(type); }); diff --git a/lib/features/add_transaction/widgets/category_picker.dart b/lib/features/add_transaction/widgets/category_picker.dart index 83c4cde..adeb6a3 100644 --- a/lib/features/add_transaction/widgets/category_picker.dart +++ b/lib/features/add_transaction/widgets/category_picker.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../../core/constants.dart'; +import '../../../core/l10n/app_strings.dart'; import '../../../core/l10n/locale_provider.dart'; +import '../../../core/services/haptic_service.dart'; +import '../../../shared/models/app_category.dart'; class CategoryPicker extends ConsumerWidget { - final List categories; + final List categories; final String selected; final ValueChanged onChanged; @@ -18,61 +22,109 @@ class CategoryPicker extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final s = ref.watch(stringsProvider); + final isRu = s.locale == AppLocale.ru; final isDark = Theme.of(context).brightness == Brightness.dark; return Wrap( spacing: 8, runSpacing: 8, - children: categories.map((cat) { - final isSelected = cat == selected; - final color = AppCategories.colors[cat] ?? AppColors.accent; - final icon = AppCategories.icons[cat] ?? Icons.category_rounded; - return GestureDetector( - onTap: () => onChanged(cat), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: isSelected - ? color.withOpacity(0.2) - : Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: isSelected - ? Border.all(color: color, width: 1.5) - : (isDark - ? null - : Border.all(color: const Color(0xFFDDDDEE), width: 1)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - color: isSelected - ? color - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - size: 16, - ), - const SizedBox(width: 6), - Text( - s.categoryLabel(cat), - style: Theme.of(context).textTheme.bodySmall?.copyWith( + children: [ + ...categories.map((cat) { + final isSelected = cat.key == selected; + final color = cat.color; + return GestureDetector( + onTap: () { + HapticService.light(); + onChanged(cat.key); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? color.withOpacity(0.2) + : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: isSelected + ? Border.all(color: color, width: 1.5) + : (isDark + ? null + : Border.all( + color: const Color(0xFFDDDDEE), width: 1)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + cat.icon, color: isSelected ? color : Theme.of( context, ).colorScheme.onSurface.withOpacity(0.6), - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, + size: 16, ), - ), - ], + const SizedBox(width: 6), + Text( + cat.label(isRu), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isSelected + ? color + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ], + ), ), - ), - ); - }).toList(), + ); + }), + _AddCategoryChip(label: s.addCategory), + ], + ); + } +} + +class _AddCategoryChip extends StatelessWidget { + final String label; + + const _AddCategoryChip({required this.label}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + HapticService.light(); + context.push('/settings/categories'); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.accent.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.accent.withOpacity(0.5), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add_rounded, color: AppColors.accent, size: 16), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), ); } } diff --git a/lib/features/categories/screen.dart b/lib/features/categories/screen.dart index 24eddaf..d6590fc 100644 --- a/lib/features/categories/screen.dart +++ b/lib/features/categories/screen.dart @@ -3,17 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../core/constants.dart'; -import '../../core/l10n/app_strings.dart'; import '../../core/l10n/locale_provider.dart'; -import '../../core/services/haptic_service.dart'; import '../../shared/providers/amount_format_provider.dart'; import '../../shared/utils/currency_utils.dart'; import '../../shared/widgets/byn_sign.dart'; -import '../dashboard/provider.dart'; import '../settings/provider.dart'; import 'provider.dart'; import 'widgets/account_scope_chips.dart'; -import 'widgets/stats_hero_card.dart'; class CategoriesScreen extends ConsumerStatefulWidget { const CategoriesScreen({super.key}); @@ -24,7 +20,7 @@ class CategoriesScreen extends ConsumerStatefulWidget { class _CategoriesScreenState extends ConsumerState { int _touchedIndex = -1; - bool _showIncome = false; + final bool _showIncome = false; @override Widget build(BuildContext context) { @@ -35,11 +31,9 @@ class _CategoriesScreenState extends ConsumerState { : ref.watch(categoryExpenseProvider); final total = data.values.fold(0.0, (a, b) => a + b); final currencyInfo = ref.watch(statsCurrencyProvider); - final timeFilter = ref.watch(timeFilterProvider); final monthlyData = _showIncome ? ref.watch(monthlyIncomeBreakdownProvider) : ref.watch(monthlyBreakdownProvider); - final scopeLabel = _scopeLabel(s); final sortedEntries = data.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); @@ -63,79 +57,6 @@ class _CategoriesScreenState extends ConsumerState { children: [ const AccountScopeChips(), const SizedBox(height: 16), - _FilterCard( - child: Column( - children: [ - Row( - children: [ - Expanded( - child: _TimeFilterChip( - label: s.filterAllTime, - isSelected: timeFilter == TimeFilter.allTime, - onTap: () { - HapticService.selection(); - ref.read(timeFilterProvider.notifier).set(TimeFilter.allTime); - }, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _TimeFilterChip( - label: s.filterMonth, - isSelected: timeFilter == TimeFilter.lastMonth, - onTap: () { - HapticService.selection(); - ref.read(timeFilterProvider.notifier).set(TimeFilter.lastMonth); - }, - ), - ), - ], - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: _TypeSegment( - label: s.expenses, - isSelected: !_showIncome, - color: AppColors.expense, - onTap: () { - HapticService.selection(); - setState(() { - _showIncome = false; - _touchedIndex = -1; - }); - }, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _TypeSegment( - label: s.income, - isSelected: _showIncome, - color: AppColors.income, - onTap: () { - HapticService.selection(); - setState(() { - _showIncome = true; - _touchedIndex = -1; - }); - }, - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 18), - StatsHeroCard( - amount: _showIncome ? summary.income : summary.expense, - label: _showIncome ? s.income.toUpperCase() : s.expenses.toUpperCase(), - accentColor: _showIncome ? AppColors.income : AppColors.expense, - scopeLabel: scopeLabel, - ), - const SizedBox(height: 16), _InsightCard( title: s.overview, subtitle: s.analyticsInsight, @@ -145,7 +66,7 @@ class _CategoriesScreenState extends ConsumerState { physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 10, crossAxisSpacing: 10, - childAspectRatio: 1.55, + childAspectRatio: 1.22, children: [ _MetricTile( label: s.income, @@ -240,32 +161,6 @@ class _CategoriesScreenState extends ConsumerState { ), ); } - - String _scopeLabel(AppStrings s) { - final activeAccount = ref.watch(activeAccountProvider); - return activeAccount?.name ?? s.allAccounts; - } -} - -class _FilterCard extends StatelessWidget { - final Widget child; - - const _FilterCard({required this.child}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.06), - ), - ), - child: child, - ); - } } class _InsightCard extends StatelessWidget { @@ -316,96 +211,6 @@ class _InsightCard extends StatelessWidget { } } -class _TimeFilterChip extends StatelessWidget { - final String label; - final bool isSelected; - final VoidCallback onTap; - - const _TimeFilterChip({ - required this.label, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: isSelected - ? AppColors.accent - : Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isSelected - ? AppColors.accent - : Theme.of(context).colorScheme.onSurface.withOpacity(0.06), - ), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: isSelected - ? Colors.white - : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - ), - ), - ), - ); - } -} - -class _TypeSegment extends StatelessWidget { - final String label; - final bool isSelected; - final Color color; - final VoidCallback onTap; - - const _TypeSegment({ - required this.label, - required this.isSelected, - required this.color, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: isSelected ? color : Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isSelected - ? color - : Theme.of(context).colorScheme.onSurface.withOpacity(0.06), - ), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: isSelected - ? Colors.white - : Theme.of(context).colorScheme.onSurface.withOpacity(0.65), - ), - ), - ), - ); - } -} - class _PieChartSection extends ConsumerWidget { final Map data; final double total; diff --git a/lib/features/categories/widgets/stats_hero_card.dart b/lib/features/categories/widgets/stats_hero_card.dart deleted file mode 100644 index eccc35f..0000000 --- a/lib/features/categories/widgets/stats_hero_card.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/constants.dart'; -import '../../../core/services/card_color_service.dart'; -import '../../../shared/providers/amount_format_provider.dart'; -import '../../../shared/utils/card_gradient.dart'; -import '../../../shared/utils/currency_utils.dart'; -import '../../../shared/widgets/byn_sign.dart'; -import '../../dashboard/provider.dart'; -import '../../settings/provider.dart'; -import '../provider.dart'; - -class StatsHeroCard extends ConsumerWidget { - final double amount; - final String label; - final Color accentColor; - final String scopeLabel; - - const StatsHeroCard({ - super.key, - required this.amount, - required this.label, - required this.accentColor, - required this.scopeLabel, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fmt = ref.watch(amountFormatProvider); - final currencyInfo = ref.watch(statsCurrencyProvider); - final brightness = Theme.of(context).brightness; - final activeAccount = ref.watch(activeAccountProvider); - final globalColors = ref.watch(cardColorsProvider); - - final CardColors colors; - if (activeAccount != null) { - colors = ref.watch(accountCardColorsProvider(activeAccount.id)); - } else { - colors = globalColors; - } - - final primary = Color.lerp(colors.primary, accentColor, 0.35)!; - final secondary = Color.lerp(colors.secondary, accentColor, 0.2)!; - final gradientType = colors.gradientTypeForBrightness(brightness); - final onCard = primary.computeLuminance() > 0.35 - ? Colors.black - : Colors.white; - - return Container( - height: 128, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: buildCardGradient(primary, secondary, gradientType), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.35), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: onCard.withOpacity(0.15), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - scopeLabel, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: onCard.withOpacity(0.85), - letterSpacing: 0.3, - ), - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle( - fontSize: 11, - letterSpacing: 1.2, - color: onCard.withOpacity(0.65), - ), - ), - const SizedBox(height: 2), - FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: currencyInfo.code == 'BYN' - ? Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - BynSign(fontSize: 28, color: onCard), - const SizedBox(width: 2), - Text( - formatAmount('', amount, fmt), - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w700, - color: onCard, - ), - maxLines: 1, - ), - ], - ) - : Text( - formatAmount(currencyInfo.symbol, amount, fmt), - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w700, - color: onCard, - ), - maxLines: 1, - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/dashboard/provider.dart b/lib/features/dashboard/provider.dart index 07b1d96..8e44995 100644 --- a/lib/features/dashboard/provider.dart +++ b/lib/features/dashboard/provider.dart @@ -273,36 +273,6 @@ final totalExpenseProvider = Provider((ref) { }); }); -final currentMonthExpenseProvider = Provider((ref) { - final now = DateTime.now(); - final txs = ref.watch(accountFilteredTransactionsProvider); - final filtered = txs.where( - (t) => - t.type == TransactionType.expense && - t.date.year == now.year && - t.date.month == now.month, - ); - - final index = ref.watch(activeAccountIndexProvider); - final accountsAsync = ref.watch(accountsProvider); - final globalCurrency = ref.watch(currencyProvider).code; - - String targetCurrency = globalCurrency; - if (index > 0) { - final accounts = accountsAsync.value ?? []; - if (index <= accounts.length) { - targetCurrency = accounts[index - 1].currency; - } - } - - final exchangeService = ref.watch(exchangeRateServiceProvider); - - return filtered.fold(0.0, (sum, t) { - return sum + - exchangeService.convert(t.amount, t.currencyCode, targetCurrency); - }); -}); - final filteredTransactionsProvider = Provider>((ref) { final txs = ref.watch(accountFilteredTransactionsProvider); final query = ref.watch(searchQueryProvider).toLowerCase(); diff --git a/lib/features/dashboard/screen.dart b/lib/features/dashboard/screen.dart index 264eb83..d07e561 100644 --- a/lib/features/dashboard/screen.dart +++ b/lib/features/dashboard/screen.dart @@ -10,7 +10,6 @@ import '../settings/provider.dart'; import 'provider.dart'; import 'widgets/account_editor_overlay/account_editor_overlay.dart'; import 'widgets/balance_card_carousel.dart'; -import 'widgets/budget_progress.dart'; import 'widgets/color_editor_overlay.dart'; import 'widgets/filter_chips.dart'; import 'widgets/search_bar.dart' as custom; @@ -266,8 +265,6 @@ class _DashboardScreenState extends ConsumerState { final balance = ref.watch(totalBalanceProvider); final income = ref.watch(totalIncomeProvider); final expense = ref.watch(totalExpenseProvider); - final monthExpense = ref.watch(currentMonthExpenseProvider); - final budget = ref.watch(budgetProvider); final recent = ref.watch(recentTransactionsProvider); final activeAccount = ref.watch(activeAccountProvider); final globalCurrencyInfo = ref.watch(currencyProvider); @@ -376,15 +373,6 @@ class _DashboardScreenState extends ConsumerState { currencyInfo: currencyInfo, strings: s, ), - if (budget != null) ...[ - const SizedBox(height: 16), - BudgetProgress( - spent: monthExpense, - budget: budget, - currencyInfo: currencyInfo, - strings: s, - ), - ], const SizedBox(height: 24), custom.SearchBar( controller: _searchController, diff --git a/lib/features/dashboard/widgets/budget_progress.dart b/lib/features/dashboard/widgets/budget_progress.dart deleted file mode 100644 index b49a365..0000000 --- a/lib/features/dashboard/widgets/budget_progress.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/l10n/app_strings.dart'; -import '../../../shared/providers/amount_format_provider.dart'; -import '../../../shared/utils/currency_utils.dart'; -import '../../../shared/widgets/byn_sign.dart'; -import '../../settings/provider.dart'; - -class BudgetProgress extends ConsumerWidget { - final double spent; - final double budget; - final CurrencyInfo currencyInfo; - final AppStrings strings; - const BudgetProgress({ - super.key, - required this.spent, - required this.budget, - required this.currencyInfo, - required this.strings, - }); - - Border? _themeBorder(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return isDark ? null : Border.all(color: const Color(0xFFDDDDEE), width: 1); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fmt = ref.watch(amountFormatProvider); - final progress = budget > 0 ? spent / budget : 0.0; - final isOver = progress > 1.0; - final displayPercent = (progress * 100).toStringAsFixed(0); - - return ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border( - left: BorderSide( - color: isOver ? const Color(0xFFE05C6B) : const Color(0xFF7C6DED), - width: 3, - ), - top: _themeBorder(context)?.top ?? BorderSide.none, - right: _themeBorder(context)?.right ?? BorderSide.none, - bottom: _themeBorder(context)?.bottom ?? BorderSide.none, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - strings.monthlyBudget, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - Text( - '$displayPercent%', - style: TextStyle( - color: isOver - ? const Color(0xFFE05C6B) - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.7), - fontWeight: isOver ? FontWeight.w700 : FontWeight.normal, - fontSize: 12, - ), - ), - ], - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: LinearProgressIndicator( - value: isOver ? 1.0 : progress, - backgroundColor: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.1), - valueColor: AlwaysStoppedAnimation( - isOver - ? const Color(0xFFE05C6B) - : (progress > 0.8 - ? Colors.orange - : const Color(0xFF4CAF8C)), - ), - minHeight: 8, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - currencyInfo.code == 'BYN' - ? Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - '${strings.spent}: ', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - BynSign( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - const SizedBox(width: 2), - Text( - formatAmount('', spent, fmt), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - ], - ) - : Text( - '${strings.spent}: ${formatAmount(currencyInfo.symbol, spent, fmt)}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - currencyInfo.code == 'BYN' - ? Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - '${strings.limit}: ', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - BynSign( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - const SizedBox(width: 2), - Text( - formatAmount('', budget, fmt), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - ], - ) - : Text( - '${strings.limit}: ${formatAmount(currencyInfo.symbol, budget, fmt)}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/dashboard/widgets/transaction_tile.dart b/lib/features/dashboard/widgets/transaction_tile.dart index d2f77c7..852f058 100644 --- a/lib/features/dashboard/widgets/transaction_tile.dart +++ b/lib/features/dashboard/widgets/transaction_tile.dart @@ -9,6 +9,7 @@ import '../../../core/l10n/locale_provider.dart'; import '../../../shared/models/transaction.dart'; import '../../../shared/models/account.dart'; import '../../../shared/providers/amount_format_provider.dart'; +import '../../../shared/providers/category_provider.dart'; import '../../../shared/utils/currency_utils.dart'; import '../../../shared/widgets/byn_sign.dart'; import '../../settings/provider.dart'; @@ -29,7 +30,9 @@ class TransactionTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final s = ref.watch(stringsProvider); + final isRu = s.locale == AppLocale.ru; final fmt = ref.watch(amountFormatProvider); + final catalog = ref.watch(categoryCatalogProvider); final isTransfer = transaction.category == 'Transfer'; final isIncome = transaction.type == TransactionType.income; final color = isTransfer @@ -37,10 +40,11 @@ class TransactionTile extends ConsumerWidget { : (isIncome ? AppColors.income : AppColors.expense); final catColor = isTransfer ? const Color(0xFF7C6DED) - : (AppCategories.colors[transaction.category] ?? AppColors.accent); + : catalog.colorFor(transaction.category); final catIcon = isTransfer ? Icons.swap_horiz_rounded - : (AppCategories.icons[transaction.category] ?? Icons.category_rounded); + : catalog.iconFor(transaction.category); + final catLabel = catalog.labelFor(transaction.category, isRu); final activeAccount = ref.watch(activeAccountProvider); final displayCurrency = @@ -105,7 +109,7 @@ class TransactionTile extends ConsumerWidget { activeAccount, ) : Text( - s.categoryLabel(transaction.category), + catLabel, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( fontWeight: FontWeight.w600, diff --git a/lib/features/settings/categories/category_editor_sheet.dart b/lib/features/settings/categories/category_editor_sheet.dart new file mode 100644 index 0000000..2157251 --- /dev/null +++ b/lib/features/settings/categories/category_editor_sheet.dart @@ -0,0 +1,617 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/constants.dart'; +import '../../../core/l10n/locale_provider.dart'; +import '../../../core/services/haptic_service.dart'; +import '../../../core/utils/result.dart'; +import '../../../shared/models/app_category.dart'; +import '../../../shared/models/transaction.dart'; +import '../../../shared/providers/category_provider.dart'; +import '../../../shared/services/translation_service.dart'; +import '../../../shared/widgets/error_snackbar.dart'; + +Future showCategoryEditor( + BuildContext context, { + AppCategory? existing, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => CategoryEditorSheet(existing: existing), + ); +} + +class CategoryEditorSheet extends ConsumerStatefulWidget { + final AppCategory? existing; + + const CategoryEditorSheet({super.key, this.existing}); + + @override + ConsumerState createState() => + _CategoryEditorSheetState(); +} + +class _CategoryEditorSheetState extends ConsumerState { + late final TextEditingController _enController; + late final TextEditingController _ruController; + late TransactionType _type; + late String _iconName; + late int _colorValue; + + String? _enSuggestion; + String? _ruSuggestion; + bool _translatingEn = false; + bool _translatingRu = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + final existing = widget.existing; + _enController = TextEditingController(text: existing?.labelEn ?? ''); + _ruController = TextEditingController(text: existing?.labelRu ?? ''); + _type = existing?.type == TransactionType.income + ? TransactionType.income + : TransactionType.expense; + _iconName = existing?.iconName ?? kCategoryIcons.keys.first; + _colorValue = existing?.color.value ?? kCategoryColors.first.value; + _enController.addListener(_onEnChanged); + _ruController.addListener(_onRuChanged); + } + + @override + void dispose() { + _enController.dispose(); + _ruController.dispose(); + super.dispose(); + } + + void _onEnChanged() { + if (_enController.text.trim().isNotEmpty && _enSuggestion != null) { + setState(() => _enSuggestion = null); + } + } + + void _onRuChanged() { + if (_ruController.text.trim().isNotEmpty && _ruSuggestion != null) { + setState(() => _ruSuggestion = null); + } + } + + Future _translateToRu() async { + final source = _enController.text.trim(); + if (source.isEmpty) return; + setState(() => _translatingRu = true); + final result = await ref + .read(translationServiceProvider) + .translate(source, TranslateDirection.enToRu); + if (!mounted) return; + setState(() { + _translatingRu = false; + _ruSuggestion = result?.text; + }); + if (result == null) { + showErrorSnackbar(context, ref.read(stringsProvider).translationFailed); + } + } + + Future _translateToEn() async { + final source = _ruController.text.trim(); + if (source.isEmpty) return; + setState(() => _translatingEn = true); + final result = await ref + .read(translationServiceProvider) + .translate(source, TranslateDirection.ruToEn); + if (!mounted) return; + setState(() { + _translatingEn = false; + _enSuggestion = result?.text; + }); + if (result == null) { + showErrorSnackbar(context, ref.read(stringsProvider).translationFailed); + } + } + + Future _save() async { + final s = ref.read(stringsProvider); + if (_enController.text.trim().isEmpty && + _ruController.text.trim().isEmpty) { + showErrorSnackbar(context, s.categoryNameRequired); + return; + } + setState(() => _saving = true); + HapticService.medium(); + final actions = ref.read(categoryActionsProvider); + final existing = widget.existing; + final result = existing != null && existing.id != null + ? await actions.edit( + id: existing.id!, + type: _type, + labelEn: _enController.text, + labelRu: _ruController.text, + iconName: _iconName, + colorValue: _colorValue, + ) + : await actions.create( + type: _type, + labelEn: _enController.text, + labelRu: _ruController.text, + iconName: _iconName, + colorValue: _colorValue, + ); + if (!mounted) return; + setState(() => _saving = false); + if (result case Failure(message: final message)) { + showErrorSnackbar(context, message); + } else { + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final s = ref.watch(stringsProvider); + final theme = Theme.of(context); + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: Container( + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SafeArea( + top: false, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withOpacity(0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + widget.existing != null ? s.editCategory : s.newCategory, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 20), + _TypeToggle( + type: _type, + onChanged: (t) => setState(() => _type = t), + expenseLabel: s.typeExpense, + incomeLabel: s.typeIncome, + ), + const SizedBox(height: 20), + _TranslatableField( + controller: _enController, + label: s.nameEn, + hint: s.nameEnHint, + suggestion: _enSuggestion, + isTranslating: _translatingEn, + canTranslate: _ruController.text.trim().isNotEmpty, + translatingLabel: s.translating, + applyLabel: s.applyTranslation, + onTranslate: _translateToEn, + onApply: () { + _enController.text = _enSuggestion ?? ''; + setState(() => _enSuggestion = null); + }, + ), + const SizedBox(height: 16), + _TranslatableField( + controller: _ruController, + label: s.nameRu, + hint: s.nameRuHint, + suggestion: _ruSuggestion, + isTranslating: _translatingRu, + canTranslate: _enController.text.trim().isNotEmpty, + translatingLabel: s.translating, + applyLabel: s.applyTranslation, + onTranslate: _translateToRu, + onApply: () { + _ruController.text = _ruSuggestion ?? ''; + setState(() => _ruSuggestion = null); + }, + ), + const SizedBox(height: 20), + Text( + s.categoryIcon, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: 10), + _IconGrid( + selected: _iconName, + color: Color(_colorValue), + onSelected: (name) { + HapticService.light(); + setState(() => _iconName = name); + }, + ), + const SizedBox(height: 20), + Text( + s.categoryColor, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: 10), + _ColorRow( + selected: _colorValue, + onSelected: (value) { + HapticService.light(); + setState(() => _colorValue = value); + }, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: AppColors.accent, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + s.save, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TypeToggle extends StatelessWidget { + final TransactionType type; + final ValueChanged onChanged; + final String expenseLabel; + final String incomeLabel; + + const _TypeToggle({ + required this.type, + required this.onChanged, + required this.expenseLabel, + required this.incomeLabel, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + _segment( + context, + label: expenseLabel, + selected: type == TransactionType.expense, + color: AppColors.expense, + onTap: () => onChanged(TransactionType.expense), + ), + _segment( + context, + label: incomeLabel, + selected: type == TransactionType.income, + color: AppColors.income, + onTap: () => onChanged(TransactionType.income), + ), + ], + ), + ); + } + + Widget _segment( + BuildContext context, { + required String label, + required bool selected, + required Color color, + required VoidCallback onTap, + }) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: selected ? color.withOpacity(0.18) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: selected + ? color + : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ); + } +} + +class _TranslatableField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String hint; + final String? suggestion; + final bool isTranslating; + final bool canTranslate; + final String translatingLabel; + final String applyLabel; + final VoidCallback onTranslate; + final VoidCallback onApply; + + const _TranslatableField({ + required this.controller, + required this.label, + required this.hint, + required this.suggestion, + required this.isTranslating, + required this.canTranslate, + required this.translatingLabel, + required this.applyLabel, + required this.onTranslate, + required this.onApply, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + final isEmpty = controller.text.trim().isEmpty; + final showGhost = isEmpty && suggestion != null && !isTranslating; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(14), + border: isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), + ), + child: Row( + children: [ + Expanded( + child: Stack( + children: [ + if (showGhost) + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + suggestion!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface + .withOpacity(0.28), + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ), + TextField( + controller: controller, + style: theme.textTheme.bodyLarge, + decoration: InputDecoration( + hintText: showGhost ? '' : hint, + isDense: true, + filled: false, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 14, + ), + ), + ), + ], + ), + ), + _trailing(context), + ], + ), + ), + ], + ); + }, + ); + } + + Widget _trailing(BuildContext context) { + if (isTranslating) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 14), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final isEmpty = controller.text.trim().isEmpty; + if (isEmpty && suggestion != null) { + return Padding( + padding: const EdgeInsets.only(right: 6), + child: TextButton( + onPressed: onApply, + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + padding: const EdgeInsets.symmetric(horizontal: 10), + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + applyLabel, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + ); + } + if (isEmpty && canTranslate) { + return IconButton( + onPressed: onTranslate, + icon: const Icon(Icons.translate_rounded, size: 20), + color: AppColors.accent, + tooltip: '', + ); + } + return const SizedBox(width: 8); + } +} + +class _IconGrid extends StatelessWidget { + final String selected; + final Color color; + final ValueChanged onSelected; + + const _IconGrid({ + required this.selected, + required this.color, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Wrap( + spacing: 10, + runSpacing: 10, + children: kCategoryIcons.entries.map((entry) { + final isSelected = entry.key == selected; + return GestureDetector( + onTap: () => onSelected(entry.key), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSelected + ? color.withOpacity(0.2) + : theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? color : Colors.transparent, + width: 1.5, + ), + ), + child: Icon( + entry.value, + color: isSelected + ? color + : theme.colorScheme.onSurface.withOpacity(0.6), + size: 22, + ), + ), + ); + }).toList(), + ); + } +} + +class _ColorRow extends StatelessWidget { + final int selected; + final ValueChanged onSelected; + + const _ColorRow({required this.selected, required this.onSelected}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 12, + runSpacing: 12, + children: kCategoryColors.map((color) { + final isSelected = color.value == selected; + return GestureDetector( + onTap: () => onSelected(color.value), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: isSelected ? Colors.white : Colors.transparent, + width: 3, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: color.withOpacity(0.5), + blurRadius: 8, + spreadRadius: 1, + ), + ] + : null, + ), + child: isSelected + ? const Icon(Icons.check_rounded, color: Colors.white, size: 20) + : null, + ), + ); + }).toList(), + ); + } +} diff --git a/lib/features/settings/categories/category_manager_screen.dart b/lib/features/settings/categories/category_manager_screen.dart new file mode 100644 index 0000000..f44d0c6 --- /dev/null +++ b/lib/features/settings/categories/category_manager_screen.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/constants.dart'; +import '../../../core/l10n/app_strings.dart'; +import '../../../core/l10n/locale_provider.dart'; +import '../../../core/services/haptic_service.dart'; +import '../../../shared/models/app_category.dart'; +import '../../../shared/models/transaction.dart'; +import '../../../shared/providers/category_provider.dart'; +import 'category_editor_sheet.dart'; + +class CategoryManagerScreen extends ConsumerWidget { + const CategoryManagerScreen({super.key}); + + Future _confirmDelete( + BuildContext context, + WidgetRef ref, + AppCategory category, + ) async { + final s = ref.read(stringsProvider); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(s.deleteCategoryConfirm), + content: Text(s.deleteCategoryWarning), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(s.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: AppColors.expense), + child: Text(s.delete), + ), + ], + ), + ); + if (confirmed == true && category.id != null) { + HapticService.medium(); + await ref.read(categoryActionsProvider).remove(category.id!); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = ref.watch(stringsProvider); + final isRu = s.locale == AppLocale.ru; + final catalog = ref.watch(categoryCatalogProvider); + final custom = catalog.custom; + final defaults = catalog.all.where((c) => !c.isCustom).toList(); + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: 0, + scrolledUnderElevation: 0, + title: Text( + s.manageCategories, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + HapticService.medium(); + showCategoryEditor(context); + }, + backgroundColor: AppColors.accent, + foregroundColor: Colors.white, + icon: const Icon(Icons.add), + label: Text( + s.addCategory, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + body: ListView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20, 8, 20, 100), + children: [ + _SectionLabel(text: s.customCategories), + const SizedBox(height: 12), + if (custom.isEmpty) + _EmptyState( + title: s.noCustomCategories, + subtitle: s.noCustomCategoriesHint, + ) + else + ...custom.map( + (c) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _CategoryRow( + category: c, + isRu: isRu, + onTap: () => showCategoryEditor(context, existing: c), + onDelete: () => _confirmDelete(context, ref, c), + ), + ), + ), + const SizedBox(height: 28), + _SectionLabel(text: s.defaultCategories), + const SizedBox(height: 12), + ...defaults.map( + (c) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _CategoryRow(category: c, isRu: isRu), + ), + ), + ], + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + final String text; + + const _SectionLabel({required this.text}); + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + style: TextStyle( + fontSize: 12, + letterSpacing: 1.1, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + ), + ); + } +} + +class _CategoryRow extends StatelessWidget { + final AppCategory category; + final bool isRu; + final VoidCallback? onTap; + final VoidCallback? onDelete; + + const _CategoryRow({ + required this.category, + required this.isRu, + this.onTap, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final isIncome = category.type == TransactionType.income; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(14), + border: isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: category.color.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(category.icon, color: category.color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.label(isRu), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + isIncome + ? (isRu ? 'Доход' : 'Income') + : (isRu ? 'Расход' : 'Expense'), + style: theme.textTheme.bodySmall?.copyWith( + color: (isIncome ? AppColors.income : AppColors.expense) + .withOpacity(0.9), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + if (onDelete != null) + IconButton( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline_rounded, size: 20), + color: theme.colorScheme.onSurface.withOpacity(0.4), + ) + else + Icon( + Icons.lock_outline_rounded, + size: 18, + color: theme.colorScheme.onSurface.withOpacity(0.25), + ), + ], + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + final String title; + final String subtitle; + + const _EmptyState({required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 20), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: theme.brightness == Brightness.dark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), + ), + child: Column( + children: [ + Icon( + Icons.category_outlined, + size: 36, + color: theme.colorScheme.onSurface.withOpacity(0.3), + ), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/provider.dart b/lib/features/settings/provider.dart index bd2ae3a..82260f3 100644 --- a/lib/features/settings/provider.dart +++ b/lib/features/settings/provider.dart @@ -11,34 +11,6 @@ import '../../shared/utils/currency_utils.dart'; import '../../shared/providers/amount_format_provider.dart'; import '../dashboard/provider.dart'; -final budgetProvider = NotifierProvider( - BudgetNotifier.new, -); - -class BudgetNotifier extends Notifier { - @override - double? build() { - final storage = ref.watch(storageServiceProvider); - return storage.loadBudget(); - } - - Future setBudget(double? budget) async { - final storage = ref.read(storageServiceProvider); - await storage.saveBudget(budget); - state = budget; - } - - void onCurrencyChanged( - String oldCode, - String newCode, - ExchangeRateService rates, - ) { - if (state == null) return; - final converted = rates.convert(state!, oldCode, newCode); - setBudget(converted); - } -} - class CurrencyInfo { final String symbol; final String code; diff --git a/lib/features/settings/screen.dart b/lib/features/settings/screen.dart index 859e7fb..8632aa5 100644 --- a/lib/features/settings/screen.dart +++ b/lib/features/settings/screen.dart @@ -13,7 +13,7 @@ import 'widgets/currency_conversions_section.dart'; import 'widgets/language_section.dart'; import 'widgets/currency_section.dart'; import 'widgets/amount_format_section.dart'; -import 'widgets/budget_section.dart'; +import 'widgets/categories_section.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @@ -112,11 +112,16 @@ class SettingsScreen extends ConsumerWidget { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, title: Text( - s.settings, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + 'Casha', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, color: Theme.of(context).colorScheme.onSurface, + letterSpacing: -0.5, ), ), ), @@ -138,8 +143,6 @@ class SettingsScreen extends ConsumerWidget { const CurrencySection(), const SizedBox(height: 16), const AmountFormatSection(), - const SizedBox(height: 16), - const BudgetSection(), const SizedBox(height: 24), Text( s.dangerZone, diff --git a/lib/features/settings/widgets/budget_section.dart b/lib/features/settings/widgets/budget_section.dart deleted file mode 100644 index b5c00b7..0000000 --- a/lib/features/settings/widgets/budget_section.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/constants.dart'; -import '../../../core/l10n/locale_provider.dart'; -import '../../../shared/providers/amount_format_provider.dart'; -import '../../../shared/utils/currency_utils.dart'; -import '../../../shared/widgets/byn_sign.dart'; -import '../provider.dart'; - -class BudgetSection extends ConsumerStatefulWidget { - const BudgetSection({super.key}); - - @override - ConsumerState createState() => _BudgetSectionState(); -} - -class _BudgetSectionState extends ConsumerState { - final _budgetController = TextEditingController(); - bool _isEditing = false; - - @override - void initState() { - super.initState(); - final budget = ref.read(budgetProvider); - if (budget != null) { - _budgetController.text = budget.toStringAsFixed(2); - } - } - - @override - void dispose() { - _budgetController.dispose(); - super.dispose(); - } - - Future _saveBudget() async { - final text = _budgetController.text.trim(); - if (text.isEmpty) { - await ref.read(budgetProvider.notifier).setBudget(null); - } else { - final value = double.tryParse(text); - if (value != null && value > 0) { - await ref.read(budgetProvider.notifier).setBudget(value); - } - } - setState(() => _isEditing = false); - } - - @override - Widget build(BuildContext context) { - final s = ref.watch(stringsProvider); - final budget = ref.watch(budgetProvider); - final currencyInfo = ref.watch(currencyProvider); - final fmt = ref.watch(amountFormatProvider); - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16), - border: isDark - ? null - : Border.all(color: const Color(0xFFDDDDEE), width: 1), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.accent.withOpacity(0.15), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.account_balance_wallet_rounded, - color: AppColors.accent, - size: 20, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - s.monthlyBudgetSetting, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - if (!_isEditing) - IconButton( - icon: const Icon(Icons.edit_rounded, size: 20), - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - onPressed: () => setState(() => _isEditing = true), - ), - ], - ), - const SizedBox(height: 16), - if (_isEditing) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: _budgetController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d+\.?\d{0,2}'), - ), - ], - decoration: InputDecoration( - prefix: currencyInfo.code == 'BYN' - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - BynSign( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(width: 4), - ], - ) - : null, - prefixText: currencyInfo.code != 'BYN' - ? (currencyInfo.symbol == '₽' - ? '${currencyInfo.symbol} ' - : currencyInfo.symbol) - : null, - hintText: '0.00', - helperText: s.leaveEmptyToRemove, - ), - autofocus: true, - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () { - final budget = ref.read(budgetProvider); - _budgetController.text = - budget?.toStringAsFixed(2) ?? ''; - setState(() => _isEditing = false); - }, - child: Text(s.cancel), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _saveBudget, - style: ElevatedButton.styleFrom( - minimumSize: const Size(80, 40), - ), - child: Text(s.save), - ), - ], - ), - ], - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - budget != null && currencyInfo.code == 'BYN' - ? Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - BynSign(fontSize: 24, color: AppColors.accent), - const SizedBox(width: 2), - Text( - formatAmount('', budget, fmt), - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith( - color: AppColors.accent, - fontWeight: FontWeight.w700, - ), - ), - ], - ) - : Text( - budget != null - ? formatAmount(currencyInfo.symbol, budget, fmt) - : s.budgetNone, - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith( - color: budget != null - ? AppColors.accent - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - budget != null - ? s.yourMonthlySpendingLimit - : s.setMonthlySpendingLimit, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/features/settings/widgets/categories_section.dart b/lib/features/settings/widgets/categories_section.dart new file mode 100644 index 0000000..303bed3 --- /dev/null +++ b/lib/features/settings/widgets/categories_section.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/constants.dart'; +import '../../../core/l10n/locale_provider.dart'; +import '../../../core/services/haptic_service.dart'; + +class CategoriesSection extends ConsumerWidget { + const CategoriesSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = ref.watch(stringsProvider); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return GestureDetector( + onTap: () { + HapticService.light(); + context.push('/settings/categories'); + }, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.accent.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.category_rounded, + color: AppColors.accent, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.manageCategories, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + Text( + s.manageCategoriesSubtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right_rounded, + color: theme.colorScheme.onSurface.withOpacity(0.4), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/widgets/currency_section.dart b/lib/features/settings/widgets/currency_section.dart index 8495a70..54aaf97 100644 --- a/lib/features/settings/widgets/currency_section.dart +++ b/lib/features/settings/widgets/currency_section.dart @@ -62,11 +62,6 @@ class CurrencySection extends ConsumerWidget { padding: const EdgeInsets.only(right: 8), child: GestureDetector( onTap: () { - final oldCode = ref.read(currencyProvider).code; - final rates = ref.read(exchangeRateServiceProvider); - ref - .read(budgetProvider.notifier) - .onCurrencyChanged(oldCode, code, rates); ref.read(currencyProvider.notifier).setCurrency(code); }, child: Container( diff --git a/lib/shared/models/app_category.dart b/lib/shared/models/app_category.dart new file mode 100644 index 0000000..7142749 --- /dev/null +++ b/lib/shared/models/app_category.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'transaction.dart'; + +class AppCategory { + final String key; + final TransactionType type; + final String labelEn; + final String labelRu; + final IconData icon; + final Color color; + final String iconName; + final bool isCustom; + final int? id; + + const AppCategory({ + required this.key, + required this.type, + required this.labelEn, + required this.labelRu, + required this.icon, + required this.color, + required this.iconName, + this.isCustom = false, + this.id, + }); + + String label(bool isRu) { + final value = isRu ? labelRu : labelEn; + return value.isEmpty ? key : value; + } +} diff --git a/lib/shared/providers/category_provider.dart b/lib/shared/providers/category_provider.dart new file mode 100644 index 0000000..d2d7246 --- /dev/null +++ b/lib/shared/providers/category_provider.dart @@ -0,0 +1,173 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/constants.dart'; +import '../../core/utils/result.dart'; +import '../../data/database/app_database.dart'; +import '../../data/repositories/category_repository.dart'; +import '../../features/dashboard/provider.dart'; +import '../models/app_category.dart'; +import '../models/transaction.dart'; +import '../services/translation_service.dart'; + +final categoryRepositoryProvider = Provider((ref) { + return CategoryRepository(ref.watch(appDatabaseProvider)); +}); + +final customCategoriesProvider = StreamProvider>((ref) { + return ref.watch(categoryRepositoryProvider).watchAll(); +}); + +final translationServiceProvider = Provider((ref) { + return TranslationService(); +}); + +final categoryActionsProvider = Provider((ref) { + return CategoryActions(ref); +}); + +class CategoryActions { + final Ref _ref; + + CategoryActions(this._ref); + + CategoryRepository get _repo => _ref.read(categoryRepositoryProvider); + + Future> create({ + required TransactionType type, + required String labelEn, + required String labelRu, + required String iconName, + required int colorValue, + }) { + return asyncResultOf(() async { + final en = labelEn.trim(); + final ru = labelRu.trim(); + if (en.isEmpty && ru.isEmpty) { + throw Exception('Category name is required'); + } + final key = 'cat_${DateTime.now().microsecondsSinceEpoch}'; + await _repo.add( + name: key, + type: type.name, + labelEn: en.isEmpty ? ru : en, + labelRu: ru.isEmpty ? en : ru, + iconName: iconName, + colorValue: colorValue, + ); + }); + } + + Future> edit({ + required int id, + required TransactionType type, + required String labelEn, + required String labelRu, + required String iconName, + required int colorValue, + }) { + return asyncResultOf(() async { + final en = labelEn.trim(); + final ru = labelRu.trim(); + if (en.isEmpty && ru.isEmpty) { + throw Exception('Category name is required'); + } + await _repo.updateFields( + id, + type: type.name, + labelEn: en.isEmpty ? ru : en, + labelRu: ru.isEmpty ? en : ru, + iconName: iconName, + colorValue: colorValue, + ); + }); + } + + Future> remove(int id) { + return asyncResultOf(() async { + await _repo.delete(id); + }); + } +} + +class CategoryCatalog { + final List all; + + const CategoryCatalog(this.all); + + List forType(TransactionType type) => + all.where((c) => c.type == type).toList(); + + List get custom => all.where((c) => c.isCustom).toList(); + + AppCategory? byKey(String key) => + all.firstWhereOrNull((c) => c.key == key); + + IconData iconFor(String key) => + byKey(key)?.icon ?? Icons.category_rounded; + + Color colorFor(String key, [Color? fallback]) => + byKey(key)?.color ?? fallback ?? AppColors.accent; + + String labelFor(String key, bool isRu) => + byKey(key)?.label(isRu) ?? key; + + bool hasKey(String key) => byKey(key) != null; +} + +final categoryCatalogProvider = Provider((ref) { + final custom = ref.watch(customCategoriesProvider).value ?? const []; + final mapped = custom.map(_fromRow).toList(); + return CategoryCatalog([..._defaultCategories(), ...mapped]); +}); + +List _defaultCategories() { + final result = []; + for (final key in AppCategories.expenseCategories) { + result.add(_defaultCategory(key, TransactionType.expense)); + } + for (final key in AppCategories.incomeCategories) { + result.add(_defaultCategory(key, TransactionType.income)); + } + return result; +} + +AppCategory _defaultCategory(String key, TransactionType type) { + final iconName = AppCategories.iconNames[key] ?? 'category'; + return AppCategory( + key: key, + type: type, + labelEn: key, + labelRu: AppCategories.ruLabels[key] ?? key, + icon: categoryIconByName(iconName), + color: AppCategories.colors[key] ?? AppColors.accent, + iconName: iconName, + isCustom: false, + ); +} + +AppCategory _fromRow(Category row) { + final type = + row.type == 'income' ? TransactionType.income : TransactionType.expense; + final labelEn = (row.labelEn != null && row.labelEn!.isNotEmpty) + ? row.labelEn! + : row.name; + final labelRu = (row.labelRu != null && row.labelRu!.isNotEmpty) + ? row.labelRu! + : row.name; + final colorValue = int.tryParse(row.color ?? ''); + final color = colorValue != null + ? Color(colorValue) + : kCategoryColors[row.id % kCategoryColors.length]; + return AppCategory( + key: row.name, + type: type, + labelEn: labelEn, + labelRu: labelRu, + icon: categoryIconByName(row.icon), + color: color, + iconName: row.icon ?? 'category', + isCustom: true, + id: row.id, + ); +} diff --git a/lib/shared/services/storage_service.dart b/lib/shared/services/storage_service.dart index 9781af9..353e0cd 100644 --- a/lib/shared/services/storage_service.dart +++ b/lib/shared/services/storage_service.dart @@ -8,7 +8,6 @@ const _uuid = Uuid(); class StorageService { static const _transactionsKey = 'transactions'; - static const _budgetKey = 'monthly_budget'; static const _currencyKey = 'currency_symbol'; static const _themeKey = 'is_dark_mode'; @@ -90,23 +89,6 @@ class StorageService { }); } - double? loadBudget() { - return _prefs.getDouble(_budgetKey); - } - - Future> saveBudget(double? budget) async { - return asyncResultOf(() async { - if (budget == null) { - await _prefs.remove(_budgetKey); - } else { - if (budget < 0) { - throw Exception('Budget cannot be negative'); - } - await _prefs.setDouble(_budgetKey, budget); - } - }); - } - String loadCurrency() { return _prefs.getString(_currencyKey) ?? '\$'; } diff --git a/lib/shared/services/translation_service.dart b/lib/shared/services/translation_service.dart new file mode 100644 index 0000000..f2d9407 --- /dev/null +++ b/lib/shared/services/translation_service.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +enum TranslateDirection { ruToEn, enToRu } + +class TranslationResult { + final String text; + final bool fromCache; + + const TranslationResult(this.text, {this.fromCache = false}); +} + +class TranslationService { + static const Map _ruToEn = { + 'еда': 'Food', + 'продукты': 'Groceries', + 'транспорт': 'Transport', + 'покупки': 'Shopping', + 'здоровье': 'Health', + 'развлечения': 'Entertainment', + 'жильё': 'Housing', + 'жилье': 'Housing', + 'аренда': 'Rent', + 'образование': 'Education', + 'путешествия': 'Travel', + 'зарплата': 'Salary', + 'фриланс': 'Freelance', + 'инвестиции': 'Investment', + 'подарок': 'Gift', + 'подарки': 'Gifts', + 'возврат': 'Refund', + 'другое': 'Other', + 'коммунальные': 'Utilities', + 'одежда': 'Clothing', + 'спорт': 'Sports', + 'красота': 'Beauty', + 'питомцы': 'Pets', + 'животные': 'Pets', + 'бизнес': 'Business', + 'накопления': 'Savings', + 'кафе': 'Cafe', + 'кофе': 'Coffee', + 'ресторан': 'Restaurant', + 'связь': 'Communication', + 'интернет': 'Internet', + 'налоги': 'Taxes', + 'страховка': 'Insurance', + 'медицина': 'Medicine', + 'дети': 'Children', + 'хобби': 'Hobby', + 'музыка': 'Music', + 'игры': 'Games', + 'книги': 'Books', + 'топливо': 'Fuel', + 'такси': 'Taxi', + }; + + static final Map _enToRu = { + for (final entry in _ruToEn.entries) entry.value.toLowerCase(): entry.key, + }; + + String? _dictionaryLookup(String input, TranslateDirection direction) { + final normalized = input.trim().toLowerCase(); + if (normalized.isEmpty) return null; + final map = direction == TranslateDirection.ruToEn ? _ruToEn : _enToRu; + final hit = map[normalized]; + if (hit == null) return null; + return _capitalize(hit); + } + + Future translate( + String input, + TranslateDirection direction, + ) async { + final trimmed = input.trim(); + if (trimmed.isEmpty) return null; + + final cached = _dictionaryLookup(trimmed, direction); + if (cached != null) { + return TranslationResult(cached, fromCache: true); + } + + final pair = direction == TranslateDirection.ruToEn ? 'ru|en' : 'en|ru'; + final uri = Uri.parse( + 'https://api.mymemory.translated.net/get' + '?q=${Uri.encodeQueryComponent(trimmed)}&langpair=$pair', + ); + + try { + final response = + await http.get(uri).timeout(const Duration(seconds: 8)); + if (response.statusCode != 200) return null; + final decoded = jsonDecode(response.body) as Map; + final data = decoded['responseData'] as Map?; + final translated = data?['translatedText'] as String?; + if (translated == null || translated.trim().isEmpty) return null; + return TranslationResult(_capitalize(translated.trim())); + } catch (_) { + return null; + } + } + + String _capitalize(String value) { + if (value.isEmpty) return value; + return value[0].toUpperCase() + value.substring(1); + } +}