feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события

Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
Dmitry
2026-09-19 22:14:21 +03:00
parent a559d6de3e
commit 62d36aa3e8
73 changed files with 6406 additions and 1192 deletions
+149
View File
@@ -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 = <String, String>{
// 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)];
+121 -15
View File
@@ -1,25 +1,70 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Design tokens for fin-tracker's visual language (`docs/ai/design-system.md`): dark /// Design tokens for fin-tracker's visual language (`docs/ai/design-system.md`): graphite-violet
/// surfaces tinted blue-indigo rather than neutral grey, flat bordered cards instead of /// surfaces, one bright sky-blue accent, flat rounded cards with no outline, filled inputs and
/// Material's default elevation shadow, and one accent seed shared with /// pill-shaped navigation — the look of Snowball.
/// `ChartColors.slot1Blue` so UI chrome and the primary data series read as the same color.
/// ///
/// 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 /// 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. /// theme, and switching `themeModeProvider` never changes which widgets exist.
class AppTheme { class AppTheme {
const AppTheme._(); const AppTheme._();
/// The accent: buttons, active tabs, links and the primary chart series.
/// Matches `ChartColors.slot1Blue`. /// Matches `ChartColors.slot1Blue`.
static const seed = Color(0xFF2A78D6); static const seed = Color(0xFF14AFFF);
static ThemeData light() => _build(Brightness.light); static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark); 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) { static ThemeData _build(Brightness brightness) {
final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: brightness); final scheme = _scheme(brightness);
final outline = scheme.outlineVariant.withValues(alpha: brightness == Brightness.dark ? 0.4 : 0.7); 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( return ThemeData(
useMaterial3: true, useMaterial3: true,
@@ -39,34 +84,95 @@ class AppTheme {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: radius,
side: BorderSide(color: outline), side: dark ? BorderSide.none : BorderSide(color: outline),
), ),
), ),
dividerTheme: DividerThemeData(color: outline, space: 1, thickness: 1), 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( navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surfaceContainerLow, backgroundColor: scheme.surfaceContainerLow,
indicatorColor: scheme.primaryContainer, indicatorColor: scheme.primaryContainer,
useIndicator: true, useIndicator: true,
selectedIconTheme: IconThemeData(color: scheme.primary), selectedIconTheme: IconThemeData(color: scheme.primary),
unselectedIconTheme: IconThemeData(color: scheme.onSurfaceVariant), 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), unselectedLabelTextStyle: TextStyle(color: scheme.onSurfaceVariant),
), ),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surfaceContainerLow, backgroundColor: scheme.surfaceContainerLowest,
indicatorColor: scheme.primaryContainer, indicatorColor: scheme.primaryContainer,
elevation: 0, elevation: 0,
iconTheme: WidgetStateProperty.resolveWith( iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData( (states) => IconThemeData(
color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant, color: states.contains(WidgetState.selected)
? scheme.primary
: scheme.onSurfaceVariant,
), ),
), ),
labelTextStyle: WidgetStateProperty.resolveWith( labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle( (states) => TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w600 : FontWeight.w400, fontWeight: states.contains(WidgetState.selected)
color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant, ? FontWeight.w600
: FontWeight.w400,
color: states.contains(WidgetState.selected)
? scheme.primary
: scheme.onSurfaceVariant,
), ),
), ),
), ),
+6 -1
View File
@@ -6,12 +6,17 @@ import 'package:flutter/material.dart';
class ChartColors { class ChartColors {
const ChartColors._(); const ChartColors._();
static const slot1Blue = Color(0xFF2A78D6); static const slot1Blue = Color(0xFF14AFFF);
static const slot2Orange = Color(0xFFEB6834); static const slot2Orange = Color(0xFFEB6834);
static const slot3Aqua = Color(0xFF1BAF7A); static const slot3Aqua = Color(0xFF1BAF7A);
static const slot4Yellow = Color(0xFFEDA100); static const slot4Yellow = Color(0xFFEDA100);
static const slot5Magenta = Color(0xFFE87BA4); 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. /// This app's fixed assignment for cashflow charts.
static const income = slot1Blue; static const income = slot1Blue;
static const expense = slot2Orange; static const expense = slot2Orange;
+3 -1
View File
@@ -4,7 +4,9 @@ import 'package:shared_preferences/shared_preferences.dart';
const _prefsKey = 'theme_mode'; const _prefsKey = 'theme_mode';
final themeModeProvider = NotifierProvider<ThemeModeController, ThemeMode>(ThemeModeController.new); final themeModeProvider = NotifierProvider<ThemeModeController, ThemeMode>(
ThemeModeController.new,
);
/// Persists the chosen [ThemeMode] to this device via `shared_preferences`. /// Persists the chosen [ThemeMode] to this device via `shared_preferences`.
class ThemeModeController extends Notifier<ThemeMode> { class ThemeModeController extends Notifier<ThemeMode> {
+4 -2
View File
@@ -15,7 +15,8 @@ import 'package:dio/dio.dart';
import '../auth/auth_controller.dart' show problemMessage; 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) { int? asInt(Object? v) => switch (v) {
null => null, null => null,
@@ -53,7 +54,8 @@ List<Map<String, dynamic>> asObjects(Object? v) => v is List
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList() ? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
: const []; : const [];
Map<String, dynamic>? asObject(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null; Map<String, dynamic>? asObject(Object? v) =>
v is Map ? Map<String, dynamic>.from(v) : null;
List<String> asStrings(Object? v) => List<String> asStrings(Object? v) =>
v is List ? v.map((e) => e.toString()).toList() : const <String>[]; v is List ? v.map((e) => e.toString()).toList() : const <String>[];
+2 -1
View File
@@ -43,7 +43,8 @@ String ruMonthYear(DateTime d) {
String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}'; String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}';
/// `'2026-09'`, the `month` query parameter format the API expects. /// `'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). /// Parses a `'YYYY-MM'` key back into a [DateTime] (first of month, UTC).
DateTime parseMonthKey(String key) { DateTime parseMonthKey(String key) {
+104
View File
@@ -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,
};
+15 -3
View File
@@ -8,7 +8,12 @@ import '../auth/auth_controller.dart';
/// a retry-capable error view otherwise. Keeps the loading/error boilerplate /// a retry-capable error view otherwise. Keeps the loading/error boilerplate
/// out of every screen that watches a provider. /// out of every screen that watches a provider.
class AsyncValueView<T> extends StatelessWidget { class AsyncValueView<T> 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<T> value; final AsyncValue<T> value;
final Widget Function(T data) data; final Widget Function(T data) data;
@@ -30,12 +35,19 @@ class AsyncValueView<T> extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(height: 8),
Text(_message(error), textAlign: TextAlign.center), Text(_message(error), textAlign: TextAlign.center),
if (onRetry != null) ...[ if (onRetry != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.tonal(onPressed: onRetry, child: const Text('Повторить')), FilledButton.tonal(
onPressed: onRetry,
child: const Text('Повторить'),
),
], ],
], ],
), ),
+5 -1
View File
@@ -2,7 +2,11 @@ import 'package:flutter/material.dart';
/// A centered placeholder for a screen or list section with nothing to show yet. /// A centered placeholder for a screen or list section with nothing to show yet.
class EmptyState extends StatelessWidget { 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 String message;
final IconData icon; final IconData icon;
+85
View File
@@ -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),
],
);
}
}
+6 -6
View File
@@ -4,11 +4,7 @@ import 'package:intl/intl.dart';
/// Currency symbols for the instruments we expect to see. Falls back to the /// 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. /// ISO code itself (e.g. 'USDT') when we don't have a nicer glyph.
const _currencySymbols = { const _currencySymbols = {'RUB': '', 'USD': '\$', 'EUR': ''};
'RUB': '',
'USD': '\$',
'EUR': '',
};
/// Formats a decimal-string amount (as the API sends it, to avoid double /// 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. /// 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) { static String format(String amount, String currency) {
final value = Decimal.parse(amount).toDouble(); final value = Decimal.parse(amount).toDouble();
final symbol = _currencySymbols[currency] ?? currency; 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); return formatter.format(value);
} }
+3 -1
View File
@@ -23,7 +23,9 @@ class ScopeSelector extends ConsumerWidget {
value: scopes, value: scopes,
data: (rows) { data: (rows) {
if (rows.length < 2) return const SizedBox.shrink(); 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( return DropdownButtonHideUnderline(
child: DropdownButton<String>( child: DropdownButton<String>(
value: known, value: known,
+28 -7
View File
@@ -1,9 +1,17 @@
import 'package:flutter/material.dart'; 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 /// A titled card section, the layout unit the phase-4 screens are built from (the same
/// shape Портфель already uses inline). /// shape Портфель already uses inline).
class SectionCard extends StatelessWidget { 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 title;
final String? subtitle; final String? subtitle;
@@ -25,13 +33,15 @@ class SectionCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: theme.textTheme.titleMedium), TermLabel(title, style: theme.textTheme.titleMedium),
if (subtitle != null) if (subtitle != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 2), padding: const EdgeInsets.only(top: 2),
child: Text( child: Text(
subtitle!, 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. /// A compact labelled number, used for the summary rows of the phase-4 screens.
class StatTile extends StatelessWidget { 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 String label;
final Widget value; final Widget value;
@@ -69,14 +85,19 @@ class StatTile extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: theme.textTheme.bodySmall), TermLabel(label, style: theme.textTheme.bodySmall),
const SizedBox(height: 4), const SizedBox(height: 4),
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value), DefaultTextStyle(
style: theme.textTheme.titleMedium!,
child: value,
),
if (note != null) ...[ if (note != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
note!, note!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
maxLines: 3, maxLines: 3,
), ),
], ],
+3 -1
View File
@@ -23,7 +23,9 @@ class SectionHeader extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Container( Container(
+66
View File
@@ -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;
}
+6 -2
View File
@@ -14,11 +14,15 @@ class StaleBanner extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final at = fetchedAt.toLocal(); 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( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 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( child: Row(
children: [ children: [
Icon(Icons.cloud_off, size: 18, color: scheme.onErrorContainer), Icon(Icons.cloud_off, size: 18, color: scheme.onErrorContainer),
@@ -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<AccountOut?> showAccountCreateDialog(
BuildContext context, {
Broker? broker,
String? sourceId,
String? name,
}) => showDialog<AccountOut>(
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<FormState>();
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<void> _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<Broker>(
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('Создать'),
),
],
);
}
}
+651 -98
View File
@@ -1,18 +1,24 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart'; import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/cache/cached.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/help_tip.dart';
import '../../core/widgets/money_text.dart'; import '../../core/widgets/money_text.dart';
import '../../core/widgets/stale_banner.dart'; import '../../core/widgets/stale_banner.dart';
import 'account_create_dialog.dart';
import 'actions.dart';
import 'providers.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) { String _roleLabel(AccountRole role) => switch (role) {
AccountRole.liquid => 'Ликвидные', AccountRole.liquid => 'Ликвидные',
@@ -22,7 +28,7 @@ String _roleLabel(AccountRole role) => switch (role) {
AccountRole.unknownDefaultOpenApi => 'Неизвестно', AccountRole.unknownDefaultOpenApi => 'Неизвестно',
}; };
String _kindLabel(AccountKind kind) => switch (kind) { String accountKindLabel(AccountKind kind) => switch (kind) {
AccountKind.zmCash => 'Наличные', AccountKind.zmCash => 'Наличные',
AccountKind.zmCard => 'Карта', AccountKind.zmCard => 'Карта',
AccountKind.zmChecking => 'Расчётный счёт', AccountKind.zmChecking => 'Расчётный счёт',
@@ -35,19 +41,119 @@ String _kindLabel(AccountKind kind) => switch (kind) {
AccountKind.unknownDefaultOpenApi => 'Неизвестно', AccountKind.unknownDefaultOpenApi => 'Неизвестно',
}; };
/// Счета: every account grouped by role, with in-place role and /// Where an account comes from — the thing that tells a ZenMoney mirror from the broker
/// include-in-net-worth edits (`PATCH /accounts/{id}`). Archived accounts /// account it mirrors when both are called «ИИС».
/// collapse into their own section at the bottom regardless of role. String _sourceLabel(String source) => switch (source) {
class AccountsPage extends ConsumerWidget { '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}); const AccountsPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<AccountsPage> createState() => _AccountsPageState();
}
class _AccountsPageState extends ConsumerState<AccountsPage> {
_Status _status = _Status.all;
AccountRole? _role;
String _query = '';
final Set<int> _selected = {};
final Set<int> _busy = {};
void _snack(String message) =>
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _apply(
Iterable<int> 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<AccountOut> _visible(List<AccountOut> 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 accounts = ref.watch(accountsProvider);
final stale = oldestFetch([accounts.valueOrNull?.fetchedAt]); final stale = oldestFetch([accounts.valueOrNull?.fetchedAt]);
return Scaffold( 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( body: RefreshIndicator(
onRefresh: () async => ref.invalidate(accountsProvider), onRefresh: () async => ref.invalidate(accountsProvider),
child: AsyncValueView( child: AsyncValueView(
@@ -65,25 +171,107 @@ class AccountsPage extends ConsumerWidget {
], ],
); );
} }
final active = rows.where((a) => !a.archived).toList(); // a selection can outlive its account (a sync archived it, a filter hid it)
final archived = rows.where((a) => a.archived).toList(); _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( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (stale != null) StaleBanner(fetchedAt: stale), if (stale != null) StaleBanner(fetchedAt: stale),
for (final role in _roleOrder) _Filters(
if (active.any((a) => a.role == role)) rows: rows,
_RoleSection( status: _status,
title: _roleLabel(role), role: _role,
accounts: active.where((a) => a.role == role).toList(), onStatus: (s) => setState(() => _status = s),
onRole: (r) => setState(() => _role = r),
onQuery: (q) => setState(() => _query = q),
), ),
if (archived.isNotEmpty) const SizedBox(height: 12),
ExpansionTile( if (_selected.isNotEmpty) ...[
title: Text('Архивные (${archived.length})'), _BulkBar(
initiallyExpanded: false, count: _selected.length,
children: [for (final a in archived) _AccountTile(account: a)], 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 { class _Filters extends StatelessWidget {
const _RoleSection({required this.title, required this.accounts}); 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<AccountOut> rows;
final List<AccountOut> accounts; final _Status status;
final AccountRole? role;
final ValueChanged<_Status> onStatus;
final ValueChanged<AccountRole?> onRole;
final ValueChanged<String> onQuery;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Column(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Wrap(
padding: const EdgeInsets.only(bottom: 4), spacing: 8,
child: Text(title, style: Theme.of(context).textTheme.titleMedium), 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 a in accounts) _AccountTile(account: a), ),
),
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<AccountRole> onRole;
final ValueChanged<bool> 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<AccountRole>(
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<bool>(
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.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Text(
text,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
Icon(Icons.arrow_drop_down, size: 18, color: color),
], ],
), ),
); );
} }
} }
class _AccountTile extends ConsumerStatefulWidget { const _typeWidth = 156.0;
const _AccountTile({required this.account}); const _amountWidth = 150.0;
const _switchWidth = 96.0;
class _HeaderRow extends StatelessWidget {
const _HeaderRow({required this.allSelected, required this.onToggleAll});
final bool allSelected;
final ValueChanged<bool> onToggleAll;
@override
Widget build(BuildContext context) {
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,
),
),
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 AccountOut account;
final bool wide;
@override final bool selected;
ConsumerState<_AccountTile> createState() => _AccountTileState(); final bool busy;
} final ValueChanged<bool> onSelect;
final ValueChanged<AccountRole> onRole;
class _AccountTileState extends ConsumerState<_AccountTile> { final ValueChanged<bool> onNetWorth;
bool _saving = false; final ValueChanged<bool> onActive;
Future<void> _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);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final a = widget.account; final theme = Theme.of(context);
return Card( final scheme = theme.colorScheme;
child: Padding( final a = account;
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(a.name, style: Theme.of(context).textTheme.titleSmall),
Text( Text(
'${_kindLabel(a.kind)} · ${a.currency}', a.name,
style: Theme.of(context).textTheme.bodySmall, 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,
), ),
MoneyText( );
a.balance ?? '0', final role = _RoleChip(role: a.role, enabled: !busy, onChanged: onRole);
currency: a.currency, final netWorth = _SwitchCell(
style: Theme.of(context).textTheme.titleMedium, 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: [
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),
), ),
], ],
), )
const SizedBox(height: 4), : Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row( Row(
children: [ children: [
Expanded( Checkbox(
child: DropdownButtonFormField<AccountRole>( value: selected,
initialValue: a.role, visualDensity: VisualDensity.compact,
isDense: true, onChanged: (v) => onSelect(v ?? false),
decoration: const InputDecoration(labelText: 'Роль', isDense: true), ),
items: [ Expanded(child: title),
for (final r in _roleOrder) money,
DropdownMenuItem(value: r, child: Text(_roleLabel(r))),
], ],
onChanged: _saving ? null : (role) { ),
if (role != null) _patch(AccountPatch(role: role)); 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<AccountRole> onChanged;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return PopupMenuButton<AccountRole>(
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),
), ),
const SizedBox(width: 12), child: Row(
Column( mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('В капитал', style: TextStyle(fontSize: 11)), Flexible(
Switch( child: Text(
value: a.includeInNetWorth, _roleLabel(role),
onChanged: _saving overflow: TextOverflow.ellipsis,
? null style: const TextStyle(fontSize: 13),
: (v) => _patch(AccountPatch(includeInNetWorth: v)),
), ),
],
), ),
], 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<bool> 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),
),
],
);
}
}
+63
View File
@@ -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<String?> patch(Iterable<int> ids, AccountPatch patch);
}
final accountActionsProvider = Provider<AccountActions>(
(ref) => _ApiAccountActions(ref),
);
class _ApiAccountActions implements AccountActions {
_ApiAccountActions(this._ref);
final Ref _ref;
@override
Future<String?> patch(Iterable<int> 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';
}
}
+7 -2
View File
@@ -7,9 +7,14 @@ import '../../core/cache/cached.dart';
/// Every account, archived included — screens decide what to show. See /// Every account, archived included — screens decide what to show. See
/// `docs/ai/offline-cache.md`: Счета reads `.data` and shows the offline banner; /// `docs/ai/offline-cache.md`: Счета reads `.data` and shows the offline banner;
/// `accountNamesProvider` and the other consumers (Транзакции, События) just unwrap `.data`. /// `accountNamesProvider` and the other consumers (Транзакции, События) just unwrap `.data`.
final accountsProvider = FutureProvider.autoDispose<Cached<List<AccountOut>>>((ref) async { final accountsProvider = FutureProvider.autoDispose<Cached<List<AccountOut>>>((
ref,
) async {
final r = await ref.watch(apiProvider).getAccountsApi().accountsList(); 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). /// `account_id -> name`, for screens that only carry the id (transactions, rules).
+6 -1
View File
@@ -42,7 +42,12 @@ class EventRow extends StatelessWidget {
leading: Icon(icon, color: color), leading: Icon(icon, color: color),
title: Row( title: Row(
children: [ 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) ...[ if (e.externalFlow) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Tooltip( Tooltip(
+179 -38
View File
@@ -1,17 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart'; import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/utils/ru_date.dart';
import '../../core/widgets/help_tip.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart'; import '../../core/widgets/money_text.dart';
import '../accounts/providers.dart'; import '../accounts/providers.dart';
import '../portfolio/labels.dart' show formatQty, signColor; import '../portfolio/labels.dart' show formatQty, signColor;
import 'event_row.dart'; import 'event_row.dart';
import 'labels.dart'; import 'labels.dart';
import 'manual_event_dialog.dart';
import 'providers.dart'; import 'providers.dart';
/// События: the broker ledger with filters and infinite scroll — the screen that answers /// События: the broker ledger with filters and infinite scroll — the screen that answers
@@ -44,7 +49,8 @@ class _EventsPageState extends ConsumerState<EventsPage> {
} }
void _onScroll() { void _onScroll() {
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) { if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(eventsControllerProvider.notifier).loadMore(); ref.read(eventsControllerProvider.notifier).loadMore();
} }
} }
@@ -52,8 +58,11 @@ class _EventsPageState extends ConsumerState<EventsPage> {
void _onSearchChanged(String value) { void _onSearchChanged(String value) {
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () { _debounce = Timer(const Duration(milliseconds: 400), () {
ref.read(eventsControllerProvider.notifier).setFilter( ref
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()), .read(eventsControllerProvider.notifier)
.setFilter(
(f) =>
f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
); );
}); });
} }
@@ -71,16 +80,79 @@ class _EventsPageState extends ConsumerState<EventsPage> {
helpText: 'Период', helpText: 'Период',
); );
if (picked != null) { if (picked != null) {
ref.read(eventsControllerProvider.notifier).setFilter( ref
.read(eventsControllerProvider.notifier)
.setFilter(
(f) => f.copyWith(from: () => picked.start, to: () => picked.end), (f) => f.copyWith(from: () => picked.start, to: () => picked.end),
); );
} }
} }
void _clearDateRange() { void _clearDateRange() {
ref.read(eventsControllerProvider.notifier).setFilter( ref
(f) => f.copyWith(from: () => null, to: () => null), .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<void> _queueMetricsRefresh() async {
try {
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
} on DioException {
// the next scheduled or manual refresh picks the change up
}
}
Future<void> _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<void> _deleteManual(EventOut e) async {
final confirmed = await showDialog<bool>(
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) { void _showDetail(EventOut e) {
@@ -88,7 +160,16 @@ class _EventsPageState extends ConsumerState<EventsPage> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, 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<EventsPage> {
final state = ref.watch(eventsControllerProvider); final state = ref.watch(eventsControllerProvider);
final filter = ref.watch(eventsControllerProvider.notifier).filter; final filter = ref.watch(eventsControllerProvider.notifier).filter;
final accountNames = ref.watch(accountNamesProvider); final accountNames = ref.watch(accountNamesProvider);
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[]; final accounts =
ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
return Scaffold( return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: _addManual,
icon: const Icon(Icons.add),
label: const Text('Событие'),
),
appBar: AppBar( appBar: AppBar(
title: const Text('События'), title: const Text('События'),
actions: [ actions: [
if (state.total > 0) if (state.total > 0)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), 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<EventsPage> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(height: 8),
Text('${state.error}', textAlign: TextAlign.center), Text('${state.error}', textAlign: TextAlign.center),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => ref.read(eventsControllerProvider.notifier).refresh(), onPressed: () =>
ref.read(eventsControllerProvider.notifier).refresh(),
child: const Text('Повторить'), child: const Text('Повторить'),
), ),
], ],
@@ -171,7 +265,9 @@ class _EventsPageState extends ConsumerState<EventsPage> {
child: state.loadingMore child: state.loadingMore
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: TextButton( : TextButton(
onPressed: () => ref.read(eventsControllerProvider.notifier).loadMore(), onPressed: () => ref
.read(eventsControllerProvider.notifier)
.loadMore(),
child: const Text('Ещё'), child: const Text('Ещё'),
), ),
), ),
@@ -246,51 +342,67 @@ class _Filters extends ConsumerWidget {
width: 180, width: 180,
child: DropdownButtonFormField<int?>( child: DropdownButtonFormField<int?>(
initialValue: filter.accountId, initialValue: filter.accountId,
isDense: true, decoration: const InputDecoration(labelText: 'Счёт'),
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
items: [ items: [
const DropdownMenuItem(value: null, child: Text('Все счета')), const DropdownMenuItem(
value: null,
child: Text('Все счета'),
),
for (final a in accounts) for (final a in accounts)
DropdownMenuItem(value: a.id, child: Text(a.name)), 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( SizedBox(
width: 180, width: 180,
child: DropdownButtonFormField<EventKind?>( child: DropdownButtonFormField<EventKind?>(
initialValue: filter.kind, initialValue: filter.kind,
isDense: true, decoration: const InputDecoration(labelText: 'Тип'),
decoration: const InputDecoration(labelText: 'Тип', isDense: true),
items: [ items: [
const DropdownMenuItem(value: null, child: Text('Все типы')), const DropdownMenuItem(
value: null,
child: Text('Все типы'),
),
for (final k in filterableEventKinds) 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( SizedBox(
width: 180, width: 180,
child: DropdownButtonFormField<EventStatus?>( child: DropdownButtonFormField<EventStatus?>(
initialValue: filter.status, initialValue: filter.status,
isDense: true, decoration: const InputDecoration(labelText: 'Статус'),
decoration: const InputDecoration(labelText: 'Статус', isDense: true),
items: [ items: [
const DropdownMenuItem(value: null, child: Text('Любой статус')), const DropdownMenuItem(
value: null,
child: Text('Любой статус'),
),
for (final s in EventStatus.values) for (final s in EventStatus.values)
if (s != EventStatus.unknownDefaultOpenApi) 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( FilterChip(
label: const Text('Внешние потоки'), label: const Text('Внешние потоки'),
tooltip: 'Только пополнения, выводы и переводы бумаг — то, что читает XIRR', tooltip: 'Только пополнения, выводы и переводы бумаг — то, что читает XIRR',
selected: filter.externalFlow == true, selected: filter.externalFlow == true,
onSelected: (on) => onSelected: (on) => notifier.setFilter(
notifier.setFilter((f) => f.copyWith(externalFlow: () => on ? true : null)), (f) => f.copyWith(externalFlow: () => on ? true : null),
),
), ),
], ],
), ),
@@ -301,31 +413,45 @@ class _Filters extends ConsumerWidget {
} }
class _EventDetailSheet extends StatelessWidget { 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 EventOut event;
final Map<int, String> accountNames; final Map<int, String> accountNames;
/// Set only for events entered by hand: broker events come back with the next sync.
final VoidCallback? onDelete;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final e = event; final e = event;
final theme = Theme.of(context); 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)>[ final rows = <(String, String)>[
('Дата сделки', ruDate(e.tradeDate)), ('Дата сделки', 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)), ('Тип', eventKindLabel(e.kind)),
('Статус', eventStatusLabel(e.status)), ('Статус', eventStatusLabel(e.status)),
('Источник', eventSourceLabel(e.source_)),
('Счёт', accountNames[e.accountId] ?? '#${e.accountId}'), ('Счёт', accountNames[e.accountId] ?? '#${e.accountId}'),
if (e.ticker != null) ('Инструмент', e.ticker!), if (e.ticker != null) ('Инструмент', e.ticker!),
if (e.quantity != null) ('Количество', formatQty(e.quantity!)), 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.amount, e.currency)),
('Сумма, ₽', money(e.amountRub, 'RUB')), ('Сумма, ₽', money(e.amountRub, 'RUB')),
if (e.fee != null) ('Комиссия', money(e.fee, e.currency)), if (e.fee != null) ('Комиссия', money(e.fee, e.currency)),
if (e.tax != null) ('Налог', money(e.tax, 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 ? 'да' : 'нет'), ('Внешний поток', e.externalFlow ? 'да' : 'нет'),
if (e.description != null) ('Описание', e.description!), if (e.description != null) ('Описание', e.description!),
]; ];
@@ -339,12 +465,16 @@ class _EventDetailSheet extends StatelessWidget {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Text(eventTitle(e.kind, e.ticker), style: theme.textTheme.titleLarge), child: Text(
eventTitle(e.kind, e.ticker),
style: theme.textTheme.titleLarge,
),
), ),
Text( Text(
MoneyText.format(e.amount, e.currency), MoneyText.format(e.amount, e.currency),
style: theme.textTheme.titleMedium style: theme.textTheme.titleMedium?.copyWith(
?.copyWith(color: signColor(context, e.amount)), color: signColor(context, e.amount),
),
), ),
], ],
), ),
@@ -357,12 +487,23 @@ class _EventDetailSheet extends StatelessWidget {
children: [ children: [
SizedBox( SizedBox(
width: 140, width: 140,
child: Text(label, style: theme.textTheme.bodySmall), child: TermLabel(label, style: theme.textTheme.bodySmall),
), ),
Expanded(child: Text(value)), 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) ...[ if (e.instrumentId != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Align( Align(
+30 -14
View File
@@ -35,31 +35,47 @@ const eventStatusLabels = {
'ignored': 'Игнорируется', '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 /// 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. /// 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.buy => (Icons.add_shopping_cart, scheme.primary),
EventKind.sell => (Icons.sell_outlined, Colors.deepPurple), EventKind.sell => (Icons.sell_outlined, Colors.deepPurple),
EventKind.dividend || EventKind.coupon || EventKind.interest => ( EventKind.dividend ||
Icons.payments_outlined, EventKind.coupon ||
Colors.green, EventKind.interest => (Icons.payments_outlined, Colors.green),
),
EventKind.taxRefund => (Icons.assignment_return_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.deposit => (Icons.arrow_circle_down_outlined, Colors.green),
EventKind.withdrawal => (Icons.arrow_circle_up_outlined, scheme.error), 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.fxExchange => (Icons.currency_exchange, Colors.amber),
EventKind.split || EventKind.amortization || EventKind.repayment => ( EventKind.split ||
Icons.call_split, EventKind.amortization ||
scheme.outline, EventKind.repayment => (Icons.call_split, scheme.outline),
), EventKind.other ||
EventKind.other || EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline), EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
}; };
/// `'Покупка · SBER'`, the one-line identity of a row. Events with no instrument behind /// `'Покупка · SBER'`, the one-line identity of a row. Events with no instrument behind
/// them (deposits, fees) keep just the kind. /// them (deposits, fees) keep just the kind.
String eventTitle(EventKind kind, String? ticker) => 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;
@@ -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<ManualEventCreate?> showManualEventDialog(BuildContext context) =>
showDialog<ManualEventCreate>(
context: context,
builder: (_) => const _ManualEventDialog(),
);
class _ManualEventDialog extends ConsumerStatefulWidget {
const _ManualEventDialog();
@override
ConsumerState<_ManualEventDialog> createState() => _ManualEventDialogState();
}
class _ManualEventDialogState extends ConsumerState<_ManualEventDialog> {
final _formKey = GlobalKey<FormState>();
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 <AccountOut>[])
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<AccountOut>(
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<EventKind>(
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<InstrumentOut?> onChanged;
final String? Function() validator;
static String _label(InstrumentOut i) => '${i.ticker ?? ''} · ${i.name}';
@override
Widget build(BuildContext context, WidgetRef ref) {
return Autocomplete<InstrumentOut>(
displayStringForOption: _label,
optionsBuilder: (value) async {
final q = value.text.trim();
if (q.length < 2) return const <InstrumentOut>[];
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(),
),
);
}
}
+4 -1
View File
@@ -112,7 +112,10 @@ class EventsController extends Notifier<EventsState> {
} }
Future<EventPage> _fetch(int page) async { Future<EventPage> _fetch(int page) async {
final r = await ref.read(apiProvider).getEventsApi().eventsList( final r = await ref
.read(apiProvider)
.getEventsApi()
.eventsList(
from: filter.from, from: filter.from,
to: filter.to, to: filter.to,
accountId: filter.accountId, accountId: filter.accountId,
+168 -46
View File
@@ -20,9 +20,13 @@ import '../../core/widgets/tile_carousel.dart';
import '../../core/api/api_client.dart'; import '../../core/api/api_client.dart';
import '../health/data_quality_list.dart' show severityColor; import '../health/data_quality_list.dart' show severityColor;
import 'providers.dart'; import 'providers.dart';
import 'scope_cards.dart';
double _d(String s) => Decimal.parse(s).toDouble(); 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, /// Обзор: the dashboard landing page — net worth, this month's cashflow,
/// runway, a net worth line chart, a 12-month income/expense bar chart, and /// runway, a net worth line chart, a 12-month income/expense bar chart, and
/// a data-quality summary linking to the findings. /// a data-quality summary linking to the findings.
@@ -39,10 +43,28 @@ class _HomePageState extends ConsumerState<HomePage> {
Future<void> _refresh() async { Future<void> _refresh() async {
setState(() => _refreshing = true); setState(() => _refreshing = true);
try { 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) { } on DioException catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally { } finally {
if (mounted) setState(() => _refreshing = false); if (mounted) setState(() => _refreshing = false);
invalidateHomeProviders(ref); invalidateHomeProviders(ref);
@@ -59,6 +81,7 @@ class _HomePageState extends ConsumerState<HomePage> {
final status = ref.watch(metricsStatusProvider); final status = ref.watch(metricsStatusProvider);
final dataQuality = ref.watch(dataQualityProvider); final dataQuality = ref.watch(dataQualityProvider);
final portfolio = ref.watch(portfolioSummaryHomeProvider); final portfolio = ref.watch(portfolioSummaryHomeProvider);
final cards = ref.watch(scopeCardsProvider);
final stale = oldestFetch([ final stale = oldestFetch([
breakdown.valueOrNull?.fetchedAt, breakdown.valueOrNull?.fetchedAt,
@@ -69,6 +92,7 @@ class _HomePageState extends ConsumerState<HomePage> {
status.valueOrNull?.fetchedAt, status.valueOrNull?.fetchedAt,
dataQuality.valueOrNull?.fetchedAt, dataQuality.valueOrNull?.fetchedAt,
portfolio.valueOrNull?.fetchedAt, portfolio.valueOrNull?.fetchedAt,
cards.valueOrNull?.fetchedAt,
]); ]);
return Scaffold( return Scaffold(
@@ -79,7 +103,10 @@ class _HomePageState extends ConsumerState<HomePage> {
tooltip: 'Пересчитать метрики', tooltip: 'Пересчитать метрики',
icon: _refreshing icon: _refreshing
? const SizedBox( ? const SizedBox(
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh), : const Icon(Icons.refresh),
onPressed: _refreshing ? null : _refresh, onPressed: _refreshing ? null : _refresh,
), ),
@@ -98,12 +125,21 @@ class _HomePageState extends ConsumerState<HomePage> {
value: status, value: status,
onRetry: () => ref.invalidate(metricsStatusProvider), onRetry: () => ref.invalidate(metricsStatusProvider),
data: (cached) { data: (cached) {
final log = cached.data; final status = cached.data;
final log = status.lastRefresh;
final at = log?.finishedAt ?? log?.startedAt; final at = log?.finishedAt ?? log?.startedAt;
final text = at == null final text = at == null
? 'Данные ещё не пересчитывались' ? 'Данные ещё не пересчитывались'
: 'Данные на ${ruDate(at.toLocal())} ${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}'; : 'Данные на ${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<HomePage> {
final rows = cached.data; final rows = cached.data;
return ActionChip( return ActionChip(
avatar: Icon( avatar: Icon(
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined, rows.isEmpty
? Icons.check_circle_outline
: Icons.warning_amber_outlined,
size: 18, 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 // Health page renders the same findings via DataQualityList — no
// second implementation of this list here. // second implementation of this list here.
onPressed: rows.isEmpty ? null : () => context.go('/health?tab=quality'), onPressed: rows.isEmpty
? null
: () => context.go('/health?tab=quality'),
); );
}, },
), ),
], ],
), ),
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 SizedBox(height: 20),
const SectionHeader(title: 'Портфели'),
const SizedBox(height: 12),
ScopeCards(cards: cached.data),
],
),
),
const SizedBox(height: 24),
const SectionHeader(title: 'Капитал'), const SectionHeader(title: 'Капитал'),
const SizedBox(height: 12), const SizedBox(height: 12),
AsyncValueView( AsyncValueView(
@@ -146,7 +205,8 @@ class _HomePageState extends ConsumerState<HomePage> {
AsyncValueView( AsyncValueView(
value: runway, value: runway,
onRetry: () => ref.invalidate(runwayProvider), onRetry: () => ref.invalidate(runwayProvider),
data: (cached) => TileCarousel(children: [_RunwayTile(runway: cached.data)]), data: (cached) =>
TileCarousel(children: [_RunwayTile(runway: cached.data)]),
), ),
AsyncValueView( AsyncValueView(
value: portfolio, value: portfolio,
@@ -175,7 +235,10 @@ class _HomePageState extends ConsumerState<HomePage> {
value: series, value: series,
onRetry: () => ref.invalidate(netWorthSeriesProvider), onRetry: () => ref.invalidate(netWorthSeriesProvider),
data: (cached) => cached.data.isEmpty data: (cached) => cached.data.isEmpty
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.') ? const EmptyState(
icon: Icons.show_chart,
message: 'Пока нет данных.',
)
: _NetWorthChart(rows: cached.data), : _NetWorthChart(rows: cached.data),
), ),
), ),
@@ -188,7 +251,10 @@ class _HomePageState extends ConsumerState<HomePage> {
value: last12, value: last12,
onRetry: () => ref.invalidate(cashflowLast12Provider), onRetry: () => ref.invalidate(cashflowLast12Provider),
data: (cached) => cached.data.isEmpty data: (cached) => cached.data.isEmpty
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.') ? const EmptyState(
icon: Icons.bar_chart,
message: 'Пока нет данных.',
)
: _IncomeExpenseChart(rows: cached.data), : _IncomeExpenseChart(rows: cached.data),
), ),
), ),
@@ -204,7 +270,11 @@ class _HomePageState extends ConsumerState<HomePage> {
); );
} }
return Column( 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TileCarousel(children: [ return TileCarousel(
StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')), children: [
StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, currency: 'RUB')), StatTile(
StatTile(label: 'Сбережения', value: MoneyText(breakdown.savingsRub, currency: 'RUB')), label: 'Капитал сегодня',
StatTile(label: 'Инвестиции', value: MoneyText(breakdown.investmentRub, currency: 'RUB')), value: MoneyText(breakdown.totalRub, currency: 'RUB'),
StatTile(label: 'Долги', value: MoneyText(breakdown.debtRub, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (month == null) { 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)}%'; final rateText = month!.savingsRate == null
return TileCarousel(children: [ ? ''
StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')), : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')), return TileCarousel(
children: [
StatTile(
label: 'Доход в этом месяце',
value: MoneyText(month!.incomeRub, currency: 'RUB'),
),
StatTile(
label: 'Расход в этом месяце',
value: MoneyText(month!.expenseRub, currency: 'RUB'),
),
StatTile(label: 'Норма сбережений', value: Text(rateText)), StatTile(label: 'Норма сбережений', value: Text(rateText)),
]); ],
);
} }
} }
@@ -278,7 +378,8 @@ class _NetWorthChart extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final spots = [ 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( return LineChart(
LineChartData( LineChartData(
@@ -323,7 +424,11 @@ class _IncomeExpenseChart extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maxY = rows.fold<double>( final maxY = rows.fold<double>(
0, 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -343,19 +448,28 @@ class _IncomeExpenseChart extends StatelessWidget {
gridData: const FlGridData(drawVerticalLine: false), gridData: const FlGridData(drawVerticalLine: false),
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), sideTitles: SideTitles(showTitles: false),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles( bottomTitles: AxisTitles(
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: true,
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final i = value.toInt(); 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( return Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: child: Text(
Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 9)), ruMonthYearShort(rows[i].month),
style: const TextStyle(fontSize: 9),
),
); );
}, },
), ),
@@ -377,8 +491,16 @@ class _IncomeExpenseChart extends StatelessWidget {
BarChartGroupData( BarChartGroupData(
x: i, x: i,
barRods: [ barRods: [
BarChartRodData(toY: _d(rows[i].incomeRub), color: ChartColors.income, width: 6), BarChartRodData(
BarChartRodData(toY: _d(rows[i].expenseRub), color: ChartColors.expense, width: 6), toY: _d(rows[i].incomeRub),
color: ChartColors.income,
width: 6,
),
BarChartRodData(
toY: _d(rows[i].expenseRub),
color: ChartColors.expense,
width: 6,
),
], ],
barsSpace: 2, barsSpace: 2,
), ),
@@ -401,7 +523,11 @@ class _LegendDot extends StatelessWidget {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.bodySmall), Text(label, style: Theme.of(context).textTheme.bodySmall),
], ],
@@ -422,7 +548,8 @@ class _PortfolioTiles extends StatelessWidget {
return InkWell( return InkWell(
onTap: () => context.go('/portfolio'), onTap: () => context.go('/portfolio'),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: TileCarousel(children: [ child: TileCarousel(
children: [
StatTile( StatTile(
label: 'Портфель', label: 'Портфель',
value: MoneyText(summary.totalRub, currency: 'RUB'), value: MoneyText(summary.totalRub, currency: 'RUB'),
@@ -434,15 +561,10 @@ class _PortfolioTiles extends StatelessWidget {
? const Text('') ? const Text('')
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'), : MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
), ),
StatTile( StatTile(label: 'XIRR, год', value: Text(_percent(yearly?.xirr))),
label: 'XIRR, год', StatTile(label: 'TWR, год', value: Text(_percent(yearly?.twr))),
value: Text(_percent(yearly?.xirr)), ],
), ),
StatTile(
label: 'TWR, год',
value: Text(_percent(yearly?.twr)),
),
]),
); );
} }
+91 -24
View File
@@ -8,53 +8,101 @@ import '../../core/cache/cached.dart';
/// Every provider below returns `Cached<T>`, not `T`: `HomePage` reads /// Every provider below returns `Cached<T>`, not `T`: `HomePage` reads
/// `.data` for the tiles and `.fetchedAt` (via `oldestFetch`) for the one /// `.data` for the tiles and `.fetchedAt` (via `oldestFetch`) for the one
/// offline banner at the top. See `docs/ai/offline-cache.md`. /// offline banner at the top. See `docs/ai/offline-cache.md`.
final netWorthBreakdownProvider = FutureProvider.autoDispose<Cached<NetWorthBreakdown>>((ref) async { final netWorthBreakdownProvider =
final r = await ref.watch(apiProvider).getNetworthApi().networthBreakdown(); FutureProvider.autoDispose<Cached<NetWorthBreakdown>>((ref) async {
final r = await ref
.watch(apiProvider)
.getNetworthApi()
.networthBreakdown();
return r.cached; return r.cached;
}); });
/// Daily net worth for the last 365 days. /// Daily net worth for the last 365 days.
final netWorthSeriesProvider = FutureProvider.autoDispose<Cached<List<NetWorthDay>>>((ref) async { final netWorthSeriesProvider =
FutureProvider.autoDispose<Cached<List<NetWorthDay>>>((ref) async {
final now = DateTime.now(); final now = DateTime.now();
final r = await ref.watch(apiProvider).getNetworthApi().networthSeries( final r = await ref
.watch(apiProvider)
.getNetworthApi()
.networthSeries(
from: now.subtract(const Duration(days: 365)), from: now.subtract(const Duration(days: 365)),
to: now, 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. /// The current (partial) month's cashflow, or null before any data exists.
final cashflowThisMonthProvider = FutureProvider.autoDispose<Cached<CashFlowMonth?>>((ref) async { final cashflowThisMonthProvider =
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 1); FutureProvider.autoDispose<Cached<CashFlowMonth?>>((ref) async {
final r = await ref
.watch(apiProvider)
.getCashflowApi()
.cashflowMonthly(months: 1);
final rows = r.data ?? const []; final rows = r.data ?? const [];
return Cached(rows.isEmpty ? null : rows.last, fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(
rows.isEmpty ? null : rows.last,
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}); });
/// The last 12 months, for the income-vs-expense bar chart. /// The last 12 months, for the income-vs-expense bar chart.
final cashflowLast12Provider = FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async { final cashflowLast12Provider =
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 12); FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async {
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); 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<Cached<RunwayOut>>((ref) async { final runwayProvider = FutureProvider.autoDispose<Cached<RunwayOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway(); final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway();
return r.cached; return r.cached;
}); });
/// When the metric tables were last rebuilt; null before the first refresh. /// When the metric tables were last rebuilt, whether they are one consistent snapshot, and
final metricsStatusProvider = FutureProvider.autoDispose<Cached<RefreshLogOut?>>((ref) async { /// whether another rebuild is on its way.
try { final metricsStatusProvider =
FutureProvider.autoDispose<Cached<MetricsStatusOut>>((ref) async {
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus(); final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
return r.cached; return r.cached;
} on DioException catch (e) {
if (e.response?.statusCode == 404) return const Cached(null);
rethrow;
}
}); });
final dataQualityProvider = FutureProvider.autoDispose<Cached<List<DataQualityRow>>>((ref) async { /// Waits for a queued metrics rebuild to finish: `POST /metrics/refresh` only queues it, so the
final r = await ref.watch(apiProvider).getMetricsApi().metricsDataQuality(); /// numbers are stale until `refreshing` turns false. Returns the last status seen, or null if
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); /// [timeout] ran out first (a rebuild stuck behind a long sync, say).
Future<MetricsStatusOut?> waitForMetricsRefresh(
Future<MetricsStatusOut> 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<void>.delayed(interval);
}
}
final dataQualityProvider =
FutureProvider.autoDispose<Cached<List<DataQualityRow>>>((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 /// Every dashboard provider, refreshed together after a manual
@@ -68,16 +116,35 @@ void invalidateHomeProviders(WidgetRef ref) {
ref.invalidate(metricsStatusProvider); ref.invalidate(metricsStatusProvider);
ref.invalidate(dataQualityProvider); ref.invalidate(dataQualityProvider);
ref.invalidate(portfolioSummaryHomeProvider); ref.invalidate(portfolioSummaryHomeProvider);
ref.invalidate(scopeCardsProvider);
} }
/// The investment side of the dashboard: one scope-wide summary, `all` by default. /// 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. /// Null when the ledger is empty, which is the normal state before a broker sync.
final portfolioSummaryHomeProvider = FutureProvider.autoDispose<Cached<SummaryOut?>>((ref) async { final portfolioSummaryHomeProvider =
FutureProvider.autoDispose<Cached<SummaryOut?>>((ref) async {
try { try {
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(); final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsSummary();
return r.cached; return r.cached;
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 404) return const Cached(null); if (e.response?.statusCode == 404) return const Cached(null);
rethrow; 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<Cached<List<ScopeCardOut>>>((ref) async {
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsOverview();
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
+194
View File
@@ -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<ScopeCardOut> 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,
),
),
),
],
),
),
),
);
}
}
+26 -10
View File
@@ -75,21 +75,24 @@ class _DimensionCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(dimensionLabel(dimension), style: Theme.of(context).textTheme.titleMedium), Text(
dimensionLabel(dimension),
style: Theme.of(context).textTheme.titleMedium,
),
if (onlyUnknown) ...[ if (onlyUnknown) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Атрибут не заполнен у инструментов — разрез пустой, а не нулевой.', 'Атрибут не заполнен у инструментов — разрез пустой, а не нулевой.',
style: Theme.of(context) style: Theme.of(context).textTheme.bodySmall
.textTheme
.bodySmall
?.copyWith(color: Theme.of(context).hintColor), ?.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 // and the legend still lists it with its real value
final positive = buckets.where((b) => _d(b.valueRub) > 0).toList(); final positive = buckets.where((b) => _d(b.valueRub) > 0).toList();
if (positive.isEmpty) { if (positive.isEmpty) {
return const EmptyState(icon: Icons.donut_large_outlined, message: 'Нечего показать.'); return const EmptyState(
icon: Icons.donut_large_outlined,
message: 'Нечего показать.',
);
} }
return PieChart( return PieChart(
PieChartData( PieChartData(
@@ -194,10 +202,18 @@ class _Legend extends StatelessWidget {
if (buckets[i].holdingCount > 0) if (buckets[i].holdingCount > 0)
Padding( Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: Text('${buckets[i].holdingCount}', child: Text(
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), '${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), const SizedBox(width: 12),
SizedBox( SizedBox(
width: 56, width: 56,
+27 -15
View File
@@ -10,13 +10,15 @@ import 'data/benchmarks_api.dart';
import 'labels.dart'; import 'labels.dart';
import 'providers.dart'; import 'providers.dart';
final benchmarksApiProvider = final benchmarksApiProvider = Provider<BenchmarksApi>(
Provider<BenchmarksApi>((ref) => BenchmarksApi(ref.watch(apiProvider).dio)); (ref) => BenchmarksApi(ref.watch(apiProvider).dio),
);
/// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own: /// 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 /// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject. See
/// `docs/ai/offline-cache.md`. /// `docs/ai/offline-cache.md`.
final benchmarkRowsProvider = FutureProvider.autoDispose<Cached<List<BenchmarkRow>>>((ref) async { final benchmarkRowsProvider =
FutureProvider.autoDispose<Cached<List<BenchmarkRow>>>((ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
return ref.watch(benchmarksApiProvider).compare(scope: scope); return ref.watch(benchmarksApiProvider).compare(scope: scope);
}); });
@@ -73,13 +75,16 @@ class _PeriodBlock extends StatelessWidget {
if (row.dateFrom != null && row.dateTo != null) if (row.dateFrom != null && row.dateTo != null)
Text( Text(
'${ruDate(row.dateFrom!)} ${ruDate(row.dateTo!)}', '${ruDate(row.dateFrom!)} ${ruDate(row.dateTo!)}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
), ),
const Spacer(), const Spacer(),
Text( Text(
'портфель ${formatPercent(row.portfolioTwr)}', 'портфель ${formatPercent(row.portfolioTwr)}',
style: theme.textTheme.titleSmall style: theme.textTheme.titleSmall?.copyWith(
?.copyWith(color: signColor(context, row.portfolioTwr)), color: signColor(context, row.portfolioTwr),
),
), ),
if (row.portfolioDaysSkipped > 0) ...[ if (row.portfolioDaysSkipped > 0) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -89,13 +94,14 @@ class _PeriodBlock extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
for (final b in row.benchmarks) _BenchmarkRowView(result: b), for (final b in row.benchmarks) _BenchmarkRowView(result: b),
if (row.hasSkippedDays) if (row.benchmarksSkipDays)
Padding( Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: Text( child: Text(
'Сетка дат не полностью совпадает: часть дней пропущена, ' 'У индекса нет котировок в часть дней окна — сравнение не строго день в день.',
'сравнение не строго like-for-like.', style: theme.textTheme.bodySmall?.copyWith(
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), color: theme.hintColor,
),
), ),
), ),
const Divider(height: 20), const Divider(height: 20),
@@ -131,8 +137,9 @@ class _BenchmarkRowView extends StatelessWidget {
child: Text( child: Text(
formatPercent(result.excess), formatPercent(result.excess),
textAlign: TextAlign.right, textAlign: TextAlign.right,
style: theme.textTheme.bodyMedium style: theme.textTheme.bodyMedium?.copyWith(
?.copyWith(color: signColor(context, result.excess)), color: signColor(context, result.excess),
),
), ),
), ),
], ],
@@ -148,7 +155,8 @@ class _PriceIndexChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Tooltip( return Tooltip(
message: 'Ценовой индекс: не учитывает дивиденды и систематически занижает ' message:
'Ценовой индекс: не учитывает дивиденды и систематически занижает '
'результат держателя. Сравнение с ним — нижняя граница, а не эталон.', 'результат держателя. Сравнение с ним — нижняя граница, а не эталон.',
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
@@ -156,7 +164,10 @@ class _PriceIndexChip extends StatelessWidget {
color: scheme.errorContainer, color: scheme.errorContainer,
borderRadius: BorderRadius.circular(6), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Tooltip( return Tooltip(
message: 'В расчёте $side пропущено $days дн. — в эти дни не было цены', message:
'В расчёте $side не учтено $days дн.: в эти дни у части позиций не было цены',
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -74,8 +74,10 @@ class BenchmarkRow {
final int portfolioDaysSkipped; final int portfolioDaysSkipped;
final List<BenchmarkResult> benchmarks; final List<BenchmarkResult> benchmarks;
bool get hasSkippedDays => bool get hasSkippedDays => portfolioDaysSkipped > 0 || benchmarksSkipDays;
portfolioDaysSkipped > 0 || benchmarks.any((b) => b.daysSkipped > 0);
/// 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<String, dynamic> json) => BenchmarkRow( static BenchmarkRow fromJson(Map<String, dynamic> json) => BenchmarkRow(
period: asString(json['period']) ?? 'all', period: asString(json['period']) ?? 'all',
@@ -84,7 +86,9 @@ class BenchmarkRow {
portfolioTwr: asString(json['portfolio_twr']), portfolioTwr: asString(json['portfolio_twr']),
portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']), portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']),
portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0, portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0,
benchmarks: asObjects(json['benchmarks']).map(BenchmarkResult.fromJson).toList(), benchmarks: asObjects(json['benchmarks'])
.map(BenchmarkResult.fromJson)
.toList(),
); );
} }
@@ -104,7 +108,9 @@ class BenchmarksApi {
// `period` is repeatable; Dio serialises a list as repeated query parameters. // `period` is repeatable; Dio serialises a list as repeated query parameters.
queryParameters: {'scope': scope, 'period': periods}, 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?); return Cached(rows, fetchedAt: r.extra['fetchedAt'] as DateTime?);
} }
} }
+109 -93
View File
@@ -3,22 +3,45 @@ import 'package:fintracker_api/fintracker_api.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/chart_colors.dart'; import '../../core/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart'; import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart'; import '../../core/widgets/money_text.dart';
import '../../core/widgets/help_tip.dart';
import 'benchmarks_card.dart'; import 'benchmarks_card.dart';
import 'holdings_table.dart';
import 'labels.dart'; import 'labels.dart';
import 'providers.dart'; import 'providers.dart';
double _d(String s) => Decimal.parse(s).toDouble(); 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. /// Позиции: what the portfolio holds, what it is worth, and what it earned.
class HoldingsTab extends ConsumerWidget { 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -26,25 +49,35 @@ class HoldingsTab extends ConsumerWidget {
final holdings = ref.watch(holdingsProvider); final holdings = ref.watch(holdingsProvider);
final series = ref.watch(valueSeriesProvider); final series = ref.watch(valueSeriesProvider);
final returns = ref.watch(portfolioReturnsProvider); final returns = ref.watch(portfolioReturnsProvider);
final all = sections == HoldingsSections.all;
final withTable = all || sections == HoldingsSections.table;
final withCharts = all || sections == HoldingsSections.overview;
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async => invalidatePortfolioProviders(ref), onRefresh: () async => invalidatePortfolioProviders(ref),
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (header != null) ...[header!, const SizedBox(height: 16)],
if (all) ...[
AsyncValueView( AsyncValueView(
value: summary, value: summary,
onRetry: () => ref.invalidate(portfolioSummaryProvider), onRetry: () => ref.invalidate(portfolioSummaryProvider),
data: (cached) => _SummaryTiles(summary: cached.data), data: (cached) => _SummaryTiles(summary: cached.data),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
],
if (withCharts) ...[
_Card( _Card(
title: 'Стоимость за 365 дней', title: 'Стоимость за 365 дней',
child: AsyncValueView( child: AsyncValueView(
value: series, value: series,
onRetry: () => ref.invalidate(valueSeriesProvider), onRetry: () => ref.invalidate(valueSeriesProvider),
data: (cached) => cached.data.isEmpty data: (cached) => cached.data.isEmpty
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.') ? const EmptyState(
icon: Icons.show_chart,
message: 'Пока нет данных.',
)
: _ValueChart(rows: cached.data), : _ValueChart(rows: cached.data),
), ),
), ),
@@ -55,7 +88,10 @@ class HoldingsTab extends ConsumerWidget {
value: returns, value: returns,
onRetry: () => ref.invalidate(portfolioReturnsProvider), onRetry: () => ref.invalidate(portfolioReturnsProvider),
data: (cached) => cached.data.isEmpty data: (cached) => cached.data.isEmpty
? const EmptyState(icon: Icons.percent, message: 'Пока нечего считать.') ? const EmptyState(
icon: Icons.percent,
message: 'Пока нечего считать.',
)
: _ReturnsTable(rows: cached.data), : _ReturnsTable(rows: cached.data),
), ),
), ),
@@ -64,9 +100,11 @@ class HoldingsTab extends ConsumerWidget {
title: 'Сравнение с бенчмарками', title: 'Сравнение с бенчмарками',
child: BenchmarksCard(), child: BenchmarksCard(),
), ),
const SizedBox(height: 16), ],
if (withTable && withCharts) const SizedBox(height: 16),
if (withTable)
_Card( _Card(
title: 'Позиции', title: all ? 'Позиции' : null,
child: AsyncValueView( child: AsyncValueView(
value: holdings, value: holdings,
onRetry: () => ref.invalidate(holdingsProvider), onRetry: () => ref.invalidate(holdingsProvider),
@@ -75,7 +113,7 @@ class HoldingsTab extends ConsumerWidget {
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
message: 'Открытых позиций нет — нужна синхронизация брокера.', message: 'Открытых позиций нет — нужна синхронизация брокера.',
) )
: _HoldingsTable(rows: cached.data), : HoldingsTable(rows: cached.data),
), ),
), ),
], ],
@@ -113,7 +151,9 @@ class _SummaryTiles extends StatelessWidget {
: MoneyText( : MoneyText(
summary.pnlTotalRub!, summary.pnlTotalRub!,
currency: 'RUB', currency: 'RUB',
style: TextStyle(color: signColor(context, summary.pnlTotalRub)), style: TextStyle(
color: signColor(context, summary.pnlTotalRub),
),
), ),
note: summary.pnlTotalRub == null note: summary.pnlTotalRub == null
? 'часть позиций без цены' ? 'часть позиций без цены'
@@ -171,14 +211,19 @@ class _Tile extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: theme.textTheme.bodySmall), TermLabel(label, style: theme.textTheme.bodySmall),
const SizedBox(height: 4), const SizedBox(height: 4),
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value), DefaultTextStyle(
style: theme.textTheme.titleMedium!,
child: value,
),
if (note != null) ...[ if (note != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
note!, note!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
maxLines: 2, maxLines: 2,
), ),
], ],
@@ -224,7 +269,10 @@ class _ValueChart extends StatelessWidget {
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final i = value.round(); final i = value.round();
if (i < 0 || i >= rows.length) return const SizedBox.shrink(); 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,27 +325,60 @@ class _ReturnsTable extends StatelessWidget {
child: DataTable( child: DataTable(
columns: [ columns: [
DataColumn(label: Text('Период', style: headerStyle)), DataColumn(label: Text('Период', style: headerStyle)),
DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true), DataColumn(
DataColumn(label: Text('Потоки', style: headerStyle), numeric: true), label: TermLabel(
DataColumn(label: Text('XIRR', style: headerStyle), numeric: true), 'Прибыль',
DataColumn(label: Text('TWR', style: headerStyle), numeric: true), 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: [ rows: [
for (final r in rows) for (final r in rows)
DataRow( DataRow(
cells: [ cells: [
DataCell(Text(periodLabel(r.period))), DataCell(Text(periodLabel(r.period))),
DataCell(MoneyText( DataCell(
MoneyText(
r.absPnlRub, r.absPnlRub,
currency: 'RUB', currency: 'RUB',
style: TextStyle(color: signColor(context, r.absPnlRub)), style: TextStyle(color: signColor(context, r.absPnlRub)),
)), ),
),
DataCell(MoneyText(r.externalFlowRub, currency: 'RUB')), DataCell(MoneyText(r.externalFlowRub, currency: 'RUB')),
DataCell(Text( DataCell(
Text(
formatPercent(r.xirr), formatPercent(r.xirr),
style: TextStyle(color: signColor(context, r.xirr)), style: TextStyle(color: signColor(context, r.xirr)),
)), ),
DataCell(Row( ),
DataCell(
Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
@@ -307,82 +388,15 @@ class _ReturnsTable extends StatelessWidget {
if (r.twrDaysSkipped > 0) ...[ if (r.twrDaysSkipped > 0) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Tooltip( Tooltip(
message: 'Пропущено ${r.twrDaysSkipped} дн.: ' message:
'Пропущено ${r.twrDaysSkipped} дн.: '
'в эти дни часть позиции была без цены', 'в эти дни часть позиции была без цены',
child: const Icon(Icons.info_outline, size: 14), child: const Icon(Icons.info_outline, size: 14),
), ),
], ],
], ],
)),
],
),
],
),
);
}
}
class _HoldingsTable extends StatelessWidget {
const _HoldingsTable({required this.rows});
final List<HoldingOut> 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 { 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; final Widget child;
@override @override
@@ -405,8 +419,10 @@ class _Card extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: Theme.of(context).textTheme.titleMedium), if (title != null) ...[
TermLabel(title!, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12), const SizedBox(height: 12),
],
child, child,
], ],
), ),
@@ -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<HoldingOut> rows;
@override
State<HoldingsTable> createState() => _HoldingsTableState();
}
class _HoldingsTableState extends State<HoldingsTable> {
HoldingsPreset _preset = HoldingsPreset.mine;
String _query = '';
List<HoldingOut> 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<double>(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,
),
),
),
],
),
);
}
}
@@ -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<InstrumentEditDialog> createState() => _InstrumentEditDialogState();
}
class _InstrumentEditDialogState extends State<InstrumentEditDialog> {
final _formKey = GlobalKey<FormState>();
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<String>(
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('Сохранить'),
),
],
);
}
}
+148 -32
View File
@@ -1,15 +1,21 @@
import 'package:decimal/decimal.dart'; import 'package:decimal/decimal.dart';
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart'; import 'package:fintracker_api/fintracker_api.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart'; import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/asset_icon.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart'; import '../../core/widgets/money_text.dart';
import '../../core/widgets/stale_banner.dart'; import '../../core/widgets/stale_banner.dart';
import '../../core/widgets/help_tip.dart';
import 'instrument_edit_dialog.dart';
import 'labels.dart'; import 'labels.dart';
import 'providers.dart'; import 'providers.dart';
@@ -23,15 +29,51 @@ class InstrumentPage extends ConsumerWidget {
final int instrumentId; final int instrumentId;
Future<void> _edit(
BuildContext context,
WidgetRef ref,
InstrumentOut instrument,
) async {
final patch = await showDialog<InstrumentPatch>(
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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(instrumentProvider(instrumentId)); final detail = ref.watch(instrumentProvider(instrumentId));
final instrument = detail.valueOrNull?.data.instrument;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(detail.valueOrNull?.data.instrument.ticker ?? title: Text(instrument?.ticker ?? instrument?.name ?? 'Инструмент'),
detail.valueOrNull?.data.instrument.name ?? actions: [
'Инструмент'), if (instrument != null)
IconButton(
tooltip: 'Редактировать',
icon: const Icon(Icons.edit_outlined),
onPressed: () => _edit(context, ref, instrument),
),
],
), ),
body: AsyncValueView( body: AsyncValueView(
value: detail, value: detail,
@@ -41,7 +83,8 @@ class InstrumentPage extends ConsumerWidget {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (cached.fetchedAt != null) StaleBanner(fetchedAt: cached.fetchedAt!), if (cached.fetchedAt != null)
StaleBanner(fetchedAt: cached.fetchedAt!),
_Header(instrument: d.instrument, holding: d.holding), _Header(instrument: d.instrument, holding: d.holding),
const SizedBox(height: 16), const SizedBox(height: 16),
_Section( _Section(
@@ -57,14 +100,20 @@ class InstrumentPage extends ConsumerWidget {
_Section( _Section(
title: 'Лоты', title: 'Лоты',
child: d.lots.isEmpty child: d.lots.isEmpty
? const EmptyState(icon: Icons.layers_outlined, message: 'Лотов нет.') ? const EmptyState(
icon: Icons.layers_outlined,
message: 'Лотов нет.',
)
: _LotsTable(lots: d.lots), : _LotsTable(lots: d.lots),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_Section( _Section(
title: 'События', title: 'События',
child: d.events.isEmpty child: d.events.isEmpty
? const EmptyState(icon: Icons.receipt_long, message: 'Событий нет.') ? const EmptyState(
icon: Icons.receipt_long,
message: 'Событий нет.',
)
: _EventsTable(events: d.events), : _EventsTable(events: d.events),
), ),
], ],
@@ -94,13 +143,35 @@ class _Header extends StatelessWidget {
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AssetIcon(
assetClass: instrument.assetClass,
logoUrl: instrument.logoUrl,
logoColor: instrument.logoColor,
size: 48,
),
const SizedBox(width: 16),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(instrument.name, style: theme.textTheme.titleLarge), Text(instrument.name, style: theme.textTheme.titleLarge),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(facts.join(' · '), Text(
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), facts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
if (holding == null) if (holding == null)
Text('Позиция закрыта', style: theme.textTheme.bodyMedium) Text('Позиция закрыта', style: theme.textTheme.bodyMedium)
@@ -139,7 +210,10 @@ class _HoldingFacts extends StatelessWidget {
children: [ children: [
h.marketPrice == null h.marketPrice == null
? const Text('') ? const Text('')
: MoneyText(h.marketPrice!, currency: h.priceCurrency ?? h.currency), : MoneyText(
h.marketPrice!,
currency: h.priceCurrency ?? h.currency,
),
const SizedBox(width: 6), const SizedBox(width: 6),
PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate), PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate),
], ],
@@ -147,7 +221,9 @@ class _HoldingFacts extends StatelessWidget {
), ),
_Fact( _Fact(
label: 'Стоимость', label: 'Стоимость',
value: h.valueRub == null ? const Text('') : MoneyText(h.valueRub!, currency: 'RUB'), value: h.valueRub == null
? const Text('')
: MoneyText(h.valueRub!, currency: 'RUB'),
), ),
_Fact( _Fact(
label: 'Нереализованная', label: 'Нереализованная',
@@ -156,14 +232,19 @@ class _HoldingFacts extends StatelessWidget {
: MoneyText( : MoneyText(
h.unrealizedPnlRub!, h.unrealizedPnlRub!,
currency: 'RUB', currency: 'RUB',
style: TextStyle(color: signColor(context, h.unrealizedPnlRub)), style: TextStyle(
color: signColor(context, h.unrealizedPnlRub),
),
), ),
), ),
_Fact( _Fact(
label: 'Реализованная', label: 'Реализованная',
value: MoneyText(h.realizedPnlRub ?? '0', currency: 'RUB'), 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( _Fact(
label: 'XIRR', label: 'XIRR',
value: Text( value: Text(
@@ -195,7 +276,10 @@ class _Fact extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(height: 2),
DefaultTextStyle(style: theme.textTheme.titleSmall!, child: value), DefaultTextStyle(style: theme.textTheme.titleSmall!, child: value),
], ],
@@ -230,8 +314,12 @@ class _PriceChart extends StatelessWidget {
interval: (prices.length / 4).clamp(1, double.infinity), interval: (prices.length / 4).clamp(1, double.infinity),
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final i = value.round(); final i = value.round();
if (i < 0 || i >= prices.length) return const SizedBox.shrink(); if (i < 0 || i >= prices.length)
return Text(ruMonthYearShort(prices[i].d), style: theme.textTheme.bodySmall); return const SizedBox.shrink();
return Text(
ruMonthYearShort(prices[i].d),
style: theme.textTheme.bodySmall,
);
}, },
), ),
), ),
@@ -245,7 +333,7 @@ class _PriceChart extends StatelessWidget {
isCurved: false, isCurved: false,
color: ChartColors.slot1Blue, color: ChartColors.slot1Blue,
barWidth: 2, barWidth: 2,
dotData: const FlDotData(show: false), dotData: FlDotData(show: prices.length < 2),
), ),
], ],
), ),
@@ -268,9 +356,15 @@ class _LotsTable extends StatelessWidget {
columns: [ 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('Цена', style: headerStyle), numeric: true),
DataColumn(label: Text('Стоимость, ₽', style: headerStyle), numeric: true), DataColumn(
label: Text('Стоимость, ₽', style: headerStyle),
numeric: true,
),
DataColumn(label: Text('Закрыт', style: headerStyle)), DataColumn(label: Text('Закрыт', style: headerStyle)),
], ],
rows: [ rows: [
@@ -280,11 +374,17 @@ class _LotsTable extends StatelessWidget {
DataCell(Text(ruDate(lot.openDate))), DataCell(Text(ruDate(lot.openDate))),
DataCell(Text(formatQty(lot.qtyOpen))), DataCell(Text(formatQty(lot.qtyOpen))),
DataCell(Text(formatQty(lot.qtyRemaining))), DataCell(Text(formatQty(lot.qtyRemaining))),
DataCell(MoneyText(lot.costPerUnit, currency: lot.costCurrency)), DataCell(
DataCell(lot.costTotalRub == null MoneyText(lot.costPerUnit, currency: lot.costCurrency),
),
DataCell(
lot.costTotalRub == null
? const Text('') ? const Text('')
: MoneyText(lot.costTotalRub!, currency: 'RUB')), : MoneyText(lot.costTotalRub!, currency: 'RUB'),
DataCell(Text(lot.closedAt == null ? '' : ruDate(lot.closedAt!))), ),
DataCell(
Text(lot.closedAt == null ? '' : ruDate(lot.closedAt!)),
),
], ],
), ),
], ],
@@ -317,7 +417,8 @@ class _EventsTable extends StatelessWidget {
DataRow( DataRow(
cells: [ cells: [
DataCell(Text(ruDate(e.tradeDate))), DataCell(Text(ruDate(e.tradeDate))),
DataCell(Row( DataCell(
Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(eventKindLabel(e.kind)), Text(eventKindLabel(e.kind)),
@@ -329,20 +430,35 @@ class _EventsTable extends StatelessWidget {
), ),
], ],
], ],
)), ),
DataCell(Text(e.quantity == null ? '' : formatQty(e.quantity!))), ),
DataCell(e.price == null DataCell(
Text(e.quantity == null ? '' : formatQty(e.quantity!)),
),
DataCell(
e.price == null
? const Text('') ? const Text('')
: MoneyText(e.price!, currency: e.priceCurrency ?? e.currency)), : MoneyText(
DataCell(MoneyText( e.price!,
currency: e.priceCurrency ?? e.currency,
),
),
DataCell(
MoneyText(
e.amount, e.amount,
currency: e.currency, currency: e.currency,
style: TextStyle(color: signColor(context, e.amount)), style: TextStyle(color: signColor(context, e.amount)),
)), ),
DataCell(SizedBox( ),
DataCell(
SizedBox(
width: 260, width: 260,
child: Text(e.description ?? '', overflow: TextOverflow.ellipsis), child: Text(
)), e.description ?? '',
overflow: TextOverflow.ellipsis,
),
),
),
], ],
), ),
], ],
+10 -4
View File
@@ -38,7 +38,8 @@ const dimensionLabels = {
'currency': 'Валюта', '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 /// 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. /// allocation step emits; everything else is the source's own value.
@@ -89,7 +90,8 @@ const eventKindLabels = {
'other': 'Прочее', '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 /// `'0.1234'` as `'+12,34 %'`. Null becomes an em dash: a return nobody could compute is
/// not zero percent. /// not zero percent.
@@ -111,13 +113,17 @@ Color? signColor(BuildContext context, String? value) {
if (value == null) return null; if (value == null) return null;
final d = Decimal.parse(value); final d = Decimal.parse(value);
if (d == Decimal.zero) return null; 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 /// 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. /// number, a missing one produces nothing at all, and both must be visible.
class PriceStatusChip extends StatelessWidget { 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 String status;
final DateTime? priceDate; final DateTime? priceDate;
@@ -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<Cached<SummaryOut>>(
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 = <Widget>[
_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,
],
),
),
);
}
}
+12 -41
View File
@@ -1,14 +1,13 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/stale_banner.dart'; import '../../core/widgets/stale_banner.dart';
import 'allocation_tab.dart'; import 'allocation_tab.dart';
import 'benchmarks_card.dart'; import 'benchmarks_card.dart';
import 'holdings_tab.dart'; import 'holdings_tab.dart';
import 'providers.dart'; import 'providers.dart';
import 'scope_selector.dart';
/// Портфель: позиции и аллокация as two tabs of one screen, sharing one scope. /// Портфель: позиции и аллокация as two tabs of one screen, sharing one scope.
/// ///
@@ -37,9 +36,12 @@ class PortfolioPage extends ConsumerWidget {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Портфель'), title: const Text('Портфель'),
actions: const [_ScopeSelector(), SizedBox(width: 8)], actions: const [ScopeSelector(), SizedBox(width: 8)],
bottom: const TabBar( bottom: const TabBar(
tabs: [Tab(text: 'Позиции'), Tab(text: 'Аллокация')], tabs: [
Tab(text: 'Позиции'),
Tab(text: 'Аллокация'),
],
), ),
), ),
body: Column( body: Column(
@@ -50,7 +52,12 @@ class PortfolioPage extends ConsumerWidget {
child: StaleBanner(fetchedAt: stale), child: StaleBanner(fetchedAt: stale),
), ),
const Expanded( 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<List<ScopeOut>>(
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<String>(
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;
},
),
);
},
);
}
}
+54 -19
View File
@@ -20,44 +20,79 @@ final scopesProvider = FutureProvider.autoDispose<List<ScopeOut>>((ref) async {
}); });
/// See `docs/ai/offline-cache.md`. /// See `docs/ai/offline-cache.md`.
final portfolioSummaryProvider = FutureProvider.autoDispose<Cached<SummaryOut>>((ref) async { final portfolioSummaryProvider = FutureProvider.autoDispose<Cached<SummaryOut>>(
(ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(scope: scope); final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsSummary(scope: scope);
return r.cached; return r.cached;
},
);
final holdingsProvider = FutureProvider.autoDispose<Cached<List<HoldingOut>>>((
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 holdingsProvider = FutureProvider.autoDispose<Cached<List<HoldingOut>>>((ref) async { final portfolioReturnsProvider =
FutureProvider.autoDispose<Cached<List<ReturnsOut>>>((ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsHoldings(scope: scope); final r = await ref
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); .watch(apiProvider)
.getAnalyticsApi()
.analyticsReturns(scope: scope);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}); });
final portfolioReturnsProvider = FutureProvider.autoDispose<Cached<List<ReturnsOut>>>((ref) async { final allocationProvider =
FutureProvider.autoDispose<Cached<List<AllocationBucket>>>((ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsReturns(scope: scope); final r = await ref
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); .watch(apiProvider)
}); .getAnalyticsApi()
.analyticsAllocation(scope: scope);
final allocationProvider = FutureProvider.autoDispose<Cached<List<AllocationBucket>>>((ref) async { return Cached(
final scope = ref.watch(scopeProvider); r.data ?? const [],
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsAllocation(scope: scope); fetchedAt: r.extra['fetchedAt'] as DateTime?,
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); );
}); });
/// Daily portfolio value for the last year, for the chart on Позиции. /// Daily portfolio value for the last year, for the chart on Позиции.
final valueSeriesProvider = FutureProvider.autoDispose<Cached<List<ValueDay>>>((ref) async { final valueSeriesProvider = FutureProvider.autoDispose<Cached<List<ValueDay>>>((
ref,
) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
final now = DateTime.now(); final now = DateTime.now();
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsValueSeries( final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsValueSeries(
scope: scope, scope: scope,
from: now.subtract(const Duration(days: 365)), from: now.subtract(const Duration(days: 365)),
to: now, 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 = final instrumentProvider = FutureProvider.autoDispose
FutureProvider.autoDispose.family<Cached<InstrumentDetail>, int>((ref, instrumentId) async { .family<Cached<InstrumentDetail>, int>((ref, instrumentId) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
final r = await ref final r = await ref
.watch(apiProvider) .watch(apiProvider)
@@ -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<List<ScopeOut>>(
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<String>(
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;
},
),
);
},
);
}
}
@@ -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<int> 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<PortfolioEditDialog> createState() =>
_PortfolioEditDialogState();
}
class _PortfolioEditDialogState extends ConsumerState<PortfolioEditDialog> {
final _formKey = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.initial?.name ?? '');
late final Set<int> _selected = {...?widget.initial?.accountIds};
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final accounts =
ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
// 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('Сохранить'),
),
],
);
}
}
@@ -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<PortfoliosPage> createState() => _PortfoliosPageState();
}
class _PortfoliosPageState extends ConsumerState<PortfoliosPage> {
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<void> _queueMetricsRefresh() async {
try {
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
} on DioException {
// the next scheduled or manual refresh picks the change up
}
}
Future<void> _run(Future<void> Function() action) async {
try {
await action();
if (!mounted) return;
invalidatePortfolios(ref);
await _queueMetricsRefresh();
} on DioException catch (e) {
if (mounted) _snack(problemMessage(e));
}
}
Future<void> _create() async {
final draft = await showDialog<PortfolioDraft>(
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<void> _edit(PortfolioOut portfolio) async {
final draft = await showDialog<PortfolioDraft>(
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<void> _delete(PortfolioOut portfolio) async {
final confirmed = await showDialog<bool>(
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),
),
),
),
],
);
},
),
),
);
}
}
@@ -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<List<PortfolioOut>>((
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);
}
+53 -10
View File
@@ -1,15 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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 /// The sections keep their own top-level routes from the contract (`/income`, `/rebalance`,
/// a phone leaves ~36 px per label in the bottom bar. Доходы, Ребалансировка, Цели and /// `/goals`, `/tax`) and are deep-linkable; `alsoMatches` in `nav_destinations.dart` keeps
/// Налоги are all "what do I do with the portfolio next" questions, so they live behind one /// Аналитика highlighted inside any of them.
/// entry that highlights for all four (see `alsoMatches` in `app_shell.dart`), while each class AnalyticsHubPage extends ConsumerWidget {
/// keeps its own top-level route from the contract (`/income`, `/rebalance`, `/goals`,
/// `/tax`) and is deep-linkable.
class AnalyticsHubPage extends StatelessWidget {
const AnalyticsHubPage({super.key}); const AnalyticsHubPage({super.key});
static const _entries = [ static const _entries = [
@@ -23,7 +28,8 @@ class AnalyticsHubPage extends StatelessWidget {
path: '/rebalance', path: '/rebalance',
icon: Icons.balance, icon: Icons.balance,
title: 'Ребалансировка', title: 'Ребалансировка',
subtitle: 'Целевые веса портфеля и рекомендации, что докупить или продать', subtitle:
'Целевые веса портфеля и рекомендации, что докупить или продать',
), ),
( (
path: '/goals', 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -46,7 +89,7 @@ class AnalyticsHubPage extends StatelessWidget {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
for (final e in _entries) for (final e in AnalyticsHubPage._entries)
Card( Card(
child: ListTile( child: ListTile(
leading: Icon(e.icon), leading: Icon(e.icon),
+101
View File
@@ -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,
),
),
],
),
),
);
}
}
+31 -26
View File
@@ -1,21 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'analytics_tabs.dart';
import 'nav_destinations.dart'; import 'nav_destinations.dart';
import 'nav_sidebar.dart'; import 'top_nav.dart';
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 6001200, /// Below this width the shell falls back to a bottom [NavigationBar]; from here up it is the
/// extended rail at 1200 and above. /// [TopNav] bar.
const _narrowBreakpoint = 600.0; const _narrowBreakpoint = 600.0;
const _wideBreakpoint = 1200.0;
/// Adaptive navigation shell around the current route: a bottom /// Adaptive navigation shell around the current route: a bottom [NavigationBar] on narrow
/// [NavigationBar] on narrow surfaces, a collapsed [NavigationRail] on medium /// surfaces and the [TopNav] bar on wider ones, with the Аналитика tab strip under it while
/// ones, and the grouped [NavSidebar] on wide ones. /// one of the analytics screens is open.
/// ///
/// The rail and the sidebar show every destination. The bottom bar shows the four primary /// The top bar shows every destination (the ledger ones behind «Операции»). The bottom bar
/// ones plus «Ещё», which opens the rest in a sheet: twelve destinations in a phone-width /// shows the four primary ones plus «Ещё», which opens the rest in a sheet: twelve
/// bar would leave about 30 px per label, which is not navigation but decoration. /// destinations in a phone-width bar would leave about 30 px per label, which is not
/// navigation but decoration.
class AppShell extends StatelessWidget { class AppShell extends StatelessWidget {
const AppShell({required this.location, required this.child, super.key}); const AppShell({required this.location, required this.child, super.key});
@@ -80,28 +81,32 @@ class AppShell extends StatelessWidget {
); );
} }
final extended = width >= _wideBreakpoint;
return Scaffold( return Scaffold(
body: Row( body: Column(
children: [ children: [
if (extended) TopNav(
NavSidebar(selectedIndex: _selectedIndex, onSelect: (i) => _onSelect(context, i))
else
NavigationRail(
selectedIndex: _selectedIndex, selectedIndex: _selectedIndex,
onDestinationSelected: (i) => _onSelect(context, i), onSelect: (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),
), ),
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)), ),
),
], ],
), ),
); );
+100 -30
View File
@@ -1,18 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// The three groups the sidebar buckets destinations under (Grimmory-style caps headers). /// The groups destinations belong to: the main tabs, the ledger screens behind «Операции», and
/// Order here is display order, top to bottom. /// the system ones (Здоровье, Настройки) that sit as icons on the right of the top bar.
enum NavGroup { overview, ledger, system } enum NavGroup { overview, ledger, system }
const navGroupLabels = {
NavGroup.overview: 'ОБЗОР',
NavGroup.ledger: 'ОПЕРАЦИИ',
NavGroup.system: 'СИСТЕМА',
};
class NavDestination { class NavDestination {
const NavDestination(this.path, this.icon, this.selectedIcon, this.label, this.group, const NavDestination(
{this.alsoMatches = const [], this.primary = false}); this.path,
this.icon,
this.selectedIcon,
this.label,
this.group, {
this.alsoMatches = const [],
this.primary = false,
});
final String path; final String path;
final IconData icon; final IconData icon;
final IconData selectedIcon; final IconData selectedIcon;
@@ -39,28 +40,97 @@ class NavDestination {
} }
const navDestinations = [ const navDestinations = [
NavDestination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', NavGroup.overview,
primary: true),
NavDestination( 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( NavDestination(
'/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель', NavGroup.overview, '/analytics',
primary: true), Icons.insights_outlined,
// One entry for the four phase-4 screens. They keep their own top-level routes from the Icons.insights,
// 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. NavGroup.overview,
NavDestination('/analytics', Icons.insights_outlined, Icons.insights, 'Аналитика', NavGroup.overview, alsoMatches: ['/income', '/rebalance', '/goals', '/tax', '/portfolios'],
alsoMatches: ['/income', '/rebalance', '/goals', '/tax'], primary: true), 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( NavDestination(
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции', NavGroup.ledger), '/portfolio',
NavDestination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила', NavGroup.ledger), Icons.pie_chart_outline,
Icons.pie_chart,
'Портфель',
NavGroup.overview,
primary: true,
),
NavDestination( NavDestination(
'/health', Icons.monitor_heart_outlined, Icons.monitor_heart, 'Здоровье', NavGroup.system), '/accounts',
NavDestination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки', NavGroup.system), 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,
),
]; ];
-199
View File
@@ -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<int> onSelect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final issueCount = ref.watch(dataQualityProvider).valueOrNull?.data.length;
final byGroup = <NavGroup, List<int>>{};
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<Widget> 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),
),
],
),
),
],
),
);
}
}
+357
View File
@@ -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<int> 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<int> indices;
final int selectedIndex;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final active = indices.contains(selectedIndex);
return PopupMenuButton<int>(
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<String>(
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<String>(
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,
),
),
),
),
);
}
}
+22 -7
View File
@@ -17,6 +17,7 @@ import 'features/login/login_page.dart';
import 'features/pending/pending_page.dart'; import 'features/pending/pending_page.dart';
import 'features/portfolio/instrument_page.dart'; import 'features/portfolio/instrument_page.dart';
import 'features/portfolio/portfolio_page.dart'; import 'features/portfolio/portfolio_page.dart';
import 'features/portfolios/portfolios_page.dart';
import 'features/rebalance/rebalance_page.dart'; import 'features/rebalance/rebalance_page.dart';
import 'features/rules/rules_page.dart'; import 'features/rules/rules_page.dart';
import 'features/settings/settings_page.dart'; import 'features/settings/settings_page.dart';
@@ -53,14 +54,22 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(path: '/portfolio', builder: (_, _) => const PortfolioPage()), GoRoute(path: '/portfolio', builder: (_, _) => const PortfolioPage()),
GoRoute( GoRoute(
path: '/portfolio/instrument/:id', path: '/portfolio/instrument/:id',
builder: (_, state) => builder: (_, state) => InstrumentPage(
InstrumentPage(instrumentId: int.parse(state.pathParameters['id']!)), instrumentId: int.parse(state.pathParameters['id']!),
),
), ),
// Аналитика: one navigation destination, four contract routes. The hub is what // Аналитика: 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 // the shell points at; each screen below keeps its own top-level path and stays
// deep-linkable (see `alsoMatches` in `app_shell.dart`). // 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: '/income', builder: (_, _) => const IncomePage()),
GoRoute(
path: '/portfolios',
builder: (_, _) => const PortfoliosPage(),
),
GoRoute(path: '/rebalance', builder: (_, _) => const RebalancePage()), GoRoute(path: '/rebalance', builder: (_, _) => const RebalancePage()),
GoRoute(path: '/goals', builder: (_, _) => const GoalsPage()), GoRoute(path: '/goals', builder: (_, _) => const GoalsPage()),
GoRoute(path: '/tax', builder: (_, _) => const TaxPage()), GoRoute(path: '/tax', builder: (_, _) => const TaxPage()),
@@ -68,8 +77,9 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(path: '/imports', builder: (_, _) => const ImportsPage()), GoRoute(path: '/imports', builder: (_, _) => const ImportsPage()),
GoRoute( GoRoute(
path: '/imports/:id', path: '/imports/:id',
builder: (_, state) => builder: (_, state) => ImportPreviewPage(
ImportPreviewPage(importId: int.parse(state.pathParameters['id']!)), importId: int.parse(state.pathParameters['id']!),
),
), ),
GoRoute( GoRoute(
path: '/instruments/pending', path: '/instruments/pending',
@@ -78,9 +88,14 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(path: '/cashflow', builder: (_, _) => const CashflowPage()), GoRoute(path: '/cashflow', builder: (_, _) => const CashflowPage()),
GoRoute( GoRoute(
path: '/categories', 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: '/rules', builder: (_, _) => const RulesPage()),
GoRoute( GoRoute(
path: '/health', path: '/health',
+56
View File
@@ -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);
});
}
+258
View File
@@ -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<int>, AccountPatch)>[];
@override
Future<String?> patch(Iterable<int> 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<AccountOut> _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<AccountOut> 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<AccountRole>, 'Сбережения'),
);
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',
);
},
);
}
+111 -52
View File
@@ -1,10 +1,12 @@
import 'package:fintracker_api/fintracker_api.dart'; import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/core/api/common_providers.dart'; import 'package:fintracker_app/core/api/common_providers.dart';
import 'package:fintracker_app/core/cache/cached.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/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/app_shell.dart';
import 'package:fintracker_app/features/shell/nav_destinations.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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -20,13 +22,19 @@ DataQualityRow _dataQualityRow(int id) => DataQualityRow(
); );
void main() { void main() {
// The sidebar (>=1200) watches meProvider and dataQualityProvider for the profile // The top bar watches meProvider and dataQualityProvider for the profile avatar and the
// footer and the Здоровье counter — every wide-breakpoint pump needs both overridden. // Здоровье counter — every pump at or above 600 needs both overridden.
Widget wrap(double width, {String location = '/'}) { Widget wrap(
double width, {
String location = '/',
List<DataQualityRow> issues = const [],
}) {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
meProvider.overrideWith((ref) async => UserOut(email: 'ada@example.com', id: 1)), meProvider.overrideWith(
dataQualityProvider.overrideWith((ref) async => const Cached(<DataQualityRow>[])), (ref) async => UserOut(email: 'ada@example.com', id: 1),
),
dataQualityProvider.overrideWith((ref) async => Cached(issues)),
], ],
child: MediaQuery( child: MediaQuery(
data: MediaQueryData(size: Size(width, 800)), 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.pumpWidget(wrap(1280));
await tester.pump(); await tester.pump();
expect(find.byType(NavSidebar), findsOneWidget); expect(find.byType(TopNav), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing); expect(find.byType(NavigationRail), findsNothing);
expect(find.byType(NavigationBar), findsNothing); expect(find.byType(NavigationBar), findsNothing);
for (final label in navGroupLabels.values) { for (final label in [
expect(find.text(label), findsOneWidget); 'Обзор',
'Аналитика',
'Портфель',
'Счета',
'Операции',
'Добавить',
]) {
expect(find.text(label), findsOneWidget, reason: label);
} }
}); });
testWidgets('shows a collapsed NavigationRail on a medium surface', (tester) async { testWidgets(
await tester.pumpWidget(wrap(900)); 'keeps the top bar on a medium surface, with «Добавить» as an icon',
expect(find.byType(NavigationRail), findsOneWidget); (tester) async {
expect(find.byType(NavSidebar), findsNothing); await tester.pumpWidget(wrap(700));
await tester.pump();
expect(find.byType(TopNav), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing); 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)); await tester.pumpWidget(wrap(400));
expect(find.byType(NavigationBar), findsOneWidget); expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing); expect(find.byType(TopNav), findsNothing);
expect(find.byType(NavSidebar), findsNothing);
}); });
testWidgets('the resolve screen keeps Импорт selected', (tester) async { testWidgets('the resolve screen keeps Импорт selected', (tester) async {
await tester.pumpWidget(wrap(900, location: '/instruments/pending')); await tester.pumpWidget(wrap(1280, location: '/instruments/pending'));
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail)); await tester.pump();
expect( final nav = tester.widget<TopNav>(find.byType(TopNav));
(rail.destinations[rail.selectedIndex!].label as Text).data, 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 { testWidgets('a nested route keeps its own section selected', (tester) async {
await tester.pumpWidget(wrap(900, location: '/portfolio/instrument/311')); await tester.pumpWidget(wrap(1280, location: '/portfolio/instrument/311'));
final rail = tester.widget<NavigationRail>(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.pump(); await tester.pump();
final sidebar = tester.widget<NavSidebar>(find.byType(NavSidebar)); final nav = tester.widget<TopNav>(find.byType(TopNav));
expect(navDestinations[sidebar.selectedIndex].label, 'Импорт'); 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<TopNav>(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( await tester.pumpWidget(
ProviderScope( wrap(1280, issues: [_dataQualityRow(1), _dataQualityRow(2)]),
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())),
),
),
); );
await tester.pump(); await tester.pump();
expect(find.text('2'), findsOneWidget); 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);
});
} }
@@ -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<void> _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<Icon>(find.byType(Icon));
expect(icon.icon, assetClassIcon('bond'));
expect(icon.color, Colors.white); // dark green → light glyph
final fill = tester.widget<ColoredBox>(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<void>.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);
});
}
+121
View File
@@ -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<Tooltip>(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',
);
});
}
+39 -11
View File
@@ -31,18 +31,40 @@ RunwayOut _runway() => RunwayOut(
// empty ledger. Without it, HomePage falls through to the real apiProvider, // empty ledger. Without it, HomePage falls through to the real apiProvider,
// which now also opens a real ResponseCacheDatabase (see docs/ai/offline-cache.md). // which now also opens a real ResponseCacheDatabase (see docs/ai/offline-cache.md).
List<Override> _overrides({DateTime? staleAt}) => [ List<Override> _overrides({DateTime? staleAt}) => [
netWorthBreakdownProvider.overrideWith((ref) => Cached(_breakdown(), fetchedAt: staleAt)), netWorthBreakdownProvider.overrideWith(
netWorthSeriesProvider.overrideWith((ref) => Cached(const <NetWorthDay>[], fetchedAt: staleAt)), (ref) => Cached(_breakdown(), fetchedAt: staleAt),
cashflowThisMonthProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)), ),
cashflowLast12Provider.overrideWith((ref) => Cached(const <CashFlowMonth>[], fetchedAt: staleAt)), netWorthSeriesProvider.overrideWith(
(ref) => Cached(const <NetWorthDay>[], fetchedAt: staleAt),
),
cashflowThisMonthProvider.overrideWith(
(ref) => Cached(null, fetchedAt: staleAt),
),
cashflowLast12Provider.overrideWith(
(ref) => Cached(const <CashFlowMonth>[], fetchedAt: staleAt),
),
runwayProvider.overrideWith((ref) => Cached(_runway(), fetchedAt: staleAt)), runwayProvider.overrideWith((ref) => Cached(_runway(), fetchedAt: staleAt)),
metricsStatusProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)), metricsStatusProvider.overrideWith(
dataQualityProvider.overrideWith((ref) => Cached(const <DataQualityRow>[], fetchedAt: staleAt)), (ref) => Cached(
portfolioSummaryHomeProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)), MetricsStatusOut(lastRefresh: null, consistent: true, refreshing: false),
fetchedAt: staleAt,
),
),
dataQualityProvider.overrideWith(
(ref) => Cached(const <DataQualityRow>[], fetchedAt: staleAt),
),
portfolioSummaryHomeProvider.overrideWith(
(ref) => Cached(null, fetchedAt: staleAt),
),
scopeCardsProvider.overrideWith(
(ref) => Cached(const <ScopeCardOut>[], fetchedAt: staleAt),
),
]; ];
void main() { 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( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: _overrides(), overrides: _overrides(),
@@ -60,7 +82,9 @@ void main() {
expect(find.textContaining('Нет соединения'), findsNothing); expect(find.textContaining('Нет соединения'), findsNothing);
}); });
testWidgets('shows the offline banner when the dashboard came from the cache', (tester) async { testWidgets(
'shows the offline banner when the dashboard came from the cache',
(tester) async {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: _overrides(staleAt: DateTime(2026, 9, 10, 8, 30)), overrides: _overrides(staleAt: DateTime(2026, 9, 10, 8, 30)),
@@ -69,6 +93,10 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('Нет соединения — данные на 10.09.2026 08:30'), findsOneWidget); expect(
}); find.textContaining('Нет соединения — данные на 10.09.2026 08:30'),
findsOneWidget,
);
},
);
} }
+109
View File
@@ -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<InstrumentPatch?> _open(
WidgetTester tester,
Future<void> Function() interact,
) async {
InstrumentPatch? result;
var closed = false;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () async {
result = await showDialog<InstrumentPatch>(
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<InstrumentPatch>(
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);
});
}
+107
View File
@@ -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<void> _open(WidgetTester tester, List<AccountOut> 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);
},
);
}
+61
View File
@@ -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);
});
}
+48 -27
View File
@@ -81,22 +81,30 @@ Future<void> pumpTall(WidgetTester tester, Widget widget) async {
} }
void main() { void main() {
testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', (tester) async { testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', (
await pumpTall(tester, _wrap( tester,
const HoldingsTab(), ) async {
[ await pumpTall(
tester,
_wrap(const HoldingsTab(), [
portfolioSummaryProvider.overrideWith((ref) => Cached(summary())), portfolioSummaryProvider.overrideWith((ref) => Cached(summary())),
valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])), valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])),
portfolioReturnsProvider.overrideWith((ref) => const Cached(<ReturnsOut>[])), portfolioReturnsProvider.overrideWith(
holdingsProvider.overrideWith((ref) => Cached([ (ref) => const Cached(<ReturnsOut>[]),
),
holdingsProvider.overrideWith(
(ref) => Cached([
holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'), holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'),
holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'), holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'),
])), ]),
),
// HoldingsTab embeds BenchmarksCard, which watches this too — leaving it out would // HoldingsTab embeds BenchmarksCard, which watches this too — leaving it out would
// fall through to the real apiProvider (see docs/ai/offline-cache.md). // fall through to the real apiProvider (see docs/ai/offline-cache.md).
benchmarkRowsProvider.overrideWith((ref) => const Cached(<BenchmarkRow>[])), benchmarkRowsProvider.overrideWith(
], (ref) => const Cached(<BenchmarkRow>[]),
)); ),
]),
);
expect(find.text('GAZP'), findsOneWidget); expect(find.text('GAZP'), findsOneWidget);
expect(find.text('SIBN6P4'), findsOneWidget); expect(find.text('SIBN6P4'), findsOneWidget);
@@ -107,27 +115,36 @@ void main() {
expect(find.text(MoneyText.format('0', 'RUB')), findsNothing); expect(find.text(MoneyText.format('0', 'RUB')), findsNothing);
}); });
testWidgets('Позиции show the total profit as «—» while a price is missing', (tester) async { testWidgets('Позиции show the total profit as «—» while a price is missing', (
await pumpTall(tester, _wrap( tester,
const HoldingsTab(), ) async {
[ await pumpTall(
tester,
_wrap(const HoldingsTab(), [
portfolioSummaryProvider.overrideWith((ref) => Cached(summary())), portfolioSummaryProvider.overrideWith((ref) => Cached(summary())),
valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])), valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])),
portfolioReturnsProvider.overrideWith((ref) => const Cached(<ReturnsOut>[])), portfolioReturnsProvider.overrideWith(
(ref) => const Cached(<ReturnsOut>[]),
),
holdingsProvider.overrideWith((ref) => const Cached(<HoldingOut>[])), holdingsProvider.overrideWith((ref) => const Cached(<HoldingOut>[])),
benchmarkRowsProvider.overrideWith((ref) => const Cached(<BenchmarkRow>[])), benchmarkRowsProvider.overrideWith(
], (ref) => const Cached(<BenchmarkRow>[]),
)); ),
]),
);
expect(find.text('Прибыль'), findsOneWidget); expect(find.text('Прибыль'), findsOneWidget);
expect(find.text('часть позиций без цены'), findsOneWidget); expect(find.text('часть позиций без цены'), findsOneWidget);
}); });
testWidgets('Аллокация labels the cash and unknown buckets in Russian', (tester) async { testWidgets('Аллокация labels the cash and unknown buckets in Russian', (
await pumpTall(tester, _wrap( tester,
const AllocationTab(), ) async {
[ await pumpTall(
allocationProvider.overrideWith((ref) => Cached([ tester,
_wrap(const AllocationTab(), [
allocationProvider.overrideWith(
(ref) => Cached([
AllocationBucket( AllocationBucket(
bucket: 'share', bucket: 'share',
dimension: AllocationDimension.assetClass, dimension: AllocationDimension.assetClass,
@@ -142,9 +159,10 @@ void main() {
valueRub: '5700', valueRub: '5700',
weight: '0.3413', weight: '0.3413',
), ),
])), ]),
], ),
)); ]),
);
expect(find.text('Класс актива'), findsOneWidget); expect(find.text('Класс актива'), findsOneWidget);
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', () { test('bucketLabel falls back to the source value it does not know', () {
expect(bucketLabel(AllocationDimension.country, 'RU'), 'Россия'); expect(bucketLabel(AllocationDimension.country, 'RU'), 'Россия');
expect(bucketLabel(AllocationDimension.country, 'unknown'), 'Не указано'); 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',
);
}); });
} }
+95
View File
@@ -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<PortfolioOut> portfolios, List<AccountOut> 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<CheckboxListTile>(find.byType(CheckboxListTile))
.toList();
expect((titles.first.title as Text).data, 'Брокер');
await tester.tap(find.text('Сохранить'));
await tester.pumpAndSettle();
expect(find.text('Обязательное поле'), findsOneWidget);
});
}
+112
View File
@@ -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<ScopeCardOut> 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<int> 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);
});
}
+82
View File
@@ -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<ByteData> _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));
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 984 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="fin-tracker">
<title>fin-tracker</title>
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#14AFFF"/><stop offset="1" stop-color="#7A5CFF"/></linearGradient></defs>
<circle cx="256" cy="256" r="256" fill="url(#g)"/>
<g fill="#FFFFFF">
<rect x="132" y="281" width="64" height="100" rx="14"/>
<rect x="224" y="211" width="64" height="170" rx="14"/>
<rect x="316" y="131" width="64" height="250" rx="14"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 558 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="fin-tracker">
<title>fin-tracker</title>
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#14AFFF"/><stop offset="1" stop-color="#7A5CFF"/></linearGradient></defs>
<circle cx="256" cy="256" r="256" fill="url(#g)"/>
<g fill="none" stroke="#FFFFFF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round">
<path d="M176 128Q150 128 150 158V220Q150 256 112 256Q150 256 150 292V354Q150 384 176 384"/>
<path d="M336 128Q362 128 362 158V220Q362 256 400 256Q362 256 362 292V354Q362 384 336 384"/>
</g>
<g fill="#FFFFFF">
<rect x="196" y="276" width="32" height="64" rx="6"/>
<rect x="240" y="228" width="32" height="112" rx="6"/>
<rect x="284" y="172" width="32" height="168" rx="6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -27,6 +27,7 @@
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="favicon.svg"/>
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>fintracker_app</title> <title>fintracker_app</title>
+2 -2
View File
@@ -3,8 +3,8 @@
"short_name": "fintracker_app", "short_name": "fintracker_app",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#25252F",
"theme_color": "#0175C2", "theme_color": "#1F1F28",
"description": "A new Flutter project.", "description": "A new Flutter project.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,
+90 -89
View File
@@ -1,104 +1,105 @@
# Визуальный язык (фаза 6, начата) # Визуальный язык
Редизайн Flutter-клиента в духе Grimmory (self-hosted книжная библиотека): тёмный сине- Клиент оформлен по образцу Snowball (сервис учёта инвестиций): графитово-фиолетовые поверхности,
индиго фон вместо нейтрального серого, плоские карточки с рамкой вместо тени, группированная один яркий голубой акцент, плоские скруглённые карточки без рамки, заливные поля ввода,
боковая навигация с капс-заголовками и левой акцентной полоской у активного пункта. Обкатано навигация вкладками сверху. Раньше был другой язык (сине-индиго, боковой сайдбар по референсу
целиком на одном экране — Обзор (`features/home/home_page.dart`) — и на навигационном шелле Grimmory) — он заменён целиком, кроме доменных экранов, которые сохранили свою раскладку и просто
(он общий для всех экранов, поэтому не в счёт «раскатки»), прежде чем идти дальше, тем же подхватили новые токены.
способом, что офлайн-кэш (`offline-cache.md`).
## Решение: одна тема на обе яркости, не «тёмная как есть — светлая как получится» Одна тема на обе яркости: форма карточек, акцент и поведение навигации одинаковы, различаются
только тона поверхностей. Приложение живёт на вебе, Linux, Windows и Android, и тема
Тёмная и светлая тема получают один и тот же язык (форма карточек, акцент, поведение переключается в Настройках (`themeModeProvider`) — светлая версия не должна выглядеть недоделанной.
навигации), различаются только тона поверхностей. Причины:
- Приложение живёт на вебе, Linux, Windows и Android одновременно — пользователь может
оказаться в любой теме по системной настройке, и она уже переключается в
`settings_page.dart`. Бесплатно смотрящаяся только тёмная версия читалась бы как
недоделанная в светлой.
- Семантика цвета (`signColor` — прибыль/убыток, `ChartColors` — ряды графиков) уже общая
для обеих тем; заводить для тёмной темы отдельный «фирменный» акцент, а для светлой
оставлять дефолтный Material-синий было бы второй, несогласованной системой токенов.
## Токены: `core/theme/app_theme.dart` ## Токены: `core/theme/app_theme.dart`
`AppTheme.light()` / `AppTheme.dark()` заменили инлайновые `ThemeData(...)` в `app.dart`. `AppTheme.light()` / `AppTheme.dark()` строятся одной функцией `_build(Brightness)`.
Обе строятся одной функцией `_build(Brightness)` — нет двух параллельных наборов чисел,
которые могут разъехаться.
- **Seed** — `Color(0xFF2A78D6)`, тот же синий, что `ChartColors.slot1Blue` (основной ряд - **Акцент** — `AppTheme.seed = 0xFF14AFFF` (тот же, что `ChartColors.slot1Blue`, главный ряд
графиков капитала/портфеля). До редизайна seed темы (`0xFF2E6F5E`, зелёно-бирюзовый) и графиков). В светлой теме `primary` чуть темнее (`0xFF0A98E6`) ради контраста на белом.
цвет основной линии на графиках были разными брендовыми цветами одного приложения — - **Тёмные поверхности** заданы явно поверх `ColorScheme.fromSeed`, потому что тонировка
теперь акцент интерфейса и «главный» цвет данных на графиках — один и тот же. Material 3 даёт синий, а не графитово-фиолетовый:
- **Поверхности** — не переопределены вручную: `ColorScheme.fromSeed` в Material 3 уже `surface` `#25252F` (фон страницы) → `surfaceContainerLowest` `#1F1F28` (верхняя панель) →
тонирует нейтральную палитру оттенком seed, поэтому тёмный `surface` от синего seed `surfaceContainer` `#2F2F3C` (карточки) → `surfaceContainerHigh` `#3A3A49` (поля ввода,
получается тёмным сине-индиго сам по себе («не чистый чёрный», как в референсе), без вторичные кнопки) → `surfaceContainerHighest` `#474757` (активная «таблетка» навигации).
захардкоженных цветов сверх seed. Слои `surfaceContainer(Low/…/High)` идут на карточки, - **Плюс и минус** — `ChartColors.gain` (мятный `#3DDC97`) и `ChartColors.loss` (коралловый
панель навигации и фон страницы — так они остаются согласованно тонированными при смене `#F26B6B`); `signColor` (`portfolio/labels.dart`) возвращает именно их. Категориальные цвета
темы. графиков (`slot1…slot5`) не менялись.
- **`cardTheme`** — скруглённые углы (16), `elevation: 0`, тонкая рамка `outlineVariant` - **`cardTheme`** — радиус 12, `elevation: 0`; в тёмной теме без рамки, в светлой с тонкой
вместо тени. Это глобальный токен: он каскадом меняет вид `Card` **везде**, где он уже `outlineVariant`. Глобальный токен: меняет вид `Card` везде.
используется (`SectionCard`, `StatTile`, `GoalCard`, карточки Здоровья, `_Card` в - **`inputDecorationTheme`** — заливка `surfaceContainerHigh`, без обводки, радиус 8, акцентная
`holdings_tab.dart` и т.д.) без правки каждого экрана — что и даёт «зафиксировать токены рамка в фокусе. Действует на все формы и диалоги без правки экранов.
один раз» смысл. Экраны, которые ещё не редизайнены явно, уже выглядят новее только за - Кнопки (радиус 8), `tabBarTheme`, `dialogTheme`, `popupMenuTheme`, `chipTheme` — из тех же токенов.
счёт этого.
- **`navigationRailTheme` / `navigationBarTheme`** — акцентная подсветка активного пункта
(`primary`/`primaryContainer`) для свёрнутого rail (600–1200) и нижнего бара (<600).
Все цвета берутся из `ColorScheme` или уже существующего `ChartColors` — ни одного нового Все цвета берутся из `ColorScheme` или `ChartColors`; хардкод вне них — только градиент логотипа.
хардкод-цвета вне них.
## Компоненты ## Навигация: верхняя панель
- **`core/widgets/section_header.dart``SectionHeader`** — жирный заголовок + короткая `features/shell/top_nav.dart` (`TopNav`) показывается от 600 px и шире; уже 600 — нижний
акцентная полоска (28×3, `colorScheme.primary`) под левой частью текста. Замена голому `NavigationBar` с «Ещё» (как раньше). Список направлений — `nav_destinations.dart`.
`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` и использует общие виджеты, как и остальные вкладки.
## `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`). `NavSidebar` и `NavigationRail` удалены. Наблюдение за `dataQualityProvider` и `meProvider`
На 600–1200 остаётся штатный `NavigationRail` в свёрнутом виде — в иконку-без-подписи всё остаётся в `TopNav`, значит эти запросы уходят при каждом заходе, а не только на трёх экранах;
равно не помещаются ни капс-заголовки групп, ни счётчики, ни профиль, так что городить туда для одного пользователя это не проблема, но стоит знать при правке `core/cache/*`.
кастомный виджет незачем; на <600 остаётся штатный `NavigationBar`. Список направлений
вынесен в `features/shell/nav_destinations.dart` (`NavDestination`, `navDestinations`,
`NavGroup`) — общий для `app_shell.dart` и `nav_sidebar.dart`, было приватным в одном файле.
- **Группы** (`NavGroup`): «ОБЗОР» (Обзор/Счета/Портфель/Аналитика — те же четыре, что ## Экраны
сейчас основные для нижнего бара), «ОПЕРАЦИИ» (События/Импорт/Потоки/Категории/
Операции/Правила), «СИСТЕМА» (Здоровье/Настройки).
- **Активный пункт** — левая полоска 3px `colorScheme.primary` + тонированный фон
`primaryContainer` + акцентный цвет текста/иконки.
- **Счётчик** — сейчас подключён только у «Здоровье», из `dataQualityProvider`
(число строк). У «Цели» счётчика нет: это не отдельный пункт меню, а часть хаба
«Аналитика» (`alsoMatches` в `NavDestination`) — добавлять его как отдельный пункт ради
счётчика значило бы менять структуру навигации, что не входило в задачу «только
визуальный слой».
- **Профиль внизу** — аватар с инициалом + email из `meProvider`, «имя» — локальная часть
email до `@` (в бэкенде нет отдельного отображаемого имени, только email/id).
**Осознанный побочный эффект**: `NavSidebar` вызывает `ref.watch` на `dataQualityProvider` - **Обзор** — сверху сетка карточек «Портфели»: по одной на `all`, портфель и счёт
и `meProvider`, которые раньше запрашивались только при открытии Обзора/Здоровья/Настроек. (`home/scope_cards.dart`). В карточке: стоимость, прибыль (₽ и %), за день, доходность
Поскольку `AppShell` смонтирован всё время сессии, эти два запроса теперь улетают на API (XIRR с начала), пассивный доход (% и ₽ в год). Один запрос `GET /analytics/overview`;
при каждом заходе в приложение, а не только на этих трёх экранах. `meProvider` не тап открывает Портфель в этом скоупе. Ниже прежние блоки (капитал, месяц, запас хода, графики).
`autoDispose` и уже держится живым где-то ещё — здесь она просто донат к уже открытому - **Аналитика** — общая полоса вкладок под верхней панелью (`shell/analytics_tabs.dart`):
запросу. `dataQualityProvider``autoDispose`; watch из шелла держит его живым, пока Общее, Дивиденды, Ребалансировка, Цели, Налоги, Портфели. Каждая вкладка — свой маршрут
приложение открыто. Для одиночного пользователя на своём VPS это не проблема (`/analytics`, `/income`, …), остаются ссылаемыми. «Общее» (`AnalyticsHubPage`) — четыре
производительности, но это решение, а не то, о чём просили буквально — стоит знать при плитки (Стоимость, Прибыль, Доходность, Пассивный доход; `portfolio/overview_tiles.dart`),
следующей правке `core/cache/*`. график стоимости, доходность, бенчмарки. На телефоне вместо полосы остаётся список разделов.
- **Портфель** — таблица активов (`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) — следующий шаг, тем же кнопки, вкладки). Переделывать их по образцу Snowball можно тем же способом: экран за экраном.
способом: один экран, проверка, потом дальше.
- Маршруты и провайдеры не менялись, кроме двух новых точек `ref.watch` в `NavSidebar` ## Иконки активов
(см. выше).
`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`), а не с общими представлениями: например, запас хода считается от «обычного»
расхода за три последних полных месяца и не включает инвестиции.