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 8aaa46a..c81f144 100644 Binary files a/app/web/favicon.png and b/app/web/favicon.png differ 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 b749bfe..6242f9c 100644 Binary files a/app/web/icons/Icon-192.png and b/app/web/icons/Icon-192.png differ diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png index 88cfd48..4b365be 100644 Binary files a/app/web/icons/Icon-512.png and b/app/web/icons/Icon-512.png differ diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png index eb9b4d7..c455bed 100644 Binary files a/app/web/icons/Icon-maskable-192.png and b/app/web/icons/Icon-maskable-192.png differ diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png index d69c566..c56b189 100644 Binary files a/app/web/icons/Icon-maskable-512.png and b/app/web/icons/Icon-maskable-512.png differ 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`), а не с общими представлениями: например, запас хода считается от «обычного» + расхода за три последних полных месяца и не включает инвестиции.