Compare commits

..

2 Commits

Author SHA1 Message Date
kolo 1a6ad1fe27 step
big
2026-06-28 02:56:09 +03:00
kolo bc51609f85 step
big
2026-06-28 02:19:57 +03:00
26 changed files with 1869 additions and 1542 deletions
+5
View File
@@ -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(),
),
],
);
+90
View File
@@ -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 {
+33 -15
View File
@@ -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';
}
+30 -16
View File
@@ -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)))
+145 -609
View File
@@ -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 =>
+3 -10
View File
@@ -21,22 +21,15 @@ class Transactions extends Table {
class Categories extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 1, max: 50)();
TextColumn get type => text()();
TextColumn get type => text()();
TextColumn get labelEn => text().nullable()();
TextColumn get labelRu => text().nullable()();
TextColumn get icon => text().nullable()();
TextColumn get color => text().nullable()();
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
class Budgets extends Table {
IntColumn get id => integer().autoIncrement()();
RealColumn get amount => real()();
TextColumn get categoryId => text().nullable()();
IntColumn get month => integer()();
IntColumn get year => integer()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
class ExchangeRates extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get fromCurrency => text()();
@@ -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);
}
}
+5 -2
View File
@@ -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,61 +22,111 @@ class CategoryPicker extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider);
final isRu = s.locale == AppLocale.ru;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.map((cat) {
final isSelected = cat == selected;
final color = AppCategories.colors[cat] ?? AppColors.accent;
final icon = AppCategories.icons[cat] ?? Icons.category_rounded;
return GestureDetector(
onTap: () => onChanged(cat),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isSelected
? color.withOpacity(0.2)
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: isSelected
? Border.all(color: color, width: 1.5)
: (isDark
? null
: Border.all(color: const Color(0xFFDDDDEE), width: 1)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: isSelected
? color
: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
size: 16,
),
const SizedBox(width: 6),
Text(
s.categoryLabel(cat),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
children: [
...categories.map((cat) {
final isSelected = cat.key == selected;
final color = cat.color;
return GestureDetector(
onTap: () {
HapticService.light();
onChanged(cat.key);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isSelected
? color.withOpacity(0.2)
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: isSelected
? Border.all(color: color, width: 1.5)
: (isDark
? null
: Border.all(
color: const Color(0xFFDDDDEE), width: 1)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
cat.icon,
color: isSelected
? color
: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
size: 16,
),
),
],
const SizedBox(width: 6),
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),
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,
),
),
],
),
),
);
}
}
+112 -200
View File
@@ -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,77 +62,15 @@ 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: () {
HapticService.selection();
setState(() {
_showIncome = false;
_touchedIndex = -1;
});
},
),
),
const SizedBox(width: 10),
Expanded(
child: _TypeSegment(
label: s.income,
isSelected: _showIncome,
color: AppColors.income,
onTap: () {
HapticService.selection();
setState(() {
_showIncome = true;
_touchedIndex = -1;
});
},
),
),
],
),
],
),
),
const SizedBox(height: 18),
StatsHeroCard(
amount: _showIncome ? summary.income : summary.expense,
label: _showIncome ? s.income.toUpperCase() : s.expenses.toUpperCase(),
accentColor: _showIncome ? AppColors.income : AppColors.expense,
scopeLabel: scopeLabel,
_IncomeExpenseToggle(
isIncome: _showIncome,
onChanged: (val) {
HapticService.selection();
setState(() {
_showIncome = val;
_touchedIndex = -1;
});
},
),
const SizedBox(height: 16),
_InsightCard(
@@ -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,
),
),
],
),
],
),
),
);
}
}
-30
View File
@@ -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();
-12
View File
@@ -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),
),
),
],
),
);
}
}
-28
View File
@@ -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;
+10 -5
View File
@@ -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(
+31
View File
@@ -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;
}
}
+173
View File
@@ -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,
);
}
-18
View File
@@ -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);
}
}