diff --git a/lib/src/app/locale/app_locale_controller.dart b/lib/src/app/locale/app_locale_controller.dart new file mode 100644 index 0000000..24eb3f1 --- /dev/null +++ b/lib/src/app/locale/app_locale_controller.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../features/settings/application/settings_controller.dart'; +import '../../features/settings/application/settings_providers.dart'; +import '../../features/user/application/active_user_controller.dart'; + +part 'app_locale_controller.g.dart'; + +@Riverpod(keepAlive: true) +class AppLocaleController extends _$AppLocaleController { + @override + Locale? build() { + final userId = ref.watch(activeUserControllerProvider).asData?.value?.id; + if (userId == null) return null; + + final locale = ref + .watch(settingsStreamProvider(userId)) + .asData + ?.value + ?.locale; + if (locale == null || locale.isEmpty) return null; + return Locale(locale); + } + + Future setLocale(String localeCode) async { + final userId = ref.read(activeUserControllerProvider).asData?.value?.id; + if (userId == null) return; + await ref + .read(settingsControllerProvider(userId).notifier) + .setLocale(localeCode); + } +} diff --git a/lib/src/features/accounts/presentation/screens/account_form_screen.dart b/lib/src/features/accounts/presentation/screens/account_form_screen.dart new file mode 100644 index 0000000..5f2a385 --- /dev/null +++ b/lib/src/features/accounts/presentation/screens/account_form_screen.dart @@ -0,0 +1,913 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../app/l10n/l10n.dart'; +import '../../../../app/theme/app_colors.dart'; +import '../../../../core/database/converters/enum_converters.dart'; +import '../../../../core/logging/app_logger.dart'; +import '../../../categories/presentation/widgets/category_color_palette.dart'; +import '../../../categories/presentation/widgets/category_icon.dart'; +import '../../../categories/presentation/widgets/color_picker_sheet.dart'; +import '../../../categories/presentation/widgets/icon_picker_sheet.dart'; +import '../widgets/currency_picker_sheet.dart'; +import '../../../user/application/active_user_controller.dart'; +import '../../application/account_providers.dart'; +import '../../application/accounts_controller.dart'; +import '../../domain/entities/account.dart'; + +/// Форма создания (`accountId == null`) или редактирования счёта. +/// Возвращает `id` созданного/обновлённого счёта при `pop`. +class AccountFormScreen extends ConsumerWidget { + const AccountFormScreen({super.key, this.accountId}); + + final String? accountId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.palette; + final activeUser = ref.watch(activeUserControllerProvider); + + return Scaffold( + backgroundColor: p.paper, + body: activeUser.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e')), + data: (user) { + if (user == null) return const SizedBox.shrink(); + if (accountId == null) { + return _FormBody(userId: user.id, initial: null); + } + return _EditLoader(userId: user.id, accountId: accountId!); + }, + ), + ); + } +} + +class _EditLoader extends ConsumerStatefulWidget { + const _EditLoader({required this.userId, required this.accountId}); + final String userId; + final String accountId; + + @override + ConsumerState<_EditLoader> createState() => _EditLoaderState(); +} + +class _EditLoaderState extends ConsumerState<_EditLoader> { + late Future _future; + + @override + void initState() { + super.initState(); + _future = ref.read(accountRepositoryProvider).findById(widget.accountId); + } + + @override + void didUpdateWidget(_EditLoader oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.accountId != widget.accountId) { + _future = + ref.read(accountRepositoryProvider).findById(widget.accountId); + } + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + return FutureBuilder( + future: _future, + builder: (context, snap) { + if (!snap.hasData && snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final account = snap.data; + if (account == null) { + return Center(child: Text('—', style: TextStyle(color: p.ink2))); + } + return _FormBody(userId: widget.userId, initial: account); + }, + ); + } +} + +class _FormBody extends ConsumerStatefulWidget { + const _FormBody({required this.userId, required this.initial}); + + final String userId; + final Account? initial; + + @override + ConsumerState<_FormBody> createState() => _FormBodyState(); +} + +class _FormBodyState extends ConsumerState<_FormBody> { + late final TextEditingController _nameCtrl; + late final TextEditingController _balanceCtrl; + late String _currency; + late AccountType _type; + late int? _iconCode; + late int _colorValue; + late bool _isDefault; + bool _submitting = false; + String? _nameError; + + @override + void initState() { + super.initState(); + final a = widget.initial; + _nameCtrl = TextEditingController(text: a?.name ?? ''); + _currency = a?.currency ?? 'RUB'; + _balanceCtrl = TextEditingController( + text: a != null ? (a.initialBalance / 100).toStringAsFixed(2) : '', + ); + _type = a?.type ?? AccountType.cash; + _iconCode = a?.iconCode; + _colorValue = a?.colorValue ?? randomCategoryColor(); + _isDefault = a?.isDefault ?? false; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _balanceCtrl.dispose(); + super.dispose(); + } + + bool get _isEdit => widget.initial != null; + + IconData get _resolvedIcon { + if (_iconCode != null) return iconForCode(_iconCode!); + switch (_type) { + case AccountType.cash: + return Icons.payments_outlined; + case AccountType.card: + return Icons.credit_card_outlined; + case AccountType.bank: + return Icons.account_balance_outlined; + case AccountType.savings: + return Icons.savings_outlined; + } + } + + int _parseBalance() { + final text = _balanceCtrl.text.trim().replaceAll(',', '.'); + final val = double.tryParse(text) ?? 0.0; + return (val * 100).round(); + } + + Future _save() async { + final l10n = context.l10n; + final name = _nameCtrl.text.trim(); + if (name.isEmpty) { + setState(() => _nameError = l10n.accountValidationNameRequired); + return; + } + if (name.length > 50) { + setState(() => _nameError = l10n.accountValidationNameTooLong); + return; + } + + setState(() { + _submitting = true; + _nameError = null; + }); + + final accountsCtrl = ref.read(accountsControllerProvider.notifier); + String resultId; + try { + if (_isEdit) { + final updated = await accountsCtrl.updateAccount( + widget.initial!.copyWith( + name: name, + type: _type, + currency: _currency, + initialBalance: _parseBalance(), + iconCode: _iconCode, + colorValue: _colorValue, + ), + ); + resultId = updated.id; + } else { + final created = await accountsCtrl.createAccount( + userId: widget.userId, + name: name, + type: _type, + currency: _currency, + initialBalance: _parseBalance(), + iconCode: _iconCode, + colorValue: _colorValue, + ); + resultId = created.id; + } + } catch (e, st) { + AppLogger.error( + _isEdit + ? 'Failed to update account (id=${widget.initial!.id})' + : 'Failed to create account', + error: e, + stackTrace: st, + tag: 'account_form', + ); + if (mounted) { + setState(() => _submitting = false); + _snack(context.l10n.accountSaveError); + } + return; + } + + // Установить/снять умолчательный счёт атомарно. + // При создании нового счёта: трогаем дефолт только если пользователь + // явно включил переключатель. При редактировании: снимаем дефолт если + // переключатель был выключен у ранее дефолтного счёта. + // Ошибка этого шага НЕ должна выглядеть как ошибка сохранения — + // счёт уже создан, повторное нажатие создаст дубликат. + var defaultOk = true; + try { + if (_isDefault) { + await accountsCtrl.setDefaultAccount(resultId, widget.userId); + } else if (_isEdit && (widget.initial?.isDefault ?? false)) { + await accountsCtrl.setDefaultAccount(null, widget.userId); + } + } catch (e, st) { + defaultOk = false; + AppLogger.error( + 'Failed to update default flag (id=$resultId)', + error: e, + stackTrace: st, + tag: 'account_form', + ); + } + + if (!mounted) return; + if (!defaultOk) { + _snack(context.l10n.accountSetDefaultError); + } + Navigator.of(context).pop(resultId); + } + + Future _archive() async { + final l10n = context.l10n; + final id = widget.initial?.id; + if (id == null) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.accountArchiveConfirmTitle), + content: Text(l10n.accountArchiveConfirmMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.txDeleteConfirmNo), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.accountArchiveButton), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _submitting = true); + try { + await ref + .read(accountsControllerProvider.notifier) + .archiveAccount(id); + if (!mounted) return; + Navigator.of(context).pop(); + } catch (e, st) { + AppLogger.error( + 'Failed to archive account (id=$id)', + error: e, + stackTrace: st, + tag: 'account_form', + ); + if (!mounted) return; + _snack(context.l10n.accountArchiveError); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _snack(String msg) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + final color = Color(_colorValue); + final icon = _resolvedIcon; + + return SafeArea( + child: Column( + children: [ + _Header( + title: _isEdit ? l10n.accountEditTitle : l10n.accountNewTitle, + onClose: () => Navigator.of(context).pop(), + trailing: _isEdit + ? IconButton( + onPressed: _submitting ? null : _archive, + icon: Icon(Icons.archive_outlined, color: p.negative), + tooltip: l10n.accountArchiveButton, + ) + : null, + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PreviewHeader(name: _nameCtrl.text.trim().isEmpty + ? l10n.accountNameHint + : _nameCtrl.text.trim(), + icon: icon, + color: color, + ), + const SizedBox(height: 16), + // Card: Name + _Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.accountNameLabel, + style: TextStyle(fontSize: 12, color: p.ink2)), + TextField( + controller: _nameCtrl, + onChanged: (_) { + if (_nameError != null) { + setState(() => _nameError = null); + } else { + setState(() {}); + } + }, + maxLength: 50, + textCapitalization: TextCapitalization.sentences, + style: TextStyle(fontSize: 16, color: p.ink), + decoration: InputDecoration( + isCollapsed: true, + contentPadding: + const EdgeInsets.symmetric(vertical: 8), + border: InputBorder.none, + hintText: l10n.accountNameHint, + hintStyle: + TextStyle(fontSize: 16, color: p.ink2), + counterText: '', + ), + ), + if (_nameError != null) ...[ + const SizedBox(height: 4), + Text(_nameError!, + style: TextStyle( + fontSize: 12, color: p.negative)), + ], + ], + ), + ), + ), + const SizedBox(height: 12), + // Card: Type + _AccountTypeSegmented( + value: _type, + onChanged: (t) => setState(() => _type = t), + ), + const SizedBox(height: 12), + // Card: Currency + Initial balance + _Card( + child: Column( + children: [ + _PickerRow( + label: l10n.accountCurrencyLabel, + leading: _CurrencyChip(currency: _currency), + onTap: () async { + final picked = await showCurrencyPicker( + context, + current: _currency, + ); + if (picked != null) { + setState(() => _currency = picked); + } + }, + ), + _Divider(), + Padding( + padding: + const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.accountInitialBalanceLabel, + style: TextStyle( + fontSize: 12, color: p.ink2)), + TextField( + controller: _balanceCtrl, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'[\d.,]')), + ], + style: + TextStyle(fontSize: 16, color: p.ink), + decoration: InputDecoration( + isCollapsed: true, + contentPadding: + const EdgeInsets.symmetric(vertical: 8), + border: InputBorder.none, + hintText: '0.00', + hintStyle: TextStyle( + fontSize: 16, color: p.ink2), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + // Card: Icon + Color + _Card( + child: Column( + children: [ + _PickerRow( + label: l10n.accountIconLabel, + leading: _IconChip(icon: icon, color: color), + onTap: () async { + final code = await showIconPicker( + context, + currentCode: _iconCode ?? icon.codePoint, + tintColor: color, + ); + if (code != null) { + setState(() => _iconCode = code); + } + }, + ), + _Divider(), + _PickerRow( + label: l10n.accountColorLabel, + leading: _ColorChip(color: color), + onTap: () async { + final argb = await showColorPicker( + context, + currentValue: _colorValue, + usedValues: const {}, + ); + if (argb != null) { + setState(() => _colorValue = argb); + } + }, + ), + ], + ), + ), + const SizedBox(height: 12), + // Card: Default account toggle + _Card( + child: Padding( + padding: + const EdgeInsets.fromLTRB(16, 4, 8, 4), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.accountIsDefaultLabel, + style: TextStyle( + fontSize: 14, color: p.ink), + ), + const SizedBox(height: 2), + Text( + l10n.accountIsDefaultHint, + style: TextStyle( + fontSize: 12, color: p.ink2), + ), + ], + ), + ), + Switch( + value: _isDefault, + activeThumbColor: p.accent, + onChanged: (v) => + setState(() => _isDefault = v), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + _SubmitButton( + label: _isEdit + ? l10n.accountSaveEditButton + : l10n.accountSaveButton, + loading: _submitting, + onPressed: _submitting ? null : _save, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ─── Shared UI components ──────────────────────────────────────────────────── + +class _Header extends StatelessWidget { + const _Header({required this.title, required this.onClose, this.trailing}); + + final String title; + final VoidCallback onClose; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + children: [ + IconButton( + onPressed: onClose, + icon: Icon(Icons.close, color: p.ink), + ), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: p.ink, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox(width: 48, child: trailing), + ], + ), + ); + } +} + +class _Card extends StatelessWidget { + const _Card({required this.child}); + final Widget child; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Container( + decoration: BoxDecoration( + color: p.paper2, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: p.line), + ), + child: child, + ); + } +} + +class _Divider extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + height: 1, + margin: const EdgeInsets.symmetric(horizontal: 16), + color: context.palette.line, + ); + } +} + +class _PickerRow extends StatelessWidget { + const _PickerRow({ + required this.label, + required this.leading, + required this.onTap, + }); + + final String label; + final Widget leading; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 12, color: p.ink2)), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: leading), + Icon(Icons.chevron_right, size: 18, color: p.ink2), + ], + ), + ], + ), + ), + ); + } +} + +class _SubmitButton extends StatelessWidget { + const _SubmitButton({ + required this.label, + required this.loading, + required this.onPressed, + }); + + final String label; + final bool loading; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: p.accent, + foregroundColor: p.paper, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: onPressed, + child: loading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: p.paper, + ), + ) + : Text( + label, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} + +class _AccountTypeSegmented extends StatelessWidget { + const _AccountTypeSegmented({required this.value, required this.onChanged}); + + final AccountType value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + final items = <(AccountType, String, IconData)>[ + (AccountType.cash, l10n.accountTypeCash, Icons.payments_outlined), + (AccountType.card, l10n.accountTypeCard, Icons.credit_card_outlined), + (AccountType.bank, l10n.accountTypeBank, Icons.account_balance_outlined), + (AccountType.savings, l10n.accountTypeSavings, Icons.savings_outlined), + ]; + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: p.paper2, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: p.line), + ), + child: Column( + children: [ + Row( + children: [ + for (final (type, label, icon) in items.take(2)) + Expanded( + child: _TypeSegment( + label: label, + icon: icon, + selected: type == value, + onTap: () => onChanged(type), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + for (final (type, label, icon) in items.skip(2)) + Expanded( + child: _TypeSegment( + label: label, + icon: icon, + selected: type == value, + onTap: () => onChanged(type), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _TypeSegment extends StatelessWidget { + const _TypeSegment({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Material( + color: selected ? p.paper : Colors.transparent, + borderRadius: BorderRadius.circular(10), + elevation: selected ? 1 : 0, + shadowColor: Colors.black.withValues(alpha: 0.08), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: SizedBox( + height: 44, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 16, + color: selected ? p.accent : p.ink2, + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? p.ink : p.ink2, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PreviewHeader extends StatelessWidget { + const _PreviewHeader({ + required this.name, + required this.icon, + required this.color, + }); + + final String name; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Column( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Icon(icon, size: 36, color: color), + ), + const SizedBox(height: 10), + Text( + name, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: p.ink, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } +} + +class _IconChip extends StatelessWidget { + const _IconChip({required this.icon, required this.color}); + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: color), + ), + ], + ); + } +} + +class _CurrencyChip extends StatelessWidget { + const _CurrencyChip({required this.currency}); + final String currency; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final symbol = symbolForCurrency(currency); + return Row( + children: [ + Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: p.paper, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: p.line), + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + symbol, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: p.ink, + ), + ), + const SizedBox(width: 6), + Text( + currency, + style: TextStyle(fontSize: 13, color: p.ink2), + ), + ], + ), + ), + ], + ); + } +} + +class _ColorChip extends StatelessWidget { + const _ColorChip({required this.color}); + final Color color; + + @override + Widget build(BuildContext context) { + final p = context.palette; + return Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: p.line), + ), + ), + ], + ); + } +} diff --git a/lib/src/features/accounts/presentation/widgets/currency_picker_sheet.dart b/lib/src/features/accounts/presentation/widgets/currency_picker_sheet.dart new file mode 100644 index 0000000..87a5c31 --- /dev/null +++ b/lib/src/features/accounts/presentation/widgets/currency_picker_sheet.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/l10n/l10n.dart'; +import '../../../../app/theme/app_colors.dart'; + +class _Currency { + const _Currency(this.code, this.symbol, this.nameEn, this.nameRu); + final String code; + final String symbol; + final String nameEn; + final String nameRu; +} + +const _kCurrencies = [ + _Currency('RUB', '₽', 'Russian Ruble', 'Российский рубль'), + _Currency('USD', '\$', 'US Dollar', 'Доллар США'), + _Currency('EUR', '€', 'Euro', 'Евро'), + _Currency('GBP', '£', 'British Pound', 'Британский фунт'), + _Currency('CNY', '¥', 'Chinese Yuan', 'Китайский юань'), + _Currency('JPY', '¥', 'Japanese Yen', 'Японская иена'), + _Currency('CHF', 'Fr', 'Swiss Franc', 'Швейцарский франк'), + _Currency('TRY', '₺', 'Turkish Lira', 'Турецкая лира'), + _Currency('KZT', '₸', 'Kazakhstani Tenge', 'Казахстанский тенге'), + _Currency('BYN', 'Br', 'Belarusian Ruble', 'Белорусский рубль'), + _Currency('UAH', '₴', 'Ukrainian Hryvnia', 'Украинская гривна'), + _Currency('GEL', '₾', 'Georgian Lari', 'Грузинский лари'), + _Currency('AMD', '֏', 'Armenian Dram', 'Армянский драм'), + _Currency('AZN', '₼', 'Azerbaijani Manat', 'Азербайджанский манат'), + _Currency('UZS', 'сўм', 'Uzbekistani Som', 'Узбекский сум'), + _Currency('KGS', 'с', 'Kyrgyzstani Som', 'Киргизский сом'), + _Currency('TJS', 'SM', 'Tajikistani Somoni', 'Таджикский сомони'), + _Currency('TMT', 'T', 'Turkmenistani Manat', 'Туркменский манат'), + _Currency('MDL', 'L', 'Moldovan Leu', 'Молдавский лей'), + _Currency('AED', 'د.إ', 'UAE Dirham', 'Дирхам ОАЭ'), + _Currency('AUD', 'A\$', 'Australian Dollar', 'Австралийский доллар'), + _Currency('CAD', 'C\$', 'Canadian Dollar', 'Канадский доллар'), + _Currency('HKD', 'HK\$', 'Hong Kong Dollar', 'Гонконгский доллар'), + _Currency('SGD', 'S\$', 'Singapore Dollar', 'Сингапурский доллар'), + _Currency('SEK', 'kr', 'Swedish Krona', 'Шведская крона'), + _Currency('NOK', 'kr', 'Norwegian Krone', 'Норвежская крона'), + _Currency('DKK', 'kr', 'Danish Krone', 'Датская крона'), + _Currency('PLN', 'zł', 'Polish Zloty', 'Польский злотый'), + _Currency('CZK', 'Kč', 'Czech Koruna', 'Чешская крона'), + _Currency('HUF', 'Ft', 'Hungarian Forint', 'Венгерский форинт'), + _Currency('RON', 'lei', 'Romanian Leu', 'Румынский лей'), + _Currency('INR', '₹', 'Indian Rupee', 'Индийская рупия'), + _Currency('KRW', '₩', 'South Korean Won', 'Южнокорейская вона'), + _Currency('BRL', 'R\$', 'Brazilian Real', 'Бразильский реал'), + _Currency('MXN', '\$', 'Mexican Peso', 'Мексиканское песо'), + _Currency('ZAR', 'R', 'South African Rand', 'Южноафриканский рэнд'), + _Currency('THB', '฿', 'Thai Baht', 'Тайский бат'), + _Currency('IDR', 'Rp', 'Indonesian Rupiah', 'Индонезийская рупия'), + _Currency('MYR', 'RM', 'Malaysian Ringgit', 'Малайзийский ринггит'), + _Currency('PHP', '₱', 'Philippine Peso', 'Филиппинское песо'), + _Currency('VND', '₫', 'Vietnamese Dong', 'Вьетнамский донг'), +]; + +/// Returns the symbol for [code], or the code itself if unknown. +String symbolForCurrency(String code) { + for (final c in _kCurrencies) { + if (c.code == code) return c.symbol; + } + return code; +} + +Future showCurrencyPicker( + BuildContext context, { + required String current, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _CurrencyPickerSheet(current: current), + ); +} + +class _CurrencyPickerSheet extends StatefulWidget { + const _CurrencyPickerSheet({required this.current}); + final String current; + + @override + State<_CurrencyPickerSheet> createState() => _CurrencyPickerSheetState(); +} + +class _CurrencyPickerSheetState extends State<_CurrencyPickerSheet> { + final _searchCtrl = TextEditingController(); + List<_Currency> _filtered = _kCurrencies; + + @override + void initState() { + super.initState(); + _searchCtrl.addListener(_onSearch); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + void _onSearch() { + final q = _searchCtrl.text.toLowerCase(); + setState(() { + _filtered = q.isEmpty + ? _kCurrencies + : _kCurrencies + .where((c) => + c.code.toLowerCase().contains(q) || + c.nameEn.toLowerCase().contains(q) || + c.nameRu.toLowerCase().contains(q) || + c.symbol.toLowerCase().contains(q)) + .toList(); + }); + } + + bool get _isRu => + Localizations.localeOf(context).languageCode == 'ru'; + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => Container( + decoration: BoxDecoration( + color: p.paper, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: p.line2, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.currencyPickerTitle, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: p.ink, + ), + ), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + height: 40, + decoration: BoxDecoration( + color: p.paper2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: p.line), + ), + child: TextField( + controller: _searchCtrl, + style: TextStyle(fontSize: 14, color: p.ink), + decoration: InputDecoration( + isCollapsed: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 12, + ), + border: InputBorder.none, + hintText: l10n.currencyPickerSearchHint, + hintStyle: TextStyle(fontSize: 14, color: p.ink2), + prefixIcon: Icon(Icons.search, size: 18, color: p.ink2), + prefixIconConstraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Expanded( + child: _filtered.isEmpty + ? Center( + child: Text( + '—', + style: TextStyle(color: p.ink2), + ), + ) + : ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(12, 4, 12, 24), + itemCount: _filtered.length, + itemBuilder: (context, i) { + final c = _filtered[i]; + final selected = c.code == widget.current; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => Navigator.of(context).pop(c.code), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + Container( + width: 44, + height: 36, + decoration: BoxDecoration( + color: p.paper2, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: p.line), + ), + alignment: Alignment.center, + child: Text( + c.symbol, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: p.ink, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + c.code, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: p.ink, + ), + ), + Text( + _isRu ? c.nameRu : c.nameEn, + style: TextStyle( + fontSize: 12, + color: p.ink2, + ), + ), + ], + ), + ), + if (selected) + Icon( + Icons.check_circle_rounded, + size: 20, + color: p.accent, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/review.diff b/review.diff new file mode 100644 index 0000000..c35e3e9 --- /dev/null +++ b/review.diff @@ -0,0 +1,722 @@ +diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb +index 8650b06..d59bdea 100644 +--- a/lib/l10n/app_en.arb ++++ b/lib/l10n/app_en.arb +@@ -74,6 +74,9 @@ + + "profileSubtitle": "Settings", + "darkTheme": "Dark theme", ++ "profileLanguageTile": "Language", ++ "langEnglish": "English", ++ "langRussian": "Russian", + "profileHint": "User profile, currency, locale and other settings will appear here.", + + "onboardingTitle": "Welcome", +@@ -154,6 +157,8 @@ + + "iconPickerTitle": "Pick an icon", + "colorPickerTitle": "Pick a color", ++ "currencyPickerTitle": "Pick a currency", ++ "currencyPickerSearchHint": "Search…", + "colorPickerAllUsed": "All palette colors are already in use by other categories.", + "iconGroupFood": "Food", + "iconGroupTransport": "Transport", +@@ -166,5 +171,32 @@ + "iconGroupFinance": "Finance", + "iconGroupOther": "Other", + +- "profileCategoriesTile": "Categories" ++ "profileCategoriesTile": "Categories", ++ "profileAccountsTile": "Accounts", ++ ++ "accountsEmpty": "No accounts yet. Tap + to add one.", ++ "accountNewTitle": "New account", ++ "accountEditTitle": "Edit account", ++ "accountNameLabel": "Name", ++ "accountNameHint": "e.g. Cash wallet", ++ "accountTypeLabel": "Type", ++ "accountTypeCash": "Cash", ++ "accountTypeCard": "Card", ++ "accountTypeBank": "Bank", ++ "accountTypeSavings": "Savings", ++ "accountCurrencyLabel": "Currency", ++ "accountInitialBalanceLabel": "Initial balance", ++ "accountIconLabel": "Icon", ++ "accountColorLabel": "Color", ++ "accountIsDefaultLabel": "Default account", ++ "accountIsDefaultHint": "Used when creating new transactions", ++ "accountSaveButton": "Create account", ++ "accountSaveEditButton": "Save changes", ++ "accountArchiveButton": "Archive", ++ "accountArchiveConfirmTitle": "Archive account?", ++ "accountArchiveConfirmMessage": "Archived accounts disappear from pickers and lists. Existing transactions keep their reference.", ++ "accountValidationNameRequired": "Enter a name", ++ "accountValidationNameTooLong": "Name is too long (max 50 chars)", ++ "accountSaveError": "Failed to save the account. Please try again.", ++ "accountArchiveError": "Failed to archive the account. Please try again." + } +diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb +index 01eed48..e4fb291 100644 +--- a/lib/l10n/app_ru.arb ++++ b/lib/l10n/app_ru.arb +@@ -74,6 +74,9 @@ + + "profileSubtitle": "Настройки", + "darkTheme": "Тёмная тема", ++ "profileLanguageTile": "Язык", ++ "langEnglish": "English", ++ "langRussian": "Русский", + "profileHint": "Здесь появится профиль пользователя, валюта, локаль и другие настройки.", + + "onboardingTitle": "Добро пожаловать", +@@ -154,6 +157,8 @@ + + "iconPickerTitle": "Выберите иконку", + "colorPickerTitle": "Выберите цвет", ++ "currencyPickerTitle": "Выберите валюту", ++ "currencyPickerSearchHint": "Поиск…", + "colorPickerAllUsed": "Все цвета палитры уже заняты другими категориями.", + "iconGroupFood": "Еда", + "iconGroupTransport": "Транспорт", +@@ -166,5 +171,32 @@ + "iconGroupFinance": "Финансы", + "iconGroupOther": "Прочее", + +- "profileCategoriesTile": "Категории" ++ "profileCategoriesTile": "Категории", ++ "profileAccountsTile": "Счета", ++ ++ "accountsEmpty": "Счетов нет. Нажмите +, чтобы добавить.", ++ "accountNewTitle": "Новый счёт", ++ "accountEditTitle": "Редактировать счёт", ++ "accountNameLabel": "Название", ++ "accountNameHint": "напр. Кошелёк", ++ "accountTypeLabel": "Тип", ++ "accountTypeCash": "Наличные", ++ "accountTypeCard": "Карта", ++ "accountTypeBank": "Банк", ++ "accountTypeSavings": "Накопления", ++ "accountCurrencyLabel": "Валюта", ++ "accountInitialBalanceLabel": "Начальный баланс", ++ "accountIconLabel": "Иконка", ++ "accountColorLabel": "Цвет", ++ "accountIsDefaultLabel": "Счёт по умолчанию", ++ "accountIsDefaultHint": "Подставляется при создании транзакций", ++ "accountSaveButton": "Создать счёт", ++ "accountSaveEditButton": "Сохранить", ++ "accountArchiveButton": "Архивировать", ++ "accountArchiveConfirmTitle": "Архивировать счёт?", ++ "accountArchiveConfirmMessage": "Архивные счета исчезают из списков и пикеров. Существующие транзакции сохранят ссылку.", ++ "accountValidationNameRequired": "Введите название", ++ "accountValidationNameTooLong": "Слишком длинное название (макс. 50 символов)", ++ "accountSaveError": "Не удалось сохранить счёт. Попробуйте ещё раз.", ++ "accountArchiveError": "Не удалось архивировать счёт. Попробуйте ещё раз." + } +diff --git a/lib/src/app/app.dart b/lib/src/app/app.dart +index cf2147b..a36c315 100644 +--- a/lib/src/app/app.dart ++++ b/lib/src/app/app.dart +@@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:new_budget/l10n/app_localizations.dart'; + ++import 'locale/app_locale_controller.dart'; + import 'router/app_router.dart'; + import 'theme/app_theme.dart'; + import 'theme/theme_mode_controller.dart'; +@@ -13,6 +14,7 @@ class NewBudgetApp extends ConsumerWidget { + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + final themeMode = ref.watch(themeModeControllerProvider); ++ final locale = ref.watch(appLocaleControllerProvider); + + return MaterialApp.router( + title: 'NewBudget', +@@ -20,6 +22,7 @@ class NewBudgetApp extends ConsumerWidget { + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: themeMode, ++ locale: locale, + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, +diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart +index a6b821e..a5af90d 100644 +--- a/lib/src/app/router/app_router.dart ++++ b/lib/src/app/router/app_router.dart +@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; + import 'package:riverpod_annotation/riverpod_annotation.dart'; + + import '../../core/database/converters/enum_converters.dart'; ++import '../../features/accounts/presentation/screens/account_form_screen.dart'; + import '../../features/accounts/presentation/screens/accounts_screen.dart'; + import '../../features/analytics/presentation/screens/analytics_screen.dart'; + import '../../features/categories/presentation/screens/categories_list_screen.dart'; +@@ -81,6 +82,20 @@ GoRouter appRouter(Ref ref) { + CategoryFormScreen(categoryId: state.pathParameters['id']), + ), + ), ++ GoRoute( ++ path: AppRoutes.accountNew, ++ pageBuilder: (context, state) => _slideUpPage( ++ state, ++ const AccountFormScreen(), ++ ), ++ ), ++ GoRoute( ++ path: AppRoutes.accountEditPattern, ++ pageBuilder: (context, state) => _slideUpPage( ++ state, ++ AccountFormScreen(accountId: state.pathParameters['id']), ++ ), ++ ), + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => AppScaffold( + navigationShell: navigationShell, +diff --git a/lib/src/app/router/app_routes.dart b/lib/src/app/router/app_routes.dart +index 0c43dfc..623cd39 100644 +--- a/lib/src/app/router/app_routes.dart ++++ b/lib/src/app/router/app_routes.dart +@@ -15,4 +15,8 @@ class AppRoutes { + static const categoryNew = '/categories/new'; + static const categoryEditPattern = '/categories/edit/:id'; + static String categoryEdit(String id) => '/categories/edit/$id'; ++ ++ static const accountNew = '/accounts/new'; ++ static const accountEditPattern = '/accounts/edit/:id'; ++ static String accountEdit(String id) => '/accounts/edit/$id'; + } +diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart +index 2848272..296e06a 100644 +--- a/lib/src/core/database/app_database.dart ++++ b/lib/src/core/database/app_database.dart +@@ -39,7 +39,7 @@ class AppDatabase extends _$AppDatabase { + AppDatabase.forTesting(super.executor); + + @override +- int get schemaVersion => 3; ++ int get schemaVersion => 4; + + @override + MigrationStrategy get migration => MigrationStrategy( +@@ -60,6 +60,10 @@ class AppDatabase extends _$AppDatabase { + // v2 → v3: добавлено поле extra_info в transactions. + await m.addColumn(transactionsTable, transactionsTable.extraInfo); + } ++ if (from < 4) { ++ // v3 → v4: добавлено поле is_default в accounts. ++ await m.addColumn(accountsTable, accountsTable.isDefault); ++ } + }, + ); + +diff --git a/lib/src/core/database/daos/accounts_dao.dart b/lib/src/core/database/daos/accounts_dao.dart +index 241000c..1407788 100644 +--- a/lib/src/core/database/daos/accounts_dao.dart ++++ b/lib/src/core/database/daos/accounts_dao.dart +@@ -29,13 +29,33 @@ class AccountsDao extends DatabaseAccessor + Future insertAccount(AccountsTableCompanion companion) => + into(accountsTable).insert(companion); + +- Future updateAccount(AccountsTableCompanion companion) => +- update(accountsTable).replace(companion); ++ Future updateAccount(AccountsTableCompanion companion) => ++ (update(accountsTable)..where((t) => t.id.equals(companion.id.value))) ++ .write(companion); + + Future archiveAccount(String id) => (update(accountsTable) + ..where((t) => t.id.equals(id))) + .write(const AccountsTableCompanion(archived: Value(true))); + ++ /// Реактивный умолчательный счёт пользователя (или null). ++ Stream watchDefaultAccount(String userId) => ++ (select(accountsTable) ++ ..where((t) => t.userId.equals(userId) & t.isDefault.equals(true)) ++ ..limit(1)) ++ .watchSingleOrNull(); ++ ++ /// Атомарная смена умолчательного счёта. ++ /// id == null — снять дефолт со всех счетов пользователя. ++ Future setDefaultAccount(String? id, String userId) => ++ transaction(() async { ++ await (update(accountsTable)..where((t) => t.userId.equals(userId))) ++ .write(const AccountsTableCompanion(isDefault: Value(false))); ++ if (id != null) { ++ await (update(accountsTable)..where((t) => t.id.equals(id))) ++ .write(const AccountsTableCompanion(isDefault: Value(true))); ++ } ++ }); ++ + /// Реактивный текущий баланс счёта. + /// + /// Формула: `initialBalance + Σincome − Σexpense + Σtransfer_in − Σtransfer_out`, +diff --git a/lib/src/core/database/tables/accounts_table.dart b/lib/src/core/database/tables/accounts_table.dart +index 61c4486..0532112 100644 +--- a/lib/src/core/database/tables/accounts_table.dart ++++ b/lib/src/core/database/tables/accounts_table.dart +@@ -26,6 +26,7 @@ class AccountsTable extends Table { + IntColumn get colorValue => integer().nullable()(); + + BoolColumn get archived => boolean().withDefault(const Constant(false))(); ++ BoolColumn get isDefault => boolean().withDefault(const Constant(false))(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + @override +diff --git a/lib/src/features/accounts/application/accounts_controller.dart b/lib/src/features/accounts/application/accounts_controller.dart +index 62cefbd..c88d2a5 100644 +--- a/lib/src/features/accounts/application/accounts_controller.dart ++++ b/lib/src/features/accounts/application/accounts_controller.dart +@@ -15,6 +15,11 @@ Stream> accountsStream(Ref ref, String userId) => + Stream accountBalance(Ref ref, String accountId) => + ref.watch(accountRepositoryProvider).watchBalance(accountId); + ++/// Умолчательный счёт пользователя (или null если не задан). ++@riverpod ++Stream defaultAccount(Ref ref, String userId) => ++ ref.watch(accountRepositoryProvider).watchDefault(userId); ++ + /// Контроллер CRUD-операций над счетами. + @Riverpod(keepAlive: true) + class AccountsController extends _$AccountsController { +@@ -31,8 +36,8 @@ class AccountsController extends _$AccountsController { + int? colorValue, + }) async { + state = const AsyncLoading(); +- final result = await AsyncValue.guard( +- () => ref.read(accountRepositoryProvider).create( ++ try { ++ final account = await ref.read(accountRepositoryProvider).create( + userId: userId, + name: name, + type: type, +@@ -40,25 +45,30 @@ class AccountsController extends _$AccountsController { + initialBalance: initialBalance, + iconCode: iconCode, + colorValue: colorValue, +- ), +- ); +- state = result.hasError +- ? AsyncError(result.error!, StackTrace.current) +- : const AsyncData(null); +- return result.value!; ++ ); ++ state = const AsyncData(null); ++ return account; ++ } catch (e, st) { ++ state = AsyncError(e, st); ++ rethrow; ++ } + } + + Future updateAccount(Account account) async { + state = const AsyncLoading(); +- final result = await AsyncValue.guard( +- () => ref.read(accountRepositoryProvider).update(account), +- ); +- state = result.hasError +- ? AsyncError(result.error!, StackTrace.current) +- : const AsyncData(null); +- return result.value!; ++ try { ++ final updated = await ref.read(accountRepositoryProvider).update(account); ++ state = const AsyncData(null); ++ return updated; ++ } catch (e, st) { ++ state = AsyncError(e, st); ++ rethrow; ++ } + } + ++ Future setDefaultAccount(String? id, String userId) => ++ ref.read(accountRepositoryProvider).setDefault(id, userId); ++ + Future archiveAccount(String id) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( +diff --git a/lib/src/features/accounts/data/mappers/account_mapper.dart b/lib/src/features/accounts/data/mappers/account_mapper.dart +index 1b9a339..32a0bcd 100644 +--- a/lib/src/features/accounts/data/mappers/account_mapper.dart ++++ b/lib/src/features/accounts/data/mappers/account_mapper.dart +@@ -12,6 +12,7 @@ extension AccountMapper on AccountsTableData { + iconCode: iconCode, + colorValue: colorValue, + archived: archived, ++ isDefault: isDefault, + createdAt: createdAt, + ); + } +diff --git a/lib/src/features/accounts/data/repositories/account_repository_impl.dart b/lib/src/features/accounts/data/repositories/account_repository_impl.dart +index 7e53d27..6773eaa 100644 +--- a/lib/src/features/accounts/data/repositories/account_repository_impl.dart ++++ b/lib/src/features/accounts/data/repositories/account_repository_impl.dart +@@ -65,6 +65,14 @@ class AccountRepositoryImpl implements AccountRepository { + @override + Future archive(String id) => _dao.archiveAccount(id); + ++ @override ++ Stream watchDefault(String userId) => ++ _dao.watchDefaultAccount(userId).map((row) => row?.toDomain()); ++ ++ @override ++ Future setDefault(String? id, String userId) => ++ _dao.setDefaultAccount(id, userId); ++ + @override + Stream watchBalance(String accountId) => _dao.watchAccountBalance(accountId); + } +diff --git a/lib/src/features/accounts/domain/entities/account.dart b/lib/src/features/accounts/domain/entities/account.dart +index 6bf4271..0e83fc9 100644 +--- a/lib/src/features/accounts/domain/entities/account.dart ++++ b/lib/src/features/accounts/domain/entities/account.dart +@@ -15,6 +15,7 @@ abstract class Account with _$Account { + int? iconCode, + int? colorValue, + required bool archived, ++ @Default(false) bool isDefault, + required DateTime createdAt, + }) = _Account; + } +diff --git a/lib/src/features/accounts/domain/repositories/account_repository.dart b/lib/src/features/accounts/domain/repositories/account_repository.dart +index 12442da..6100a21 100644 +--- a/lib/src/features/accounts/domain/repositories/account_repository.dart ++++ b/lib/src/features/accounts/domain/repositories/account_repository.dart +@@ -16,6 +16,12 @@ abstract interface class AccountRepository { + Future update(Account account); + Future archive(String id); + ++ /// Реактивный умолчательный счёт пользователя. ++ Stream watchDefault(String userId); ++ ++ /// Установить (или снять, если id == null) умолчательный счёт. ++ Future setDefault(String? id, String userId); ++ + /// Текущий баланс счёта (начальный + агрегат транзакций). + Stream watchBalance(String accountId); + } +diff --git a/lib/src/features/accounts/presentation/screens/accounts_screen.dart b/lib/src/features/accounts/presentation/screens/accounts_screen.dart +index edb896c..52596be 100644 +--- a/lib/src/features/accounts/presentation/screens/accounts_screen.dart ++++ b/lib/src/features/accounts/presentation/screens/accounts_screen.dart +@@ -1,17 +1,205 @@ + import 'package:flutter/material.dart'; ++import 'package:flutter_riverpod/flutter_riverpod.dart'; ++import 'package:go_router/go_router.dart'; + + import '../../../../app/l10n/l10n.dart'; +-import '../../../../shared/widgets/placeholder_screen.dart'; ++import '../../../../app/router/app_routes.dart'; ++import '../../../../app/theme/app_colors.dart'; ++import '../../../home/presentation/widgets/money_text.dart'; ++import '../../../user/application/active_user_controller.dart'; ++import '../../application/accounts_controller.dart'; ++import '../../domain/entities/account.dart'; ++import '../widgets/account_icon.dart'; + +-class AccountsScreen extends StatelessWidget { ++class AccountsScreen extends ConsumerWidget { + const AccountsScreen({super.key}); + + @override +- Widget build(BuildContext context) { ++ Widget build(BuildContext context, WidgetRef ref) { ++ final p = context.palette; ++ final activeUser = ref.watch(activeUserControllerProvider); ++ ++ return Scaffold( ++ backgroundColor: p.paper, ++ body: SafeArea( ++ child: activeUser.when( ++ loading: () => const Center(child: CircularProgressIndicator()), ++ error: (e, _) => Center(child: Text('$e')), ++ data: (user) { ++ if (user == null) return const SizedBox.shrink(); ++ return _AccountsList(userId: user.id); ++ }, ++ ), ++ ), ++ floatingActionButton: FloatingActionButton( ++ backgroundColor: p.accent, ++ foregroundColor: p.paper, ++ onPressed: () => context.push(AppRoutes.accountNew), ++ child: const Icon(Icons.add), ++ ), ++ ); ++ } ++} ++ ++class _AccountsList extends ConsumerWidget { ++ const _AccountsList({required this.userId}); ++ final String userId; ++ ++ @override ++ Widget build(BuildContext context, WidgetRef ref) { ++ final p = context.palette; + final l10n = context.l10n; +- return PlaceholderScreen( +- subtitle: l10n.accountsSubtitle, +- title: l10n.navAccounts, ++ final accountsAsync = ref.watch(accountsStreamProvider(userId)); ++ final defaultAsync = ref.watch(defaultAccountProvider(userId)); ++ final defaultId = defaultAsync.value?.id; ++ ++ return Column( ++ crossAxisAlignment: CrossAxisAlignment.stretch, ++ children: [ ++ Padding( ++ padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), ++ child: Column( ++ crossAxisAlignment: CrossAxisAlignment.start, ++ children: [ ++ Text( ++ l10n.accountsSubtitle.toUpperCase(), ++ style: TextStyle( ++ fontSize: 11, ++ color: p.ink2, ++ letterSpacing: 0.6, ++ fontWeight: FontWeight.w500, ++ ), ++ ), ++ const SizedBox(height: 2), ++ Text( ++ l10n.navAccounts, ++ style: TextStyle( ++ fontSize: 20, ++ fontWeight: FontWeight.w600, ++ color: p.ink, ++ ), ++ ), ++ ], ++ ), ++ ), ++ Expanded( ++ child: accountsAsync.when( ++ loading: () => const Center(child: CircularProgressIndicator()), ++ error: (e, _) => Center(child: Text('$e')), ++ data: (accounts) { ++ if (accounts.isEmpty) { ++ return Center( ++ child: Text( ++ l10n.accountsEmpty, ++ style: TextStyle(fontSize: 14, color: p.ink2), ++ textAlign: TextAlign.center, ++ ), ++ ); ++ } ++ return ListView.builder( ++ padding: const EdgeInsets.fromLTRB(12, 4, 12, 80), ++ itemCount: accounts.length, ++ itemBuilder: (context, i) => _AccountTile( ++ account: accounts[i], ++ isDefault: accounts[i].id == defaultId, ++ onTap: () => context.push( ++ AppRoutes.accountEdit(accounts[i].id), ++ ), ++ ), ++ ); ++ }, ++ ), ++ ), ++ ], ++ ); ++ } ++} ++ ++class _AccountTile extends ConsumerWidget { ++ const _AccountTile({ ++ required this.account, ++ required this.isDefault, ++ required this.onTap, ++ }); ++ ++ final Account account; ++ final bool isDefault; ++ final VoidCallback onTap; ++ ++ @override ++ Widget build(BuildContext context, WidgetRef ref) { ++ final p = context.palette; ++ final balance = ref.watch(accountBalanceProvider(account.id)).value ?? 0; ++ final color = Color(account.colorValue ?? 0xFFB8B5AC); ++ ++ return Padding( ++ padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), ++ child: Material( ++ color: p.paper2, ++ borderRadius: BorderRadius.circular(14), ++ child: InkWell( ++ borderRadius: BorderRadius.circular(14), ++ onTap: onTap, ++ child: Padding( ++ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), ++ child: Row( ++ children: [ ++ Container( ++ width: 44, ++ height: 44, ++ decoration: BoxDecoration( ++ color: color.withValues(alpha: 0.15), ++ borderRadius: BorderRadius.circular(12), ++ ), ++ child: Icon(iconForAccount(account), size: 22, color: color), ++ ), ++ const SizedBox(width: 12), ++ Expanded( ++ child: Column( ++ crossAxisAlignment: CrossAxisAlignment.start, ++ children: [ ++ Row( ++ children: [ ++ Flexible( ++ child: Text( ++ account.name, ++ style: TextStyle( ++ fontSize: 15, ++ fontWeight: FontWeight.w600, ++ color: p.ink, ++ ), ++ maxLines: 1, ++ overflow: TextOverflow.ellipsis, ++ ), ++ ), ++ if (isDefault) ...[ ++ const SizedBox(width: 6), ++ Icon(Icons.star_rounded, ++ size: 14, color: p.accent), ++ ], ++ ], ++ ), ++ const SizedBox(height: 2), ++ Text( ++ shortAccountLabel(account), ++ style: TextStyle(fontSize: 12, color: p.ink2), ++ ), ++ ], ++ ), ++ ), ++ MoneyText( ++ balance, ++ color: p.ink, ++ fontSize: 14, ++ fontWeight: FontWeight.w600, ++ ), ++ const SizedBox(width: 4), ++ Icon(Icons.chevron_right, size: 16, color: p.ink2), ++ ], ++ ), ++ ), ++ ), ++ ), + ); + } + } +diff --git a/lib/src/features/accounts/presentation/widgets/account_icon.dart b/lib/src/features/accounts/presentation/widgets/account_icon.dart +index 00b6e87..e12bfdc 100644 +--- a/lib/src/features/accounts/presentation/widgets/account_icon.dart ++++ b/lib/src/features/accounts/presentation/widgets/account_icon.dart +@@ -16,15 +16,4 @@ IconData iconForAccount(Account a) { + } + } + +-String shortAccountLabel(Account a) { +- switch (a.type) { +- case AccountType.cash: +- return 'Кэш'; +- case AccountType.card: +- return 'Карта'; +- case AccountType.bank: +- return 'Банк'; +- case AccountType.savings: +- return 'Копилка'; +- } +-} ++String shortAccountLabel(Account a) => a.name; +diff --git a/lib/src/features/home/presentation/widgets/account_tabs.dart b/lib/src/features/home/presentation/widgets/account_tabs.dart +index e282c34..a23102c 100644 +--- a/lib/src/features/home/presentation/widgets/account_tabs.dart ++++ b/lib/src/features/home/presentation/widgets/account_tabs.dart +@@ -15,9 +15,9 @@ class AccountTabs extends ConsumerWidget { + + @override + Widget build(BuildContext context, WidgetRef ref) { +- final accounts = +- ref.watch(accountsStreamProvider(userId)).value ?? +- const []; ++ final accounts = [ ++ ...ref.watch(accountsStreamProvider(userId)).value ?? const [], ++ ]..sort((a, b) => (b.isDefault ? 1 : 0) - (a.isDefault ? 1 : 0)); + final selected = ref.watch(selectedAccountProvider); + + return SizedBox( +diff --git a/lib/src/features/profile/presentation/screens/profile_screen.dart b/lib/src/features/profile/presentation/screens/profile_screen.dart +index 71c6a57..15d6aa5 100644 +--- a/lib/src/features/profile/presentation/screens/profile_screen.dart ++++ b/lib/src/features/profile/presentation/screens/profile_screen.dart +@@ -42,6 +42,12 @@ class ProfileScreen extends ConsumerWidget { + ), + ), + Container(height: 1, color: p.line), ++ _NavRow( ++ icon: Icons.account_balance_wallet_outlined, ++ title: l10n.profileAccountsTile, ++ onTap: () => context.go(AppRoutes.accounts), ++ ), ++ Container(height: 1, color: p.line), + _NavRow( + icon: Icons.label_outline, + title: l10n.profileCategoriesTile, +diff --git a/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart b/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart +index ee24203..245fced 100644 +--- a/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart ++++ b/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart +@@ -297,6 +297,21 @@ class _FormBodyState extends ConsumerState<_FormBody> { + ref.read(transactionDraftControllerProvider(widget.txId).notifier); + _syncControllersFromDraft(draft); + ++ // Подставить умолчательный счёт при открытии формы создания. ++ if (widget.txId == null) { ++ final defaultAccount = ref.watch(defaultAccountProvider(widget.userId)); ++ if (draft.accountId == null && defaultAccount.value != null) { ++ WidgetsBinding.instance.addPostFrameCallback((_) { ++ if (!mounted) return; ++ final current = ref.read( ++ transactionDraftControllerProvider(widget.txId)); ++ if (current.accountId == null) { ++ draftCtrl.setAccount(defaultAccount.value!.id); ++ } ++ }); ++ } ++ } ++ + final accounts = ref.watch(accountsStreamProvider(widget.userId)).value ?? + const []; + final accountById = {for (final a in accounts) a.id: a}; +diff --git a/lib/src/features/transactions/presentation/widgets/account_picker_sheet.dart b/lib/src/features/transactions/presentation/widgets/account_picker_sheet.dart +index 5991cc3..79e0874 100644 +--- a/lib/src/features/transactions/presentation/widgets/account_picker_sheet.dart ++++ b/lib/src/features/transactions/presentation/widgets/account_picker_sheet.dart +@@ -1,7 +1,9 @@ + import 'package:flutter/material.dart'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; ++import 'package:go_router/go_router.dart'; + + import '../../../../app/l10n/l10n.dart'; ++import '../../../../app/router/app_routes.dart'; + import '../../../../app/theme/app_colors.dart'; + import '../../../accounts/application/accounts_controller.dart'; + import '../../../accounts/domain/entities/account.dart'; +@@ -99,10 +101,11 @@ class _AccountPickerSheet extends ConsumerWidget { + if (i == accounts.length) { + return _AddAccountTile( + label: l10n.pickerAddAccount, +- onTap: () { +- ScaffoldMessenger.of(context).showSnackBar( +- SnackBar(content: Text(l10n.comingSoonShort)), +- ); ++ onTap: () async { ++ final id = await context.push(AppRoutes.accountNew); ++ if (id != null && context.mounted) { ++ Navigator.of(context).pop(id); ++ } + }, + ); + }