From 62d36aa3e8d7210f21dfc0703a0144eb10705e42 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 22:14:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(app):=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=8F=D0=B7=D1=8B=D0=BA,=20=D0=B2=D0=B5=D1=80=D1=85=D0=BD?= =?UTF-8?q?=D1=8F=D1=8F=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F,=20=D0=BF=D0=BE=D1=80=D1=82=D1=84=D0=B5=D0=BB=D0=B8,=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D1=87?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=80=D1=83=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён. --- app/lib/core/glossary.dart | 149 ++++ app/lib/core/theme/app_theme.dart | 136 ++- app/lib/core/theme/chart_colors.dart | 7 +- app/lib/core/theme/theme_controller.dart | 4 +- app/lib/core/utils/json.dart | 22 +- app/lib/core/utils/ru_date.dart | 3 +- app/lib/core/widgets/asset_icon.dart | 104 +++ app/lib/core/widgets/async_value_view.dart | 18 +- app/lib/core/widgets/empty_state.dart | 6 +- app/lib/core/widgets/help_tip.dart | 85 ++ app/lib/core/widgets/money_text.dart | 12 +- app/lib/core/widgets/scope_selector.dart | 4 +- app/lib/core/widgets/section_card.dart | 35 +- app/lib/core/widgets/section_header.dart | 4 +- app/lib/core/widgets/service_mark.dart | 66 ++ app/lib/core/widgets/stale_banner.dart | 8 +- .../accounts/account_create_dialog.dart | 171 ++++ app/lib/features/accounts/accounts_page.dart | 823 +++++++++++++++--- app/lib/features/accounts/actions.dart | 63 ++ app/lib/features/accounts/providers.dart | 9 +- app/lib/features/events/event_row.dart | 7 +- app/lib/features/events/events_page.dart | 219 ++++- app/lib/features/events/labels.dart | 44 +- .../features/events/manual_event_dialog.dart | 317 +++++++ app/lib/features/events/providers.dart | 5 +- app/lib/features/home/home_page.dart | 242 +++-- app/lib/features/home/providers.dart | 155 +++- app/lib/features/home/scope_cards.dart | 194 +++++ .../features/portfolio/allocation_tab.dart | 36 +- .../features/portfolio/benchmarks_card.dart | 48 +- .../portfolio/data/benchmarks_api.dart | 44 +- app/lib/features/portfolio/holdings_tab.dart | 318 +++---- .../features/portfolio/holdings_table.dart | 466 ++++++++++ .../portfolio/instrument_edit_dialog.dart | 147 ++++ .../features/portfolio/instrument_page.dart | 216 +++-- app/lib/features/portfolio/labels.dart | 14 +- .../features/portfolio/overview_tiles.dart | 222 +++++ .../features/portfolio/portfolio_page.dart | 53 +- app/lib/features/portfolio/providers.dart | 97 ++- .../features/portfolio/scope_selector.dart | 44 + .../portfolios/portfolio_edit_dialog.dart | 130 +++ .../features/portfolios/portfolios_page.dart | 197 +++++ app/lib/features/portfolios/providers.dart | 21 + app/lib/features/shell/analytics_page.dart | 63 +- app/lib/features/shell/analytics_tabs.dart | 101 +++ app/lib/features/shell/app_shell.dart | 63 +- app/lib/features/shell/nav_destinations.dart | 130 ++- app/lib/features/shell/nav_sidebar.dart | 199 ----- app/lib/features/shell/top_nav.dart | 357 ++++++++ app/lib/router.dart | 29 +- app/test/account_create_dialog_test.dart | 56 ++ app/test/accounts_page_test.dart | 258 ++++++ app/test/app_shell_test.dart | 181 ++-- app/test/core/widgets/asset_icon_test.dart | 60 ++ app/test/help_tip_test.dart | 121 +++ app/test/home_page_test.dart | 98 ++- app/test/instrument_edit_dialog_test.dart | 109 +++ app/test/manual_event_dialog_test.dart | 107 +++ app/test/metrics_refresh_test.dart | 61 ++ app/test/portfolio_test.dart | 143 +-- app/test/portfolios_page_test.dart | 95 ++ app/test/scope_cards_test.dart | 112 +++ app/test/service_mark_test.dart | 82 ++ app/web/favicon.png | Bin 917 -> 984 bytes app/web/favicon.svg | 10 + app/web/icon.svg | 14 + app/web/icons/Icon-192.png | Bin 5292 -> 8295 bytes app/web/icons/Icon-512.png | Bin 8252 -> 31175 bytes app/web/icons/Icon-maskable-192.png | Bin 5594 -> 3998 bytes app/web/icons/Icon-maskable-512.png | Bin 20998 -> 16273 bytes app/web/index.html | 1 + app/web/manifest.json | 4 +- docs/ai/design-system.md | 179 ++-- 73 files changed, 6406 insertions(+), 1192 deletions(-) create mode 100644 app/lib/core/glossary.dart create mode 100644 app/lib/core/widgets/asset_icon.dart create mode 100644 app/lib/core/widgets/help_tip.dart create mode 100644 app/lib/core/widgets/service_mark.dart create mode 100644 app/lib/features/accounts/account_create_dialog.dart create mode 100644 app/lib/features/accounts/actions.dart create mode 100644 app/lib/features/events/manual_event_dialog.dart create mode 100644 app/lib/features/home/scope_cards.dart create mode 100644 app/lib/features/portfolio/holdings_table.dart create mode 100644 app/lib/features/portfolio/instrument_edit_dialog.dart create mode 100644 app/lib/features/portfolio/overview_tiles.dart create mode 100644 app/lib/features/portfolio/scope_selector.dart create mode 100644 app/lib/features/portfolios/portfolio_edit_dialog.dart create mode 100644 app/lib/features/portfolios/portfolios_page.dart create mode 100644 app/lib/features/portfolios/providers.dart create mode 100644 app/lib/features/shell/analytics_tabs.dart delete mode 100644 app/lib/features/shell/nav_sidebar.dart create mode 100644 app/lib/features/shell/top_nav.dart create mode 100644 app/test/account_create_dialog_test.dart create mode 100644 app/test/accounts_page_test.dart create mode 100644 app/test/core/widgets/asset_icon_test.dart create mode 100644 app/test/help_tip_test.dart create mode 100644 app/test/instrument_edit_dialog_test.dart create mode 100644 app/test/manual_event_dialog_test.dart create mode 100644 app/test/metrics_refresh_test.dart create mode 100644 app/test/portfolios_page_test.dart create mode 100644 app/test/scope_cards_test.dart create mode 100644 app/test/service_mark_test.dart create mode 100644 app/web/favicon.svg create mode 100644 app/web/icon.svg diff --git a/app/lib/core/glossary.dart b/app/lib/core/glossary.dart new file mode 100644 index 0000000..5da5f0e --- /dev/null +++ b/app/lib/core/glossary.dart @@ -0,0 +1,149 @@ +/// Plain-language explanations of the terms and abbreviations the screens print, looked up by +/// the label as it appears. One place, so a term means the same on every screen and a wording +/// fix is a one-line change. `TermLabel` (`widgets/help_tip.dart`) calls [glossaryHint] and +/// draws a «?» next to any label that has an entry. +library; + +const _entries = { + // returns + 'xirr': + 'XIRR — годовая доходность с учётом дат и сумм ваших пополнений и выводов ' + '(денежно-взвешенная): сколько вы заработали на своих деньгах в пересчёте на год. ' + 'На очень коротких сроках может быть недоступна или сильно завышена.', + 'xirr, год': + 'XIRR за последние 12 месяцев — годовая доходность с учётом дат и сумм ваших ' + 'пополнений и выводов.', + 'twr': + 'TWR — доходность самого портфеля без влияния пополнений и выводов: дневные ' + 'доходности перемножаются. Подходит, чтобы честно сравнивать с индексом.', + 'twr, год': + 'TWR за последние 12 месяцев — доходность портфеля без влияния пополнений и ' + 'выводов. «Пропущено N дн.» — дни, когда часть бумаг была без цены.', + 'доходность': + 'Доходность — XIRR: годовая доходность с учётом дат и сумм ваших пополнений ' + 'и выводов. «—» — если посчитать нечего или срок слишком короткий.', + 'прибыль': + 'Прибыль — на сколько текущая стоимость больше (или меньше) вложенного. По позиции: ' + 'рыночная стоимость минус стоимость покупки, процент — от вложенного. Пока у части бумаг ' + 'нет цены, итог показывается как «—».', + 'нереализованная': + 'Нереализованная прибыль — на сколько бумага подорожала или подешевела ' + 'относительно покупки, пока вы её не продали.', + 'нереализ.': + 'Нереализованная прибыль — на сколько бумага подорожала или подешевела ' + 'относительно покупки, пока вы её не продали.', + 'реализованная': + 'Реализованная прибыль — результат по уже проданным бумагам (правило FIFO: ' + 'первыми продаются самые ранние покупки).', + 'реализовано': + 'Реализованный результат по уже проданным бумагам (правило FIFO: первыми ' + 'продаются самые ранние покупки).', + 'за день': + 'За день — изменение результата за последний торговый день. Пополнения и выводы ' + 'в него не входят, только движение цен и выплаты.', + 'потоки': 'Внешние потоки за период: пополнения за вычетом выводов.', + // value + 'вложено': + 'Вложено — стоимость покупки (для всего портфеля — пополнения минус выводы). ' + 'Ниже — средняя цена покупки за штуку.', + 'текущая стоимость': + 'Количество × последняя цена. Ниже — цена за одну бумагу. «—» значит, ' + 'что цены нет и стоимость неизвестна.', + 'доля': 'Доля позиции в общей стоимости выбранного набора счетов.', + 'доля в портфеле': 'Доля позиции в общей стоимости выбранного набора счетов.', + 'выплаты': 'Полученные дивиденды и купоны за всё время.', + 'дивиденды': 'Полученные по бумаге дивиденды и купоны за всё время.', + 'див. доходность': + 'Полученные выплаты в процентах от вложенного в позицию за всё время ' + 'владения.', + 'пассивный доход': + 'Пассивный доход — ожидаемые дивиденды и купоны за ближайшие 12 месяцев: ' + 'по объявленным выплатам, графику купонов облигаций и (для дивидендов) по истории за два ' + 'года. Это оценка, а не обещание. Процент — от текущей стоимости.', + 'нкд': + 'НКД — накопленный купонный доход: часть очередного купона, набежавшая с даты ' + 'последней выплаты. Покупатель облигации доплачивает его продавцу.', + // capital and cash + 'капитал сегодня': + 'Капитал — всё, чем вы владеете, за вычетом долгов: деньги и сбережения на ' + 'счетах ZenMoney плюс стоимость активных брокерских счетов.', + 'ликвидные': 'Деньги, доступные сразу: карты, наличные, расчётные счета.', + 'сбережения': + 'Накопления, которые вы отложили: вклады и накопительные счета. Роль счёта ' + 'задаётся на экране «Счета».', + 'инвестиции': 'Стоимость брокерских счетов: бумаги по рыночным ценам плюс деньги на счёте.', + 'долги': 'Кредиты и долги; входят в капитал со знаком минус.', + 'запас хода (runway)': + 'Запас хода — на сколько месяцев хватит ликвидных денег и сбережений ' + 'при вашем обычном расходе: (ликвидные + сбережения) ÷ средний «обычный» расход за три ' + 'последних полных месяца, без разовых трат. Инвестиции в запас не входят.', + 'запас хода': + 'Запас хода — на сколько месяцев хватит ликвидных денег и сбережений при вашем ' + 'обычном расходе (средний «обычный» расход за три последних полных месяца).', + 'норма сбережений': + 'Норма сбережений — доля дохода, которая осталась после расходов: ' + '(доход − расход) ÷ доход за месяц.', + // events + 'внешний поток': + 'Внешний поток — событие, при котором деньги или бумаги пересекают границу ' + 'портфеля: пополнение, вывод, перевод бумаг. XIRR считается именно по внешним потокам.', + 'внешние потоки': + 'Показать только события, при которых деньги или бумаги пересекают границу ' + 'портфеля: пополнения, выводы, переводы бумаг. По ним считается XIRR.', + 'статус': + 'Подтверждено — событие учитывается в расчётах. Теневое — дубль из второстепенного ' + 'источника, нужен только для сверки. Ожидает — загружено, но ещё не записано в леджер. ' + 'Игнорируется — исключено.', + // portfolio tools + 'сравнение с бенчмарками': + 'Бенчмарк — индекс для сравнения (например, индекс Мосбиржи): ' + 'показывает, обгоняет ли ваш портфель рынок за тот же период.', + 'целевые веса': + 'Целевые веса — какую долю портфеля вы хотите держать в каждой категории. ' + 'Сумма по одному измерению должна быть 100 %.', + // accounts + 'тип': + 'Тип счёта — его роль в расчётах: ликвидные, сбережения, инвестиции или долги. От неё ' + 'зависит, в какую строку «Капитала» попадёт остаток.', + 'в капитал': + 'Входит ли остаток счёта в «Капитал» на Обзоре. Выключите для счетов-зеркал ' + '(например, ZenMoney-копия брокерского счёта): иначе одни и те же деньги посчитаются дважды.', + 'активен': + 'Отключённый счёт не учитывается ни в капитале, ни в портфелях, ни в аналитике; его ' + 'история остаётся и вернётся при включении.', + // tax + 'лдв': + 'ЛДВ — льгота долгосрочного владения (ст. 219.1 НК РФ): при владении биржевой бумагой ' + 'три полных года и более налог с прибыли по ней уменьшается. В приложении это оценка.', + 'дата лдв': 'Дата, с которой лот пробудет в портфеле три полных года и попадёт под ЛДВ.', + 'дней до лдв': 'Сколько дней осталось до трёх лет владения этим лотом (ЛДВ).', + 'освобождено по лдв': + 'Часть прибыли, освобождённая от налога по ЛДВ (три года владения ' + 'биржевой бумагой). Оценка.', + 'оценка налога': + 'Приблизительный НДФЛ по вашим сделкам — для сверки со справкой брокера. ' + 'Окончательный расчёт делает брокер как налоговый агент.', + 'налог (оценка)': + 'Приблизительный НДФЛ по вашим сделкам — для сверки со справкой брокера. ' + 'Окончательный расчёт делает брокер.', + 'налог при продаже': + 'Сколько налога примерно придётся заплатить, если продать этот лот ' + 'сегодня. Оценка.', + 'налоговая база': + 'Сумма, с которой считается налог; приложение оценивает её по вашим ' + 'сделкам. Окончательный расчёт делает брокер.', + 'база': 'Сумма, с которой считается налог; приложение оценивает её по вашим сделкам.', + 'удержано брокером': + 'Налог, который брокер уже удержал при выплате дивидендов и купонов.', + 'удержано': + 'Налог, который брокер уже удержал при выплате дивидендов и купонов.', + 'убыток': + 'Отрицательный результат по проданным бумагам; уменьшает налоговую базу.', +}; + +String _key(String label) => label.trim().toLowerCase().replaceAll('ё', 'е'); + +final _normalized = {for (final e in _entries.entries) _key(e.key): e.value}; + +/// The explanation for [label], or null when it is not a term worth explaining. +String? glossaryHint(String label) => _normalized[_key(label)]; diff --git a/app/lib/core/theme/app_theme.dart b/app/lib/core/theme/app_theme.dart index 6169dca..229faff 100644 --- a/app/lib/core/theme/app_theme.dart +++ b/app/lib/core/theme/app_theme.dart @@ -1,25 +1,70 @@ import 'package:flutter/material.dart'; -/// Design tokens for fin-tracker's visual language (`docs/ai/design-system.md`): dark -/// surfaces tinted blue-indigo rather than neutral grey, flat bordered cards instead of -/// Material's default elevation shadow, and one accent seed shared with -/// `ChartColors.slot1Blue` so UI chrome and the primary data series read as the same color. +/// Design tokens for fin-tracker's visual language (`docs/ai/design-system.md`): graphite-violet +/// surfaces, one bright sky-blue accent, flat rounded cards with no outline, filled inputs and +/// pill-shaped navigation — the look of Snowball. /// -/// Both brightnesses share this seed and this shape language — only the surface tones +/// Both brightnesses share this accent and this shape language — only the surface tones /// differ — so the dark theme is not a special case bolted onto a default light Material /// theme, and switching `themeModeProvider` never changes which widgets exist. class AppTheme { const AppTheme._(); + /// The accent: buttons, active tabs, links and the primary chart series. /// Matches `ChartColors.slot1Blue`. - static const seed = Color(0xFF2A78D6); + static const seed = Color(0xFF14AFFF); static ThemeData light() => _build(Brightness.light); static ThemeData dark() => _build(Brightness.dark); + static ColorScheme _scheme(Brightness brightness) { + final base = ColorScheme.fromSeed(seedColor: seed, brightness: brightness); + if (brightness == Brightness.light) { + return base.copyWith( + primary: const Color(0xFF0A98E6), + onPrimary: Colors.white, + surface: const Color(0xFFF2F2F7), + surfaceContainerLowest: Colors.white, + surfaceContainerLow: const Color(0xFFF7F7FB), + surfaceContainer: Colors.white, + surfaceContainerHigh: const Color(0xFFE8E8F0), + surfaceContainerHighest: const Color(0xFFDCDCE6), + outlineVariant: const Color(0xFFD8D8E2), + ); + } + return base.copyWith( + primary: seed, + onPrimary: const Color(0xFF04283B), + primaryContainer: const Color(0xFF0E4B6E), + onPrimaryContainer: const Color(0xFFD3F0FF), + surface: const Color(0xFF25252F), + surfaceContainerLowest: const Color(0xFF1F1F28), + surfaceContainerLow: const Color(0xFF2A2A35), + surfaceContainer: const Color(0xFF2F2F3C), + surfaceContainerHigh: const Color(0xFF3A3A49), + surfaceContainerHighest: const Color(0xFF474757), + onSurface: const Color(0xFFEDEDF3), + onSurfaceVariant: const Color(0xFFB4B4C3), + outline: const Color(0xFF74748A), + outlineVariant: const Color(0xFF3F3F4F), + error: const Color(0xFFF26B6B), + ); + } + static ThemeData _build(Brightness brightness) { - final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: brightness); - final outline = scheme.outlineVariant.withValues(alpha: brightness == Brightness.dark ? 0.4 : 0.7); + final scheme = _scheme(brightness); + final dark = brightness == Brightness.dark; + final outline = scheme.outlineVariant; + final radius = BorderRadius.circular(12); + final fieldRadius = BorderRadius.circular(8); + + OutlineInputBorder field([Color? color, double width = 1]) => + OutlineInputBorder( + borderRadius: fieldRadius, + borderSide: color == null + ? BorderSide.none + : BorderSide(color: color, width: width), + ); return ThemeData( useMaterial3: true, @@ -39,34 +84,95 @@ class AppTheme { clipBehavior: Clip.antiAlias, margin: EdgeInsets.zero, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: outline), + borderRadius: radius, + side: dark ? BorderSide.none : BorderSide(color: outline), ), ), dividerTheme: DividerThemeData(color: outline, space: 1, thickness: 1), + dialogTheme: DialogThemeData( + backgroundColor: scheme.surfaceContainer, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: scheme.surfaceContainer, + surfaceTintColor: Colors.transparent, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHigh, + border: field(), + enabledBorder: field(), + focusedBorder: field(scheme.primary, 1.5), + errorBorder: field(scheme.error), + focusedErrorBorder: field(scheme.error, 1.5), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: fieldRadius), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: fieldRadius), + side: BorderSide(color: outline), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: fieldRadius), + ), + ), + chipTheme: ChipThemeData( + backgroundColor: scheme.surfaceContainerHigh, + side: BorderSide.none, + shape: RoundedRectangleBorder(borderRadius: fieldRadius), + ), + tabBarTheme: TabBarThemeData( + indicatorColor: scheme.primary, + labelColor: scheme.onSurface, + unselectedLabelColor: scheme.onSurfaceVariant, + dividerColor: outline, + labelStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + popupMenuTheme: PopupMenuThemeData( + color: scheme.surfaceContainerHigh, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: radius), + ), navigationRailTheme: NavigationRailThemeData( backgroundColor: scheme.surfaceContainerLow, indicatorColor: scheme.primaryContainer, useIndicator: true, selectedIconTheme: IconThemeData(color: scheme.primary), unselectedIconTheme: IconThemeData(color: scheme.onSurfaceVariant), - selectedLabelTextStyle: TextStyle(color: scheme.primary, fontWeight: FontWeight.w600), + selectedLabelTextStyle: TextStyle( + color: scheme.primary, + fontWeight: FontWeight.w600, + ), unselectedLabelTextStyle: TextStyle(color: scheme.onSurfaceVariant), ), navigationBarTheme: NavigationBarThemeData( - backgroundColor: scheme.surfaceContainerLow, + backgroundColor: scheme.surfaceContainerLowest, indicatorColor: scheme.primaryContainer, elevation: 0, iconTheme: WidgetStateProperty.resolveWith( (states) => IconThemeData( - color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant, + color: states.contains(WidgetState.selected) + ? scheme.primary + : scheme.onSurfaceVariant, ), ), labelTextStyle: WidgetStateProperty.resolveWith( (states) => TextStyle( fontSize: 12, - fontWeight: states.contains(WidgetState.selected) ? FontWeight.w600 : FontWeight.w400, - color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant, + fontWeight: states.contains(WidgetState.selected) + ? FontWeight.w600 + : FontWeight.w400, + color: states.contains(WidgetState.selected) + ? scheme.primary + : scheme.onSurfaceVariant, ), ), ), diff --git a/app/lib/core/theme/chart_colors.dart b/app/lib/core/theme/chart_colors.dart index a55ef20..9af8136 100644 --- a/app/lib/core/theme/chart_colors.dart +++ b/app/lib/core/theme/chart_colors.dart @@ -6,12 +6,17 @@ import 'package:flutter/material.dart'; class ChartColors { const ChartColors._(); - static const slot1Blue = Color(0xFF2A78D6); + static const slot1Blue = Color(0xFF14AFFF); static const slot2Orange = Color(0xFFEB6834); static const slot3Aqua = Color(0xFF1BAF7A); static const slot4Yellow = Color(0xFFEDA100); static const slot5Magenta = Color(0xFFE87BA4); + /// Gain and loss, everywhere a number is signed (`signColor`): mint and coral, readable on + /// both the graphite and the light surfaces. + static const gain = Color(0xFF3DDC97); + static const loss = Color(0xFFF26B6B); + /// This app's fixed assignment for cashflow charts. static const income = slot1Blue; static const expense = slot2Orange; diff --git a/app/lib/core/theme/theme_controller.dart b/app/lib/core/theme/theme_controller.dart index caa845f..6afdadb 100644 --- a/app/lib/core/theme/theme_controller.dart +++ b/app/lib/core/theme/theme_controller.dart @@ -4,7 +4,9 @@ import 'package:shared_preferences/shared_preferences.dart'; const _prefsKey = 'theme_mode'; -final themeModeProvider = NotifierProvider(ThemeModeController.new); +final themeModeProvider = NotifierProvider( + ThemeModeController.new, +); /// Persists the chosen [ThemeMode] to this device via `shared_preferences`. class ThemeModeController extends Notifier { diff --git a/app/lib/core/utils/json.dart b/app/lib/core/utils/json.dart index 970fd7d..7b69c4e 100644 --- a/app/lib/core/utils/json.dart +++ b/app/lib/core/utils/json.dart @@ -15,14 +15,15 @@ import 'package:dio/dio.dart'; import '../auth/auth_controller.dart' show problemMessage; -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, +}; bool asBool(Object? v) => v == true; @@ -53,7 +54,8 @@ List> asObjects(Object? v) => v is List ? v.whereType().map((e) => Map.from(e)).toList() : const []; -Map? asObject(Object? v) => v is Map ? Map.from(v) : null; +Map? asObject(Object? v) => + v is Map ? Map.from(v) : null; List asStrings(Object? v) => v is List ? v.map((e) => e.toString()).toList() : const []; @@ -74,9 +76,9 @@ Decimal? asDecimal(Object? v) { /// Sum of decimal strings, exact — used for weight sums, where 0.1 + 0.2 in `double` /// would make a valid set look invalid. Decimal sumDecimals(Iterable values) => values.fold( - Decimal.zero, - (acc, v) => acc + (Decimal.tryParse(v) ?? Decimal.zero), - ); + Decimal.zero, + (acc, v) => acc + (Decimal.tryParse(v) ?? Decimal.zero), +); /// RFC 7807 `detail` first, then a readable fallback. Never surfaces a raw [DioException]. String apiErrorMessage(Object error) { diff --git a/app/lib/core/utils/ru_date.dart b/app/lib/core/utils/ru_date.dart index b9cf46f..1e4539a 100644 --- a/app/lib/core/utils/ru_date.dart +++ b/app/lib/core/utils/ru_date.dart @@ -43,7 +43,8 @@ String ruMonthYear(DateTime d) { String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}'; /// `'2026-09'`, the `month` query parameter format the API expects. -String monthKey(DateTime d) => '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}'; +String monthKey(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}'; /// Parses a `'YYYY-MM'` key back into a [DateTime] (first of month, UTC). DateTime parseMonthKey(String key) { diff --git a/app/lib/core/widgets/asset_icon.dart b/app/lib/core/widgets/asset_icon.dart new file mode 100644 index 0000000..0a523d9 --- /dev/null +++ b/app/lib/core/widgets/asset_icon.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +/// An asset's picture: the issuer's logo from the broker, and while it loads or when the +/// broker has none, a circle in the brand colour with the icon of the asset class. +/// +/// The logo URL and colour come from the API as they are; a failed load (offline, a logo +/// the CDN has since dropped) quietly falls back rather than showing a broken image. +class AssetIcon extends StatelessWidget { + const AssetIcon({ + super.key, + required this.assetClass, + this.logoUrl, + this.logoColor, + this.size = 32, + }); + + final String? assetClass; + final String? logoUrl; + final String? logoColor; + final double size; + + @override + Widget build(BuildContext context) { + final url = logoUrl; + final fallback = _Fallback( + assetClass: assetClass, + logoColor: logoColor, + size: size, + ); + return SizedBox.square( + dimension: size, + child: ClipOval( + child: url == null + ? fallback + : Image.network( + url, + width: size, + height: size, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + gaplessPlayback: true, + errorBuilder: (_, _, _) => fallback, + loadingBuilder: (_, child, progress) => + progress == null ? child : fallback, + ), + ), + ); + } +} + +class _Fallback extends StatelessWidget { + const _Fallback({ + required this.assetClass, + required this.logoColor, + required this.size, + }); + + final String? assetClass; + final String? logoColor; + final double size; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final brand = parseBrandColor(logoColor); + final background = brand ?? scheme.surfaceContainerHigh; + final foreground = brand == null + ? scheme.onSurfaceVariant + : (ThemeData.estimateBrightnessForColor(brand) == Brightness.dark + ? Colors.white + : Colors.black87); + return ColoredBox( + color: background, + child: Icon( + assetClassIcon(assetClass), + size: size * 0.55, + color: foreground, + ), + ); + } +} + +/// `#21A038` (or `#AA21A038`) as a colour; null for anything else, so a malformed value +/// from the broker degrades to the neutral fallback instead of throwing. +Color? parseBrandColor(String? hex) { + if (hex == null) return null; + final digits = hex.startsWith('#') ? hex.substring(1) : hex; + if (digits.length != 6 && digits.length != 8) return null; + final value = int.tryParse(digits, radix: 16); + if (value == null) return null; + return Color(digits.length == 6 ? 0xFF000000 | value : value); +} + +IconData assetClassIcon(String? assetClass) => switch (assetClass) { + 'share' => Icons.trending_up, + 'bond' => Icons.receipt_long_outlined, + 'etf' || 'fund' => Icons.pie_chart_outline, + 'currency' => Icons.currency_exchange, + 'index' => Icons.show_chart, + 'deposit' => Icons.savings_outlined, + 'real_estate' => Icons.home_outlined, + 'crypto' => Icons.currency_bitcoin, + _ => Icons.circle_outlined, +}; diff --git a/app/lib/core/widgets/async_value_view.dart b/app/lib/core/widgets/async_value_view.dart index 4de54c9..1dcf388 100644 --- a/app/lib/core/widgets/async_value_view.dart +++ b/app/lib/core/widgets/async_value_view.dart @@ -8,7 +8,12 @@ import '../auth/auth_controller.dart'; /// a retry-capable error view otherwise. Keeps the loading/error boilerplate /// out of every screen that watches a provider. class AsyncValueView extends StatelessWidget { - const AsyncValueView({required this.value, required this.data, this.onRetry, super.key}); + const AsyncValueView({ + required this.value, + required this.data, + this.onRetry, + super.key, + }); final AsyncValue value; final Widget Function(T data) data; @@ -30,12 +35,19 @@ class AsyncValueView extends StatelessWidget { 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(_message(error), textAlign: TextAlign.center), if (onRetry != null) ...[ const SizedBox(height: 12), - FilledButton.tonal(onPressed: onRetry, child: const Text('Повторить')), + FilledButton.tonal( + onPressed: onRetry, + child: const Text('Повторить'), + ), ], ], ), diff --git a/app/lib/core/widgets/empty_state.dart b/app/lib/core/widgets/empty_state.dart index a637858..5e444b2 100644 --- a/app/lib/core/widgets/empty_state.dart +++ b/app/lib/core/widgets/empty_state.dart @@ -2,7 +2,11 @@ import 'package:flutter/material.dart'; /// A centered placeholder for a screen or list section with nothing to show yet. class EmptyState extends StatelessWidget { - const EmptyState({required this.message, this.icon = Icons.inbox_outlined, super.key}); + const EmptyState({ + required this.message, + this.icon = Icons.inbox_outlined, + super.key, + }); final String message; final IconData icon; diff --git a/app/lib/core/widgets/help_tip.dart b/app/lib/core/widgets/help_tip.dart new file mode 100644 index 0000000..353d115 --- /dev/null +++ b/app/lib/core/widgets/help_tip.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import '../glossary.dart'; + +/// A small «?» that explains itself: hover on desktop and web, tap on a phone. Deliberately +/// quiet — a muted outline icon, not a badge — because it sits next to text people read all day. +class HelpTip extends StatelessWidget { + const HelpTip(this.message, {this.size = 15, super.key}); + + final String message; + final double size; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Tooltip( + message: message, + triggerMode: TooltipTriggerMode.tap, + waitDuration: const Duration(milliseconds: 150), + showDuration: const Duration(seconds: 12), + constraints: const BoxConstraints(maxWidth: 340), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + margin: const EdgeInsets.all(12), + textStyle: const TextStyle( + fontSize: 13, + height: 1.35, + color: Colors.white, + ), + decoration: BoxDecoration( + color: const Color(0xEB16161C), + borderRadius: BorderRadius.circular(8), + ), + child: MouseRegion( + cursor: SystemMouseCursors.help, + child: Padding( + padding: const EdgeInsets.all(3), + child: Icon( + Icons.help_outline, + size: size, + color: scheme.onSurfaceVariant.withValues(alpha: 0.75), + ), + ), + ), + ); + } +} + +/// A label that carries its own explanation: [text], then a [HelpTip] when the glossary knows the +/// term (or when [hint] is given, which wins — the same word can mean two things on two screens). +/// A label with no entry is a plain [Text], so it is safe to use for any caption. +class TermLabel extends StatelessWidget { + const TermLabel( + this.text, { + this.hint, + this.style, + this.alignment = MainAxisAlignment.start, + this.textAlign, + super.key, + }); + + final String text; + final String? hint; + final TextStyle? style; + + /// Where the label sits in its box; numeric table headers pass [MainAxisAlignment.end]. + final MainAxisAlignment alignment; + final TextAlign? textAlign; + + @override + Widget build(BuildContext context) { + final message = hint ?? glossaryHint(text); + if (message == null) return Text(text, style: style, textAlign: textAlign); + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: alignment, + children: [ + Flexible( + child: Text(text, style: style, textAlign: textAlign), + ), + const SizedBox(width: 2), + HelpTip(message, size: (style?.fontSize ?? 14) + 1), + ], + ); + } +} diff --git a/app/lib/core/widgets/money_text.dart b/app/lib/core/widgets/money_text.dart index 5001c62..b6e9a72 100644 --- a/app/lib/core/widgets/money_text.dart +++ b/app/lib/core/widgets/money_text.dart @@ -4,11 +4,7 @@ import 'package:intl/intl.dart'; /// Currency symbols for the instruments we expect to see. Falls back to the /// ISO code itself (e.g. 'USDT') when we don't have a nicer glyph. -const _currencySymbols = { - 'RUB': '₽', - 'USD': '\$', - 'EUR': '€', -}; +const _currencySymbols = {'RUB': '₽', 'USD': '\$', 'EUR': '€'}; /// Formats a decimal-string amount (as the API sends it, to avoid double /// rounding errors) with `intl`'s ru_RU rules and a currency symbol. @@ -23,7 +19,11 @@ class MoneyText extends StatelessWidget { static String format(String amount, String currency) { final value = Decimal.parse(amount).toDouble(); final symbol = _currencySymbols[currency] ?? currency; - final formatter = NumberFormat.currency(locale: 'ru_RU', symbol: symbol, decimalDigits: 2); + final formatter = NumberFormat.currency( + locale: 'ru_RU', + symbol: symbol, + decimalDigits: 2, + ); return formatter.format(value); } diff --git a/app/lib/core/widgets/scope_selector.dart b/app/lib/core/widgets/scope_selector.dart index e57e98d..6d56c1d 100644 --- a/app/lib/core/widgets/scope_selector.dart +++ b/app/lib/core/widgets/scope_selector.dart @@ -23,7 +23,9 @@ class ScopeSelector extends ConsumerWidget { value: scopes, data: (rows) { if (rows.length < 2) return const SizedBox.shrink(); - final known = rows.any((s) => s.scope == current) ? current : rows.first.scope; + final known = rows.any((s) => s.scope == current) + ? current + : rows.first.scope; return DropdownButtonHideUnderline( child: DropdownButton( value: known, diff --git a/app/lib/core/widgets/section_card.dart b/app/lib/core/widgets/section_card.dart index dd08c77..1564a03 100644 --- a/app/lib/core/widgets/section_card.dart +++ b/app/lib/core/widgets/section_card.dart @@ -1,9 +1,17 @@ import 'package:flutter/material.dart'; +import 'help_tip.dart'; + /// A titled card section, the layout unit the phase-4 screens are built from (the same /// shape Портфель already uses inline). class SectionCard extends StatelessWidget { - const SectionCard({required this.title, required this.child, this.subtitle, this.trailing, super.key}); + const SectionCard({ + required this.title, + required this.child, + this.subtitle, + this.trailing, + super.key, + }); final String title; final String? subtitle; @@ -25,13 +33,15 @@ class SectionCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: theme.textTheme.titleMedium), + TermLabel(title, style: theme.textTheme.titleMedium), if (subtitle != null) Padding( padding: const EdgeInsets.only(top: 2), child: Text( subtitle!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), ), ), ], @@ -51,7 +61,13 @@ class SectionCard extends StatelessWidget { /// A compact labelled number, used for the summary rows of the phase-4 screens. class StatTile extends StatelessWidget { - const StatTile({required this.label, required this.value, this.note, this.width = 184, super.key}); + const StatTile({ + required this.label, + required this.value, + this.note, + this.width = 184, + super.key, + }); final String label; final Widget value; @@ -69,14 +85,19 @@ class StatTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: theme.textTheme.bodySmall), + TermLabel(label, style: theme.textTheme.bodySmall), const SizedBox(height: 4), - DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value), + DefaultTextStyle( + style: theme.textTheme.titleMedium!, + child: value, + ), if (note != null) ...[ const SizedBox(height: 2), Text( note!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), maxLines: 3, ), ], diff --git a/app/lib/core/widgets/section_header.dart b/app/lib/core/widgets/section_header.dart index 94e4873..86981a5 100644 --- a/app/lib/core/widgets/section_header.dart +++ b/app/lib/core/widgets/section_header.dart @@ -23,7 +23,9 @@ class SectionHeader extends StatelessWidget { children: [ Text( title, - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), ), const SizedBox(height: 6), Container( diff --git a/app/lib/core/widgets/service_mark.dart b/app/lib/core/widgets/service_mark.dart new file mode 100644 index 0000000..e11406f --- /dev/null +++ b/app/lib/core/widgets/service_mark.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// The service's mark: a sky-blue-to-violet disc with three rising bars. The same drawing as +/// `web/favicon.svg` (a 512 × 512 design scaled to [size]), painted natively so the top bar +/// needs neither an asset nor an SVG package. +class ServiceMark extends StatelessWidget { + const ServiceMark({this.size = 40, super.key}); + + final double size; + + /// The far end of the gradient; the near end is the app accent, [AppTheme.seed]. + static const violet = Color(0xFF7A5CFF); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: size, + child: CustomPaint(painter: _MarkPainter()), + ); + } +} + +class _MarkPainter extends CustomPainter { + // (x, y, width, height) in the 512 design grid, tallest last + static const _bars = [ + Rect.fromLTWH(132, 281, 64, 100), + Rect.fromLTWH(224, 211, 64, 170), + Rect.fromLTWH(316, 131, 64, 250), + ]; + + @override + void paint(Canvas canvas, Size size) { + final s = size.width / 512; + final disc = Offset.zero & size; + canvas.drawCircle( + disc.center, + size.width / 2, + Paint() + ..shader = const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppTheme.seed, ServiceMark.violet], + ).createShader(disc), + ); + final white = Paint()..color = Colors.white; + for (final bar in _bars) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + bar.left * s, + bar.top * s, + bar.width * s, + bar.height * s, + ), + Radius.circular(14 * s), + ), + white, + ); + } + } + + @override + bool shouldRepaint(_MarkPainter oldDelegate) => false; +} diff --git a/app/lib/core/widgets/stale_banner.dart b/app/lib/core/widgets/stale_banner.dart index ea13a01..53bb354 100644 --- a/app/lib/core/widgets/stale_banner.dart +++ b/app/lib/core/widgets/stale_banner.dart @@ -14,11 +14,15 @@ class StaleBanner extends StatelessWidget { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final at = fetchedAt.toLocal(); - final time = '${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}'; + final time = + '${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}'; return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(8)), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), child: Row( children: [ Icon(Icons.cloud_off, size: 18, color: scheme.onErrorContainer), diff --git a/app/lib/features/accounts/account_create_dialog.dart b/app/lib/features/accounts/account_create_dialog.dart new file mode 100644 index 0000000..7a7daa3 --- /dev/null +++ b/app/lib/features/accounts/account_create_dialog.dart @@ -0,0 +1,171 @@ +import 'package:dio/dio.dart'; +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import '../../core/auth/auth_controller.dart'; +import 'providers.dart'; + +/// The brokers whose accounts are created by hand: T-Invest accounts come from the sync. +const _brokerChoices = [ + (Broker.sber, 'Сбер'), + (Broker.vtb, 'ВТБ'), + (Broker.other, 'Другой (CSV)'), +]; + +/// Maps the import contract's broker key (`sber | vtb | csv`) onto a creatable broker. +Broker? brokerFromImportKey(String? key) => switch (key) { + 'sber' => Broker.sber, + 'vtb' => Broker.vtb, + 'csv' => Broker.other, + _ => null, +}; + +/// Asks for the details of a broker account and creates it. Returns the new account, or null +/// when cancelled or when the server refused (the reason is shown in the dialog). +Future showAccountCreateDialog( + BuildContext context, { + Broker? broker, + String? sourceId, + String? name, +}) => showDialog( + context: context, + builder: (_) => + _AccountCreateDialog(broker: broker, sourceId: sourceId, name: name), +); + +class _AccountCreateDialog extends ConsumerStatefulWidget { + const _AccountCreateDialog({this.broker, this.sourceId, this.name}); + + final Broker? broker; + final String? sourceId; + final String? name; + + @override + ConsumerState<_AccountCreateDialog> createState() => + _AccountCreateDialogState(); +} + +class _AccountCreateDialogState extends ConsumerState<_AccountCreateDialog> { + final _formKey = GlobalKey(); + late final _name = TextEditingController(text: widget.name ?? ''); + late final _sourceId = TextEditingController(text: widget.sourceId ?? ''); + final _currency = TextEditingController(text: 'RUB'); + late Broker _broker = widget.broker ?? Broker.sber; + bool _saving = false; + String? _error; + + @override + void dispose() { + _name.dispose(); + _sourceId.dispose(); + _currency.dispose(); + super.dispose(); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() { + _saving = true; + _error = null; + }); + try { + final r = await ref + .read(apiProvider) + .getAccountsApi() + .accountsCreate( + accountCreate: AccountCreate( + name: _name.text.trim(), + broker: _broker, + sourceId: _sourceId.text.trim(), + currency: _currency.text.trim().toUpperCase(), + ), + ); + ref.invalidate(accountsProvider); + if (mounted) Navigator.of(context).pop(r.data); + } on DioException catch (e) { + if (mounted) { + setState(() { + _saving = false; + _error = problemMessage(e); + }); + } + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Новый брокерский счёт'), + content: SizedBox( + width: 420, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _name, + decoration: const InputDecoration(labelText: 'Название'), + validator: (v) => + (v ?? '').trim().isEmpty ? 'Обязательное поле' : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _broker, + decoration: const InputDecoration(labelText: 'Брокер'), + items: [ + for (final (b, label) in _brokerChoices) + DropdownMenuItem(value: b, child: Text(label)), + ], + onChanged: (b) => setState(() => _broker = b ?? _broker), + ), + const SizedBox(height: 12), + TextFormField( + controller: _sourceId, + decoration: const InputDecoration( + labelText: 'Номер договора', + helperText: 'Как напечатан в отчёте брокера — по нему импорт находит счёт', + helperMaxLines: 2, + ), + validator: (v) => + (v ?? '').trim().isEmpty ? 'Обязательное поле' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _currency, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration(labelText: 'Валюта'), + validator: (v) => (v ?? '').trim().length == 3 + ? null + : 'Три буквы, например RUB', + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: const Text('Создать'), + ), + ], + ); + } +} diff --git a/app/lib/features/accounts/accounts_page.dart b/app/lib/features/accounts/accounts_page.dart index 15b5cbf..adb9a23 100644 --- a/app/lib/features/accounts/accounts_page.dart +++ b/app/lib/features/accounts/accounts_page.dart @@ -1,53 +1,159 @@ -import 'package:dio/dio.dart'; import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; -import '../../core/api/api_client.dart'; -import '../../core/auth/auth_controller.dart'; import '../../core/cache/cached.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/help_tip.dart'; import '../../core/widgets/money_text.dart'; import '../../core/widgets/stale_banner.dart'; +import 'account_create_dialog.dart'; +import 'actions.dart'; import 'providers.dart'; -const _roleOrder = [AccountRole.liquid, AccountRole.savings, AccountRole.investment, AccountRole.debt]; +const _roleOrder = [ + AccountRole.liquid, + AccountRole.savings, + AccountRole.investment, + AccountRole.debt, +]; String _roleLabel(AccountRole role) => switch (role) { - AccountRole.liquid => 'Ликвидные', - AccountRole.savings => 'Сбережения', - AccountRole.investment => 'Инвестиции', - AccountRole.debt => 'Долги', - AccountRole.unknownDefaultOpenApi => 'Неизвестно', - }; + AccountRole.liquid => 'Ликвидные', + AccountRole.savings => 'Сбережения', + AccountRole.investment => 'Инвестиции', + AccountRole.debt => 'Долги', + AccountRole.unknownDefaultOpenApi => 'Неизвестно', +}; -String _kindLabel(AccountKind kind) => switch (kind) { - AccountKind.zmCash => 'Наличные', - AccountKind.zmCard => 'Карта', - AccountKind.zmChecking => 'Расчётный счёт', - AccountKind.zmDeposit => 'Вклад', - AccountKind.zmLoan => 'Кредит', - AccountKind.zmEmoney => 'Электронные деньги', - AccountKind.zmDebt => 'Долг', - AccountKind.broker => 'Брокерский счёт', - AccountKind.manualAsset => 'Актив вручную', - AccountKind.unknownDefaultOpenApi => 'Неизвестно', - }; +String accountKindLabel(AccountKind kind) => switch (kind) { + AccountKind.zmCash => 'Наличные', + AccountKind.zmCard => 'Карта', + AccountKind.zmChecking => 'Расчётный счёт', + AccountKind.zmDeposit => 'Вклад', + AccountKind.zmLoan => 'Кредит', + AccountKind.zmEmoney => 'Электронные деньги', + AccountKind.zmDebt => 'Долг', + AccountKind.broker => 'Брокерский счёт', + AccountKind.manualAsset => 'Актив вручную', + AccountKind.unknownDefaultOpenApi => 'Неизвестно', +}; -/// Счета: every account grouped by role, with in-place role and -/// include-in-net-worth edits (`PATCH /accounts/{id}`). Archived accounts -/// collapse into their own section at the bottom regardless of role. -class AccountsPage extends ConsumerWidget { +/// Where an account comes from — the thing that tells a ZenMoney mirror from the broker +/// account it mirrors when both are called «ИИС». +String _sourceLabel(String source) => switch (source) { + 'zenmoney' => 'ZenMoney', + 'tinvest' => 'T-Invest', + 'report_sber' => 'Сбер, отчёты', + 'report_vtb' => 'ВТБ, отчёты', + 'csv' => 'CSV', + _ => source, +}; + +enum _Status { + all('Все'), + active('Активные'), + disabled('Отключённые'), + archived('Архивные'); + + const _Status(this.label); + final String label; + + bool matches(AccountOut a) => switch (this) { + _Status.all => true, + _Status.active => !a.archived && !a.disabled, + _Status.disabled => a.disabled, + _Status.archived => a.archived && !a.disabled, + }; +} + +/// Below this width a row is two lines instead of one. +const _wideRow = 880.0; + +/// Счета: every account in one compact list. The two switches that matter — «Активен» and +/// «В капитал» — and the account type sit on the row itself, and a selection turns them into +/// bulk actions: switching off a dozen old cards is one selection, not a dozen taps. +class AccountsPage extends ConsumerStatefulWidget { const AccountsPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _AccountsPageState(); +} + +class _AccountsPageState extends ConsumerState { + _Status _status = _Status.all; + AccountRole? _role; + String _query = ''; + final Set _selected = {}; + final Set _busy = {}; + + void _snack(String message) => + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + + Future _apply( + Iterable ids, + AccountPatch patch, { + bool clearSelection = false, + }) async { + final list = ids.toList(); + setState(() => _busy.addAll(list)); + final error = await ref.read(accountActionsProvider).patch(list, patch); + if (!mounted) return; + setState(() { + _busy.removeAll(list); + if (clearSelection) _selected.clear(); + }); + if (error != null) _snack(error); + } + + List _visible(List rows) { + final q = _query.trim().toLowerCase(); + int rank(AccountOut a) => a.archived ? 2 : (a.disabled ? 1 : 0); + final out = + [ + for (final a in rows) + if (_status.matches(a) && + (_role == null || a.role == _role) && + (q.isEmpty || + a.name.toLowerCase().contains(q) || + accountKindLabel(a.kind).toLowerCase().contains(q) || + _sourceLabel(a.source_).toLowerCase().contains(q))) + a, + ]..sort((a, b) { + final byRank = rank(a).compareTo(rank(b)); + if (byRank != 0) return byRank; + final byRole = _roleOrder + .indexOf(a.role) + .compareTo(_roleOrder.indexOf(b.role)); + return byRole != 0 ? byRole : a.name.compareTo(b.name); + }); + return out; + } + + @override + Widget build(BuildContext context) { final accounts = ref.watch(accountsProvider); final stale = oldestFetch([accounts.valueOrNull?.fetchedAt]); return Scaffold( - appBar: AppBar(title: const Text('Счета')), + appBar: AppBar( + title: const Text('Счета'), + actions: [ + IconButton( + tooltip: 'Новый брокерский счёт', + icon: const Icon(Icons.add), + onPressed: () => showAccountCreateDialog(context), + ), + IconButton( + tooltip: 'Портфели', + icon: const Icon(Icons.pie_chart_outline), + onPressed: () => context.go('/portfolios'), + ), + ], + ), body: RefreshIndicator( onRefresh: () async => ref.invalidate(accountsProvider), child: AsyncValueView( @@ -65,25 +171,107 @@ class AccountsPage extends ConsumerWidget { ], ); } - final active = rows.where((a) => !a.archived).toList(); - final archived = rows.where((a) => a.archived).toList(); - return ListView( - padding: const EdgeInsets.all(16), - children: [ - if (stale != null) StaleBanner(fetchedAt: stale), - for (final role in _roleOrder) - if (active.any((a) => a.role == role)) - _RoleSection( - title: _roleLabel(role), - accounts: active.where((a) => a.role == role).toList(), + // a selection can outlive its account (a sync archived it, a filter hid it) + _selected.retainAll({for (final a in rows) a.id}); + final visible = _visible(rows); + + return LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= _wideRow; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (stale != null) StaleBanner(fetchedAt: stale), + _Filters( + rows: rows, + status: _status, + role: _role, + onStatus: (s) => setState(() => _status = s), + onRole: (r) => setState(() => _role = r), + onQuery: (q) => setState(() => _query = q), ), - if (archived.isNotEmpty) - ExpansionTile( - title: Text('Архивные (${archived.length})'), - initiallyExpanded: false, - children: [for (final a in archived) _AccountTile(account: a)], - ), - ], + const SizedBox(height: 12), + if (_selected.isNotEmpty) ...[ + _BulkBar( + count: _selected.length, + busy: _busy.isNotEmpty, + onEnable: () => _apply( + _selected, + AccountPatch(disabled: false), + clearSelection: true, + ), + onDisable: () => _apply( + _selected, + AccountPatch(disabled: true), + clearSelection: true, + ), + onRole: (r) => _apply( + _selected, + AccountPatch(role: r), + clearSelection: true, + ), + onNetWorth: (v) => _apply( + _selected, + AccountPatch(includeInNetWorth: v), + clearSelection: true, + ), + onClear: () => setState(_selected.clear), + ), + const SizedBox(height: 12), + ], + Card( + child: visible.isEmpty + ? const Padding( + padding: EdgeInsets.all(32), + child: Center(child: Text('Ничего не найдено')), + ) + : Column( + children: [ + if (wide) + _HeaderRow( + allSelected: visible.every( + (a) => _selected.contains(a.id), + ), + onToggleAll: (on) => setState(() { + if (on) { + _selected.addAll( + visible.map((a) => a.id), + ); + } else { + _selected.removeAll( + visible.map((a) => a.id), + ); + } + }), + ), + for (final a in visible) + _AccountRow( + account: a, + wide: wide, + selected: _selected.contains(a.id), + busy: _busy.contains(a.id), + onSelect: (on) => setState(() { + if (on) { + _selected.add(a.id); + } else { + _selected.remove(a.id); + } + }), + onRole: (r) => + _apply([a.id], AccountPatch(role: r)), + onNetWorth: (v) => _apply([ + a.id, + ], AccountPatch(includeInNetWorth: v)), + onActive: (v) => _apply([ + a.id, + ], AccountPatch(disabled: !v)), + ), + ], + ), + ), + ], + ); + }, ); }, ), @@ -92,118 +280,445 @@ class AccountsPage extends ConsumerWidget { } } -class _RoleSection extends StatelessWidget { - const _RoleSection({required this.title, required this.accounts}); +class _Filters extends StatelessWidget { + const _Filters({ + required this.rows, + required this.status, + required this.role, + required this.onStatus, + required this.onRole, + required this.onQuery, + }); - final String title; - final List accounts; + final List rows; + final _Status status; + final AccountRole? role; + final ValueChanged<_Status> onStatus; + final ValueChanged onRole; + final ValueChanged onQuery; @override Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: 260, + child: TextField( + onChanged: onQuery, + decoration: const InputDecoration( + hintText: 'Найти счёт…', + isDense: true, + prefixIcon: Icon(Icons.search, size: 20), + ), + ), + ), + for (final s in _Status.values) + ChoiceChip( + label: Text('${s.label} ${rows.where(s.matches).length}'), + selected: s == status, + onSelected: (_) => onStatus(s), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final r in _roleOrder) + FilterChip( + label: Text(_roleLabel(r)), + selected: role == r, + onSelected: (on) => onRole(on ? r : null), + ), + ], + ), + ], + ); + } +} + +/// «Выбрано N» with what can be done to all of them at once. +class _BulkBar extends StatelessWidget { + const _BulkBar({ + required this.count, + required this.busy, + required this.onEnable, + required this.onDisable, + required this.onRole, + required this.onNetWorth, + required this.onClear, + }); + + final int count; + final bool busy; + final VoidCallback onEnable; + final VoidCallback onDisable; + final ValueChanged onRole; + final ValueChanged onNetWorth; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Card( + color: scheme.primary.withValues(alpha: 0.14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'Выбрано: $count', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: busy ? null : onDisable, + icon: const Icon(Icons.toggle_off_outlined), + label: const Text('Отключить'), + ), + TextButton.icon( + onPressed: busy ? null : onEnable, + icon: const Icon(Icons.toggle_on_outlined), + label: const Text('Включить'), + ), + PopupMenuButton( + enabled: !busy, + tooltip: 'Сменить тип', + onSelected: onRole, + itemBuilder: (_) => [ + for (final r in _roleOrder) + PopupMenuItem(value: r, child: Text(_roleLabel(r))), + ], + child: const _MenuLabel(icon: Icons.label_outline, text: 'Тип'), + ), + PopupMenuButton( + enabled: !busy, + tooltip: 'Учёт в капитале', + onSelected: onNetWorth, + itemBuilder: (_) => const [ + PopupMenuItem(value: true, child: Text('Учитывать в капитале')), + PopupMenuItem( + value: false, + child: Text('Не учитывать в капитале'), + ), + ], + child: const _MenuLabel( + icon: Icons.account_balance_wallet_outlined, + text: 'В капитал', + ), + ), + TextButton(onPressed: onClear, child: const Text('Снять выбор')), + ], + ), + ), + ); + } +} + +class _MenuLabel extends StatelessWidget { + const _MenuLabel({required this.icon, required this.text}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text(title, style: Theme.of(context).textTheme.titleMedium), + Icon(icon, size: 18, color: color), + const SizedBox(width: 8), + Text( + text, + style: TextStyle(color: color, fontWeight: FontWeight.w600), ), - for (final a in accounts) _AccountTile(account: a), + Icon(Icons.arrow_drop_down, size: 18, color: color), ], ), ); } } -class _AccountTile extends ConsumerStatefulWidget { - const _AccountTile({required this.account}); +const _typeWidth = 156.0; +const _amountWidth = 150.0; +const _switchWidth = 96.0; - final AccountOut account; +class _HeaderRow extends StatelessWidget { + const _HeaderRow({required this.allSelected, required this.onToggleAll}); - @override - ConsumerState<_AccountTile> createState() => _AccountTileState(); -} - -class _AccountTileState extends ConsumerState<_AccountTile> { - bool _saving = false; - - Future _patch(AccountPatch patch) async { - setState(() => _saving = true); - try { - await ref.read(apiProvider).getAccountsApi().accountsPatch( - accountId: widget.account.id, - accountPatch: patch, - ); - ref.invalidate(accountsProvider); - } on DioException catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); - } finally { - if (mounted) setState(() => _saving = false); - } - } + final bool allSelected; + final ValueChanged onToggleAll; @override Widget build(BuildContext context) { - final a = widget.account; - return Card( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(a.name, style: Theme.of(context).textTheme.titleSmall), - Text( - '${_kindLabel(a.kind)} · ${a.currency}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - MoneyText( - a.balance ?? '0', - currency: a.currency, - style: Theme.of(context).textTheme.titleMedium, - ), - ], + final scheme = Theme.of(context).colorScheme; + final style = Theme.of(context).textTheme.labelMedium + ?.copyWith(color: scheme.onSurfaceVariant); + return Container( + padding: const EdgeInsets.fromLTRB(8, 4, 16, 4), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: scheme.outlineVariant)), + ), + child: Row( + children: [ + Checkbox( + value: allSelected, + onChanged: (v) => onToggleAll(v ?? false), + ), + Expanded(child: Text('Счёт', style: style)), + SizedBox( + width: _typeWidth, + child: TermLabel('Тип', style: style), + ), + SizedBox( + width: _amountWidth, + child: Text( + 'Баланс / стоимость', + textAlign: TextAlign.end, + style: style, ), - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: DropdownButtonFormField( - initialValue: a.role, - isDense: true, - decoration: const InputDecoration(labelText: 'Роль', isDense: true), - items: [ - for (final r in _roleOrder) - DropdownMenuItem(value: r, child: Text(_roleLabel(r))), - ], - onChanged: _saving ? null : (role) { - if (role != null) _patch(AccountPatch(role: role)); - }, - ), - ), - const SizedBox(width: 12), - Column( + ), + SizedBox( + width: _switchWidth, + child: TermLabel( + 'В капитал', + style: style, + alignment: MainAxisAlignment.center, + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: _switchWidth, + child: TermLabel( + 'Активен', + style: style, + alignment: MainAxisAlignment.center, + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} + +class _AccountRow extends StatelessWidget { + const _AccountRow({ + required this.account, + required this.wide, + required this.selected, + required this.busy, + required this.onSelect, + required this.onRole, + required this.onNetWorth, + required this.onActive, + }); + + final AccountOut account; + final bool wide; + final bool selected; + final bool busy; + final ValueChanged onSelect; + final ValueChanged onRole; + final ValueChanged onNetWorth; + final ValueChanged onActive; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final a = account; + final isBroker = a.kind == AccountKind.broker; + // a broker account has no balance: what it is worth comes from the ledger valuation + final amount = isBroker ? a.valueRub : a.balance; + final dim = a.disabled || a.archived; + + final title = Opacity( + opacity: dim ? 0.55 : 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + a.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + Text( + [ + accountKindLabel(a.kind), + a.currency, + _sourceLabel(a.source_), + if (a.archived) 'в архиве', + ].join(' · '), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ); + final money = Opacity( + opacity: dim ? 0.55 : 1, + child: amount == null + ? Text('—', style: theme.textTheme.titleSmall) + : MoneyText( + amount, + currency: isBroker ? 'RUB' : a.currency, + style: theme.textTheme.titleSmall, + ), + ); + final role = _RoleChip(role: a.role, enabled: !busy, onChanged: onRole); + final netWorth = _SwitchCell( + label: 'В капитал', + value: a.includeInNetWorth, + enabled: !busy, + onChanged: onNetWorth, + showLabel: !wide, + ); + final active = _SwitchCell( + label: 'Активен', + value: !a.disabled, + enabled: !busy, + onChanged: onActive, + showLabel: !wide, + ); + + return Container( + decoration: BoxDecoration( + color: selected ? scheme.primary.withValues(alpha: 0.08) : null, + border: Border(bottom: BorderSide(color: scheme.outlineVariant)), + ), + child: InkWell( + onTap: () => onSelect(!selected), + child: Padding( + padding: EdgeInsets.fromLTRB(8, wide ? 8 : 6, 16, wide ? 8 : 6), + child: wide + ? Row( children: [ - const Text('В капитал', style: TextStyle(fontSize: 11)), - Switch( - value: a.includeInNetWorth, - onChanged: _saving - ? null - : (v) => _patch(AccountPatch(includeInNetWorth: v)), + Checkbox( + value: selected, + onChanged: (v) => onSelect(v ?? false), + ), + Expanded(child: title), + SizedBox( + width: _typeWidth, + child: Align( + alignment: Alignment.centerLeft, + child: role, + ), + ), + SizedBox( + width: _amountWidth, + child: Align( + alignment: Alignment.centerRight, + child: money, + ), + ), + SizedBox( + width: _switchWidth, + child: Center(child: netWorth), + ), + SizedBox( + width: _switchWidth, + child: Center(child: active), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Checkbox( + value: selected, + visualDensity: VisualDensity.compact, + onChanged: (v) => onSelect(v ?? false), + ), + Expanded(child: title), + money, + ], + ), + Padding( + padding: const EdgeInsets.only(left: 40), + child: Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [role, netWorth, active], + ), ), ], ), - ], + ), + ), + ); + } +} + +/// The account type as a small pill that opens the four choices — one tap and one pick +/// instead of a form-field dropdown per row. +class _RoleChip extends StatelessWidget { + const _RoleChip({ + required this.role, + required this.enabled, + required this.onChanged, + }); + + final AccountRole role; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return PopupMenuButton( + enabled: enabled, + tooltip: 'Тип счёта', + onSelected: (r) { + if (r != role) onChanged(r); + }, + itemBuilder: (_) => [ + for (final r in _roleOrder) + CheckedPopupMenuItem( + value: r, + checked: r == role, + child: Text(_roleLabel(r)), + ), + ], + child: Container( + padding: const EdgeInsets.fromLTRB(12, 6, 6, 6), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + _roleLabel(role), + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13), + ), + ), + Icon( + Icons.arrow_drop_down, + size: 18, + color: scheme.onSurfaceVariant, ), ], ), @@ -211,3 +726,41 @@ class _AccountTileState extends ConsumerState<_AccountTile> { ); } } + +class _SwitchCell extends StatelessWidget { + const _SwitchCell({ + required this.label, + required this.value, + required this.enabled, + required this.onChanged, + required this.showLabel, + }); + + final String label; + final bool value; + final bool enabled; + final ValueChanged onChanged; + final bool showLabel; + + @override + Widget build(BuildContext context) { + final sw = Semantics( + label: label, + child: Switch(value: value, onChanged: enabled ? onChanged : null), + ); + if (!showLabel) return sw; + // on a phone both switches and the type pill have to share one line, so the switch is + // drawn at 80 % and keeps a tap area of its own + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + SizedBox( + width: 40, + height: 32, + child: Transform.scale(scale: 0.75, child: sw), + ), + ], + ); + } +} diff --git a/app/lib/features/accounts/actions.dart b/app/lib/features/accounts/actions.dart new file mode 100644 index 0000000..360c77d --- /dev/null +++ b/app/lib/features/accounts/actions.dart @@ -0,0 +1,63 @@ +import 'package:dio/dio.dart'; +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import '../../core/auth/auth_controller.dart'; +import '../portfolio/providers.dart' show scopesProvider; +import '../portfolios/providers.dart' show portfolioListProvider; +import '../rebalance/providers.dart' show portfoliosProvider; +import 'providers.dart'; + +/// Changes to accounts: one place for a single switch and for a bulk edit. +abstract class AccountActions { + /// Applies [patch] to every account in [ids]. Returns null on success, or a message naming + /// how many failed and why. One failure does not stop the rest: a bulk «отключить» that + /// stalls on the 20th of 30 accounts would leave the list half changed and silent. + Future patch(Iterable ids, AccountPatch patch); +} + +final accountActionsProvider = Provider( + (ref) => _ApiAccountActions(ref), +); + +class _ApiAccountActions implements AccountActions { + _ApiAccountActions(this._ref); + + final Ref _ref; + + @override + Future patch(Iterable ids, AccountPatch patch) async { + final list = ids.toList(); + final api = _ref.read(apiProvider).getAccountsApi(); + var failed = 0; + String? reason; + for (final id in list) { + try { + await api.accountsPatch(accountId: id, accountPatch: patch); + } on DioException catch (e) { + failed++; + reason ??= problemMessage(e); + } + } + _ref.invalidate(accountsProvider); + if (patch.disabled != null || + patch.includeInNetWorth != null || + patch.role != null) { + // net worth, scopes and portfolios all follow these switches: refetch what is derived + // from them and rebuild the metrics once, however many accounts changed + _ref + ..invalidate(scopesProvider) + ..invalidate(portfolioListProvider) + ..invalidate(portfoliosProvider); + try { + await _ref.read(apiProvider).getMetricsApi().metricsRefresh(); + } on DioException { + // the next scheduled or manual refresh picks the change up + } + } + return failed == 0 + ? null + : 'Не удалось изменить $failed из ${list.length}: $reason'; + } +} diff --git a/app/lib/features/accounts/providers.dart b/app/lib/features/accounts/providers.dart index 0b83968..2e3370b 100644 --- a/app/lib/features/accounts/providers.dart +++ b/app/lib/features/accounts/providers.dart @@ -7,9 +7,14 @@ import '../../core/cache/cached.dart'; /// Every account, archived included — screens decide what to show. See /// `docs/ai/offline-cache.md`: Счета reads `.data` and shows the offline banner; /// `accountNamesProvider` and the other consumers (Транзакции, События) just unwrap `.data`. -final accountsProvider = FutureProvider.autoDispose>>((ref) async { +final accountsProvider = FutureProvider.autoDispose>>(( + ref, +) async { final r = await ref.watch(apiProvider).getAccountsApi().accountsList(); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); }); /// `account_id -> name`, for screens that only carry the id (transactions, rules). diff --git a/app/lib/features/events/event_row.dart b/app/lib/features/events/event_row.dart index 6c295e7..ab0dde6 100644 --- a/app/lib/features/events/event_row.dart +++ b/app/lib/features/events/event_row.dart @@ -42,7 +42,12 @@ class EventRow extends StatelessWidget { leading: Icon(icon, color: color), title: Row( children: [ - Flexible(child: Text(eventTitle(e.kind, e.ticker), overflow: TextOverflow.ellipsis)), + Flexible( + child: Text( + eventTitle(e.kind, e.ticker), + overflow: TextOverflow.ellipsis, + ), + ), if (e.externalFlow) ...[ const SizedBox(width: 6), Tooltip( diff --git a/app/lib/features/events/events_page.dart b/app/lib/features/events/events_page.dart index 024a912..42fa968 100644 --- a/app/lib/features/events/events_page.dart +++ b/app/lib/features/events/events_page.dart @@ -1,17 +1,22 @@ import 'dart:async'; +import 'package:dio/dio.dart'; import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../core/api/api_client.dart'; +import '../../core/auth/auth_controller.dart'; import '../../core/utils/ru_date.dart'; +import '../../core/widgets/help_tip.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; import '../accounts/providers.dart'; import '../portfolio/labels.dart' show formatQty, signColor; import 'event_row.dart'; import 'labels.dart'; +import 'manual_event_dialog.dart'; import 'providers.dart'; /// События: the broker ledger with filters and infinite scroll — the screen that answers @@ -44,7 +49,8 @@ class _EventsPageState extends ConsumerState { } void _onScroll() { - if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) { + if (_scrollController.position.pixels > + _scrollController.position.maxScrollExtent - 200) { ref.read(eventsControllerProvider.notifier).loadMore(); } } @@ -52,8 +58,11 @@ class _EventsPageState extends ConsumerState { void _onSearchChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 400), () { - ref.read(eventsControllerProvider.notifier).setFilter( - (f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()), + ref + .read(eventsControllerProvider.notifier) + .setFilter( + (f) => + f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()), ); }); } @@ -71,16 +80,79 @@ class _EventsPageState extends ConsumerState { helpText: 'Период', ); if (picked != null) { - ref.read(eventsControllerProvider.notifier).setFilter( + ref + .read(eventsControllerProvider.notifier) + .setFilter( (f) => f.copyWith(from: () => picked.start, to: () => picked.end), ); } } void _clearDateRange() { - ref.read(eventsControllerProvider.notifier).setFilter( - (f) => f.copyWith(from: () => null, to: () => null), - ); + ref + .read(eventsControllerProvider.notifier) + .setFilter((f) => f.copyWith(from: () => null, to: () => null)); + } + + void _snack(String message) => + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + + /// Lots and every valuation are rebuilt from the ledger by the metrics refresh, so a change + /// to the ledger asks for one. Best effort: the event itself is already saved. + Future _queueMetricsRefresh() async { + try { + await ref.read(apiProvider).getMetricsApi().metricsRefresh(); + } on DioException { + // the next scheduled or manual refresh picks the change up + } + } + + Future _addManual() async { + final body = await showManualEventDialog(context); + if (body == null || !mounted) return; + try { + await ref + .read(apiProvider) + .getEventsApi() + .eventsCreate(manualEventCreate: body); + await ref.read(eventsControllerProvider.notifier).refresh(); + await _queueMetricsRefresh(); + if (mounted) _snack('Событие добавлено, метрики пересчитываются'); + } on DioException catch (e) { + if (mounted) _snack(problemMessage(e)); + } + } + + Future _deleteManual(EventOut e) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Удалить событие?'), + content: Text( + '«${eventTitle(e.kind, e.ticker)}» от ${ruDate(e.tradeDate)} будет удалено.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Удалить'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + await ref.read(apiProvider).getEventsApi().eventsDelete(eventId: e.id); + await ref.read(eventsControllerProvider.notifier).refresh(); + await _queueMetricsRefresh(); + if (mounted) _snack('Событие удалено, метрики пересчитываются'); + } on DioException catch (err) { + if (mounted) _snack(problemMessage(err)); + } } void _showDetail(EventOut e) { @@ -88,7 +160,16 @@ class _EventsPageState extends ConsumerState { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => _EventDetailSheet(event: e, accountNames: accountNames), + builder: (sheetContext) => _EventDetailSheet( + event: e, + accountNames: accountNames, + onDelete: e.source_ == 'manual' + ? () { + Navigator.of(sheetContext).pop(); + _deleteManual(e); + } + : null, + ), ); } @@ -97,16 +178,24 @@ class _EventsPageState extends ConsumerState { final state = ref.watch(eventsControllerProvider); final filter = ref.watch(eventsControllerProvider.notifier).filter; final accountNames = ref.watch(accountNamesProvider); - final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const []; + final accounts = + ref.watch(accountsProvider).valueOrNull?.data ?? const []; return Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: _addManual, + icon: const Icon(Icons.add), + label: const Text('Событие'), + ), appBar: AppBar( title: const Text('События'), actions: [ if (state.total > 0) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Center(child: Text('${state.items.length} из ${state.total}')), + child: Center( + child: Text('${state.items.length} из ${state.total}'), + ), ), ], ), @@ -138,12 +227,17 @@ class _EventsPageState 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(eventsControllerProvider.notifier).refresh(), + onPressed: () => + ref.read(eventsControllerProvider.notifier).refresh(), child: const Text('Повторить'), ), ], @@ -171,7 +265,9 @@ class _EventsPageState extends ConsumerState { child: state.loadingMore ? const CircularProgressIndicator() : TextButton( - onPressed: () => ref.read(eventsControllerProvider.notifier).loadMore(), + onPressed: () => ref + .read(eventsControllerProvider.notifier) + .loadMore(), child: const Text('Ещё'), ), ), @@ -246,51 +342,67 @@ class _Filters extends ConsumerWidget { width: 180, child: DropdownButtonFormField( initialValue: filter.accountId, - isDense: true, - decoration: const InputDecoration(labelText: 'Счёт', isDense: true), + decoration: const InputDecoration(labelText: 'Счёт'), items: [ - const DropdownMenuItem(value: null, child: Text('Все счета')), + const DropdownMenuItem( + value: null, + child: Text('Все счета'), + ), for (final a in accounts) DropdownMenuItem(value: a.id, child: Text(a.name)), ], - onChanged: (v) => notifier.setFilter((f) => f.copyWith(accountId: () => v)), + onChanged: (v) => + notifier.setFilter((f) => f.copyWith(accountId: () => v)), ), ), SizedBox( width: 180, child: DropdownButtonFormField( initialValue: filter.kind, - isDense: true, - decoration: const InputDecoration(labelText: 'Тип', isDense: true), + decoration: const InputDecoration(labelText: 'Тип'), items: [ - const DropdownMenuItem(value: null, child: Text('Все типы')), + const DropdownMenuItem( + value: null, + child: Text('Все типы'), + ), for (final k in filterableEventKinds) - DropdownMenuItem(value: k, child: Text(eventKindLabel(k))), + DropdownMenuItem( + value: k, + child: Text(eventKindLabel(k)), + ), ], - onChanged: (v) => notifier.setFilter((f) => f.copyWith(kind: () => v)), + onChanged: (v) => + notifier.setFilter((f) => f.copyWith(kind: () => v)), ), ), SizedBox( width: 180, child: DropdownButtonFormField( initialValue: filter.status, - isDense: true, - decoration: const InputDecoration(labelText: 'Статус', isDense: true), + decoration: const InputDecoration(labelText: 'Статус'), items: [ - const DropdownMenuItem(value: null, child: Text('Любой статус')), + const DropdownMenuItem( + value: null, + child: Text('Любой статус'), + ), for (final s in EventStatus.values) if (s != EventStatus.unknownDefaultOpenApi) - DropdownMenuItem(value: s, child: Text(eventStatusLabel(s))), + DropdownMenuItem( + value: s, + child: Text(eventStatusLabel(s)), + ), ], - onChanged: (v) => notifier.setFilter((f) => f.copyWith(status: () => v)), + onChanged: (v) => + notifier.setFilter((f) => f.copyWith(status: () => v)), ), ), FilterChip( label: const Text('Внешние потоки'), tooltip: 'Только пополнения, выводы и переводы бумаг — то, что читает XIRR', selected: filter.externalFlow == true, - onSelected: (on) => - notifier.setFilter((f) => f.copyWith(externalFlow: () => on ? true : null)), + onSelected: (on) => notifier.setFilter( + (f) => f.copyWith(externalFlow: () => on ? true : null), + ), ), ], ), @@ -301,31 +413,45 @@ class _Filters extends ConsumerWidget { } class _EventDetailSheet extends StatelessWidget { - const _EventDetailSheet({required this.event, required this.accountNames}); + const _EventDetailSheet({ + required this.event, + required this.accountNames, + this.onDelete, + }); final EventOut event; final Map accountNames; + /// Set only for events entered by hand: broker events come back with the next sync. + final VoidCallback? onDelete; + @override Widget build(BuildContext context) { final e = event; final theme = Theme.of(context); - String money(String? v, String currency) => v == null ? '—' : MoneyText.format(v, currency); + String money(String? v, String currency) => + v == null ? '—' : MoneyText.format(v, currency); final rows = <(String, String)>[ ('Дата сделки', ruDate(e.tradeDate)), - ('Время', '${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:' - '${e.ts.minute.toString().padLeft(2, '0')}'), + ( + 'Время', + '${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:' + '${e.ts.minute.toString().padLeft(2, '0')}', + ), ('Тип', eventKindLabel(e.kind)), ('Статус', eventStatusLabel(e.status)), + ('Источник', eventSourceLabel(e.source_)), ('Счёт', accountNames[e.accountId] ?? '#${e.accountId}'), if (e.ticker != null) ('Инструмент', e.ticker!), if (e.quantity != null) ('Количество', formatQty(e.quantity!)), - if (e.price != null) ('Цена', money(e.price, e.priceCurrency ?? e.currency)), + if (e.price != null) + ('Цена', money(e.price, e.priceCurrency ?? e.currency)), ('Сумма', money(e.amount, e.currency)), ('Сумма, ₽', money(e.amountRub, 'RUB')), if (e.fee != null) ('Комиссия', money(e.fee, e.currency)), if (e.tax != null) ('Налог', money(e.tax, e.currency)), - if (e.accruedInterest != null) ('НКД', money(e.accruedInterest, e.currency)), + if (e.accruedInterest != null) + ('НКД', money(e.accruedInterest, e.currency)), ('Внешний поток', e.externalFlow ? 'да' : 'нет'), if (e.description != null) ('Описание', e.description!), ]; @@ -339,12 +465,16 @@ class _EventDetailSheet extends StatelessWidget { Row( children: [ Expanded( - child: Text(eventTitle(e.kind, e.ticker), style: theme.textTheme.titleLarge), + child: Text( + eventTitle(e.kind, e.ticker), + style: theme.textTheme.titleLarge, + ), ), Text( MoneyText.format(e.amount, e.currency), - style: theme.textTheme.titleMedium - ?.copyWith(color: signColor(context, e.amount)), + style: theme.textTheme.titleMedium?.copyWith( + color: signColor(context, e.amount), + ), ), ], ), @@ -357,12 +487,23 @@ class _EventDetailSheet extends StatelessWidget { children: [ SizedBox( width: 140, - child: Text(label, style: theme.textTheme.bodySmall), + child: TermLabel(label, style: theme.textTheme.bodySmall), ), Expanded(child: Text(value)), ], ), ), + if (onDelete != null) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Удалить'), + ), + ), + ], if (e.instrumentId != null) ...[ const SizedBox(height: 12), Align( diff --git a/app/lib/features/events/labels.dart b/app/lib/features/events/labels.dart index 4af42ab..2a78bd0 100644 --- a/app/lib/features/events/labels.dart +++ b/app/lib/features/events/labels.dart @@ -35,31 +35,47 @@ const eventStatusLabels = { 'ignored': 'Игнорируется', }; -String eventStatusLabel(EventStatus status) => eventStatusLabels[status.value] ?? status.value; +String eventStatusLabel(EventStatus status) => + eventStatusLabels[status.value] ?? status.value; /// Icon and colour per kind, on the same principle as the transaction list: money coming /// in is green, money leaving is the error colour, everything structural is neutral. -(IconData, Color) eventKindIcon(EventKind kind, ColorScheme scheme) => switch (kind) { +(IconData, Color) eventKindIcon(EventKind kind, ColorScheme scheme) => + switch (kind) { EventKind.buy => (Icons.add_shopping_cart, scheme.primary), EventKind.sell => (Icons.sell_outlined, Colors.deepPurple), - EventKind.dividend || EventKind.coupon || EventKind.interest => ( - Icons.payments_outlined, - Colors.green, - ), + EventKind.dividend || + EventKind.coupon || + EventKind.interest => (Icons.payments_outlined, Colors.green), EventKind.taxRefund => (Icons.assignment_return_outlined, Colors.green), - EventKind.tax || EventKind.commission => (Icons.receipt_outlined, scheme.error), + EventKind.tax || + EventKind.commission => (Icons.receipt_outlined, scheme.error), EventKind.deposit => (Icons.arrow_circle_down_outlined, Colors.green), EventKind.withdrawal => (Icons.arrow_circle_up_outlined, scheme.error), - EventKind.transferIn || EventKind.transferOut => (Icons.swap_horiz, scheme.primary), + EventKind.transferIn || + EventKind.transferOut => (Icons.swap_horiz, scheme.primary), EventKind.fxExchange => (Icons.currency_exchange, Colors.amber), - EventKind.split || EventKind.amortization || EventKind.repayment => ( - Icons.call_split, - scheme.outline, - ), - EventKind.other || EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline), + EventKind.split || + EventKind.amortization || + EventKind.repayment => (Icons.call_split, scheme.outline), + EventKind.other || + EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline), }; /// `'Покупка · SBER'`, the one-line identity of a row. Events with no instrument behind /// them (deposits, fees) keep just the kind. String eventTitle(EventKind kind, String? ticker) => - ticker == null || ticker.isEmpty ? eventKindLabel(kind) : '${eventKindLabel(kind)} · $ticker'; + ticker == null || ticker.isEmpty + ? eventKindLabel(kind) + : '${eventKindLabel(kind)} · $ticker'; + +/// Where a ledger event came from, as a person reads it. +const eventSourceLabels = { + 'tinvest': 'T-Invest', + 'report_sber': 'Отчёт Сбера', + 'report_vtb': 'Отчёт ВТБ', + 'csv': 'CSV', + 'manual': 'Введено вручную', +}; + +String eventSourceLabel(String source) => eventSourceLabels[source] ?? source; diff --git a/app/lib/features/events/manual_event_dialog.dart b/app/lib/features/events/manual_event_dialog.dart new file mode 100644 index 0000000..3fb0f4a --- /dev/null +++ b/app/lib/features/events/manual_event_dialog.dart @@ -0,0 +1,317 @@ +import 'package:decimal/decimal.dart'; +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/utils/ru_date.dart'; +import '../accounts/providers.dart'; +import '../pending/providers.dart' show instrumentSearchProvider; +import 'labels.dart'; + +/// The kinds `POST /events` accepts, in the order a person looks for them. +const manualEventKinds = [ + EventKind.buy, + EventKind.sell, + EventKind.transferIn, + EventKind.transferOut, + EventKind.dividend, + EventKind.coupon, + EventKind.interest, + EventKind.deposit, + EventKind.withdrawal, + EventKind.commission, + EventKind.tax, + EventKind.taxRefund, +]; + +bool _isTrade(EventKind k) => k == EventKind.buy || k == EventKind.sell; +bool _isTransfer(EventKind k) => + k == EventKind.transferIn || k == EventKind.transferOut; +bool _isPayout(EventKind k) => k == EventKind.dividend || k == EventKind.coupon; + +/// A ledger event the broker's feeds do not carry. Amounts are typed as a person reads them +/// off a statement — positive; the server derives the signs from the kind. +Future showManualEventDialog(BuildContext context) => + showDialog( + context: context, + builder: (_) => const _ManualEventDialog(), + ); + +class _ManualEventDialog extends ConsumerStatefulWidget { + const _ManualEventDialog(); + + @override + ConsumerState<_ManualEventDialog> createState() => _ManualEventDialogState(); +} + +class _ManualEventDialogState extends ConsumerState<_ManualEventDialog> { + final _formKey = GlobalKey(); + final _quantity = TextEditingController(); + final _price = TextEditingController(); + final _amount = TextEditingController(); + final _fee = TextEditingController(); + final _accrued = TextEditingController(); + final _description = TextEditingController(); + + AccountOut? _account; + EventKind _kind = EventKind.buy; + DateTime _date = DateTime.now(); + InstrumentOut? _instrument; + + @override + void dispose() { + for (final c in [ + _quantity, + _price, + _amount, + _fee, + _accrued, + _description, + ]) { + c.dispose(); + } + super.dispose(); + } + + static String? _positive(String? v, {required bool required}) { + final text = (v ?? '').trim().replaceAll(',', '.'); + if (text.isEmpty) return required ? 'Обязательное поле' : null; + final d = Decimal.tryParse(text); + return d == null || d <= Decimal.zero ? 'Число больше нуля' : null; + } + + static String? _text(TextEditingController c) { + final t = c.text.trim().replaceAll(',', '.').replaceAll(' ', ''); + return t.isEmpty ? null : t; + } + + bool get _needsInstrument => + _isTrade(_kind) || _isTransfer(_kind) || _isPayout(_kind); + + ManualEventCreate _build() { + final trade = _isTrade(_kind); + return ManualEventCreate( + accountId: _account!.id, + kind: _kind, + tradeDate: DateTime.utc(_date.year, _date.month, _date.day), + instrumentId: _needsInstrument ? _instrument!.id : null, + quantity: trade || _isTransfer(_kind) ? _text(_quantity) : null, + price: trade ? _text(_price) : null, + amount: _isTransfer(_kind) ? null : _text(_amount), + fee: trade ? _text(_fee) : null, + accruedInterest: trade ? _text(_accrued) : null, + description: _description.text.trim().isEmpty + ? null + : _description.text.trim(), + ); + } + + @override + Widget build(BuildContext context) { + final accounts = [ + for (final a + in ref.watch(accountsProvider).valueOrNull?.data ?? + const []) + if (a.kind == AccountKind.broker && !a.archived && !a.disabled) a, + ]; + final trade = _isTrade(_kind); + + return AlertDialog( + title: const Text('Новое событие'), + content: SizedBox( + width: 460, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _account, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Брокерский счёт', + ), + items: [ + for (final a in accounts) + DropdownMenuItem(value: a, child: Text(a.name)), + ], + validator: (v) => v == null ? 'Выберите счёт' : null, + onChanged: (v) => setState(() => _account = v), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _kind, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Событие'), + items: [ + for (final k in manualEventKinds) + DropdownMenuItem( + value: k, + child: Text(eventKindLabel(k)), + ), + ], + onChanged: (v) => setState(() { + _kind = v ?? _kind; + // the field is gone for kinds without an instrument: its pick must go too + if (!_needsInstrument) _instrument = null; + }), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: Text('Дата: ${ruDate(_date)}')), + TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2015), + lastDate: DateTime.now(), + ); + if (picked != null) setState(() => _date = picked); + }, + child: const Text('Выбрать'), + ), + ], + ), + if (_needsInstrument) ...[ + const SizedBox(height: 8), + _InstrumentField( + onChanged: (i) => _instrument = i, + validator: () => _instrument == null + ? 'Выберите инструмент из списка' + : null, + ), + ], + if (trade || _isTransfer(_kind)) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _quantity, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Количество, шт.', + ), + validator: (v) => _positive(v, required: true), + ), + ], + if (trade) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _price, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Цена за штуку', + ), + validator: (v) => + _positive(v, required: _text(_amount) == null), + ), + const SizedBox(height: 12), + TextFormField( + controller: _fee, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Комиссия', + helperText: 'Входит в итоговую сумму', + ), + validator: (v) => _positive(v, required: false), + ), + const SizedBox(height: 12), + TextFormField( + controller: _accrued, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'НКД (для облигаций)', + ), + validator: (v) => _positive(v, required: false), + ), + ], + if (!_isTransfer(_kind)) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _amount, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + labelText: trade ? 'Итого по выписке' : 'Сумма', + helperText: trade + ? 'Необязательно: иначе количество × цена ± НКД ± комиссия' + : 'Положительная; знак зададут по виду события', + helperMaxLines: 2, + ), + validator: (v) => _positive(v, required: !trade), + ), + ], + const SizedBox(height: 12), + TextFormField( + controller: _description, + decoration: const InputDecoration( + labelText: 'Описание (необязательно)', + ), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + if (!(_formKey.currentState?.validate() ?? false)) return; + Navigator.of(context).pop(_build()); + }, + child: const Text('Добавить'), + ), + ], + ); + } +} + +/// Type-ahead over `GET /instruments?q=`. The chosen instrument is reported through +/// [onChanged]; editing the text afterwards drops it, so a stale pick can never be submitted. +class _InstrumentField extends ConsumerWidget { + const _InstrumentField({required this.onChanged, required this.validator}); + + final ValueChanged onChanged; + final String? Function() validator; + + static String _label(InstrumentOut i) => '${i.ticker ?? '—'} · ${i.name}'; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Autocomplete( + displayStringForOption: _label, + optionsBuilder: (value) async { + final q = value.text.trim(); + if (q.length < 2) return const []; + return ref.read(instrumentSearchProvider(q).future); + }, + onSelected: onChanged, + fieldViewBuilder: (context, controller, focusNode, onSubmit) => + TextFormField( + controller: controller, + focusNode: focusNode, + decoration: const InputDecoration( + labelText: 'Инструмент', + helperText: 'Тикер, ISIN или название', + prefixIcon: Icon(Icons.search), + ), + onChanged: (_) => onChanged(null), + validator: (_) => validator(), + ), + ); + } +} diff --git a/app/lib/features/events/providers.dart b/app/lib/features/events/providers.dart index d743199..ea2d4d7 100644 --- a/app/lib/features/events/providers.dart +++ b/app/lib/features/events/providers.dart @@ -112,7 +112,10 @@ class EventsController extends Notifier { } Future _fetch(int page) async { - final r = await ref.read(apiProvider).getEventsApi().eventsList( + final r = await ref + .read(apiProvider) + .getEventsApi() + .eventsList( from: filter.from, to: filter.to, accountId: filter.accountId, diff --git a/app/lib/features/home/home_page.dart b/app/lib/features/home/home_page.dart index 0ca1d58..419a6b9 100644 --- a/app/lib/features/home/home_page.dart +++ b/app/lib/features/home/home_page.dart @@ -20,9 +20,13 @@ import '../../core/widgets/tile_carousel.dart'; import '../../core/api/api_client.dart'; import '../health/data_quality_list.dart' show severityColor; import 'providers.dart'; +import 'scope_cards.dart'; double _d(String s) => Decimal.parse(s).toDouble(); +/// The refresh log row can be a run still in progress, which has no failed step yet. +String _failedStepNote(String? step) => step == null ? '' : ' (шаг «$step»)'; + /// Обзор: the dashboard landing page — net worth, this month's cashflow, /// runway, a net worth line chart, a 12-month income/expense bar chart, and /// a data-quality summary linking to the findings. @@ -39,10 +43,28 @@ class _HomePageState extends ConsumerState { Future _refresh() async { setState(() => _refreshing = true); try { - await ref.read(apiProvider).getMetricsApi().metricsRefresh(); + final metrics = ref.read(apiProvider).getMetricsApi(); + await metrics + .metricsRefresh(); // 202: only queued, the worker does the rebuild + final done = await waitForMetricsRefresh( + () async => (await metrics.metricsStatus()).data!, + ); + if (mounted) { + final message = done == null + ? 'Пересчёт идёт дольше обычного — данные обновятся, когда он закончится' + : done.consistent + ? null + : 'Пересчёт не завершён${_failedStepNote(done.lastRefresh?.failedStep)} — ' + 'часть метрик может не сходиться'; + if (message != null) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } + } } 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(() => _refreshing = false); invalidateHomeProviders(ref); @@ -59,6 +81,7 @@ class _HomePageState extends ConsumerState { final status = ref.watch(metricsStatusProvider); final dataQuality = ref.watch(dataQualityProvider); final portfolio = ref.watch(portfolioSummaryHomeProvider); + final cards = ref.watch(scopeCardsProvider); final stale = oldestFetch([ breakdown.valueOrNull?.fetchedAt, @@ -69,6 +92,7 @@ class _HomePageState extends ConsumerState { status.valueOrNull?.fetchedAt, dataQuality.valueOrNull?.fetchedAt, portfolio.valueOrNull?.fetchedAt, + cards.valueOrNull?.fetchedAt, ]); return Scaffold( @@ -79,7 +103,10 @@ class _HomePageState extends ConsumerState { tooltip: 'Пересчитать метрики', icon: _refreshing ? const SizedBox( - width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Icon(Icons.refresh), onPressed: _refreshing ? null : _refresh, ), @@ -98,12 +125,21 @@ class _HomePageState extends ConsumerState { value: status, onRetry: () => ref.invalidate(metricsStatusProvider), data: (cached) { - final log = cached.data; + final status = cached.data; + final log = status.lastRefresh; final at = log?.finishedAt ?? log?.startedAt; final text = at == null ? 'Данные ещё не пересчитывались' : 'Данные на ${ruDate(at.toLocal())} ${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}'; - return Text(text, style: Theme.of(context).textTheme.bodySmall); + final style = Theme.of(context).textTheme.bodySmall; + if (status.consistent) return Text(text, style: style); + return Text( + '$text · пересчёт не завершён${_failedStepNote(log?.failedStep)}, ' + 'часть метрик может не сходиться', + style: style?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ); }, ), ), @@ -113,20 +149,43 @@ class _HomePageState extends ConsumerState { final rows = cached.data; return ActionChip( avatar: Icon( - rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined, + rows.isEmpty + ? Icons.check_circle_outline + : Icons.warning_amber_outlined, size: 18, - color: rows.isEmpty ? ChartColors.slot3Aqua : severityColor(rows.first.severity), + color: rows.isEmpty + ? ChartColors.slot3Aqua + : severityColor(rows.first.severity), + ), + label: Text( + rows.isEmpty ? 'ок' : '${rows.length} замечаний', ), - label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'), // Health page renders the same findings via DataQualityList — no // second implementation of this list here. - onPressed: rows.isEmpty ? null : () => context.go('/health?tab=quality'), + onPressed: rows.isEmpty + ? null + : () => context.go('/health?tab=quality'), ); }, ), ], ), - const SizedBox(height: 20), + AsyncValueView( + value: cards, + onRetry: () => ref.invalidate(scopeCardsProvider), + data: (cached) => cached.data.isEmpty + ? const SizedBox.shrink() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + const SectionHeader(title: 'Портфели'), + const SizedBox(height: 12), + ScopeCards(cards: cached.data), + ], + ), + ), + const SizedBox(height: 24), const SectionHeader(title: 'Капитал'), const SizedBox(height: 12), AsyncValueView( @@ -146,7 +205,8 @@ class _HomePageState extends ConsumerState { AsyncValueView( value: runway, onRetry: () => ref.invalidate(runwayProvider), - data: (cached) => TileCarousel(children: [_RunwayTile(runway: cached.data)]), + data: (cached) => + TileCarousel(children: [_RunwayTile(runway: cached.data)]), ), AsyncValueView( value: portfolio, @@ -175,7 +235,10 @@ class _HomePageState extends ConsumerState { value: series, onRetry: () => ref.invalidate(netWorthSeriesProvider), data: (cached) => cached.data.isEmpty - ? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.') + ? const EmptyState( + icon: Icons.show_chart, + message: 'Пока нет данных.', + ) : _NetWorthChart(rows: cached.data), ), ), @@ -188,7 +251,10 @@ class _HomePageState extends ConsumerState { value: last12, onRetry: () => ref.invalidate(cashflowLast12Provider), data: (cached) => cached.data.isEmpty - ? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.') + ? const EmptyState( + icon: Icons.bar_chart, + message: 'Пока нет данных.', + ) : _IncomeExpenseChart(rows: cached.data), ), ), @@ -204,7 +270,11 @@ class _HomePageState extends ConsumerState { ); } return Column( - children: [netWorthChart, const SizedBox(height: 16), cashflowChart], + children: [ + netWorthChart, + const SizedBox(height: 16), + cashflowChart, + ], ); }, ), @@ -230,13 +300,30 @@ class _NetWorthTiles extends StatelessWidget { @override Widget build(BuildContext context) { - return TileCarousel(children: [ - StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')), - StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, currency: 'RUB')), - StatTile(label: 'Сбережения', value: MoneyText(breakdown.savingsRub, currency: 'RUB')), - StatTile(label: 'Инвестиции', value: MoneyText(breakdown.investmentRub, currency: 'RUB')), - StatTile(label: 'Долги', value: MoneyText(breakdown.debtRub, currency: 'RUB')), - ]); + return TileCarousel( + children: [ + StatTile( + label: 'Капитал сегодня', + value: MoneyText(breakdown.totalRub, currency: 'RUB'), + ), + StatTile( + label: 'Ликвидные', + value: MoneyText(breakdown.liquidRub, currency: 'RUB'), + ), + StatTile( + label: 'Сбережения', + value: MoneyText(breakdown.savingsRub, currency: 'RUB'), + ), + StatTile( + label: 'Инвестиции', + value: MoneyText(breakdown.investmentRub, currency: 'RUB'), + ), + StatTile( + label: 'Долги', + value: MoneyText(breakdown.debtRub, currency: 'RUB'), + ), + ], + ); } } @@ -247,14 +334,27 @@ class _MonthTiles extends StatelessWidget { @override Widget build(BuildContext context) { if (month == null) { - return const EmptyState(icon: Icons.event_note_outlined, message: 'Данных за этот месяц нет.'); + return const EmptyState( + icon: Icons.event_note_outlined, + message: 'Данных за этот месяц нет.', + ); } - final rateText = month!.savingsRate == null ? '—' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%'; - return TileCarousel(children: [ - StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')), - StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')), - StatTile(label: 'Норма сбережений', value: Text(rateText)), - ]); + final rateText = month!.savingsRate == null + ? '—' + : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%'; + return TileCarousel( + children: [ + StatTile( + label: 'Доход в этом месяце', + value: MoneyText(month!.incomeRub, currency: 'RUB'), + ), + StatTile( + label: 'Расход в этом месяце', + value: MoneyText(month!.expenseRub, currency: 'RUB'), + ), + StatTile(label: 'Норма сбережений', value: Text(rateText)), + ], + ); } } @@ -278,7 +378,8 @@ class _NetWorthChart extends StatelessWidget { @override Widget build(BuildContext context) { final spots = [ - for (var i = 0; i < rows.length; i++) FlSpot(i.toDouble(), _d(rows[i].totalRub)), + for (var i = 0; i < rows.length; i++) + FlSpot(i.toDouble(), _d(rows[i].totalRub)), ]; return LineChart( LineChartData( @@ -323,7 +424,11 @@ class _IncomeExpenseChart 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 Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -343,19 +448,28 @@ class _IncomeExpenseChart 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, getTitlesWidget: (value, meta) { final i = value.toInt(); - if (i < 0 || i >= rows.length) return const SizedBox.shrink(); + 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: 9)), + child: Text( + ruMonthYearShort(rows[i].month), + style: const TextStyle(fontSize: 9), + ), ); }, ), @@ -377,8 +491,16 @@ class _IncomeExpenseChart extends StatelessWidget { BarChartGroupData( x: i, barRods: [ - BarChartRodData(toY: _d(rows[i].incomeRub), color: ChartColors.income, width: 6), - BarChartRodData(toY: _d(rows[i].expenseRub), color: ChartColors.expense, width: 6), + BarChartRodData( + toY: _d(rows[i].incomeRub), + color: ChartColors.income, + width: 6, + ), + BarChartRodData( + toY: _d(rows[i].expenseRub), + color: ChartColors.expense, + width: 6, + ), ], barsSpace: 2, ), @@ -401,7 +523,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), ], @@ -422,27 +548,23 @@ class _PortfolioTiles extends StatelessWidget { return InkWell( onTap: () => context.go('/portfolio'), borderRadius: BorderRadius.circular(12), - child: TileCarousel(children: [ - StatTile( - label: 'Портфель', - value: MoneyText(summary.totalRub, currency: 'RUB'), - ), - StatTile( - label: 'Прибыль', - value: summary.pnlTotalRub == null - // null, not zero: something in the portfolio has no price today - ? const Text('—') - : MoneyText(summary.pnlTotalRub!, currency: 'RUB'), - ), - StatTile( - label: 'XIRR, год', - value: Text(_percent(yearly?.xirr)), - ), - StatTile( - label: 'TWR, год', - value: Text(_percent(yearly?.twr)), - ), - ]), + child: TileCarousel( + children: [ + StatTile( + label: 'Портфель', + value: MoneyText(summary.totalRub, currency: 'RUB'), + ), + StatTile( + label: 'Прибыль', + value: summary.pnlTotalRub == null + // null, not zero: something in the portfolio has no price today + ? const Text('—') + : MoneyText(summary.pnlTotalRub!, currency: 'RUB'), + ), + StatTile(label: 'XIRR, год', value: Text(_percent(yearly?.xirr))), + StatTile(label: 'TWR, год', value: Text(_percent(yearly?.twr))), + ], + ), ); } diff --git a/app/lib/features/home/providers.dart b/app/lib/features/home/providers.dart index 49a948b..395673d 100644 --- a/app/lib/features/home/providers.dart +++ b/app/lib/features/home/providers.dart @@ -8,54 +8,102 @@ import '../../core/cache/cached.dart'; /// Every provider below returns `Cached`, not `T`: `HomePage` reads /// `.data` for the tiles and `.fetchedAt` (via `oldestFetch`) for the one /// offline banner at the top. See `docs/ai/offline-cache.md`. -final netWorthBreakdownProvider = FutureProvider.autoDispose>((ref) async { - final r = await ref.watch(apiProvider).getNetworthApi().networthBreakdown(); - return r.cached; -}); +final netWorthBreakdownProvider = + FutureProvider.autoDispose>((ref) async { + final r = await ref + .watch(apiProvider) + .getNetworthApi() + .networthBreakdown(); + return r.cached; + }); /// Daily net worth for the last 365 days. -final netWorthSeriesProvider = FutureProvider.autoDispose>>((ref) async { - final now = DateTime.now(); - final r = await ref.watch(apiProvider).getNetworthApi().networthSeries( - from: now.subtract(const Duration(days: 365)), - to: now, +final netWorthSeriesProvider = + FutureProvider.autoDispose>>((ref) async { + final now = DateTime.now(); + final r = await ref + .watch(apiProvider) + .getNetworthApi() + .networthSeries( + from: now.subtract(const Duration(days: 365)), + to: now, + ); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, ); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); -}); + }); /// The current (partial) month's cashflow, or null before any data exists. -final cashflowThisMonthProvider = FutureProvider.autoDispose>((ref) async { - final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 1); - final rows = r.data ?? const []; - return Cached(rows.isEmpty ? null : rows.last, fetchedAt: r.extra['fetchedAt'] as DateTime?); -}); +final cashflowThisMonthProvider = + FutureProvider.autoDispose>((ref) async { + final r = await ref + .watch(apiProvider) + .getCashflowApi() + .cashflowMonthly(months: 1); + final rows = r.data ?? const []; + return Cached( + rows.isEmpty ? null : rows.last, + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); + }); /// The last 12 months, for the income-vs-expense bar chart. -final cashflowLast12Provider = FutureProvider.autoDispose>>((ref) async { - final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 12); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); -}); +final cashflowLast12Provider = + FutureProvider.autoDispose>>((ref) async { + final r = await ref + .watch(apiProvider) + .getCashflowApi() + .cashflowMonthly(months: 12); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); + }); -final runwayProvider = FutureProvider.autoDispose>((ref) async { +final runwayProvider = FutureProvider.autoDispose>(( + ref, +) async { final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway(); return r.cached; }); -/// When the metric tables were last rebuilt; null before the first refresh. -final metricsStatusProvider = FutureProvider.autoDispose>((ref) async { - try { - final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus(); - return r.cached; - } on DioException catch (e) { - if (e.response?.statusCode == 404) return const Cached(null); - rethrow; - } -}); +/// When the metric tables were last rebuilt, whether they are one consistent snapshot, and +/// whether another rebuild is on its way. +final metricsStatusProvider = + FutureProvider.autoDispose>((ref) async { + final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus(); + return r.cached; + }); -final dataQualityProvider = FutureProvider.autoDispose>>((ref) async { - final r = await ref.watch(apiProvider).getMetricsApi().metricsDataQuality(); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); -}); +/// Waits for a queued metrics rebuild to finish: `POST /metrics/refresh` only queues it, so the +/// numbers are stale until `refreshing` turns false. Returns the last status seen, or null if +/// [timeout] ran out first (a rebuild stuck behind a long sync, say). +Future waitForMetricsRefresh( + Future Function() fetch, { + Duration interval = const Duration(seconds: 2), + Duration timeout = const Duration(minutes: 5), +}) async { + final clock = Stopwatch()..start(); + while (true) { + final status = await fetch(); + if (!status.refreshing) return status; + if (clock.elapsed >= timeout) return null; + await Future.delayed(interval); + } +} + +final dataQualityProvider = + FutureProvider.autoDispose>>((ref) async { + final r = await ref + .watch(apiProvider) + .getMetricsApi() + .metricsDataQuality(); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); + }); /// Every dashboard provider, refreshed together after a manual /// `POST /metrics/refresh` or a pull-to-refresh. @@ -68,16 +116,35 @@ void invalidateHomeProviders(WidgetRef ref) { ref.invalidate(metricsStatusProvider); ref.invalidate(dataQualityProvider); ref.invalidate(portfolioSummaryHomeProvider); + ref.invalidate(scopeCardsProvider); } /// The investment side of the dashboard: one scope-wide summary, `all` by default. /// Null when the ledger is empty, which is the normal state before a broker sync. -final portfolioSummaryHomeProvider = FutureProvider.autoDispose>((ref) async { - try { - final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(); - return r.cached; - } on DioException catch (e) { - if (e.response?.statusCode == 404) return const Cached(null); - rethrow; - } -}); +final portfolioSummaryHomeProvider = + FutureProvider.autoDispose>((ref) async { + try { + final r = await ref + .watch(apiProvider) + .getAnalyticsApi() + .analyticsSummary(); + return r.cached; + } on DioException catch (e) { + if (e.response?.statusCode == 404) return const Cached(null); + rethrow; + } + }); + +/// One card per portfolio and account for the home grid (`GET /analytics/overview`): value, +/// result, the last day's change, return and expected passive income, in one round trip. +final scopeCardsProvider = + FutureProvider.autoDispose>>((ref) async { + final r = await ref + .watch(apiProvider) + .getAnalyticsApi() + .analyticsOverview(); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); + }); diff --git a/app/lib/features/home/scope_cards.dart b/app/lib/features/home/scope_cards.dart new file mode 100644 index 0000000..32facf4 --- /dev/null +++ b/app/lib/features/home/scope_cards.dart @@ -0,0 +1,194 @@ +import 'package:decimal/decimal.dart'; +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/theme/chart_colors.dart'; +import '../../core/widgets/help_tip.dart'; +import '../../core/widgets/money_text.dart'; +import '../portfolio/labels.dart' show formatPercent, signColor; +import '../portfolio/providers.dart' show scopeProvider; + +/// The home grid: a card per portfolio and account — value, result, the last day, return and +/// expected passive income at a glance. A tap opens Портфель scoped to that card. +class ScopeCards extends ConsumerWidget { + const ScopeCards({required this.cards, super.key}); + + final List cards; + + static const _gap = 16.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final columns = width >= 980 ? 3 : (width >= 620 ? 2 : 1); + final itemWidth = (width - _gap * (columns - 1)) / columns; + return Wrap( + spacing: _gap, + runSpacing: _gap, + children: [ + for (final c in cards) + SizedBox( + width: itemWidth, + child: ScopeCard( + card: c, + onTap: () { + ref.read(scopeProvider.notifier).state = c.scope; + context.go('/portfolio'); + }, + ), + ), + ], + ); + }, + ); + } +} + +IconData _icon(String kind) => switch (kind) { + 'all' => Icons.layers_outlined, + 'portfolio' => Icons.layers, + _ => Icons.account_balance_wallet_outlined, +}; + +class ScopeCard extends StatelessWidget { + const ScopeCard({required this.card, required this.onTap, super.key}); + + final ScopeCardOut card; + final VoidCallback onTap; + + static String _money(String? v) => + v == null ? '—' : MoneyText.format(v, 'RUB'); + + /// `+6 643,47 ₽` — a signed amount; a plain minus and plus, never a bare number. + static String _signedMoney(String? v) { + if (v == null) return '—'; + final d = Decimal.parse(v); + final text = MoneyText.format(v, 'RUB'); + return d > Decimal.zero ? '+$text' : text; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final label = theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ); + + Widget row(String name, Widget value) => Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: TermLabel(name, style: label), + ), + ), + value, + ], + ), + ); + + /// «+6 643,47 ₽ (▲ 1,5 %)» in the colour of its sign. + Widget change(String? amount, String? share) { + if (amount == null) return const Text('—'); + final color = signColor(context, amount) ?? scheme.onSurface; + final positive = Decimal.parse(amount) > Decimal.zero; + final negative = Decimal.parse(amount) < Decimal.zero; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_signedMoney(amount), style: TextStyle(color: color)), + if (share != null) ...[ + Text(' (', style: TextStyle(color: color)), + if (positive || negative) + Icon( + positive ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 18, + color: color, + ), + Text( + formatPercent(share, signed: false).replaceAll('-', ''), + style: TextStyle(color: color), + ), + Text(')', style: TextStyle(color: color)), + ], + ], + ); + } + + final income = Decimal.parse(card.incomeYearRub); + return Card( + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _icon(card.kind), + size: 18, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + card.name.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.onSurface, + letterSpacing: 0.4, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + _money(card.totalRub), + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + row('Прибыль', change(card.pnlRub, card.pnlPct)), + row('За день', change(card.dayChangeRub, card.dayChangePct)), + row( + 'Доходность', + Text( + card.xirr == null + ? '—' + : formatPercent(card.xirr, signed: false), + style: TextStyle(color: signColor(context, card.xirr)), + ), + ), + row( + 'Пассивный доход', + Text( + income == Decimal.zero + ? '—' + : '${card.incomeYearPct == null ? '' : '${formatPercent(card.incomeYearPct, signed: false)} '}' + '(${MoneyText.format(card.incomeYearRub, 'RUB')})', + style: TextStyle( + color: income == Decimal.zero + ? scheme.onSurfaceVariant + : ChartColors.gain, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/portfolio/allocation_tab.dart b/app/lib/features/portfolio/allocation_tab.dart index 94ed9eb..de4f13e 100644 --- a/app/lib/features/portfolio/allocation_tab.dart +++ b/app/lib/features/portfolio/allocation_tab.dart @@ -75,21 +75,24 @@ class _DimensionCard extends StatelessWidget { @override Widget build(BuildContext context) { - final onlyUnknown = buckets.every((b) => b.bucket == 'unknown' || b.bucket == 'cash'); + final onlyUnknown = buckets.every( + (b) => b.bucket == 'unknown' || b.bucket == 'cash', + ); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(dimensionLabel(dimension), style: Theme.of(context).textTheme.titleMedium), + Text( + dimensionLabel(dimension), + style: Theme.of(context).textTheme.titleMedium, + ), if (onlyUnknown) ...[ const SizedBox(height: 4), Text( 'Атрибут не заполнен у инструментов — разрез пустой, а не нулевой.', - style: Theme.of(context) - .textTheme - .bodySmall + style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: Theme.of(context).hintColor), ), ], @@ -111,7 +114,9 @@ class _DimensionCard extends StatelessWidget { ], ); } - return Column(children: [donut, const SizedBox(height: 12), legend]); + return Column( + children: [donut, const SizedBox(height: 12), legend], + ); }, ), ], @@ -133,7 +138,10 @@ class _Donut extends StatelessWidget { // and the legend still lists it with its real value final positive = buckets.where((b) => _d(b.valueRub) > 0).toList(); if (positive.isEmpty) { - return const EmptyState(icon: Icons.donut_large_outlined, message: 'Нечего показать.'); + return const EmptyState( + icon: Icons.donut_large_outlined, + message: 'Нечего показать.', + ); } return PieChart( PieChartData( @@ -194,10 +202,18 @@ class _Legend extends StatelessWidget { if (buckets[i].holdingCount > 0) Padding( padding: const EdgeInsets.only(right: 8), - child: Text('${buckets[i].holdingCount}', - style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), + child: Text( + '${buckets[i].holdingCount}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), + ), ), - MoneyText(buckets[i].valueRub, currency: 'RUB', style: theme.textTheme.bodyMedium), + MoneyText( + buckets[i].valueRub, + currency: 'RUB', + style: theme.textTheme.bodyMedium, + ), const SizedBox(width: 12), SizedBox( width: 56, diff --git a/app/lib/features/portfolio/benchmarks_card.dart b/app/lib/features/portfolio/benchmarks_card.dart index cf9cf68..6d7a437 100644 --- a/app/lib/features/portfolio/benchmarks_card.dart +++ b/app/lib/features/portfolio/benchmarks_card.dart @@ -10,16 +10,18 @@ import 'data/benchmarks_api.dart'; import 'labels.dart'; import 'providers.dart'; -final benchmarksApiProvider = - Provider((ref) => BenchmarksApi(ref.watch(apiProvider).dio)); +final benchmarksApiProvider = Provider( + (ref) => BenchmarksApi(ref.watch(apiProvider).dio), +); /// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own: /// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject. See /// `docs/ai/offline-cache.md`. -final benchmarkRowsProvider = FutureProvider.autoDispose>>((ref) async { - final scope = ref.watch(scopeProvider); - return ref.watch(benchmarksApiProvider).compare(scope: scope); -}); +final benchmarkRowsProvider = + FutureProvider.autoDispose>>((ref) async { + final scope = ref.watch(scopeProvider); + return ref.watch(benchmarksApiProvider).compare(scope: scope); + }); /// The comparison block on Портфель. /// @@ -73,13 +75,16 @@ class _PeriodBlock extends StatelessWidget { if (row.dateFrom != null && row.dateTo != null) Text( '${ruDate(row.dateFrom!)} – ${ruDate(row.dateTo!)}', - style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), ), const Spacer(), Text( 'портфель ${formatPercent(row.portfolioTwr)}', - style: theme.textTheme.titleSmall - ?.copyWith(color: signColor(context, row.portfolioTwr)), + style: theme.textTheme.titleSmall?.copyWith( + color: signColor(context, row.portfolioTwr), + ), ), if (row.portfolioDaysSkipped > 0) ...[ const SizedBox(width: 6), @@ -89,13 +94,14 @@ class _PeriodBlock extends StatelessWidget { ), const SizedBox(height: 6), for (final b in row.benchmarks) _BenchmarkRowView(result: b), - if (row.hasSkippedDays) + if (row.benchmarksSkipDays) Padding( padding: const EdgeInsets.only(top: 6), child: Text( - 'Сетка дат не полностью совпадает: часть дней пропущена, ' - 'сравнение не строго like-for-like.', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), + 'У индекса нет котировок в часть дней окна — сравнение не строго день в день.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), ), ), const Divider(height: 20), @@ -131,8 +137,9 @@ class _BenchmarkRowView extends StatelessWidget { child: Text( formatPercent(result.excess), textAlign: TextAlign.right, - style: theme.textTheme.bodyMedium - ?.copyWith(color: signColor(context, result.excess)), + style: theme.textTheme.bodyMedium?.copyWith( + color: signColor(context, result.excess), + ), ), ), ], @@ -148,7 +155,8 @@ class _PriceIndexChip extends StatelessWidget { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Tooltip( - message: 'Ценовой индекс: не учитывает дивиденды и систематически занижает ' + message: + 'Ценовой индекс: не учитывает дивиденды и систематически занижает ' 'результат держателя. Сравнение с ним — нижняя граница, а не эталон.', child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), @@ -156,7 +164,10 @@ class _PriceIndexChip extends StatelessWidget { color: scheme.errorContainer, borderRadius: BorderRadius.circular(6), ), - child: Text('ценовой индекс', style: Theme.of(context).textTheme.labelSmall), + child: Text( + 'ценовой индекс', + style: Theme.of(context).textTheme.labelSmall, + ), ), ); } @@ -171,7 +182,8 @@ class _SkippedChip extends StatelessWidget { @override Widget build(BuildContext context) { return Tooltip( - message: 'В расчёте $side пропущено $days дн. — в эти дни не было цены', + message: + 'В расчёте $side не учтено $days дн.: в эти дни у части позиций не было цены', child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/app/lib/features/portfolio/data/benchmarks_api.dart b/app/lib/features/portfolio/data/benchmarks_api.dart index 04e1bed..198f7f3 100644 --- a/app/lib/features/portfolio/data/benchmarks_api.dart +++ b/app/lib/features/portfolio/data/benchmarks_api.dart @@ -42,14 +42,14 @@ class BenchmarkResult { bool get isPriceIndex => kind == 'price'; static BenchmarkResult fromJson(Map json) => BenchmarkResult( - benchmarkId: asInt(json['benchmark_id']) ?? 0, - code: asString(json['code']) ?? '—', - kind: asString(json['kind']) ?? 'total_return', - twr: asString(json['twr']), - twrAnnualized: asString(json['twr_annualized']), - daysSkipped: asInt(json['days_skipped']) ?? 0, - excess: asString(json['excess']), - ); + benchmarkId: asInt(json['benchmark_id']) ?? 0, + code: asString(json['code']) ?? '—', + kind: asString(json['kind']) ?? 'total_return', + twr: asString(json['twr']), + twrAnnualized: asString(json['twr_annualized']), + daysSkipped: asInt(json['days_skipped']) ?? 0, + excess: asString(json['excess']), + ); } class BenchmarkRow { @@ -74,18 +74,22 @@ class BenchmarkRow { final int portfolioDaysSkipped; final List benchmarks; - bool get hasSkippedDays => - portfolioDaysSkipped > 0 || benchmarks.any((b) => b.daysSkipped > 0); + bool get hasSkippedDays => portfolioDaysSkipped > 0 || benchmarksSkipDays; + + /// Only the index side: the portfolio's own gaps are already marked next to its number. + bool get benchmarksSkipDays => benchmarks.any((b) => b.daysSkipped > 0); static BenchmarkRow fromJson(Map json) => BenchmarkRow( - period: asString(json['period']) ?? 'all', - dateFrom: asDate(json['date_from']), - dateTo: asDate(json['date_to']), - portfolioTwr: asString(json['portfolio_twr']), - portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']), - portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0, - benchmarks: asObjects(json['benchmarks']).map(BenchmarkResult.fromJson).toList(), - ); + period: asString(json['period']) ?? 'all', + dateFrom: asDate(json['date_from']), + dateTo: asDate(json['date_to']), + portfolioTwr: asString(json['portfolio_twr']), + portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']), + portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0, + benchmarks: asObjects(json['benchmarks']) + .map(BenchmarkResult.fromJson) + .toList(), + ); } class BenchmarksApi { @@ -104,7 +108,9 @@ class BenchmarksApi { // `period` is repeatable; Dio serialises a list as repeated query parameters. queryParameters: {'scope': scope, 'period': periods}, ); - final rows = asObjects((r.data ?? const {})['rows']).map(BenchmarkRow.fromJson).toList(); + final rows = asObjects((r.data ?? const {})['rows']) + .map(BenchmarkRow.fromJson) + .toList(); return Cached(rows, fetchedAt: r.extra['fetchedAt'] as DateTime?); } } diff --git a/app/lib/features/portfolio/holdings_tab.dart b/app/lib/features/portfolio/holdings_tab.dart index 9936589..8b862df 100644 --- a/app/lib/features/portfolio/holdings_tab.dart +++ b/app/lib/features/portfolio/holdings_tab.dart @@ -3,22 +3,45 @@ import 'package:fintracker_api/fintracker_api.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import '../../core/theme/chart_colors.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; +import '../../core/widgets/help_tip.dart'; import 'benchmarks_card.dart'; +import 'holdings_table.dart'; import 'labels.dart'; import 'providers.dart'; double _d(String s) => Decimal.parse(s).toDouble(); +/// Which parts of the screen a [HoldingsTab] draws. The same providers feed all of them, so +/// the numbers on Портфель and on Аналитика → Общее can never disagree. +enum HoldingsSections { + /// Everything, top to bottom. + all, + + /// Only the assets table — the Портфель screen. + table, + + /// The charts and returns, without the table — Аналитика → Общее. + overview, +} + /// Позиции: what the portfolio holds, what it is worth, and what it earned. class HoldingsTab extends ConsumerWidget { - const HoldingsTab({super.key}); + const HoldingsTab({ + this.sections = HoldingsSections.all, + this.header, + super.key, + }); + + final HoldingsSections sections; + + /// Drawn above everything else, inside the same scrolling list. + final Widget? header; @override Widget build(BuildContext context, WidgetRef ref) { @@ -26,58 +49,73 @@ class HoldingsTab extends ConsumerWidget { final holdings = ref.watch(holdingsProvider); final series = ref.watch(valueSeriesProvider); final returns = ref.watch(portfolioReturnsProvider); + final all = sections == HoldingsSections.all; + final withTable = all || sections == HoldingsSections.table; + final withCharts = all || sections == HoldingsSections.overview; return RefreshIndicator( onRefresh: () async => invalidatePortfolioProviders(ref), child: ListView( padding: const EdgeInsets.all(16), children: [ - AsyncValueView( - value: summary, - onRetry: () => ref.invalidate(portfolioSummaryProvider), - data: (cached) => _SummaryTiles(summary: cached.data), - ), - const SizedBox(height: 20), - _Card( - title: 'Стоимость за 365 дней', - child: AsyncValueView( - value: series, - onRetry: () => ref.invalidate(valueSeriesProvider), - data: (cached) => cached.data.isEmpty - ? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.') - : _ValueChart(rows: cached.data), + if (header != null) ...[header!, const SizedBox(height: 16)], + if (all) ...[ + AsyncValueView( + value: summary, + onRetry: () => ref.invalidate(portfolioSummaryProvider), + data: (cached) => _SummaryTiles(summary: cached.data), ), - ), - const SizedBox(height: 16), - _Card( - title: 'Доходность', - child: AsyncValueView( - value: returns, - onRetry: () => ref.invalidate(portfolioReturnsProvider), - data: (cached) => cached.data.isEmpty - ? const EmptyState(icon: Icons.percent, message: 'Пока нечего считать.') - : _ReturnsTable(rows: cached.data), + const SizedBox(height: 20), + ], + if (withCharts) ...[ + _Card( + title: 'Стоимость за 365 дней', + child: AsyncValueView( + value: series, + onRetry: () => ref.invalidate(valueSeriesProvider), + data: (cached) => cached.data.isEmpty + ? const EmptyState( + icon: Icons.show_chart, + message: 'Пока нет данных.', + ) + : _ValueChart(rows: cached.data), + ), ), - ), - const SizedBox(height: 16), - const _Card( - title: 'Сравнение с бенчмарками', - child: BenchmarksCard(), - ), - const SizedBox(height: 16), - _Card( - title: 'Позиции', - child: AsyncValueView( - value: holdings, - onRetry: () => ref.invalidate(holdingsProvider), - data: (cached) => cached.data.isEmpty - ? const EmptyState( - icon: Icons.inventory_2_outlined, - message: 'Открытых позиций нет — нужна синхронизация брокера.', - ) - : _HoldingsTable(rows: cached.data), + const SizedBox(height: 16), + _Card( + title: 'Доходность', + child: AsyncValueView( + value: returns, + onRetry: () => ref.invalidate(portfolioReturnsProvider), + data: (cached) => cached.data.isEmpty + ? const EmptyState( + icon: Icons.percent, + message: 'Пока нечего считать.', + ) + : _ReturnsTable(rows: cached.data), + ), + ), + const SizedBox(height: 16), + const _Card( + title: 'Сравнение с бенчмарками', + child: BenchmarksCard(), + ), + ], + if (withTable && withCharts) const SizedBox(height: 16), + if (withTable) + _Card( + title: all ? 'Позиции' : null, + child: AsyncValueView( + value: holdings, + onRetry: () => ref.invalidate(holdingsProvider), + data: (cached) => cached.data.isEmpty + ? const EmptyState( + icon: Icons.inventory_2_outlined, + message: 'Открытых позиций нет — нужна синхронизация брокера.', + ) + : HoldingsTable(rows: cached.data), + ), ), - ), ], ), ); @@ -113,7 +151,9 @@ class _SummaryTiles extends StatelessWidget { : MoneyText( summary.pnlTotalRub!, currency: 'RUB', - style: TextStyle(color: signColor(context, summary.pnlTotalRub)), + style: TextStyle( + color: signColor(context, summary.pnlTotalRub), + ), ), note: summary.pnlTotalRub == null ? 'часть позиций без цены' @@ -171,14 +211,19 @@ class _Tile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: theme.textTheme.bodySmall), + TermLabel(label, style: theme.textTheme.bodySmall), const SizedBox(height: 4), - DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value), + DefaultTextStyle( + style: theme.textTheme.titleMedium!, + child: value, + ), if (note != null) ...[ const SizedBox(height: 2), Text( note!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), maxLines: 2, ), ], @@ -224,7 +269,10 @@ class _ValueChart extends StatelessWidget { getTitlesWidget: (value, meta) { final i = value.round(); if (i < 0 || i >= rows.length) return const SizedBox.shrink(); - return Text(ruMonthYearShort(rows[i].d), style: theme.textTheme.bodySmall); + return Text( + ruMonthYearShort(rows[i].d), + style: theme.textTheme.bodySmall, + ); }, ), ), @@ -277,112 +325,78 @@ class _ReturnsTable extends StatelessWidget { 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('XIRR', style: headerStyle), numeric: true), - DataColumn(label: Text('TWR', style: headerStyle), 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( + 'XIRR', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), + DataColumn( + label: TermLabel( + 'TWR', + style: headerStyle, + alignment: MainAxisAlignment.end, + ), + numeric: true, + ), ], rows: [ for (final r in rows) DataRow( cells: [ DataCell(Text(periodLabel(r.period))), - DataCell(MoneyText( - r.absPnlRub, - currency: 'RUB', - style: TextStyle(color: signColor(context, r.absPnlRub)), - )), + DataCell( + MoneyText( + r.absPnlRub, + currency: 'RUB', + style: TextStyle(color: signColor(context, r.absPnlRub)), + ), + ), DataCell(MoneyText(r.externalFlowRub, currency: 'RUB')), - DataCell(Text( - formatPercent(r.xirr), - style: TextStyle(color: signColor(context, r.xirr)), - )), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - formatPercent(r.twr), - style: TextStyle(color: signColor(context, r.twr)), - ), - if (r.twrDaysSkipped > 0) ...[ - const SizedBox(width: 4), - Tooltip( - message: 'Пропущено ${r.twrDaysSkipped} дн.: ' - 'в эти дни часть позиции была без цены', - child: const Icon(Icons.info_outline, size: 14), + DataCell( + Text( + formatPercent(r.xirr), + style: TextStyle(color: signColor(context, r.xirr)), + ), + ), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formatPercent(r.twr), + style: TextStyle(color: signColor(context, r.twr)), ), + if (r.twrDaysSkipped > 0) ...[ + const SizedBox(width: 4), + Tooltip( + message: + 'Пропущено ${r.twrDaysSkipped} дн.: ' + 'в эти дни часть позиции была без цены', + child: const Icon(Icons.info_outline, size: 14), + ), + ], ], - ], - )), - ], - ), - ], - ), - ); - } -} - -class _HoldingsTable extends StatelessWidget { - const _HoldingsTable({required this.rows}); - - final List rows; - - @override - Widget build(BuildContext context) { - final headerStyle = Theme.of(context).textTheme.labelMedium; - return SingleChildScrollView( - 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), numeric: true), - DataColumn(label: Text('XIRR', style: headerStyle), numeric: true), - ], - rows: [ - for (final r in rows) - DataRow( - onSelectChanged: (_) => context.push('/portfolio/instrument/${r.instrumentId}'), - cells: [ - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - r.ticker ?? r.name, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 6), - PriceStatusChip(status: r.priceStatus, priceDate: r.priceDate), - ], - )), - DataCell(Text(assetClassLabel(r.assetClass))), - DataCell(Text(formatQty(r.qty))), - DataCell(r.marketPrice == null - ? const Text('—') - : MoneyText(r.marketPrice!, currency: r.priceCurrency ?? r.currency)), - // a position with no price shows nothing, never 0 ₽: it is absent from the - // totals above, and a zero would read as "worthless" instead of "unknown" - DataCell(r.valueRub == null - ? const Text('—') - : MoneyText(r.valueRub!, currency: 'RUB')), - DataCell(Text(formatPercent(r.weight, signed: false))), - DataCell(r.unrealizedPnlRub == null - ? const Text('—') - : MoneyText( - r.unrealizedPnlRub!, - currency: 'RUB', - style: TextStyle(color: signColor(context, r.unrealizedPnlRub)), - )), - DataCell(Text( - formatPercent(r.xirr), - style: TextStyle(color: signColor(context, r.xirr)), - )), + ), + ), ], ), ], @@ -392,9 +406,9 @@ class _HoldingsTable extends StatelessWidget { } class _Card extends StatelessWidget { - const _Card({required this.title, required this.child}); + const _Card({required this.child, this.title}); - final String title; + final String? title; final Widget child; @override @@ -405,8 +419,10 @@ class _Card extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), + if (title != null) ...[ + TermLabel(title!, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + ], child, ], ), diff --git a/app/lib/features/portfolio/holdings_table.dart b/app/lib/features/portfolio/holdings_table.dart new file mode 100644 index 0000000..cef21fd --- /dev/null +++ b/app/lib/features/portfolio/holdings_table.dart @@ -0,0 +1,466 @@ +import 'package:decimal/decimal.dart'; +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/widgets/asset_icon.dart'; +import '../../core/widgets/money_text.dart'; +import '../../core/widgets/help_tip.dart'; +import 'labels.dart'; + +/// Which columns the table shows. Each preset answers one question about the same rows, the +/// way Snowball's «Мои активы | Общее | Дивиденды | Прибыль | Облигации» tabs do. +enum HoldingsPreset { + mine('Мои активы'), + overall('Общее'), + dividends('Дивиденды'), + profit('Прибыль'), + bonds('Облигации'); + + const HoldingsPreset(this.label); + final String label; +} + +/// A column: a header, a width, and how a position renders in it. A cell is one or two lines — +/// the figure and, muted below it, the same figure per unit or as a share. +class _Col { + const _Col(this.title, this.width, this.cell, {this.alignEnd = true}); + + final String title; + final double width; + final Widget Function(BuildContext context, HoldingOut h) cell; + final bool alignEnd; +} + +const _dash = Text('—'); + +/// A cell whose figure is unknown is a dash, never a zero: the position is absent from the +/// totals, and 0 ₽ would read as «worthless» instead of «unknown». +Widget _money( + String? v, { + String currency = 'RUB', + TextStyle? style, + bool signed = false, +}) { + if (v == null) return _dash; + final d = Decimal.parse(v); + final text = MoneyText.format(v, currency); + return Text(signed && d > Decimal.zero ? '+$text' : text, style: style); +} + +Widget _stack(BuildContext context, Widget top, Widget? bottom) { + final muted = Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + DefaultTextStyle.merge( + style: const TextStyle(fontWeight: FontWeight.w600), + child: top, + ), + if (bottom != null) DefaultTextStyle.merge(style: muted, child: bottom), + ], + ); +} + +/// `share` of a decimal-string pair, or null when the base is missing or not positive. +String? _ratio(String? part, String? base) { + if (part == null || base == null) return null; + final b = Decimal.parse(base); + if (b <= Decimal.zero) return null; + return (Decimal.parse(part) / b) + .toDecimal(scaleOnInfinitePrecision: 10) + .toString(); +} + +Widget _signedPercent(BuildContext context, String? share) { + if (share == null) return _dash; + final color = signColor(context, share); + final d = Decimal.parse(share); + final arrow = d > Decimal.zero + ? Icons.arrow_drop_up + : d < Decimal.zero + ? Icons.arrow_drop_down + : null; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (arrow != null) Icon(arrow, size: 18, color: color), + Text( + formatPercent(share, signed: false).replaceAll('-', ''), + style: TextStyle(color: color), + ), + ], + ); +} + +Widget _pnl(BuildContext context, String? amount, String? share) { + if (amount == null) return _dash; + return _stack( + context, + _money( + amount, + signed: true, + style: TextStyle(color: signColor(context, amount)), + ), + _signedPercent(context, share), + ); +} + +final _asset = _Col('Актив', 260, (context, h) { + final theme = Theme.of(context); + final title = h.name; + final ticker = h.ticker; + return Row( + children: [ + AssetIcon( + assetClass: h.assetClass, + logoUrl: h.logoUrl, + logoColor: h.logoColor, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + Row( + children: [ + if (ticker != null && ticker != title) + Flexible( + child: Text( + ticker, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate), + ], + ), + ], + ), + ), + ], + ); +}, alignEnd: false); + +final _qty = _Col('Кол-во', 90, (c, h) => Text(formatQty(h.qty))); + +final _invested = _Col( + 'Вложено', + 140, + (c, h) => _stack( + c, + _money(h.costTotalRub), + h.avgCost == null + ? null + : _money(h.avgCost, currency: h.costCurrency ?? h.currency), + ), +); + +final _value = _Col( + 'Текущая стоимость', + 170, + (c, h) => h.valueRub == null + ? _dash + : _stack( + c, + _money(h.valueRub), + h.marketPrice == null + ? null + : _money(h.marketPrice, currency: h.priceCurrency ?? h.currency), + ), +); + +final _income = _Col('Дивиденды', 120, (c, h) => _money(_nonZero(h.incomeRub))); + +final _incomeYield = _Col( + 'Див. доходность', + 130, + (c, h) => Text( + formatPercent(_nonZero(_ratio(h.incomeRub, h.costTotalRub)), signed: false), + ), +); + +final _profit = _Col( + 'Прибыль', + 130, + (c, h) => + _pnl(c, h.unrealizedPnlRub, _ratio(h.unrealizedPnlRub, h.costTotalRub)), +); + +final _weight = _Col( + 'Доля в портфеле', + 130, + (c, h) => Text(formatPercent(h.weight, signed: false)), +); + +final _xirr = _Col( + 'Доходность', + 120, + (c, h) => Text( + formatPercent(h.xirr), + style: TextStyle(color: signColor(c, h.xirr)), + ), +); + +final _held = _Col( + 'В портфеле', + 120, + (c, h) => Text(h.daysHeld == null ? '—' : '${h.daysHeld} дн.'), +); + +final _realized = _Col( + 'Реализовано', + 130, + (c, h) => _money( + h.realizedPnlRub, + signed: true, + style: TextStyle(color: signColor(c, h.realizedPnlRub)), + ), +); + +final _accrued = _Col( + 'НКД', + 110, + (c, h) => _money(_nonZero(h.accruedInterestRub)), +); + +/// A payout or accrual of zero is «none», shown as a dash; only a real amount is a figure. +String? _nonZero(String? v) => + v == null || Decimal.parse(v) == Decimal.zero ? null : v; + +final _columns = { + HoldingsPreset.mine: [ + _asset, + _qty, + _invested, + _value, + _income, + _incomeYield, + _profit, + _weight, + ], + HoldingsPreset.overall: [ + _asset, + _qty, + _invested, + _value, + _profit, + _xirr, + _weight, + _held, + ], + HoldingsPreset.dividends: [_asset, _qty, _income, _incomeYield], + HoldingsPreset.profit: [_asset, _profit, _realized, _income, _xirr], + HoldingsPreset.bonds: [ + _asset, + _qty, + _invested, + _value, + _accrued, + _profit, + _xirr, + ], +}; + +/// Позиции as a table: preset tabs, a search box, and one row per holding. A tap opens the +/// instrument card. +class HoldingsTable extends StatefulWidget { + const HoldingsTable({required this.rows, super.key}); + + final List rows; + + @override + State createState() => _HoldingsTableState(); +} + +class _HoldingsTableState extends State { + HoldingsPreset _preset = HoldingsPreset.mine; + String _query = ''; + + List get _visible { + final q = _query.trim().toLowerCase(); + return [ + for (final h in widget.rows) + if ((_preset != HoldingsPreset.bonds || h.assetClass == 'bond') && + (q.isEmpty || + h.name.toLowerCase().contains(q) || + (h.ticker ?? '').toLowerCase().contains(q))) + h, + ]; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final columns = _columns[_preset]!; + final rows = _visible; + const pad = 32.0; + final natural = columns.fold(0, (sum, c) => sum + c.width); + + return LayoutBuilder( + builder: (context, constraints) { + // spread the columns over the card when there is room; scroll sideways when there is not + final room = constraints.maxWidth.isFinite + ? constraints.maxWidth - pad + : natural; + final scale = room > natural ? room / natural : 1.0; + final tableWidth = natural * scale + pad; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.spaceBetween, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final p in HoldingsPreset.values) + InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => setState(() => _preset = p), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Text( + p.label, + style: TextStyle( + color: p == _preset + ? scheme.onSurface + : scheme.onSurfaceVariant, + fontWeight: p == _preset + ? FontWeight.w700 + : FontWeight.w400, + ), + ), + ), + ), + ], + ), + SizedBox( + width: 260, + child: TextField( + onChanged: (v) => setState(() => _query = v), + decoration: const InputDecoration( + hintText: 'Найти…', + isDense: true, + prefixIcon: Icon(Icons.search, size: 20), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tableWidth, + child: Column( + children: [ + _HeaderRow(columns: columns, scale: scale), + Divider(color: scheme.outlineVariant), + if (rows.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text('Ничего не найдено'), + ), + for (final h in rows) + InkWell( + onTap: () => context.push( + '/portfolio/instrument/${h.instrumentId}', + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: scheme.outlineVariant), + ), + ), + child: Row( + children: [ + for (final c in columns) + SizedBox( + width: c.width * scale, + child: Align( + alignment: c.alignEnd + ? Alignment.centerRight + : Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only( + right: c.alignEnd ? 8 : 0, + ), + child: c.cell(context, h), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +class _HeaderRow extends StatelessWidget { + const _HeaderRow({required this.columns, required this.scale}); + + final List<_Col> columns; + final double scale; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.labelMedium + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + for (final c in columns) + SizedBox( + width: c.width * scale, + child: Padding( + padding: EdgeInsets.only(right: c.alignEnd ? 8 : 0), + child: TermLabel( + c.title, + textAlign: c.alignEnd ? TextAlign.end : TextAlign.start, + alignment: c.alignEnd + ? MainAxisAlignment.end + : MainAxisAlignment.start, + style: style, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/portfolio/instrument_edit_dialog.dart b/app/lib/features/portfolio/instrument_edit_dialog.dart new file mode 100644 index 0000000..509aec5 --- /dev/null +++ b/app/lib/features/portfolio/instrument_edit_dialog.dart @@ -0,0 +1,147 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; + +import 'labels.dart'; + +/// Edit form for the fields of an instrument a person may correct. Returns a patch holding +/// only what changed, or null when cancelled or nothing changed. +class InstrumentEditDialog extends StatefulWidget { + const InstrumentEditDialog({required this.instrument, super.key}); + + final InstrumentOut instrument; + + @override + State createState() => _InstrumentEditDialogState(); +} + +class _InstrumentEditDialogState extends State { + final _formKey = GlobalKey(); + late final _name = TextEditingController(text: widget.instrument.name); + late final _board = TextEditingController( + text: widget.instrument.board ?? '', + ); + late final _lot = TextEditingController(text: '${widget.instrument.lot}'); + late final _sector = TextEditingController( + text: widget.instrument.sector ?? '', + ); + late String _assetClass = widget.instrument.assetClass; + + @override + void dispose() { + _name.dispose(); + _board.dispose(); + _lot.dispose(); + _sector.dispose(); + super.dispose(); + } + + InstrumentPatch? _patch() { + final i = widget.instrument; + final name = _name.text.trim(); + final board = _board.text.trim(); + final sector = _sector.text.trim(); + final lot = int.parse(_lot.text.trim()); + final patch = InstrumentPatch( + name: name != i.name ? name : null, + assetClass: _assetClass != i.assetClass ? _assetClass : null, + // an empty field means "leave as is": the wire format cannot express "clear" + board: board.isNotEmpty && board != (i.board ?? '') ? board : null, + lot: lot != i.lot ? lot : null, + sector: sector.isNotEmpty && sector != (i.sector ?? '') ? sector : null, + ); + final changed = + patch.name != null || + patch.assetClass != null || + patch.board != null || + patch.lot != null || + patch.sector != null; + return changed ? patch : null; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Инструмент ${widget.instrument.ticker ?? ''}'.trim()), + content: SizedBox( + width: 420, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _name, + decoration: const InputDecoration(labelText: 'Название'), + validator: (v) => + (v ?? '').trim().isEmpty ? 'Обязательное поле' : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: assetClassLabels.containsKey(_assetClass) + ? _assetClass + : null, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Класс актива'), + items: [ + for (final e in assetClassLabels.entries) + DropdownMenuItem( + value: e.key, + child: Text('${e.value} (${e.key})'), + ), + ], + onChanged: (v) => + setState(() => _assetClass = v ?? _assetClass), + ), + const SizedBox(height: 12), + TextFormField( + controller: _board, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration( + labelText: 'Доска (TQBR, TQTF…)', + helperText: 'По ней синк Мосбиржи находит котировки', + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _lot, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Лот', + helperText: + 'Ребалансировка округляет сделки до целого числа лотов', + helperMaxLines: 2, + ), + validator: (v) { + final n = int.tryParse((v ?? '').trim()); + return n == null || n < 1 + ? 'Целое число не меньше 1' + : null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _sector, + decoration: const InputDecoration(labelText: 'Сектор'), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + if (!(_formKey.currentState?.validate() ?? false)) return; + Navigator.of(context).pop(_patch()); + }, + child: const Text('Сохранить'), + ), + ], + ); + } +} diff --git a/app/lib/features/portfolio/instrument_page.dart b/app/lib/features/portfolio/instrument_page.dart index 51d4985..bb03b77 100644 --- a/app/lib/features/portfolio/instrument_page.dart +++ b/app/lib/features/portfolio/instrument_page.dart @@ -1,15 +1,21 @@ import 'package:decimal/decimal.dart'; +import 'package:dio/dio.dart'; import 'package:fintracker_api/fintracker_api.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/api/api_client.dart'; +import '../../core/auth/auth_controller.dart'; import '../../core/theme/chart_colors.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/asset_icon.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; import '../../core/widgets/stale_banner.dart'; +import '../../core/widgets/help_tip.dart'; +import 'instrument_edit_dialog.dart'; import 'labels.dart'; import 'providers.dart'; @@ -23,15 +29,51 @@ class InstrumentPage extends ConsumerWidget { final int instrumentId; + Future _edit( + BuildContext context, + WidgetRef ref, + InstrumentOut instrument, + ) async { + final patch = await showDialog( + context: context, + builder: (_) => InstrumentEditDialog(instrument: instrument), + ); + if (patch == null || !context.mounted) return; + final messenger = ScaffoldMessenger.of(context); + try { + final api = ref.read(apiProvider); + await api.getInstrumentsApi().instrumentsPatch( + instrumentId: instrumentId, + instrumentPatch: patch, + ); + invalidatePortfolioProviders(ref); + try { + // the asset class feeds the allocation metrics; the lot only the live rebalancing + await api.getMetricsApi().metricsRefresh(); + } on DioException { + // the next scheduled or manual refresh picks the change up + } + } on DioException catch (e) { + messenger.showSnackBar(SnackBar(content: Text(problemMessage(e)))); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final detail = ref.watch(instrumentProvider(instrumentId)); + final instrument = detail.valueOrNull?.data.instrument; return Scaffold( appBar: AppBar( - title: Text(detail.valueOrNull?.data.instrument.ticker ?? - detail.valueOrNull?.data.instrument.name ?? - 'Инструмент'), + title: Text(instrument?.ticker ?? instrument?.name ?? 'Инструмент'), + actions: [ + if (instrument != null) + IconButton( + tooltip: 'Редактировать', + icon: const Icon(Icons.edit_outlined), + onPressed: () => _edit(context, ref, instrument), + ), + ], ), body: AsyncValueView( value: detail, @@ -41,7 +83,8 @@ class InstrumentPage extends ConsumerWidget { return ListView( padding: const EdgeInsets.all(16), children: [ - if (cached.fetchedAt != null) StaleBanner(fetchedAt: cached.fetchedAt!), + if (cached.fetchedAt != null) + StaleBanner(fetchedAt: cached.fetchedAt!), _Header(instrument: d.instrument, holding: d.holding), const SizedBox(height: 16), _Section( @@ -57,14 +100,20 @@ class InstrumentPage extends ConsumerWidget { _Section( title: 'Лоты', child: d.lots.isEmpty - ? const EmptyState(icon: Icons.layers_outlined, message: 'Лотов нет.') + ? const EmptyState( + icon: Icons.layers_outlined, + message: 'Лотов нет.', + ) : _LotsTable(lots: d.lots), ), const SizedBox(height: 16), _Section( title: 'События', child: d.events.isEmpty - ? const EmptyState(icon: Icons.receipt_long, message: 'Событий нет.') + ? const EmptyState( + icon: Icons.receipt_long, + message: 'Событий нет.', + ) : _EventsTable(events: d.events), ), ], @@ -97,10 +146,32 @@ class _Header extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(instrument.name, style: theme.textTheme.titleLarge), - const SizedBox(height: 4), - Text(facts.join(' · '), - style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), + Row( + children: [ + AssetIcon( + assetClass: instrument.assetClass, + logoUrl: instrument.logoUrl, + logoColor: instrument.logoColor, + size: 48, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(instrument.name, style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + facts.join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), + ), + ], + ), + ), + ], + ), const SizedBox(height: 16), if (holding == null) Text('Позиция закрыта', style: theme.textTheme.bodyMedium) @@ -139,7 +210,10 @@ class _HoldingFacts extends StatelessWidget { children: [ h.marketPrice == null ? const Text('—') - : MoneyText(h.marketPrice!, currency: h.priceCurrency ?? h.currency), + : MoneyText( + h.marketPrice!, + currency: h.priceCurrency ?? h.currency, + ), const SizedBox(width: 6), PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate), ], @@ -147,7 +221,9 @@ class _HoldingFacts extends StatelessWidget { ), _Fact( label: 'Стоимость', - value: h.valueRub == null ? const Text('—') : MoneyText(h.valueRub!, currency: 'RUB'), + value: h.valueRub == null + ? const Text('—') + : MoneyText(h.valueRub!, currency: 'RUB'), ), _Fact( label: 'Нереализованная', @@ -156,14 +232,19 @@ class _HoldingFacts extends StatelessWidget { : MoneyText( h.unrealizedPnlRub!, currency: 'RUB', - style: TextStyle(color: signColor(context, h.unrealizedPnlRub)), + style: TextStyle( + color: signColor(context, h.unrealizedPnlRub), + ), ), ), _Fact( label: 'Реализованная', value: MoneyText(h.realizedPnlRub ?? '0', currency: 'RUB'), ), - _Fact(label: 'Выплаты', value: MoneyText(h.incomeRub ?? '0', currency: 'RUB')), + _Fact( + label: 'Выплаты', + value: MoneyText(h.incomeRub ?? '0', currency: 'RUB'), + ), _Fact( label: 'XIRR', value: Text( @@ -195,7 +276,10 @@ class _Fact extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), + TermLabel( + label, + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + ), const SizedBox(height: 2), DefaultTextStyle(style: theme.textTheme.titleSmall!, child: value), ], @@ -230,8 +314,12 @@ class _PriceChart extends StatelessWidget { interval: (prices.length / 4).clamp(1, double.infinity), getTitlesWidget: (value, meta) { final i = value.round(); - if (i < 0 || i >= prices.length) return const SizedBox.shrink(); - return Text(ruMonthYearShort(prices[i].d), style: theme.textTheme.bodySmall); + if (i < 0 || i >= prices.length) + return const SizedBox.shrink(); + return Text( + ruMonthYearShort(prices[i].d), + style: theme.textTheme.bodySmall, + ); }, ), ), @@ -245,7 +333,7 @@ class _PriceChart extends StatelessWidget { isCurved: false, color: ChartColors.slot1Blue, barWidth: 2, - dotData: const FlDotData(show: false), + dotData: FlDotData(show: prices.length < 2), ), ], ), @@ -268,9 +356,15 @@ class _LotsTable extends StatelessWidget { 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)), ], rows: [ @@ -280,11 +374,17 @@ class _LotsTable extends StatelessWidget { DataCell(Text(ruDate(lot.openDate))), DataCell(Text(formatQty(lot.qtyOpen))), DataCell(Text(formatQty(lot.qtyRemaining))), - DataCell(MoneyText(lot.costPerUnit, currency: lot.costCurrency)), - DataCell(lot.costTotalRub == null - ? const Text('—') - : MoneyText(lot.costTotalRub!, currency: 'RUB')), - DataCell(Text(lot.closedAt == null ? '—' : ruDate(lot.closedAt!))), + DataCell( + MoneyText(lot.costPerUnit, currency: lot.costCurrency), + ), + DataCell( + lot.costTotalRub == null + ? const Text('—') + : MoneyText(lot.costTotalRub!, currency: 'RUB'), + ), + DataCell( + Text(lot.closedAt == null ? '—' : ruDate(lot.closedAt!)), + ), ], ), ], @@ -317,32 +417,48 @@ class _EventsTable extends StatelessWidget { DataRow( cells: [ DataCell(Text(ruDate(e.tradeDate))), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(eventKindLabel(e.kind)), - if (e.externalFlow) ...[ - const SizedBox(width: 4), - const Tooltip( - message: 'Внешний поток — учитывается в XIRR', - child: Icon(Icons.swap_vert, size: 14), - ), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(eventKindLabel(e.kind)), + if (e.externalFlow) ...[ + const SizedBox(width: 4), + const Tooltip( + message: 'Внешний поток — учитывается в XIRR', + child: Icon(Icons.swap_vert, size: 14), + ), + ], ], - ], - )), - DataCell(Text(e.quantity == null ? '—' : formatQty(e.quantity!))), - DataCell(e.price == null - ? const Text('—') - : MoneyText(e.price!, currency: e.priceCurrency ?? e.currency)), - DataCell(MoneyText( - e.amount, - currency: e.currency, - style: TextStyle(color: signColor(context, e.amount)), - )), - DataCell(SizedBox( - width: 260, - child: Text(e.description ?? '', overflow: TextOverflow.ellipsis), - )), + ), + ), + DataCell( + Text(e.quantity == null ? '—' : formatQty(e.quantity!)), + ), + DataCell( + e.price == null + ? const Text('—') + : MoneyText( + e.price!, + currency: e.priceCurrency ?? e.currency, + ), + ), + DataCell( + MoneyText( + e.amount, + currency: e.currency, + style: TextStyle(color: signColor(context, e.amount)), + ), + ), + DataCell( + SizedBox( + width: 260, + child: Text( + e.description ?? '', + overflow: TextOverflow.ellipsis, + ), + ), + ), ], ), ], diff --git a/app/lib/features/portfolio/labels.dart b/app/lib/features/portfolio/labels.dart index 87421ec..3566de2 100644 --- a/app/lib/features/portfolio/labels.dart +++ b/app/lib/features/portfolio/labels.dart @@ -38,7 +38,8 @@ const dimensionLabels = { 'currency': 'Валюта', }; -String assetClassLabel(String? value) => assetClassLabels[value] ?? value ?? '—'; +String assetClassLabel(String? value) => + assetClassLabels[value] ?? value ?? '—'; /// A bucket key as a person reads it. `cash` and `unknown` are the two literals the /// allocation step emits; everything else is the source's own value. @@ -89,7 +90,8 @@ const eventKindLabels = { 'other': 'Прочее', }; -String eventKindLabel(EventKind kind) => eventKindLabels[kind.value] ?? kind.value; +String eventKindLabel(EventKind kind) => + eventKindLabels[kind.value] ?? kind.value; /// `'0.1234'` as `'+12,34 %'`. Null becomes an em dash: a return nobody could compute is /// not zero percent. @@ -111,13 +113,17 @@ Color? signColor(BuildContext context, String? value) { if (value == null) return null; final d = Decimal.parse(value); if (d == Decimal.zero) return null; - return d > Decimal.zero ? ChartColors.slot3Aqua : ChartColors.slot2Orange; + return d > Decimal.zero ? ChartColors.gain : ChartColors.loss; } /// The price-status chip every value on screen depends on: a stale price still produces a /// number, a missing one produces nothing at all, and both must be visible. class PriceStatusChip extends StatelessWidget { - const PriceStatusChip({required this.status, required this.priceDate, super.key}); + const PriceStatusChip({ + required this.status, + required this.priceDate, + super.key, + }); final String status; final DateTime? priceDate; diff --git a/app/lib/features/portfolio/overview_tiles.dart b/app/lib/features/portfolio/overview_tiles.dart new file mode 100644 index 0000000..34c5020 --- /dev/null +++ b/app/lib/features/portfolio/overview_tiles.dart @@ -0,0 +1,222 @@ +import 'package:decimal/decimal.dart'; +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/cache/cached.dart'; +import '../../core/theme/chart_colors.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/money_text.dart'; +import '../home/providers.dart' show scopeCardsProvider; +import '../../core/widgets/help_tip.dart'; +import 'labels.dart'; +import 'providers.dart'; + +/// The four figures on top of Аналитика → Общее: what the scope is worth, what it earned, its +/// return and the passive income it should bring in a year. Everything comes from the same +/// providers as Портфель and the home cards, so the numbers cannot disagree between screens. +class OverviewTiles extends ConsumerWidget { + const OverviewTiles({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final summary = ref.watch(portfolioSummaryProvider); + final scope = ref.watch(scopeProvider); + final card = ref + .watch(scopeCardsProvider) + .valueOrNull + ?.data + .where((c) => c.scope == scope) + .firstOrNull; + + return AsyncValueView>( + value: summary, + onRetry: () => ref.invalidate(portfolioSummaryProvider), + data: (cached) => _Grid(summary: cached.data, card: card), + ); + } +} + +class _Grid extends StatelessWidget { + const _Grid({required this.summary, required this.card}); + + final SummaryOut summary; + final ScopeCardOut? card; + + static const _gap = 16.0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final yearly = summary.returns.where((r) => r.period == '1y').firstOrNull; + final all = summary.returns.where((r) => r.period == 'all').firstOrNull; + final xirr = card?.xirr ?? all?.xirr; + final pnl = summary.pnlTotalRub; + final pnlShare = + pnl != null && Decimal.parse(summary.investedNetRub) > Decimal.zero + ? (Decimal.parse(pnl) / Decimal.parse(summary.investedNetRub)) + .toDecimal(scaleOnInfinitePrecision: 10) + .toString() + : null; + final day = card?.dayChangeRub; + final income = card?.incomeYearRub; + + Widget muted(String text, {Color? color}) => Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + color: color ?? scheme.onSurfaceVariant, + ), + ); + + final tiles = [ + _Tile( + icon: Icons.account_balance_wallet_outlined, + label: 'Стоимость', + value: Text(MoneyText.format(summary.totalRub, 'RUB')), + note: muted( + '${MoneyText.format(summary.investedNetRub, 'RUB')} вложено', + ), + ), + _Tile( + icon: Icons.show_chart, + label: 'Прибыль', + value: pnl == null + ? const Text('—') + : Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + (Decimal.parse(pnl) > Decimal.zero ? '+' : '') + + MoneyText.format(pnl, 'RUB'), + style: TextStyle(color: signColor(context, pnl)), + ), + if (pnlShare != null) + Text( + ' ${formatPercent(pnlShare)}', + style: theme.textTheme.titleSmall?.copyWith( + color: signColor(context, pnl), + ), + ), + ], + ), + note: day == null + ? muted(pnl == null ? 'часть позиций без цены' : 'за день —') + : muted( + '${Decimal.parse(day) > Decimal.zero ? '+' : ''}${MoneyText.format(day, 'RUB')}' + '${card?.dayChangePct == null ? '' : ' ${formatPercent(card!.dayChangePct)}'} за день', + color: signColor(context, day), + ), + ), + _Tile( + icon: Icons.percent, + label: 'Доходность', + value: Text( + formatPercent(xirr, signed: false), + style: TextStyle(color: signColor(context, xirr)), + ), + note: muted( + yearly?.twr == null + ? 'денежно-взвешенная, с начала' + : 'рост активов ${formatPercent(yearly!.twr)} за год', + ), + ), + _Tile( + icon: Icons.savings_outlined, + label: 'Пассивный доход', + value: Text( + card?.incomeYearPct == null + ? '—' + : formatPercent(card!.incomeYearPct, signed: false), + ), + note: muted( + income == null || Decimal.parse(income) == Decimal.zero + ? 'прогноза выплат пока нет' + : '${MoneyText.format(income, 'RUB')} в год', + color: income == null || Decimal.parse(income) == Decimal.zero + ? null + : ChartColors.gain, + ), + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final columns = width >= 1000 ? 4 : (width >= 560 ? 2 : 1); + final itemWidth = (width - _gap * (columns - 1)) / columns; + return Wrap( + spacing: _gap, + runSpacing: _gap, + children: [ + for (final t in tiles) SizedBox(width: itemWidth, child: t), + ], + ); + }, + ); + } +} + +class _Tile extends StatelessWidget { + const _Tile({ + required this.icon, + required this.label, + required this.value, + required this.note, + }); + + final IconData icon; + final String label; + final Widget value; + final Widget note; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: scheme.primary), + ), + const SizedBox(width: 10), + TermLabel( + label, + hint: label == 'Стоимость' + ? 'Стоимость — всё сразу: бумаги по последним ценам плюс деньги на счёте. ' + 'Ниже — сколько вы вложили (пополнения минус выводы).' + : null, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + DefaultTextStyle.merge( + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + child: value, + ), + const SizedBox(height: 8), + note, + ], + ), + ), + ); + } +} diff --git a/app/lib/features/portfolio/portfolio_page.dart b/app/lib/features/portfolio/portfolio_page.dart index d5620e2..f592f02 100644 --- a/app/lib/features/portfolio/portfolio_page.dart +++ b/app/lib/features/portfolio/portfolio_page.dart @@ -1,14 +1,13 @@ -import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/cache/cached.dart'; -import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/stale_banner.dart'; import 'allocation_tab.dart'; import 'benchmarks_card.dart'; import 'holdings_tab.dart'; import 'providers.dart'; +import 'scope_selector.dart'; /// Портфель: позиции и аллокация as two tabs of one screen, sharing one scope. /// @@ -37,9 +36,12 @@ class PortfolioPage extends ConsumerWidget { child: Scaffold( appBar: AppBar( title: const Text('Портфель'), - actions: const [_ScopeSelector(), SizedBox(width: 8)], + actions: const [ScopeSelector(), SizedBox(width: 8)], bottom: const TabBar( - tabs: [Tab(text: 'Позиции'), Tab(text: 'Аллокация')], + tabs: [ + Tab(text: 'Позиции'), + Tab(text: 'Аллокация'), + ], ), ), body: Column( @@ -50,7 +52,12 @@ class PortfolioPage extends ConsumerWidget { child: StaleBanner(fetchedAt: stale), ), const Expanded( - child: TabBarView(children: [HoldingsTab(), AllocationTab()]), + child: TabBarView( + children: [ + HoldingsTab(sections: HoldingsSections.table), + AllocationTab(), + ], + ), ), ], ), @@ -58,39 +65,3 @@ class PortfolioPage extends ConsumerWidget { ); } } - -/// Switches every portfolio screen at once. Hidden while there is nothing to choose -/// between — a dropdown with one option is furniture, not a control. -class _ScopeSelector extends ConsumerWidget { - const _ScopeSelector(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scopes = ref.watch(scopesProvider); - final current = ref.watch(scopeProvider); - - return AsyncValueView>( - value: scopes, - data: (rows) { - if (rows.length < 2) return const SizedBox.shrink(); - final known = rows.any((s) => s.scope == current) ? current : rows.first.scope; - return DropdownButtonHideUnderline( - child: DropdownButton( - value: known, - borderRadius: BorderRadius.circular(8), - items: [ - for (final s in rows) - DropdownMenuItem( - value: s.scope, - child: Text(s.name, overflow: TextOverflow.ellipsis), - ), - ], - onChanged: (value) { - if (value != null) ref.read(scopeProvider.notifier).state = value; - }, - ), - ); - }, - ); - } -} diff --git a/app/lib/features/portfolio/providers.dart b/app/lib/features/portfolio/providers.dart index 3cf265e..ef0cc4b 100644 --- a/app/lib/features/portfolio/providers.dart +++ b/app/lib/features/portfolio/providers.dart @@ -20,51 +20,86 @@ final scopesProvider = FutureProvider.autoDispose>((ref) async { }); /// See `docs/ai/offline-cache.md`. -final portfolioSummaryProvider = FutureProvider.autoDispose>((ref) async { +final portfolioSummaryProvider = FutureProvider.autoDispose>( + (ref) async { + final scope = ref.watch(scopeProvider); + final r = await ref + .watch(apiProvider) + .getAnalyticsApi() + .analyticsSummary(scope: scope); + return r.cached; + }, +); + +final holdingsProvider = FutureProvider.autoDispose>>(( + ref, +) async { final scope = ref.watch(scopeProvider); - final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(scope: scope); - return r.cached; + final r = await ref + .watch(apiProvider) + .getAnalyticsApi() + .analyticsHoldings(scope: scope); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); }); -final holdingsProvider = FutureProvider.autoDispose>>((ref) async { - final scope = ref.watch(scopeProvider); - final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsHoldings(scope: scope); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); -}); +final portfolioReturnsProvider = + FutureProvider.autoDispose>>((ref) async { + final scope = ref.watch(scopeProvider); + final r = await ref + .watch(apiProvider) + .getAnalyticsApi() + .analyticsReturns(scope: scope); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); + }); -final portfolioReturnsProvider = FutureProvider.autoDispose>>((ref) async { - final scope = ref.watch(scopeProvider); - final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsReturns(scope: scope); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); -}); - -final allocationProvider = FutureProvider.autoDispose>>((ref) async { - final scope = ref.watch(scopeProvider); - final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsAllocation(scope: scope); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); -}); +final allocationProvider = + FutureProvider.autoDispose>>((ref) async { + final scope = ref.watch(scopeProvider); + final r = await ref + .watch(apiProvider) + .getAnalyticsApi() + .analyticsAllocation(scope: scope); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); + }); /// Daily portfolio value for the last year, for the chart on Позиции. -final valueSeriesProvider = FutureProvider.autoDispose>>((ref) async { +final valueSeriesProvider = FutureProvider.autoDispose>>(( + ref, +) async { final scope = ref.watch(scopeProvider); final now = DateTime.now(); - final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsValueSeries( + final r = await ref + .watch(apiProvider) + .getAnalyticsApi() + .analyticsValueSeries( scope: scope, from: now.subtract(const Duration(days: 365)), to: now, ); - return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); + return Cached( + r.data ?? const [], + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); }); -final instrumentProvider = - FutureProvider.autoDispose.family, int>((ref, instrumentId) async { - final scope = ref.watch(scopeProvider); - final r = await ref - .watch(apiProvider) - .getInstrumentsApi() - .instrumentsGet(instrumentId: instrumentId, scope: scope); - return r.cached; -}); +final instrumentProvider = FutureProvider.autoDispose + .family, int>((ref, instrumentId) async { + final scope = ref.watch(scopeProvider); + final r = await ref + .watch(apiProvider) + .getInstrumentsApi() + .instrumentsGet(instrumentId: instrumentId, scope: scope); + return r.cached; + }); /// Every portfolio provider, refreshed together after a metrics rebuild or a pull-to-refresh. void invalidatePortfolioProviders(WidgetRef ref) { diff --git a/app/lib/features/portfolio/scope_selector.dart b/app/lib/features/portfolio/scope_selector.dart new file mode 100644 index 0000000..f6ab356 --- /dev/null +++ b/app/lib/features/portfolio/scope_selector.dart @@ -0,0 +1,44 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/widgets/async_value_view.dart'; +import 'providers.dart'; + +/// Switches every portfolio screen at once. Hidden while there is nothing to choose +/// between — a dropdown with one option is furniture, not a control. +class ScopeSelector extends ConsumerWidget { + const ScopeSelector({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scopes = ref.watch(scopesProvider); + final current = ref.watch(scopeProvider); + + return AsyncValueView>( + value: scopes, + data: (rows) { + if (rows.length < 2) return const SizedBox.shrink(); + final known = rows.any((s) => s.scope == current) + ? current + : rows.first.scope; + return DropdownButtonHideUnderline( + child: DropdownButton( + value: known, + borderRadius: BorderRadius.circular(8), + items: [ + for (final s in rows) + DropdownMenuItem( + value: s.scope, + child: Text(s.name, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (value) { + if (value != null) ref.read(scopeProvider.notifier).state = value; + }, + ), + ); + }, + ); + } +} diff --git a/app/lib/features/portfolios/portfolio_edit_dialog.dart b/app/lib/features/portfolios/portfolio_edit_dialog.dart new file mode 100644 index 0000000..8c0bfe2 --- /dev/null +++ b/app/lib/features/portfolios/portfolio_edit_dialog.dart @@ -0,0 +1,130 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../accounts/accounts_page.dart' show accountKindLabel; +import '../accounts/providers.dart'; + +class PortfolioDraft { + const PortfolioDraft({required this.name, required this.accountIds}); + final String name; + final Set accountIds; +} + +/// Create/edit form for a portfolio: a name and the accounts it is made of. Returns the +/// draft to save, or null when cancelled. +class PortfolioEditDialog extends ConsumerStatefulWidget { + const PortfolioEditDialog({super.key, this.initial}); + + final PortfolioOut? initial; + + @override + ConsumerState createState() => + _PortfolioEditDialogState(); +} + +class _PortfolioEditDialogState extends ConsumerState { + final _formKey = GlobalKey(); + late final _name = TextEditingController(text: widget.initial?.name ?? ''); + late final Set _selected = {...?widget.initial?.accountIds}; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final accounts = + ref.watch(accountsProvider).valueOrNull?.data ?? const []; + // Brokers first: that is what a portfolio to rebalance is made of. An archived account + // stays listed only while the portfolio already contains it, so it can be removed. + final shown = + [ + for (final a in accounts) + if (!a.archived || _selected.contains(a.id)) a, + ]..sort((a, b) { + final byBroker = + (b.kind == AccountKind.broker ? 1 : 0) - + (a.kind == AccountKind.broker ? 1 : 0); + return byBroker != 0 ? byBroker : a.name.compareTo(b.name); + }); + + return AlertDialog( + title: Text(widget.initial == null ? 'Новый портфель' : 'Портфель'), + content: SizedBox( + width: 420, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _name, + decoration: const InputDecoration(labelText: 'Название'), + validator: (v) => + (v ?? '').trim().isEmpty ? 'Обязательное поле' : null, + ), + const SizedBox(height: 16), + Text( + 'Счета в портфеле', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 4), + Flexible( + child: shown.isEmpty + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text('Счетов ещё нет — нужна синхронизация.'), + ) + : ListView( + shrinkWrap: true, + children: [ + for (final a in shown) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + dense: true, + value: _selected.contains(a.id), + title: Text(a.name), + subtitle: Text( + '${accountKindLabel(a.kind)} · ${a.currency}', + ), + onChanged: (v) => setState(() { + if (v ?? false) { + _selected.add(a.id); + } else { + _selected.remove(a.id); + } + }), + ), + ], + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + if (!(_formKey.currentState?.validate() ?? false)) return; + Navigator.of(context).pop( + PortfolioDraft( + name: _name.text.trim(), + accountIds: {..._selected}, + ), + ); + }, + child: const Text('Сохранить'), + ), + ], + ); + } +} diff --git a/app/lib/features/portfolios/portfolios_page.dart b/app/lib/features/portfolios/portfolios_page.dart new file mode 100644 index 0000000..ae37427 --- /dev/null +++ b/app/lib/features/portfolios/portfolios_page.dart @@ -0,0 +1,197 @@ +import 'package:dio/dio.dart'; +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/foundation.dart' show setEquals; + +import '../../core/api/api_client.dart'; +import '../../core/auth/auth_controller.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../accounts/providers.dart'; +import 'portfolio_edit_dialog.dart'; +import 'providers.dart'; + +String _accountsLabel(int n) { + final mod10 = n % 10, mod100 = n % 100; + final word = mod10 == 1 && mod100 != 11 + ? 'счёт' + : (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) + ? 'счёта' + : 'счетов'; + return '$n $word'; +} + +/// Портфели: a portfolio is a named set of accounts. Rebalancing and the portfolio scope of +/// the analytics screens are computed over it. +class PortfoliosPage extends ConsumerStatefulWidget { + const PortfoliosPage({super.key}); + + @override + ConsumerState createState() => _PortfoliosPageState(); +} + +class _PortfoliosPageState extends ConsumerState { + void _snack(String message) => + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + + /// Queues a metrics rebuild so the portfolio's value history follows the new account set. + /// Best effort: the portfolio itself is already saved, and the rebalance screen does not + /// depend on it. + Future _queueMetricsRefresh() async { + try { + await ref.read(apiProvider).getMetricsApi().metricsRefresh(); + } on DioException { + // the next scheduled or manual refresh picks the change up + } + } + + Future _run(Future Function() action) async { + try { + await action(); + if (!mounted) return; + invalidatePortfolios(ref); + await _queueMetricsRefresh(); + } on DioException catch (e) { + if (mounted) _snack(problemMessage(e)); + } + } + + Future _create() async { + final draft = await showDialog( + context: context, + builder: (_) => const PortfolioEditDialog(), + ); + if (draft == null) return; + await _run(() async { + await ref + .read(apiProvider) + .getPortfoliosApi() + .portfoliosCreate( + portfolioCreate: PortfolioCreate( + name: draft.name, + accountIds: draft.accountIds.toList()..sort(), + ), + ); + }); + } + + Future _edit(PortfolioOut portfolio) async { + final draft = await showDialog( + context: context, + builder: (_) => PortfolioEditDialog(initial: portfolio), + ); + if (draft == null) return; + await _run(() async { + final api = ref.read(apiProvider).getPortfoliosApi(); + if (draft.name != portfolio.name) { + await api.portfoliosPatch( + portfolioId: portfolio.id, + portfolioPatch: PortfolioPatch(name: draft.name), + ); + } + if (!setEquals(draft.accountIds, portfolio.accountIds.toSet())) { + await api.portfoliosSetAccounts( + portfolioId: portfolio.id, + portfolioAccountsIn: PortfolioAccountsIn( + accountIds: draft.accountIds.toList()..sort(), + ), + ); + } + }); + } + + Future _delete(PortfolioOut portfolio) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Удалить портфель?'), + content: Text( + '«${portfolio.name}» и его целевые доли будут удалены. Сами счета останутся.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Удалить'), + ), + ], + ), + ); + if (confirmed != true) return; + await _run(() async { + await ref + .read(apiProvider) + .getPortfoliosApi() + .portfoliosDelete(portfolioId: portfolio.id); + }); + } + + @override + Widget build(BuildContext context) { + final portfolios = ref.watch(portfolioListProvider); + final names = ref.watch(accountNamesProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Портфели')), + floatingActionButton: FloatingActionButton.extended( + onPressed: _create, + icon: const Icon(Icons.add), + label: const Text('Новый портфель'), + ), + body: RefreshIndicator( + onRefresh: () async => ref.invalidate(portfolioListProvider), + child: AsyncValueView( + value: portfolios, + onRetry: () => ref.invalidate(portfolioListProvider), + data: (rows) { + if (rows.isEmpty) { + return ListView( + padding: const EdgeInsets.all(16), + children: const [ + SizedBox(height: 48), + EmptyState( + icon: Icons.pie_chart_outline, + message: + 'Портфелей пока нет.\n' + 'Портфель — это набор счетов; по нему считаются ребалансировка ' + 'и аналитика. Создайте его и отметьте брокерские счета.', + ), + ], + ); + } + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), + children: [ + for (final p in rows) + Card( + child: ListTile( + title: Text(p.name), + subtitle: Text( + p.accountIds.isEmpty + ? 'Нет счетов' + : '${_accountsLabel(p.accountIds.length)}: ' + '${p.accountIds.map((id) => names[id] ?? '#$id').join(', ')}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: () => _edit(p), + trailing: IconButton( + tooltip: 'Удалить', + icon: const Icon(Icons.delete_outline), + onPressed: () => _delete(p), + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/features/portfolios/providers.dart b/app/lib/features/portfolios/providers.dart new file mode 100644 index 0000000..7666015 --- /dev/null +++ b/app/lib/features/portfolios/providers.dart @@ -0,0 +1,21 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import '../portfolio/providers.dart' show scopesProvider; +import '../rebalance/providers.dart' show portfoliosProvider; + +final portfolioListProvider = FutureProvider.autoDispose>(( + ref, +) async { + final r = await ref.watch(apiProvider).getPortfoliosApi().portfoliosList(); + return r.data ?? const []; +}); + +/// After any change the list, the scope picker and the rebalance screen's portfolio list +/// (both derived from scopes) must be refetched. +void invalidatePortfolios(WidgetRef ref) { + ref.invalidate(portfolioListProvider); + ref.invalidate(scopesProvider); + ref.invalidate(portfoliosProvider); +} diff --git a/app/lib/features/shell/analytics_page.dart b/app/lib/features/shell/analytics_page.dart index 3db3f5e..45ce41c 100644 --- a/app/lib/features/shell/analytics_page.dart +++ b/app/lib/features/shell/analytics_page.dart @@ -1,15 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// Аналитика: a hub for the phase-4 screens. +import '../portfolio/holdings_tab.dart'; +import '../portfolio/overview_tiles.dart'; +import '../portfolio/scope_selector.dart'; + +/// Аналитика → Общее. On a wide surface it is Snowball's overview — the four headline figures +/// for the chosen scope, then the value chart, returns and benchmark comparison; the other +/// analytics screens are one tab away (`AnalyticsTabs`). On a phone there is no tab strip, so +/// the same entry is a list of the sections instead. /// -/// Why a hub instead of four more destinations: the shell already carried eleven, which on -/// a phone leaves ~36 px per label in the bottom bar. Доходы, Ребалансировка, Цели and -/// Налоги are all "what do I do with the portfolio next" questions, so they live behind one -/// entry that highlights for all four (see `alsoMatches` in `app_shell.dart`), while each -/// keeps its own top-level route from the contract (`/income`, `/rebalance`, `/goals`, -/// `/tax`) and is deep-linkable. -class AnalyticsHubPage extends StatelessWidget { +/// The sections keep their own top-level routes from the contract (`/income`, `/rebalance`, +/// `/goals`, `/tax`) and are deep-linkable; `alsoMatches` in `nav_destinations.dart` keeps +/// Аналитика highlighted inside any of them. +class AnalyticsHubPage extends ConsumerWidget { const AnalyticsHubPage({super.key}); static const _entries = [ @@ -23,7 +28,8 @@ class AnalyticsHubPage extends StatelessWidget { path: '/rebalance', icon: Icons.balance, title: 'Ребалансировка', - subtitle: 'Целевые веса портфеля и рекомендации, что докупить или продать', + subtitle: + 'Целевые веса портфеля и рекомендации, что докупить или продать', ), ( path: '/goals', @@ -39,6 +45,43 @@ class AnalyticsHubPage extends StatelessWidget { ), ]; + @override + Widget build(BuildContext context, WidgetRef ref) { + if (MediaQuery.sizeOf(context).width >= 600) return const _Overview(); + return const _SectionList(); + } +} + +class _Overview extends StatelessWidget { + const _Overview(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Align( + alignment: Alignment.centerRight, + child: ScopeSelector(), + ), + ), + const Expanded( + child: HoldingsTab( + sections: HoldingsSections.overview, + header: OverviewTiles(), + ), + ), + ], + ), + ); + } +} + +class _SectionList extends StatelessWidget { + const _SectionList(); + @override Widget build(BuildContext context) { return Scaffold( @@ -46,7 +89,7 @@ class AnalyticsHubPage extends StatelessWidget { body: ListView( padding: const EdgeInsets.all(16), children: [ - for (final e in _entries) + for (final e in AnalyticsHubPage._entries) Card( child: ListTile( leading: Icon(e.icon), diff --git a/app/lib/features/shell/analytics_tabs.dart b/app/lib/features/shell/analytics_tabs.dart new file mode 100644 index 0000000..b0115a7 --- /dev/null +++ b/app/lib/features/shell/analytics_tabs.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// The tab strip under the top bar on every «Аналитика» screen. Each tab keeps its own +/// top-level route (`/income`, `/rebalance`, …), so the screens stay deep-linkable; the strip +/// only makes them read as the parts of one section. +class AnalyticsTabs extends StatelessWidget { + const AnalyticsTabs({required this.location, super.key}); + + final String location; + + static const tabs = [ + (path: '/analytics', icon: Icons.work_outline, label: 'Общее'), + (path: '/income', icon: Icons.bar_chart, label: 'Дивиденды'), + (path: '/rebalance', icon: Icons.balance, label: 'Ребалансировка'), + (path: '/goals', icon: Icons.flag_outlined, label: 'Цели'), + (path: '/tax', icon: Icons.receipt_long_outlined, label: 'Налоги'), + (path: '/portfolios', icon: Icons.pie_chart_outline, label: 'Портфели'), + ]; + + /// Whether [location] belongs to the analytics section at all. + static bool contains(String location) => + tabs.any((t) => location.startsWith(t.path)); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + color: scheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final t in tabs) + _Tab( + icon: t.icon, + label: t.label, + selected: location.startsWith(t.path), + onTap: () => context.go(t.path), + ), + ], + ), + ), + ); + } +} + +class _Tab extends StatelessWidget { + const _Tab({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final color = selected ? scheme.onSurface : scheme.onSurfaceVariant; + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: selected ? scheme.primary : Colors.transparent, + width: 2, + ), + ), + ), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: selected ? scheme.primary : scheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: color, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/shell/app_shell.dart b/app/lib/features/shell/app_shell.dart index 9db9f8a..e37598c 100644 --- a/app/lib/features/shell/app_shell.dart +++ b/app/lib/features/shell/app_shell.dart @@ -1,21 +1,22 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'analytics_tabs.dart'; import 'nav_destinations.dart'; -import 'nav_sidebar.dart'; +import 'top_nav.dart'; -/// Breakpoints per the plan: bottom bar under 600, collapsed rail 600–1200, -/// extended rail at 1200 and above. +/// Below this width the shell falls back to a bottom [NavigationBar]; from here up it is the +/// [TopNav] bar. const _narrowBreakpoint = 600.0; -const _wideBreakpoint = 1200.0; -/// Adaptive navigation shell around the current route: a bottom -/// [NavigationBar] on narrow surfaces, a collapsed [NavigationRail] on medium -/// ones, and the grouped [NavSidebar] on wide ones. +/// Adaptive navigation shell around the current route: a bottom [NavigationBar] on narrow +/// surfaces and the [TopNav] bar on wider ones, with the Аналитика tab strip under it while +/// one of the analytics screens is open. /// -/// The rail and the sidebar show every destination. The bottom bar shows the four primary -/// ones plus «Ещё», which opens the rest in a sheet: twelve destinations in a phone-width -/// bar would leave about 30 px per label, which is not navigation but decoration. +/// The top bar shows every destination (the ledger ones behind «Операции»). The bottom bar +/// shows the four primary ones plus «Ещё», which opens the rest in a sheet: twelve +/// destinations in a phone-width bar would leave about 30 px per label, which is not +/// navigation but decoration. class AppShell extends StatelessWidget { const AppShell({required this.location, required this.child, super.key}); @@ -80,28 +81,32 @@ class AppShell extends StatelessWidget { ); } - final extended = width >= _wideBreakpoint; return Scaffold( - body: Row( + body: Column( children: [ - if (extended) - NavSidebar(selectedIndex: _selectedIndex, onSelect: (i) => _onSelect(context, i)) - else - NavigationRail( - selectedIndex: _selectedIndex, - onDestinationSelected: (i) => _onSelect(context, i), - labelType: NavigationRailLabelType.selected, - destinations: [ - for (final d in navDestinations) - NavigationRailDestination( - icon: Icon(d.icon), - selectedIcon: Icon(d.selectedIcon), - label: Text(d.label), - ), - ], + TopNav( + selectedIndex: _selectedIndex, + onSelect: (i) => _onSelect(context, i), + ), + Expanded( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: kContentMaxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (AnalyticsTabs.contains(location)) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: AnalyticsTabs(location: location), + ), + Expanded(child: child), + ], + ), + ), ), - const VerticalDivider(width: 1), - Expanded(child: SafeArea(child: child)), + ), ], ), ); diff --git a/app/lib/features/shell/nav_destinations.dart b/app/lib/features/shell/nav_destinations.dart index 6ccb4f7..e2d2a05 100644 --- a/app/lib/features/shell/nav_destinations.dart +++ b/app/lib/features/shell/nav_destinations.dart @@ -1,18 +1,19 @@ import 'package:flutter/material.dart'; -/// The three groups the sidebar buckets destinations under (Grimmory-style caps headers). -/// Order here is display order, top to bottom. +/// The groups destinations belong to: the main tabs, the ledger screens behind «Операции», and +/// the system ones (Здоровье, Настройки) that sit as icons on the right of the top bar. enum NavGroup { overview, ledger, system } -const navGroupLabels = { - NavGroup.overview: 'ОБЗОР', - NavGroup.ledger: 'ОПЕРАЦИИ', - NavGroup.system: 'СИСТЕМА', -}; - class NavDestination { - const NavDestination(this.path, this.icon, this.selectedIcon, this.label, this.group, - {this.alsoMatches = const [], this.primary = false}); + const NavDestination( + this.path, + this.icon, + this.selectedIcon, + this.label, + this.group, { + this.alsoMatches = const [], + this.primary = false, + }); final String path; final IconData icon; final IconData selectedIcon; @@ -39,28 +40,97 @@ class NavDestination { } const navDestinations = [ - NavDestination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', NavGroup.overview, - primary: true), NavDestination( - '/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета', NavGroup.overview, - primary: true), + '/', + Icons.dashboard_outlined, + Icons.dashboard, + 'Обзор', + NavGroup.overview, + primary: true, + ), + // One entry for the phase-4 screens. They keep their own top-level routes from the + // contract and stay deep-linkable; `/analytics` is what the bars point at, and + // `alsoMatches` keeps it highlighted while you are inside any of them. NavDestination( - '/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель', NavGroup.overview, - primary: true), - // One entry for the four phase-4 screens. They keep their own top-level routes from the - // contract and stay deep-linkable; the hub at `/analytics` is what the bottom bar and the - // rail point at, and `alsoMatches` keeps it highlighted while you are inside any of them. - NavDestination('/analytics', Icons.insights_outlined, Icons.insights, 'Аналитика', NavGroup.overview, - alsoMatches: ['/income', '/rebalance', '/goals', '/tax'], primary: true), - NavDestination('/events', Icons.event_note_outlined, Icons.event_note, 'События', NavGroup.ledger), - NavDestination('/imports', Icons.upload_file_outlined, Icons.upload_file, 'Импорт', NavGroup.ledger, - alsoMatches: ['/instruments/pending']), - NavDestination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки', NavGroup.ledger), - NavDestination('/categories', Icons.category_outlined, Icons.category, 'Категории', NavGroup.ledger), + '/analytics', + Icons.insights_outlined, + Icons.insights, + 'Аналитика', + NavGroup.overview, + alsoMatches: ['/income', '/rebalance', '/goals', '/tax', '/portfolios'], + primary: true, + ), NavDestination( - '/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции', NavGroup.ledger), - NavDestination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила', NavGroup.ledger), + '/portfolio', + Icons.pie_chart_outline, + Icons.pie_chart, + 'Портфель', + NavGroup.overview, + primary: true, + ), NavDestination( - '/health', Icons.monitor_heart_outlined, Icons.monitor_heart, 'Здоровье', NavGroup.system), - NavDestination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки', NavGroup.system), + '/accounts', + Icons.account_balance_outlined, + Icons.account_balance, + 'Счета', + NavGroup.overview, + primary: true, + ), + NavDestination( + '/events', + Icons.event_note_outlined, + Icons.event_note, + 'События', + NavGroup.ledger, + ), + NavDestination( + '/imports', + Icons.upload_file_outlined, + Icons.upload_file, + 'Импорт', + NavGroup.ledger, + alsoMatches: ['/instruments/pending'], + ), + NavDestination( + '/cashflow', + Icons.swap_horiz_outlined, + Icons.swap_horiz, + 'Потоки', + NavGroup.ledger, + ), + NavDestination( + '/categories', + Icons.category_outlined, + Icons.category, + 'Категории', + NavGroup.ledger, + ), + NavDestination( + '/transactions', + Icons.receipt_long_outlined, + Icons.receipt_long, + 'Операции', + NavGroup.ledger, + ), + NavDestination( + '/rules', + Icons.rule_folder_outlined, + Icons.rule_folder, + 'Правила', + NavGroup.ledger, + ), + NavDestination( + '/health', + Icons.monitor_heart_outlined, + Icons.monitor_heart, + 'Здоровье', + NavGroup.system, + ), + NavDestination( + '/settings', + Icons.settings_outlined, + Icons.settings, + 'Настройки', + NavGroup.system, + ), ]; diff --git a/app/lib/features/shell/nav_sidebar.dart b/app/lib/features/shell/nav_sidebar.dart deleted file mode 100644 index e47d0df..0000000 --- a/app/lib/features/shell/nav_sidebar.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/api/common_providers.dart'; -import '../home/providers.dart' show dataQualityProvider; -import 'nav_destinations.dart'; - -/// The Grimmory-style sidebar shown at the extended breakpoint (>=1200): destinations -/// grouped under muted caps headers, a left accent bar on the active row, a counter next -/// to a destination where one is meaningful, and the signed-in account pinned at the -/// bottom. Below 1200 the shell falls back to a plain [NavigationRail] — there is no room -/// for group headers or a profile block in an icon-only rail. -class NavSidebar extends ConsumerWidget { - const NavSidebar({required this.selectedIndex, required this.onSelect, super.key}); - - final int selectedIndex; - final ValueChanged onSelect; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final issueCount = ref.watch(dataQualityProvider).valueOrNull?.data.length; - - final byGroup = >{}; - for (var i = 0; i < navDestinations.length; i++) { - byGroup.putIfAbsent(navDestinations[i].group, () => []).add(i); - } - - return Container( - width: 240, - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: Column( - children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 12), - children: [ - for (final group in NavGroup.values) - if (byGroup[group] case final indices?) - _NavGroupSection( - label: navGroupLabels[group]!, - children: [ - for (final i in indices) - _NavRow( - destination: navDestinations[i], - selected: i == selectedIndex, - counter: navDestinations[i].path == '/health' ? issueCount : null, - onTap: () => onSelect(i), - ), - ], - ), - ], - ), - ), - const Divider(height: 1), - const _ProfileFooter(), - ], - ), - ); - } -} - -class _NavGroupSection extends StatelessWidget { - const _NavGroupSection({required this.label, required this.children}); - - final String label; - final List children; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 12, 6), - child: Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - letterSpacing: 1.1, - fontWeight: FontWeight.w600, - ), - ), - ), - ...children, - ], - ); - } -} - -class _NavRow extends StatelessWidget { - const _NavRow({required this.destination, required this.selected, required this.onTap, this.counter}); - - final NavDestination destination; - final bool selected; - final VoidCallback onTap; - final int? counter; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 12, 2), - child: Material( - color: selected ? scheme.primaryContainer.withValues(alpha: 0.35) : Colors.transparent, - borderRadius: BorderRadius.circular(10), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 9), - child: Row( - children: [ - Container( - width: 3, - height: 18, - margin: const EdgeInsets.only(right: 9), - decoration: BoxDecoration( - color: selected ? scheme.primary : Colors.transparent, - borderRadius: BorderRadius.circular(2), - ), - ), - Icon( - selected ? destination.selectedIcon : destination.icon, - size: 20, - color: selected ? scheme.primary : scheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - destination.label, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: selected ? scheme.primary : scheme.onSurface, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, - ), - ), - ), - if (counter != null && counter! > 0) - Text( - '$counter', - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _ProfileFooter extends ConsumerWidget { - const _ProfileFooter(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scheme = Theme.of(context).colorScheme; - final theme = Theme.of(context); - final email = ref.watch(meProvider).valueOrNull?.email; - final name = email?.split('@').first; - final initial = (name?.isNotEmpty ?? false) ? name![0].toUpperCase() : '?'; - - return Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - CircleAvatar( - radius: 18, - backgroundColor: scheme.primaryContainer, - child: Text( - initial, - style: TextStyle(color: scheme.onPrimaryContainer, fontWeight: FontWeight.w700), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name ?? '…', - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - ), - Text( - email ?? '', - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/app/lib/features/shell/top_nav.dart b/app/lib/features/shell/top_nav.dart new file mode 100644 index 0000000..e827674 --- /dev/null +++ b/app/lib/features/shell/top_nav.dart @@ -0,0 +1,357 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/api/common_providers.dart'; +import '../../core/auth/auth_controller.dart'; +import '../../core/widgets/service_mark.dart'; +import '../accounts/account_create_dialog.dart'; +import '../home/providers.dart' show dataQualityProvider; +import 'nav_destinations.dart'; + +/// The widest the page content (and the content of the top bar) grows: on a big monitor a table +/// stretched edge to edge is harder to read than one that stops. +const kContentMaxWidth = 1440.0; + +/// The top bar of the wide layout (>=600): the primary destinations as pills, the ledger +/// screens behind «Операции», and on the right «Добавить», the data-health counter, settings +/// and the profile. Below 600 the shell uses a bottom bar instead. +class TopNav extends ConsumerWidget { + const TopNav({ + required this.selectedIndex, + required this.onSelect, + super.key, + }); + + final int selectedIndex; + final ValueChanged onSelect; + + int _indexOf(String path) => + navDestinations.indexWhere((d) => d.path == path); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scheme = Theme.of(context).colorScheme; + final compact = MediaQuery.sizeOf(context).width < 900; + final issueCount = + ref.watch(dataQualityProvider).valueOrNull?.data.length ?? 0; + + final primary = [ + for (var i = 0; i < navDestinations.length; i++) + if (navDestinations[i].primary) i, + ]; + final ledger = [ + for (var i = 0; i < navDestinations.length; i++) + if (navDestinations[i].group == NavGroup.ledger) i, + ]; + final health = _indexOf('/health'); + final settings = _indexOf('/settings'); + + return Container( + height: 64, + decoration: BoxDecoration( + color: scheme.surfaceContainerLowest, + border: Border(bottom: BorderSide(color: scheme.outlineVariant)), + ), + // the band runs edge to edge, but what is on it lines up with the page content below: + // same maximum width, same 16 px side padding as the cards + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: kContentMaxWidth), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Tooltip( + message: 'Обзор', + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => onSelect(_indexOf('/')), + child: const ServiceMark(), + ), + ), + SizedBox(width: compact ? 12 : 28), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final i in primary) + _Pill( + label: navDestinations[i].label, + selected: i == selectedIndex, + onTap: () => onSelect(i), + ), + _LedgerMenu( + indices: ledger, + selectedIndex: selectedIndex, + onSelect: onSelect, + ), + ], + ), + ), + ), + _AddMenu(compact: compact), + const SizedBox(width: 4), + IconButton( + tooltip: 'Здоровье данных', + onPressed: () => onSelect(health), + icon: Badge( + isLabelVisible: issueCount > 0, + label: Text('$issueCount'), + child: Icon( + Icons.monitor_heart_outlined, + color: selectedIndex == health + ? scheme.primary + : scheme.onSurfaceVariant, + ), + ), + ), + IconButton( + tooltip: 'Настройки', + onPressed: () => onSelect(settings), + icon: Icon( + Icons.settings_outlined, + color: selectedIndex == settings + ? scheme.primary + : scheme.onSurfaceVariant, + ), + ), + const _ProfileMenu(), + ], + ), + ), + ), + ), + ); + } +} + +class _Pill extends StatelessWidget { + const _Pill({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(right: 4), + child: Material( + color: selected ? scheme.surfaceContainerHighest : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Text( + label, + style: TextStyle( + color: selected ? scheme.onSurface : scheme.onSurfaceVariant, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ), + ), + ); + } +} + +/// «Операции»: the ledger screens, which are visited less often than the four main tabs. +class _LedgerMenu extends StatelessWidget { + const _LedgerMenu({ + required this.indices, + required this.selectedIndex, + required this.onSelect, + }); + + final List indices; + final int selectedIndex; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final active = indices.contains(selectedIndex); + return PopupMenuButton( + tooltip: 'Операции', + position: PopupMenuPosition.under, + onSelected: onSelect, + itemBuilder: (_) => [ + for (final i in indices) + PopupMenuItem( + value: i, + child: Row( + children: [ + Icon( + navDestinations[i].icon, + size: 20, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Text(navDestinations[i].label), + ], + ), + ), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: active ? scheme.surfaceContainerHighest : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + active ? navDestinations[selectedIndex].label : 'Операции', + style: TextStyle( + color: active ? scheme.onSurface : scheme.onSurfaceVariant, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + ), + ), + Icon( + Icons.arrow_drop_down, + size: 20, + color: scheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + +/// «Добавить»: the four things a person creates by hand. +class _AddMenu extends StatelessWidget { + const _AddMenu({required this.compact}); + + final bool compact; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return PopupMenuButton( + tooltip: 'Добавить', + position: PopupMenuPosition.under, + onSelected: (v) => switch (v) { + 'event' => context.go('/events'), + 'account' => showAccountCreateDialog(context), + 'portfolio' => context.go('/portfolios'), + _ => context.go('/imports'), + }, + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'event', + child: ListTile( + dense: true, + leading: Icon(Icons.swap_vert), + title: Text('Событие'), + subtitle: Text('Сделка, пополнение, выплата'), + ), + ), + PopupMenuItem( + value: 'account', + child: ListTile( + dense: true, + leading: Icon(Icons.account_balance_outlined), + title: Text('Брокерский счёт'), + ), + ), + PopupMenuItem( + value: 'portfolio', + child: ListTile( + dense: true, + leading: Icon(Icons.pie_chart_outline), + title: Text('Портфель'), + ), + ), + PopupMenuItem( + value: 'import', + child: ListTile( + dense: true, + leading: Icon(Icons.upload_file_outlined), + title: Text('Отчёт брокера'), + ), + ), + ], + child: Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_circle_outline, size: 20, color: scheme.primary), + if (!compact) ...[ + const SizedBox(width: 8), + Text( + 'Добавить', + style: TextStyle( + color: scheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _ProfileMenu extends ConsumerWidget { + const _ProfileMenu(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scheme = Theme.of(context).colorScheme; + final email = ref.watch(meProvider).valueOrNull?.email; + final initial = (email?.isNotEmpty ?? false) + ? email![0].toUpperCase() + : '?'; + return PopupMenuButton( + tooltip: email ?? 'Профиль', + position: PopupMenuPosition.under, + onSelected: (_) => ref.read(authControllerProvider.notifier).logout(), + itemBuilder: (_) => [ + PopupMenuItem(enabled: false, child: Text(email ?? '…')), + const PopupMenuItem( + value: 'logout', + child: ListTile( + dense: true, + leading: Icon(Icons.logout), + title: Text('Выйти'), + ), + ), + ], + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: CircleAvatar( + radius: 18, + backgroundColor: scheme.primaryContainer, + child: Text( + initial, + style: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/router.dart b/app/lib/router.dart index ce334c6..cebfefd 100644 --- a/app/lib/router.dart +++ b/app/lib/router.dart @@ -17,6 +17,7 @@ import 'features/login/login_page.dart'; import 'features/pending/pending_page.dart'; import 'features/portfolio/instrument_page.dart'; import 'features/portfolio/portfolio_page.dart'; +import 'features/portfolios/portfolios_page.dart'; import 'features/rebalance/rebalance_page.dart'; import 'features/rules/rules_page.dart'; import 'features/settings/settings_page.dart'; @@ -53,14 +54,22 @@ final routerProvider = Provider((ref) { GoRoute(path: '/portfolio', builder: (_, _) => const PortfolioPage()), GoRoute( path: '/portfolio/instrument/:id', - builder: (_, state) => - InstrumentPage(instrumentId: int.parse(state.pathParameters['id']!)), + builder: (_, state) => InstrumentPage( + instrumentId: int.parse(state.pathParameters['id']!), + ), ), // Аналитика: one navigation destination, four contract routes. The hub is what // the shell points at; each screen below keeps its own top-level path and stays // deep-linkable (see `alsoMatches` in `app_shell.dart`). - GoRoute(path: '/analytics', builder: (_, _) => const AnalyticsHubPage()), + GoRoute( + path: '/analytics', + builder: (_, _) => const AnalyticsHubPage(), + ), GoRoute(path: '/income', builder: (_, _) => const IncomePage()), + GoRoute( + path: '/portfolios', + builder: (_, _) => const PortfoliosPage(), + ), GoRoute(path: '/rebalance', builder: (_, _) => const RebalancePage()), GoRoute(path: '/goals', builder: (_, _) => const GoalsPage()), GoRoute(path: '/tax', builder: (_, _) => const TaxPage()), @@ -68,8 +77,9 @@ final routerProvider = Provider((ref) { GoRoute(path: '/imports', builder: (_, _) => const ImportsPage()), GoRoute( path: '/imports/:id', - builder: (_, state) => - ImportPreviewPage(importId: int.parse(state.pathParameters['id']!)), + builder: (_, state) => ImportPreviewPage( + importId: int.parse(state.pathParameters['id']!), + ), ), GoRoute( path: '/instruments/pending', @@ -78,9 +88,14 @@ final routerProvider = Provider((ref) { GoRoute(path: '/cashflow', builder: (_, _) => const CashflowPage()), GoRoute( path: '/categories', - builder: (_, state) => CategoriesPage(initialMonth: state.uri.queryParameters['month']), + builder: (_, state) => CategoriesPage( + initialMonth: state.uri.queryParameters['month'], + ), + ), + GoRoute( + path: '/transactions', + builder: (_, _) => const TransactionsPage(), ), - GoRoute(path: '/transactions', builder: (_, _) => const TransactionsPage()), GoRoute(path: '/rules', builder: (_, _) => const RulesPage()), GoRoute( path: '/health', diff --git a/app/test/account_create_dialog_test.dart b/app/test/account_create_dialog_test.dart new file mode 100644 index 0000000..0b75876 --- /dev/null +++ b/app/test/account_create_dialog_test.dart @@ -0,0 +1,56 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/features/accounts/account_create_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host({Broker? broker, String? sourceId, String? name}) => ProviderScope( + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showAccountCreateDialog( + context, + broker: broker, + sourceId: sourceId, + name: name, + ), + child: const Text('open'), + ), + ), + ), + ), +); + +void main() { + testWidgets('the dialog starts from what the report says', (tester) async { + await tester.pumpWidget( + _host(broker: Broker.vtb, sourceId: 'BR-42', name: 'ВТБ BR-42'), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Новый брокерский счёт'), findsOneWidget); + expect(find.text('BR-42'), findsOneWidget); + expect(find.text('ВТБ BR-42'), findsOneWidget); + expect(find.text('ВТБ'), findsOneWidget); // the selected broker + }); + + testWidgets('name and agreement number are required', (tester) async { + await tester.pumpWidget(_host()); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Создать')); + await tester.pumpAndSettle(); + + expect(find.text('Обязательное поле'), findsNWidgets(2)); + }); + + test('import broker keys map onto creatable brokers', () { + expect(brokerFromImportKey('sber'), Broker.sber); + expect(brokerFromImportKey('vtb'), Broker.vtb); + expect(brokerFromImportKey('csv'), Broker.other); + expect(brokerFromImportKey('tinvest'), isNull); + }); +} diff --git a/app/test/accounts_page_test.dart b/app/test/accounts_page_test.dart new file mode 100644 index 0000000..a5010a3 --- /dev/null +++ b/app/test/accounts_page_test.dart @@ -0,0 +1,258 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/cache/cached.dart'; +import 'package:fintracker_app/core/widgets/money_text.dart'; +import 'package:fintracker_app/features/accounts/accounts_page.dart'; +import 'package:fintracker_app/features/accounts/actions.dart'; +import 'package:fintracker_app/features/accounts/providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +AccountOut _account( + int id, + String name, + AccountKind kind, { + String? balance, + String? valueRub, + bool disabled = false, + bool archived = false, + AccountRole? role, + String source = 'zenmoney', +}) => AccountOut( + id: id, + kind: kind, + source_: source, + sourceId: 'acc-$id', + broker: null, + name: name, + currency: 'RUB', + role: + role ?? + (kind == AccountKind.broker + ? AccountRole.investment + : AccountRole.liquid), + includeInNetWorth: true, + mirrorOfAccountId: null, + primaryEventSource: null, + archived: archived, + disabled: disabled, + openedAt: null, + balance: balance, + balanceAsOf: null, + startBalance: null, + creditLimit: null, + valueRub: valueRub, +); + +class _FakeActions implements AccountActions { + final calls = <(List, AccountPatch)>[]; + + @override + Future patch(Iterable ids, AccountPatch patch) async { + calls.add((ids.toList(), patch)); + return null; + } +} + +/// Rows sort as: active by type then name, then disabled — so «Карта» (1), «Брокер» (3), +/// «Старая карта» (2, disabled). +List _sample() => [ + _account(1, 'Карта', AccountKind.zmCard, balance: '500'), + _account( + 2, + 'Старая карта', + AccountKind.zmCard, + balance: '20', + disabled: true, + ), + _account( + 3, + 'Брокер', + AccountKind.broker, + valueRub: '1044264', + source: 'tinvest', + ), +]; + +Future<_FakeActions> _open( + WidgetTester tester, + List accounts, { + double width = 1200, +}) async { + tester.view.physicalSize = Size(width, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final fake = _FakeActions(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + accountsProvider.overrideWith((ref) async => Cached(accounts)), + accountActionsProvider.overrideWithValue(fake), + ], + child: const MaterialApp(home: AccountsPage()), + ), + ); + await tester.pumpAndSettle(); + return fake; +} + +void main() { + testWidgets( + 'a broker account shows its ledger valuation, a card its balance', + (tester) async { + await _open(tester, _sample()); + + expect(find.text(MoneyText.format('1044264', 'RUB')), findsOneWidget); + expect(find.text(MoneyText.format('500', 'RUB')), findsOneWidget); + // the source tells a ZenMoney account from a broker one + expect(find.textContaining('T-Invest'), findsOneWidget); + expect(find.textContaining('ZenMoney'), findsWidgets); + }, + ); + + testWidgets('no amount at all reads as a dash, not as zero', (tester) async { + await _open(tester, [_account(1, 'Пустой брокер', AccountKind.broker)]); + + expect(find.text('—'), findsOneWidget); + }); + + testWidgets('the status chips count the accounts and filter the list', ( + tester, + ) async { + await _open(tester, _sample()); + + expect(find.text('Все 3'), findsOneWidget); + expect(find.text('Активные 2'), findsOneWidget); + expect(find.text('Отключённые 1'), findsOneWidget); + expect(find.text('Архивные 0'), findsOneWidget); + expect( + find.text('Старая карта'), + findsOneWidget, + reason: 'all shows the disabled one too', + ); + + await tester.tap(find.text('Отключённые 1')); + await tester.pumpAndSettle(); + expect(find.text('Старая карта'), findsOneWidget); + expect(find.text('Карта'), findsNothing); + expect(find.text('Брокер'), findsNothing); + }); + + testWidgets('the type chips and the search narrow the list', (tester) async { + await _open(tester, _sample()); + + await tester.tap(find.widgetWithText(FilterChip, 'Инвестиции')); + await tester.pumpAndSettle(); + expect(find.text('Брокер'), findsOneWidget); + expect(find.text('Карта'), findsNothing); + + await tester.tap(find.widgetWithText(FilterChip, 'Инвестиции')); + await tester.enterText(find.byType(TextField), 'старая'); + await tester.pumpAndSettle(); + expect(find.text('Старая карта'), findsOneWidget); + expect(find.text('Брокер'), findsNothing); + + await tester.enterText(find.byType(TextField), 'nothing like it'); + await tester.pumpAndSettle(); + expect(find.text('Ничего не найдено'), findsOneWidget); + }); + + testWidgets( + 'the «Активен» switch on a row sends one patch for that account', + (tester) async { + final fake = await _open(tester, _sample()); + + // per row the switches are «В капитал» then «Активен»; the first row is «Карта» (id 1) + await tester.tap(find.byType(Switch).at(1)); + await tester.pumpAndSettle(); + + expect(fake.calls, hasLength(1)); + expect(fake.calls.single.$1, [1]); + expect(fake.calls.single.$2.disabled, isTrue); + }, + ); + + testWidgets('switching a disabled account back on sends disabled: false', ( + tester, + ) async { + final fake = await _open(tester, _sample()); + + await tester.tap(find.byType(Switch).at(5)); // «Старая карта», «Активен» + await tester.pumpAndSettle(); + + expect(fake.calls.single.$1, [2]); + expect(fake.calls.single.$2.disabled, isFalse); + }); + + testWidgets('the type pill offers the four types and sends the chosen one', ( + tester, + ) async { + final fake = await _open(tester, _sample()); + + await tester.tap(find.byTooltip('Тип счёта').first); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(CheckedPopupMenuItem, 'Сбережения'), + ); + await tester.pumpAndSettle(); + + expect(fake.calls.single.$1, [1]); + expect(fake.calls.single.$2.role, AccountRole.savings); + }); + + testWidgets( + 'a selection turns into bulk actions and clears itself afterwards', + (tester) async { + final fake = await _open(tester, _sample()); + + // checkbox 0 is «select all»; rows follow in list order: Карта (1), Брокер (3) + await tester.tap(find.byType(Checkbox).at(1)); + await tester.tap(find.byType(Checkbox).at(2)); + await tester.pumpAndSettle(); + expect(find.text('Выбрано: 2'), findsOneWidget); + + await tester.tap(find.text('Отключить')); + await tester.pumpAndSettle(); + + expect(fake.calls.single.$1, unorderedEquals([1, 3])); + expect(fake.calls.single.$2.disabled, isTrue); + expect( + find.textContaining('Выбрано'), + findsNothing, + reason: 'the selection is spent', + ); + }, + ); + + testWidgets('«select all» takes exactly what the filter shows', ( + tester, + ) async { + final fake = await _open(tester, _sample()); + + await tester.tap(find.text('Отключённые 1')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Checkbox).first); + await tester.pumpAndSettle(); + expect(find.text('Выбрано: 1'), findsOneWidget); + + await tester.tap(find.text('Включить')); + await tester.pumpAndSettle(); + expect(fake.calls.single.$1, [2]); + expect(fake.calls.single.$2.disabled, isFalse); + }); + + testWidgets( + 'a narrow window puts the controls on a second line with their labels', + (tester) async { + await _open(tester, _sample(), width: 500); + + expect(find.text('Активен'), findsNWidgets(3)); + expect(find.text('В капитал'), findsNWidgets(3)); + expect( + find.text('Баланс / стоимость'), + findsNothing, + reason: 'no table header on a phone', + ); + }, + ); +} diff --git a/app/test/app_shell_test.dart b/app/test/app_shell_test.dart index f8c0008..4d44370 100644 --- a/app/test/app_shell_test.dart +++ b/app/test/app_shell_test.dart @@ -1,32 +1,40 @@ import 'package:fintracker_api/fintracker_api.dart'; import 'package:fintracker_app/core/api/common_providers.dart'; import 'package:fintracker_app/core/cache/cached.dart'; +import 'package:fintracker_app/core/widgets/service_mark.dart'; import 'package:fintracker_app/features/home/providers.dart'; +import 'package:fintracker_app/features/shell/analytics_tabs.dart'; import 'package:fintracker_app/features/shell/app_shell.dart'; import 'package:fintracker_app/features/shell/nav_destinations.dart'; -import 'package:fintracker_app/features/shell/nav_sidebar.dart'; +import 'package:fintracker_app/features/shell/top_nav.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; DataQualityRow _dataQualityRow(int id) => DataQualityRow( - checkName: 'x', - computedAt: DateTime(2026, 9, 19), - count: 1, - detail: 'issue $id', - id: id, - ref: null, - severity: 'warning', - ); + checkName: 'x', + computedAt: DateTime(2026, 9, 19), + count: 1, + detail: 'issue $id', + id: id, + ref: null, + severity: 'warning', +); void main() { - // The sidebar (>=1200) watches meProvider and dataQualityProvider for the profile - // footer and the Здоровье counter — every wide-breakpoint pump needs both overridden. - Widget wrap(double width, {String location = '/'}) { + // The top bar watches meProvider and dataQualityProvider for the profile avatar and the + // Здоровье counter — every pump at or above 600 needs both overridden. + Widget wrap( + double width, { + String location = '/', + List issues = const [], + }) { return ProviderScope( overrides: [ - meProvider.overrideWith((ref) async => UserOut(email: 'ada@example.com', id: 1)), - dataQualityProvider.overrideWith((ref) async => const Cached([])), + meProvider.overrideWith( + (ref) async => UserOut(email: 'ada@example.com', id: 1), + ), + dataQualityProvider.overrideWith((ref) async => Cached(issues)), ], child: MediaQuery( data: MediaQueryData(size: Size(width, 800)), @@ -37,75 +45,126 @@ void main() { ); } - testWidgets('shows the grouped sidebar on a wide surface', (tester) async { + testWidgets('shows the top bar on a wide surface', (tester) async { await tester.pumpWidget(wrap(1280)); await tester.pump(); - expect(find.byType(NavSidebar), findsOneWidget); + expect(find.byType(TopNav), findsOneWidget); expect(find.byType(NavigationRail), findsNothing); expect(find.byType(NavigationBar), findsNothing); - for (final label in navGroupLabels.values) { - expect(find.text(label), findsOneWidget); + for (final label in [ + 'Обзор', + 'Аналитика', + 'Портфель', + 'Счета', + 'Операции', + 'Добавить', + ]) { + expect(find.text(label), findsOneWidget, reason: label); } }); - testWidgets('shows a collapsed NavigationRail on a medium surface', (tester) async { - await tester.pumpWidget(wrap(900)); - expect(find.byType(NavigationRail), findsOneWidget); - expect(find.byType(NavSidebar), findsNothing); - expect(find.byType(NavigationBar), findsNothing); - }); + testWidgets( + 'keeps the top bar on a medium surface, with «Добавить» as an icon', + (tester) async { + await tester.pumpWidget(wrap(700)); + await tester.pump(); + expect(find.byType(TopNav), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(find.text('Добавить'), findsNothing); + }, + ); - testWidgets('shows a bottom NavigationBar on a narrow surface', (tester) async { + testWidgets('shows a bottom NavigationBar on a narrow surface', ( + tester, + ) async { await tester.pumpWidget(wrap(400)); expect(find.byType(NavigationBar), findsOneWidget); - expect(find.byType(NavigationRail), findsNothing); - expect(find.byType(NavSidebar), findsNothing); + expect(find.byType(TopNav), findsNothing); }); testWidgets('the resolve screen keeps Импорт selected', (tester) async { - await tester.pumpWidget(wrap(900, location: '/instruments/pending')); - final rail = tester.widget(find.byType(NavigationRail)); - expect( - (rail.destinations[rail.selectedIndex!].label as Text).data, - 'Импорт', - ); + await tester.pumpWidget(wrap(1280, location: '/instruments/pending')); + await tester.pump(); + final nav = tester.widget(find.byType(TopNav)); + expect(navDestinations[nav.selectedIndex].label, 'Импорт'); + // a ledger screen shows its own name in place of «Операции» + expect(find.text('Импорт'), findsOneWidget); + expect(find.text('Операции'), findsNothing); }); testWidgets('a nested route keeps its own section selected', (tester) async { - await tester.pumpWidget(wrap(900, location: '/portfolio/instrument/311')); - final rail = tester.widget(find.byType(NavigationRail)); - expect( - (rail.destinations[rail.selectedIndex!].label as Text).data, - 'Портфель', - ); - }); - - testWidgets('the sidebar keeps Импорт selected on the resolve screen', (tester) async { - await tester.pumpWidget(wrap(1280, location: '/instruments/pending')); + await tester.pumpWidget(wrap(1280, location: '/portfolio/instrument/311')); await tester.pump(); - final sidebar = tester.widget(find.byType(NavSidebar)); - expect(navDestinations[sidebar.selectedIndex].label, 'Импорт'); + final nav = tester.widget(find.byType(TopNav)); + expect(navDestinations[nav.selectedIndex].label, 'Портфель'); }); - testWidgets('the sidebar shows the Здоровье issue count', (tester) async { + testWidgets( + 'the analytics screens share one tab strip and highlight Аналитика', + (tester) async { + await tester.pumpWidget(wrap(1280, location: '/income')); + await tester.pump(); + final nav = tester.widget(find.byType(TopNav)); + expect(navDestinations[nav.selectedIndex].label, 'Аналитика'); + expect(find.byType(AnalyticsTabs), findsOneWidget); + for (final label in [ + 'Общее', + 'Дивиденды', + 'Ребалансировка', + 'Цели', + 'Налоги', + 'Портфели', + ]) { + expect(find.text(label), findsOneWidget, reason: label); + } + }, + ); + + testWidgets('other sections have no analytics tab strip', (tester) async { + await tester.pumpWidget(wrap(1280, location: '/accounts')); + await tester.pump(); + expect(find.byType(AnalyticsTabs), findsNothing); + }); + + testWidgets('the top bar shows the Здоровье issue count', (tester) async { await tester.pumpWidget( - ProviderScope( - overrides: [ - meProvider.overrideWith((ref) async => UserOut(email: 'ada@example.com', id: 1)), - dataQualityProvider.overrideWith( - (ref) async => Cached([ - _dataQualityRow(1), - _dataQualityRow(2), - ]), - ), - ], - child: MediaQuery( - data: const MediaQueryData(size: Size(1280, 800)), - child: MaterialApp(home: AppShell(location: '/', child: Container())), - ), - ), + wrap(1280, issues: [_dataQualityRow(1), _dataQualityRow(2)]), ); await tester.pump(); expect(find.text('2'), findsOneWidget); }); + + testWidgets( + 'on a wide monitor the top bar content lines up with the page column', + (tester) async { + tester.view.physicalSize = const Size(2400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrap(2400)); + await tester.pump(); + + // the page column is 1440 wide and centred: it starts at 480 and ends at 1920; the cards + // inside have 16 px of padding, and the bar's content must sit on the same edges + final logo = tester.getTopLeft(find.byType(ServiceMark)); + expect( + logo.dx, + 480 + 16, + reason: 'the logo starts at the column edge plus the card padding', + ); + expect(tester.getTopRight(find.byType(CircleAvatar)).dx, 1920 - 16); + }, + ); + + testWidgets('on a normal window the top bar keeps the plain 16 px margin', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrap(1200)); + await tester.pump(); + + expect(tester.getTopLeft(find.byType(ServiceMark)).dx, 16); + expect(tester.getTopRight(find.byType(CircleAvatar)).dx, 1200 - 16); + }); } diff --git a/app/test/core/widgets/asset_icon_test.dart b/app/test/core/widgets/asset_icon_test.dart new file mode 100644 index 0000000..afa1258 --- /dev/null +++ b/app/test/core/widgets/asset_icon_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fintracker_app/core/widgets/asset_icon.dart'; + +Future _pump(WidgetTester tester, Widget icon) => tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Center(child: icon)), + ), +); + +void main() { + test('parseBrandColor reads #RRGGBB and #AARRGGBB, and refuses the rest', () { + expect(parseBrandColor('#21A038'), const Color(0xFF21A038)); + expect(parseBrandColor('80FF0000'), const Color(0x80FF0000)); + expect(parseBrandColor(null), isNull); + expect(parseBrandColor('#12'), isNull); + expect(parseBrandColor('#GGGGGG'), isNull); + }); + + testWidgets('without a logo the class icon shows on the brand colour', ( + tester, + ) async { + await _pump( + tester, + const AssetIcon(assetClass: 'bond', logoColor: '#21A038'), + ); + + expect(find.byType(Image), findsNothing); + final icon = tester.widget(find.byType(Icon)); + expect(icon.icon, assetClassIcon('bond')); + expect(icon.color, Colors.white); // dark green → light glyph + final fill = tester.widget(find.byType(ColoredBox).last); + expect(fill.color, const Color(0xFF21A038)); + }); + + testWidgets( + 'with a logo an image is requested, and a failed load falls back', + (tester) async { + await _pump( + tester, + const AssetIcon( + assetClass: 'share', + logoUrl: 'http://127.0.0.1:1/none.png', + ), + ); + expect(find.byType(Image), findsOneWidget); + + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pump(); + expect(find.byIcon(assetClassIcon('share')), findsOneWidget); + }, + ); + + test('every asset class has its own icon, unknown ones a neutral one', () { + expect(assetClassIcon('share'), isNot(assetClassIcon('bond'))); + expect(assetClassIcon('something-new'), Icons.circle_outlined); + }); +} diff --git a/app/test/help_tip_test.dart b/app/test/help_tip_test.dart new file mode 100644 index 0000000..d4c6103 --- /dev/null +++ b/app/test/help_tip_test.dart @@ -0,0 +1,121 @@ +import 'package:fintracker_app/core/glossary.dart'; +import 'package:fintracker_app/core/widgets/help_tip.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _app(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), +); + +void main() { + test('the glossary finds a term regardless of case, spaces and «ё»', () { + expect(glossaryHint('XIRR'), isNotNull); + expect(glossaryHint(' xirr '), glossaryHint('XIRR')); + expect(glossaryHint('Запас хода (runway)'), isNotNull); + expect(glossaryHint('Внешний поток'), isNotNull); + expect( + glossaryHint('Счёт'), + isNull, + reason: 'an ordinary word is not a term', + ); + }); + + test('every explanation is a real sentence, not a stub', () { + for (final label in [ + 'XIRR', + 'TWR', + 'НКД', + 'ЛДВ', + 'Пассивный доход', + 'Запас хода (runway)', + ]) { + final text = glossaryHint(label)!; + expect(text.length, greaterThan(40), reason: label); + expect(text.trim().endsWith('.'), isTrue, reason: label); + } + }); + + testWidgets('a known term carries a «?», an unknown label stays plain text', ( + tester, + ) async { + await tester.pumpWidget( + _app( + const Column( + mainAxisSize: MainAxisSize.min, + children: [TermLabel('TWR'), TermLabel('Название')], + ), + ), + ); + + expect(find.text('TWR'), findsOneWidget); + expect(find.text('Название'), findsOneWidget); + expect(find.byIcon(Icons.help_outline), findsOneWidget); + }); + + testWidgets('an explicit hint wins over the glossary', (tester) async { + await tester.pumpWidget( + _app(const TermLabel('Прибыль', hint: 'Своё пояснение для налогов.')), + ); + + final tip = tester.widget(find.byType(Tooltip)); + expect(tip.message, 'Своё пояснение для налогов.'); + }); + + testWidgets('hovering the «?» with a mouse shows the explanation', ( + tester, + ) async { + await tester.pumpWidget(_app(const TermLabel('XIRR'))); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(find.byIcon(Icons.help_outline))); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + + expect( + find.textContaining('годовая доходность с учётом дат'), + findsOneWidget, + ); + }); + + testWidgets('a tap shows it too, for a phone with no hover', (tester) async { + await tester.pumpWidget(_app(const TermLabel('НКД'))); + + await tester.tap(find.byIcon(Icons.help_outline)); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.textContaining('накопленный купонный доход'), findsOneWidget); + }); + + testWidgets('a numeric header keeps its label at the end of the cell', ( + tester, + ) async { + await tester.pumpWidget( + _app( + const SizedBox( + width: 200, + child: TermLabel( + 'XIRR', + alignment: MainAxisAlignment.end, + textAlign: TextAlign.end, + ), + ), + ), + ); + + final label = tester.getRect(find.text('XIRR')); + final icon = tester.getRect(find.byIcon(Icons.help_outline)); + expect( + icon.left, + greaterThanOrEqualTo(label.right), + reason: 'the «?» follows the text', + ); + expect( + icon.right, + greaterThan(150), + reason: 'and the pair hugs the right edge', + ); + }); +} diff --git a/app/test/home_page_test.dart b/app/test/home_page_test.dart index 6fafe6c..8cf2b1f 100644 --- a/app/test/home_page_test.dart +++ b/app/test/home_page_test.dart @@ -8,41 +8,63 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; NetWorthBreakdown _breakdown() => NetWorthBreakdown( - accounts: const [], - byCurrency: const {'RUB': '123456.78'}, - d: DateTime(2026, 9, 17), - debtRub: '1000', - investmentRub: '2000', - liquidRub: '3000', - missingFxCount: 0, - savingsRub: '4000', - totalRub: '123456.78', - ); + accounts: const [], + byCurrency: const {'RUB': '123456.78'}, + d: DateTime(2026, 9, 17), + debtRub: '1000', + investmentRub: '2000', + liquidRub: '3000', + missingFxCount: 0, + savingsRub: '4000', + totalRub: '123456.78', +); RunwayOut _runway() => RunwayOut( - asOf: DateTime(2026, 9, 17), - avgBaseline3mRub: '0', - liquidReserveRub: '0', - runwayMonths: null, - ); + asOf: DateTime(2026, 9, 17), + avgBaseline3mRub: '0', + liquidReserveRub: '0', + runwayMonths: null, +); // Every provider HomePage watches needs an override — including // portfolioSummaryHomeProvider, easy to forget since it renders nothing on an // empty ledger. Without it, HomePage falls through to the real apiProvider, // which now also opens a real ResponseCacheDatabase (see docs/ai/offline-cache.md). List _overrides({DateTime? staleAt}) => [ - netWorthBreakdownProvider.overrideWith((ref) => Cached(_breakdown(), fetchedAt: staleAt)), - netWorthSeriesProvider.overrideWith((ref) => Cached(const [], fetchedAt: staleAt)), - cashflowThisMonthProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)), - cashflowLast12Provider.overrideWith((ref) => Cached(const [], fetchedAt: staleAt)), - runwayProvider.overrideWith((ref) => Cached(_runway(), fetchedAt: staleAt)), - metricsStatusProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)), - dataQualityProvider.overrideWith((ref) => Cached(const [], fetchedAt: staleAt)), - portfolioSummaryHomeProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)), - ]; + netWorthBreakdownProvider.overrideWith( + (ref) => Cached(_breakdown(), fetchedAt: staleAt), + ), + netWorthSeriesProvider.overrideWith( + (ref) => Cached(const [], fetchedAt: staleAt), + ), + cashflowThisMonthProvider.overrideWith( + (ref) => Cached(null, fetchedAt: staleAt), + ), + cashflowLast12Provider.overrideWith( + (ref) => Cached(const [], fetchedAt: staleAt), + ), + runwayProvider.overrideWith((ref) => Cached(_runway(), fetchedAt: staleAt)), + metricsStatusProvider.overrideWith( + (ref) => Cached( + MetricsStatusOut(lastRefresh: null, consistent: true, refreshing: false), + fetchedAt: staleAt, + ), + ), + dataQualityProvider.overrideWith( + (ref) => Cached(const [], fetchedAt: staleAt), + ), + portfolioSummaryHomeProvider.overrideWith( + (ref) => Cached(null, fetchedAt: staleAt), + ), + scopeCardsProvider.overrideWith( + (ref) => Cached(const [], fetchedAt: staleAt), + ), +]; void main() { - testWidgets('Обзор renders stat tiles from a net worth breakdown', (tester) async { + testWidgets('Обзор renders stat tiles from a net worth breakdown', ( + tester, + ) async { await tester.pumpWidget( ProviderScope( overrides: _overrides(), @@ -60,15 +82,21 @@ void main() { expect(find.textContaining('Нет соединения'), findsNothing); }); - testWidgets('shows the offline banner when the dashboard came from the cache', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: _overrides(staleAt: DateTime(2026, 9, 10, 8, 30)), - child: const MaterialApp(home: HomePage()), - ), - ); - await tester.pumpAndSettle(); + testWidgets( + 'shows the offline banner when the dashboard came from the cache', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: _overrides(staleAt: DateTime(2026, 9, 10, 8, 30)), + child: const MaterialApp(home: HomePage()), + ), + ); + await tester.pumpAndSettle(); - expect(find.textContaining('Нет соединения — данные на 10.09.2026 08:30'), findsOneWidget); - }); + expect( + find.textContaining('Нет соединения — данные на 10.09.2026 08:30'), + findsOneWidget, + ); + }, + ); } diff --git a/app/test/instrument_edit_dialog_test.dart b/app/test/instrument_edit_dialog_test.dart new file mode 100644 index 0000000..5392b02 --- /dev/null +++ b/app/test/instrument_edit_dialog_test.dart @@ -0,0 +1,109 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/features/portfolio/instrument_edit_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +InstrumentOut _instrument() => InstrumentOut( + id: 1, + assetClass: 'share', + isin: 'RU0008958863', + figi: null, + ticker: 'MSNG', + board: 'TQBR', + exchange: null, + name: 'Мосэнерго', + issuer: null, + currency: 'RUB', + lot: 1, + nominal: null, + nominalCurrency: null, + maturityDate: null, + sector: null, + country: null, + isActive: true, +); + +Future _open( + WidgetTester tester, + Future Function() interact, +) async { + InstrumentPatch? result; + var closed = false; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async { + result = await showDialog( + context: context, + builder: (_) => InstrumentEditDialog(instrument: _instrument()), + ); + closed = true; + }, + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await interact(); + await tester.pumpAndSettle(); + expect(closed, isTrue); + return result; +} + +void main() { + testWidgets('only the changed lot goes into the patch', (tester) async { + final patch = await _open(tester, () async { + await tester.enterText(find.widgetWithText(TextFormField, '1'), '1000'); + await tester.tap(find.text('Сохранить')); + }); + + expect(patch, isNotNull); + expect(patch!.lot, 1000); + expect(patch.name, isNull); + expect(patch.board, isNull); + expect(patch.assetClass, isNull); + }); + + testWidgets('saving without changes closes with nothing to send', ( + tester, + ) async { + final patch = await _open(tester, () async { + await tester.tap(find.text('Сохранить')); + }); + + expect(patch, isNull); + }); + + testWidgets('a lot below 1 is refused and the dialog stays open', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => InstrumentEditDialog(instrument: _instrument()), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextFormField, '1'), '0'); + await tester.tap(find.text('Сохранить')); + await tester.pumpAndSettle(); + + expect(find.text('Целое число не меньше 1'), findsOneWidget); + expect(find.text('Сохранить'), findsOneWidget); + }); +} diff --git a/app/test/manual_event_dialog_test.dart b/app/test/manual_event_dialog_test.dart new file mode 100644 index 0000000..f68285c --- /dev/null +++ b/app/test/manual_event_dialog_test.dart @@ -0,0 +1,107 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/cache/cached.dart'; +import 'package:fintracker_app/features/accounts/providers.dart'; +import 'package:fintracker_app/features/events/manual_event_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +AccountOut _account( + int id, + String name, + AccountKind kind, { + bool disabled = false, +}) => AccountOut( + id: id, + kind: kind, + source_: 'test', + sourceId: 'acc-$id', + broker: null, + name: name, + currency: 'RUB', + role: AccountRole.investment, + includeInNetWorth: true, + mirrorOfAccountId: null, + primaryEventSource: null, + archived: false, + disabled: disabled, + openedAt: null, + balance: null, + balanceAsOf: null, + startBalance: null, + creditLimit: null, +); + +Future _open(WidgetTester tester, List accounts) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + accountsProvider.overrideWith((ref) async => Cached(accounts)), + ], + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showManualEventDialog(context), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('a purchase asks for account, instrument, quantity and price', ( + tester, + ) async { + await _open(tester, [_account(1, 'Брокер', AccountKind.broker)]); + + expect(find.text('Новое событие'), findsOneWidget); + expect(find.text('Инструмент'), findsOneWidget); + expect(find.text('Количество, шт.'), findsOneWidget); + expect(find.text('Цена за штуку'), findsOneWidget); + + await tester.tap(find.text('Добавить')); + await tester.pumpAndSettle(); + + expect(find.text('Выберите счёт'), findsOneWidget); + expect(find.text('Выберите инструмент из списка'), findsOneWidget); + expect(find.text('Обязательное поле'), findsWidgets); + }); + + testWidgets('only active broker accounts can be picked', (tester) async { + await _open(tester, [ + _account(1, 'Брокер', AccountKind.broker), + _account(2, 'Отключённый', AccountKind.broker, disabled: true), + _account(3, 'Карта', AccountKind.zmCard), + ]); + + await tester.tap(find.text('Брокерский счёт')); + await tester.pumpAndSettle(); + + expect(find.text('Брокер'), findsOneWidget); + expect(find.text('Отключённый'), findsNothing); + expect(find.text('Карта'), findsNothing); + }); + + testWidgets( + 'a deposit needs only an amount: no instrument, quantity or price', + (tester) async { + await _open(tester, [_account(1, 'Брокер', AccountKind.broker)]); + + await tester.tap(find.text('Покупка')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Пополнение').last); + await tester.pumpAndSettle(); + + expect(find.text('Инструмент'), findsNothing); + expect(find.text('Количество, шт.'), findsNothing); + expect(find.text('Цена за штуку'), findsNothing); + expect(find.text('Сумма'), findsOneWidget); + }, + ); +} diff --git a/app/test/metrics_refresh_test.dart b/app/test/metrics_refresh_test.dart new file mode 100644 index 0000000..34dee08 --- /dev/null +++ b/app/test/metrics_refresh_test.dart @@ -0,0 +1,61 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/features/home/providers.dart'; +import 'package:flutter_test/flutter_test.dart'; + +MetricsStatusOut _status({required bool refreshing, bool consistent = true}) => + MetricsStatusOut( + lastRefresh: null, + consistent: consistent, + refreshing: refreshing, + ); + +void main() { + const fast = Duration(milliseconds: 1); + + test('polls until the server stops reporting a rebuild', () async { + final answers = [ + _status(refreshing: true), + _status(refreshing: true), + _status(refreshing: false), + ]; + var calls = 0; + + final done = await waitForMetricsRefresh( + () async => answers[calls++], + interval: fast, + ); + + expect(calls, 3); + expect(done?.refreshing, isFalse); + }); + + test('returns straight away when nothing is queued', () async { + var calls = 0; + final done = await waitForMetricsRefresh(() async { + calls++; + return _status(refreshing: false); + }, interval: fast); + + expect(calls, 1); + expect(done, isNotNull); + }); + + test('a failed rebuild is handed back so the caller can say so', () async { + final done = await waitForMetricsRefresh( + () async => _status(refreshing: false, consistent: false), + interval: fast, + ); + + expect(done?.consistent, isFalse); + }); + + test('gives up with null when the rebuild never finishes', () async { + final done = await waitForMetricsRefresh( + () async => _status(refreshing: true), + interval: fast, + timeout: const Duration(milliseconds: 20), + ); + + expect(done, isNull); + }); +} diff --git a/app/test/portfolio_test.dart b/app/test/portfolio_test.dart index 6a073e4..9eee516 100644 --- a/app/test/portfolio_test.dart +++ b/app/test/portfolio_test.dart @@ -49,26 +49,26 @@ HoldingOut holding({ } SummaryOut summary() => SummaryOut( - asOf: DateTime(2026, 9, 18), - cashRub: '5700', - computedAt: DateTime.utc(2026, 9, 18, 10), - holdingCount: 2, - incomeRub: '700', - investedNetRub: '20000', - marketValueRub: '11000', - pnlTotalRub: null, - realizedPnlRub: '0', - returns: const [], - scope: 'all', - staleCount: 0, - totalRub: '16700', - unpricedCount: 1, - ); + asOf: DateTime(2026, 9, 18), + cashRub: '5700', + computedAt: DateTime.utc(2026, 9, 18, 10), + holdingCount: 2, + incomeRub: '700', + investedNetRub: '20000', + marketValueRub: '11000', + pnlTotalRub: null, + realizedPnlRub: '0', + returns: const [], + scope: 'all', + staleCount: 0, + totalRub: '16700', + unpricedCount: 1, +); Widget _wrap(Widget child, List overrides) => ProviderScope( - overrides: overrides, - child: MaterialApp(home: Scaffold(body: child)), - ); + overrides: overrides, + child: MaterialApp(home: Scaffold(body: child)), +); /// The tabs are long scrolling lists and a ListView only builds what fits, so the default /// 800x600 surface would leave the holdings table unbuilt and every `find` on it empty. @@ -81,22 +81,30 @@ Future pumpTall(WidgetTester tester, Widget widget) async { } void main() { - testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', (tester) async { - await pumpTall(tester, _wrap( - const HoldingsTab(), - [ + testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', ( + tester, + ) async { + await pumpTall( + tester, + _wrap(const HoldingsTab(), [ portfolioSummaryProvider.overrideWith((ref) => Cached(summary())), valueSeriesProvider.overrideWith((ref) => const Cached([])), - portfolioReturnsProvider.overrideWith((ref) => const Cached([])), - holdingsProvider.overrideWith((ref) => Cached([ - holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'), - holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'), - ])), + portfolioReturnsProvider.overrideWith( + (ref) => const Cached([]), + ), + holdingsProvider.overrideWith( + (ref) => Cached([ + holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'), + holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'), + ]), + ), // HoldingsTab embeds BenchmarksCard, which watches this too — leaving it out would // fall through to the real apiProvider (see docs/ai/offline-cache.md). - benchmarkRowsProvider.overrideWith((ref) => const Cached([])), - ], - )); + benchmarkRowsProvider.overrideWith( + (ref) => const Cached([]), + ), + ]), + ); expect(find.text('GAZP'), findsOneWidget); expect(find.text('SIBN6P4'), findsOneWidget); @@ -107,44 +115,54 @@ void main() { expect(find.text(MoneyText.format('0', 'RUB')), findsNothing); }); - testWidgets('Позиции show the total profit as «—» while a price is missing', (tester) async { - await pumpTall(tester, _wrap( - const HoldingsTab(), - [ + testWidgets('Позиции show the total profit as «—» while a price is missing', ( + tester, + ) async { + await pumpTall( + tester, + _wrap(const HoldingsTab(), [ portfolioSummaryProvider.overrideWith((ref) => Cached(summary())), valueSeriesProvider.overrideWith((ref) => const Cached([])), - portfolioReturnsProvider.overrideWith((ref) => const Cached([])), + portfolioReturnsProvider.overrideWith( + (ref) => const Cached([]), + ), holdingsProvider.overrideWith((ref) => const Cached([])), - benchmarkRowsProvider.overrideWith((ref) => const Cached([])), - ], - )); + benchmarkRowsProvider.overrideWith( + (ref) => const Cached([]), + ), + ]), + ); expect(find.text('Прибыль'), findsOneWidget); expect(find.text('часть позиций без цены'), findsOneWidget); }); - testWidgets('Аллокация labels the cash and unknown buckets in Russian', (tester) async { - await pumpTall(tester, _wrap( - const AllocationTab(), - [ - allocationProvider.overrideWith((ref) => Cached([ - AllocationBucket( - bucket: 'share', - dimension: AllocationDimension.assetClass, - holdingCount: 1, - valueRub: '11000', - weight: '0.6587', - ), - AllocationBucket( - bucket: 'cash', - dimension: AllocationDimension.assetClass, - holdingCount: 0, - valueRub: '5700', - weight: '0.3413', - ), - ])), - ], - )); + testWidgets('Аллокация labels the cash and unknown buckets in Russian', ( + tester, + ) async { + await pumpTall( + tester, + _wrap(const AllocationTab(), [ + allocationProvider.overrideWith( + (ref) => Cached([ + AllocationBucket( + bucket: 'share', + dimension: AllocationDimension.assetClass, + holdingCount: 1, + valueRub: '11000', + weight: '0.6587', + ), + AllocationBucket( + bucket: 'cash', + dimension: AllocationDimension.assetClass, + holdingCount: 0, + valueRub: '5700', + weight: '0.3413', + ), + ]), + ), + ]), + ); expect(find.text('Класс актива'), findsOneWidget); expect(find.text('Акции'), findsOneWidget); @@ -168,6 +186,9 @@ void main() { test('bucketLabel falls back to the source value it does not know', () { expect(bucketLabel(AllocationDimension.country, 'RU'), 'Россия'); expect(bucketLabel(AllocationDimension.country, 'unknown'), 'Не указано'); - expect(bucketLabel(AllocationDimension.sector, 'oil_and_gas'), 'oil_and_gas'); + expect( + bucketLabel(AllocationDimension.sector, 'oil_and_gas'), + 'oil_and_gas', + ); }); } diff --git a/app/test/portfolios_page_test.dart b/app/test/portfolios_page_test.dart new file mode 100644 index 0000000..916f680 --- /dev/null +++ b/app/test/portfolios_page_test.dart @@ -0,0 +1,95 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/cache/cached.dart'; +import 'package:fintracker_app/features/accounts/providers.dart'; +import 'package:fintracker_app/features/portfolios/portfolios_page.dart'; +import 'package:fintracker_app/features/portfolios/providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +AccountOut _account(int id, String name, AccountKind kind) => AccountOut( + id: id, + kind: kind, + source_: 'test', + sourceId: 'acc-$id', + broker: null, + name: name, + currency: 'RUB', + role: AccountRole.investment, + includeInNetWorth: true, + mirrorOfAccountId: null, + primaryEventSource: null, + archived: false, + disabled: false, + openedAt: null, + balance: null, + balanceAsOf: null, + startBalance: null, + creditLimit: null, +); + +Widget _app(List portfolios, List accounts) => + ProviderScope( + overrides: [ + portfolioListProvider.overrideWith((ref) async => portfolios), + accountsProvider.overrideWith((ref) async => Cached(accounts)), + ], + child: const MaterialApp(home: PortfoliosPage()), + ); + +void main() { + testWidgets('an empty list explains what a portfolio is', (tester) async { + await tester.pumpWidget(_app(const [], const [])); + await tester.pumpAndSettle(); + + expect(find.textContaining('Портфелей пока нет'), findsOneWidget); + expect(find.text('Новый портфель'), findsOneWidget); + }); + + testWidgets('a portfolio shows its accounts by name', (tester) async { + await tester.pumpWidget( + _app( + [ + PortfolioOut( + id: 1, + name: 'Основной', + baseCurrency: 'RUB', + accountIds: [10, 11], + ), + ], + [ + _account(10, 'Брокер А', AccountKind.broker), + _account(11, 'Брокер Б', AccountKind.broker), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Основной'), findsOneWidget); + expect(find.text('2 счёта: Брокер А, Брокер Б'), findsOneWidget); + }); + + testWidgets('the new-portfolio dialog needs a name and lists brokers first', ( + tester, + ) async { + await tester.pumpWidget( + _app(const [], [ + _account(1, 'Карта', AccountKind.zmCard), + _account(2, 'Брокер', AccountKind.broker), + ]), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Новый портфель')); + await tester.pumpAndSettle(); + + final titles = tester + .widgetList(find.byType(CheckboxListTile)) + .toList(); + expect((titles.first.title as Text).data, 'Брокер'); + + await tester.tap(find.text('Сохранить')); + await tester.pumpAndSettle(); + expect(find.text('Обязательное поле'), findsOneWidget); + }); +} diff --git a/app/test/scope_cards_test.dart b/app/test/scope_cards_test.dart new file mode 100644 index 0000000..d28b6c1 --- /dev/null +++ b/app/test/scope_cards_test.dart @@ -0,0 +1,112 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/widgets/money_text.dart'; +import 'package:fintracker_app/features/home/scope_cards.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ScopeCardOut _card({ + String scope = 'portfolio:1', + String name = 'ИИСы', + String kind = 'portfolio', + String total = '224608.65', + String? pnl = '6643.47', + String? pnlPct = '0.015', + String? day = '-265.68', + String? dayPct = '-0.001', + String? xirr = '0.0298', + String income = '17387.49', + String? incomePct = '0.043', +}) => ScopeCardOut( + scope: scope, + name: name, + kind: kind, + asOf: DateTime(2026, 9, 19), + totalRub: total, + investedRub: '200000', + pnlRub: pnl, + pnlPct: pnlPct, + dayChangeRub: day, + dayChangePct: dayPct, + xirr: xirr, + incomeYearRub: income, + incomeYearPct: incomePct, +); + +Widget _app(List cards, {double width = 1200}) => ProviderScope( + child: MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: Size(width, 900)), + child: Scaffold( + body: SingleChildScrollView(child: ScopeCards(cards: cards)), + ), + ), + ), +); + +void main() { + testWidgets('a card shows the name, the value and the four figures', ( + tester, + ) async { + await tester.pumpWidget(_app([_card()])); + + expect(find.text('ИИСЫ'), findsOneWidget); + expect(find.text(MoneyText.format('224608.65', 'RUB')), findsOneWidget); + for (final label in [ + 'Прибыль', + 'За день', + 'Доходность', + 'Пассивный доход', + ]) { + expect(find.text(label), findsOneWidget, reason: label); + } + // the gain carries an explicit plus, the loss its own minus + expect(find.text('+${MoneyText.format('6643.47', 'RUB')}'), findsOneWidget); + expect(find.text(MoneyText.format('-265.68', 'RUB')), findsOneWidget); + expect(find.text('2,98 %'), findsOneWidget); + expect( + find.textContaining(MoneyText.format('17387.49', 'RUB')), + findsOneWidget, + ); + }); + + testWidgets('missing figures read as a dash, not as zero', (tester) async { + await tester.pumpWidget( + _app([ + _card( + pnl: null, + pnlPct: null, + day: null, + dayPct: null, + xirr: null, + income: '0', + incomePct: null, + ), + ]), + ); + + expect(find.text('—'), findsNWidgets(4)); + }); + + testWidgets('cards flow into columns by the available width', (tester) async { + final cards = [ + for (var i = 0; i < 6; i++) _card(scope: 'account:$i', name: 'Счёт $i'), + ]; + addTearDown(tester.view.reset); + + Future columns(double width) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + await tester.pumpWidget(_app(cards, width: width)); + final xs = { + for (final c in cards) + tester.getTopLeft(find.text('СЧЁТ ${cards.indexOf(c)}')).dx.round(), + }; + return xs.length; + } + + expect(await columns(1200), 3); + expect(await columns(800), 2); + expect(await columns(400), 1); + }); +} diff --git a/app/test/service_mark_test.dart b/app/test/service_mark_test.dart new file mode 100644 index 0000000..5aafae2 --- /dev/null +++ b/app/test/service_mark_test.dart @@ -0,0 +1,82 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:fintracker_app/core/theme/app_theme.dart'; +import 'package:fintracker_app/core/widgets/service_mark.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _render(WidgetTester tester, double size) async { + final key = GlobalKey(); + tester.view.physicalSize = Size(size, size); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RepaintBoundary( + key: key, + child: ServiceMark(size: size), + ), + ), + ), + ); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + return (await tester.runAsync(() async { + final image = await boundary.toImage(); + return image.toByteData(format: ui.ImageByteFormat.rawRgba); + }))!; +} + +Color _at(ByteData data, int size, int x, int y) { + final i = (y * size + x) * 4; + return Color.fromARGB( + data.getUint8(i + 3), + data.getUint8(i), + data.getUint8(i + 1), + data.getUint8(i + 2), + ); +} + +void main() { + testWidgets( + 'a gradient disc with three white bars, and nothing outside the disc', + (tester) async { + const size = 256; + final data = await _render(tester, size.toDouble()); + + // scale of the 512 design grid + int px(num v) => (v * size / 512).round(); + + expect( + _at(data, size, 2, 2).a, + 0, + reason: 'the corner is outside the disc', + ); + // the tallest bar (x 316..380, y 131..381): white in its middle + expect(_at(data, size, px(348), px(256)), const Color(0xFFFFFFFF)); + // between two bars the disc shows through, and it is coloured, not white + final gap = _at(data, size, px(210), px(300)); + expect(gap.a, 1); + expect(gap.b, greaterThan(gap.r), reason: 'blue-violet, not grey'); + // the gradient runs from the accent (top left) toward violet (bottom right) + final start = _at(data, size, px(90), px(90)); + final end = _at(data, size, px(430), px(430)); + expect(end.r, greaterThan(start.r)); + expect(start.b * 255, greaterThan(200)); + expect( + (start.g - end.g) * 255, + greaterThan(40), + reason: 'the green channel falls off toward violet', + ); + }, + ); + + test('the near end of the gradient is the app accent', () { + expect(AppTheme.seed, const Color(0xFF14AFFF)); + expect(ServiceMark.violet, isNot(AppTheme.seed)); + }); +} diff --git a/app/web/favicon.png b/app/web/favicon.png index 8aaa46ac1ae21512746f852a42ba87e4165dfdd1..c81f14429a72c13669e9c27e0243cb68b46a9093 100644 GIT binary patch literal 984 zcmV;}11J26P)UK~z|Uy_er>TvZUqKQs5P=9k+FEi70iiP<73wpuK{i2VT~M*AQX ze9Au{QV`mxfw-t(Q$&2wP4Ptnt%?PoVwxh2p-@^Bt+o}!N?L511{7({u7+**j1PA= zyUE^rca!dVVA;z#!+gGHX3pGmg$7685li|7`UQ50u|v|_y^;&gfMbypIlm#fK6Xmd)p#! ztFccU{t}=*yn5}H8XriG;pvPlHY}j`sHL^YmufsBE1a$Z9@qzKXZ_o}cw$Jd*A>t= zW{)A9P-9~=;8k|Bo}+(qNX|zJDDC`o3*hCsS-Hg%6B(H=GqwEC{fV_{kz*}`I}pbv zRmS(eZ|yY#e2;h3*wQk1Wo=vGCa*0^LGPGt_TlF{sCLvKc^AC-0wmi4=i@)X*XKe- z=6of6EF<#@;KAUX!quQ}BW&x2bSn7memF8hU9P||fc71+WU0wCCG0CJJjzaCV9x=& z$1kHoY$Q5=-TM)P@eNky}c>$8UYpwFSB;}r5DSp;jiw7hf+b9f4L4HP1a9A z$VMOa)#zEv&i8DsfYK=#od|Xum4b*U_o_OQYYBS|$U-7uv2fH_r_HE#sMtpd3yZQE z_-gzujtx=p2iCz;4}u5$wE!nCMu$2~Kpb2gH+^wjR%1id&R^L9Pxe$C&i)CP<|060 zDTs!n#*CJ(QITP9^Orkq_XFE70iltJKe|7u7*nx1w^~qK!&x>*EF3XTYL_ssM!wN% zFE^L42!u7Fm`<4Y9mjS0WyxWDzN%`EIEG>OyQoemj!&~$$@u^YVZ@3IsF7G{JarK| z6M?gZe_>`WSeyI~()R_>->wF8zh8q3zd~0>Fn{Lz2)I!Y<6t>W06hJk9a3X|BNvi7 z?KRd;94H*jX61FDawC!)!|FKuKfup;9+~PwT^c(MSt?QFTQ%-)3EY@-#`eR9<=Rq> zR126sAoHH%c{OHR0Kck5o~?%02mqKIlJf|UdyZ4h3_HU14?mU*HK!!xSC{Qd_h=t?2%lIZII00001AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM diff --git a/app/web/favicon.svg b/app/web/favicon.svg new file mode 100644 index 0000000..6bc56d9 --- /dev/null +++ b/app/web/favicon.svg @@ -0,0 +1,10 @@ + + fin-tracker + + + + + + + + diff --git a/app/web/icon.svg b/app/web/icon.svg new file mode 100644 index 0000000..c336db2 --- /dev/null +++ b/app/web/icon.svg @@ -0,0 +1,14 @@ + + fin-tracker + + + + + + + + + + + + diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png index b749bfef07473333cf1dd31e9eed89862a5d52aa..6242f9c936b5ddd507d4e73c0f1d3df5d3f124ca 100644 GIT binary patch literal 8295 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 literal 5292 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 diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png index 88cfd48dff1169879ba46840804b412fe02fefd6..4b365be34ed0fbfd5a3861e54b5e3b4e340912b9 100644 GIT binary patch literal 31175 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 diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png index eb9b4d76e525556d5d89141648c724331630325d..c455bedb8de0ece89b86498b23d8466259f099ba 100644 GIT binary patch literal 3998 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 literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png index d69c56691fbdb0b7efa65097c7cc1edac12a6d3e..c56b18926d2efcf9ad9d1a49a949406871d5e559 100644 GIT binary patch literal 16273 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 QjXj(8S-ms)r!DUO4}sq{Jpcdz literal 20998 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 MPgg&ebxsLQ025IeI{*Lx diff --git a/app/web/index.html b/app/web/index.html index e2b961d..0cfe6a9 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -27,6 +27,7 @@ + fintracker_app diff --git a/app/web/manifest.json b/app/web/manifest.json index 1bae3ef..260fa4f 100644 --- a/app/web/manifest.json +++ b/app/web/manifest.json @@ -3,8 +3,8 @@ "short_name": "fintracker_app", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", + "background_color": "#25252F", + "theme_color": "#1F1F28", "description": "A new Flutter project.", "orientation": "portrait-primary", "prefer_related_applications": false, diff --git a/docs/ai/design-system.md b/docs/ai/design-system.md index 232c8a7..d5a8a95 100644 --- a/docs/ai/design-system.md +++ b/docs/ai/design-system.md @@ -1,104 +1,105 @@ -# Визуальный язык (фаза 6, начата) +# Визуальный язык -Редизайн Flutter-клиента в духе Grimmory (self-hosted книжная библиотека): тёмный сине- -индиго фон вместо нейтрального серого, плоские карточки с рамкой вместо тени, группированная -боковая навигация с капс-заголовками и левой акцентной полоской у активного пункта. Обкатано -целиком на одном экране — Обзор (`features/home/home_page.dart`) — и на навигационном шелле -(он общий для всех экранов, поэтому не в счёт «раскатки»), прежде чем идти дальше, тем же -способом, что офлайн-кэш (`offline-cache.md`). +Клиент оформлен по образцу Snowball (сервис учёта инвестиций): графитово-фиолетовые поверхности, +один яркий голубой акцент, плоские скруглённые карточки без рамки, заливные поля ввода, +навигация вкладками сверху. Раньше был другой язык (сине-индиго, боковой сайдбар по референсу +Grimmory) — он заменён целиком, кроме доменных экранов, которые сохранили свою раскладку и просто +подхватили новые токены. -## Решение: одна тема на обе яркости, не «тёмная как есть — светлая как получится» - -Тёмная и светлая тема получают один и тот же язык (форма карточек, акцент, поведение -навигации), различаются только тона поверхностей. Причины: - -- Приложение живёт на вебе, Linux, Windows и Android одновременно — пользователь может - оказаться в любой теме по системной настройке, и она уже переключается в - `settings_page.dart`. Бесплатно смотрящаяся только тёмная версия читалась бы как - недоделанная в светлой. -- Семантика цвета (`signColor` — прибыль/убыток, `ChartColors` — ряды графиков) уже общая - для обеих тем; заводить для тёмной темы отдельный «фирменный» акцент, а для светлой - оставлять дефолтный Material-синий было бы второй, несогласованной системой токенов. +Одна тема на обе яркости: форма карточек, акцент и поведение навигации одинаковы, различаются +только тона поверхностей. Приложение живёт на вебе, Linux, Windows и Android, и тема +переключается в Настройках (`themeModeProvider`) — светлая версия не должна выглядеть недоделанной. ## Токены: `core/theme/app_theme.dart` -`AppTheme.light()` / `AppTheme.dark()` заменили инлайновые `ThemeData(...)` в `app.dart`. -Обе строятся одной функцией `_build(Brightness)` — нет двух параллельных наборов чисел, -которые могут разъехаться. +`AppTheme.light()` / `AppTheme.dark()` строятся одной функцией `_build(Brightness)`. -- **Seed** — `Color(0xFF2A78D6)`, тот же синий, что `ChartColors.slot1Blue` (основной ряд - графиков капитала/портфеля). До редизайна seed темы (`0xFF2E6F5E`, зелёно-бирюзовый) и - цвет основной линии на графиках были разными брендовыми цветами одного приложения — - теперь акцент интерфейса и «главный» цвет данных на графиках — один и тот же. -- **Поверхности** — не переопределены вручную: `ColorScheme.fromSeed` в Material 3 уже - тонирует нейтральную палитру оттенком seed, поэтому тёмный `surface` от синего seed - получается тёмным сине-индиго сам по себе («не чистый чёрный», как в референсе), без - захардкоженных цветов сверх seed. Слои `surfaceContainer(Low/…/High)` идут на карточки, - панель навигации и фон страницы — так они остаются согласованно тонированными при смене - темы. -- **`cardTheme`** — скруглённые углы (16), `elevation: 0`, тонкая рамка `outlineVariant` - вместо тени. Это глобальный токен: он каскадом меняет вид `Card` **везде**, где он уже - используется (`SectionCard`, `StatTile`, `GoalCard`, карточки Здоровья, `_Card` в - `holdings_tab.dart` и т.д.) без правки каждого экрана — что и даёт «зафиксировать токены - один раз» смысл. Экраны, которые ещё не редизайнены явно, уже выглядят новее только за - счёт этого. -- **`navigationRailTheme` / `navigationBarTheme`** — акцентная подсветка активного пункта - (`primary`/`primaryContainer`) для свёрнутого rail (600–1200) и нижнего бара (<600). +- **Акцент** — `AppTheme.seed = 0xFF14AFFF` (тот же, что `ChartColors.slot1Blue`, главный ряд + графиков). В светлой теме `primary` чуть темнее (`0xFF0A98E6`) ради контраста на белом. +- **Тёмные поверхности** заданы явно поверх `ColorScheme.fromSeed`, потому что тонировка + Material 3 даёт синий, а не графитово-фиолетовый: + `surface` `#25252F` (фон страницы) → `surfaceContainerLowest` `#1F1F28` (верхняя панель) → + `surfaceContainer` `#2F2F3C` (карточки) → `surfaceContainerHigh` `#3A3A49` (поля ввода, + вторичные кнопки) → `surfaceContainerHighest` `#474757` (активная «таблетка» навигации). +- **Плюс и минус** — `ChartColors.gain` (мятный `#3DDC97`) и `ChartColors.loss` (коралловый + `#F26B6B`); `signColor` (`portfolio/labels.dart`) возвращает именно их. Категориальные цвета + графиков (`slot1…slot5`) не менялись. +- **`cardTheme`** — радиус 12, `elevation: 0`; в тёмной теме без рамки, в светлой с тонкой + `outlineVariant`. Глобальный токен: меняет вид `Card` везде. +- **`inputDecorationTheme`** — заливка `surfaceContainerHigh`, без обводки, радиус 8, акцентная + рамка в фокусе. Действует на все формы и диалоги без правки экранов. +- Кнопки (радиус 8), `tabBarTheme`, `dialogTheme`, `popupMenuTheme`, `chipTheme` — из тех же токенов. -Все цвета берутся из `ColorScheme` или уже существующего `ChartColors` — ни одного нового -хардкод-цвета вне них. +Все цвета берутся из `ColorScheme` или `ChartColors`; хардкод вне них — только градиент логотипа. -## Компоненты +## Навигация: верхняя панель -- **`core/widgets/section_header.dart` → `SectionHeader`** — жирный заголовок + короткая - акцентная полоска (28×3, `colorScheme.primary`) под левой частью текста. Замена голому - `Text(title, style: titleMedium)` для смысловых блоков на странице (не для карточек со - своим заголовком — там по-прежнему `SectionCard`/`_Card`). -- **`core/widgets/tile_carousel.dart` → `TileCarousel`** — горизонтальная прокрутка вместо - `Wrap` для рядов `StatTile`. На Обзоре заменила `Wrap` у капитала, месяца, портфеля и - runway. Не требует общей высоты у детей (`IntrinsicHeight` внутри), поэтому подходит для - разноразмерных карточек будущих экранов (счета, холдинги). -- **`SectionCard`/`StatTile`** (`core/widgets/section_card.dart`) не переписаны — их новый - вид целиком идёт из `cardTheme`. `home_page.dart` перестал дублировать их локальными - `_ChartCard`/`_StatTile` и использует общие виджеты, как и остальные вкладки. +`features/shell/top_nav.dart` (`TopNav`) показывается от 600 px и шире; уже 600 — нижний +`NavigationBar` с «Ещё» (как раньше). Список направлений — `nav_destinations.dart`. -## `NavSidebar` (features/shell/nav_sidebar.dart) +- **Слева** знак сервиса (`core/widgets/service_mark.dart`, тот же рисунок, что `web/favicon.svg`: диск с градиентом от акцента к фиолетовому и три растущих столбца; тап ведёт на Обзор) и четыре основные вкладки-«таблетки»: Обзор, Аналитика, Портфель, Счета + (порядок как в Snowball). Экраны «Операции» (События, Импорт, Потоки, Категории, Операции, + Правила) собраны в выпадающем меню «Операции ▾»; на нём же подписано имя открытого экрана. +- **Справа** «Добавить» (событие, брокерский счёт, портфель, отчёт брокера), значок Здоровья со + счётчиком замечаний (`dataQualityProvider`), Настройки, аватар с меню (email, «Выйти»). +- До 900 px «Добавить» сжимается до иконки. +- Контент центрируется и не шире 1440 px. -Показывается только на широком breakpoint (≥1200, `_wideBreakpoint` в `app_shell.dart`). -На 600–1200 остаётся штатный `NavigationRail` в свёрнутом виде — в иконку-без-подписи всё -равно не помещаются ни капс-заголовки групп, ни счётчики, ни профиль, так что городить туда -кастомный виджет незачем; на <600 остаётся штатный `NavigationBar`. Список направлений -вынесен в `features/shell/nav_destinations.dart` (`NavDestination`, `navDestinations`, -`NavGroup`) — общий для `app_shell.dart` и `nav_sidebar.dart`, было приватным в одном файле. +`NavSidebar` и `NavigationRail` удалены. Наблюдение за `dataQualityProvider` и `meProvider` +остаётся в `TopNav`, значит эти запросы уходят при каждом заходе, а не только на трёх экранах; +для одного пользователя это не проблема, но стоит знать при правке `core/cache/*`. -- **Группы** (`NavGroup`): «ОБЗОР» (Обзор/Счета/Портфель/Аналитика — те же четыре, что - сейчас основные для нижнего бара), «ОПЕРАЦИИ» (События/Импорт/Потоки/Категории/ - Операции/Правила), «СИСТЕМА» (Здоровье/Настройки). -- **Активный пункт** — левая полоска 3px `colorScheme.primary` + тонированный фон - `primaryContainer` + акцентный цвет текста/иконки. -- **Счётчик** — сейчас подключён только у «Здоровье», из `dataQualityProvider` - (число строк). У «Цели» счётчика нет: это не отдельный пункт меню, а часть хаба - «Аналитика» (`alsoMatches` в `NavDestination`) — добавлять его как отдельный пункт ради - счётчика значило бы менять структуру навигации, что не входило в задачу «только - визуальный слой». -- **Профиль внизу** — аватар с инициалом + email из `meProvider`, «имя» — локальная часть - email до `@` (в бэкенде нет отдельного отображаемого имени, только email/id). +## Экраны -**Осознанный побочный эффект**: `NavSidebar` вызывает `ref.watch` на `dataQualityProvider` -и `meProvider`, которые раньше запрашивались только при открытии Обзора/Здоровья/Настроек. -Поскольку `AppShell` смонтирован всё время сессии, эти два запроса теперь улетают на API -при каждом заходе в приложение, а не только на этих трёх экранах. `meProvider` не -`autoDispose` и уже держится живым где-то ещё — здесь она просто донат к уже открытому -запросу. `dataQualityProvider` — `autoDispose`; watch из шелла держит его живым, пока -приложение открыто. Для одиночного пользователя на своём VPS это не проблема -производительности, но это решение, а не то, о чём просили буквально — стоит знать при -следующей правке `core/cache/*`. +- **Обзор** — сверху сетка карточек «Портфели»: по одной на `all`, портфель и счёт + (`home/scope_cards.dart`). В карточке: стоимость, прибыль (₽ и %), за день, доходность + (XIRR с начала), пассивный доход (% и ₽ в год). Один запрос `GET /analytics/overview`; + тап открывает Портфель в этом скоупе. Ниже прежние блоки (капитал, месяц, запас хода, графики). +- **Аналитика** — общая полоса вкладок под верхней панелью (`shell/analytics_tabs.dart`): + Общее, Дивиденды, Ребалансировка, Цели, Налоги, Портфели. Каждая вкладка — свой маршрут + (`/analytics`, `/income`, …), остаются ссылаемыми. «Общее» (`AnalyticsHubPage`) — четыре + плитки (Стоимость, Прибыль, Доходность, Пассивный доход; `portfolio/overview_tiles.dart`), + график стоимости, доходность, бенчмарки. На телефоне вместо полосы остаётся список разделов. +- **Портфель** — таблица активов (`portfolio/holdings_table.dart`) с вкладками-пресетами колонок + (Мои активы, Общее, Дивиденды, Прибыль, Облигации), поиском и двухстрочными ячейками + (сумма и, ниже, цена за штуку или доля). Колонки растягиваются на карточку, при нехватке места + таблица прокручивается вбок. Неизвестная величина — «—», а не «0 ₽»; нулевые дивиденды тоже «—». + `HoldingsTab` принимает `HoldingsSections` (`all`, `table`, `overview`), чтобы Портфель и + «Аналитика → Общее» брали одни и те же провайдеры и не могли разойтись в числах. +- **Счета** — один компактный список (`accounts/accounts_page.dart`): поиск, чипы статуса с числами (Все / Активные / Отключённые / Архивные) и типа (ликвидные, сбережения, инвестиции, долги). В строке — источник (ZenMoney, T-Invest, отчёты), тип-«таблетка» с меню, баланс или стоимость, переключатели «В капитал» и «Активен». Отключённые и архивные приглушены и идут в конце. Чекбоксы включают панель массовых действий (отключить, включить, сменить тип, учёт в капитале) — по одному запросу на счёт и один пересчёт метрик на всю пачку (`accounts/actions.dart`). До 880 px строка двухстрочная. +- **Настройки** — карточка с вкладками сверху (Аккаунт, Отображение, Сервер) и меню разделов + слева; до 720 px меню превращается в ряд чипов. -## Что не тронуто в этой фазе +## Что не менялось -- `health_page.dart`, `holdings_tab.dart`, `goal_card.dart` — контент не менялся. - `SectionHeader` для «Требует внимания»/списка запусков на Здоровье и разбор карточек - холдингов/целей под бейдж+прогресс-полоску (как обложки Grimmory) — следующий шаг, тем же - способом: один экран, проверка, потом дальше. -- Маршруты и провайдеры не менялись, кроме двух новых точек `ref.watch` в `NavSidebar` - (см. выше). +Экраны Счета, События, Импорт, Потоки, Категории, Операции, Правила, Здоровье, Цели, Налоги, +Ребалансировка, карточка инструмента сохранили раскладку и подхватили токены (карточки, поля, +кнопки, вкладки). Переделывать их по образцу Snowball можно тем же способом: экран за экраном. + +## Иконки активов + +`AssetIcon` (`core/widgets/asset_icon.dart`) — круг с логотипом эмитента; стоит в колонке «Актив» таблицы +и в шапке карточки инструмента. Логотип берётся из брендовых данных T-Invest: синк раскладывает +`brand.logo_name` и `brand.logo_base_color` по `instrument.logo_name` / `logo_color` (сначала по +`tinvest_uid`, потом по ISIN — так подхватываются и бумаги из отчётов Сбера и ВТБ; заполненное не +перезаписывается). API отдаёт готовый `logo_url` (`branding.logo_url`, публичный CDN +`invest-brands.cdn-tinkoff.ru`, 160 px, без ключа, с `access-control-allow-origin: *`) и `logo_color`. +Нет логотипа, он ещё грузится или не загрузился — круг цвета бренда (нейтральный, если цвета нет) +с иконкой класса актива (`assetClassIcon`), так что подписи по типу остаются запасным вариантом. + +## Подсказки к терминам + +Рядом с терминами и аббревиатурами (XIRR, TWR, НКД, ЛДВ, «Запас хода», «Пассивный доход» и т. д.) +стоит приглушённый значок «?»: на вебе и десктопе подсказка появляется по наведению, на телефоне +по тапу (`core/widgets/help_tip.dart`, `HelpTip`). + +- **`TermLabel(text)`** — подпись, которая сама ищет объяснение в словаре + (`core/glossary.dart`, `glossaryHint`) по тексту подписи (без учёта регистра и «ё»). Нет записи — + обычный `Text`, поэтому его безопасно ставить под любую подпись. Параметр `hint` перекрывает + словарь там, где одно слово значит разное (например, «Прибыль» в налогах). +- `StatTile` и `SectionCard` уже рисуют подписи через `TermLabel`, поэтому Обзор, доходы, цели и + налоги получили подсказки без правки экранов; заголовки таблиц (Портфель, Налоги), карточки на + Обзоре, плитки Аналитики, детали события и карточка инструмента — тоже. +- **Новый термин** — добавить строку в `_entries` словаря; определения сверяйте с кодом аналитики + (`analytics/*.py`), а не с общими представлениями: например, запас хода считается от «обычного» + расхода за три последних полных месяца и не включает инвестиции.