From 322c60a359457bf150104d8c616394c63cce32cd Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 22:14:27 +0300 Subject: [PATCH] =?UTF-8?q?style(app):=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=8F?= =?UTF-8?q?=D0=B7=D1=8B=D0=BA=20=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели. --- app/lib/features/cashflow/cashflow_page.dart | 69 +++-- app/lib/features/cashflow/providers.dart | 15 +- .../features/categories/categories_page.dart | 57 +++- app/lib/features/categories/providers.dart | 20 +- app/lib/features/goals/data/goals_api.dart | 69 ++--- app/lib/features/goals/goal_card.dart | 34 ++- app/lib/features/goals/goal_edit_dialog.dart | 68 +++-- app/lib/features/goals/goals_page.dart | 30 ++- app/lib/features/goals/providers.dart | 17 +- .../features/health/data_quality_list.dart | 11 +- app/lib/features/health/health_page.dart | 60 ++++- .../features/imports/data/imports_api.dart | 188 +++++++------ .../features/imports/data/report_picker.dart | 4 +- .../features/imports/import_preview_page.dart | 188 +++++++++---- app/lib/features/imports/imports_page.dart | 45 +++- app/lib/features/imports/providers.dart | 16 +- .../imports/widgets/reconciliation_card.dart | 37 ++- .../imports/widgets/sample_events_table.dart | 43 +-- app/lib/features/income/calendar_tab.dart | 36 ++- app/lib/features/income/data/income_api.dart | 119 +++++---- app/lib/features/income/forecast_tab.dart | 57 ++-- app/lib/features/income/history_tab.dart | 49 +++- app/lib/features/income/income_page.dart | 12 +- app/lib/features/income/labels.dart | 20 +- app/lib/features/income/providers.dart | 68 +++-- .../features/pending/data/pending_api.dart | 53 ++-- app/lib/features/pending/pending_page.dart | 72 +++-- app/lib/features/pending/providers.dart | 28 +- .../widgets/create_instrument_dialog.dart | 67 +++-- .../widgets/link_instrument_dialog.dart | 22 +- .../rebalance/data/rebalance_api.dart | 138 ++++++---- app/lib/features/rebalance/labels.dart | 4 +- app/lib/features/rebalance/plan_tab.dart | 61 +++-- app/lib/features/rebalance/providers.dart | 30 ++- .../features/rebalance/rebalance_page.dart | 61 ++++- app/lib/features/rebalance/targets_tab.dart | 116 +++++--- app/lib/features/rebalance/weights.dart | 6 +- app/lib/features/rules/providers.dart | 18 +- app/lib/features/rules/rules_page.dart | 128 ++++++--- app/lib/features/tax/data/tax_api.dart | 74 +++--- app/lib/features/tax/lots_tab.dart | 192 ++++++++++---- app/lib/features/tax/providers.dart | 20 +- app/lib/features/tax/summary_tab.dart | 93 ++++++- app/lib/features/tax/tax_page.dart | 14 +- app/lib/features/transactions/providers.dart | 25 +- .../transactions/transaction_row.dart | 50 +++- .../transactions/transactions_page.dart | 170 ++++++++---- app/test/date_query_interceptor_test.dart | 6 +- app/test/event_row_test.dart | 42 ++- app/test/health_page_test.dart | 45 ++-- app/test/imports_api_test.dart | 59 ++-- app/test/phase4_models_test.dart | 251 ++++++++++-------- app/test/rules_page_test.dart | 4 +- app/test/transaction_row_test.dart | 52 ++-- app/test/widget_test.dart | 5 +- 55 files changed, 2158 insertions(+), 1080 deletions(-) diff --git a/app/lib/features/cashflow/cashflow_page.dart b/app/lib/features/cashflow/cashflow_page.dart index f0d2438..0c59392 100644 --- a/app/lib/features/cashflow/cashflow_page.dart +++ b/app/lib/features/cashflow/cashflow_page.dart @@ -61,7 +61,10 @@ class CashflowPage extends ConsumerWidget { scrollDirection: Axis.horizontal, reverse: true, child: SizedBox( - width: (rows.length * 56).toDouble().clamp(320, double.infinity), + width: (rows.length * 56).toDouble().clamp( + 320, + double.infinity, + ), child: _CashflowChart(rows: rows), ), ), @@ -102,7 +105,11 @@ class _LegendDot extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), const SizedBox(width: 6), Text(label, style: Theme.of(context).textTheme.bodySmall), ], @@ -119,7 +126,11 @@ class _CashflowChart extends StatelessWidget { Widget build(BuildContext context) { final maxY = rows.fold( 0, - (m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b), + (m, r) => [ + m, + _d(r.incomeRub), + _d(r.expenseRub), + ].reduce((a, b) => a > b ? a : b), ); return BarChart( BarChartData( @@ -127,9 +138,15 @@ class _CashflowChart extends StatelessWidget { gridData: const FlGridData(drawVerticalLine: false), borderData: FlBorderData(show: false), titlesData: FlTitlesData( - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, @@ -138,7 +155,10 @@ class _CashflowChart extends StatelessWidget { if (i < 0 || i >= rows.length) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(top: 6), - child: Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 10)), + child: Text( + ruMonthYearShort(rows[i].month), + style: const TextStyle(fontSize: 10), + ), ); }, ), @@ -160,8 +180,16 @@ class _CashflowChart extends StatelessWidget { BarChartGroupData( x: i, barRods: [ - BarChartRodData(toY: _d(rows[i].incomeRub), color: _incomeColor, width: 8), - BarChartRodData(toY: _d(rows[i].expenseRub), color: _expenseColor, width: 8), + BarChartRodData( + toY: _d(rows[i].incomeRub), + color: _incomeColor, + width: 8, + ), + BarChartRodData( + toY: _d(rows[i].expenseRub), + color: _expenseColor, + width: 8, + ), ], barsSpace: 2, ), @@ -188,13 +216,20 @@ class _Table extends StatelessWidget { DataColumn(label: Text('Расход', style: headerStyle), numeric: true), DataColumn(label: Text('Базовые', style: headerStyle), numeric: true), DataColumn(label: Text('Разовые', style: headerStyle), numeric: true), - DataColumn(label: Text('В сбережения', style: headerStyle), numeric: true), - DataColumn(label: Text('Норма сбер., %', style: headerStyle), numeric: true), + DataColumn( + label: Text('В сбережения', style: headerStyle), + numeric: true, + ), + DataColumn( + label: Text('Норма сбер., %', style: headerStyle), + numeric: true, + ), ], rows: [ for (final r in rows.reversed) DataRow( - onSelectChanged: (_) => context.go('/categories?month=${monthKey(r.month)}'), + onSelectChanged: (_) => + context.go('/categories?month=${monthKey(r.month)}'), cells: [ DataCell(Text(ruMonthYearShort(r.month))), DataCell(MoneyText(r.incomeRub, currency: 'RUB')), @@ -202,9 +237,13 @@ class _Table extends StatelessWidget { DataCell(MoneyText(r.baselineRub, currency: 'RUB')), DataCell(MoneyText(r.oneOffRub, currency: 'RUB')), DataCell(MoneyText(r.savingsTransferRub, currency: 'RUB')), - DataCell(Text(r.savingsRate == null - ? '—' - : '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%')), + DataCell( + Text( + r.savingsRate == null + ? '—' + : '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%', + ), + ), ], ), ], diff --git a/app/lib/features/cashflow/providers.dart b/app/lib/features/cashflow/providers.dart index 7b5d27d..1f5d477 100644 --- a/app/lib/features/cashflow/providers.dart +++ b/app/lib/features/cashflow/providers.dart @@ -5,7 +5,14 @@ import '../../core/api/api_client.dart'; import '../../core/cache/cached.dart'; /// The last 24 months, oldest first (as the API returns them). See `docs/ai/offline-cache.md`. -final cashflowMonthly24Provider = FutureProvider.autoDispose>>((ref) async { - final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 24); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); -}); +final cashflowMonthly24Provider = + FutureProvider.autoDispose>>((ref) async { + final r = await ref + .watch(apiProvider) + .getCashflowApi() + .cashflowMonthly(months: 24); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); + }); diff --git a/app/lib/features/categories/categories_page.dart b/app/lib/features/categories/categories_page.dart index 121373c..1944097 100644 --- a/app/lib/features/categories/categories_page.dart +++ b/app/lib/features/categories/categories_page.dart @@ -18,20 +18,29 @@ class _Group { final String rootName; final List rows = []; - Decimal get total => rows.fold(Decimal.zero, (a, r) => a + Decimal.parse(r.amountRub)); + Decimal get total => + rows.fold(Decimal.zero, (a, r) => a + Decimal.parse(r.amountRub)); } List<_Group> _group(List rows) { final byRoot = {}; for (final r in rows) { - final key = r.categoryId == null ? null : (r.rootCategoryId ?? r.categoryId); + final key = r.categoryId == null + ? null + : (r.rootCategoryId ?? r.categoryId); final group = byRoot.putIfAbsent( key, - () => _Group(key, key == null ? 'Без категории' : (r.rootCategoryName ?? r.categoryName ?? '—')), + () => _Group( + key, + key == null + ? 'Без категории' + : (r.rootCategoryName ?? r.categoryName ?? '—'), + ), ); group.rows.add(r); } - final groups = byRoot.values.toList()..sort((a, b) => b.total.compareTo(a.total)); + final groups = byRoot.values.toList() + ..sort((a, b) => b.total.compareTo(a.total)); return groups; } @@ -51,7 +60,9 @@ class _CategoriesPageState extends ConsumerState { super.initState(); final initial = widget.initialMonth; if (initial != null) { - Future.microtask(() => ref.read(selectedSpendingMonthProvider.notifier).state = initial); + Future.microtask( + () => ref.read(selectedSpendingMonthProvider.notifier).state = initial, + ); } } @@ -66,8 +77,9 @@ class _CategoriesPageState extends ConsumerState { initialDatePickerMode: DatePickerMode.year, ); if (picked != null) { - ref.read(selectedSpendingMonthProvider.notifier).state = - monthKey(DateTime(picked.year, picked.month)); + ref.read(selectedSpendingMonthProvider.notifier).state = monthKey( + DateTime(picked.year, picked.month), + ); } } @@ -134,7 +146,10 @@ class _CategoriesPageState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Всего расходов', style: Theme.of(context).textTheme.bodyLarge), + Text( + 'Всего расходов', + style: Theme.of(context).textTheme.bodyLarge, + ), MoneyText( total.toString(), currency: 'RUB', @@ -144,7 +159,8 @@ class _CategoriesPageState extends ConsumerState { ), ), const Divider(), - for (final g in groups) _GroupTile(group: g, maxTotal: maxTotal), + for (final g in groups) + _GroupTile(group: g, maxTotal: maxTotal), ], ); }, @@ -163,7 +179,8 @@ class _GroupTile extends StatelessWidget { final Decimal maxTotal; bool get _flat => - group.rows.length == 1 && (group.rootId == null || group.rows.first.categoryId == group.rootId); + group.rows.length == 1 && + (group.rootId == null || group.rows.first.categoryId == group.rootId); @override Widget build(BuildContext context) { @@ -176,16 +193,26 @@ class _GroupTile extends StatelessWidget { ); } final children = [...group.rows] - ..sort((a, b) => Decimal.parse(b.amountRub).compareTo(Decimal.parse(a.amountRub))); + ..sort( + (a, b) => + Decimal.parse(b.amountRub).compareTo(Decimal.parse(a.amountRub)), + ); return ExpansionTile( tilePadding: EdgeInsets.zero, - title: _CategoryBar(name: group.rootName, amount: group.total, maxAmount: maxTotal, bold: true), + title: _CategoryBar( + name: group.rootName, + amount: group.total, + maxAmount: maxTotal, + bold: true, + ), children: [ for (final r in children) Padding( padding: const EdgeInsets.only(left: 16), child: _CategoryBar( - name: r.categoryId == group.rootId ? 'Без подкатегории' : (r.categoryName ?? '—'), + name: r.categoryId == group.rootId + ? 'Без подкатегории' + : (r.categoryName ?? '—'), amount: Decimal.parse(r.amountRub), maxAmount: group.total, bold: false, @@ -240,7 +267,9 @@ class _CategoryBar extends StatelessWidget { Container( height: 6, width: constraints.maxWidth * ratio, - color: bold ? ChartColors.expense : ChartColors.expense.withValues(alpha: 0.6), + color: bold + ? ChartColors.expense + : ChartColors.expense.withValues(alpha: 0.6), ), ], ), diff --git a/app/lib/features/categories/providers.dart b/app/lib/features/categories/providers.dart index 29fda27..45621e9 100644 --- a/app/lib/features/categories/providers.dart +++ b/app/lib/features/categories/providers.dart @@ -7,7 +7,9 @@ import '../../core/utils/ru_date.dart'; /// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed. Shared with /// Транзакции; not wrapped in `Cached` since it is a lookup, not a screen's own primary read. -final categoriesListProvider = FutureProvider.autoDispose>((ref) async { +final categoriesListProvider = FutureProvider.autoDispose>(( + ref, +) async { final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList(); return r.data ?? const []; }); @@ -20,11 +22,17 @@ final categoryNamesProvider = Provider.autoDispose>((ref) { /// Spending by category for one month (`YYYY-MM`); null means "the latest month". See /// `docs/ai/offline-cache.md`. -final spendingProvider = - FutureProvider.autoDispose.family>, String?>((ref, month) async { - final r = await ref.watch(apiProvider).getCashflowApi().cashflowSpending(month: month); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); -}); +final spendingProvider = FutureProvider.autoDispose + .family>, String?>((ref, month) async { + final r = await ref + .watch(apiProvider) + .getCashflowApi() + .cashflowSpending(month: month); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); + }); /// The month currently selected on the Категории screen, `YYYY-MM`. final selectedSpendingMonthProvider = StateProvider.autoDispose( diff --git a/app/lib/features/goals/data/goals_api.dart b/app/lib/features/goals/data/goals_api.dart index 1c2083d..f758c14 100644 --- a/app/lib/features/goals/data/goals_api.dart +++ b/app/lib/features/goals/data/goals_api.dart @@ -40,33 +40,33 @@ class Goal { /// The create/patch body. `id` is never sent; `null` fields are omitted so a PATCH that /// touches one field does not blank the rest. Map toJson() => { - 'name': name, - 'scope': scope, - 'target_amount': targetAmount, - 'currency': currency, - 'target_date': ?_isoDate(targetDate), - 'monthly_contribution': ?monthlyContribution, - 'note': ?note, - 'archived': archived, - }; + 'name': name, + 'scope': scope, + 'target_amount': targetAmount, + 'currency': currency, + 'target_date': ?_isoDate(targetDate), + 'monthly_contribution': ?monthlyContribution, + 'note': ?note, + 'archived': archived, + }; static Goal fromJson(Map json) => Goal( - id: asInt(json['id']) ?? 0, - name: asString(json['name']) ?? 'Без названия', - scope: asString(json['scope']) ?? 'all', - targetAmount: asString(json['target_amount']) ?? '0', - currency: asString(json['currency']) ?? 'RUB', - targetDate: asDate(json['target_date']), - monthlyContribution: asString(json['monthly_contribution']), - note: asString(json['note']), - archived: asBool(json['archived']), - ); + id: asInt(json['id']) ?? 0, + name: asString(json['name']) ?? 'Без названия', + scope: asString(json['scope']) ?? 'all', + targetAmount: asString(json['target_amount']) ?? '0', + currency: asString(json['currency']) ?? 'RUB', + targetDate: asDate(json['target_date']), + monthlyContribution: asString(json['monthly_contribution']), + note: asString(json['note']), + archived: asBool(json['archived']), + ); static String? _isoDate(DateTime? d) => d == null ? null : '${d.year.toString().padLeft(4, '0')}-' - '${d.month.toString().padLeft(2, '0')}-' - '${d.day.toString().padLeft(2, '0')}'; + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; } class GoalProgress { @@ -106,17 +106,17 @@ class GoalProgress { bool get isUnreachable => projectedDate == null && basis != 'none'; static GoalProgress fromJson(Map json) => GoalProgress( - goalId: asInt(json['goal_id']) ?? 0, - asOf: asDate(json['as_of']), - currentValueRub: asString(json['current_value_rub']), - targetAmountRub: asString(json['target_amount_rub']), - progress: asString(json['progress']), - projectedDate: asDate(json['projected_date']), - basis: asString(json['basis']) ?? 'none', - assumedRate: asString(json['assumed_rate']), - monthlyNeededRub: asString(json['monthly_needed_rub']), - onTrack: asBool(json['on_track']), - ); + goalId: asInt(json['goal_id']) ?? 0, + asOf: asDate(json['as_of']), + currentValueRub: asString(json['current_value_rub']), + targetAmountRub: asString(json['target_amount_rub']), + progress: asString(json['progress']), + projectedDate: asDate(json['projected_date']), + basis: asString(json['basis']) ?? 'none', + assumedRate: asString(json['assumed_rate']), + monthlyNeededRub: asString(json['monthly_needed_rub']), + onTrack: asBool(json['on_track']), + ); } class GoalsApi { @@ -142,7 +142,10 @@ class GoalsApi { } Future patch(int id, Map changes) async { - final r = await _dio.patch>('$_base/$id', data: changes); + final r = await _dio.patch>( + '$_base/$id', + data: changes, + ); return Goal.fromJson(r.data ?? const {}); } diff --git a/app/lib/features/goals/goal_card.dart b/app/lib/features/goals/goal_card.dart index fc8b7bf..b882ec1 100644 --- a/app/lib/features/goals/goal_card.dart +++ b/app/lib/features/goals/goal_card.dart @@ -19,7 +19,8 @@ const goalBasisLabels = { const goalBasisDescriptions = { 'xirr': 'Прогноз построен на фактической доходности портфеля (XIRR).', - 'contribution': 'Прогноз построен на регулярных взносах, без учёта доходности.', + 'contribution': + 'Прогноз построен на регулярных взносах, без учёта доходности.', 'none': 'Данных для прогноза нет: ни доходности, ни истории взносов.', }; @@ -58,12 +59,15 @@ class GoalCard extends ConsumerWidget { Text( [ 'цель ${MoneyText.format(goal.targetAmount, goal.currency)}', - if (goal.targetDate != null) 'к ${ruDate(goal.targetDate!)}', + if (goal.targetDate != null) + 'к ${ruDate(goal.targetDate!)}', if (goal.monthlyContribution != null) 'взнос ${MoneyText.format(goal.monthlyContribution!, goal.currency)}/мес', if (goal.archived) 'в архиве', ].join(' · '), - style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), ), ], ), @@ -86,7 +90,8 @@ class GoalCard extends ConsumerWidget { AsyncValueView( value: progress, onRetry: () => ref.invalidate(goalProgressProvider(goal.id)), - data: (cached) => GoalProgressView(goal: goal, progress: cached.data), + data: (cached) => + GoalProgressView(goal: goal, progress: cached.data), ), ], ), @@ -98,7 +103,11 @@ class GoalCard extends ConsumerWidget { /// The progress body, split out of [GoalCard] so it can be rendered (and tested) without a /// provider container. class GoalProgressView extends StatelessWidget { - const GoalProgressView({required this.goal, required this.progress, super.key}); + const GoalProgressView({ + required this.goal, + required this.progress, + super.key, + }); final Goal goal; final GoalProgress progress; @@ -150,15 +159,17 @@ class GoalProgressView extends StatelessWidget { Icon( unreachable ? Icons.trending_flat : Icons.flag_outlined, size: 18, - color: unreachable ? theme.colorScheme.error : theme.colorScheme.primary, + color: unreachable + ? theme.colorScheme.error + : theme.colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Text( unreachable ? (progress.basis == 'none' - ? 'Прогноз недоступен: данных для оценки тренда пока нет' - : 'При текущем тренде цель не достигается') + ? 'Прогноз недоступен: данных для оценки тренда пока нет' + : 'При текущем тренде цель не достигается') : 'Прогнозная дата достижения: ${ruDate(progress.projectedDate!)}', style: theme.textTheme.bodyMedium?.copyWith( color: unreachable ? theme.colorScheme.error : null, @@ -177,7 +188,7 @@ class GoalProgressView extends StatelessWidget { progress.monthlyNeededRub == null ? 'Нужный ежемесячный взнос: — (нет целевой даты)' : 'Нужно докладывать: ' - '${MoneyText.format(progress.monthlyNeededRub!, 'RUB')}/мес', + '${MoneyText.format(progress.monthlyNeededRub!, 'RUB')}/мес', style: theme.textTheme.bodySmall, ), if (progress.assumedRate != null) @@ -192,7 +203,10 @@ class GoalProgressView extends StatelessWidget { ), ), if (progress.asOf != null) - Text('на ${ruDate(progress.asOf!)}', style: theme.textTheme.bodySmall), + Text( + 'на ${ruDate(progress.asOf!)}', + style: theme.textTheme.bodySmall, + ), ], ), ], diff --git a/app/lib/features/goals/goal_edit_dialog.dart b/app/lib/features/goals/goal_edit_dialog.dart index 717bd11..ff90b86 100644 --- a/app/lib/features/goals/goal_edit_dialog.dart +++ b/app/lib/features/goals/goal_edit_dialog.dart @@ -20,9 +20,12 @@ class GoalEditDialog extends ConsumerStatefulWidget { class _GoalEditDialogState extends ConsumerState { final _formKey = GlobalKey(); late final _name = TextEditingController(text: widget.initial?.name ?? ''); - late final _amount = TextEditingController(text: widget.initial?.targetAmount ?? ''); - late final _contribution = - TextEditingController(text: widget.initial?.monthlyContribution ?? ''); + late final _amount = TextEditingController( + text: widget.initial?.targetAmount ?? '', + ); + late final _contribution = TextEditingController( + text: widget.initial?.monthlyContribution ?? '', + ); late final _note = TextEditingController(text: widget.initial?.note ?? ''); late String _scope = widget.initial?.scope ?? 'all'; late DateTime? _targetDate = widget.initial?.targetDate; @@ -71,7 +74,8 @@ class _GoalEditDialogState extends ConsumerState { ), const SizedBox(height: 12), DropdownButtonFormField( - initialValue: scopes.any((s) => s.scope == _scope) || _scope == 'all' + initialValue: + scopes.any((s) => s.scope == _scope) || _scope == 'all' ? _scope : 'all', decoration: const InputDecoration(labelText: 'Что считаем'), @@ -86,14 +90,20 @@ class _GoalEditDialogState extends ConsumerState { const SizedBox(height: 12), TextFormField( controller: _amount, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Целевая сумма, ₽'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Целевая сумма, ₽', + ), validator: _decimalValidator, ), const SizedBox(height: 12), TextFormField( controller: _contribution, - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), decoration: const InputDecoration( labelText: 'Ежемесячный взнос, ₽', helperText: 'необязательно', @@ -104,20 +114,25 @@ class _GoalEditDialogState extends ConsumerState { Row( children: [ Expanded( - child: Text(_targetDate == null - ? 'Целевая дата не задана' - : 'Целевая дата: ${ruDate(_targetDate!)}'), + child: Text( + _targetDate == null + ? 'Целевая дата не задана' + : 'Целевая дата: ${ruDate(_targetDate!)}', + ), ), TextButton( onPressed: () async { final now = DateTime.now(); final picked = await showDatePicker( context: context, - initialDate: _targetDate ?? DateTime(now.year + 3, now.month, now.day), + initialDate: + _targetDate ?? + DateTime(now.year + 3, now.month, now.day), firstDate: DateTime(now.year - 1), lastDate: DateTime(now.year + 50), ); - if (picked != null) setState(() => _targetDate = picked); + if (picked != null) + setState(() => _targetDate = picked); }, child: const Text('Выбрать'), ), @@ -147,21 +162,26 @@ class _GoalEditDialogState extends ConsumerState { ), ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена')), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), FilledButton( onPressed: () { if (!(_formKey.currentState?.validate() ?? false)) return; - Navigator.of(context).pop(Goal( - id: widget.initial?.id ?? 0, - name: _name.text.trim(), - scope: _scope, - targetAmount: _decimal(_amount.text) ?? '0', - currency: widget.initial?.currency ?? 'RUB', - targetDate: _targetDate, - monthlyContribution: _decimal(_contribution.text), - note: _note.text.trim().isEmpty ? null : _note.text.trim(), - archived: _archived, - )); + Navigator.of(context).pop( + Goal( + id: widget.initial?.id ?? 0, + name: _name.text.trim(), + scope: _scope, + targetAmount: _decimal(_amount.text) ?? '0', + currency: widget.initial?.currency ?? 'RUB', + targetDate: _targetDate, + monthlyContribution: _decimal(_contribution.text), + note: _note.text.trim().isEmpty ? null : _note.text.trim(), + archived: _archived, + ), + ); }, child: const Text('Сохранить'), ), diff --git a/app/lib/features/goals/goals_page.dart b/app/lib/features/goals/goals_page.dart index 892971f..980044c 100644 --- a/app/lib/features/goals/goals_page.dart +++ b/app/lib/features/goals/goals_page.dart @@ -23,7 +23,8 @@ class _GoalsPageState extends ConsumerState { bool _showArchived = false; void _snack(String message) => - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); Future _create() async { final goal = await showDialog( @@ -62,8 +63,14 @@ class _GoalsPageState extends ConsumerState { title: const Text('Удалить цель?'), content: Text('«${goal.name}» будет удалена безвозвратно.'), actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Отмена')), - FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Удалить')), + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Удалить'), + ), ], ), ); @@ -82,10 +89,13 @@ class _GoalsPageState extends ConsumerState { final goals = ref.watch(goalsProvider); // Every card's own progress fetch counts toward the one banner too — a fresh list with a // stale progress card is still an offline dashboard, just not a visibly empty one. - final goalIds = [for (final g in goals.valueOrNull?.data ?? const []) g.id]; + final goalIds = [ + for (final g in goals.valueOrNull?.data ?? const []) g.id, + ]; final stale = oldestFetch([ goals.valueOrNull?.fetchedAt, - for (final id in goalIds) ref.watch(goalProgressProvider(id)).valueOrNull?.fetchedAt, + for (final id in goalIds) + ref.watch(goalProgressProvider(id)).valueOrNull?.fetchedAt, ]); return Scaffold( @@ -94,7 +104,9 @@ class _GoalsPageState extends ConsumerState { actions: [ IconButton( tooltip: _showArchived ? 'Скрыть архив' : 'Показать архив', - icon: Icon(_showArchived ? Icons.inventory_2 : Icons.inventory_2_outlined), + icon: Icon( + _showArchived ? Icons.inventory_2 : Icons.inventory_2_outlined, + ), onPressed: () => setState(() => _showArchived = !_showArchived), ), IconButton( @@ -116,7 +128,9 @@ class _GoalsPageState extends ConsumerState { onRetry: () => ref.invalidate(goalsProvider), data: (cached) { final all = cached.data; - final rows = _showArchived ? all : all.where((g) => !g.archived).toList(); + final rows = _showArchived + ? all + : all.where((g) => !g.archived).toList(); if (rows.isEmpty) { return ListView( padding: const EdgeInsets.all(16), @@ -127,7 +141,7 @@ class _GoalsPageState extends ConsumerState { icon: Icons.flag_outlined, message: all.isEmpty ? 'Целей пока нет.\nЦель — это сумма и (необязательно) дата; ' - 'прогноз считает сервер по доходности или по взносам.' + 'прогноз считает сервер по доходности или по взносам.' : 'Все цели в архиве — включите показ архива.', ), ], diff --git a/app/lib/features/goals/providers.dart b/app/lib/features/goals/providers.dart index 6e3f006..e6a9a7d 100644 --- a/app/lib/features/goals/providers.dart +++ b/app/lib/features/goals/providers.dart @@ -4,18 +4,21 @@ import '../../core/api/api_client.dart'; import '../../core/cache/cached.dart'; import 'data/goals_api.dart'; -final goalsApiProvider = Provider((ref) => GoalsApi(ref.watch(apiProvider).dio)); +final goalsApiProvider = Provider( + (ref) => GoalsApi(ref.watch(apiProvider).dio), +); /// See `docs/ai/offline-cache.md`. -final goalsProvider = - FutureProvider.autoDispose>>((ref) => ref.watch(goalsApiProvider).list()); +final goalsProvider = FutureProvider.autoDispose>>( + (ref) => ref.watch(goalsApiProvider).list(), +); /// Progress is computed server-side and refetched per goal — the client never projects /// anything itself. -final goalProgressProvider = - FutureProvider.autoDispose.family, int>((ref, id) async { - return ref.watch(goalsApiProvider).progress(id); -}); +final goalProgressProvider = FutureProvider.autoDispose + .family, int>((ref, id) async { + return ref.watch(goalsApiProvider).progress(id); + }); /// After any create/patch/delete the list and every progress card are refetched: the /// numbers are recomputed on the server, not patched in the app. diff --git a/app/lib/features/health/data_quality_list.dart b/app/lib/features/health/data_quality_list.dart index 45666f1..4ee2325 100644 --- a/app/lib/features/health/data_quality_list.dart +++ b/app/lib/features/health/data_quality_list.dart @@ -52,14 +52,19 @@ class _DataQualityTile extends StatelessWidget { margin: const EdgeInsets.only(top: 4, right: 10), width: 10, height: 10, - decoration: BoxDecoration(color: severityColor(row.severity), shape: BoxShape.circle), + decoration: BoxDecoration( + color: severityColor(row.severity), + shape: BoxShape.circle, + ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${row.checkName} (${row.count})', - style: Theme.of(context).textTheme.titleSmall), + Text( + '${row.checkName} (${row.count})', + style: Theme.of(context).textTheme.titleSmall, + ), const SizedBox(height: 2), Text(row.detail), ], diff --git a/app/lib/features/health/health_page.dart b/app/lib/features/health/health_page.dart index 3d2b25d..f24f1dd 100644 --- a/app/lib/features/health/health_page.dart +++ b/app/lib/features/health/health_page.dart @@ -16,12 +16,16 @@ import 'data_quality_list.dart'; final _dateFmt = DateFormat('dd.MM.yyyy HH:mm'); -final syncStatusProvider = FutureProvider.autoDispose>((ref) async { +final syncStatusProvider = FutureProvider.autoDispose>(( + ref, +) async { final r = await ref.watch(apiProvider).getSyncApi().syncStatus(); return r.data ?? const []; }); -final syncRunsProvider = FutureProvider.autoDispose>((ref) async { +final syncRunsProvider = FutureProvider.autoDispose>(( + ref, +) async { final r = await ref.watch(apiProvider).getSyncApi().syncRuns(limit: 20); return r.data ?? const []; }); @@ -74,7 +78,8 @@ class _HealthPageState extends ConsumerState { await ref.read(apiProvider).getSyncApi().syncTrigger(source_: source); } on DioException catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(problemMessage(e)))); } finally { ref.invalidate(syncStatusProvider); ref.invalidate(syncRunsProvider); @@ -114,7 +119,10 @@ class _HealthPageState extends ConsumerState { body: TabBarView( children: [ _SourcesTab(onRefresh: _refreshAll, onTrigger: _trigger), - _QualityTab(dataQuality: dataQuality, onRefresh: () async => ref.invalidate(dataQualityProvider)), + _QualityTab( + dataQuality: dataQuality, + onRefresh: () async => ref.invalidate(dataQualityProvider), + ), ], ), ), @@ -149,17 +157,26 @@ class _SourcesTab extends ConsumerWidget { message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).', ) : Column( - children: [for (final s in rows) _SourceCard(source: s, onTrigger: onTrigger)], + children: [ + for (final s in rows) + _SourceCard(source: s, onTrigger: onTrigger), + ], ), ), const SizedBox(height: 24), - Text('Последние запуски', style: Theme.of(context).textTheme.titleMedium), + Text( + 'Последние запуски', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 8), AsyncValueView( value: runs, onRetry: () => ref.invalidate(syncRunsProvider), data: (rows) => rows.isEmpty - ? const EmptyState(icon: Icons.history, message: 'Запусков ещё не было.') + ? const EmptyState( + icon: Icons.history, + message: 'Запусков ещё не было.', + ) : Column(children: [for (final r in rows) _RunTile(run: r)]), ), ], @@ -217,10 +234,14 @@ class _SourceCard extends StatelessWidget { ), subtitle: Padding( padding: const EdgeInsets.only(top: 4), - child: Text([ - if (source.lastRunAt != null) 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}', - if (source.cursor != null) 'курсор ${_shortCursor(source.cursor!)}', - ].join(' · ')), + child: Text( + [ + if (source.lastRunAt != null) + 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}', + if (source.cursor != null) + 'курсор ${_shortCursor(source.cursor!)}', + ].join(' · '), + ), ), trailing: IconButton( tooltip: 'Запустить синхронизацию', @@ -277,7 +298,12 @@ class _RunTile extends StatelessWidget { children: [ content, const SizedBox(height: 8), - Text(run.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + Text( + run.error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), ], ), ), @@ -295,7 +321,10 @@ class _RunTile extends StatelessWidget { _runTitle(context), Padding( padding: const EdgeInsets.only(top: 2, bottom: 6), - child: Text(subtitle, style: Theme.of(context).textTheme.bodySmall), + child: Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall, + ), ), content, ], @@ -317,7 +346,10 @@ class _RunTile extends StatelessWidget { Widget _statusChip(BuildContext context, RunStatus? status) { if (status == null) { - return const Chip(visualDensity: VisualDensity.compact, label: Text('нет данных')); + return const Chip( + visualDensity: VisualDensity.compact, + label: Text('нет данных'), + ); } final scheme = Theme.of(context).colorScheme; final (label, color) = switch (status) { diff --git a/app/lib/features/imports/data/imports_api.dart b/app/lib/features/imports/data/imports_api.dart index 3c76000..a01a46e 100644 --- a/app/lib/features/imports/data/imports_api.dart +++ b/app/lib/features/imports/data/imports_api.dart @@ -17,14 +17,20 @@ import '../../pending/data/pending_api.dart'; /// A candidate account for an import whose `account_id` the server could not resolve. class AccountSuggestion { - const AccountSuggestion({required this.id, required this.name, this.broker, this.sourceId}); + const AccountSuggestion({ + required this.id, + required this.name, + this.broker, + this.sourceId, + }); final int id; final String name; final String? broker; final String? sourceId; - static AccountSuggestion fromJson(Map json) => AccountSuggestion( + static AccountSuggestion fromJson(Map json) => + AccountSuggestion( id: asInt(json['id'])!, name: asString(json['name']) ?? '#${json['id']}', broker: asString(json['broker']), @@ -97,15 +103,15 @@ class ReconPosition { String get title => instrumentName ?? ticker ?? isin ?? '—'; static ReconPosition fromJson(Map json) => ReconPosition( - matches: json['matches'] == true, - instrumentId: asInt(json['instrument_id']), - instrumentName: asString(json['instrument_name']), - ticker: asString(json['ticker']), - isin: asString(json['isin']), - qtyReport: asString(json['qty_report']), - qtyDerived: asString(json['qty_derived']), - qtyDelta: asString(json['qty_delta']), - ); + matches: json['matches'] == true, + instrumentId: asInt(json['instrument_id']), + instrumentName: asString(json['instrument_name']), + ticker: asString(json['ticker']), + isin: asString(json['isin']), + qtyReport: asString(json['qty_report']), + qtyDerived: asString(json['qty_derived']), + qtyDelta: asString(json['qty_delta']), + ); } class ReconCash { @@ -124,12 +130,12 @@ class ReconCash { final String? delta; static ReconCash fromJson(Map json) => ReconCash( - currency: asString(json['currency']) ?? 'RUB', - matches: json['matches'] == true, - balanceReport: asString(json['balance_report']), - balanceDerived: asString(json['balance_derived']), - delta: asString(json['delta']), - ); + currency: asString(json['currency']) ?? 'RUB', + matches: json['matches'] == true, + balanceReport: asString(json['balance_report']), + balanceDerived: asString(json['balance_derived']), + delta: asString(json['delta']), + ); } class Reconciliation { @@ -197,23 +203,23 @@ class SampleEvent { final String? description; static SampleEvent fromJson(Map json) => SampleEvent( - lineNo: asInt(json['line_no']) ?? 0, - kind: asString(json['kind']) ?? 'other', - isDuplicate: json['is_duplicate'] == true, - tradeDate: asDate(json['trade_date']), - settleDate: asDate(json['settle_date']), - instrumentKey: asString(json['instrument_key']), - instrumentName: asString(json['instrument_name']), - instrumentId: asInt(json['instrument_id']), - quantity: asString(json['quantity']), - price: asString(json['price']), - amount: asString(json['amount']), - currency: asString(json['currency']), - fee: asString(json['fee']), - tradeNo: asString(json['trade_no']), - dedupeKey: asString(json['dedupe_key']), - description: asString(json['description']), - ); + lineNo: asInt(json['line_no']) ?? 0, + kind: asString(json['kind']) ?? 'other', + isDuplicate: json['is_duplicate'] == true, + tradeDate: asDate(json['trade_date']), + settleDate: asDate(json['settle_date']), + instrumentKey: asString(json['instrument_key']), + instrumentName: asString(json['instrument_name']), + instrumentId: asInt(json['instrument_id']), + quantity: asString(json['quantity']), + price: asString(json['price']), + amount: asString(json['amount']), + currency: asString(json['currency']), + fee: asString(json['fee']), + tradeNo: asString(json['trade_no']), + dedupeKey: asString(json['dedupe_key']), + description: asString(json['description']), + ); } /// `ImportPreview` and `ImportSummary` in one class: the summary is the same object without @@ -281,34 +287,38 @@ class ImportPreview { bool get canDelete => !isCommitted; static ImportPreview fromJson(Map json) => ImportPreview( - id: asInt(json['id'])!, - filename: asString(json['filename']) ?? 'без имени', - parseStatus: asString(json['parse_status']) ?? 'uploaded', - broker: asString(json['broker']), - sha256: asString(json['sha256']), - sizeBytes: asInt(json['size_bytes']), - parserName: asString(json['parser_name']), - parserVersion: asString(json['parser_version']), - error: asString(json['error']), - duplicateOfId: asInt(json['duplicate_of_id']), - accountExternalId: asString(json['account_external_id']), - accountId: asInt(json['account_id']), - accountName: asString(json['account_name']), - accountSuggestions: - asList(json['account_suggestions']).map(AccountSuggestion.fromJson).toList(), - periodFrom: asDate(json['period_from']), - periodTo: asDate(json['period_to']), - uploadedAt: asDate(json['uploaded_at']), - committedAt: asDate(json['committed_at']), - counts: ImportCounts.fromJson(asMap(json['counts'])), - pendingInstruments: - asList(json['pending_instruments']).map(PendingInstrument.fromJson).toList(), - reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])), - warnings: json['warnings'] is List - ? (json['warnings'] as List).map((e) => e.toString()).toList() - : const [], - sampleEvents: asList(json['sample_events']).map(SampleEvent.fromJson).toList(), - ); + id: asInt(json['id'])!, + filename: asString(json['filename']) ?? 'без имени', + parseStatus: asString(json['parse_status']) ?? 'uploaded', + broker: asString(json['broker']), + sha256: asString(json['sha256']), + sizeBytes: asInt(json['size_bytes']), + parserName: asString(json['parser_name']), + parserVersion: asString(json['parser_version']), + error: asString(json['error']), + duplicateOfId: asInt(json['duplicate_of_id']), + accountExternalId: asString(json['account_external_id']), + accountId: asInt(json['account_id']), + accountName: asString(json['account_name']), + accountSuggestions: asList(json['account_suggestions']) + .map(AccountSuggestion.fromJson) + .toList(), + periodFrom: asDate(json['period_from']), + periodTo: asDate(json['period_to']), + uploadedAt: asDate(json['uploaded_at']), + committedAt: asDate(json['committed_at']), + counts: ImportCounts.fromJson(asMap(json['counts'])), + pendingInstruments: asList(json['pending_instruments']) + .map(PendingInstrument.fromJson) + .toList(), + reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])), + warnings: json['warnings'] is List + ? (json['warnings'] as List).map((e) => e.toString()).toList() + : const [], + sampleEvents: asList(json['sample_events']) + .map(SampleEvent.fromJson) + .toList(), + ); } /// What `POST /imports/{id}/commit` reports back. @@ -336,16 +346,16 @@ class ImportResult { final bool metricsRefreshed; static ImportResult fromJson(Map json) => ImportResult( - importId: asInt(json['import_id']) ?? 0, - committed: json['committed'] == true, - eventsCreated: asInt(json['events_created']) ?? 0, - eventsUpdated: asInt(json['events_updated']) ?? 0, - eventsSkipped: asInt(json['events_skipped']) ?? 0, - eventsShadow: asInt(json['events_shadow']) ?? 0, - pendingInstruments: asInt(json['pending_instruments']) ?? 0, - reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])), - metricsRefreshed: json['metrics_refreshed'] == true, - ); + importId: asInt(json['import_id']) ?? 0, + committed: json['committed'] == true, + eventsCreated: asInt(json['events_created']) ?? 0, + eventsUpdated: asInt(json['events_updated']) ?? 0, + eventsSkipped: asInt(json['events_skipped']) ?? 0, + eventsShadow: asInt(json['events_shadow']) ?? 0, + pendingInstruments: asInt(json['pending_instruments']) ?? 0, + reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])), + metricsRefreshed: json['metrics_refreshed'] == true, + ); } /// A report chosen by the user. On web `file_picker` can only hand over [bytes]; on desktop @@ -366,14 +376,14 @@ class ImportsApi { static const _base = '/api/v1/imports'; - Future> list({int limit = 50, int offset = 0, String? status}) async { + Future> list({ + int limit = 50, + int offset = 0, + String? status, + }) async { final r = await _dio.get>( _base, - queryParameters: { - 'limit': limit, - 'offset': offset, - 'status': ?status, - }, + queryParameters: {'limit': limit, 'offset': offset, 'status': ?status}, ); return (r.data ?? const []) .map((e) => ImportPreview.fromJson(Map.from(e as Map))) @@ -385,7 +395,11 @@ class ImportsApi { return ImportPreview.fromJson(r.data!); } - Future upload(PickedReport report, {int? accountId, String? parser}) async { + Future upload( + PickedReport report, { + int? accountId, + String? parser, + }) async { final bytes = report.bytes; final form = FormData.fromMap({ 'file': bytes != null @@ -404,11 +418,14 @@ class ImportsApi { bool confirmDuplicates = false, bool dryRun = false, }) async { - final r = await _dio.post>('$_base/$id/commit', data: { - 'account_id': ?accountId, - 'confirm_duplicates': confirmDuplicates, - 'dry_run': dryRun, - }); + final r = await _dio.post>( + '$_base/$id/commit', + data: { + 'account_id': ?accountId, + 'confirm_duplicates': confirmDuplicates, + 'dry_run': dryRun, + }, + ); return ImportResult.fromJson(r.data ?? const {}); } @@ -438,4 +455,5 @@ List> asList(Object? v) => v is List ? v.whereType().map((e) => Map.from(e)).toList() : const []; -Map? asMap(Object? v) => v is Map ? Map.from(v) : null; +Map? asMap(Object? v) => + v is Map ? Map.from(v) : null; diff --git a/app/lib/features/imports/data/report_picker.dart b/app/lib/features/imports/data/report_picker.dart index 6ccd446..29cac4f 100644 --- a/app/lib/features/imports/data/report_picker.dart +++ b/app/lib/features/imports/data/report_picker.dart @@ -34,4 +34,6 @@ class FilePickerReportPicker implements ReportPicker { } } -final reportPickerProvider = Provider((ref) => const FilePickerReportPicker()); +final reportPickerProvider = Provider( + (ref) => const FilePickerReportPicker(), +); diff --git a/app/lib/features/imports/import_preview_page.dart b/app/lib/features/imports/import_preview_page.dart index 8f53225..cdd1d2f 100644 --- a/app/lib/features/imports/import_preview_page.dart +++ b/app/lib/features/imports/import_preview_page.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; +import '../accounts/account_create_dialog.dart'; import '../portfolio/labels.dart' show eventKindLabels, formatQty; import 'data/imports_api.dart'; import 'labels.dart'; @@ -31,14 +32,17 @@ class _ImportPreviewPageState extends ConsumerState { ImportResult? _result; void _snack(String message) => - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); Future _commit(ImportPreview preview) async { final accountId = preview.accountId ?? _accountChoice; if (accountId == null) return; setState(() => _busy = true); try { - final result = await ref.read(importsApiProvider).commit( + final result = await ref + .read(importsApiProvider) + .commit( preview.id, accountId: preview.accountId == null ? accountId : null, confirmDuplicates: _confirmDuplicates, @@ -49,8 +53,10 @@ class _ImportPreviewPageState extends ConsumerState { invalidateLedgerDependents(ref); ref.invalidate(importsListProvider); ref.invalidate(importPreviewProvider(preview.id)); - _snack('Импортировано: создано ${result.eventsCreated}, ' - 'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}'); + _snack( + 'Импортировано: создано ${result.eventsCreated}, ' + 'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}', + ); } catch (e) { if (!mounted) return; _snack(importErrorMessage(e)); @@ -64,13 +70,19 @@ class _ImportPreviewPageState extends ConsumerState { context: context, builder: (context) => AlertDialog( title: const Text('Удалить импорт?'), - content: Text('Файл «${preview.filename}» и разобранные строки будут удалены. ' - 'События в леджере не создавались, так что удалять нечего.'), + content: Text( + 'Файл «${preview.filename}» и разобранные строки будут удалены. ' + 'События в леджере не создавались, так что удалять нечего.', + ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')), + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), FilledButton( - onPressed: () => Navigator.of(context).pop(true), child: const Text('Удалить')), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Удалить'), + ), ], ), ); @@ -104,7 +116,8 @@ class _ImportPreviewPageState extends ConsumerState { IconButton( tooltip: 'Обновить', icon: const Icon(Icons.refresh), - onPressed: () => ref.invalidate(importPreviewProvider(widget.importId)), + onPressed: () => + ref.invalidate(importPreviewProvider(widget.importId)), ), ], ), @@ -131,7 +144,8 @@ class _ImportPreviewPageState extends ConsumerState { icon: Icons.copy_all_outlined, color: theme.colorScheme.secondary, title: 'Этот файл уже загружали', - body: 'Показан ранее созданный импорт №${p.duplicateOfId}. ' + body: + 'Показан ранее созданный импорт №${p.duplicateOfId}. ' 'Повторная загрузка не создаёт новых событий.', ), ], @@ -172,8 +186,10 @@ class _ImportPreviewPageState extends ConsumerState { children: [ Text('Строки отчёта', style: theme.textTheme.titleMedium), const SizedBox(height: 4), - Text('Первые ${p.sampleEvents.length} из ${p.counts.lines}', - style: theme.textTheme.bodySmall), + Text( + 'Первые ${p.sampleEvents.length} из ${p.counts.lines}', + style: theme.textTheme.bodySmall, + ), const SizedBox(height: 8), SampleEventsTable(events: p.sampleEvents), ], @@ -217,13 +233,17 @@ class _ImportPreviewPageState extends ConsumerState { _kv( 'Счёт', p.accountName ?? - (p.accountId != null ? '#${p.accountId}' : 'не определён по отчёту'), + (p.accountId != null + ? '#${p.accountId}' + : 'не определён по отчёту'), ), - if (p.accountExternalId != null) _kv('Счёт в отчёте', p.accountExternalId!), + if (p.accountExternalId != null) + _kv('Счёт в отчёте', p.accountExternalId!), if (p.parserName != null) _kv('Парсер', '${p.parserName} v${p.parserVersion ?? '1'}'), if (p.sizeBytes != null) _kv('Размер', formatBytes(p.sizeBytes)), - if (p.uploadedAt != null) _kv('Загружен', ruDate(p.uploadedAt!.toLocal())), + if (p.uploadedAt != null) + _kv('Загружен', ruDate(p.uploadedAt!.toLocal())), if (p.committedAt != null) _kv('Импортирован', ruDate(p.committedAt!.toLocal())), ], @@ -232,6 +252,22 @@ class _ImportPreviewPageState extends ConsumerState { ); } + Future _createAccountFromReport(ImportPreview p) async { + final created = await showAccountCreateDialog( + context, + broker: brokerFromImportKey(p.broker), + sourceId: p.accountExternalId, + name: p.accountExternalId == null + ? null + : '${brokerLabel(p.broker)} ${p.accountExternalId}', + ); + if (created == null || !mounted) return; + // refetch first: the dropdown only accepts a value that is among the server's suggestions + ref.invalidate(importPreviewProvider(widget.importId)); + await ref.read(importPreviewProvider(widget.importId).future); + if (mounted) setState(() => _accountChoice = created.id); + } + Widget _accountPicker(ImportPreview p) { final theme = Theme.of(context); return Card( @@ -243,12 +279,18 @@ class _ImportPreviewPageState extends ConsumerState { children: [ Row( children: [ - Icon(Icons.account_balance_outlined, color: theme.colorScheme.onErrorContainer), + Icon( + Icons.account_balance_outlined, + color: theme.colorScheme.onErrorContainer, + ), const SizedBox(width: 8), Expanded( - child: Text('Счёт не определён', - style: theme.textTheme.titleMedium - ?.copyWith(color: theme.colorScheme.onErrorContainer)), + child: Text( + 'Счёт не определён', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), ), ], ), @@ -256,10 +298,17 @@ class _ImportPreviewPageState extends ConsumerState { Text( p.accountSuggestions.isEmpty ? 'В отчёте номер счёта ${p.accountExternalId ?? '—'}, но подходящего ' - 'счёта в базе нет. Заведите счёт, затем вернитесь сюда.' - : 'Выберите счёт, в который писать события. Без него импорт недоступен.', + 'счёта в базе нет. Создайте счёт по данным из отчёта.' + : 'Выберите счёт, в который писать события, или создайте новый. ' + 'Без счёта импорт недоступен.', style: TextStyle(color: theme.colorScheme.onErrorContainer), ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => _createAccountFromReport(p), + icon: const Icon(Icons.add), + label: const Text('Создать счёт из отчёта'), + ), if (p.accountSuggestions.isNotEmpty) ...[ const SizedBox(height: 12), DropdownButtonFormField( @@ -274,11 +323,13 @@ class _ImportPreviewPageState extends ConsumerState { for (final s in p.accountSuggestions) DropdownMenuItem( value: s.id, - child: Text([ - s.name, - if (s.broker != null) brokerLabel(s.broker), - if (s.sourceId != null) s.sourceId!, - ].join(' · ')), + child: Text( + [ + s.name, + if (s.broker != null) brokerLabel(s.broker), + if (s.sourceId != null) s.sourceId!, + ].join(' · '), + ), ), ], onChanged: (v) => setState(() => _accountChoice = v), @@ -308,14 +359,25 @@ class _ImportPreviewPageState extends ConsumerState { _stat('Строк', c.lines), _stat('Событий', c.eventsTotal), _stat('Новых', c.eventsNew, color: Colors.green), - _stat('Дубликатов', c.eventsDuplicate, - color: c.eventsDuplicate > 0 ? theme.colorScheme.secondary : null, - hint: 'Уже есть в леджере: будут обновлены, а не продублированы'), - _stat('Shadow', c.eventsShadow, - hint: 'Не первичный источник — в аналитику не идут'), - _stat('Ждут инструмента', c.eventsPending, - color: c.eventsPending > 0 ? theme.colorScheme.error : null, - hint: 'Инструмент не распознан, события останутся в статусе pending'), + _stat( + 'Дубликатов', + c.eventsDuplicate, + color: c.eventsDuplicate > 0 + ? theme.colorScheme.secondary + : null, + hint: 'Уже есть в леджере: будут обновлены, а не продублированы', + ), + _stat( + 'Shadow', + c.eventsShadow, + hint: 'Не первичный источник — в аналитику не идут', + ), + _stat( + 'Ждут инструмента', + c.eventsPending, + color: c.eventsPending > 0 ? theme.colorScheme.error : null, + hint: 'Инструмент не распознан, события останутся в статусе pending', + ), ], ), if (c.byKind.isNotEmpty) ...[ @@ -329,7 +391,9 @@ class _ImportPreviewPageState extends ConsumerState { for (final e in c.byKind.entries) Chip( visualDensity: VisualDensity.compact, - label: Text('${eventKindLabels[e.key] ?? e.key}: ${e.value}'), + label: Text( + '${eventKindLabels[e.key] ?? e.key}: ${e.value}', + ), ), ], ), @@ -348,8 +412,10 @@ class _ImportPreviewPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Нераспознанные инструменты (${p.pendingInstruments.length})', - style: theme.textTheme.titleMedium), + Text( + 'Нераспознанные инструменты (${p.pendingInstruments.length})', + style: theme.textTheme.titleMedium, + ), const SizedBox(height: 4), Text( 'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, ' @@ -362,11 +428,14 @@ class _ImportPreviewPageState extends ConsumerState { contentPadding: EdgeInsets.zero, dense: true, title: Text(pi.title), - subtitle: Text([ - if (pi.isin != null) 'ISIN ${pi.isin}', - 'встречается ${pi.occurrences}', - if (pi.sampleQuantity != null) 'кол-во ${formatQty(pi.sampleQuantity!)}', - ].join(' · ')), + subtitle: Text( + [ + if (pi.isin != null) 'ISIN ${pi.isin}', + 'встречается ${pi.occurrences}', + if (pi.sampleQuantity != null) + 'кол-во ${formatQty(pi.sampleQuantity!)}', + ].join(' · '), + ), ), Align( alignment: Alignment.centerRight, @@ -392,7 +461,10 @@ class _ImportPreviewPageState extends ConsumerState { children: [ Row( children: [ - Icon(Icons.warning_amber_outlined, color: theme.colorScheme.tertiary), + Icon( + Icons.warning_amber_outlined, + color: theme.colorScheme.tertiary, + ), const SizedBox(width: 8), Text('Предупреждения', style: theme.textTheme.titleMedium), ], @@ -434,7 +506,8 @@ class _ImportPreviewPageState extends ConsumerState { } Widget _actions(ImportPreview p, int? accountId) { - final canCommit = !p.isCommitted && !p.isFailed && accountId != null && !_busy; + final canCommit = + !p.isCommitted && !p.isFailed && accountId != null && !_busy; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -444,8 +517,10 @@ class _ImportPreviewPageState extends ConsumerState { value: _confirmDuplicates, onChanged: (v) => setState(() => _confirmDuplicates = v ?? false), title: const Text('Обновлять дубликаты'), - subtitle: Text('Перезаписать ${p.counts.eventsDuplicate} событий, ' - 'которые уже есть в леджере'), + subtitle: Text( + 'Перезаписать ${p.counts.eventsDuplicate} событий, ' + 'которые уже есть в леджере', + ), ), Wrap( spacing: 12, @@ -455,7 +530,10 @@ class _ImportPreviewPageState extends ConsumerState { onPressed: canCommit ? () => _commit(p) : null, icon: _busy ? const SizedBox( - width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Icon(Icons.playlist_add_check), label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'), ), @@ -497,11 +575,11 @@ class _ImportPreviewPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith(color: color)), + Text( + title, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(color: color), + ), const SizedBox(height: 4), Text(body), ], @@ -525,8 +603,10 @@ class _ImportPreviewPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: theme.textTheme.bodySmall), - Text('$value', - style: theme.textTheme.titleLarge?.copyWith(color: color)), + Text( + '$value', + style: theme.textTheme.titleLarge?.copyWith(color: color), + ), ], ), ); diff --git a/app/lib/features/imports/imports_page.dart b/app/lib/features/imports/imports_page.dart index 54fd498..a24df24 100644 --- a/app/lib/features/imports/imports_page.dart +++ b/app/lib/features/imports/imports_page.dart @@ -35,7 +35,9 @@ class _ImportsPageState extends ConsumerState { if (!mounted) return; ref.invalidate(importsListProvider); if (preview.duplicateOfId != null) { - _snack('Этот файл уже загружали — открыт существующий импорт №${preview.id}'); + _snack( + 'Этот файл уже загружали — открыт существующий импорт №${preview.id}', + ); } context.go('/imports/${preview.id}'); } catch (e) { @@ -47,7 +49,8 @@ class _ImportsPageState extends ConsumerState { } void _snack(String message) => - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); @override Widget build(BuildContext context) { @@ -72,7 +75,10 @@ class _ImportsPageState extends ConsumerState { onPressed: _uploading ? null : _upload, icon: _uploading ? const SizedBox( - width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Icon(Icons.upload_file), label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'), ), @@ -91,7 +97,8 @@ class _ImportsPageState extends ConsumerState { leading: const Icon(Icons.help_outline), title: Text('Нераспознанных инструментов: $pendingCount'), subtitle: const Text( - 'События по ним ждут в статусе pending и не попадают в аналитику.'), + 'События по ним ждут в статусе pending и не попадают в аналитику.', + ), trailing: const Icon(Icons.chevron_right), onTap: () => context.go('/instruments/pending'), ), @@ -106,11 +113,16 @@ class _ImportsPageState extends ConsumerState { padding: EdgeInsets.only(top: 48), child: EmptyState( icon: Icons.upload_file_outlined, - message: 'Отчёты ещё не загружались.\n' + message: + 'Отчёты ещё не загружались.\n' 'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.', ), ) - : Column(children: [for (final row in rows) _ImportCard(item: row)]), + : Column( + children: [ + for (final row in rows) _ImportCard(item: row), + ], + ), ), ], ), @@ -164,8 +176,12 @@ class _ImportCard extends StatelessWidget { : 'период не определён'; final subtitle = [ period, - item.accountName ?? (item.accountId != null ? 'счёт #${item.accountId}' : 'счёт не найден'), - if (item.uploadedAt != null) 'загружен ${ruDate(item.uploadedAt!.toLocal())}', + item.accountName ?? + (item.accountId != null + ? 'счёт #${item.accountId}' + : 'счёт не найден'), + if (item.uploadedAt != null) + 'загружен ${ruDate(item.uploadedAt!.toLocal())}', if (item.sizeBytes != null) formatBytes(item.sizeBytes), ].join(' · '); @@ -174,7 +190,9 @@ class _ImportCard extends StatelessWidget { onTap: () => context.go('/imports/${item.id}'), title: Row( children: [ - Flexible(child: Text(item.filename, overflow: TextOverflow.ellipsis)), + Flexible( + child: Text(item.filename, overflow: TextOverflow.ellipsis), + ), const SizedBox(width: 8), parseStatusChip(context, item.parseStatus), ], @@ -198,9 +216,12 @@ class _ImportCard extends StatelessWidget { if (item.isFailed && item.error != null) Padding( padding: const EdgeInsets.only(top: 4), - child: Text(item.error!, - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.error)), + child: Text( + item.error!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), ), ], ), diff --git a/app/lib/features/imports/providers.dart b/app/lib/features/imports/providers.dart index be695dc..b78547f 100644 --- a/app/lib/features/imports/providers.dart +++ b/app/lib/features/imports/providers.dart @@ -6,13 +6,17 @@ import '../home/providers.dart'; import '../portfolio/providers.dart'; import 'data/imports_api.dart'; -final importsApiProvider = Provider((ref) => ImportsApi(ref.watch(apiProvider).dio)); +final importsApiProvider = Provider( + (ref) => ImportsApi(ref.watch(apiProvider).dio), +); /// The list on `/imports`. Not auto-disposed by status: the filter is a separate provider so /// changing it refetches without rebuilding the page state. final importsStatusFilterProvider = StateProvider((ref) => null); -final importsListProvider = FutureProvider.autoDispose>((ref) async { +final importsListProvider = FutureProvider.autoDispose>(( + ref, +) async { final status = ref.watch(importsStatusFilterProvider); return ref.watch(importsApiProvider).list(status: status); }); @@ -20,10 +24,10 @@ final importsListProvider = FutureProvider.autoDispose>((ref /// A single import's preview. For an uncommitted import the server recomputes counts and /// reconciliation on every read, so this is deliberately re-fetched rather than cached from /// the list response. -final importPreviewProvider = - FutureProvider.autoDispose.family((ref, id) async { - return ref.watch(importsApiProvider).get(id); -}); +final importPreviewProvider = FutureProvider.autoDispose + .family((ref, id) async { + return ref.watch(importsApiProvider).get(id); + }); /// Everything whose numbers change once events land in (or move inside) the ledger: /// portfolio, holdings, allocation, the value series, the dashboard and the event list. diff --git a/app/lib/features/imports/widgets/reconciliation_card.dart b/app/lib/features/imports/widgets/reconciliation_card.dart index 8b01f96..224bc4b 100644 --- a/app/lib/features/imports/widgets/reconciliation_card.dart +++ b/app/lib/features/imports/widgets/reconciliation_card.dart @@ -23,7 +23,10 @@ class ReconciliationCard extends StatelessWidget { if (reconciliation.isEmpty) { return Card( child: ListTile( - leading: Icon(Icons.remove_circle_outline, color: theme.colorScheme.outline), + leading: Icon( + Icons.remove_circle_outline, + color: theme.colorScheme.outline, + ), title: Text(title), subtitle: const Text('В отчёте нет остатков для сверки'), ), @@ -67,7 +70,12 @@ class ReconciliationCard extends StatelessWidget { Text('Позиции', style: theme.textTheme.titleSmall), const SizedBox(height: 4), _ScrollableTable( - columns: const ['Позиция', 'Из отчёта', 'Из леджера', 'Расхождение'], + columns: const [ + 'Позиция', + 'Из отчёта', + 'Из леджера', + 'Расхождение', + ], rows: [ for (final p in reconciliation.positions) _Row( @@ -87,7 +95,12 @@ class ReconciliationCard extends StatelessWidget { Text('Денежные остатки', style: theme.textTheme.titleSmall), const SizedBox(height: 4), _ScrollableTable( - columns: const ['Валюта', 'Из отчёта', 'Из леджера', 'Расхождение'], + columns: const [ + 'Валюта', + 'Из отчёта', + 'Из леджера', + 'Расхождение', + ], rows: [ for (final c in reconciliation.cash) _Row( @@ -145,16 +158,20 @@ class _ScrollableTable extends StatelessWidget { for (final r in rows) DataRow( color: r.highlight - ? WidgetStatePropertyAll(theme.colorScheme.errorContainer.withValues(alpha: 0.4)) + ? WidgetStatePropertyAll( + theme.colorScheme.errorContainer.withValues(alpha: 0.4), + ) : null, cells: [ for (final cell in r.cells) - DataCell(Text( - cell, - style: r.highlight - ? TextStyle(color: theme.colorScheme.error) - : null, - )), + DataCell( + Text( + cell, + style: r.highlight + ? TextStyle(color: theme.colorScheme.error) + : null, + ), + ), ], ), ], diff --git a/app/lib/features/imports/widgets/sample_events_table.dart b/app/lib/features/imports/widgets/sample_events_table.dart index 2c8197f..a3df6dd 100644 --- a/app/lib/features/imports/widgets/sample_events_table.dart +++ b/app/lib/features/imports/widgets/sample_events_table.dart @@ -37,11 +37,16 @@ class SampleEventsTable extends StatelessWidget { DataRow( color: e.isDuplicate ? WidgetStatePropertyAll( - theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6)) + theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.6, + ), + ) : null, cells: [ DataCell(Text('${e.lineNo}')), - DataCell(Text(e.tradeDate == null ? '—' : ruDate(e.tradeDate!))), + DataCell( + Text(e.tradeDate == null ? '—' : ruDate(e.tradeDate!)), + ), DataCell(Text(eventKindLabels[e.kind] ?? e.kind)), DataCell( Tooltip( @@ -49,20 +54,26 @@ class SampleEventsTable extends StatelessWidget { child: Text(e.instrumentName ?? e.instrumentKey ?? '—'), ), ), - DataCell(Text(e.quantity == null ? '—' : formatQty(e.quantity!))), + DataCell( + Text(e.quantity == null ? '—' : formatQty(e.quantity!)), + ), DataCell(Text(_money(e.price, e.currency))), DataCell(Text(_money(e.amount, e.currency))), - DataCell(e.isDuplicate - ? Tooltip( - message: 'Такое событие уже есть в леджере', - child: Chip( - visualDensity: VisualDensity.compact, - label: const Text('дубль'), - backgroundColor: - theme.colorScheme.secondaryContainer.withValues(alpha: 0.8), - ), - ) - : const SizedBox.shrink()), + DataCell( + e.isDuplicate + ? Tooltip( + message: 'Такое событие уже есть в леджере', + child: Chip( + visualDensity: VisualDensity.compact, + label: const Text('дубль'), + backgroundColor: theme + .colorScheme + .secondaryContainer + .withValues(alpha: 0.8), + ), + ) + : const SizedBox.shrink(), + ), ], ), ], @@ -71,5 +82,7 @@ class SampleEventsTable extends StatelessWidget { } static String _money(String? value, String? currency) => - value == null || value.isEmpty ? '—' : MoneyText.format(value, currency ?? 'RUB'); + value == null || value.isEmpty + ? '—' + : MoneyText.format(value, currency ?? 'RUB'); } diff --git a/app/lib/features/income/calendar_tab.dart b/app/lib/features/income/calendar_tab.dart index 7cfeaa9..af4fa4f 100644 --- a/app/lib/features/income/calendar_tab.dart +++ b/app/lib/features/income/calendar_tab.dart @@ -43,7 +43,8 @@ class IncomeCalendarTab extends ConsumerWidget { padding: EdgeInsets.only(top: 48), child: EmptyState( icon: Icons.event_available_outlined, - message: 'Ожидаемых выплат в этом окне нет.\n' + message: + 'Ожидаемых выплат в этом окне нет.\n' 'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.', ), ) @@ -77,13 +78,15 @@ class _CalendarControls extends ConsumerWidget { ChoiceChip( label: Text('$m мес'), selected: months == m, - onSelected: (_) => ref.read(calendarMonthsProvider.notifier).state = m, + onSelected: (_) => + ref.read(calendarMonthsProvider.notifier).state = m, ), const SizedBox(width: 8), FilterChip( label: const Text('Показать выплаченные'), selected: includePaid, - onSelected: (v) => ref.read(calendarIncludePaidProvider.notifier).state = v, + onSelected: (v) => + ref.read(calendarIncludePaidProvider.notifier).state = v, ), ], ); @@ -130,7 +133,11 @@ class _Totals extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.info_outline, size: 16, color: basisColor('history')), + Icon( + Icons.info_outline, + size: 16, + color: basisColor('history'), + ), const SizedBox(width: 6), Expanded( child: Text( @@ -164,7 +171,11 @@ class _BasisTotal extends StatelessWidget { children: [ BasisChip(basis: basis), const SizedBox(height: 4), - MoneyText(amount, currency: 'RUB', style: Theme.of(context).textTheme.titleSmall), + MoneyText( + amount, + currency: 'RUB', + style: Theme.of(context).textTheme.titleSmall, + ), ], ), ); @@ -240,7 +251,11 @@ class _EntryRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ if (entry.amount != null) - MoneyText(entry.amount!, currency: entry.currency, style: theme.textTheme.titleSmall), + MoneyText( + entry.amount!, + currency: entry.currency, + style: theme.textTheme.titleSmall, + ), // no FX rate for the date ⇒ no rouble figure. An em dash, never 0 ₽. if (entry.currency != 'RUB') Text( @@ -257,7 +272,9 @@ class _EntryRow extends StatelessWidget { /// Groups entries by month, preserving the server's order inside each month. Entries with /// no date go last, under their own heading — they are still expected money. -List>> _groupByMonth(List entries) { +List>> _groupByMonth( + List entries, +) { final groups = >{}; for (final e in entries) { final d = e.expectedDate; @@ -272,5 +289,8 @@ List>> _groupByMonth(List entr List _orderedBases(Iterable bases) { const order = ['schedule', 'announced', 'history', 'paid']; final set = bases.toSet(); - return [...order.where(set.contains), ...set.where((b) => !order.contains(b))]; + return [ + ...order.where(set.contains), + ...set.where((b) => !order.contains(b)), + ]; } diff --git a/app/lib/features/income/data/income_api.dart b/app/lib/features/income/data/income_api.dart index b509a99..b0a4cf7 100644 --- a/app/lib/features/income/data/income_api.dart +++ b/app/lib/features/income/data/income_api.dart @@ -56,23 +56,24 @@ class IncomeEntry { final String basis; final String? taxWithheld; - String get title => ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId'); + String get title => + ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId'); static IncomeEntry fromJson(Map json) => IncomeEntry( - instrumentId: asInt(json['instrument_id']), - ticker: asString(json['ticker']), - name: asString(json['name']), - kind: asString(json['kind']) ?? 'dividend', - expectedDate: asDate(json['expected_date']), - recordDate: asDate(json['record_date']), - qty: asString(json['qty']), - perUnit: asString(json['per_unit']), - amount: asString(json['amount']), - currency: asString(json['currency']) ?? 'RUB', - amountRub: asString(json['amount_rub']), - basis: asString(json['basis']) ?? 'history', - taxWithheld: asString(json['tax_withheld']), - ); + instrumentId: asInt(json['instrument_id']), + ticker: asString(json['ticker']), + name: asString(json['name']), + kind: asString(json['kind']) ?? 'dividend', + expectedDate: asDate(json['expected_date']), + recordDate: asDate(json['record_date']), + qty: asString(json['qty']), + perUnit: asString(json['per_unit']), + amount: asString(json['amount']), + currency: asString(json['currency']) ?? 'RUB', + amountRub: asString(json['amount_rub']), + basis: asString(json['basis']) ?? 'history', + taxWithheld: asString(json['tax_withheld']), + ); } class IncomeCalendar { @@ -91,12 +92,12 @@ class IncomeCalendar { final Map byBasis; static IncomeCalendar fromJson(Map json) => IncomeCalendar( - asOf: asDate(json['as_of']), - currency: asString(json['currency']) ?? 'RUB', - totalExpectedRub: asString(json['total_expected_rub']) ?? '0', - entries: asObjects(json['entries']).map(IncomeEntry.fromJson).toList(), - byBasis: asStringMap(json['by_basis']), - ); + asOf: asDate(json['as_of']), + currency: asString(json['currency']) ?? 'RUB', + totalExpectedRub: asString(json['total_expected_rub']) ?? '0', + entries: asObjects(json['entries']).map(IncomeEntry.fromJson).toList(), + byBasis: asStringMap(json['by_basis']), + ); } class IncomeHistoryRow { @@ -118,7 +119,8 @@ class IncomeHistoryRow { final String? taxWithheld; final int paymentCount; - static IncomeHistoryRow fromJson(Map json) => IncomeHistoryRow( + static IncomeHistoryRow fromJson(Map json) => + IncomeHistoryRow( month: asDate(json['month']), kind: asString(json['kind']) ?? 'other', currency: asString(json['currency']) ?? 'RUB', @@ -151,7 +153,11 @@ class IncomeHistory { } class ForecastMonth { - const ForecastMonth({required this.amountRub, this.month, this.byBasis = const {}}); + const ForecastMonth({ + required this.amountRub, + this.month, + this.byBasis = const {}, + }); final DateTime? month; final String amountRub; @@ -160,10 +166,10 @@ class ForecastMonth { final Map byBasis; static ForecastMonth fromJson(Map json) => ForecastMonth( - month: asDate(json['month']), - amountRub: asString(json['amount_rub']) ?? '0', - byBasis: asStringMap(json['by_basis']), - ); + month: asDate(json['month']), + amountRub: asString(json['amount_rub']) ?? '0', + byBasis: asStringMap(json['by_basis']), + ); } class IncomeForecast { @@ -192,11 +198,11 @@ class IncomeForecast { } static IncomeForecast fromJson(Map json) => IncomeForecast( - months: asObjects(json['months']).map(ForecastMonth.fromJson).toList(), - totalRub: asString(json['total_rub']) ?? '0', - annualYieldOnValue: asString(json['annual_yield_on_value']), - warnings: asStrings(json['warnings']), - ); + months: asObjects(json['months']).map(ForecastMonth.fromJson).toList(), + totalRub: asString(json['total_rub']) ?? '0', + annualYieldOnValue: asString(json['annual_yield_on_value']), + warnings: asStrings(json['warnings']), + ); } class IncomeApi { @@ -214,12 +220,15 @@ class IncomeApi { DateTime? dateTo, bool includePaid = false, }) async { - final r = await _dio.get>('$_base/calendar', queryParameters: { - 'scope': scope, - 'date_from': ?_isoDate(dateFrom), - 'date_to': ?_isoDate(dateTo), - 'include_paid': includePaid, - }); + final r = await _dio.get>( + '$_base/calendar', + queryParameters: { + 'scope': scope, + 'date_from': ?_isoDate(dateFrom), + 'date_to': ?_isoDate(dateTo), + 'include_paid': includePaid, + }, + ); return Cached( IncomeCalendar.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?, @@ -233,24 +242,30 @@ class IncomeApi { DateTime? dateTo, String? kind, }) async { - final r = await _dio.get>('$_base/history', queryParameters: { - 'scope': scope, - 'group': group, - 'date_from': ?_isoDate(dateFrom), - 'date_to': ?_isoDate(dateTo), - 'kind': ?kind, - }); + final r = await _dio.get>( + '$_base/history', + queryParameters: { + 'scope': scope, + 'group': group, + 'date_from': ?_isoDate(dateFrom), + 'date_to': ?_isoDate(dateTo), + 'kind': ?kind, + }, + ); return Cached( IncomeHistory.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?, ); } - Future> forecast({String scope = 'all', int months = 12}) async { - final r = await _dio.get>('$_base/forecast', queryParameters: { - 'scope': scope, - 'months': months, - }); + Future> forecast({ + String scope = 'all', + int months = 12, + }) async { + final r = await _dio.get>( + '$_base/forecast', + queryParameters: {'scope': scope, 'months': months}, + ); return Cached( IncomeForecast.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?, @@ -262,6 +277,6 @@ class IncomeApi { static String? _isoDate(DateTime? d) => d == null ? null : '${d.year.toString().padLeft(4, '0')}-' - '${d.month.toString().padLeft(2, '0')}-' - '${d.day.toString().padLeft(2, '0')}'; + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; } diff --git a/app/lib/features/income/forecast_tab.dart b/app/lib/features/income/forecast_tab.dart index 70e9472..6a47ae3 100644 --- a/app/lib/features/income/forecast_tab.dart +++ b/app/lib/features/income/forecast_tab.dart @@ -50,7 +50,9 @@ class IncomeForecastTab extends ConsumerWidget { StatTile( label: 'Доходность к стоимости', // null means "нет оценки стоимости" and must not read as 0 % - value: Text(formatPercent(data.annualYieldOnValue, signed: false)), + value: Text( + formatPercent(data.annualYieldOnValue, signed: false), + ), note: data.annualYieldOnValue == null ? 'нет оценки текущей стоимости' : 'ожидаемый доход / стоимость портфеля', @@ -58,7 +60,10 @@ class IncomeForecastTab extends ConsumerWidget { for (final b in bases) StatTile( label: 'Основание: ${basisLabel(b)}', - value: MoneyText(_basisTotal(data, b).toString(), currency: 'RUB'), + value: MoneyText( + _basisTotal(data, b).toString(), + currency: 'RUB', + ), note: basisDescription(b), width: 220, ), @@ -91,8 +96,14 @@ class IncomeForecastTab extends ConsumerWidget { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox( - width: (data.months.length * 52).toDouble().clamp(320, double.infinity), - child: _ForecastChart(months: data.months, bases: bases), + width: (data.months.length * 52).toDouble().clamp( + 320, + double.infinity, + ), + child: _ForecastChart( + months: data.months, + bases: bases, + ), ), ), ), @@ -130,7 +141,8 @@ class _HorizonChips extends ConsumerWidget { ChoiceChip( label: Text('$m мес'), selected: months == m, - onSelected: (_) => ref.read(forecastMonthsProvider.notifier).state = m, + onSelected: (_) => + ref.read(forecastMonthsProvider.notifier).state = m, ), ], ); @@ -191,10 +203,16 @@ class _BasisLegend extends StatelessWidget { Container( width: 10, height: 10, - decoration: BoxDecoration(color: basisColor(b), shape: BoxShape.circle), + decoration: BoxDecoration( + color: basisColor(b), + shape: BoxShape.circle, + ), ), const SizedBox(width: 6), - Text(basisLabel(b), style: Theme.of(context).textTheme.bodySmall), + Text( + basisLabel(b), + style: Theme.of(context).textTheme.bodySmall, + ), ], ), ), @@ -235,7 +253,8 @@ class _ForecastChart extends StatelessWidget { getTitlesWidget: (value, meta) { final i = value.round(); if (i < 0 || i >= months.length) return const SizedBox.shrink(); - if (months.length > 14 && i.isOdd) return const SizedBox.shrink(); + if (months.length > 14 && i.isOdd) + return const SizedBox.shrink(); final m = months[i].month; return Text( m == null ? '—' : ruMonthYearShort(m), @@ -251,7 +270,8 @@ class _ForecastChart extends StatelessWidget { final m = months[group.x]; final parts = [ for (final b in bases) - if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) > Decimal.zero) + if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) > + Decimal.zero) '${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}', ]; return BarTooltipItem( @@ -277,7 +297,8 @@ class _ForecastChart extends StatelessWidget { final stack = []; var from = 0.0; for (final b in bases) { - final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero).toDouble(); + final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) + .toDouble(); if (v <= 0) continue; stack.add(BarChartRodStackItem(from, from + v, basisColor(b))); from += v; @@ -321,14 +342,18 @@ class _ForecastTable extends StatelessWidget { for (final m in months) DataRow( cells: [ - DataCell(Text(m.month == null ? '—' : ruMonthYearShort(m.month!))), + DataCell( + Text(m.month == null ? '—' : ruMonthYearShort(m.month!)), + ), for (final b in bases) DataCell(MoneyText(m.byBasis[b] ?? '0', currency: 'RUB')), - DataCell(MoneyText( - m.amountRub, - currency: 'RUB', - style: Theme.of(context).textTheme.titleSmall, - )), + DataCell( + MoneyText( + m.amountRub, + currency: 'RUB', + style: Theme.of(context).textTheme.titleSmall, + ), + ), ], ), ], diff --git a/app/lib/features/income/history_tab.dart b/app/lib/features/income/history_tab.dart index 5a0b354..0e024dd 100644 --- a/app/lib/features/income/history_tab.dart +++ b/app/lib/features/income/history_tab.dart @@ -77,7 +77,10 @@ class IncomeHistoryTab extends ConsumerWidget { scrollDirection: Axis.horizontal, reverse: true, child: SizedBox( - width: (months.length * 44).toDouble().clamp(320, double.infinity), + width: (months.length * 44).toDouble().clamp( + 320, + double.infinity, + ), child: _HistoryChart(months: months), ), ), @@ -109,7 +112,8 @@ class _PeriodChips extends ConsumerWidget { ChoiceChip( label: Text(m >= 120 ? 'Всё время' : '$m мес'), selected: months == m, - onSelected: (_) => ref.read(historyMonthsProvider.notifier).state = m, + onSelected: (_) => + ref.read(historyMonthsProvider.notifier).state = m, ), ], ); @@ -130,7 +134,9 @@ List<_MonthTotal> _byMonth(List rows) { final m = r.month; if (m == null) continue; final key = DateTime.utc(m.year, m.month); - sums[key] = (sums[key] ?? Decimal.zero) + (Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero); + sums[key] = + (sums[key] ?? Decimal.zero) + + (Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero); } final keys = sums.keys.toList()..sort(); return [for (final k in keys) _MonthTotal(k, sums[k]!)]; @@ -166,8 +172,12 @@ class _HistoryChart extends StatelessWidget { getTitlesWidget: (value, meta) { final i = value.round(); if (i < 0 || i >= months.length) return const SizedBox.shrink(); - if (months.length > 14 && i % 3 != 0) return const SizedBox.shrink(); - return Text(ruMonthYearShort(months[i].month), style: theme.textTheme.bodySmall); + if (months.length > 14 && i % 3 != 0) + return const SizedBox.shrink(); + return Text( + ruMonthYearShort(months[i].month), + style: theme.textTheme.bodySmall, + ); }, ), ), @@ -190,7 +200,9 @@ class _HistoryChart extends StatelessWidget { toY: months[i].amountRub.toDouble(), color: basisColor('paid'), width: 12, - borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(2), + ), ), ], ), @@ -216,7 +228,10 @@ class _HistoryTable extends StatelessWidget { DataColumn(label: Text('Тип', style: headerStyle)), DataColumn(label: Text('Валюта', style: headerStyle)), DataColumn(label: Text('Сумма', style: headerStyle), numeric: true), - DataColumn(label: Text('В рублях', style: headerStyle), numeric: true), + DataColumn( + label: Text('В рублях', style: headerStyle), + numeric: true, + ), DataColumn(label: Text('Налог', style: headerStyle), numeric: true), DataColumn(label: Text('Выплат', style: headerStyle), numeric: true), ], @@ -224,16 +239,22 @@ class _HistoryTable extends StatelessWidget { for (final r in rows) DataRow( cells: [ - DataCell(Text(r.month == null ? '—' : ruMonthYearShort(r.month!))), + DataCell( + Text(r.month == null ? '—' : ruMonthYearShort(r.month!)), + ), DataCell(Text(incomeKindLabel(r.kind))), DataCell(Text(r.currency)), DataCell(MoneyText(r.amount, currency: r.currency)), - DataCell(r.amountRub == null - ? const Text('—') - : MoneyText(r.amountRub!, currency: 'RUB')), - DataCell(r.taxWithheld == null - ? const Text('—') - : MoneyText(r.taxWithheld!, currency: r.currency)), + DataCell( + r.amountRub == null + ? const Text('—') + : MoneyText(r.amountRub!, currency: 'RUB'), + ), + DataCell( + r.taxWithheld == null + ? const Text('—') + : MoneyText(r.taxWithheld!, currency: r.currency), + ), DataCell(Text('${r.paymentCount}')), ], ), diff --git a/app/lib/features/income/income_page.dart b/app/lib/features/income/income_page.dart index 23ee364..c597120 100644 --- a/app/lib/features/income/income_page.dart +++ b/app/lib/features/income/income_page.dart @@ -40,7 +40,11 @@ class IncomePage extends ConsumerWidget { ), ], bottom: const TabBar( - tabs: [Tab(text: 'Календарь'), Tab(text: 'История'), Tab(text: 'Прогноз')], + tabs: [ + Tab(text: 'Календарь'), + Tab(text: 'История'), + Tab(text: 'Прогноз'), + ], ), ), body: Column( @@ -52,7 +56,11 @@ class IncomePage extends ConsumerWidget { ), const Expanded( child: TabBarView( - children: [IncomeCalendarTab(), IncomeHistoryTab(), IncomeForecastTab()], + children: [ + IncomeCalendarTab(), + IncomeHistoryTab(), + IncomeForecastTab(), + ], ), ), ], diff --git a/app/lib/features/income/labels.dart b/app/lib/features/income/labels.dart index 1c387c4..9144a4f 100644 --- a/app/lib/features/income/labels.dart +++ b/app/lib/features/income/labels.dart @@ -26,7 +26,8 @@ const basisLabels = { const basisDescriptions = { 'schedule': 'Арифметика по опубликованному графику выплат эмитента.', 'announced': 'Объявленный эмитентом факт: размер и дата известны.', - 'history': 'Экстраполяция по выплатам за последние 24 мес — может ошибаться ' + 'history': + 'Экстраполяция по выплатам за последние 24 мес — может ошибаться ' 'на любую величину, в том числе выплаты может не быть вовсе.', 'paid': 'Уже получено.', }; @@ -38,12 +39,12 @@ String basisDescription(String basis) => basisDescriptions[basis] ?? basis; /// Fixed slot per basis — never cycled, so the same basis is the same colour on the /// calendar, in the forecast legend and in the stacked bars. Color basisColor(String basis) => switch (basis) { - 'schedule' => ChartColors.slot1Blue, - 'announced' => ChartColors.slot3Aqua, - 'history' => ChartColors.slot4Yellow, - 'paid' => ChartColors.slot5Magenta, - _ => ChartColors.slot2Orange, - }; + 'schedule' => ChartColors.slot1Blue, + 'announced' => ChartColors.slot3Aqua, + 'history' => ChartColors.slot4Yellow, + 'paid' => ChartColors.slot5Magenta, + _ => ChartColors.slot2Orange, +}; /// The basis chip that has to sit on every calendar and forecast row. class BasisChip extends StatelessWidget { @@ -58,7 +59,10 @@ class BasisChip extends StatelessWidget { return Tooltip( message: basisDescription(basis), child: Container( - padding: EdgeInsets.symmetric(horizontal: dense ? 6 : 10, vertical: dense ? 1 : 4), + padding: EdgeInsets.symmetric( + horizontal: dense ? 6 : 10, + vertical: dense ? 1 : 4, + ), decoration: BoxDecoration( color: color.withValues(alpha: 0.14), border: Border.all(color: color.withValues(alpha: 0.5)), diff --git a/app/lib/features/income/providers.dart b/app/lib/features/income/providers.dart index bb53902..4e1cca9 100644 --- a/app/lib/features/income/providers.dart +++ b/app/lib/features/income/providers.dart @@ -5,7 +5,9 @@ import '../../core/cache/cached.dart'; import '../portfolio/providers.dart' show scopeProvider; import 'data/income_api.dart'; -final incomeApiProvider = Provider((ref) => IncomeApi(ref.watch(apiProvider).dio)); +final incomeApiProvider = Provider( + (ref) => IncomeApi(ref.watch(apiProvider).dio), +); /// How far the calendar looks ahead, in months. 12 is the contract default. final calendarMonthsProvider = StateProvider((ref) => 12); @@ -14,41 +16,49 @@ final calendarIncludePaidProvider = StateProvider((ref) => false); /// Доходы shares the portfolio-wide [scopeProvider]: switching the scope on Портфель must /// not leave the income calendar showing a different portfolio. See `docs/ai/offline-cache.md`. -final incomeCalendarProvider = FutureProvider.autoDispose>((ref) async { - final scope = ref.watch(scopeProvider); - final months = ref.watch(calendarMonthsProvider); - final includePaid = ref.watch(calendarIncludePaidProvider); - final now = DateTime.now(); - return ref.watch(incomeApiProvider).calendar( - scope: scope, - dateFrom: DateTime(now.year, now.month, now.day), - dateTo: DateTime(now.year, now.month + months, now.day), - includePaid: includePaid, - ); -}); +final incomeCalendarProvider = + FutureProvider.autoDispose>((ref) async { + final scope = ref.watch(scopeProvider); + final months = ref.watch(calendarMonthsProvider); + final includePaid = ref.watch(calendarIncludePaidProvider); + final now = DateTime.now(); + return ref + .watch(incomeApiProvider) + .calendar( + scope: scope, + dateFrom: DateTime(now.year, now.month, now.day), + dateTo: DateTime(now.year, now.month + months, now.day), + includePaid: includePaid, + ); + }); /// How far the history goes back, in months. final historyMonthsProvider = StateProvider((ref) => 24); -final incomeHistoryProvider = FutureProvider.autoDispose>((ref) async { - final scope = ref.watch(scopeProvider); - final months = ref.watch(historyMonthsProvider); - final now = DateTime.now(); - return ref.watch(incomeApiProvider).history( - scope: scope, - dateFrom: DateTime(now.year, now.month - months + 1, 1), - dateTo: DateTime(now.year, now.month + 1, 0), - ); -}); +final incomeHistoryProvider = FutureProvider.autoDispose>( + (ref) async { + final scope = ref.watch(scopeProvider); + final months = ref.watch(historyMonthsProvider); + final now = DateTime.now(); + return ref + .watch(incomeApiProvider) + .history( + scope: scope, + dateFrom: DateTime(now.year, now.month - months + 1, 1), + dateTo: DateTime(now.year, now.month + 1, 0), + ); + }, +); final forecastMonthsProvider = StateProvider((ref) => 12); -final incomeForecastProvider = FutureProvider.autoDispose>((ref) async { - final scope = ref.watch(scopeProvider); - return ref - .watch(incomeApiProvider) - .forecast(scope: scope, months: ref.watch(forecastMonthsProvider)); -}); +final incomeForecastProvider = + FutureProvider.autoDispose>((ref) async { + final scope = ref.watch(scopeProvider); + return ref + .watch(incomeApiProvider) + .forecast(scope: scope, months: ref.watch(forecastMonthsProvider)); + }); void invalidateIncomeProviders(WidgetRef ref) { ref.invalidate(incomeCalendarProvider); diff --git a/app/lib/features/pending/data/pending_api.dart b/app/lib/features/pending/data/pending_api.dart index 107e7ef..a215c96 100644 --- a/app/lib/features/pending/data/pending_api.dart +++ b/app/lib/features/pending/data/pending_api.dart @@ -60,7 +60,8 @@ class PendingInstrument { return isin ?? sourceKey; } - static PendingInstrument fromJson(Map json) => PendingInstrument( + static PendingInstrument fromJson(Map json) => + PendingInstrument( id: asInt(json['id'])!, source: asString(json['source']) ?? '', sourceKey: asString(json['source_key']) ?? '', @@ -98,7 +99,8 @@ class PendingResolveResult { final bool aliasCreated; final bool metricsRefreshed; - static PendingResolveResult fromJson(Map json) => PendingResolveResult( + static PendingResolveResult fromJson(Map json) => + PendingResolveResult( id: asInt(json['id']) ?? 0, status: asString(json['status']) ?? 'resolved', instrumentId: asInt(json['instrument_id']), @@ -131,14 +133,14 @@ class NewInstrument { final int? lot; Map toJson() => { - 'asset_class': assetClass, - 'name': name, - 'currency': currency, - if (isin != null && isin!.isNotEmpty) 'isin': isin, - if (ticker != null && ticker!.isNotEmpty) 'ticker': ticker, - if (board != null && board!.isNotEmpty) 'board': board, - if (lot != null) 'lot': lot, - }; + 'asset_class': assetClass, + 'name': name, + 'currency': currency, + if (isin != null && isin!.isNotEmpty) 'isin': isin, + if (ticker != null && ticker!.isNotEmpty) 'ticker': ticker, + if (board != null && board!.isNotEmpty) 'board': board, + if (lot != null) 'lot': lot, + }; } /// The asset classes the contract allows, as wire strings. @@ -172,7 +174,10 @@ class PendingApi { queryParameters: {'status': status, 'limit': limit, 'offset': offset}, ); return (r.data ?? const []) - .map((e) => PendingInstrument.fromJson(Map.from(e as Map))) + .map( + (e) => + PendingInstrument.fromJson(Map.from(e as Map)), + ) .toList(); } @@ -182,10 +187,17 @@ class PendingApi { Future create(int id, NewInstrument instrument) => _resolve(id, {'action': 'create', 'instrument': instrument.toJson()}); - Future ignore(int id) => _resolve(id, {'action': 'ignore'}); + Future ignore(int id) => + _resolve(id, {'action': 'ignore'}); - Future _resolve(int id, Map body) async { - final r = await _dio.post>('$_base/$id/resolve', data: body); + Future _resolve( + int id, + Map body, + ) async { + final r = await _dio.post>( + '$_base/$id/resolve', + data: body, + ); return PendingResolveResult.fromJson(r.data ?? const {}); } } @@ -196,14 +208,15 @@ class PendingApi { // not convert to `double` anywhere — a numeric JSON value (should one ever appear) is kept // as its lossless string form and parsed into `Decimal` at the point of display. -String? asString(Object? v) => v == null ? null : (v is String ? v : v.toString()); +String? asString(Object? v) => + v == null ? null : (v is String ? v : v.toString()); int? asInt(Object? v) => switch (v) { - null => null, - final int i => i, - final String s => int.tryParse(s), - _ => null, - }; + null => null, + final int i => i, + final String s => int.tryParse(s), + _ => null, +}; DateTime? asDate(Object? v) { final s = asString(v); diff --git a/app/lib/features/pending/pending_page.dart b/app/lib/features/pending/pending_page.dart index 65f8ccf..a440b91 100644 --- a/app/lib/features/pending/pending_page.dart +++ b/app/lib/features/pending/pending_page.dart @@ -18,16 +18,22 @@ class PendingInstrumentsPage extends ConsumerStatefulWidget { const PendingInstrumentsPage({super.key}); @override - ConsumerState createState() => _PendingInstrumentsPageState(); + ConsumerState createState() => + _PendingInstrumentsPageState(); } -class _PendingInstrumentsPageState extends ConsumerState { +class _PendingInstrumentsPageState + extends ConsumerState { int? _busyId; void _snack(String message) => - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); - Future _run(int id, Future Function() action) async { + Future _run( + int id, + Future Function() action, + ) async { setState(() => _busyId = id); try { final result = await action(); @@ -36,10 +42,12 @@ class _PendingInstrumentsPageState extends ConsumerState ref.invalidate(pendingCountProvider); // Events moved out of `pending`, so holdings, allocation and the event list changed. invalidateLedgerDependents(ref); - _snack(result.status == 'ignored' - ? 'Строка помечена как «не инструмент»' - : 'Привязано событий: ${result.eventsBound}' - '${result.aliasCreated ? ', добавлен алиас' : ''}'); + _snack( + result.status == 'ignored' + ? 'Строка помечена как «не инструмент»' + : 'Привязано событий: ${result.eventsBound}' + '${result.aliasCreated ? ', добавлен алиас' : ''}', + ); } catch (e) { if (!mounted) return; _snack(importErrorMessage(e)); @@ -56,7 +64,10 @@ class _PendingInstrumentsPageState extends ConsumerState ), ); if (instrumentId == null || !mounted) return; - await _run(p.id, () => ref.read(pendingApiProvider).link(p.id, instrumentId)); + await _run( + p.id, + () => ref.read(pendingApiProvider).link(p.id, instrumentId), + ); } Future _create(PendingInstrument p) async { @@ -65,7 +76,10 @@ class _PendingInstrumentsPageState extends ConsumerState builder: (_) => CreateInstrumentDialog(pending: p), ); if (instrument == null || !mounted) return; - await _run(p.id, () => ref.read(pendingApiProvider).create(p.id, instrument)); + await _run( + p.id, + () => ref.read(pendingApiProvider).create(p.id, instrument), + ); } Future _ignore(PendingInstrument p) async { @@ -73,14 +87,19 @@ class _PendingInstrumentsPageState extends ConsumerState context: context, builder: (context) => AlertDialog( title: const Text('Игнорировать строку?'), - content: Text('«${p.title}» больше не будет предлагаться к резолву. ' - 'Её события останутся без инструмента.'), + content: Text( + '«${p.title}» больше не будет предлагаться к резолву. ' + 'Её события останутся без инструмента.', + ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')), + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Игнорировать')), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Игнорировать'), + ), ], ), ); @@ -164,7 +183,8 @@ class _StatusFilter extends ConsumerWidget { ChoiceChip( label: Text(e.value), selected: current == e.key, - onSelected: (_) => ref.read(pendingStatusFilterProvider.notifier).state = e.key, + onSelected: (_) => + ref.read(pendingStatusFilterProvider.notifier).state = e.key, ), ], ); @@ -197,7 +217,8 @@ class _PendingCard extends StatelessWidget { if (p.ticker != null) 'тикер ${p.ticker}', if (p.board != null) 'доска ${p.board}', if (p.currency != null) p.currency!, - if (p.assetClassHint != null) 'в отчёте: ${assetClassLabel(p.assetClassHint)}', + if (p.assetClassHint != null) + 'в отчёте: ${assetClassLabel(p.assetClassHint)}', ]; final sample = [ 'встречается ${p.occurrences} раз', @@ -214,18 +235,25 @@ class _PendingCard extends StatelessWidget { children: [ Row( children: [ - Expanded(child: Text(p.title, style: theme.textTheme.titleMedium)), + Expanded( + child: Text(p.title, style: theme.textTheme.titleMedium), + ), if (!open) Chip( visualDensity: VisualDensity.compact, - label: Text(p.status == 'ignored' ? 'игнорируется' : 'привязан'), + label: Text( + p.status == 'ignored' ? 'игнорируется' : 'привязан', + ), ), ], ), if (facts.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), - child: Text(facts.join(' · '), style: theme.textTheme.bodySmall), + child: Text( + facts.join(' · '), + style: theme.textTheme.bodySmall, + ), ), Padding( padding: const EdgeInsets.only(top: 2), @@ -236,7 +264,9 @@ class _PendingCard extends StatelessWidget { child: Text( 'источник ${p.source} · ключ ${p.sourceKey}' '${p.firstSeenFileId != null ? ' · файл №${p.firstSeenFileId}' : ''}', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), ), ), if (open) ...[ diff --git a/app/lib/features/pending/providers.dart b/app/lib/features/pending/providers.dart index f83db87..cd8fa83 100644 --- a/app/lib/features/pending/providers.dart +++ b/app/lib/features/pending/providers.dart @@ -4,16 +4,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; import 'data/pending_api.dart'; -final pendingApiProvider = Provider((ref) => PendingApi(ref.watch(apiProvider).dio)); +final pendingApiProvider = Provider( + (ref) => PendingApi(ref.watch(apiProvider).dio), +); /// `pending | resolved | ignored | all` — the filter of the resolve screen. final pendingStatusFilterProvider = StateProvider((ref) => 'pending'); final pendingInstrumentsProvider = FutureProvider.autoDispose>((ref) async { - final status = ref.watch(pendingStatusFilterProvider); - return ref.watch(pendingApiProvider).list(status: status); -}); + final status = ref.watch(pendingStatusFilterProvider); + return ref.watch(pendingApiProvider).list(status: status); + }); /// How many rows still await a decision — shown as a badge next to the import screen's link. final pendingCountProvider = FutureProvider.autoDispose((ref) async { @@ -23,12 +25,12 @@ final pendingCountProvider = FutureProvider.autoDispose((ref) async { /// Instrument search for the "link to an existing instrument" dialog. This one endpoint is /// already in the generated client, so it goes through it rather than raw Dio. -final instrumentSearchProvider = - FutureProvider.autoDispose.family, String>((ref, query) async { - if (query.trim().length < 2) return const []; - final r = await ref - .watch(apiProvider) - .getInstrumentsApi() - .instrumentsList(q: query.trim(), limit: 25); - return r.data ?? const []; -}); +final instrumentSearchProvider = FutureProvider.autoDispose + .family, String>((ref, query) async { + if (query.trim().length < 2) return const []; + final r = await ref + .watch(apiProvider) + .getInstrumentsApi() + .instrumentsList(q: query.trim(), limit: 25); + return r.data ?? const []; + }); diff --git a/app/lib/features/pending/widgets/create_instrument_dialog.dart b/app/lib/features/pending/widgets/create_instrument_dialog.dart index 853cf44..22cd6a1 100644 --- a/app/lib/features/pending/widgets/create_instrument_dialog.dart +++ b/app/lib/features/pending/widgets/create_instrument_dialog.dart @@ -21,12 +21,15 @@ class _CreateInstrumentDialogState extends State { late final _ticker = TextEditingController(text: widget.pending.ticker ?? ''); late final _board = TextEditingController(text: widget.pending.board ?? ''); late final _name = TextEditingController(text: widget.pending.name ?? ''); - late final _currency = TextEditingController(text: widget.pending.currency ?? 'RUB'); + late final _currency = TextEditingController( + text: widget.pending.currency ?? 'RUB', + ); late final _lot = TextEditingController(text: '1'); /// `asset_class_hint` is what the report's own section said (e.g. the «Фонды» table), so /// it is offered as the initial value of a control the user must still look at. - late String _assetClass = assetClassKeys.contains(widget.pending.assetClassHint) + late String _assetClass = + assetClassKeys.contains(widget.pending.assetClassHint) ? widget.pending.assetClassHint! : 'share'; @@ -43,15 +46,17 @@ class _CreateInstrumentDialogState extends State { void _submit() { if (!_formKey.currentState!.validate()) return; - Navigator.of(context).pop(NewInstrument( - assetClass: _assetClass, - name: _name.text.trim(), - currency: _currency.text.trim().toUpperCase(), - isin: _isin.text.trim(), - ticker: _ticker.text.trim(), - board: _board.text.trim(), - lot: int.tryParse(_lot.text.trim()), - )); + Navigator.of(context).pop( + NewInstrument( + assetClass: _assetClass, + name: _name.text.trim(), + currency: _currency.text.trim().toUpperCase(), + isin: _isin.text.trim(), + ticker: _ticker.text.trim(), + board: _board.text.trim(), + lot: int.tryParse(_lot.text.trim()), + ), + ); } @override @@ -72,27 +77,44 @@ class _CreateInstrumentDialogState extends State { decoration: const InputDecoration(labelText: 'Класс актива'), items: [ for (final key in assetClassKeys) - DropdownMenuItem(value: key, child: Text('${assetClassLabel(key)} ($key)')), + DropdownMenuItem( + value: key, + child: Text('${assetClassLabel(key)} ($key)'), + ), ], - onChanged: (v) => setState(() => _assetClass = v ?? _assetClass), + onChanged: (v) => + setState(() => _assetClass = v ?? _assetClass), ), _field(_name, 'Название', required: true), _field(_isin, 'ISIN'), _field(_ticker, 'Тикер'), _field(_board, 'Доска (TQBR, TQTF…)'), _field(_currency, 'Валюта', required: true), - _field(_lot, 'Лот', keyboard: TextInputType.number, validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return int.tryParse(v.trim()) == null ? 'Целое число' : null; - }), + _field( + _lot, + 'Лот', + keyboard: TextInputType.number, + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return int.tryParse(v.trim()) == null + ? 'Целое число' + : null; + }, + ), ], ), ), ), ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена')), - FilledButton(onPressed: _submit, child: const Text('Создать и привязать')), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: _submit, + child: const Text('Создать и привязать'), + ), ], ); } @@ -110,9 +132,12 @@ class _CreateInstrumentDialogState extends State { controller: controller, keyboardType: keyboard, decoration: InputDecoration(labelText: label, isDense: true), - validator: validator ?? + validator: + validator ?? (required - ? (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null + ? (v) => (v == null || v.trim().isEmpty) + ? 'Обязательное поле' + : null : null), ), ); diff --git a/app/lib/features/pending/widgets/link_instrument_dialog.dart b/app/lib/features/pending/widgets/link_instrument_dialog.dart index 0d200a5..008fba7 100644 --- a/app/lib/features/pending/widgets/link_instrument_dialog.dart +++ b/app/lib/features/pending/widgets/link_instrument_dialog.dart @@ -18,12 +18,14 @@ class LinkInstrumentDialog extends ConsumerStatefulWidget { final String initialQuery; @override - ConsumerState createState() => _LinkInstrumentDialogState(); + ConsumerState createState() => + _LinkInstrumentDialogState(); } class _LinkInstrumentDialogState extends ConsumerState { - late final TextEditingController _controller = - TextEditingController(text: widget.initialQuery); + late final TextEditingController _controller = TextEditingController( + text: widget.initialQuery, + ); String _query = ''; Timer? _debounce; @@ -75,7 +77,9 @@ class _LinkInstrumentDialogState extends ConsumerState { error: (e, _) => Center(child: Text('$e')), data: (rows) { if (_query.trim().length < 2) { - return const Center(child: Text('Введите минимум 2 символа')); + return const Center( + child: Text('Введите минимум 2 символа'), + ); } if (rows.isEmpty) { return const Center(child: Text('Ничего не найдено')); @@ -108,10 +112,12 @@ class _LinkInstrumentDialogState extends ConsumerState { ].join(' · '); return ListTile( dense: true, - title: Text([ - if (instrument.ticker != null) instrument.ticker!, - instrument.name, - ].join(' · ')), + title: Text( + [ + if (instrument.ticker != null) instrument.ticker!, + instrument.name, + ].join(' · '), + ), subtitle: Text(subtitle), onTap: () => Navigator.of(context).pop(instrument.id), ); diff --git a/app/lib/features/rebalance/data/rebalance_api.dart b/app/lib/features/rebalance/data/rebalance_api.dart index 8f50352..7ca6ec1 100644 --- a/app/lib/features/rebalance/data/rebalance_api.dart +++ b/app/lib/features/rebalance/data/rebalance_api.dart @@ -34,31 +34,39 @@ class TargetWeight { final String? band; final String? note; - TargetWeight copyWith({String? bucket, String? targetWeight, String? band, String? note}) => - TargetWeight( - bucket: bucket ?? this.bucket, - targetWeight: targetWeight ?? this.targetWeight, - band: band ?? this.band, - note: note ?? this.note, - ); + TargetWeight copyWith({ + String? bucket, + String? targetWeight, + String? band, + String? note, + }) => TargetWeight( + bucket: bucket ?? this.bucket, + targetWeight: targetWeight ?? this.targetWeight, + band: band ?? this.band, + note: note ?? this.note, + ); Map toJson() => { - 'bucket': bucket, - 'target_weight': targetWeight, - 'band': ?band, - 'note': ?note, - }; + 'bucket': bucket, + 'target_weight': targetWeight, + 'band': ?band, + 'note': ?note, + }; static TargetWeight fromJson(Map json) => TargetWeight( - bucket: asString(json['bucket']) ?? '', - targetWeight: asString(json['target_weight']) ?? '0', - band: asString(json['band']), - note: asString(json['note']), - ); + bucket: asString(json['bucket']) ?? '', + targetWeight: asString(json['target_weight']) ?? '0', + band: asString(json['band']), + note: asString(json['note']), + ); } class TargetSet { - const TargetSet({required this.dimension, this.targets = const [], this.weightsSum}); + const TargetSet({ + required this.dimension, + this.targets = const [], + this.weightsSum, + }); final String dimension; final List targets; @@ -72,18 +80,19 @@ class TargetSet { Decimal get localSum => sumDecimals(targets.map((t) => t.targetWeight)); /// The contract's tolerance: the sum must be 1 within 0.0001. - bool get sumIsValid => (localSum - Decimal.one).abs() <= Decimal.parse('0.0001'); + bool get sumIsValid => + (localSum - Decimal.one).abs() <= Decimal.parse('0.0001'); Map toJson() => { - 'dimension': dimension, - 'targets': [for (final t in targets) t.toJson()], - }; + 'dimension': dimension, + 'targets': [for (final t in targets) t.toJson()], + }; static TargetSet fromJson(Map json) => TargetSet( - dimension: asString(json['dimension']) ?? 'asset_class', - targets: asObjects(json['targets']).map(TargetWeight.fromJson).toList(), - weightsSum: asString(json['weights_sum']), - ); + dimension: asString(json['dimension']) ?? 'asset_class', + targets: asObjects(json['targets']).map(TargetWeight.fromJson).toList(), + weightsSum: asString(json['weights_sum']), + ); } class RebalanceTrade { @@ -119,20 +128,21 @@ class RebalanceTrade { /// tell an underweight recommendation from a wrong one. final bool blockedByCash; - String get title => ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId'); + String get title => + ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId'); static RebalanceTrade fromJson(Map json) => RebalanceTrade( - instrumentId: asInt(json['instrument_id']), - ticker: asString(json['ticker']), - name: asString(json['name']), - action: asString(json['action']) ?? 'buy', - suggestedQty: asString(json['suggested_qty']), - lot: asInt(json['lot']), - price: asString(json['price']), - priceCurrency: asString(json['price_currency']), - amountRub: asString(json['amount_rub']), - blockedByCash: asBool(json['blocked_by_cash']), - ); + instrumentId: asInt(json['instrument_id']), + ticker: asString(json['ticker']), + name: asString(json['name']), + action: asString(json['action']) ?? 'buy', + suggestedQty: asString(json['suggested_qty']), + lot: asInt(json['lot']), + price: asString(json['price']), + priceCurrency: asString(json['price_currency']), + amountRub: asString(json['amount_rub']), + blockedByCash: asBool(json['blocked_by_cash']), + ); } class RebalanceBucket { @@ -161,15 +171,15 @@ class RebalanceBucket { final List trades; static RebalanceBucket fromJson(Map json) => RebalanceBucket( - bucket: asString(json['bucket']) ?? '', - currentValueRub: asString(json['current_value_rub']), - currentWeight: asString(json['current_weight']), - targetWeight: asString(json['target_weight']), - drift: asString(json['drift']), - withinBand: asBool(json['within_band']), - deltaValueRub: asString(json['delta_value_rub']), - trades: asObjects(json['trades']).map(RebalanceTrade.fromJson).toList(), - ); + bucket: asString(json['bucket']) ?? '', + currentValueRub: asString(json['current_value_rub']), + currentWeight: asString(json['current_weight']), + targetWeight: asString(json['target_weight']), + drift: asString(json['drift']), + withinBand: asBool(json['within_band']), + deltaValueRub: asString(json['delta_value_rub']), + trades: asObjects(json['trades']).map(RebalanceTrade.fromJson).toList(), + ); } class RebalancePlan { @@ -191,17 +201,18 @@ class RebalancePlan { final List buckets; final List warnings; - bool get everythingWithinBand => buckets.isNotEmpty && buckets.every((b) => b.withinBand); + bool get everythingWithinBand => + buckets.isNotEmpty && buckets.every((b) => b.withinBand); static RebalancePlan fromJson(Map json) => RebalancePlan( - portfolioId: asInt(json['portfolio_id']) ?? 0, - dimension: asString(json['dimension']) ?? 'asset_class', - asOf: asDate(json['as_of']), - totalValueRub: asString(json['total_value_rub']), - cashAvailableRub: asString(json['cash_available_rub']), - buckets: asObjects(json['buckets']).map(RebalanceBucket.fromJson).toList(), - warnings: asStrings(json['warnings']), - ); + portfolioId: asInt(json['portfolio_id']) ?? 0, + dimension: asString(json['dimension']) ?? 'asset_class', + asOf: asDate(json['as_of']), + totalValueRub: asString(json['total_value_rub']), + cashAvailableRub: asString(json['cash_available_rub']), + buckets: asObjects(json['buckets']).map(RebalanceBucket.fromJson).toList(), + warnings: asStrings(json['warnings']), + ); } class RebalanceApi { @@ -217,12 +228,18 @@ class RebalanceApi { /// not — revisit once the route is in the spec. /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached` directly /// (there is no generated `Response` for the screen to unwrap `r.cached` from). - Future> getTargets(int portfolioId, {String dimension = 'asset_class'}) async { + Future> getTargets( + int portfolioId, { + String dimension = 'asset_class', + }) async { final r = await _dio.get>( '$_base/$portfolioId/targets', queryParameters: {'dimension': dimension}, ); - return Cached(TargetSet.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?); + return Cached( + TargetSet.fromJson(r.data ?? const {}), + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); } /// Full replacement of one dimension — partial updates are not supported by the contract. @@ -241,7 +258,10 @@ class RebalanceApi { }) async { final r = await _dio.get>( '$_base/$portfolioId/rebalance', - queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable}, + queryParameters: { + 'dimension': dimension, + 'cash_available': ?cashAvailable, + }, ); return Cached( RebalancePlan.fromJson(r.data ?? const {}), diff --git a/app/lib/features/rebalance/labels.dart b/app/lib/features/rebalance/labels.dart index b155062..27fbfea 100644 --- a/app/lib/features/rebalance/labels.dart +++ b/app/lib/features/rebalance/labels.dart @@ -10,5 +10,7 @@ String bucketLabelForKey(String dimension, String bucket) { if (bucket == 'cash') return 'Денежные средства'; if (bucket == 'unknown') return 'Не указано'; if (bucket.isEmpty) return '—'; - return dimension == 'asset_class' ? (assetClassLabels[bucket] ?? bucket) : bucket; + return dimension == 'asset_class' + ? (assetClassLabels[bucket] ?? bucket) + : bucket; } diff --git a/app/lib/features/rebalance/plan_tab.dart b/app/lib/features/rebalance/plan_tab.dart index 6a57607..91e6291 100644 --- a/app/lib/features/rebalance/plan_tab.dart +++ b/app/lib/features/rebalance/plan_tab.dart @@ -62,7 +62,9 @@ class RebalancePlanTab extends ConsumerWidget { child: const ListTile( leading: Icon(Icons.check_circle_outline), title: Text('Все группы внутри коридора'), - subtitle: Text('Действий не требуется — отклонения меньше заданного допуска.'), + subtitle: Text( + 'Действий не требуется — отклонения меньше заданного допуска.', + ), ), ), const SizedBox(height: 8), @@ -104,7 +106,9 @@ class _Header extends ConsumerWidget { : MoneyText(plan.totalValueRub!, currency: 'RUB'), ), _Figure( - label: whatIf == null ? 'Доступно денег' : 'Доступно денег (what-if)', + label: whatIf == null + ? 'Доступно денег' + : 'Доступно денег (what-if)', child: plan.cashAvailableRub == null ? const Text('—') : MoneyText(plan.cashAvailableRub!, currency: 'RUB'), @@ -149,8 +153,9 @@ class _WhatIfCashField extends ConsumerStatefulWidget { } class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> { - late final TextEditingController _controller = - TextEditingController(text: widget.current ?? ''); + late final TextEditingController _controller = TextEditingController( + text: widget.current ?? '', + ); @override void dispose() { @@ -159,7 +164,10 @@ class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> { } void _apply() { - final text = _controller.text.trim().replaceAll(',', '.').replaceAll(' ', ''); + final text = _controller.text + .trim() + .replaceAll(',', '.') + .replaceAll(' ', ''); ref.read(whatIfCashProvider.notifier).state = text.isEmpty ? null : text; } @@ -228,30 +236,35 @@ class _BucketCard extends StatelessWidget { final theme = Theme.of(context); return SectionCard( title: bucketLabelForKey(dimension, bucket.bucket), - subtitle: 'сейчас ${formatShareAsPercent(bucket.currentWeight)} · ' + subtitle: + 'сейчас ${formatShareAsPercent(bucket.currentWeight)} · ' 'цель ${formatShareAsPercent(bucket.targetWeight)} · ' 'отклонение ${formatShareAsPercent(bucket.drift, signed: true)}', trailing: bucket.withinBand ? const _WithinBandChip() : (bucket.deltaValueRub == null - ? const Text('—') - : MoneyText( - bucket.deltaValueRub!, - currency: 'RUB', - style: TextStyle(color: signColor(context, bucket.deltaValueRub)), - )), + ? const Text('—') + : MoneyText( + bucket.deltaValueRub!, + currency: 'RUB', + style: TextStyle( + color: signColor(context, bucket.deltaValueRub), + ), + )), child: bucket.withinBand ? Text( 'Внутри коридора — сделок не требуется.', style: theme.textTheme.bodySmall, ) : bucket.trades.isEmpty - ? Text( - 'Сервер не предложил сделок по этой группе: ' - 'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.', - style: theme.textTheme.bodySmall, - ) - : Column(children: [for (final t in bucket.trades) TradeRow(trade: t)]), + ? Text( + 'Сервер не предложил сделок по этой группе: ' + 'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.', + style: theme.textTheme.bodySmall, + ) + : Column( + children: [for (final t in bucket.trades) TradeRow(trade: t)], + ), ); } } @@ -301,7 +314,9 @@ class TradeRow extends StatelessWidget { : () => context.push('/portfolio/instrument/${trade.instrumentId}'), leading: Icon( isBuy ? Icons.add_circle_outline : Icons.remove_circle_outline, - color: isBuy ? Theme.of(context).colorScheme.primary : theme.colorScheme.error, + color: isBuy + ? Theme.of(context).colorScheme.primary + : theme.colorScheme.error, ), title: Row( children: [ @@ -315,12 +330,16 @@ class TradeRow extends StatelessWidget { noQty ? 'Количество не рассчитано: нет цены инструмента' : '${isBuy ? 'Купить' : 'Продать'} ${formatQty(trade.suggestedQty!)} шт.' - '${details.isEmpty ? '' : ' · $details'}', + '${details.isEmpty ? '' : ' · $details'}', style: theme.textTheme.bodySmall, ), trailing: noQty || trade.amountRub == null ? const Text('—') - : MoneyText(trade.amountRub!, currency: 'RUB', style: theme.textTheme.titleSmall), + : MoneyText( + trade.amountRub!, + currency: 'RUB', + style: theme.textTheme.titleSmall, + ), ); } } diff --git a/app/lib/features/rebalance/providers.dart b/app/lib/features/rebalance/providers.dart index 44eba70..4483460 100644 --- a/app/lib/features/rebalance/providers.dart +++ b/app/lib/features/rebalance/providers.dart @@ -5,8 +5,9 @@ import '../../core/cache/cached.dart'; import '../portfolio/providers.dart' show scopeProvider, scopesProvider; import 'data/rebalance_api.dart'; -final rebalanceApiProvider = - Provider((ref) => RebalanceApi(ref.watch(apiProvider).dio)); +final rebalanceApiProvider = Provider( + (ref) => RebalanceApi(ref.watch(apiProvider).dio), +); /// A portfolio the user can rebalance, derived from the scope list the analytics API /// already publishes (`portfolio:`): there is no separate portfolios endpoint, and @@ -17,7 +18,9 @@ class PortfolioRef { final String name; } -final portfoliosProvider = FutureProvider.autoDispose>((ref) async { +final portfoliosProvider = FutureProvider.autoDispose>(( + ref, +) async { final scopes = await ref.watch(scopesProvider.future); return [ for (final s in scopes) @@ -41,20 +44,25 @@ final whatIfCashProvider = StateProvider((ref) => null); /// See `docs/ai/offline-cache.md`. No portfolio selected yet is a live, empty answer — not a /// stale one — so it is wrapped with `fetchedAt: null` rather than left unwrapped. -final targetsProvider = FutureProvider.autoDispose>((ref) async { +final targetsProvider = FutureProvider.autoDispose>(( + ref, +) async { final id = ref.watch(selectedPortfolioProvider); final dimension = ref.watch(targetDimensionProvider); if (id == null) return Cached(TargetSet(dimension: dimension)); return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension); }); -final rebalancePlanProvider = FutureProvider.autoDispose>((ref) async { - final id = ref.watch(selectedPortfolioProvider); - final dimension = ref.watch(targetDimensionProvider); - final cash = ref.watch(whatIfCashProvider); - if (id == null) return const Cached(null); - return ref.watch(rebalanceApiProvider).plan(id, dimension: dimension, cashAvailable: cash); -}); +final rebalancePlanProvider = + FutureProvider.autoDispose>((ref) async { + final id = ref.watch(selectedPortfolioProvider); + final dimension = ref.watch(targetDimensionProvider); + final cash = ref.watch(whatIfCashProvider); + if (id == null) return const Cached(null); + return ref + .watch(rebalanceApiProvider) + .plan(id, dimension: dimension, cashAvailable: cash); + }); /// After a successful PUT the numbers are recomputed on the server, so both sides of the /// screen are refetched rather than patched in place. diff --git a/app/lib/features/rebalance/rebalance_page.dart b/app/lib/features/rebalance/rebalance_page.dart index 77b3221..8808423 100644 --- a/app/lib/features/rebalance/rebalance_page.dart +++ b/app/lib/features/rebalance/rebalance_page.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../core/cache/cached.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/stale_banner.dart'; import '../portfolio/labels.dart' show dimensionLabels; +import '../portfolios/providers.dart' show portfolioListProvider; import 'data/rebalance_api.dart'; import 'plan_tab.dart'; import 'providers.dart'; @@ -45,7 +47,10 @@ class RebalancePage extends ConsumerWidget { ), ], bottom: const TabBar( - tabs: [Tab(text: 'Целевые веса'), Tab(text: 'Рекомендации')], + tabs: [ + Tab(text: 'Целевые веса'), + Tab(text: 'Рекомендации'), + ], ), ), body: Column( @@ -61,21 +66,48 @@ class RebalancePage extends ConsumerWidget { onRetry: () => ref.invalidate(portfoliosProvider), data: (list) { if (list.isEmpty) { - return const EmptyState( - icon: Icons.pie_chart_outline, - message: 'Портфелей пока нет.\n' - 'Ребалансировка считается по портфелю: заведите его в настройках счетов.', + // A portfolio can exist yet be absent here: this list comes from the + // analytics scopes, which only cover accounts that have ledger events. + final created = + ref + .watch(portfolioListProvider) + .valueOrNull + ?.isNotEmpty ?? + false; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + EmptyState( + icon: Icons.pie_chart_outline, + message: created + ? 'Портфели есть, но в них нет счетов с бумагами.\n' + 'Добавьте брокерские счета: у счетов ZenMoney нет сделок, ' + 'считать по ним нечего.' + : 'Портфелей пока нет.\n' + 'Ребалансировка считается по портфелю — набору счетов.', + ), + FilledButton.icon( + onPressed: () => context.go('/portfolios'), + icon: Icon(created ? Icons.edit_outlined : Icons.add), + label: Text( + created ? 'Изменить портфели' : 'Создать портфель', + ), + ), + ], ); } final selected = ref.watch(selectedPortfolioProvider); if (selected == null || !list.any((p) => p.id == selected)) { // pick the first portfolio once, after the list is known WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(selectedPortfolioProvider.notifier).state = list.first.id; + ref.read(selectedPortfolioProvider.notifier).state = + list.first.id; }); return const Center(child: CircularProgressIndicator()); } - return const TabBarView(children: [TargetsTab(), RebalancePlanTab()]); + return const TabBarView( + children: [TargetsTab(), RebalancePlanTab()], + ); }, ), ), @@ -91,16 +123,23 @@ class _PortfolioSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final portfolios = ref.watch(portfoliosProvider).valueOrNull ?? const []; + final portfolios = + ref.watch(portfoliosProvider).valueOrNull ?? const []; final selected = ref.watch(selectedPortfolioProvider); - if (portfolios.length < 2 || selected == null) return const SizedBox.shrink(); + if (portfolios.length < 2 || selected == null) + return const SizedBox.shrink(); return DropdownButtonHideUnderline( child: DropdownButton( - value: portfolios.any((p) => p.id == selected) ? selected : portfolios.first.id, + value: portfolios.any((p) => p.id == selected) + ? selected + : portfolios.first.id, borderRadius: BorderRadius.circular(8), items: [ for (final p in portfolios) - DropdownMenuItem(value: p.id, child: Text(p.name, overflow: TextOverflow.ellipsis)), + DropdownMenuItem( + value: p.id, + child: Text(p.name, overflow: TextOverflow.ellipsis), + ), ], onChanged: (v) { if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v; diff --git a/app/lib/features/rebalance/targets_tab.dart b/app/lib/features/rebalance/targets_tab.dart index a9277d1..5889504 100644 --- a/app/lib/features/rebalance/targets_tab.dart +++ b/app/lib/features/rebalance/targets_tab.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/utils/json.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/section_card.dart'; +import '../../core/widgets/help_tip.dart'; import '../portfolio/labels.dart' show assetClassLabels; import 'data/rebalance_api.dart'; import 'labels.dart'; @@ -38,7 +39,8 @@ class _TargetsTabState extends ConsumerState { _dirty = false; } - Decimal get _sum => sumDecimals((_draft ?? const []).map((t) => t.targetWeight)); + Decimal get _sum => + sumDecimals((_draft ?? const []).map((t) => t.targetWeight)); bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001'); @@ -52,13 +54,17 @@ class _TargetsTabState extends ConsumerState { try { await ref .read(rebalanceApiProvider) - .putTargets(portfolioId, TargetSet(dimension: dimension, targets: draft)); + .putTargets( + portfolioId, + TargetSet(dimension: dimension, targets: draft), + ); if (!mounted) return; _dirty = false; _seededFor = null; // refetch reseeds the draft from the saved set invalidateRebalanceProviders(ref); - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Целевые веса сохранены'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Целевые веса сохранены'))); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context) @@ -84,11 +90,16 @@ class _TargetsTabState extends ConsumerState { return ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), children: [ - _SumBanner(sum: _sum, valid: _sumIsValid, serverSum: set.weightsSum), + _SumBanner( + sum: _sum, + valid: _sumIsValid, + serverSum: set.weightsSum, + ), const SizedBox(height: 12), SectionCard( title: 'Целевые веса', - subtitle: 'Вес — доля портфеля; допуск (band) — ширина коридора, ' + subtitle: + 'Вес — доля портфеля; допуск (band) — ширина коридора, ' 'внутри которого сделки не предлагаются.', child: Column( children: [ @@ -118,7 +129,11 @@ class _TargetsTabState extends ConsumerState { onPressed: () => setState(() { _draft = [ ...draft, - const TargetWeight(bucket: '', targetWeight: '0', band: '0.05'), + const TargetWeight( + bucket: '', + targetWeight: '0', + band: '0.05', + ), ]; _dirty = true; }), @@ -133,12 +148,18 @@ class _TargetsTabState extends ConsumerState { Row( children: [ FilledButton.icon( - onPressed: _saving || !_sumIsValid || draft.any((t) => t.bucket.isEmpty) + onPressed: + _saving || + !_sumIsValid || + draft.any((t) => t.bucket.isEmpty) ? null : _save, icon: _saving ? const SizedBox( - width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Icon(Icons.save_outlined), label: const Text('Сохранить'), ), @@ -192,7 +213,7 @@ class _SumBanner extends StatelessWidget { valid ? 'Набор можно сохранить.' : 'Нужно ровно 100 %. Разница: ' - '${formatShareAsPercent((sum - Decimal.one).toString(), signed: true)}', + '${formatShareAsPercent((sum - Decimal.one).toString(), signed: true)}', ), trailing: serverSum == null ? null @@ -224,12 +245,15 @@ class _TargetRow extends StatefulWidget { } class _TargetRowState extends State<_TargetRow> { - late final TextEditingController _weight = - TextEditingController(text: shareToPercentText(widget.target.targetWeight)); - late final TextEditingController _band = - TextEditingController(text: shareToPercentText(widget.target.band)); - late final TextEditingController _bucket = - TextEditingController(text: widget.target.bucket); + late final TextEditingController _weight = TextEditingController( + text: shareToPercentText(widget.target.targetWeight), + ); + late final TextEditingController _band = TextEditingController( + text: shareToPercentText(widget.target.band), + ); + late final TextEditingController _bucket = TextEditingController( + text: widget.target.bucket, + ); @override void dispose() { @@ -257,20 +281,32 @@ class _TargetRowState extends State<_TargetRow> { child: knownBuckets.isEmpty ? TextField( controller: _bucket, - decoration: const InputDecoration(labelText: 'Группа', isDense: true), - onChanged: (v) => widget.onChanged(widget.target.copyWith(bucket: v)), + decoration: const InputDecoration( + labelText: 'Группа', + isDense: true, + ), + onChanged: (v) => + widget.onChanged(widget.target.copyWith(bucket: v)), ) : DropdownButtonFormField( - initialValue: - knownBuckets.contains(widget.target.bucket) ? widget.target.bucket : null, + initialValue: knownBuckets.contains(widget.target.bucket) + ? widget.target.bucket + : null, isExpanded: true, - decoration: const InputDecoration(labelText: 'Группа', isDense: true), + decoration: const InputDecoration( + labelText: 'Группа', + isDense: true, + ), items: [ for (final b in knownBuckets) - DropdownMenuItem(value: b, child: Text(bucketLabelForKey('asset_class', b))), + DropdownMenuItem( + value: b, + child: Text(bucketLabelForKey('asset_class', b)), + ), ], - onChanged: (v) => - widget.onChanged(widget.target.copyWith(bucket: v ?? '')), + onChanged: (v) => widget.onChanged( + widget.target.copyWith(bucket: v ?? ''), + ), ), ), const SizedBox(width: 12), @@ -278,11 +314,18 @@ class _TargetRowState extends State<_TargetRow> { flex: 2, child: TextField( controller: _weight, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Вес, %', isDense: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Вес, %', + isDense: true, + ), onChanged: (v) { final share = percentTextToShare(v); - widget.onChanged(widget.target.copyWith(targetWeight: share ?? '0')); + widget.onChanged( + widget.target.copyWith(targetWeight: share ?? '0'), + ); }, ), ), @@ -291,10 +334,21 @@ class _TargetRowState extends State<_TargetRow> { flex: 2, child: TextField( controller: _band, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Допуск, %', isDense: true), - onChanged: (v) => - widget.onChanged(widget.target.copyWith(band: percentTextToShare(v))), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Допуск, %', + isDense: true, + suffixIcon: HelpTip( + 'Допуск (band) — ширина коридора вокруг целевого веса: пока фактическая доля ' + 'внутри него, сделки не предлагаются. ±5 % значит «не трогать, пока доля в ' + 'пределах 5 процентных пунктов от цели».', + ), + ), + onChanged: (v) => widget.onChanged( + widget.target.copyWith(band: percentTextToShare(v)), + ), ), ), IconButton( diff --git a/app/lib/features/rebalance/weights.dart b/app/lib/features/rebalance/weights.dart index 74fa703..00e0b21 100644 --- a/app/lib/features/rebalance/weights.dart +++ b/app/lib/features/rebalance/weights.dart @@ -26,7 +26,11 @@ String? percentTextToShare(String text) { /// `"0.032"` → `"3,20 %"`, signed. Kept local to the rebalance screen because drift is a /// share, not a return, and the portfolio helper would sign it the same way by accident. -String formatShareAsPercent(String? share, {bool signed = false, int digits = 2}) { +String formatShareAsPercent( + String? share, { + bool signed = false, + int digits = 2, +}) { final d = share == null ? null : Decimal.tryParse(share); if (d == null) return '—'; final p = (d * _hundred).toDouble(); diff --git a/app/lib/features/rules/providers.dart b/app/lib/features/rules/providers.dart index aece13b..7ccf51a 100644 --- a/app/lib/features/rules/providers.dart +++ b/app/lib/features/rules/providers.dart @@ -5,17 +5,27 @@ import '../../core/api/api_client.dart'; import '../../core/cache/cached.dart'; /// See `docs/ai/offline-cache.md`. -final rulesListProvider = FutureProvider.autoDispose>>((ref) async { +final rulesListProvider = FutureProvider.autoDispose>>(( + ref, +) async { final r = await ref.watch(apiProvider).getRulesApi().rulesList(); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); }); /// Enabled rules that matched nothing in the latest refresh — «устарело» here is a business /// concept (the rule looks dead), unrelated to the offline-cache staleness this file also /// tracks; both happen to be named "stale" in their own domains. -final rulesStaleProvider = FutureProvider.autoDispose>>((ref) async { +final rulesStaleProvider = FutureProvider.autoDispose>>(( + ref, +) async { final r = await ref.watch(apiProvider).getRulesApi().rulesStale(); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); }); final staleRuleIdsProvider = Provider.autoDispose>((ref) { diff --git a/app/lib/features/rules/rules_page.dart b/app/lib/features/rules/rules_page.dart index 795a4cb..50d44d2 100644 --- a/app/lib/features/rules/rules_page.dart +++ b/app/lib/features/rules/rules_page.dart @@ -13,24 +13,24 @@ import '../../core/widgets/stale_banner.dart'; import 'providers.dart'; String ruleKindLabel(RuleKind k) => switch (k) { - RuleKind.savings => 'Сбережения', - RuleKind.oneOff => 'Разовая трата', - RuleKind.category => 'Категория', - RuleKind.payee => 'Плательщик', - RuleKind.brokerTarget => 'Целевой брокер', - RuleKind.ignore => 'Игнорировать', - RuleKind.unknownDefaultOpenApi => 'Неизвестно', - }; + RuleKind.savings => 'Сбережения', + RuleKind.oneOff => 'Разовая трата', + RuleKind.category => 'Категория', + RuleKind.payee => 'Плательщик', + RuleKind.brokerTarget => 'Целевой брокер', + RuleKind.ignore => 'Игнорировать', + RuleKind.unknownDefaultOpenApi => 'Неизвестно', +}; String ruleMatchTypeLabel(RuleMatchType t) => switch (t) { - RuleMatchType.id => 'ID транзакции', - RuleMatchType.payee => 'Плательщик', - RuleMatchType.comment => 'Комментарий', - RuleMatchType.category => 'Категория', - RuleMatchType.mcc => 'MCC', - RuleMatchType.account => 'Счёт', - RuleMatchType.unknownDefaultOpenApi => 'Неизвестно', - }; + RuleMatchType.id => 'ID транзакции', + RuleMatchType.payee => 'Плательщик', + RuleMatchType.comment => 'Комментарий', + RuleMatchType.category => 'Категория', + RuleMatchType.mcc => 'MCC', + RuleMatchType.account => 'Счёт', + RuleMatchType.unknownDefaultOpenApi => 'Неизвестно', +}; const _kinds = [ RuleKind.savings, @@ -65,9 +65,13 @@ class RulesPage extends ConsumerWidget { try { final r = await ref.read(apiProvider).getRulesApi().rulesApply(); final ok = r.data?.error == null; - messenger.showSnackBar(SnackBar( - content: Text(ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}'), - )); + messenger.showSnackBar( + SnackBar( + content: Text( + ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}', + ), + ), + ); } on DioException catch (e) { messenger.showSnackBar(SnackBar(content: Text(problemMessage(e)))); } finally { @@ -113,11 +117,15 @@ class RulesPage extends ConsumerWidget { return ListView( children: [ if (stale != null) StaleBanner(fetchedAt: stale), - const EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'), + const EmptyState( + icon: Icons.rule_folder_outlined, + message: 'Правил ещё нет.', + ), ], ); } - final sorted = [...rows]..sort((a, b) => a.priority.compareTo(b.priority)); + final sorted = [...rows] + ..sort((a, b) => a.priority.compareTo(b.priority)); return ListView( padding: const EdgeInsets.all(16), children: [ @@ -138,7 +146,11 @@ class RulesPage extends ConsumerWidget { } class _RuleTile extends ConsumerStatefulWidget { - const _RuleTile({required this.rule, required this.stale, required this.onChanged}); + const _RuleTile({ + required this.rule, + required this.stale, + required this.onChanged, + }); final RuleOut rule; final bool stale; @@ -154,14 +166,18 @@ class _RuleTileState extends ConsumerState<_RuleTile> { Future _toggle(bool enabled) async { setState(() => _busy = true); try { - await ref.read(apiProvider).getRulesApi().rulesPatch( + await ref + .read(apiProvider) + .getRulesApi() + .rulesPatch( ruleId: widget.rule.id, rulePatch: RulePatch(enabled: enabled), ); widget.onChanged(); } on DioException catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(problemMessage(e)))); } finally { if (mounted) setState(() => _busy = false); } @@ -174,18 +190,28 @@ class _RuleTileState extends ConsumerState<_RuleTile> { title: const Text('Удалить правило?'), content: Text('«${widget.rule.pattern}» — действие необратимо.'), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')), - FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')), + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Удалить'), + ), ], ), ); if (confirmed != true) return; try { - await ref.read(apiProvider).getRulesApi().rulesDelete(ruleId: widget.rule.id); + await ref + .read(apiProvider) + .getRulesApi() + .rulesDelete(ruleId: widget.rule.id); widget.onChanged(); } on DioException catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(problemMessage(e)))); } } @@ -196,7 +222,8 @@ class _RuleTileState extends ConsumerState<_RuleTile> { child: ListTile( onTap: () => showDialog( context: context, - builder: (_) => _RuleDialog(existing: rule, onSaved: widget.onChanged), + builder: (_) => + _RuleDialog(existing: rule, onSaved: widget.onChanged), ), title: Row( children: [ @@ -222,7 +249,8 @@ class _RuleTileState extends ConsumerState<_RuleTile> { [ '${rule.pattern} → ${rule.value ?? '—'}', 'совпадений: ${rule.matchCount}', - if (rule.lastMatchedAt != null) 'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}', + if (rule.lastMatchedAt != null) + 'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}', ].join(' · '), ), ), @@ -230,7 +258,10 @@ class _RuleTileState extends ConsumerState<_RuleTile> { mainAxisSize: MainAxisSize.min, children: [ Switch(value: rule.enabled, onChanged: _busy ? null : _toggle), - IconButton(icon: const Icon(Icons.delete_outline), onPressed: _delete), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: _delete, + ), ], ), ), @@ -317,7 +348,8 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> { if (mounted) Navigator.pop(context); } on DioException catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(problemMessage(e)))); } finally { if (mounted) setState(() => _saving = false); } @@ -337,7 +369,10 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> { DropdownButtonFormField( initialValue: _kind, decoration: const InputDecoration(labelText: 'Тип правила'), - items: [for (final k in _kinds) DropdownMenuItem(value: k, child: Text(ruleKindLabel(k)))], + items: [ + for (final k in _kinds) + DropdownMenuItem(value: k, child: Text(ruleKindLabel(k))), + ], onChanged: (v) => setState(() => _kind = v!), ), DropdownButtonFormField( @@ -345,18 +380,27 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> { decoration: const InputDecoration(labelText: 'Совпадение по'), items: [ for (final t in _matchTypes) - DropdownMenuItem(value: t, child: Text(ruleMatchTypeLabel(t))), + DropdownMenuItem( + value: t, + child: Text(ruleMatchTypeLabel(t)), + ), ], onChanged: (v) => setState(() => _matchType = v!), ), TextFormField( controller: _pattern, - decoration: const InputDecoration(labelText: 'Шаблон (pattern)'), - validator: (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null, + decoration: const InputDecoration( + labelText: 'Шаблон (pattern)', + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Обязательное поле' + : null, ), TextFormField( controller: _value, - decoration: const InputDecoration(labelText: 'Значение (value)'), + decoration: const InputDecoration( + labelText: 'Значение (value)', + ), ), TextFormField( controller: _note, @@ -378,8 +422,14 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> { ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Отмена')), - FilledButton(onPressed: _saving ? null : _save, child: const Text('Сохранить')), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: const Text('Сохранить'), + ), ], ); } diff --git a/app/lib/features/tax/data/tax_api.dart b/app/lib/features/tax/data/tax_api.dart index 3941dd6..6f75264 100644 --- a/app/lib/features/tax/data/tax_api.dart +++ b/app/lib/features/tax/data/tax_api.dart @@ -41,20 +41,21 @@ class TaxRow { final String? taxableBaseRub; final String? estimatedTaxRub; - String get title => accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId'); + String get title => + accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId'); static TaxRow fromJson(Map json) => TaxRow( - accountId: asInt(json['account_id']), - accountName: asString(json['account_name']), - dividendsGrossRub: asString(json['dividends_gross_rub']), - couponsGrossRub: asString(json['coupons_gross_rub']), - taxWithheldRub: asString(json['tax_withheld_rub']), - realizedGainRub: asString(json['realized_gain_rub']), - realizedLossRub: asString(json['realized_loss_rub']), - ldvExemptRub: asString(json['ldv_exempt_rub']), - taxableBaseRub: asString(json['taxable_base_rub']), - estimatedTaxRub: asString(json['estimated_tax_rub']), - ); + accountId: asInt(json['account_id']), + accountName: asString(json['account_name']), + dividendsGrossRub: asString(json['dividends_gross_rub']), + couponsGrossRub: asString(json['coupons_gross_rub']), + taxWithheldRub: asString(json['tax_withheld_rub']), + realizedGainRub: asString(json['realized_gain_rub']), + realizedLossRub: asString(json['realized_loss_rub']), + ldvExemptRub: asString(json['ldv_exempt_rub']), + taxableBaseRub: asString(json['taxable_base_rub']), + estimatedTaxRub: asString(json['estimated_tax_rub']), + ); } class TaxSummary { @@ -83,7 +84,9 @@ class TaxSummary { final totals = asObject(json['totals']); return TaxSummary( year: asInt(json['year']) ?? DateTime.now().year, - estimated: json.containsKey('estimated') ? asBool(json['estimated']) : true, + estimated: json.containsKey('estimated') + ? asBool(json['estimated']) + : true, taxRate: asString(json['tax_rate']), accounts: asObjects(json['accounts']).map(TaxRow.fromJson).toList(), totals: totals == null ? null : TaxRow.fromJson(totals), @@ -128,23 +131,24 @@ class TaxLot { /// horizon at which a person can still decide to wait. bool get nearLdv => !ldvEligible && daysToLdv != null && daysToLdv! <= 183; - String get title => ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId'); + String get title => + ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId'); static TaxLot fromJson(Map json) => TaxLot( - lotId: asInt(json['lot_id']) ?? 0, - instrumentId: asInt(json['instrument_id']), - ticker: asString(json['ticker']), - accountId: asInt(json['account_id']), - openDate: asDate(json['open_date']), - qtyRemaining: asString(json['qty_remaining']), - costRub: asString(json['cost_rub']), - marketValueRub: asString(json['market_value_rub']), - unrealizedGainRub: asString(json['unrealized_gain_rub']), - ldvEligible: asBool(json['ldv_eligible']), - ldvDate: asDate(json['ldv_date']), - daysToLdv: asInt(json['days_to_ldv']), - taxIfSoldNowRub: asString(json['tax_if_sold_now_rub']), - ); + lotId: asInt(json['lot_id']) ?? 0, + instrumentId: asInt(json['instrument_id']), + ticker: asString(json['ticker']), + accountId: asInt(json['account_id']), + openDate: asDate(json['open_date']), + qtyRemaining: asString(json['qty_remaining']), + costRub: asString(json['cost_rub']), + marketValueRub: asString(json['market_value_rub']), + unrealizedGainRub: asString(json['unrealized_gain_rub']), + ldvEligible: asBool(json['ldv_eligible']), + ldvDate: asDate(json['ldv_date']), + daysToLdv: asInt(json['days_to_ldv']), + taxIfSoldNowRub: asString(json['tax_if_sold_now_rub']), + ); } class TaxApi { @@ -156,12 +160,18 @@ class TaxApi { /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached` directly /// (there is no generated `Response` for the screen to unwrap `r.cached` from). - Future> summary({required int year, int? accountId}) async { + Future> summary({ + required int year, + int? accountId, + }) async { final r = await _dio.get>( _base, queryParameters: {'year': year, 'account_id': ?accountId}, ); - return Cached(TaxSummary.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?); + return Cached( + TaxSummary.fromJson(r.data ?? const {}), + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); } Future>> lots({required int year, int? accountId}) async { @@ -169,7 +179,9 @@ class TaxApi { '$_base/lots', queryParameters: {'year': year, 'account_id': ?accountId}, ); - final lots = asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList(); + final lots = asObjects((r.data ?? const {})['lots']) + .map(TaxLot.fromJson) + .toList(); return Cached(lots, fetchedAt: r.extra['fetchedAt'] as DateTime?); } } diff --git a/app/lib/features/tax/lots_tab.dart b/app/lib/features/tax/lots_tab.dart index 3c8b435..77e6400 100644 --- a/app/lib/features/tax/lots_tab.dart +++ b/app/lib/features/tax/lots_tab.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; + +import '../../core/widgets/help_tip.dart'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -67,9 +70,12 @@ class _TaxLotsTabState extends ConsumerState { color: Theme.of(context).colorScheme.tertiaryContainer, child: ListTile( leading: const Icon(Icons.schedule), - title: Text('До ЛДВ меньше полугода: ${near.length} лот(ов)'), + title: Text( + 'До ЛДВ меньше полугода: ${near.length} лот(ов)', + ), subtitle: const Text( - 'Продажа до этой даты облагается налогом на весь прирост.'), + 'Продажа до этой даты облагается налогом на весь прирост.', + ), trailing: FilterChip( label: const Text('только они'), selected: _onlyNearLdv, @@ -80,7 +86,8 @@ class _TaxLotsTabState extends ConsumerState { const SizedBox(height: 12), SectionCard( title: 'Открытые лоты', - subtitle: 'налог при продаже сегодня — оценка по ставке из сводки', + subtitle: + 'налог при продаже сегодня — оценка по ставке из сводки', child: _LotsTable(rows: rows), ), ], @@ -104,71 +111,144 @@ class _LotsTable extends StatelessWidget { scrollDirection: Axis.horizontal, child: DataTable( columns: [ - DataColumn(label: Text('Бумага', style: headerStyle)), - DataColumn(label: Text('Куплен', style: headerStyle)), - DataColumn(label: Text('Кол-во', style: headerStyle), numeric: true), - DataColumn(label: Text('Стоимость', style: headerStyle), numeric: true), - DataColumn(label: Text('Рынок', style: headerStyle), numeric: true), - DataColumn(label: Text('Нереализ.', style: headerStyle), numeric: true), - DataColumn(label: Text('Дата ЛДВ', style: headerStyle)), - DataColumn(label: Text('Дней до ЛДВ', style: headerStyle), numeric: true), - DataColumn(label: Text('Налог при продаже', style: headerStyle), numeric: true), + DataColumn(label: TermLabel('Бумага', style: headerStyle)), + DataColumn(label: TermLabel('Куплен', style: headerStyle)), + DataColumn( + label: TermLabel( + 'Кол-во', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'Стоимость', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'Рынок', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'Нереализ.', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn(label: TermLabel('Дата ЛДВ', style: headerStyle)), + DataColumn( + label: TermLabel( + 'Дней до ЛДВ', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'Налог при продаже', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), ], rows: [ for (final l in rows) DataRow( color: l.nearLdv ? WidgetStatePropertyAll( - theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5)) + theme.colorScheme.tertiaryContainer.withValues( + alpha: 0.5, + ), + ) : null, onSelectChanged: l.instrumentId == null ? null - : (_) => context.push('/portfolio/instrument/${l.instrumentId}'), + : (_) => + context.push('/portfolio/instrument/${l.instrumentId}'), cells: [ - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l.title), - if (l.nearLdv) ...[ - const SizedBox(width: 6), - Tooltip( - message: 'До ЛДВ осталось ${l.daysToLdv} дн. — ' - 'продажа сейчас облагается налогом полностью', - child: Icon(Icons.schedule, size: 16, color: theme.colorScheme.error), - ), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l.title), + if (l.nearLdv) ...[ + const SizedBox(width: 6), + Tooltip( + message: + 'До ЛДВ осталось ${l.daysToLdv} дн. — ' + 'продажа сейчас облагается налогом полностью', + child: Icon( + Icons.schedule, + size: 16, + color: theme.colorScheme.error, + ), + ), + ], ], - ], - )), + ), + ), DataCell(Text(l.openDate == null ? '—' : ruDate(l.openDate!))), - DataCell(Text(l.qtyRemaining == null ? '—' : formatQty(l.qtyRemaining!))), - DataCell(l.costRub == null - ? const Text('—') - : MoneyText(l.costRub!, currency: 'RUB')), - DataCell(l.marketValueRub == null - ? const Text('—') - : MoneyText(l.marketValueRub!, currency: 'RUB')), - DataCell(l.unrealizedGainRub == null - ? const Text('—') - : MoneyText( - l.unrealizedGainRub!, - currency: 'RUB', - style: TextStyle(color: signColor(context, l.unrealizedGainRub)), - )), - DataCell(l.ldvEligible - ? const Text('уже действует') - : Text(l.ldvDate == null ? '—' : ruDate(l.ldvDate!))), - DataCell(l.ldvEligible - ? const Text('—') - : Text( - l.daysToLdv == null ? '—' : '${l.daysToLdv}', - style: l.nearLdv - ? TextStyle( - color: theme.colorScheme.error, fontWeight: FontWeight.w600) - : null, - )), - DataCell(l.taxIfSoldNowRub == null - ? const Text('—') - : MoneyText(l.taxIfSoldNowRub!, currency: 'RUB')), + DataCell( + Text( + l.qtyRemaining == null ? '—' : formatQty(l.qtyRemaining!), + ), + ), + DataCell( + l.costRub == null + ? const Text('—') + : MoneyText(l.costRub!, currency: 'RUB'), + ), + DataCell( + l.marketValueRub == null + ? const Text('—') + : MoneyText(l.marketValueRub!, currency: 'RUB'), + ), + DataCell( + l.unrealizedGainRub == null + ? const Text('—') + : MoneyText( + l.unrealizedGainRub!, + currency: 'RUB', + style: TextStyle( + color: signColor(context, l.unrealizedGainRub), + ), + ), + ), + DataCell( + l.ldvEligible + ? const Text('уже действует') + : Text(l.ldvDate == null ? '—' : ruDate(l.ldvDate!)), + ), + DataCell( + l.ldvEligible + ? const Text('—') + : Text( + l.daysToLdv == null ? '—' : '${l.daysToLdv}', + style: l.nearLdv + ? TextStyle( + color: theme.colorScheme.error, + fontWeight: FontWeight.w600, + ) + : null, + ), + ), + DataCell( + l.taxIfSoldNowRub == null + ? const Text('—') + : MoneyText(l.taxIfSoldNowRub!, currency: 'RUB'), + ), ], ), ], diff --git a/app/lib/features/tax/providers.dart b/app/lib/features/tax/providers.dart index f780f74..1822643 100644 --- a/app/lib/features/tax/providers.dart +++ b/app/lib/features/tax/providers.dart @@ -4,7 +4,9 @@ import '../../core/api/api_client.dart'; import '../../core/cache/cached.dart'; import 'data/tax_api.dart'; -final taxApiProvider = Provider((ref) => TaxApi(ref.watch(apiProvider).dio)); +final taxApiProvider = Provider( + (ref) => TaxApi(ref.watch(apiProvider).dio), +); final taxYearProvider = StateProvider((ref) => DateTime.now().year); @@ -12,15 +14,23 @@ final taxYearProvider = StateProvider((ref) => DateTime.now().year); final taxAccountProvider = StateProvider((ref) => null); /// See `docs/ai/offline-cache.md`. -final taxSummaryProvider = FutureProvider.autoDispose>((ref) async { - return ref.watch(taxApiProvider).summary( +final taxSummaryProvider = FutureProvider.autoDispose>(( + ref, +) async { + return ref + .watch(taxApiProvider) + .summary( year: ref.watch(taxYearProvider), accountId: ref.watch(taxAccountProvider), ); }); -final taxLotsProvider = FutureProvider.autoDispose>>((ref) async { - return ref.watch(taxApiProvider).lots( +final taxLotsProvider = FutureProvider.autoDispose>>(( + ref, +) async { + return ref + .watch(taxApiProvider) + .lots( year: ref.watch(taxYearProvider), accountId: ref.watch(taxAccountProvider), ); diff --git a/app/lib/features/tax/summary_tab.dart b/app/lib/features/tax/summary_tab.dart index bb25dbc..f68a776 100644 --- a/app/lib/features/tax/summary_tab.dart +++ b/app/lib/features/tax/summary_tab.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; + +import '../../core/widgets/help_tip.dart'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/widgets/async_value_view.dart'; @@ -30,7 +33,10 @@ class TaxSummaryTab extends ConsumerWidget { children: [ Row( children: [ - Text('${data.year} год', style: Theme.of(context).textTheme.titleLarge), + Text( + '${data.year} год', + style: Theme.of(context).textTheme.titleLarge, + ), const SizedBox(width: 12), if (data.estimated) const Chip( @@ -40,7 +46,9 @@ class TaxSummaryTab extends ConsumerWidget { ), const Spacer(), if (data.taxRate != null) - Text('ставка ${formatPercent(data.taxRate, signed: false)}'), + Text( + 'ставка ${formatPercent(data.taxRate, signed: false)}', + ), ], ), const SizedBox(height: 12), @@ -124,15 +132,72 @@ class _AccountsTable extends StatelessWidget { scrollDirection: Axis.horizontal, child: DataTable( columns: [ - DataColumn(label: Text('Счёт', style: headerStyle)), - DataColumn(label: Text('Дивиденды', style: headerStyle), numeric: true), - DataColumn(label: Text('Купоны', style: headerStyle), numeric: true), - DataColumn(label: Text('Удержано', style: headerStyle), numeric: true), - DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true), - DataColumn(label: Text('Убыток', style: headerStyle), numeric: true), - DataColumn(label: Text('ЛДВ', style: headerStyle), numeric: true), - DataColumn(label: Text('База', style: headerStyle), numeric: true), - DataColumn(label: Text('Налог (оценка)', style: headerStyle), numeric: true), + DataColumn(label: TermLabel('Счёт', style: headerStyle)), + DataColumn( + label: TermLabel( + 'Дивиденды', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'Купоны', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'Удержано', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'Прибыль', + hint: 'Сумма положительных результатов по проданным бумагам за год (до вычета убытков).', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'Убыток', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'ЛДВ', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'База', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'Налог (оценка)', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), ], rows: [ for (final r in rows) _row(context, r, bold: false), @@ -149,7 +214,11 @@ class _AccountsTable extends StatelessWidget { : MoneyText( v, currency: 'RUB', - style: signed ? (style ?? const TextStyle()).copyWith(color: signColor(context, v)) : style, + style: signed + ? (style ?? const TextStyle()).copyWith( + color: signColor(context, v), + ) + : style, ); return DataRow( diff --git a/app/lib/features/tax/tax_page.dart b/app/lib/features/tax/tax_page.dart index fa1c322..9067175 100644 --- a/app/lib/features/tax/tax_page.dart +++ b/app/lib/features/tax/tax_page.dart @@ -37,7 +37,10 @@ class TaxPage extends ConsumerWidget { ), ], bottom: const TabBar( - tabs: [Tab(text: 'Сводка за год'), Tab(text: 'Лоты и ЛДВ')], + tabs: [ + Tab(text: 'Сводка за год'), + Tab(text: 'Лоты и ЛДВ'), + ], ), ), body: Column( @@ -48,7 +51,9 @@ class TaxPage extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: StaleBanner(fetchedAt: stale), ), - const Expanded(child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()])), + const Expanded( + child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()]), + ), ], ), ), @@ -75,10 +80,7 @@ class EstimateBanner extends ConsumerWidget { const Icon(Icons.info_outline, size: 18), const SizedBox(width: 8), Expanded( - child: Text( - text, - style: Theme.of(context).textTheme.bodySmall, - ), + child: Text(text, style: Theme.of(context).textTheme.bodySmall), ), ], ), diff --git a/app/lib/features/transactions/providers.dart b/app/lib/features/transactions/providers.dart index 344e5bb..93ba4e1 100644 --- a/app/lib/features/transactions/providers.dart +++ b/app/lib/features/transactions/providers.dart @@ -22,7 +22,12 @@ class TransactionsFilter { final FlowType? flowType; bool get isEmpty => - q == null && from == null && to == null && accountId == null && categoryId == null && flowType == null; + q == null && + from == null && + to == null && + accountId == null && + categoryId == null && + flowType == null; TransactionsFilter copyWith({ String? Function()? q, @@ -98,7 +103,9 @@ class TransactionsController extends Notifier { return const TransactionsState(loading: true); } - Future setFilter(TransactionsFilter Function(TransactionsFilter) update) { + Future setFilter( + TransactionsFilter Function(TransactionsFilter) update, + ) { filter = update(filter); return refresh(); } @@ -106,7 +113,10 @@ class TransactionsController extends Notifier { Future refresh() async { state = state.copyWith(loading: true, clearError: true); try { - final r = await ref.read(apiProvider).getTransactionsApi().transactionsList( + final r = await ref + .read(apiProvider) + .getTransactionsApi() + .transactionsList( from: filter.from, to: filter.to, accountId: filter.accountId, @@ -133,7 +143,10 @@ class TransactionsController extends Notifier { state = state.copyWith(loadingMore: true, clearError: true); try { final next = state.page + 1; - final r = await ref.read(apiProvider).getTransactionsApi().transactionsList( + final r = await ref + .read(apiProvider) + .getTransactionsApi() + .transactionsList( from: filter.from, to: filter.to, accountId: filter.accountId, @@ -157,4 +170,6 @@ class TransactionsController extends Notifier { } final transactionsControllerProvider = - NotifierProvider(TransactionsController.new); + NotifierProvider( + TransactionsController.new, + ); diff --git a/app/lib/features/transactions/transaction_row.dart b/app/lib/features/transactions/transaction_row.dart index 048dd65..ce41c8e 100644 --- a/app/lib/features/transactions/transaction_row.dart +++ b/app/lib/features/transactions/transaction_row.dart @@ -7,12 +7,22 @@ import '../../core/widgets/money_text.dart'; /// The amount/currency/RUB-equivalent a transaction is shown with, chosen by /// its [FlowType]: the outgoing leg for expenses and transfers out, the /// incoming leg for income. -({String amount, String currency, String? rub}) primaryAmount(TransactionOut t) { +({String amount, String currency, String? rub}) primaryAmount( + TransactionOut t, +) { switch (t.flowType) { case FlowType.income: - return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub); + return ( + amount: t.income, + currency: t.incomeCurrency ?? 'RUB', + rub: t.incomeRub, + ); case FlowType.expense: - return (amount: t.outcome, currency: t.outcomeCurrency ?? 'RUB', rub: t.outcomeRub); + return ( + amount: t.outcome, + currency: t.outcomeCurrency ?? 'RUB', + rub: t.outcomeRub, + ); case FlowType.internalTransfer: case FlowType.savingsTransfer: case FlowType.brokerExternalFlow: @@ -20,22 +30,33 @@ import '../../core/widgets/money_text.dart'; case FlowType.deleted: case FlowType.unknownDefaultOpenApi: if (t.outcome != '0' && t.outcomeCurrency != null) { - return (amount: t.outcome, currency: t.outcomeCurrency!, rub: t.outcomeRub); + return ( + amount: t.outcome, + currency: t.outcomeCurrency!, + rub: t.outcomeRub, + ); } - return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub); + return ( + amount: t.income, + currency: t.incomeCurrency ?? 'RUB', + rub: t.incomeRub, + ); } } -(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) => switch (type) { +(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) => + switch (type) { FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green), FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error), FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary), - FlowType.savingsTransfer => (Icons.savings_outlined, Colors.amber.shade800), + FlowType.savingsTransfer => ( + Icons.savings_outlined, + Colors.amber.shade800, + ), FlowType.brokerExternalFlow => (Icons.trending_up, Colors.deepPurple), - FlowType.other || FlowType.deleted || FlowType.unknownDefaultOpenApi => ( - Icons.help_outline, - scheme.outline, - ), + FlowType.other || + FlowType.deleted || + FlowType.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline), }; /// One row of the Операции list: date, payee, category, native amount with @@ -59,7 +80,9 @@ class TransactionRow extends StatelessWidget { final (icon, color) = flowTypeIcon(t.flowType, scheme); final amount = primaryAmount(t); final showRub = amount.currency != 'RUB' && amount.rub != null; - final payee = t.payeeCanonical?.isNotEmpty == true ? t.payeeCanonical! : (t.payee ?? '—'); + final payee = t.payeeCanonical?.isNotEmpty == true + ? t.payeeCanonical! + : (t.payee ?? '—'); return ListTile( onTap: onTap, @@ -74,7 +97,8 @@ class TransactionRow extends StatelessWidget { if (showRub) Text( MoneyText.format(amount.rub!, 'RUB'), - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.outline), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: scheme.outline), ), ], ), diff --git a/app/lib/features/transactions/transactions_page.dart b/app/lib/features/transactions/transactions_page.dart index f5742a2..d1e590a 100644 --- a/app/lib/features/transactions/transactions_page.dart +++ b/app/lib/features/transactions/transactions_page.dart @@ -20,15 +20,15 @@ const _flowTypes = [ ]; String _flowTypeLabel(FlowType t) => switch (t) { - FlowType.income => 'Доход', - FlowType.expense => 'Расход', - FlowType.internalTransfer => 'Перевод', - FlowType.savingsTransfer => 'В сбережения', - FlowType.brokerExternalFlow => 'Брокер', - FlowType.other => 'Прочее', - FlowType.deleted => 'Удалено', - FlowType.unknownDefaultOpenApi => 'Неизвестно', - }; + FlowType.income => 'Доход', + FlowType.expense => 'Расход', + FlowType.internalTransfer => 'Перевод', + FlowType.savingsTransfer => 'В сбережения', + FlowType.brokerExternalFlow => 'Брокер', + FlowType.other => 'Прочее', + FlowType.deleted => 'Удалено', + FlowType.unknownDefaultOpenApi => 'Неизвестно', +}; /// Операции: filtered, paginated transaction list with infinite scroll and a /// detail bottom sheet per row. @@ -60,7 +60,8 @@ class _TransactionsPageState extends ConsumerState { } void _onScroll() { - if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) { + if (_scrollController.position.pixels > + _scrollController.position.maxScrollExtent - 200) { ref.read(transactionsControllerProvider.notifier).loadMore(); } } @@ -68,8 +69,11 @@ class _TransactionsPageState extends ConsumerState { void _onSearchChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 400), () { - ref.read(transactionsControllerProvider.notifier).setFilter( - (f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()), + ref + .read(transactionsControllerProvider.notifier) + .setFilter( + (f) => + f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()), ); }); } @@ -87,16 +91,18 @@ class _TransactionsPageState extends ConsumerState { helpText: 'Период', ); if (picked != null) { - ref.read(transactionsControllerProvider.notifier).setFilter( + ref + .read(transactionsControllerProvider.notifier) + .setFilter( (f) => f.copyWith(from: () => picked.start, to: () => picked.end), ); } } void _clearDateRange() { - ref.read(transactionsControllerProvider.notifier).setFilter( - (f) => f.copyWith(from: () => null, to: () => null), - ); + ref + .read(transactionsControllerProvider.notifier) + .setFilter((f) => f.copyWith(from: () => null, to: () => null)); } void _showDetail(TransactionOut t) { @@ -116,10 +122,14 @@ class _TransactionsPageState extends ConsumerState { @override Widget build(BuildContext context) { final state = ref.watch(transactionsControllerProvider); - final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter; + final controllerFilter = ref + .watch(transactionsControllerProvider.notifier) + .filter; final categoryNames = ref.watch(categoryNamesProvider); - final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const []; - final categories = ref.watch(categoriesListProvider).valueOrNull ?? const []; + final accounts = + ref.watch(accountsProvider).valueOrNull?.data ?? const []; + final categories = + ref.watch(categoriesListProvider).valueOrNull ?? const []; return Scaffold( appBar: AppBar(title: const Text('Операции')), @@ -149,26 +159,36 @@ class _TransactionsPageState extends ConsumerState { InputChip( avatar: const Icon(Icons.date_range, size: 18), label: Text( - controllerFilter.from != null && controllerFilter.to != null + controllerFilter.from != null && + controllerFilter.to != null ? '${ruDate(controllerFilter.from!)} – ${ruDate(controllerFilter.to!)}' : 'Период', ), onPressed: _pickDateRange, - onDeleted: controllerFilter.from != null ? _clearDateRange : null, + onDeleted: controllerFilter.from != null + ? _clearDateRange + : null, ), SizedBox( width: 180, child: DropdownButtonFormField( initialValue: controllerFilter.accountId, isDense: true, - decoration: const InputDecoration(labelText: 'Счёт', isDense: true), + decoration: const InputDecoration( + labelText: 'Счёт', + isDense: true, + ), items: [ - const DropdownMenuItem(value: null, child: Text('Все счета')), - for (final a in accounts) DropdownMenuItem(value: a.id, child: Text(a.name)), + const DropdownMenuItem( + value: null, + child: Text('Все счета'), + ), + for (final a in accounts) + DropdownMenuItem(value: a.id, child: Text(a.name)), ], - onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter( - (f) => f.copyWith(accountId: () => v), - ), + onChanged: (v) => ref + .read(transactionsControllerProvider.notifier) + .setFilter((f) => f.copyWith(accountId: () => v)), ), ), SizedBox( @@ -176,14 +196,21 @@ class _TransactionsPageState extends ConsumerState { child: DropdownButtonFormField( initialValue: controllerFilter.categoryId, isDense: true, - decoration: const InputDecoration(labelText: 'Категория', isDense: true), + decoration: const InputDecoration( + labelText: 'Категория', + isDense: true, + ), items: [ - const DropdownMenuItem(value: null, child: Text('Все категории')), - for (final c in categories) DropdownMenuItem(value: c.id, child: Text(c.name)), + const DropdownMenuItem( + value: null, + child: Text('Все категории'), + ), + for (final c in categories) + DropdownMenuItem(value: c.id, child: Text(c.name)), ], - onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter( - (f) => f.copyWith(categoryId: () => v), - ), + onChanged: (v) => ref + .read(transactionsControllerProvider.notifier) + .setFilter((f) => f.copyWith(categoryId: () => v)), ), ), ], @@ -195,17 +222,17 @@ class _TransactionsPageState extends ConsumerState { ChoiceChip( label: const Text('Все типы'), selected: controllerFilter.flowType == null, - onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter( - (f) => f.copyWith(flowType: () => null), - ), + onSelected: (_) => ref + .read(transactionsControllerProvider.notifier) + .setFilter((f) => f.copyWith(flowType: () => null)), ), for (final ft in _flowTypes) ChoiceChip( label: Text(_flowTypeLabel(ft)), selected: controllerFilter.flowType == ft, - onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter( - (f) => f.copyWith(flowType: () => ft), - ), + onSelected: (_) => ref + .read(transactionsControllerProvider.notifier) + .setFilter((f) => f.copyWith(flowType: () => ft)), ), ], ), @@ -230,12 +257,17 @@ class _TransactionsPageState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32), + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + size: 32, + ), const SizedBox(height: 8), Text('${state.error}', textAlign: TextAlign.center), const SizedBox(height: 12), FilledButton.tonal( - onPressed: () => ref.read(transactionsControllerProvider.notifier).refresh(), + onPressed: () => + ref.read(transactionsControllerProvider.notifier).refresh(), child: const Text('Повторить'), ), ], @@ -250,7 +282,8 @@ class _TransactionsPageState extends ConsumerState { ); } return RefreshIndicator( - onRefresh: () => ref.read(transactionsControllerProvider.notifier).refresh(), + onRefresh: () => + ref.read(transactionsControllerProvider.notifier).refresh(), child: ListView.builder( controller: _scrollController, itemCount: state.items.length + 1, @@ -263,7 +296,9 @@ class _TransactionsPageState extends ConsumerState { child: state.loadingMore ? const CircularProgressIndicator() : TextButton( - onPressed: () => ref.read(transactionsControllerProvider.notifier).loadMore(), + onPressed: () => ref + .read(transactionsControllerProvider.notifier) + .loadMore(), child: const Text('Ещё'), ), ), @@ -272,7 +307,9 @@ class _TransactionsPageState extends ConsumerState { final t = state.items[index]; return TransactionRow( transaction: t, - categoryName: t.categoryId != null ? categoryNames[t.categoryId] : null, + categoryName: t.categoryId != null + ? categoryNames[t.categoryId] + : null, onTap: () => _showDetail(t), ); }, @@ -298,24 +335,43 @@ class _TransactionDetailSheet extends StatelessWidget { final rows = <(String, String)>[ ('Дата', ruDate(t.date)), ('Плательщик', t.payee ?? '—'), - if (t.payeeCanonical != null && t.payeeCanonical != t.payee) ('Канонический', t.payeeCanonical!), + if (t.payeeCanonical != null && t.payeeCanonical != t.payee) + ('Канонический', t.payeeCanonical!), ('Комментарий', t.comment ?? '—'), - ('Категория', t.categoryId != null ? (categoryNames[t.categoryId] ?? '#${t.categoryId}') : 'Без категории'), + ( + 'Категория', + t.categoryId != null + ? (categoryNames[t.categoryId] ?? '#${t.categoryId}') + : 'Без категории', + ), ('Тип', _flowTypeLabel(t.flowType)), if (t.outcome != '0') - ('Списание', '${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}' - '${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}'), + ( + 'Списание', + '${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}' + '${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}', + ), if (t.income != '0') - ('Зачисление', '${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}' - '${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}'), + ( + 'Зачисление', + '${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}' + '${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}', + ), if (t.outcomeAccountId != null) - ('Счёт списания', accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}'), + ( + 'Счёт списания', + accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}', + ), if (t.incomeAccountId != null) - ('Счёт зачисления', accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}'), + ( + 'Счёт зачисления', + accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}', + ), if (t.mcc != null) ('MCC', '${t.mcc}'), ('Удержание (hold)', t.hold ? 'да' : 'нет'), ('Разовая трата', t.isOneOff ? 'да' : 'нет'), - if (t.tags.isNotEmpty) ('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')), + if (t.tags.isNotEmpty) + ('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')), if (t.tripId != null) ('Поездка', '#${t.tripId}'), ('Источник (id)', t.sourceId), ]; @@ -334,7 +390,13 @@ class _TransactionDetailSheet extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(width: 140, child: Text(label, style: Theme.of(context).textTheme.bodySmall)), + SizedBox( + width: 140, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ), Expanded(child: Text(value)), ], ), diff --git a/app/test/date_query_interceptor_test.dart b/app/test/date_query_interceptor_test.dart index 43e589b..5b0a477 100644 --- a/app/test/date_query_interceptor_test.dart +++ b/app/test/date_query_interceptor_test.dart @@ -33,6 +33,10 @@ void main() { test('non-date parameters are passed through untouched', () { final out = run({'page': 2, 'q': 'кофе', 'include_deleted': false}); - expect(out.queryParameters, {'page': 2, 'q': 'кофе', 'include_deleted': false}); + expect(out.queryParameters, { + 'page': 2, + 'q': 'кофе', + 'include_deleted': false, + }); }); } diff --git a/app/test/event_row_test.dart b/app/test/event_row_test.dart index 8fcaa16..5a04cbb 100644 --- a/app/test/event_row_test.dart +++ b/app/test/event_row_test.dart @@ -28,6 +28,7 @@ EventOut event({ kind: kind, price: '100', priceCurrency: currency, + source_: 'tinvest', quantity: quantity, status: status, tax: null, @@ -38,13 +39,15 @@ EventOut event({ } Widget wrap(EventOut e) => MaterialApp( - home: Scaffold( - body: EventRow(event: e, accountName: 'T-Invest ИИС', onTap: () {}), - ), - ); + home: Scaffold( + body: EventRow(event: e, accountName: 'T-Invest ИИС', onTap: () {}), + ), +); void main() { - testWidgets('shows kind, ticker, account and the native amount', (tester) async { + testWidgets('shows kind, ticker, account and the native amount', ( + tester, + ) async { await tester.pumpWidget(wrap(event())); expect(find.text('Покупка · SBER'), findsOneWidget); @@ -53,15 +56,23 @@ void main() { expect(find.text(MoneyText.format('-10000', 'RUB')), findsOneWidget); }); - testWidgets('adds the RUB equivalent only for a foreign currency', (tester) async { - await tester.pumpWidget(wrap(event(currency: 'USD', amount: '-100', amountRub: '-9500'))); + testWidgets('adds the RUB equivalent only for a foreign currency', ( + tester, + ) async { + await tester.pumpWidget( + wrap(event(currency: 'USD', amount: '-100', amountRub: '-9500')), + ); expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget); expect(find.text(MoneyText.format('-9500', 'RUB')), findsOneWidget); }); - testWidgets('leaves the RUB line out when no rate was available', (tester) async { - await tester.pumpWidget(wrap(event(currency: 'USD', amount: '-100', amountRub: null))); + testWidgets('leaves the RUB line out when no rate was available', ( + tester, + ) async { + await tester.pumpWidget( + wrap(event(currency: 'USD', amount: '-100', amountRub: null)), + ); expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget); expect(find.textContaining('₽'), findsNothing); @@ -73,9 +84,18 @@ void main() { expect(find.textContaining('Ожидает'), findsOneWidget); }); - testWidgets('drops the separator for an event with no instrument', (tester) async { + testWidgets('drops the separator for an event with no instrument', ( + tester, + ) async { await tester.pumpWidget( - wrap(event(kind: EventKind.deposit, ticker: null, quantity: null, externalFlow: true)), + wrap( + event( + kind: EventKind.deposit, + ticker: null, + quantity: null, + externalFlow: true, + ), + ), ); expect(find.text('Пополнение'), findsOneWidget); diff --git a/app/test/health_page_test.dart b/app/test/health_page_test.dart index 20e9d8f..e0185bf 100644 --- a/app/test/health_page_test.dart +++ b/app/test/health_page_test.dart @@ -1,32 +1,35 @@ import 'package:fintracker_api/fintracker_api.dart'; import 'package:fintracker_app/core/cache/cached.dart'; import 'package:fintracker_app/features/health/health_page.dart'; -import 'package:fintracker_app/features/home/providers.dart' show dataQualityProvider; +import 'package:fintracker_app/features/home/providers.dart' + show dataQualityProvider; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; SourceStatus _source(String name) => SourceStatus( - source_: name, - lastRunStatus: RunStatus.ok, - lastRunAt: DateTime(2026, 9, 18, 10), - lastSuccessAt: DateTime(2026, 9, 18, 10), - cursor: null, - queued: false, - ); + source_: name, + lastRunStatus: RunStatus.ok, + lastRunAt: DateTime(2026, 9, 18, 10), + lastSuccessAt: DateTime(2026, 9, 18, 10), + cursor: null, + queued: false, +); DataQualityRow _issue() => DataQualityRow( - id: 1, - ref: null, - severity: 'warn', - checkName: 'missing_fx', - count: 3, - detail: 'Курс не найден для 3 операций.', - computedAt: DateTime(2026, 9, 18, 10), - ); + id: 1, + ref: null, + severity: 'warn', + checkName: 'missing_fx', + count: 3, + detail: 'Курс не найден для 3 операций.', + computedAt: DateTime(2026, 9, 18, 10), +); void main() { - testWidgets('shows sources on the first tab and findings on the second', (tester) async { + testWidgets('shows sources on the first tab and findings on the second', ( + tester, + ) async { await tester.pumpWidget( ProviderScope( overrides: [ @@ -50,7 +53,9 @@ void main() { expect(find.text('Курс не найден для 3 операций.'), findsOneWidget); }); - testWidgets('opens directly on the findings tab when initialTab is 1', (tester) async { + testWidgets('opens directly on the findings tab when initialTab is 1', ( + tester, + ) async { await tester.pumpWidget( ProviderScope( overrides: [ @@ -72,7 +77,9 @@ void main() { overrides: [ syncStatusProvider.overrideWith((ref) async => const []), syncRunsProvider.overrideWith((ref) async => const []), - dataQualityProvider.overrideWith((ref) => const Cached([])), + dataQualityProvider.overrideWith( + (ref) => const Cached([]), + ), ], child: const MaterialApp(home: HealthPage(initialTab: 1)), ), diff --git a/app/test/imports_api_test.dart b/app/test/imports_api_test.dart index 83680c1..2df98ba 100644 --- a/app/test/imports_api_test.dart +++ b/app/test/imports_api_test.dart @@ -145,36 +145,45 @@ void main() { final json = Map.from(_previewJson)..['account_id'] = null; expect(ImportPreview.fromJson(json).canCommit, isFalse); - final failed = Map.from(_previewJson)..['parse_status'] = 'failed'; + final failed = Map.from(_previewJson) + ..['parse_status'] = 'failed'; expect(ImportPreview.fromJson(failed).canCommit, isFalse); expect(ImportPreview.fromJson(failed).canDelete, isTrue); - final committed = Map.from(_previewJson)..['parse_status'] = 'committed'; + final committed = Map.from(_previewJson) + ..['parse_status'] = 'committed'; expect(ImportPreview.fromJson(committed).canDelete, isFalse); }); - test('parses PendingResolveResult and builds the create body as plain strings', () { - final r = PendingResolveResult.fromJson(const { - 'id': 4, - 'status': 'resolved', - 'instrument_id': 88, - 'events_bound': 3, - 'alias_created': true, - 'metrics_refreshed': true, - }); - expect(r.eventsBound, 3); - expect(r.instrumentId, 88); + test( + 'parses PendingResolveResult and builds the create body as plain strings', + () { + final r = PendingResolveResult.fromJson(const { + 'id': 4, + 'status': 'resolved', + 'instrument_id': 88, + 'events_bound': 3, + 'alias_created': true, + 'metrics_refreshed': true, + }); + expect(r.eventsBound, 3); + expect(r.instrumentId, 88); - final body = const NewInstrument( - assetClass: 'index', - name: 'Индекс МосБиржи', - currency: 'RUB', - isin: '', - ticker: 'IMOEX', - lot: 1, - ).toJson(); - expect(body['asset_class'], 'index'); - expect(body.containsKey('isin'), isFalse, reason: 'empty optionals are omitted'); - expect(body['ticker'], 'IMOEX'); - }); + final body = const NewInstrument( + assetClass: 'index', + name: 'Индекс МосБиржи', + currency: 'RUB', + isin: '', + ticker: 'IMOEX', + lot: 1, + ).toJson(); + expect(body['asset_class'], 'index'); + expect( + body.containsKey('isin'), + isFalse, + reason: 'empty optionals are omitted', + ); + expect(body['ticker'], 'IMOEX'); + }, + ); } diff --git a/app/test/phase4_models_test.dart b/app/test/phase4_models_test.dart index 7324a66..d917214 100644 --- a/app/test/phase4_models_test.dart +++ b/app/test/phase4_models_test.dart @@ -33,7 +33,11 @@ void main() { 'tax_withheld': null, }, ], - 'by_basis': {'schedule': '8100.00', 'announced': '4130.50', 'history': '2000.00'}, + 'by_basis': { + 'schedule': '8100.00', + 'announced': '4130.50', + 'history': '2000.00', + }, }; test('parses the calendar, keeping every amount as a string', () { @@ -44,13 +48,20 @@ void main() { expect(c.entries.single.basis, 'announced'); expect(c.entries.single.ticker, 'SBER'); expect(c.entries.single.taxWithheld, isNull); - expect(c.entries.single.perUnit, '34.84', reason: 'never parsed through double'); + expect( + c.entries.single.perUnit, + '34.84', + reason: 'never parsed through double', + ); }); test('a null amount_rub stays null — no FX rate is not zero', () { final json = Map.from(calendarJson); json['entries'] = [ - {...(calendarJson['entries'] as List).first as Map, 'amount_rub': null}, + { + ...(calendarJson['entries'] as List).first as Map, + 'amount_rub': null, + }, ]; expect(IncomeCalendar.fromJson(json).entries.single.amountRub, isNull); }); @@ -82,18 +93,28 @@ void main() { { 'month': '2026-10-01', 'amount_rub': '1830.20', - 'by_basis': {'schedule': '1133.40', 'announced': '696.80', 'history': '0'}, + 'by_basis': { + 'schedule': '1133.40', + 'announced': '696.80', + 'history': '0', + }, }, ], 'total_rub': '21960.00', 'annual_yield_on_value': '0.081', - 'warnings': ['у 3 инструментов нет истории выплат — в прогноз не вошли'], + 'warnings': [ + 'у 3 инструментов нет истории выплат — в прогноз не вошли', + ], }); expect(f.months.single.byBasis['schedule'], '1133.40'); expect(f.totalRub, '21960.00'); expect(f.annualYieldOnValue, '0.081'); expect(f.warnings, hasLength(1)); - expect(f.bases, ['schedule', 'announced', 'history'], reason: 'contract order'); + expect(f.bases, [ + 'schedule', + 'announced', + 'history', + ], reason: 'contract order'); final noYield = IncomeForecast.fromJson(const { 'months': [], @@ -110,7 +131,12 @@ void main() { 'dimension': 'asset_class', 'weights_sum': '1.00', 'targets': [ - {'bucket': 'share', 'target_weight': '0.60', 'band': '0.05', 'note': null}, + { + 'bucket': 'share', + 'target_weight': '0.60', + 'band': '0.05', + 'note': null, + }, {'bucket': 'bond', 'target_weight': '0.30', 'band': '0.05'}, {'bucket': 'cash', 'target_weight': '0.10', 'band': '0.02'}, ], @@ -121,22 +147,29 @@ void main() { expect(set.targets.first.band, '0.05'); // 0.1 + 0.2 + 0.7 is exactly 1 in Decimal and would not be in double - final tenths = TargetSet(dimension: 'asset_class', targets: const [ - TargetWeight(bucket: 'a', targetWeight: '0.1'), - TargetWeight(bucket: 'b', targetWeight: '0.2'), - TargetWeight(bucket: 'c', targetWeight: '0.7'), - ]); + final tenths = TargetSet( + dimension: 'asset_class', + targets: const [ + TargetWeight(bucket: 'a', targetWeight: '0.1'), + TargetWeight(bucket: 'b', targetWeight: '0.2'), + TargetWeight(bucket: 'c', targetWeight: '0.7'), + ], + ); expect(tenths.sumIsValid, isTrue); - final short = TargetSet(dimension: 'asset_class', targets: const [ - TargetWeight(bucket: 'a', targetWeight: '0.9'), - ]); + final short = TargetSet( + dimension: 'asset_class', + targets: const [TargetWeight(bucket: 'a', targetWeight: '0.9')], + ); expect(short.sumIsValid, isFalse); }); test('the PUT body omits empty optionals and keeps shares as strings', () { - final body = const TargetWeight(bucket: 'bond', targetWeight: '0.30', band: '0.05') - .toJson(); + final body = const TargetWeight( + bucket: 'bond', + targetWeight: '0.30', + band: '0.05', + ).toJson(); expect(body['target_weight'], '0.30'); expect(body.containsKey('note'), isFalse); }); @@ -151,51 +184,54 @@ void main() { expect(formatShareAsPercent(null), '—'); }); - test('parses the rebalance plan, including within_band and blocked_by_cash', () { - final plan = RebalancePlan.fromJson(const { - 'portfolio_id': 1, - 'dimension': 'asset_class', - 'as_of': '2026-09-18', - 'total_value_rub': '1284300.00', - 'cash_available_rub': '48120.87', - 'buckets': [ - { - 'bucket': 'share', - 'current_value_rub': '812000.00', - 'current_weight': '0.632', - 'target_weight': '0.60', - 'drift': '0.032', - 'within_band': true, - 'delta_value_rub': '-41420.00', - 'trades': [ - { - 'instrument_id': 88, - 'ticker': 'SBER', - 'name': 'Сбербанк России', - 'action': 'sell', - 'suggested_qty': '150', - 'lot': 10, - 'price': '275.89', - 'price_currency': 'RUB', - 'amount_rub': '41383.50', - 'blocked_by_cash': false, - }, - ], - }, - ], - 'warnings': ['у 2 инструментов нет цены — в рекомендации не вошли'], - }); + test( + 'parses the rebalance plan, including within_band and blocked_by_cash', + () { + final plan = RebalancePlan.fromJson(const { + 'portfolio_id': 1, + 'dimension': 'asset_class', + 'as_of': '2026-09-18', + 'total_value_rub': '1284300.00', + 'cash_available_rub': '48120.87', + 'buckets': [ + { + 'bucket': 'share', + 'current_value_rub': '812000.00', + 'current_weight': '0.632', + 'target_weight': '0.60', + 'drift': '0.032', + 'within_band': true, + 'delta_value_rub': '-41420.00', + 'trades': [ + { + 'instrument_id': 88, + 'ticker': 'SBER', + 'name': 'Сбербанк России', + 'action': 'sell', + 'suggested_qty': '150', + 'lot': 10, + 'price': '275.89', + 'price_currency': 'RUB', + 'amount_rub': '41383.50', + 'blocked_by_cash': false, + }, + ], + }, + ], + 'warnings': ['у 2 инструментов нет цены — в рекомендации не вошли'], + }); - expect(plan.portfolioId, 1); - expect(plan.buckets.single.withinBand, isTrue); - expect(plan.everythingWithinBand, isTrue); - final trade = plan.buckets.single.trades.single; - expect(trade.action, 'sell'); - expect(trade.suggestedQty, '150'); - expect(trade.lot, 10); - expect(trade.blockedByCash, isFalse); - expect(plan.warnings, hasLength(1)); - }); + expect(plan.portfolioId, 1); + expect(plan.buckets.single.withinBand, isTrue); + expect(plan.everythingWithinBand, isTrue); + final trade = plan.buckets.single.trades.single; + expect(trade.action, 'sell'); + expect(trade.suggestedQty, '150'); + expect(trade.lot, 10); + expect(trade.blockedByCash, isFalse); + expect(plan.warnings, hasLength(1)); + }, + ); test('suggested_qty stays null when there is no price', () { final trade = RebalanceTrade.fromJson(const { @@ -214,49 +250,55 @@ void main() { }); group('benchmarks', () { - test('parses a period row with excess, kind and days_skipped on both sides', () { - final rows = [ - for (final r in const [ - { - 'period': '1y', - 'date_from': '2025-09-18', - 'date_to': '2026-09-18', - 'portfolio_twr': '0.184', - 'portfolio_twr_annualized': '0.184', - 'portfolio_days_skipped': 3, - 'benchmarks': [ - { - 'benchmark_id': 1, - 'code': 'MCFTR', - 'kind': 'total_return', - 'twr': '0.121', - 'twr_annualized': '0.121', - 'days_skipped': 0, - 'excess': '0.063', - }, - { - 'benchmark_id': 2, - 'code': 'IMOEX', - 'kind': 'price', - 'twr': '0.084', - 'days_skipped': 2, - 'excess': '0.100', - }, - ], - }, - ]) - BenchmarkRow.fromJson(r), - ]; + test( + 'parses a period row with excess, kind and days_skipped on both sides', + () { + final rows = [ + for (final r in const [ + { + 'period': '1y', + 'date_from': '2025-09-18', + 'date_to': '2026-09-18', + 'portfolio_twr': '0.184', + 'portfolio_twr_annualized': '0.184', + 'portfolio_days_skipped': 3, + 'benchmarks': [ + { + 'benchmark_id': 1, + 'code': 'MCFTR', + 'kind': 'total_return', + 'twr': '0.121', + 'twr_annualized': '0.121', + 'days_skipped': 0, + 'excess': '0.063', + }, + { + 'benchmark_id': 2, + 'code': 'IMOEX', + 'kind': 'price', + 'twr': '0.084', + 'days_skipped': 2, + 'excess': '0.100', + }, + ], + }, + ]) + BenchmarkRow.fromJson(r), + ]; - final row = rows.single; - expect(row.period, '1y'); - expect(row.portfolioDaysSkipped, 3); - expect(row.hasSkippedDays, isTrue); - expect(row.benchmarks.first.isPriceIndex, isFalse); - expect(row.benchmarks.last.isPriceIndex, isTrue, - reason: 'a price index must be markable'); - expect(row.benchmarks.first.excess, '0.063'); - }); + final row = rows.single; + expect(row.period, '1y'); + expect(row.portfolioDaysSkipped, 3); + expect(row.hasSkippedDays, isTrue); + expect(row.benchmarks.first.isPriceIndex, isFalse); + expect( + row.benchmarks.last.isPriceIndex, + isTrue, + reason: 'a price index must be markable', + ); + expect(row.benchmarks.first.excess, '0.063'); + }, + ); }); group('goals', () { @@ -346,7 +388,8 @@ void main() { 'dividends_gross_rub': '12400.00', 'estimated_tax_rub': '1963.00', }, - 'disclaimer': 'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.', + 'disclaimer': + 'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.', }); expect(s.year, 2026); diff --git a/app/test/rules_page_test.dart b/app/test/rules_page_test.dart index e8a48ee..a87e147 100644 --- a/app/test/rules_page_test.dart +++ b/app/test/rules_page_test.dart @@ -7,7 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('the add-rule dialog requires a non-empty pattern', (tester) async { + testWidgets('the add-rule dialog requires a non-empty pattern', ( + tester, + ) async { await tester.pumpWidget( ProviderScope( overrides: [ diff --git a/app/test/transaction_row_test.dart b/app/test/transaction_row_test.dart index 2ff6df2..3c0f13e 100644 --- a/app/test/transaction_row_test.dart +++ b/app/test/transaction_row_test.dart @@ -5,33 +5,35 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; TransactionOut _usdExpense() => TransactionOut( - categoryId: 1, - comment: null, - date: DateTime(2026, 9, 10), - deleted: false, - flowType: FlowType.expense, - hold: false, - id: 1, - income: '0', - incomeAccountId: null, - incomeCurrency: null, - incomeRub: null, - isOneOff: false, - mcc: null, - outcome: '100', - outcomeAccountId: 7, - outcomeCurrency: 'USD', - outcomeRub: '9500.00', - payee: 'Amazon.com', - payeeCanonical: 'Amazon', - sourceId: 'src-1', - tags: const [], - tripId: null, - ts: DateTime(2026, 9, 10), - ); + categoryId: 1, + comment: null, + date: DateTime(2026, 9, 10), + deleted: false, + flowType: FlowType.expense, + hold: false, + id: 1, + income: '0', + incomeAccountId: null, + incomeCurrency: null, + incomeRub: null, + isOneOff: false, + mcc: null, + outcome: '100', + outcomeAccountId: 7, + outcomeCurrency: 'USD', + outcomeRub: '9500.00', + payee: 'Amazon.com', + payeeCanonical: 'Amazon', + sourceId: 'src-1', + tags: const [], + tripId: null, + ts: DateTime(2026, 9, 10), +); void main() { - testWidgets('shows a USD amount with its RUB equivalent as secondary text', (tester) async { + testWidgets('shows a USD amount with its RUB equivalent as secondary text', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( home: Scaffold( diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 2d0027d..5ca57b3 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -3,7 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('AuthState.signedIn reflects status', () { - expect(const AuthState(AuthStatus.signedIn, accessToken: 't').signedIn, isTrue); + expect( + const AuthState(AuthStatus.signedIn, accessToken: 't').signedIn, + isTrue, + ); expect(const AuthState(AuthStatus.signedOut).signedIn, isFalse); }); }