mirror of
https://github.com/koloideal/Casha.git
synced 2026-08-08 17:51:14 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6ad1fe27 | |||
| bc51609f85 |
@@ -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<NavigatorState>();
|
||||
@@ -44,6 +45,10 @@ final appRouter = GoRouter(
|
||||
return AddTransactionScreen(initial: transaction);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/categories',
|
||||
builder: (context, state) => const CategoryManagerScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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<String, IconData> 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<Color> 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 {
|
||||
|
||||
@@ -24,9 +24,6 @@ class AppStrings {
|
||||
String get filterMonth => _ru ? 'Месяц' : 'Month';
|
||||
String get income => _ru ? 'Доход' : 'Income';
|
||||
String get expenses => _ru ? 'Расходы' : 'Expenses';
|
||||
String get monthlyBudget => _ru ? 'Бюджет на месяц' : 'Monthly Budget';
|
||||
String get spent => _ru ? 'Потрачено' : 'Spent';
|
||||
String get limit => _ru ? 'Лимит' : 'Limit';
|
||||
String get noTransactions =>
|
||||
_ru ? 'Транзакции не найдены' : 'No transactions found';
|
||||
String get addFirstTx => _ru
|
||||
@@ -80,18 +77,6 @@ class AppStrings {
|
||||
String get language => _ru ? 'Язык' : 'Language';
|
||||
String get langRu => _ru ? 'Русский' : 'Russian';
|
||||
String get langEn => _ru ? 'Английский' : 'English';
|
||||
String get budget => _ru ? 'Бюджет' : 'Budget';
|
||||
String get budgetHint => _ru ? 'Месячный лимит' : 'Monthly limit';
|
||||
String get budgetNone => _ru ? 'Не установлен' : 'Not set';
|
||||
String get monthlyBudgetSetting => _ru ? 'Месячный бюджет' : 'Monthly Budget';
|
||||
String get yourMonthlySpendingLimit =>
|
||||
_ru ? 'Ваш лимит расходов на месяц' : 'Your monthly spending limit';
|
||||
String get setMonthlySpendingLimit => _ru
|
||||
? 'Контролируйте свои расходы за месяц'
|
||||
: 'Track your monthly spending';
|
||||
String get leaveEmptyToRemove => _ru
|
||||
? 'Оставьте пустым для удаления лимита'
|
||||
: 'Leave empty to remove budget limit';
|
||||
String get data => _ru ? 'Данные' : 'Data';
|
||||
String get exportData => _ru ? 'Экспорт данных' : 'Export data';
|
||||
String get clearData => _ru ? 'Очистить данные' : 'Clear all data';
|
||||
@@ -169,6 +154,7 @@ class AppStrings {
|
||||
'Freelance': 'Фриланс',
|
||||
'Investment': 'Инвестиции',
|
||||
'Gift': 'Подарок',
|
||||
'Refund': 'Возврат',
|
||||
'Other': 'Другое',
|
||||
'Utilities': 'Коммунальные',
|
||||
'Clothing': 'Одежда',
|
||||
@@ -220,5 +206,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';
|
||||
}
|
||||
|
||||
@@ -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<List<Category>> watchAllCategories() {
|
||||
return (select(categories)
|
||||
..orderBy([(c) => OrderingTerm.asc(c.createdAt)]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
Future<List<Category>> 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<Budget?> getBudget(int month, int year) {
|
||||
return (select(budgets)
|
||||
..where((b) => b.month.equals(month) & b.year.equals(year)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> upsertBudget(BudgetsCompanion budget) {
|
||||
return into(budgets).insertOnConflictUpdate(budget);
|
||||
}
|
||||
|
||||
Future<int> deleteBudget(int id) {
|
||||
return (delete(budgets)..where((b) => b.id.equals(id))).go();
|
||||
}
|
||||
|
||||
Future<ExchangeRate?> getExchangeRate(String from, String to) {
|
||||
return (select(exchangeRates)
|
||||
..where((r) => r.fromCurrency.equals(from) & r.toCurrency.equals(to)))
|
||||
|
||||
@@ -745,6 +745,28 @@ class $CategoriesTable extends Categories
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _labelEnMeta = const VerificationMeta(
|
||||
'labelEn',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> labelEn = GeneratedColumn<String>(
|
||||
'label_en',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _labelRuMeta = const VerificationMeta(
|
||||
'labelRu',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> labelRu = GeneratedColumn<String>(
|
||||
'label_ru',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _iconMeta = const VerificationMeta('icon');
|
||||
@override
|
||||
late final GeneratedColumn<String> icon = GeneratedColumn<String>(
|
||||
@@ -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<Category> {
|
||||
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<Category> {
|
||||
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<Category> {
|
||||
map['id'] = Variable<int>(id);
|
||||
map['name'] = Variable<String>(name);
|
||||
map['type'] = Variable<String>(type);
|
||||
if (!nullToAbsent || labelEn != null) {
|
||||
map['label_en'] = Variable<String>(labelEn);
|
||||
}
|
||||
if (!nullToAbsent || labelRu != null) {
|
||||
map['label_ru'] = Variable<String>(labelRu);
|
||||
}
|
||||
if (!nullToAbsent || icon != null) {
|
||||
map['icon'] = Variable<String>(icon);
|
||||
}
|
||||
@@ -940,6 +994,12 @@ class Category extends DataClass implements Insertable<Category> {
|
||||
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<Category> {
|
||||
id: serializer.fromJson<int>(json['id']),
|
||||
name: serializer.fromJson<String>(json['name']),
|
||||
type: serializer.fromJson<String>(json['type']),
|
||||
labelEn: serializer.fromJson<String?>(json['labelEn']),
|
||||
labelRu: serializer.fromJson<String?>(json['labelRu']),
|
||||
icon: serializer.fromJson<String?>(json['icon']),
|
||||
color: serializer.fromJson<String?>(json['color']),
|
||||
isDefault: serializer.fromJson<bool>(json['isDefault']),
|
||||
@@ -971,6 +1033,8 @@ class Category extends DataClass implements Insertable<Category> {
|
||||
'id': serializer.toJson<int>(id),
|
||||
'name': serializer.toJson<String>(name),
|
||||
'type': serializer.toJson<String>(type),
|
||||
'labelEn': serializer.toJson<String?>(labelEn),
|
||||
'labelRu': serializer.toJson<String?>(labelRu),
|
||||
'icon': serializer.toJson<String?>(icon),
|
||||
'color': serializer.toJson<String?>(color),
|
||||
'isDefault': serializer.toJson<bool>(isDefault),
|
||||
@@ -982,6 +1046,8 @@ class Category extends DataClass implements Insertable<Category> {
|
||||
int? id,
|
||||
String? name,
|
||||
String? type,
|
||||
Value<String?> labelEn = const Value.absent(),
|
||||
Value<String?> labelRu = const Value.absent(),
|
||||
Value<String?> icon = const Value.absent(),
|
||||
Value<String?> color = const Value.absent(),
|
||||
bool? isDefault,
|
||||
@@ -990,6 +1056,8 @@ class Category extends DataClass implements Insertable<Category> {
|
||||
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<Category> {
|
||||
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<Category> {
|
||||
..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<Category> {
|
||||
}
|
||||
|
||||
@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<Category> {
|
||||
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<Category> {
|
||||
final Value<int> id;
|
||||
final Value<String> name;
|
||||
final Value<String> type;
|
||||
final Value<String?> labelEn;
|
||||
final Value<String?> labelRu;
|
||||
final Value<String?> icon;
|
||||
final Value<String?> color;
|
||||
final Value<bool> isDefault;
|
||||
@@ -1049,6 +1134,8 @@ class CategoriesCompanion extends UpdateCompanion<Category> {
|
||||
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<Category> {
|
||||
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<Category> {
|
||||
Expression<int>? id,
|
||||
Expression<String>? name,
|
||||
Expression<String>? type,
|
||||
Expression<String>? labelEn,
|
||||
Expression<String>? labelRu,
|
||||
Expression<String>? icon,
|
||||
Expression<String>? color,
|
||||
Expression<bool>? isDefault,
|
||||
@@ -1077,6 +1168,8 @@ class CategoriesCompanion extends UpdateCompanion<Category> {
|
||||
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<Category> {
|
||||
Value<int>? id,
|
||||
Value<String>? name,
|
||||
Value<String>? type,
|
||||
Value<String?>? labelEn,
|
||||
Value<String?>? labelRu,
|
||||
Value<String?>? icon,
|
||||
Value<String?>? color,
|
||||
Value<bool>? isDefault,
|
||||
@@ -1097,6 +1192,8 @@ class CategoriesCompanion extends UpdateCompanion<Category> {
|
||||
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<Category> {
|
||||
if (type.present) {
|
||||
map['type'] = Variable<String>(type.value);
|
||||
}
|
||||
if (labelEn.present) {
|
||||
map['label_en'] = Variable<String>(labelEn.value);
|
||||
}
|
||||
if (labelRu.present) {
|
||||
map['label_ru'] = Variable<String>(labelRu.value);
|
||||
}
|
||||
if (icon.present) {
|
||||
map['icon'] = Variable<String>(icon.value);
|
||||
}
|
||||
@@ -1137,6 +1240,8 @@ class CategoriesCompanion extends UpdateCompanion<Category> {
|
||||
..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<Category> {
|
||||
}
|
||||
}
|
||||
|
||||
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<int> id = GeneratedColumn<int>(
|
||||
'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<double> amount = GeneratedColumn<double>(
|
||||
'amount',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _categoryIdMeta = const VerificationMeta(
|
||||
'categoryId',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> categoryId = GeneratedColumn<String>(
|
||||
'category_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _monthMeta = const VerificationMeta('month');
|
||||
@override
|
||||
late final GeneratedColumn<int> month = GeneratedColumn<int>(
|
||||
'month',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _yearMeta = const VerificationMeta('year');
|
||||
@override
|
||||
late final GeneratedColumn<int> year = GeneratedColumn<int>(
|
||||
'year',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _createdAtMeta = const VerificationMeta(
|
||||
'createdAt',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
|
||||
'created_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: currentDateAndTime,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> 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<Budget> 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<GeneratedColumn> get $primaryKey => {id};
|
||||
@override
|
||||
Budget map(Map<String, dynamic> 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<Budget> {
|
||||
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<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['id'] = Variable<int>(id);
|
||||
map['amount'] = Variable<double>(amount);
|
||||
if (!nullToAbsent || categoryId != null) {
|
||||
map['category_id'] = Variable<String>(categoryId);
|
||||
}
|
||||
map['month'] = Variable<int>(month);
|
||||
map['year'] = Variable<int>(year);
|
||||
map['created_at'] = Variable<DateTime>(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<String, dynamic> json, {
|
||||
ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return Budget(
|
||||
id: serializer.fromJson<int>(json['id']),
|
||||
amount: serializer.fromJson<double>(json['amount']),
|
||||
categoryId: serializer.fromJson<String?>(json['categoryId']),
|
||||
month: serializer.fromJson<int>(json['month']),
|
||||
year: serializer.fromJson<int>(json['year']),
|
||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'id': serializer.toJson<int>(id),
|
||||
'amount': serializer.toJson<double>(amount),
|
||||
'categoryId': serializer.toJson<String?>(categoryId),
|
||||
'month': serializer.toJson<int>(month),
|
||||
'year': serializer.toJson<int>(year),
|
||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
Budget copyWith({
|
||||
int? id,
|
||||
double? amount,
|
||||
Value<String?> 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<Budget> {
|
||||
final Value<int> id;
|
||||
final Value<double> amount;
|
||||
final Value<String?> categoryId;
|
||||
final Value<int> month;
|
||||
final Value<int> year;
|
||||
final Value<DateTime> 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<Budget> custom({
|
||||
Expression<int>? id,
|
||||
Expression<double>? amount,
|
||||
Expression<String>? categoryId,
|
||||
Expression<int>? month,
|
||||
Expression<int>? year,
|
||||
Expression<DateTime>? 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<int>? id,
|
||||
Value<double>? amount,
|
||||
Value<String?>? categoryId,
|
||||
Value<int>? month,
|
||||
Value<int>? year,
|
||||
Value<DateTime>? 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<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (id.present) {
|
||||
map['id'] = Variable<int>(id.value);
|
||||
}
|
||||
if (amount.present) {
|
||||
map['amount'] = Variable<double>(amount.value);
|
||||
}
|
||||
if (categoryId.present) {
|
||||
map['category_id'] = Variable<String>(categoryId.value);
|
||||
}
|
||||
if (month.present) {
|
||||
map['month'] = Variable<int>(month.value);
|
||||
}
|
||||
if (year.present) {
|
||||
map['year'] = Variable<int>(year.value);
|
||||
}
|
||||
if (createdAt.present) {
|
||||
map['created_at'] = Variable<DateTime>(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<DatabaseSchemaEntity> get allSchemaEntities => [
|
||||
transactions,
|
||||
categories,
|
||||
budgets,
|
||||
exchangeRates,
|
||||
accounts,
|
||||
];
|
||||
@@ -2654,6 +2363,8 @@ typedef $$CategoriesTableCreateCompanionBuilder =
|
||||
Value<int> id,
|
||||
required String name,
|
||||
required String type,
|
||||
Value<String?> labelEn,
|
||||
Value<String?> labelRu,
|
||||
Value<String?> icon,
|
||||
Value<String?> color,
|
||||
Value<bool> isDefault,
|
||||
@@ -2664,6 +2375,8 @@ typedef $$CategoriesTableUpdateCompanionBuilder =
|
||||
Value<int> id,
|
||||
Value<String> name,
|
||||
Value<String> type,
|
||||
Value<String?> labelEn,
|
||||
Value<String?> labelRu,
|
||||
Value<String?> icon,
|
||||
Value<String?> color,
|
||||
Value<bool> isDefault,
|
||||
@@ -2694,6 +2407,16 @@ class $$CategoriesTableFilterComposer
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get labelEn => $composableBuilder(
|
||||
column: $table.labelEn,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get labelRu => $composableBuilder(
|
||||
column: $table.labelRu,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get icon => $composableBuilder(
|
||||
column: $table.icon,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
@@ -2739,6 +2462,16 @@ class $$CategoriesTableOrderingComposer
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get labelEn => $composableBuilder(
|
||||
column: $table.labelEn,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get labelRu => $composableBuilder(
|
||||
column: $table.labelRu,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get icon => $composableBuilder(
|
||||
column: $table.icon,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
@@ -2778,6 +2511,12 @@ class $$CategoriesTableAnnotationComposer
|
||||
GeneratedColumn<String> get type =>
|
||||
$composableBuilder(column: $table.type, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get labelEn =>
|
||||
$composableBuilder(column: $table.labelEn, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get labelRu =>
|
||||
$composableBuilder(column: $table.labelRu, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get icon =>
|
||||
$composableBuilder(column: $table.icon, builder: (column) => column);
|
||||
|
||||
@@ -2822,6 +2561,8 @@ class $$CategoriesTableTableManager
|
||||
Value<int> id = const Value.absent(),
|
||||
Value<String> name = const Value.absent(),
|
||||
Value<String> type = const Value.absent(),
|
||||
Value<String?> labelEn = const Value.absent(),
|
||||
Value<String?> labelRu = const Value.absent(),
|
||||
Value<String?> icon = const Value.absent(),
|
||||
Value<String?> color = const Value.absent(),
|
||||
Value<bool> 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<int> id = const Value.absent(),
|
||||
required String name,
|
||||
required String type,
|
||||
Value<String?> labelEn = const Value.absent(),
|
||||
Value<String?> labelRu = const Value.absent(),
|
||||
Value<String?> icon = const Value.absent(),
|
||||
Value<String?> color = const Value.absent(),
|
||||
Value<bool> 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<int> id,
|
||||
required double amount,
|
||||
Value<String?> categoryId,
|
||||
required int month,
|
||||
required int year,
|
||||
Value<DateTime> createdAt,
|
||||
});
|
||||
typedef $$BudgetsTableUpdateCompanionBuilder =
|
||||
BudgetsCompanion Function({
|
||||
Value<int> id,
|
||||
Value<double> amount,
|
||||
Value<String?> categoryId,
|
||||
Value<int> month,
|
||||
Value<int> year,
|
||||
Value<DateTime> createdAt,
|
||||
});
|
||||
|
||||
class $$BudgetsTableFilterComposer
|
||||
extends Composer<_$AppDatabase, $BudgetsTable> {
|
||||
$$BudgetsTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<int> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<double> get amount => $composableBuilder(
|
||||
column: $table.amount,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get categoryId => $composableBuilder(
|
||||
column: $table.categoryId,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get month => $composableBuilder(
|
||||
column: $table.month,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get year => $composableBuilder(
|
||||
column: $table.year,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<DateTime> 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<int> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<double> get amount => $composableBuilder(
|
||||
column: $table.amount,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get categoryId => $composableBuilder(
|
||||
column: $table.categoryId,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get month => $composableBuilder(
|
||||
column: $table.month,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get year => $composableBuilder(
|
||||
column: $table.year,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<DateTime> 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<int> get id =>
|
||||
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<double> get amount =>
|
||||
$composableBuilder(column: $table.amount, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get categoryId => $composableBuilder(
|
||||
column: $table.categoryId,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get month =>
|
||||
$composableBuilder(column: $table.month, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<int> get year =>
|
||||
$composableBuilder(column: $table.year, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> 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<int> id = const Value.absent(),
|
||||
Value<double> amount = const Value.absent(),
|
||||
Value<String?> categoryId = const Value.absent(),
|
||||
Value<int> month = const Value.absent(),
|
||||
Value<int> year = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
}) => BudgetsCompanion(
|
||||
id: id,
|
||||
amount: amount,
|
||||
categoryId: categoryId,
|
||||
month: month,
|
||||
year: year,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
Value<int> id = const Value.absent(),
|
||||
required double amount,
|
||||
Value<String?> categoryId = const Value.absent(),
|
||||
required int month,
|
||||
required int year,
|
||||
Value<DateTime> 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<int> 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 =>
|
||||
|
||||
@@ -22,21 +22,14 @@ class Categories extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get name => text().withLength(min: 1, max: 50)();
|
||||
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()();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import '../database/app_database.dart';
|
||||
|
||||
class CategoryRepository {
|
||||
final AppDatabase _db;
|
||||
|
||||
CategoryRepository(this._db);
|
||||
|
||||
Stream<List<Category>> watchAll() {
|
||||
return _db.watchAllCategories();
|
||||
}
|
||||
|
||||
Future<List<Category>> getAll() {
|
||||
return _db.getAllCategories();
|
||||
}
|
||||
|
||||
Future<int> 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<void> 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<int> delete(int id) {
|
||||
return _db.deleteCategory(id);
|
||||
}
|
||||
}
|
||||
@@ -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<AddTransactionState> {
|
||||
}
|
||||
|
||||
final availableCategoriesProvider = Provider.autoDispose
|
||||
.family<List<String>, Transaction?>((ref, initial) {
|
||||
.family<List<AppCategory>, 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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<String> categories;
|
||||
final List<AppCategory> categories;
|
||||
final String selected;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@@ -18,16 +22,20 @@ 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;
|
||||
children: [
|
||||
...categories.map((cat) {
|
||||
final isSelected = cat.key == selected;
|
||||
final color = cat.color;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(cat),
|
||||
onTap: () {
|
||||
HapticService.light();
|
||||
onChanged(cat.key);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
@@ -40,13 +48,14 @@ class CategoryPicker extends ConsumerWidget {
|
||||
? Border.all(color: color, width: 1.5)
|
||||
: (isDark
|
||||
? null
|
||||
: Border.all(color: const Color(0xFFDDDDEE), width: 1)),
|
||||
: Border.all(
|
||||
color: const Color(0xFFDDDDEE), width: 1)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
cat.icon,
|
||||
color: isSelected
|
||||
? color
|
||||
: Theme.of(
|
||||
@@ -55,24 +64,69 @@ class CategoryPicker extends ConsumerWidget {
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
s.categoryLabel(cat),
|
||||
Flexible(
|
||||
child: Text(
|
||||
cat.label(isRu),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isSelected
|
||||
? color
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withOpacity(0.6),
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+107
-195
@@ -7,13 +7,12 @@ 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/providers/category_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});
|
||||
@@ -29,17 +28,17 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final isRu = s.locale == AppLocale.ru;
|
||||
final catalog = ref.watch(categoryCatalogProvider);
|
||||
final summary = ref.watch(statsSummaryProvider);
|
||||
final data = _showIncome
|
||||
? ref.watch(categoryIncomeProvider)
|
||||
: 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,78 +62,16 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
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: () {
|
||||
_IncomeExpenseToggle(
|
||||
isIncome: _showIncome,
|
||||
onChanged: (val) {
|
||||
HapticService.selection();
|
||||
setState(() {
|
||||
_showIncome = false;
|
||||
_showIncome = val;
|
||||
_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,
|
||||
@@ -145,7 +82,7 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 1.55,
|
||||
childAspectRatio: 1.22,
|
||||
children: [
|
||||
_MetricTile(
|
||||
label: s.income,
|
||||
@@ -207,7 +144,7 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
title: s.topCategories,
|
||||
subtitle: topEntry == null
|
||||
? s.rankedByAmount
|
||||
: '${s.topCategory}: ${s.categoryLabel(topEntry.key)}',
|
||||
: '${s.topCategory}: ${catalog.labelFor(topEntry.key, isRu)}',
|
||||
child: Column(
|
||||
children: [
|
||||
_SummaryBadgeRow(
|
||||
@@ -240,32 +177,6 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 +227,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<String, double> data;
|
||||
final double total;
|
||||
@@ -426,6 +247,8 @@ class _PieChartSection extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final isRu = s.locale == AppLocale.ru;
|
||||
final catalog = ref.watch(categoryCatalogProvider);
|
||||
final fmt = ref.watch(amountFormatProvider);
|
||||
final entries = data.entries.toList();
|
||||
final accent = isIncome ? AppColors.income : AppColors.expense;
|
||||
@@ -459,7 +282,7 @@ class _PieChartSection extends ConsumerWidget {
|
||||
final isTouched = i == touchedIndex;
|
||||
final cat = entries[i].key;
|
||||
final val = entries[i].value;
|
||||
final color = AppCategories.colors[cat] ?? AppColors.accent;
|
||||
final color = catalog.colorFor(cat, accent);
|
||||
return PieChartSectionData(
|
||||
color: color,
|
||||
value: val,
|
||||
@@ -514,7 +337,7 @@ class _PieChartSection extends ConsumerWidget {
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: (AppCategories.colors[selectedEntry.key] ?? accent).withOpacity(0.10),
|
||||
color: catalog.colorFor(selectedEntry.key, accent).withOpacity(0.10),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Row(
|
||||
@@ -523,14 +346,14 @@ class _PieChartSection extends ConsumerWidget {
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: AppCategories.colors[selectedEntry.key] ?? accent,
|
||||
color: catalog.colorFor(selectedEntry.key, accent),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.categoryLabel(selectedEntry.key),
|
||||
catalog.labelFor(selectedEntry.key, isRu),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
@@ -652,9 +475,11 @@ class _CategoryItem extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final isRu = s.locale == AppLocale.ru;
|
||||
final catalog = ref.watch(categoryCatalogProvider);
|
||||
final fmt = ref.watch(amountFormatProvider);
|
||||
final color = AppCategories.colors[category] ?? AppColors.accent;
|
||||
final icon = AppCategories.icons[category] ?? Icons.category_rounded;
|
||||
final color = catalog.colorFor(category, AppColors.accent);
|
||||
final icon = catalog.iconFor(category);
|
||||
final pct = total > 0 ? amount / total : 0.0;
|
||||
|
||||
return Container(
|
||||
@@ -681,7 +506,7 @@ class _CategoryItem extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
s.categoryLabel(category),
|
||||
catalog.labelFor(category, isRu),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -1041,3 +866,90 @@ class _EmptyState extends ConsumerWidget {
|
||||
return isIncome;
|
||||
}
|
||||
}
|
||||
|
||||
class _IncomeExpenseToggle extends ConsumerWidget {
|
||||
final bool isIncome;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const _IncomeExpenseToggle({
|
||||
required this.isIncome,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.06),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ToggleChip(
|
||||
label: s.expenses,
|
||||
isSelected: !isIncome,
|
||||
color: AppColors.expense,
|
||||
onTap: () => onChanged(false),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _ToggleChip(
|
||||
label: s.income,
|
||||
isSelected: isIncome,
|
||||
color: AppColors.income,
|
||||
onTap: () => onChanged(true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToggleChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ToggleChip({
|
||||
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: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? color.withOpacity(0.15) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? color
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -273,36 +273,6 @@ final totalExpenseProvider = Provider<double>((ref) {
|
||||
});
|
||||
});
|
||||
|
||||
final currentMonthExpenseProvider = Provider<double>((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<List<Transaction>>((ref) {
|
||||
final txs = ref.watch(accountFilteredTransactionsProvider);
|
||||
final query = ref.watch(searchQueryProvider).toLowerCase();
|
||||
|
||||
@@ -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<DashboardScreen> {
|
||||
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<DashboardScreen> {
|
||||
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,
|
||||
|
||||
@@ -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<Color>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> showCategoryEditor(
|
||||
BuildContext context, {
|
||||
AppCategory? existing,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
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<CategoryEditorSheet> createState() =>
|
||||
_CategoryEditorSheetState();
|
||||
}
|
||||
|
||||
class _CategoryEditorSheetState extends ConsumerState<CategoryEditorSheet> {
|
||||
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<void> _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<void> _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<void> _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<TransactionType> 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<String> 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<int> 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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<void> _confirmDelete(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppCategory category,
|
||||
) async {
|
||||
final s = ref.read(stringsProvider);
|
||||
final confirmed = await showDialog<bool>(
|
||||
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 ConsumerWidget {
|
||||
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, WidgetRef ref) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
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 ? s.income : s.expenses,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, double?>(
|
||||
BudgetNotifier.new,
|
||||
);
|
||||
|
||||
class BudgetNotifier extends Notifier<double?> {
|
||||
@override
|
||||
double? build() {
|
||||
final storage = ref.watch(storageServiceProvider);
|
||||
return storage.loadBudget();
|
||||
}
|
||||
|
||||
Future<void> 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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -139,7 +144,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
const AmountFormatSection(),
|
||||
const SizedBox(height: 16),
|
||||
const BudgetSection(),
|
||||
const CategoriesSection(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
s.dangerZone,
|
||||
|
||||
@@ -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<BudgetSection> createState() => _BudgetSectionState();
|
||||
}
|
||||
|
||||
class _BudgetSectionState extends ConsumerState<BudgetSection> {
|
||||
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<void> _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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<CategoryRepository>((ref) {
|
||||
return CategoryRepository(ref.watch(appDatabaseProvider));
|
||||
});
|
||||
|
||||
final customCategoriesProvider = StreamProvider<List<Category>>((ref) {
|
||||
return ref.watch(categoryRepositoryProvider).watchAll();
|
||||
});
|
||||
|
||||
final translationServiceProvider = Provider<TranslationService>((ref) {
|
||||
return TranslationService();
|
||||
});
|
||||
|
||||
final categoryActionsProvider = Provider<CategoryActions>((ref) {
|
||||
return CategoryActions(ref);
|
||||
});
|
||||
|
||||
class CategoryActions {
|
||||
final Ref _ref;
|
||||
|
||||
CategoryActions(this._ref);
|
||||
|
||||
CategoryRepository get _repo => _ref.read(categoryRepositoryProvider);
|
||||
|
||||
Future<Result<void>> 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<Result<void>> 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<Result<void>> remove(int id) {
|
||||
return asyncResultOf(() async {
|
||||
await _repo.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryCatalog {
|
||||
final List<AppCategory> all;
|
||||
|
||||
const CategoryCatalog(this.all);
|
||||
|
||||
List<AppCategory> forType(TransactionType type) =>
|
||||
all.where((c) => c.type == type).toList();
|
||||
|
||||
List<AppCategory> 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<CategoryCatalog>((ref) {
|
||||
final custom = ref.watch(customCategoriesProvider).value ?? const [];
|
||||
final mapped = custom.map(_fromRow).toList();
|
||||
return CategoryCatalog([..._defaultCategories(), ...mapped]);
|
||||
});
|
||||
|
||||
List<AppCategory> _defaultCategories() {
|
||||
final result = <AppCategory>[];
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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<Result<void>> 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) ?? '\$';
|
||||
}
|
||||
|
||||
@@ -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<String, String> _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<String, String> _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<TranslationResult?> 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<String, dynamic>;
|
||||
final data = decoded['responseData'] as Map<String, dynamic>?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user