import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/intl.dart'; import 'package:file_selector/file_selector.dart'; import 'package:share_plus/share_plus.dart'; import '../domain/finance.dart'; import '../services/app_store.dart'; import 'design.dart'; import 'editors.dart'; class BalanceApp extends StatelessWidget { const BalanceApp({super.key, required this.store}); final AppStore store; @override Widget build(BuildContext context) => ListenableBuilder( listenable: store, builder: (context, _) => MaterialApp( title: 'Баланс', debugShowCheckedModeBanner: false, locale: const Locale('ru'), supportedLocales: const [Locale('ru'), Locale('en')], localizationsDelegates: GlobalMaterialLocalizations.delegates, theme: makeTheme(Brightness.light), darkTheme: makeTheme(Brightness.dark), themeMode: store.theme == 'dark' ? ThemeMode.dark : store.theme == 'light' ? ThemeMode.light : ThemeMode.system, home: store.ready ? BalanceShell(store: store) : const Scaffold(body: Center(child: CircularProgressIndicator())), ), ); ThemeData makeTheme(Brightness brightness) { final scheme = ColorScheme.fromSeed( seedColor: const Color(0xff356859), brightness: brightness, ); return ThemeData( useMaterial3: true, fontFamily: 'BalanceRoboto', colorScheme: scheme, scaffoldBackgroundColor: scheme.surface, inputDecorationTheme: const InputDecorationTheme( border: OutlineInputBorder(), ), cardTheme: CardThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), ), appBarTheme: AppBarTheme( backgroundColor: scheme.surface, centerTitle: false, ), ); } } class BalanceShell extends StatefulWidget { const BalanceShell({super.key, required this.store}); final AppStore store; @override State createState() => _BalanceShellState(); } class _BalanceShellState extends State { AppStore get store => widget.store; int page = 0; int months = 1; String query = '', filter = 'all'; DateTime budgetMonth = DateTime(DateTime.now().year, DateTime.now().month); static const titles = [ 'Обзор', 'Операции', 'Аналитика', 'Бюджеты', 'Настройки', ]; static const icons = [ Icons.space_dashboard_outlined, Icons.receipt_long_outlined, Icons.insights_outlined, Icons.savings_outlined, Icons.settings_outlined, ]; Future run(Future Function() action, {String? success}) async { try { await action(); if (success != null && mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(success))); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( e.toString().replaceFirst('Invalid argument(s): ', ''), ), duration: const Duration(seconds: 6), ), ); } } } Future editTransaction([TransactionRecord? value]) async { final result = await editDialog( context, TransactionEditor(existing: value, categories: store.categories), ); if (result != null) await run(() => store.saveTransaction(result)); } Future editCategory([CategoryRecord? value]) async { final result = await editDialog( context, CategoryEditor(existing: value, categories: store.categories), ); if (result != null) await run(() => store.saveCategory(result)); } Future editBudget([BudgetRecord? value]) async { final choices = store.categoriesForKind('expense'); if (value != null && !choices.any((c) => c.name == value.categoryName)) { choices.add( CategoryRecord( id: 'archive', colorName: 'indigo', name: value.categoryName, icon: value.categoryIcon, emoji: value.categoryEmoji, createdAt: DateTime.now(), updatedAt: DateTime.now(), ), ); } final result = await editDialog( context, BudgetEditor(existing: value, categories: choices, month: budgetMonth), ); if (result != null) await run(() => store.saveBudget(result)); } Future adjust() async { final amount = TextEditingController( text: amountInput(FinanceMath.totalBalance(store.transactions)), ); final note = TextEditingController(); final form = GlobalKey(); final target = await showDialog( context: context, builder: (context) => AlertDialog( scrollable: true, title: const Text('Скорректировать баланс'), content: SizedBox( width: 400, child: Form( key: form, child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( 'Укажите фактический остаток. Разница сохранится отдельной операцией и не повлияет на аналитику.', ), const SizedBox(height: 20), TextFormField( controller: amount, keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), decoration: InputDecoration( labelText: 'Фактический баланс, ${store.currency}', ), validator: (s) => parseAmount(s ?? '', signed: true) == null ? 'Введите сумму' : null, ), const SizedBox(height: 16), TextFormField( controller: note, decoration: const InputDecoration(labelText: 'Заметка'), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Отмена'), ), FilledButton( onPressed: () { if (form.currentState!.validate()) { Navigator.pop(context, parseAmount(amount.text, signed: true)); } }, child: const Text('Сохранить'), ), ], ), ); if (target != null) { await run(() => store.adjustBalance(target, note: note.text.trim())); } // Dialog route may still be animating; controllers are no longer attached after frame. await Future.delayed(const Duration(milliseconds: 300)); amount.dispose(); note.dispose(); } @override Widget build(BuildContext context) { final wide = MediaQuery.sizeOf(context).width >= 900; return Scaffold( appBar: AppBar( title: Row( children: [ Container( width: 34, height: 34, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(11), ), child: Icon( Icons.account_balance_wallet_outlined, color: Theme.of(context).colorScheme.onPrimary, size: 21, ), ), const SizedBox(width: 12), const Text('Баланс', style: TextStyle(fontWeight: FontWeight.w700)), ], ), actions: [ if (store.syncing) const Padding( padding: EdgeInsets.all(16), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ) else IconButton( tooltip: store.session == null ? 'Настроить синхронизацию' : 'Синхронизировать', onPressed: store.session == null ? () => setState(() => page = 4) : () => run(store.sync), icon: Icon( store.session == null ? Icons.cloud_off_outlined : Icons.sync, ), ), const SizedBox(width: 12), ], ), body: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (wide) NavigationRail( selectedIndex: page, onDestinationSelected: (i) => setState(() => page = i), labelType: NavigationRailLabelType.all, minWidth: 100, leading: Padding( padding: const EdgeInsets.only(bottom: 28, top: 16), child: FloatingActionButton( tooltip: 'Добавить операцию', onPressed: editTransaction, child: const Icon(Icons.add), ), ), destinations: List.generate( 5, (i) => NavigationRailDestination( icon: Icon(icons[i]), label: Text(titles[i]), ), ), ), if (wide) const VerticalDivider(width: 1), Expanded( child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1280), child: ListView( padding: EdgeInsets.fromLTRB( wide ? 36 : 16, 16, wide ? 36 : 16, 100, ), children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( titles[page], style: Theme.of(context) .textTheme .headlineMedium ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 5), Text( page == 0 ? DateFormat( 'LLLL y', 'ru', ).format(DateTime.now()) : [ '', 'Вся история ваших финансов', 'Понимайте, куда уходят деньги', 'Планируйте расходы по категориям', 'Ваши предпочтения и данные', ][page], style: Theme.of(context).textTheme.bodyMedium ?.copyWith( color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), ), ], ), ), if (wide && page == 0) FilledButton.icon( onPressed: editTransaction, icon: const Icon(Icons.add), label: const Text('Новая операция'), ), ], ), const SizedBox(height: 28), if (store.error != null) ...[ MaterialBanner( content: Text(store.error!), leading: const Icon(Icons.cloud_off_outlined), actions: [ TextButton( onPressed: store.clearError, child: const Text('Закрыть'), ), ], ), const SizedBox(height: 20), ], ...switch (page) { 0 => overview(), 1 => transactions(), 2 => analytics(), 3 => budgets(), _ => settings(), }, ], ), ), ), ), ], ), bottomNavigationBar: wide ? null : NavigationBar( selectedIndex: page, onDestinationSelected: (i) => setState(() => page = i), destinations: List.generate( 5, (i) => NavigationDestination( icon: Icon(icons[i]), label: titles[i], ), ), ), floatingActionButton: !wide && (page == 0 || page == 1) ? FloatingActionButton.extended( onPressed: editTransaction, icon: const Icon(Icons.add), label: const Text('Операция'), ) : null, ); } Widget sectionTitle(String title, {Widget? action}) => Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( children: [ Expanded( child: Text( title, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), ), ), ?action, ], ), ); Widget metricRow(List metrics) => LayoutBuilder( builder: (context, c) => c.maxWidth < 550 ? Column( children: [ for (final m in metrics) Padding( padding: const EdgeInsets.only(bottom: 12), child: SizedBox(width: double.infinity, child: m), ), ], ) : Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < metrics.length; i++) ...[ if (i > 0) const SizedBox(width: 16), Expanded(child: metrics[i]), ], ], ), ); List overview() { final now = DateTime.now(); final start = DateTime(now.year, now.month); final end = DateTime(now.year, now.month + 1); final summary = FinanceMath.summary(store.transactions, start, end); return [ Container( padding: const EdgeInsets.all(28), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(28), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Expanded(child: Text('Общий баланс')), TextButton.icon( onPressed: adjust, icon: const Icon(Icons.tune, size: 18), label: const Text('Изменить'), ), ], ), const SizedBox(height: 8), FittedBox( fit: BoxFit.scaleDown, child: Text( money( FinanceMath.totalBalance(store.transactions), store.currency, ), style: Theme.of(context).textTheme.displaySmall?.copyWith( fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), ), const SizedBox(height: 16), Text( store.session == null ? 'Данные хранятся на этом устройстве' : '${store.email} · ${store.syncMessage ?? 'Готово к синхронизации'}', ), ], ), ), const SizedBox(height: 20), metricRow([ Metric( label: 'Доходы за месяц', value: money(summary.incomeMinor, store.currency), icon: Icons.south_west, color: Colors.teal, ), Metric( label: 'Расходы за месяц', value: money(summary.expensesMinor, store.currency), icon: Icons.north_east, color: Colors.deepOrange, ), ]), const SizedBox(height: 28), sectionTitle('Расходы по категориям'), spending(start, end, limit: 5), const SizedBox(height: 28), sectionTitle( 'Последние операции', action: TextButton( onPressed: () => setState(() => page = 1), child: const Text('Все операции'), ), ), if (store.transactions.isEmpty) EmptyView( title: 'Начните с первой операции', subtitle: 'Запишите доход или расход, чтобы увидеть свой баланс.', action: FilledButton.icon( onPressed: editTransaction, icon: const Icon(Icons.add), label: const Text('Добавить'), ), ) else Panel( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( children: store.transactions.take(5).map(transactionTile).toList(), ), ), ]; } Widget transactionTile(TransactionRecord t) => ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), leading: CategoryAvatar( icon: t.isBalanceAdjustment ? 'tune' : t.categoryIcon, emoji: t.categoryEmoji, color: t.categoryColorName, ), title: Text( t.isBalanceAdjustment ? 'Корректировка баланса' : t.categoryName, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( '${DateFormat('d MMM, HH:mm', 'ru').format(t.date.toLocal())}${t.note.isEmpty ? '' : ' · ${t.note}'}', maxLines: 2, overflow: TextOverflow.ellipsis, ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Text( '${t.kindRawValue == 'income' ? '+' : '−'}${money(t.amountMinor, store.currency)}', style: TextStyle( fontWeight: FontWeight.w600, color: t.kindRawValue == 'income' ? Colors.teal : null, ), ), PopupMenuButton( tooltip: 'Действия с операцией', onSelected: (v) async { if (v == 'edit') { await editTransaction(t); } else if (await confirmDelete(context, 'Удалить операцию?')) { await run(() => store.deleteTransaction(t.id)); } }, itemBuilder: (_) => const [ PopupMenuItem(value: 'edit', child: Text('Изменить')), PopupMenuItem(value: 'delete', child: Text('Удалить')), ], ), ], ), onTap: () => editTransaction(t), ); List transactions() { final items = store.transactions .where( (t) => (filter == 'all' || t.kindRawValue == filter) && (query.isEmpty || '${t.categoryName} ${t.note}'.toLowerCase().contains( query.toLowerCase(), )), ) .toList(); return [ TextField( decoration: const InputDecoration( prefixIcon: Icon(Icons.search), hintText: 'Поиск по категории или заметке', ), onChanged: (s) => setState(() => query = s), ), const SizedBox(height: 16), Wrap( spacing: 8, children: [ for (final e in { 'all': 'Все', 'expense': 'Расходы', 'income': 'Доходы', }.entries) ChoiceChip( label: Text(e.value), selected: filter == e.key, onSelected: (_) => setState(() => filter = e.key), ), ], ), const SizedBox(height: 20), if (items.isEmpty) const EmptyView( title: 'Операций пока нет', subtitle: 'Добавьте операцию или измените условия поиска.', ) else Panel( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( children: [ for (int i = 0; i < items.length; i++) ...[ if (i == 0 || !DateUtils.isSameDay( items[i - 1].date.toLocal(), items[i].date.toLocal(), )) Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), child: Align( alignment: Alignment.centerLeft, child: Text( DateFormat( 'd MMMM y', 'ru', ).format(items[i].date.toLocal()), style: Theme.of(context).textTheme.labelLarge, ), ), ), transactionTile(items[i]), ], ], ), ), ]; } Widget spending(DateTime start, DateTime end, {int? limit}) { var groups = FinanceMath.spending(store.transactions, start, end); if (limit != null) groups = groups.take(limit).toList(); final total = FinanceMath.summary( store.transactions, start, end, ).expensesMinor; if (groups.isEmpty) { return const EmptyView( title: 'Расходов за период нет', subtitle: 'Здесь появится структура ваших расходов.', icon: Icons.donut_large, ); } return Panel( child: Column( children: [ for (final g in groups) Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( children: [ CategoryAvatar( icon: g.icon, emoji: g.emoji, color: g.colorName, size: 36, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( g.name, overflow: TextOverflow.ellipsis, ), ), Text( money(g.amountMinor, store.currency), style: const TextStyle( fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 8), LinearProgressIndicator( value: total == 0 ? 0 : g.amountMinor / total, borderRadius: BorderRadius.circular(8), minHeight: 5, color: categoryColors[g.colorName], ), const SizedBox(height: 4), Text( '${total == 0 ? 0 : (g.amountMinor / total * 100).round()}% расходов', style: Theme.of(context).textTheme.labelSmall, ), ], ), ), ], ), ), ], ), ); } List analytics() { final now = DateTime.now(); final start = DateTime(now.year, now.month - months + 1); final end = DateTime(now.year, now.month + 1); final sum = FinanceMath.summary(store.transactions, start, end); final grouped = FinanceMath.spending(store.transactions, start, end); final saving = sum.incomeMinor > 0 ? sum.balanceMinor / sum.incomeMinor * 100 : 0.0; final days = (DateTime.utc(now.year, now.month, now.day) .difference( DateTime.utc(start.year, start.month, start.day), ) .inDays + 1) .clamp(1, 10000); return [ SegmentedButton( segments: const [ ButtonSegment(value: 1, label: Text('Месяц')), ButtonSegment(value: 3, label: Text('3 месяца')), ButtonSegment(value: 12, label: Text('12 месяцев')), ], selected: {months}, onSelectionChanged: (s) => setState(() => months = s.first), ), const SizedBox(height: 20), metricRow([ Metric( label: 'Доходы', value: money(sum.incomeMinor, store.currency), icon: Icons.south_west, color: Colors.teal, ), Metric( label: 'Расходы', value: money(sum.expensesMinor, store.currency), icon: Icons.north_east, color: Colors.deepOrange, ), ]), const SizedBox(height: 16), metricRow([ Metric( label: 'Сбережения', value: '${saving.toStringAsFixed(1)}%', icon: Icons.savings_outlined, ), Metric( label: 'Средний расход в день', value: money((sum.expensesMinor / days).round(), store.currency), icon: Icons.calendar_today_outlined, ), ]), const SizedBox(height: 16), metricRow([ Metric( label: 'Результат периода', value: money(sum.balanceMinor, store.currency), icon: Icons.account_balance_wallet_outlined, ), Metric( label: 'Крупнейшая категория', value: grouped.isEmpty ? '—' : grouped.first.name, icon: Icons.category_outlined, ), ]), const SizedBox(height: 28), sectionTitle('Динамика доходов и расходов'), trend(start, end), const SizedBox(height: 28), sectionTitle('Структура расходов'), if (grouped.isNotEmpty) ...[ Panel( child: SpendingRing( segments: grouped .map( (g) => ( g.amountMinor, categoryColors[g.colorName] ?? Theme.of(context).colorScheme.primary, ), ) .toList(), total: money(sum.expensesMinor, store.currency), ), ), const SizedBox(height: 16), ], spending(start, end), ]; } Widget trend(DateTime start, DateTime end) { final buckets = <(DateTime, DateTime, String)>[]; if (months == 1) { for ( var d = start; d.isBefore(end); d = DateTime(d.year, d.month, d.day + 1) ) { buckets.add((d, DateTime(d.year, d.month, d.day + 1), '${d.day}')); } } else if (months == 3) { for ( var d = DateTime( start.year, start.month, start.day - start.weekday + 1, ); d.isBefore(end); d = DateTime(d.year, d.month, d.day + 7) ) { final e = DateTime(d.year, d.month, d.day + 7); buckets.add(( d.isBefore(start) ? start : d, e.isAfter(end) ? end : e, DateFormat('d.MM').format(d), )); } } else { for (var d = start; d.isBefore(end); d = DateTime(d.year, d.month + 1)) { buckets.add(( d, DateTime(d.year, d.month + 1), DateFormat('MMM', 'ru').format(d), )); } } final summaries = buckets .map((b) => FinanceMath.summary(store.transactions, b.$1, b.$2)) .toList(); var max = 1; for (final s in summaries) { if (s.incomeMinor > max) max = s.incomeMinor; if (s.expensesMinor > max) max = s.expensesMinor; } return Panel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Wrap( spacing: 20, children: [ Text('● Доходы', style: TextStyle(color: Colors.teal)), Text('● Расходы', style: TextStyle(color: Colors.deepOrange)), ], ), const SizedBox(height: 24), SizedBox( height: 210, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ for (int i = 0; i < buckets.length; i++) Expanded( child: Tooltip( message: '${DateFormat('d MMM', 'ru').format(buckets[i].$1)}\nДоходы: ${money(summaries[i].incomeMinor, store.currency)}\nРасходы: ${money(summaries[i].expensesMinor, store.currency)}', child: Semantics( label: '${buckets[i].$3}, доходы ${money(summaries[i].incomeMinor, store.currency)}, расходы ${money(summaries[i].expensesMinor, store.currency)}', child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ const Spacer(), Expanded( flex: 3, child: Container( height: (summaries[i].incomeMinor / max * 174) .clamp(2, 174) .toDouble(), decoration: BoxDecoration( color: Colors.teal, borderRadius: BorderRadius.circular(4), ), ), ), const SizedBox(width: 2), Expanded( flex: 3, child: Container( height: (summaries[i].expensesMinor / max * 174) .clamp(2, 174) .toDouble(), decoration: BoxDecoration( color: Colors.deepOrange, borderRadius: BorderRadius.circular(4), ), ), ), const Spacer(), ], ), ), const SizedBox(height: 8), SizedBox( height: 24, child: i % ((buckets.length / 8).ceil()) == 0 ? Text( buckets[i].$3, style: const TextStyle(fontSize: 10), maxLines: 1, ) : null, ), ], ), ), ), ), ], ), ), ], ), ); } List budgets() { final entries = store.budgets .where( (b) => b.monthStart.toLocal().year == budgetMonth.year && b.monthStart.toLocal().month == budgetMonth.month, ) .toList(); return [ Wrap( alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, spacing: 16, runSpacing: 12, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Предыдущий месяц', onPressed: () => setState( () => budgetMonth = DateTime( budgetMonth.year, budgetMonth.month - 1, ), ), icon: const Icon(Icons.chevron_left), ), Text(DateFormat('LLLL y', 'ru').format(budgetMonth)), IconButton( tooltip: 'Следующий месяц', onPressed: () => setState( () => budgetMonth = DateTime( budgetMonth.year, budgetMonth.month + 1, ), ), icon: const Icon(Icons.chevron_right), ), ], ), FilledButton.icon( onPressed: editBudget, icon: const Icon(Icons.add), label: const Text('Добавить лимит'), ), ], ), const SizedBox(height: 24), if (entries.isEmpty) const EmptyView( title: 'Бюджет ещё не запланирован', subtitle: 'Задайте месячные лимиты для категорий расходов.', icon: Icons.savings_outlined, ), for (final b in entries) Padding( padding: const EdgeInsets.only(bottom: 16), child: budgetCard(b), ), ]; } Widget budgetCard(BudgetRecord b) { final used = FinanceMath.budgetUsed(b, store.transactions); final ratio = b.limitMinor > 0 ? used / b.limitMinor : 0.0; final color = b.limitMinor <= 0 || ratio >= 1 ? Theme.of(context).colorScheme.error : ratio >= .8 ? Colors.orange : Theme.of(context).colorScheme.primary; return Panel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ CategoryAvatar(icon: b.categoryIcon, emoji: b.categoryEmoji), const SizedBox(width: 12), Expanded( child: Text( b.categoryName, style: Theme.of(context).textTheme.titleMedium, ), ), IconButton( tooltip: 'Изменить лимит', onPressed: () => editBudget(b), icon: const Icon(Icons.edit_outlined), ), IconButton( tooltip: 'Удалить лимит', onPressed: () async { if (await confirmDelete(context, 'Удалить лимит?')) { await run(() => store.deleteBudget(b.id)); } }, icon: const Icon(Icons.delete_outline), ), ], ), const SizedBox(height: 18), Wrap( spacing: 6, children: [ Text( money(used, store.currency), style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, color: color, ), ), Text('из ${money(b.limitMinor, store.currency)}'), ], ), const SizedBox(height: 12), LinearProgressIndicator( value: ratio.clamp(0, 1), color: color, minHeight: 8, borderRadius: BorderRadius.circular(8), ), const SizedBox(height: 12), Text( b.limitMinor <= 0 ? 'Некорректный лимит. Измените бюджет.' : used > b.limitMinor ? 'Превышение: ${money(used - b.limitMinor, store.currency)}' : 'Осталось: ${money(b.limitMinor - used, store.currency)}', style: TextStyle(color: color), ), ], ), ); } List settings() => [ Panel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ sectionTitle('Оформление'), DropdownButtonFormField( initialValue: store.currency, decoration: const InputDecoration(labelText: 'Валюта'), items: const [ 'RUB', 'EUR', 'USD', 'SEK', ].map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(), onChanged: (v) { if (v != null) run(() => store.setCurrency(v)); }, ), const SizedBox(height: 16), DropdownButtonFormField( initialValue: store.theme, decoration: const InputDecoration(labelText: 'Тема'), items: const [ DropdownMenuItem(value: 'system', child: Text('Системная')), DropdownMenuItem(value: 'light', child: Text('Светлая')), DropdownMenuItem(value: 'dark', child: Text('Тёмная')), ], onChanged: (v) { if (v != null) run(() => store.setTheme(v)); }, ), const SizedBox(height: 12), Text( 'Валюта задаёт формат сумм. Конвертация валют не выполняется.', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), const SizedBox(height: 20), Panel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ sectionTitle('Сервер и синхронизация'), Text( store.serverUrl.isEmpty ? 'Сервер не подключён' : store.serverUrl, ), const SizedBox(height: 12), Text( store.session == null ? 'Можно работать локально или подключить свой BalanceServer.' : store.email, ), if (store.lastSyncAt != null) Padding( padding: const EdgeInsets.only(top: 8), child: Text( 'Последняя синхронизация: ${DateFormat('d.MM.y HH:mm').format(store.lastSyncAt!)}', ), ), if (store.syncMessage != null) Padding( padding: const EdgeInsets.only(top: 8), child: Text(store.syncMessage!), ), const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, children: [ OutlinedButton.icon( onPressed: serverDialog, icon: const Icon(Icons.dns_outlined), label: const Text('Адрес сервера'), ), if (store.session == null) ...[ FilledButton( onPressed: store.serverUrl.isEmpty ? null : () => authDialog(false), child: const Text('Войти'), ), TextButton( onPressed: store.serverUrl.isEmpty ? null : () => authDialog(true), child: const Text('Регистрация'), ), ] else ...[ FilledButton.icon( onPressed: store.syncing ? null : () => run(store.sync), icon: const Icon(Icons.sync), label: const Text('Синхронизировать'), ), OutlinedButton( onPressed: store.syncing ? null : () => run(store.resetAndSync), child: const Text('Полная синхронизация'), ), TextButton( onPressed: store.syncing ? null : () => run(store.signOut), child: const Text('Выйти'), ), ], ], ), if (kIsWeb) const Padding( padding: EdgeInsets.only(top: 12), child: Text( 'Web: используйте HTTPS и разрешите адрес приложения в CORS-настройках сервера.', ), ), ], ), ), const SizedBox(height: 20), Panel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ sectionTitle( 'Категории', action: FilledButton.tonalIcon( onPressed: editCategory, icon: const Icon(Icons.add), label: const Text('Добавить'), ), ), if (store.categories.isEmpty) const Text('Создайте свои категории или используйте встроенные.'), for (final c in store.categories) ListTile( contentPadding: EdgeInsets.zero, leading: CategoryAvatar( icon: c.icon, emoji: c.emoji, color: c.colorName, ), title: Text(c.name), subtitle: Text(c.kindRawValue == 'income' ? 'Доход' : 'Расход'), onTap: () => editCategory(c), trailing: IconButton( tooltip: 'Удалить категорию', onPressed: () async { if (await confirmDelete( context, 'Удалить «${c.name}»?', message: 'Операции сохранят категорию. Связанные бюджеты будут удалены.', )) { await run(() => store.deleteCategory(c.id)); } }, icon: const Icon(Icons.delete_outline), ), ), ExpansionTile( tilePadding: EdgeInsets.zero, title: const Text('Встроенные категории'), children: [ for (final c in [ ...BuiltInCategories.expense, ...BuiltInCategories.income, ]) ListTile( leading: CategoryAvatar( icon: c.icon, emoji: c.emoji, color: c.colorName, size: 32, ), title: Text(c.name), subtitle: Text( c.kindRawValue == 'income' ? 'Доход' : 'Расход', ), ), ], ), ], ), ), const SizedBox(height: 20), Panel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ sectionTitle('Данные'), Wrap( spacing: 12, runSpacing: 12, children: [ OutlinedButton.icon( onPressed: export, icon: const Icon(Icons.download_outlined), label: const Text('Экспорт истории CSV'), ), TextButton( onPressed: () => showAboutDialog( context: context, applicationName: 'Баланс', applicationVersion: '4.0.0', applicationIcon: const Icon( Icons.account_balance_wallet_outlined, size: 40, ), children: const [ Text( 'Личный бюджет · Flutter · Material 3\nСинхронизация с BalanceServer', ), ], ), child: const Text('О приложении'), ), ], ), ], ), ), ]; Future serverDialog() async { final controller = TextEditingController(text: store.serverUrl); final form = GlobalKey(); final result = await showDialog( context: context, builder: (context) => AlertDialog( scrollable: true, title: const Text('Адрес сервера'), content: SizedBox( width: 460, child: Form( key: form, child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( 'При смене сервера потребуется новый вход. Данные аккаунтов хранятся раздельно.', ), const SizedBox(height: 20), TextFormField( controller: controller, keyboardType: TextInputType.url, decoration: const InputDecoration( labelText: 'https://balance.example.com', ), validator: (s) { final uri = Uri.tryParse((s ?? '').trim()); if (uri == null || !uri.hasAuthority || !['https', 'http'].contains(uri.scheme)) { return 'Введите полный адрес сервера'; } return null; }, ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Отмена'), ), FilledButton( onPressed: () { if (form.currentState!.validate()) { Navigator.pop(context, controller.text); } }, child: const Text('Сохранить'), ), ], ), ); if (result != null) { await run( () => store.setServerUrl(result), success: 'Адрес сервера сохранён', ); } await Future.delayed(const Duration(milliseconds: 300)); controller.dispose(); } Future authDialog(bool register) async { final email = TextEditingController(); final password = TextEditingController(); final form = GlobalKey(); final result = await showDialog( context: context, builder: (context) => AlertDialog( scrollable: true, title: Text(register ? 'Создать аккаунт' : 'Войти в аккаунт'), content: SizedBox( width: 420, child: Form( key: form, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextFormField( controller: email, keyboardType: TextInputType.emailAddress, autofillHints: const [AutofillHints.email], decoration: const InputDecoration( labelText: 'Электронная почта', ), validator: (s) => (s ?? '').contains('@') ? null : 'Введите email', ), const SizedBox(height: 16), TextFormField( controller: password, obscureText: true, autofillHints: [ register ? AutofillHints.newPassword : AutofillHints.password, ], decoration: const InputDecoration(labelText: 'Пароль'), validator: (s) => (s ?? '').isEmpty ? 'Введите пароль' : register && s!.length < 8 ? 'Не менее 8 символов' : null, ), const SizedBox(height: 12), const Text( 'При первом подключении нового аккаунта локальные записи переносятся в него. Аккаунты хранятся раздельно.', ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Отмена'), ), FilledButton( onPressed: () { if (form.currentState!.validate()) Navigator.pop(context, true); }, child: Text(register ? 'Зарегистрироваться' : 'Войти'), ), ], ), ); if (result == true) { await run( () => register ? store.register(email.text.trim(), password.text) : store.signIn(email.text.trim(), password.text), ); } await Future.delayed(const Duration(milliseconds: 300)); email.dispose(); password.dispose(); } Future export() async { await run(() async { final csv = FinanceMath.exportCsv(store.transactions); final bytes = Uint8List.fromList(utf8.encode(csv)); if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS)) { final box = context.findRenderObject() as RenderBox?; await SharePlus.instance.share( ShareParams( files: [ XFile.fromData( bytes, mimeType: 'text/csv', name: 'balance-transactions.csv', ), ], fileNameOverrides: ['balance-transactions.csv'], sharePositionOrigin: box == null ? null : box.localToGlobal(Offset.zero) & box.size, ), ); } else { final location = await getSaveLocation( suggestedName: 'balance-transactions.csv', acceptedTypeGroups: const [ XTypeGroup(label: 'CSV', extensions: ['csv']), ], ); if (location != null) { await XFile.fromData( bytes, mimeType: 'text/csv', name: 'balance-transactions.csv', ).saveTo(location.path); } } }); } }