feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -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)];
|
||||
@@ -1,25 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Design tokens for fin-tracker's visual language (`docs/ai/design-system.md`): dark
|
||||
/// surfaces tinted blue-indigo rather than neutral grey, flat bordered cards instead of
|
||||
/// Material's default elevation shadow, and one accent seed shared with
|
||||
/// `ChartColors.slot1Blue` so UI chrome and the primary data series read as the same color.
|
||||
/// Design tokens for fin-tracker's visual language (`docs/ai/design-system.md`): graphite-violet
|
||||
/// surfaces, one bright sky-blue accent, flat rounded cards with no outline, filled inputs and
|
||||
/// pill-shaped navigation — the look of Snowball.
|
||||
///
|
||||
/// Both brightnesses share this seed and this shape language — only the surface tones
|
||||
/// Both brightnesses share this accent and this shape language — only the surface tones
|
||||
/// differ — so the dark theme is not a special case bolted onto a default light Material
|
||||
/// theme, and switching `themeModeProvider` never changes which widgets exist.
|
||||
class AppTheme {
|
||||
const AppTheme._();
|
||||
|
||||
/// The accent: buttons, active tabs, links and the primary chart series.
|
||||
/// Matches `ChartColors.slot1Blue`.
|
||||
static const seed = Color(0xFF2A78D6);
|
||||
static const seed = Color(0xFF14AFFF);
|
||||
|
||||
static ThemeData light() => _build(Brightness.light);
|
||||
static ThemeData dark() => _build(Brightness.dark);
|
||||
|
||||
static ColorScheme _scheme(Brightness brightness) {
|
||||
final base = ColorScheme.fromSeed(seedColor: seed, brightness: brightness);
|
||||
if (brightness == Brightness.light) {
|
||||
return base.copyWith(
|
||||
primary: const Color(0xFF0A98E6),
|
||||
onPrimary: Colors.white,
|
||||
surface: const Color(0xFFF2F2F7),
|
||||
surfaceContainerLowest: Colors.white,
|
||||
surfaceContainerLow: const Color(0xFFF7F7FB),
|
||||
surfaceContainer: Colors.white,
|
||||
surfaceContainerHigh: const Color(0xFFE8E8F0),
|
||||
surfaceContainerHighest: const Color(0xFFDCDCE6),
|
||||
outlineVariant: const Color(0xFFD8D8E2),
|
||||
);
|
||||
}
|
||||
return base.copyWith(
|
||||
primary: seed,
|
||||
onPrimary: const Color(0xFF04283B),
|
||||
primaryContainer: const Color(0xFF0E4B6E),
|
||||
onPrimaryContainer: const Color(0xFFD3F0FF),
|
||||
surface: const Color(0xFF25252F),
|
||||
surfaceContainerLowest: const Color(0xFF1F1F28),
|
||||
surfaceContainerLow: const Color(0xFF2A2A35),
|
||||
surfaceContainer: const Color(0xFF2F2F3C),
|
||||
surfaceContainerHigh: const Color(0xFF3A3A49),
|
||||
surfaceContainerHighest: const Color(0xFF474757),
|
||||
onSurface: const Color(0xFFEDEDF3),
|
||||
onSurfaceVariant: const Color(0xFFB4B4C3),
|
||||
outline: const Color(0xFF74748A),
|
||||
outlineVariant: const Color(0xFF3F3F4F),
|
||||
error: const Color(0xFFF26B6B),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData _build(Brightness brightness) {
|
||||
final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: brightness);
|
||||
final outline = scheme.outlineVariant.withValues(alpha: brightness == Brightness.dark ? 0.4 : 0.7);
|
||||
final scheme = _scheme(brightness);
|
||||
final dark = brightness == Brightness.dark;
|
||||
final outline = scheme.outlineVariant;
|
||||
final radius = BorderRadius.circular(12);
|
||||
final fieldRadius = BorderRadius.circular(8);
|
||||
|
||||
OutlineInputBorder field([Color? color, double width = 1]) =>
|
||||
OutlineInputBorder(
|
||||
borderRadius: fieldRadius,
|
||||
borderSide: color == null
|
||||
? BorderSide.none
|
||||
: BorderSide(color: color, width: width),
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
@@ -39,34 +84,95 @@ class AppTheme {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: outline),
|
||||
borderRadius: radius,
|
||||
side: dark ? BorderSide.none : BorderSide(color: outline),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: outline, space: 1, thickness: 1),
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: scheme.surfaceContainer,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
backgroundColor: scheme.surfaceContainer,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainerHigh,
|
||||
border: field(),
|
||||
enabledBorder: field(),
|
||||
focusedBorder: field(scheme.primary, 1.5),
|
||||
errorBorder: field(scheme.error),
|
||||
focusedErrorBorder: field(scheme.error, 1.5),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: fieldRadius),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: fieldRadius),
|
||||
side: BorderSide(color: outline),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: fieldRadius),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: scheme.surfaceContainerHigh,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: fieldRadius),
|
||||
),
|
||||
tabBarTheme: TabBarThemeData(
|
||||
indicatorColor: scheme.primary,
|
||||
labelColor: scheme.onSurface,
|
||||
unselectedLabelColor: scheme.onSurfaceVariant,
|
||||
dividerColor: outline,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
popupMenuTheme: PopupMenuThemeData(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(borderRadius: radius),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: scheme.surfaceContainerLow,
|
||||
indicatorColor: scheme.primaryContainer,
|
||||
useIndicator: true,
|
||||
selectedIconTheme: IconThemeData(color: scheme.primary),
|
||||
unselectedIconTheme: IconThemeData(color: scheme.onSurfaceVariant),
|
||||
selectedLabelTextStyle: TextStyle(color: scheme.primary, fontWeight: FontWeight.w600),
|
||||
selectedLabelTextStyle: TextStyle(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedLabelTextStyle: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: scheme.surfaceContainerLow,
|
||||
backgroundColor: scheme.surfaceContainerLowest,
|
||||
indicatorColor: scheme.primaryContainer,
|
||||
elevation: 0,
|
||||
iconTheme: WidgetStateProperty.resolveWith(
|
||||
(states) => IconThemeData(
|
||||
color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant,
|
||||
color: states.contains(WidgetState.selected)
|
||||
? scheme.primary
|
||||
: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(states) => TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w600 : FontWeight.w400,
|
||||
color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant,
|
||||
fontWeight: states.contains(WidgetState.selected)
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: states.contains(WidgetState.selected)
|
||||
? scheme.primary
|
||||
: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,12 +6,17 @@ import 'package:flutter/material.dart';
|
||||
class ChartColors {
|
||||
const ChartColors._();
|
||||
|
||||
static const slot1Blue = Color(0xFF2A78D6);
|
||||
static const slot1Blue = Color(0xFF14AFFF);
|
||||
static const slot2Orange = Color(0xFFEB6834);
|
||||
static const slot3Aqua = Color(0xFF1BAF7A);
|
||||
static const slot4Yellow = Color(0xFFEDA100);
|
||||
static const slot5Magenta = Color(0xFFE87BA4);
|
||||
|
||||
/// Gain and loss, everywhere a number is signed (`signColor`): mint and coral, readable on
|
||||
/// both the graphite and the light surfaces.
|
||||
static const gain = Color(0xFF3DDC97);
|
||||
static const loss = Color(0xFFF26B6B);
|
||||
|
||||
/// This app's fixed assignment for cashflow charts.
|
||||
static const income = slot1Blue;
|
||||
static const expense = slot2Orange;
|
||||
|
||||
@@ -4,7 +4,9 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
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`.
|
||||
class ThemeModeController extends Notifier<ThemeMode> {
|
||||
|
||||
@@ -15,14 +15,15 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../auth/auth_controller.dart' show problemMessage;
|
||||
|
||||
String? asString(Object? v) => v == null ? null : (v is String ? v : v.toString());
|
||||
String? asString(Object? v) =>
|
||||
v == null ? null : (v is String ? v : v.toString());
|
||||
|
||||
int? asInt(Object? v) => switch (v) {
|
||||
null => null,
|
||||
final int i => i,
|
||||
final String s => int.tryParse(s),
|
||||
_ => null,
|
||||
};
|
||||
null => null,
|
||||
final int i => i,
|
||||
final String s => int.tryParse(s),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
bool asBool(Object? v) => v == true;
|
||||
|
||||
@@ -53,7 +54,8 @@ List<Map<String, dynamic>> asObjects(Object? v) => v is List
|
||||
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
|
||||
: 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) =>
|
||||
v is List ? v.map((e) => e.toString()).toList() : const <String>[];
|
||||
@@ -74,9 +76,9 @@ Decimal? asDecimal(Object? v) {
|
||||
/// Sum of decimal strings, exact — used for weight sums, where 0.1 + 0.2 in `double`
|
||||
/// would make a valid set look invalid.
|
||||
Decimal sumDecimals(Iterable<String> values) => values.fold(
|
||||
Decimal.zero,
|
||||
(acc, v) => acc + (Decimal.tryParse(v) ?? Decimal.zero),
|
||||
);
|
||||
Decimal.zero,
|
||||
(acc, v) => acc + (Decimal.tryParse(v) ?? Decimal.zero),
|
||||
);
|
||||
|
||||
/// RFC 7807 `detail` first, then a readable fallback. Never surfaces a raw [DioException].
|
||||
String apiErrorMessage(Object error) {
|
||||
|
||||
@@ -43,7 +43,8 @@ String ruMonthYear(DateTime d) {
|
||||
String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}';
|
||||
|
||||
/// `'2026-09'`, the `month` query parameter format the API expects.
|
||||
String monthKey(DateTime d) => '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
|
||||
String monthKey(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
|
||||
|
||||
/// Parses a `'YYYY-MM'` key back into a [DateTime] (first of month, UTC).
|
||||
DateTime parseMonthKey(String key) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -8,7 +8,12 @@ import '../auth/auth_controller.dart';
|
||||
/// a retry-capable error view otherwise. Keeps the loading/error boilerplate
|
||||
/// out of every screen that watches a provider.
|
||||
class AsyncValueView<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 Widget Function(T data) data;
|
||||
@@ -30,12 +35,19 @@ class AsyncValueView<T> extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(_message(error), textAlign: TextAlign.center),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(onPressed: onRetry, child: const Text('Повторить')),
|
||||
FilledButton.tonal(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,7 +2,11 @@ import 'package:flutter/material.dart';
|
||||
|
||||
/// A centered placeholder for a screen or list section with nothing to show yet.
|
||||
class EmptyState extends StatelessWidget {
|
||||
const EmptyState({required this.message, this.icon = Icons.inbox_outlined, super.key});
|
||||
const EmptyState({
|
||||
required this.message,
|
||||
this.icon = Icons.inbox_outlined,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final IconData icon;
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,7 @@ import 'package:intl/intl.dart';
|
||||
|
||||
/// Currency symbols for the instruments we expect to see. Falls back to the
|
||||
/// ISO code itself (e.g. 'USDT') when we don't have a nicer glyph.
|
||||
const _currencySymbols = {
|
||||
'RUB': '₽',
|
||||
'USD': '\$',
|
||||
'EUR': '€',
|
||||
};
|
||||
const _currencySymbols = {'RUB': '₽', 'USD': '\$', 'EUR': '€'};
|
||||
|
||||
/// Formats a decimal-string amount (as the API sends it, to avoid double
|
||||
/// rounding errors) with `intl`'s ru_RU rules and a currency symbol.
|
||||
@@ -23,7 +19,11 @@ class MoneyText extends StatelessWidget {
|
||||
static String format(String amount, String currency) {
|
||||
final value = Decimal.parse(amount).toDouble();
|
||||
final symbol = _currencySymbols[currency] ?? currency;
|
||||
final formatter = NumberFormat.currency(locale: 'ru_RU', symbol: symbol, decimalDigits: 2);
|
||||
final formatter = NumberFormat.currency(
|
||||
locale: 'ru_RU',
|
||||
symbol: symbol,
|
||||
decimalDigits: 2,
|
||||
);
|
||||
return formatter.format(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ class ScopeSelector extends ConsumerWidget {
|
||||
value: scopes,
|
||||
data: (rows) {
|
||||
if (rows.length < 2) return const SizedBox.shrink();
|
||||
final known = rows.any((s) => s.scope == current) ? current : rows.first.scope;
|
||||
final known = rows.any((s) => s.scope == current)
|
||||
? current
|
||||
: rows.first.scope;
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: known,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'help_tip.dart';
|
||||
|
||||
/// A titled card section, the layout unit the phase-4 screens are built from (the same
|
||||
/// shape Портфель already uses inline).
|
||||
class SectionCard extends StatelessWidget {
|
||||
const SectionCard({required this.title, required this.child, this.subtitle, this.trailing, super.key});
|
||||
const SectionCard({
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
@@ -25,13 +33,15 @@ class SectionCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
TermLabel(title, style: theme.textTheme.titleMedium),
|
||||
if (subtitle != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
subtitle!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -51,7 +61,13 @@ class SectionCard extends StatelessWidget {
|
||||
|
||||
/// A compact labelled number, used for the summary rows of the phase-4 screens.
|
||||
class StatTile extends StatelessWidget {
|
||||
const StatTile({required this.label, required this.value, this.note, this.width = 184, super.key});
|
||||
const StatTile({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.note,
|
||||
this.width = 184,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget value;
|
||||
@@ -69,14 +85,19 @@ class StatTile extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall),
|
||||
TermLabel(label, style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 4),
|
||||
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value),
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.titleMedium!,
|
||||
child: value,
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
note!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -23,7 +23,9 @@ class SectionHeader extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800),
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,11 +14,15 @@ class StaleBanner extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final at = fetchedAt.toLocal();
|
||||
final time = '${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}';
|
||||
final time =
|
||||
'${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(8)),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off, size: 18, color: scheme.onErrorContainer),
|
||||
|
||||
@@ -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('Создать'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,159 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/cache/cached.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../../core/widgets/stale_banner.dart';
|
||||
import 'account_create_dialog.dart';
|
||||
import 'actions.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
const _roleOrder = [AccountRole.liquid, AccountRole.savings, AccountRole.investment, AccountRole.debt];
|
||||
const _roleOrder = [
|
||||
AccountRole.liquid,
|
||||
AccountRole.savings,
|
||||
AccountRole.investment,
|
||||
AccountRole.debt,
|
||||
];
|
||||
|
||||
String _roleLabel(AccountRole role) => switch (role) {
|
||||
AccountRole.liquid => 'Ликвидные',
|
||||
AccountRole.savings => 'Сбережения',
|
||||
AccountRole.investment => 'Инвестиции',
|
||||
AccountRole.debt => 'Долги',
|
||||
AccountRole.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
AccountRole.liquid => 'Ликвидные',
|
||||
AccountRole.savings => 'Сбережения',
|
||||
AccountRole.investment => 'Инвестиции',
|
||||
AccountRole.debt => 'Долги',
|
||||
AccountRole.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
String _kindLabel(AccountKind kind) => switch (kind) {
|
||||
AccountKind.zmCash => 'Наличные',
|
||||
AccountKind.zmCard => 'Карта',
|
||||
AccountKind.zmChecking => 'Расчётный счёт',
|
||||
AccountKind.zmDeposit => 'Вклад',
|
||||
AccountKind.zmLoan => 'Кредит',
|
||||
AccountKind.zmEmoney => 'Электронные деньги',
|
||||
AccountKind.zmDebt => 'Долг',
|
||||
AccountKind.broker => 'Брокерский счёт',
|
||||
AccountKind.manualAsset => 'Актив вручную',
|
||||
AccountKind.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
String accountKindLabel(AccountKind kind) => switch (kind) {
|
||||
AccountKind.zmCash => 'Наличные',
|
||||
AccountKind.zmCard => 'Карта',
|
||||
AccountKind.zmChecking => 'Расчётный счёт',
|
||||
AccountKind.zmDeposit => 'Вклад',
|
||||
AccountKind.zmLoan => 'Кредит',
|
||||
AccountKind.zmEmoney => 'Электронные деньги',
|
||||
AccountKind.zmDebt => 'Долг',
|
||||
AccountKind.broker => 'Брокерский счёт',
|
||||
AccountKind.manualAsset => 'Актив вручную',
|
||||
AccountKind.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
/// Счета: every account grouped by role, with in-place role and
|
||||
/// include-in-net-worth edits (`PATCH /accounts/{id}`). Archived accounts
|
||||
/// collapse into their own section at the bottom regardless of role.
|
||||
class AccountsPage extends ConsumerWidget {
|
||||
/// Where an account comes from — the thing that tells a ZenMoney mirror from the broker
|
||||
/// account it mirrors when both are called «ИИС».
|
||||
String _sourceLabel(String source) => switch (source) {
|
||||
'zenmoney' => 'ZenMoney',
|
||||
'tinvest' => 'T-Invest',
|
||||
'report_sber' => 'Сбер, отчёты',
|
||||
'report_vtb' => 'ВТБ, отчёты',
|
||||
'csv' => 'CSV',
|
||||
_ => source,
|
||||
};
|
||||
|
||||
enum _Status {
|
||||
all('Все'),
|
||||
active('Активные'),
|
||||
disabled('Отключённые'),
|
||||
archived('Архивные');
|
||||
|
||||
const _Status(this.label);
|
||||
final String label;
|
||||
|
||||
bool matches(AccountOut a) => switch (this) {
|
||||
_Status.all => true,
|
||||
_Status.active => !a.archived && !a.disabled,
|
||||
_Status.disabled => a.disabled,
|
||||
_Status.archived => a.archived && !a.disabled,
|
||||
};
|
||||
}
|
||||
|
||||
/// Below this width a row is two lines instead of one.
|
||||
const _wideRow = 880.0;
|
||||
|
||||
/// Счета: every account in one compact list. The two switches that matter — «Активен» and
|
||||
/// «В капитал» — and the account type sit on the row itself, and a selection turns them into
|
||||
/// bulk actions: switching off a dozen old cards is one selection, not a dozen taps.
|
||||
class AccountsPage extends ConsumerStatefulWidget {
|
||||
const AccountsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<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 stale = oldestFetch([accounts.valueOrNull?.fetchedAt]);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Счета')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Счета'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Новый брокерский счёт',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => showAccountCreateDialog(context),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Портфели',
|
||||
icon: const Icon(Icons.pie_chart_outline),
|
||||
onPressed: () => context.go('/portfolios'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(accountsProvider),
|
||||
child: AsyncValueView(
|
||||
@@ -65,25 +171,107 @@ class AccountsPage extends ConsumerWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
final active = rows.where((a) => !a.archived).toList();
|
||||
final archived = rows.where((a) => a.archived).toList();
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (stale != null) StaleBanner(fetchedAt: stale),
|
||||
for (final role in _roleOrder)
|
||||
if (active.any((a) => a.role == role))
|
||||
_RoleSection(
|
||||
title: _roleLabel(role),
|
||||
accounts: active.where((a) => a.role == role).toList(),
|
||||
// a selection can outlive its account (a sync archived it, a filter hid it)
|
||||
_selected.retainAll({for (final a in rows) a.id});
|
||||
final visible = _visible(rows);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wide = constraints.maxWidth >= _wideRow;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (stale != null) StaleBanner(fetchedAt: stale),
|
||||
_Filters(
|
||||
rows: rows,
|
||||
status: _status,
|
||||
role: _role,
|
||||
onStatus: (s) => setState(() => _status = s),
|
||||
onRole: (r) => setState(() => _role = r),
|
||||
onQuery: (q) => setState(() => _query = q),
|
||||
),
|
||||
if (archived.isNotEmpty)
|
||||
ExpansionTile(
|
||||
title: Text('Архивные (${archived.length})'),
|
||||
initiallyExpanded: false,
|
||||
children: [for (final a in archived) _AccountTile(account: a)],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_selected.isNotEmpty) ...[
|
||||
_BulkBar(
|
||||
count: _selected.length,
|
||||
busy: _busy.isNotEmpty,
|
||||
onEnable: () => _apply(
|
||||
_selected,
|
||||
AccountPatch(disabled: false),
|
||||
clearSelection: true,
|
||||
),
|
||||
onDisable: () => _apply(
|
||||
_selected,
|
||||
AccountPatch(disabled: true),
|
||||
clearSelection: true,
|
||||
),
|
||||
onRole: (r) => _apply(
|
||||
_selected,
|
||||
AccountPatch(role: r),
|
||||
clearSelection: true,
|
||||
),
|
||||
onNetWorth: (v) => _apply(
|
||||
_selected,
|
||||
AccountPatch(includeInNetWorth: v),
|
||||
clearSelection: true,
|
||||
),
|
||||
onClear: () => setState(_selected.clear),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Card(
|
||||
child: visible.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: Text('Ничего не найдено')),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
if (wide)
|
||||
_HeaderRow(
|
||||
allSelected: visible.every(
|
||||
(a) => _selected.contains(a.id),
|
||||
),
|
||||
onToggleAll: (on) => setState(() {
|
||||
if (on) {
|
||||
_selected.addAll(
|
||||
visible.map((a) => a.id),
|
||||
);
|
||||
} else {
|
||||
_selected.removeAll(
|
||||
visible.map((a) => a.id),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
for (final a in visible)
|
||||
_AccountRow(
|
||||
account: a,
|
||||
wide: wide,
|
||||
selected: _selected.contains(a.id),
|
||||
busy: _busy.contains(a.id),
|
||||
onSelect: (on) => setState(() {
|
||||
if (on) {
|
||||
_selected.add(a.id);
|
||||
} else {
|
||||
_selected.remove(a.id);
|
||||
}
|
||||
}),
|
||||
onRole: (r) =>
|
||||
_apply([a.id], AccountPatch(role: r)),
|
||||
onNetWorth: (v) => _apply([
|
||||
a.id,
|
||||
], AccountPatch(includeInNetWorth: v)),
|
||||
onActive: (v) => _apply([
|
||||
a.id,
|
||||
], AccountPatch(disabled: !v)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -92,118 +280,445 @@ class AccountsPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleSection extends StatelessWidget {
|
||||
const _RoleSection({required this.title, required this.accounts});
|
||||
class _Filters extends StatelessWidget {
|
||||
const _Filters({
|
||||
required this.rows,
|
||||
required this.status,
|
||||
required this.role,
|
||||
required this.onStatus,
|
||||
required this.onRole,
|
||||
required this.onQuery,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<AccountOut> accounts;
|
||||
final List<AccountOut> rows;
|
||||
final _Status status;
|
||||
final AccountRole? role;
|
||||
final ValueChanged<_Status> onStatus;
|
||||
final ValueChanged<AccountRole?> onRole;
|
||||
final ValueChanged<String> onQuery;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: TextField(
|
||||
onChanged: onQuery,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Найти счёт…',
|
||||
isDense: true,
|
||||
prefixIcon: Icon(Icons.search, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final s in _Status.values)
|
||||
ChoiceChip(
|
||||
label: Text('${s.label} ${rows.where(s.matches).length}'),
|
||||
selected: s == status,
|
||||
onSelected: (_) => onStatus(s),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final r in _roleOrder)
|
||||
FilterChip(
|
||||
label: Text(_roleLabel(r)),
|
||||
selected: role == r,
|
||||
onSelected: (on) => onRole(on ? r : null),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// «Выбрано N» with what can be done to all of them at once.
|
||||
class _BulkBar extends StatelessWidget {
|
||||
const _BulkBar({
|
||||
required this.count,
|
||||
required this.busy,
|
||||
required this.onEnable,
|
||||
required this.onDisable,
|
||||
required this.onRole,
|
||||
required this.onNetWorth,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
final int count;
|
||||
final bool busy;
|
||||
final VoidCallback onEnable;
|
||||
final VoidCallback onDisable;
|
||||
final ValueChanged<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.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
||||
),
|
||||
for (final a in accounts) _AccountTile(account: a),
|
||||
Icon(Icons.arrow_drop_down, size: 18, color: color),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountTile extends ConsumerStatefulWidget {
|
||||
const _AccountTile({required this.account});
|
||||
const _typeWidth = 156.0;
|
||||
const _amountWidth = 150.0;
|
||||
const _switchWidth = 96.0;
|
||||
|
||||
final AccountOut account;
|
||||
class _HeaderRow extends StatelessWidget {
|
||||
const _HeaderRow({required this.allSelected, required this.onToggleAll});
|
||||
|
||||
@override
|
||||
ConsumerState<_AccountTile> createState() => _AccountTileState();
|
||||
}
|
||||
|
||||
class _AccountTileState extends ConsumerState<_AccountTile> {
|
||||
bool _saving = false;
|
||||
|
||||
Future<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);
|
||||
}
|
||||
}
|
||||
final bool allSelected;
|
||||
final ValueChanged<bool> onToggleAll;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final a = widget.account;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(a.name, style: Theme.of(context).textTheme.titleSmall),
|
||||
Text(
|
||||
'${_kindLabel(a.kind)} · ${a.currency}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MoneyText(
|
||||
a.balance ?? '0',
|
||||
currency: a.currency,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final style = Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(color: scheme.onSurfaceVariant);
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 16, 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: scheme.outlineVariant)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: allSelected,
|
||||
onChanged: (v) => onToggleAll(v ?? false),
|
||||
),
|
||||
Expanded(child: Text('Счёт', style: style)),
|
||||
SizedBox(
|
||||
width: _typeWidth,
|
||||
child: TermLabel('Тип', style: style),
|
||||
),
|
||||
SizedBox(
|
||||
width: _amountWidth,
|
||||
child: Text(
|
||||
'Баланс / стоимость',
|
||||
textAlign: TextAlign.end,
|
||||
style: style,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<AccountRole>(
|
||||
initialValue: a.role,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Роль', isDense: true),
|
||||
items: [
|
||||
for (final r in _roleOrder)
|
||||
DropdownMenuItem(value: r, child: Text(_roleLabel(r))),
|
||||
],
|
||||
onChanged: _saving ? null : (role) {
|
||||
if (role != null) _patch(AccountPatch(role: role));
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
),
|
||||
SizedBox(
|
||||
width: _switchWidth,
|
||||
child: TermLabel(
|
||||
'В капитал',
|
||||
style: style,
|
||||
alignment: MainAxisAlignment.center,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _switchWidth,
|
||||
child: TermLabel(
|
||||
'Активен',
|
||||
style: style,
|
||||
alignment: MainAxisAlignment.center,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountRow extends StatelessWidget {
|
||||
const _AccountRow({
|
||||
required this.account,
|
||||
required this.wide,
|
||||
required this.selected,
|
||||
required this.busy,
|
||||
required this.onSelect,
|
||||
required this.onRole,
|
||||
required this.onNetWorth,
|
||||
required this.onActive,
|
||||
});
|
||||
|
||||
final AccountOut account;
|
||||
final bool wide;
|
||||
final bool selected;
|
||||
final bool busy;
|
||||
final ValueChanged<bool> onSelect;
|
||||
final ValueChanged<AccountRole> onRole;
|
||||
final ValueChanged<bool> onNetWorth;
|
||||
final ValueChanged<bool> onActive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final a = account;
|
||||
final isBroker = a.kind == AccountKind.broker;
|
||||
// a broker account has no balance: what it is worth comes from the ledger valuation
|
||||
final amount = isBroker ? a.valueRub : a.balance;
|
||||
final dim = a.disabled || a.archived;
|
||||
|
||||
final title = Opacity(
|
||||
opacity: dim ? 0.55 : 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
a.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
[
|
||||
accountKindLabel(a.kind),
|
||||
a.currency,
|
||||
_sourceLabel(a.source_),
|
||||
if (a.archived) 'в архиве',
|
||||
].join(' · '),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final money = Opacity(
|
||||
opacity: dim ? 0.55 : 1,
|
||||
child: amount == null
|
||||
? Text('—', style: theme.textTheme.titleSmall)
|
||||
: MoneyText(
|
||||
amount,
|
||||
currency: isBroker ? 'RUB' : a.currency,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
);
|
||||
final role = _RoleChip(role: a.role, enabled: !busy, onChanged: onRole);
|
||||
final netWorth = _SwitchCell(
|
||||
label: 'В капитал',
|
||||
value: a.includeInNetWorth,
|
||||
enabled: !busy,
|
||||
onChanged: onNetWorth,
|
||||
showLabel: !wide,
|
||||
);
|
||||
final active = _SwitchCell(
|
||||
label: 'Активен',
|
||||
value: !a.disabled,
|
||||
enabled: !busy,
|
||||
onChanged: onActive,
|
||||
showLabel: !wide,
|
||||
);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? scheme.primary.withValues(alpha: 0.08) : null,
|
||||
border: Border(bottom: BorderSide(color: scheme.outlineVariant)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => onSelect(!selected),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, wide ? 8 : 6, 16, wide ? 8 : 6),
|
||||
child: wide
|
||||
? Row(
|
||||
children: [
|
||||
const Text('В капитал', style: TextStyle(fontSize: 11)),
|
||||
Switch(
|
||||
value: a.includeInNetWorth,
|
||||
onChanged: _saving
|
||||
? null
|
||||
: (v) => _patch(AccountPatch(includeInNetWorth: v)),
|
||||
Checkbox(
|
||||
value: selected,
|
||||
onChanged: (v) => onSelect(v ?? false),
|
||||
),
|
||||
Expanded(child: title),
|
||||
SizedBox(
|
||||
width: _typeWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: role,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _amountWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: money,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _switchWidth,
|
||||
child: Center(child: netWorth),
|
||||
),
|
||||
SizedBox(
|
||||
width: _switchWidth,
|
||||
child: Center(child: active),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: selected,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onChanged: (v) => onSelect(v ?? false),
|
||||
),
|
||||
Expanded(child: title),
|
||||
money,
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 40),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [role, netWorth, active],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The account type as a small pill that opens the four choices — one tap and one pick
|
||||
/// instead of a form-field dropdown per row.
|
||||
class _RoleChip extends StatelessWidget {
|
||||
const _RoleChip({
|
||||
required this.role,
|
||||
required this.enabled,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final AccountRole role;
|
||||
final bool enabled;
|
||||
final ValueChanged<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),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
_roleLabel(role),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 18,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -211,3 +726,41 @@ class _AccountTileState extends ConsumerState<_AccountTile> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SwitchCell extends StatelessWidget {
|
||||
const _SwitchCell({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.enabled,
|
||||
required this.onChanged,
|
||||
required this.showLabel,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool value;
|
||||
final bool enabled;
|
||||
final ValueChanged<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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +7,14 @@ import '../../core/cache/cached.dart';
|
||||
/// Every account, archived included — screens decide what to show. See
|
||||
/// `docs/ai/offline-cache.md`: Счета reads `.data` and shows the offline banner;
|
||||
/// `accountNamesProvider` and the other consumers (Транзакции, События) just unwrap `.data`.
|
||||
final accountsProvider = FutureProvider.autoDispose<Cached<List<AccountOut>>>((ref) async {
|
||||
final accountsProvider = FutureProvider.autoDispose<Cached<List<AccountOut>>>((
|
||||
ref,
|
||||
) async {
|
||||
final r = await ref.watch(apiProvider).getAccountsApi().accountsList();
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
/// `account_id -> name`, for screens that only carry the id (transactions, rules).
|
||||
|
||||
@@ -42,7 +42,12 @@ class EventRow extends StatelessWidget {
|
||||
leading: Icon(icon, color: color),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(eventTitle(e.kind, e.ticker), overflow: TextOverflow.ellipsis)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
eventTitle(e.kind, e.ticker),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (e.externalFlow) ...[
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../accounts/providers.dart';
|
||||
import '../portfolio/labels.dart' show formatQty, signColor;
|
||||
import 'event_row.dart';
|
||||
import 'labels.dart';
|
||||
import 'manual_event_dialog.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// События: the broker ledger with filters and infinite scroll — the screen that answers
|
||||
@@ -44,7 +49,8 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
|
||||
if (_scrollController.position.pixels >
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
ref.read(eventsControllerProvider.notifier).loadMore();
|
||||
}
|
||||
}
|
||||
@@ -52,8 +58,11 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
void _onSearchChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 400), () {
|
||||
ref.read(eventsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
|
||||
ref
|
||||
.read(eventsControllerProvider.notifier)
|
||||
.setFilter(
|
||||
(f) =>
|
||||
f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -71,16 +80,79 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
helpText: 'Период',
|
||||
);
|
||||
if (picked != null) {
|
||||
ref.read(eventsControllerProvider.notifier).setFilter(
|
||||
ref
|
||||
.read(eventsControllerProvider.notifier)
|
||||
.setFilter(
|
||||
(f) => f.copyWith(from: () => picked.start, to: () => picked.end),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearDateRange() {
|
||||
ref.read(eventsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(from: () => null, to: () => null),
|
||||
);
|
||||
ref
|
||||
.read(eventsControllerProvider.notifier)
|
||||
.setFilter((f) => f.copyWith(from: () => null, to: () => null));
|
||||
}
|
||||
|
||||
void _snack(String message) =>
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
/// Lots and every valuation are rebuilt from the ledger by the metrics refresh, so a change
|
||||
/// to the ledger asks for one. Best effort: the event itself is already saved.
|
||||
Future<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) {
|
||||
@@ -88,7 +160,16 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _EventDetailSheet(event: e, accountNames: accountNames),
|
||||
builder: (sheetContext) => _EventDetailSheet(
|
||||
event: e,
|
||||
accountNames: accountNames,
|
||||
onDelete: e.source_ == 'manual'
|
||||
? () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_deleteManual(e);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,16 +178,24 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
final state = ref.watch(eventsControllerProvider);
|
||||
final filter = ref.watch(eventsControllerProvider.notifier).filter;
|
||||
final accountNames = ref.watch(accountNamesProvider);
|
||||
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
|
||||
final accounts =
|
||||
ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _addManual,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Событие'),
|
||||
),
|
||||
appBar: AppBar(
|
||||
title: const Text('События'),
|
||||
actions: [
|
||||
if (state.total > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Center(child: Text('${state.items.length} из ${state.total}')),
|
||||
child: Center(
|
||||
child: Text('${state.items.length} из ${state.total}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -138,12 +227,17 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('${state.error}', textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.read(eventsControllerProvider.notifier).refresh(),
|
||||
onPressed: () =>
|
||||
ref.read(eventsControllerProvider.notifier).refresh(),
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
@@ -171,7 +265,9 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
child: state.loadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: TextButton(
|
||||
onPressed: () => ref.read(eventsControllerProvider.notifier).loadMore(),
|
||||
onPressed: () => ref
|
||||
.read(eventsControllerProvider.notifier)
|
||||
.loadMore(),
|
||||
child: const Text('Ещё'),
|
||||
),
|
||||
),
|
||||
@@ -246,51 +342,67 @@ class _Filters extends ConsumerWidget {
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<int?>(
|
||||
initialValue: filter.accountId,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
|
||||
decoration: const InputDecoration(labelText: 'Счёт'),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Все счета')),
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Все счета'),
|
||||
),
|
||||
for (final a in accounts)
|
||||
DropdownMenuItem(value: a.id, child: Text(a.name)),
|
||||
],
|
||||
onChanged: (v) => notifier.setFilter((f) => f.copyWith(accountId: () => v)),
|
||||
onChanged: (v) =>
|
||||
notifier.setFilter((f) => f.copyWith(accountId: () => v)),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<EventKind?>(
|
||||
initialValue: filter.kind,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Тип', isDense: true),
|
||||
decoration: const InputDecoration(labelText: 'Тип'),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Все типы')),
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Все типы'),
|
||||
),
|
||||
for (final k in filterableEventKinds)
|
||||
DropdownMenuItem(value: k, child: Text(eventKindLabel(k))),
|
||||
DropdownMenuItem(
|
||||
value: k,
|
||||
child: Text(eventKindLabel(k)),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => notifier.setFilter((f) => f.copyWith(kind: () => v)),
|
||||
onChanged: (v) =>
|
||||
notifier.setFilter((f) => f.copyWith(kind: () => v)),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<EventStatus?>(
|
||||
initialValue: filter.status,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Статус', isDense: true),
|
||||
decoration: const InputDecoration(labelText: 'Статус'),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Любой статус')),
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Любой статус'),
|
||||
),
|
||||
for (final s in EventStatus.values)
|
||||
if (s != EventStatus.unknownDefaultOpenApi)
|
||||
DropdownMenuItem(value: s, child: Text(eventStatusLabel(s))),
|
||||
DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(eventStatusLabel(s)),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => notifier.setFilter((f) => f.copyWith(status: () => v)),
|
||||
onChanged: (v) =>
|
||||
notifier.setFilter((f) => f.copyWith(status: () => v)),
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Внешние потоки'),
|
||||
tooltip: 'Только пополнения, выводы и переводы бумаг — то, что читает XIRR',
|
||||
selected: filter.externalFlow == true,
|
||||
onSelected: (on) =>
|
||||
notifier.setFilter((f) => f.copyWith(externalFlow: () => on ? true : null)),
|
||||
onSelected: (on) => notifier.setFilter(
|
||||
(f) => f.copyWith(externalFlow: () => on ? true : null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -301,31 +413,45 @@ class _Filters extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _EventDetailSheet extends StatelessWidget {
|
||||
const _EventDetailSheet({required this.event, required this.accountNames});
|
||||
const _EventDetailSheet({
|
||||
required this.event,
|
||||
required this.accountNames,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final EventOut event;
|
||||
final Map<int, String> accountNames;
|
||||
|
||||
/// Set only for events entered by hand: broker events come back with the next sync.
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = event;
|
||||
final theme = Theme.of(context);
|
||||
String money(String? v, String currency) => v == null ? '—' : MoneyText.format(v, currency);
|
||||
String money(String? v, String currency) =>
|
||||
v == null ? '—' : MoneyText.format(v, currency);
|
||||
final rows = <(String, String)>[
|
||||
('Дата сделки', ruDate(e.tradeDate)),
|
||||
('Время', '${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:'
|
||||
'${e.ts.minute.toString().padLeft(2, '0')}'),
|
||||
(
|
||||
'Время',
|
||||
'${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:'
|
||||
'${e.ts.minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
('Тип', eventKindLabel(e.kind)),
|
||||
('Статус', eventStatusLabel(e.status)),
|
||||
('Источник', eventSourceLabel(e.source_)),
|
||||
('Счёт', accountNames[e.accountId] ?? '#${e.accountId}'),
|
||||
if (e.ticker != null) ('Инструмент', e.ticker!),
|
||||
if (e.quantity != null) ('Количество', formatQty(e.quantity!)),
|
||||
if (e.price != null) ('Цена', money(e.price, e.priceCurrency ?? e.currency)),
|
||||
if (e.price != null)
|
||||
('Цена', money(e.price, e.priceCurrency ?? e.currency)),
|
||||
('Сумма', money(e.amount, e.currency)),
|
||||
('Сумма, ₽', money(e.amountRub, 'RUB')),
|
||||
if (e.fee != null) ('Комиссия', money(e.fee, e.currency)),
|
||||
if (e.tax != null) ('Налог', money(e.tax, e.currency)),
|
||||
if (e.accruedInterest != null) ('НКД', money(e.accruedInterest, e.currency)),
|
||||
if (e.accruedInterest != null)
|
||||
('НКД', money(e.accruedInterest, e.currency)),
|
||||
('Внешний поток', e.externalFlow ? 'да' : 'нет'),
|
||||
if (e.description != null) ('Описание', e.description!),
|
||||
];
|
||||
@@ -339,12 +465,16 @@ class _EventDetailSheet extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(eventTitle(e.kind, e.ticker), style: theme.textTheme.titleLarge),
|
||||
child: Text(
|
||||
eventTitle(e.kind, e.ticker),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
MoneyText.format(e.amount, e.currency),
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(color: signColor(context, e.amount)),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: signColor(context, e.amount),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -357,12 +487,23 @@ class _EventDetailSheet extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(label, style: theme.textTheme.bodySmall),
|
||||
child: TermLabel(label, style: theme.textTheme.bodySmall),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onDelete != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Удалить'),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (e.instrumentId != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
|
||||
@@ -35,31 +35,47 @@ const eventStatusLabels = {
|
||||
'ignored': 'Игнорируется',
|
||||
};
|
||||
|
||||
String eventStatusLabel(EventStatus status) => eventStatusLabels[status.value] ?? status.value;
|
||||
String eventStatusLabel(EventStatus status) =>
|
||||
eventStatusLabels[status.value] ?? status.value;
|
||||
|
||||
/// Icon and colour per kind, on the same principle as the transaction list: money coming
|
||||
/// in is green, money leaving is the error colour, everything structural is neutral.
|
||||
(IconData, Color) eventKindIcon(EventKind kind, ColorScheme scheme) => switch (kind) {
|
||||
(IconData, Color) eventKindIcon(EventKind kind, ColorScheme scheme) =>
|
||||
switch (kind) {
|
||||
EventKind.buy => (Icons.add_shopping_cart, scheme.primary),
|
||||
EventKind.sell => (Icons.sell_outlined, Colors.deepPurple),
|
||||
EventKind.dividend || EventKind.coupon || EventKind.interest => (
|
||||
Icons.payments_outlined,
|
||||
Colors.green,
|
||||
),
|
||||
EventKind.dividend ||
|
||||
EventKind.coupon ||
|
||||
EventKind.interest => (Icons.payments_outlined, Colors.green),
|
||||
EventKind.taxRefund => (Icons.assignment_return_outlined, Colors.green),
|
||||
EventKind.tax || EventKind.commission => (Icons.receipt_outlined, scheme.error),
|
||||
EventKind.tax ||
|
||||
EventKind.commission => (Icons.receipt_outlined, scheme.error),
|
||||
EventKind.deposit => (Icons.arrow_circle_down_outlined, Colors.green),
|
||||
EventKind.withdrawal => (Icons.arrow_circle_up_outlined, scheme.error),
|
||||
EventKind.transferIn || EventKind.transferOut => (Icons.swap_horiz, scheme.primary),
|
||||
EventKind.transferIn ||
|
||||
EventKind.transferOut => (Icons.swap_horiz, scheme.primary),
|
||||
EventKind.fxExchange => (Icons.currency_exchange, Colors.amber),
|
||||
EventKind.split || EventKind.amortization || EventKind.repayment => (
|
||||
Icons.call_split,
|
||||
scheme.outline,
|
||||
),
|
||||
EventKind.other || EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
|
||||
EventKind.split ||
|
||||
EventKind.amortization ||
|
||||
EventKind.repayment => (Icons.call_split, scheme.outline),
|
||||
EventKind.other ||
|
||||
EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
|
||||
};
|
||||
|
||||
/// `'Покупка · SBER'`, the one-line identity of a row. Events with no instrument behind
|
||||
/// them (deposits, fees) keep just the kind.
|
||||
String eventTitle(EventKind kind, String? ticker) =>
|
||||
ticker == null || ticker.isEmpty ? eventKindLabel(kind) : '${eventKindLabel(kind)} · $ticker';
|
||||
ticker == null || ticker.isEmpty
|
||||
? eventKindLabel(kind)
|
||||
: '${eventKindLabel(kind)} · $ticker';
|
||||
|
||||
/// Where a ledger event came from, as a person reads it.
|
||||
const eventSourceLabels = {
|
||||
'tinvest': 'T-Invest',
|
||||
'report_sber': 'Отчёт Сбера',
|
||||
'report_vtb': 'Отчёт ВТБ',
|
||||
'csv': 'CSV',
|
||||
'manual': 'Введено вручную',
|
||||
};
|
||||
|
||||
String eventSourceLabel(String source) => eventSourceLabels[source] ?? source;
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,10 @@ class EventsController extends Notifier<EventsState> {
|
||||
}
|
||||
|
||||
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,
|
||||
to: filter.to,
|
||||
accountId: filter.accountId,
|
||||
|
||||
@@ -20,9 +20,13 @@ import '../../core/widgets/tile_carousel.dart';
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../health/data_quality_list.dart' show severityColor;
|
||||
import 'providers.dart';
|
||||
import 'scope_cards.dart';
|
||||
|
||||
double _d(String s) => Decimal.parse(s).toDouble();
|
||||
|
||||
/// The refresh log row can be a run still in progress, which has no failed step yet.
|
||||
String _failedStepNote(String? step) => step == null ? '' : ' (шаг «$step»)';
|
||||
|
||||
/// Обзор: the dashboard landing page — net worth, this month's cashflow,
|
||||
/// runway, a net worth line chart, a 12-month income/expense bar chart, and
|
||||
/// a data-quality summary linking to the findings.
|
||||
@@ -39,10 +43,28 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
|
||||
final metrics = ref.read(apiProvider).getMetricsApi();
|
||||
await metrics
|
||||
.metricsRefresh(); // 202: only queued, the worker does the rebuild
|
||||
final done = await waitForMetricsRefresh(
|
||||
() async => (await metrics.metricsStatus()).data!,
|
||||
);
|
||||
if (mounted) {
|
||||
final message = done == null
|
||||
? 'Пересчёт идёт дольше обычного — данные обновятся, когда он закончится'
|
||||
: done.consistent
|
||||
? null
|
||||
: 'Пересчёт не завершён${_failedStepNote(done.lastRefresh?.failedStep)} — '
|
||||
'часть метрик может не сходиться';
|
||||
if (message != null) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
invalidateHomeProviders(ref);
|
||||
@@ -59,6 +81,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final status = ref.watch(metricsStatusProvider);
|
||||
final dataQuality = ref.watch(dataQualityProvider);
|
||||
final portfolio = ref.watch(portfolioSummaryHomeProvider);
|
||||
final cards = ref.watch(scopeCardsProvider);
|
||||
|
||||
final stale = oldestFetch([
|
||||
breakdown.valueOrNull?.fetchedAt,
|
||||
@@ -69,6 +92,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
status.valueOrNull?.fetchedAt,
|
||||
dataQuality.valueOrNull?.fetchedAt,
|
||||
portfolio.valueOrNull?.fetchedAt,
|
||||
cards.valueOrNull?.fetchedAt,
|
||||
]);
|
||||
|
||||
return Scaffold(
|
||||
@@ -79,7 +103,10 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
tooltip: 'Пересчитать метрики',
|
||||
icon: _refreshing
|
||||
? const SizedBox(
|
||||
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _refreshing ? null : _refresh,
|
||||
),
|
||||
@@ -98,12 +125,21 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
value: status,
|
||||
onRetry: () => ref.invalidate(metricsStatusProvider),
|
||||
data: (cached) {
|
||||
final log = cached.data;
|
||||
final status = cached.data;
|
||||
final log = status.lastRefresh;
|
||||
final at = log?.finishedAt ?? log?.startedAt;
|
||||
final text = at == null
|
||||
? 'Данные ещё не пересчитывались'
|
||||
: 'Данные на ${ruDate(at.toLocal())} ${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}';
|
||||
return Text(text, style: Theme.of(context).textTheme.bodySmall);
|
||||
final style = Theme.of(context).textTheme.bodySmall;
|
||||
if (status.consistent) return Text(text, style: style);
|
||||
return Text(
|
||||
'$text · пересчёт не завершён${_failedStepNote(log?.failedStep)}, '
|
||||
'часть метрик может не сходиться',
|
||||
style: style?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -113,20 +149,43 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final rows = cached.data;
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
|
||||
rows.isEmpty
|
||||
? Icons.check_circle_outline
|
||||
: Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: rows.isEmpty ? ChartColors.slot3Aqua : severityColor(rows.first.severity),
|
||||
color: rows.isEmpty
|
||||
? ChartColors.slot3Aqua
|
||||
: severityColor(rows.first.severity),
|
||||
),
|
||||
label: Text(
|
||||
rows.isEmpty ? 'ок' : '${rows.length} замечаний',
|
||||
),
|
||||
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
|
||||
// Health page renders the same findings via DataQualityList — no
|
||||
// second implementation of this list here.
|
||||
onPressed: rows.isEmpty ? null : () => context.go('/health?tab=quality'),
|
||||
onPressed: rows.isEmpty
|
||||
? null
|
||||
: () => context.go('/health?tab=quality'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
AsyncValueView(
|
||||
value: cards,
|
||||
onRetry: () => ref.invalidate(scopeCardsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
const SectionHeader(title: 'Портфели'),
|
||||
const SizedBox(height: 12),
|
||||
ScopeCards(cards: cached.data),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SectionHeader(title: 'Капитал'),
|
||||
const SizedBox(height: 12),
|
||||
AsyncValueView(
|
||||
@@ -146,7 +205,8 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
AsyncValueView(
|
||||
value: runway,
|
||||
onRetry: () => ref.invalidate(runwayProvider),
|
||||
data: (cached) => TileCarousel(children: [_RunwayTile(runway: cached.data)]),
|
||||
data: (cached) =>
|
||||
TileCarousel(children: [_RunwayTile(runway: cached.data)]),
|
||||
),
|
||||
AsyncValueView(
|
||||
value: portfolio,
|
||||
@@ -175,7 +235,10 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(netWorthSeriesProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
||||
? const EmptyState(
|
||||
icon: Icons.show_chart,
|
||||
message: 'Пока нет данных.',
|
||||
)
|
||||
: _NetWorthChart(rows: cached.data),
|
||||
),
|
||||
),
|
||||
@@ -188,7 +251,10 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
value: last12,
|
||||
onRetry: () => ref.invalidate(cashflowLast12Provider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
|
||||
? const EmptyState(
|
||||
icon: Icons.bar_chart,
|
||||
message: 'Пока нет данных.',
|
||||
)
|
||||
: _IncomeExpenseChart(rows: cached.data),
|
||||
),
|
||||
),
|
||||
@@ -204,7 +270,11 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [netWorthChart, const SizedBox(height: 16), cashflowChart],
|
||||
children: [
|
||||
netWorthChart,
|
||||
const SizedBox(height: 16),
|
||||
cashflowChart,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -230,13 +300,30 @@ class _NetWorthTiles extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TileCarousel(children: [
|
||||
StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')),
|
||||
StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, currency: 'RUB')),
|
||||
StatTile(label: 'Сбережения', value: MoneyText(breakdown.savingsRub, currency: 'RUB')),
|
||||
StatTile(label: 'Инвестиции', value: MoneyText(breakdown.investmentRub, currency: 'RUB')),
|
||||
StatTile(label: 'Долги', value: MoneyText(breakdown.debtRub, currency: 'RUB')),
|
||||
]);
|
||||
return TileCarousel(
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Капитал сегодня',
|
||||
value: MoneyText(breakdown.totalRub, currency: 'RUB'),
|
||||
),
|
||||
StatTile(
|
||||
label: 'Ликвидные',
|
||||
value: MoneyText(breakdown.liquidRub, currency: 'RUB'),
|
||||
),
|
||||
StatTile(
|
||||
label: 'Сбережения',
|
||||
value: MoneyText(breakdown.savingsRub, currency: 'RUB'),
|
||||
),
|
||||
StatTile(
|
||||
label: 'Инвестиции',
|
||||
value: MoneyText(breakdown.investmentRub, currency: 'RUB'),
|
||||
),
|
||||
StatTile(
|
||||
label: 'Долги',
|
||||
value: MoneyText(breakdown.debtRub, currency: 'RUB'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,14 +334,27 @@ class _MonthTiles extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (month == null) {
|
||||
return const EmptyState(icon: Icons.event_note_outlined, message: 'Данных за этот месяц нет.');
|
||||
return const EmptyState(
|
||||
icon: Icons.event_note_outlined,
|
||||
message: 'Данных за этот месяц нет.',
|
||||
);
|
||||
}
|
||||
final rateText = month!.savingsRate == null ? '—' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
|
||||
return TileCarousel(children: [
|
||||
StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')),
|
||||
StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')),
|
||||
StatTile(label: 'Норма сбережений', value: Text(rateText)),
|
||||
]);
|
||||
final rateText = month!.savingsRate == null
|
||||
? '—'
|
||||
: '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
|
||||
return TileCarousel(
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Доход в этом месяце',
|
||||
value: MoneyText(month!.incomeRub, currency: 'RUB'),
|
||||
),
|
||||
StatTile(
|
||||
label: 'Расход в этом месяце',
|
||||
value: MoneyText(month!.expenseRub, currency: 'RUB'),
|
||||
),
|
||||
StatTile(label: 'Норма сбережений', value: Text(rateText)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +378,8 @@ class _NetWorthChart extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spots = [
|
||||
for (var i = 0; i < rows.length; i++) FlSpot(i.toDouble(), _d(rows[i].totalRub)),
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
FlSpot(i.toDouble(), _d(rows[i].totalRub)),
|
||||
];
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
@@ -323,7 +424,11 @@ class _IncomeExpenseChart extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final maxY = rows.fold<double>(
|
||||
0,
|
||||
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b),
|
||||
(m, r) => [
|
||||
m,
|
||||
_d(r.incomeRub),
|
||||
_d(r.expenseRub),
|
||||
].reduce((a, b) => a > b ? a : b),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -343,19 +448,28 @@ class _IncomeExpenseChart extends StatelessWidget {
|
||||
gridData: const FlGridData(drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.toInt();
|
||||
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
|
||||
if (i < 0 || i >= rows.length)
|
||||
return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child:
|
||||
Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 9)),
|
||||
child: Text(
|
||||
ruMonthYearShort(rows[i].month),
|
||||
style: const TextStyle(fontSize: 9),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -377,8 +491,16 @@ class _IncomeExpenseChart extends StatelessWidget {
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(toY: _d(rows[i].incomeRub), color: ChartColors.income, width: 6),
|
||||
BarChartRodData(toY: _d(rows[i].expenseRub), color: ChartColors.expense, width: 6),
|
||||
BarChartRodData(
|
||||
toY: _d(rows[i].incomeRub),
|
||||
color: ChartColors.income,
|
||||
width: 6,
|
||||
),
|
||||
BarChartRodData(
|
||||
toY: _d(rows[i].expenseRub),
|
||||
color: ChartColors.expense,
|
||||
width: 6,
|
||||
),
|
||||
],
|
||||
barsSpace: 2,
|
||||
),
|
||||
@@ -401,7 +523,11 @@ class _LegendDot extends StatelessWidget {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
@@ -422,27 +548,23 @@ class _PortfolioTiles extends StatelessWidget {
|
||||
return InkWell(
|
||||
onTap: () => context.go('/portfolio'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: TileCarousel(children: [
|
||||
StatTile(
|
||||
label: 'Портфель',
|
||||
value: MoneyText(summary.totalRub, currency: 'RUB'),
|
||||
),
|
||||
StatTile(
|
||||
label: 'Прибыль',
|
||||
value: summary.pnlTotalRub == null
|
||||
// null, not zero: something in the portfolio has no price today
|
||||
? const Text('—')
|
||||
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
|
||||
),
|
||||
StatTile(
|
||||
label: 'XIRR, год',
|
||||
value: Text(_percent(yearly?.xirr)),
|
||||
),
|
||||
StatTile(
|
||||
label: 'TWR, год',
|
||||
value: Text(_percent(yearly?.twr)),
|
||||
),
|
||||
]),
|
||||
child: TileCarousel(
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Портфель',
|
||||
value: MoneyText(summary.totalRub, currency: 'RUB'),
|
||||
),
|
||||
StatTile(
|
||||
label: 'Прибыль',
|
||||
value: summary.pnlTotalRub == null
|
||||
// null, not zero: something in the portfolio has no price today
|
||||
? const Text('—')
|
||||
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
|
||||
),
|
||||
StatTile(label: 'XIRR, год', value: Text(_percent(yearly?.xirr))),
|
||||
StatTile(label: 'TWR, год', value: Text(_percent(yearly?.twr))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,54 +8,102 @@ import '../../core/cache/cached.dart';
|
||||
/// Every provider below returns `Cached<T>`, not `T`: `HomePage` reads
|
||||
/// `.data` for the tiles and `.fetchedAt` (via `oldestFetch`) for the one
|
||||
/// offline banner at the top. See `docs/ai/offline-cache.md`.
|
||||
final netWorthBreakdownProvider = FutureProvider.autoDispose<Cached<NetWorthBreakdown>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getNetworthApi().networthBreakdown();
|
||||
return r.cached;
|
||||
});
|
||||
final netWorthBreakdownProvider =
|
||||
FutureProvider.autoDispose<Cached<NetWorthBreakdown>>((ref) async {
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getNetworthApi()
|
||||
.networthBreakdown();
|
||||
return r.cached;
|
||||
});
|
||||
|
||||
/// Daily net worth for the last 365 days.
|
||||
final netWorthSeriesProvider = FutureProvider.autoDispose<Cached<List<NetWorthDay>>>((ref) async {
|
||||
final now = DateTime.now();
|
||||
final r = await ref.watch(apiProvider).getNetworthApi().networthSeries(
|
||||
from: now.subtract(const Duration(days: 365)),
|
||||
to: now,
|
||||
final netWorthSeriesProvider =
|
||||
FutureProvider.autoDispose<Cached<List<NetWorthDay>>>((ref) async {
|
||||
final now = DateTime.now();
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getNetworthApi()
|
||||
.networthSeries(
|
||||
from: now.subtract(const Duration(days: 365)),
|
||||
to: now,
|
||||
);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
});
|
||||
|
||||
/// The current (partial) month's cashflow, or null before any data exists.
|
||||
final cashflowThisMonthProvider = FutureProvider.autoDispose<Cached<CashFlowMonth?>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 1);
|
||||
final rows = r.data ?? const [];
|
||||
return Cached(rows.isEmpty ? null : rows.last, fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
final cashflowThisMonthProvider =
|
||||
FutureProvider.autoDispose<Cached<CashFlowMonth?>>((ref) async {
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getCashflowApi()
|
||||
.cashflowMonthly(months: 1);
|
||||
final rows = r.data ?? const [];
|
||||
return Cached(
|
||||
rows.isEmpty ? null : rows.last,
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
/// The last 12 months, for the income-vs-expense bar chart.
|
||||
final cashflowLast12Provider = FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 12);
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
final cashflowLast12Provider =
|
||||
FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async {
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getCashflowApi()
|
||||
.cashflowMonthly(months: 12);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
final runwayProvider = FutureProvider.autoDispose<Cached<RunwayOut>>((ref) async {
|
||||
final runwayProvider = FutureProvider.autoDispose<Cached<RunwayOut>>((
|
||||
ref,
|
||||
) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway();
|
||||
return r.cached;
|
||||
});
|
||||
|
||||
/// When the metric tables were last rebuilt; null before the first refresh.
|
||||
final metricsStatusProvider = FutureProvider.autoDispose<Cached<RefreshLogOut?>>((ref) async {
|
||||
try {
|
||||
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
|
||||
return r.cached;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return const Cached(null);
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
/// When the metric tables were last rebuilt, whether they are one consistent snapshot, and
|
||||
/// whether another rebuild is on its way.
|
||||
final metricsStatusProvider =
|
||||
FutureProvider.autoDispose<Cached<MetricsStatusOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
|
||||
return r.cached;
|
||||
});
|
||||
|
||||
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?);
|
||||
});
|
||||
/// Waits for a queued metrics rebuild to finish: `POST /metrics/refresh` only queues it, so the
|
||||
/// numbers are stale until `refreshing` turns false. Returns the last status seen, or null if
|
||||
/// [timeout] ran out first (a rebuild stuck behind a long sync, say).
|
||||
Future<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
|
||||
/// `POST /metrics/refresh` or a pull-to-refresh.
|
||||
@@ -68,16 +116,35 @@ void invalidateHomeProviders(WidgetRef ref) {
|
||||
ref.invalidate(metricsStatusProvider);
|
||||
ref.invalidate(dataQualityProvider);
|
||||
ref.invalidate(portfolioSummaryHomeProvider);
|
||||
ref.invalidate(scopeCardsProvider);
|
||||
}
|
||||
|
||||
/// The investment side of the dashboard: one scope-wide summary, `all` by default.
|
||||
/// Null when the ledger is empty, which is the normal state before a broker sync.
|
||||
final portfolioSummaryHomeProvider = FutureProvider.autoDispose<Cached<SummaryOut?>>((ref) async {
|
||||
try {
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary();
|
||||
return r.cached;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return const Cached(null);
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
final portfolioSummaryHomeProvider =
|
||||
FutureProvider.autoDispose<Cached<SummaryOut?>>((ref) async {
|
||||
try {
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsSummary();
|
||||
return r.cached;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return const Cached(null);
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
|
||||
/// One card per portfolio and account for the home grid (`GET /analytics/overview`): value,
|
||||
/// result, the last day's change, return and expected passive income, in one round trip.
|
||||
final scopeCardsProvider =
|
||||
FutureProvider.autoDispose<Cached<List<ScopeCardOut>>>((ref) async {
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsOverview();
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,21 +75,24 @@ class _DimensionCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onlyUnknown = buckets.every((b) => b.bucket == 'unknown' || b.bucket == 'cash');
|
||||
final onlyUnknown = buckets.every(
|
||||
(b) => b.bucket == 'unknown' || b.bucket == 'cash',
|
||||
);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(dimensionLabel(dimension), style: Theme.of(context).textTheme.titleMedium),
|
||||
Text(
|
||||
dimensionLabel(dimension),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if (onlyUnknown) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Атрибут не заполнен у инструментов — разрез пустой, а не нулевой.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
@@ -111,7 +114,9 @@ class _DimensionCard extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(children: [donut, const SizedBox(height: 12), legend]);
|
||||
return Column(
|
||||
children: [donut, const SizedBox(height: 12), legend],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -133,7 +138,10 @@ class _Donut extends StatelessWidget {
|
||||
// and the legend still lists it with its real value
|
||||
final positive = buckets.where((b) => _d(b.valueRub) > 0).toList();
|
||||
if (positive.isEmpty) {
|
||||
return const EmptyState(icon: Icons.donut_large_outlined, message: 'Нечего показать.');
|
||||
return const EmptyState(
|
||||
icon: Icons.donut_large_outlined,
|
||||
message: 'Нечего показать.',
|
||||
);
|
||||
}
|
||||
return PieChart(
|
||||
PieChartData(
|
||||
@@ -194,10 +202,18 @@ class _Legend extends StatelessWidget {
|
||||
if (buckets[i].holdingCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text('${buckets[i].holdingCount}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
|
||||
child: Text(
|
||||
'${buckets[i].holdingCount}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
MoneyText(buckets[i].valueRub, currency: 'RUB', style: theme.textTheme.bodyMedium),
|
||||
MoneyText(
|
||||
buckets[i].valueRub,
|
||||
currency: 'RUB',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 56,
|
||||
|
||||
@@ -10,16 +10,18 @@ import 'data/benchmarks_api.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
final benchmarksApiProvider =
|
||||
Provider<BenchmarksApi>((ref) => BenchmarksApi(ref.watch(apiProvider).dio));
|
||||
final benchmarksApiProvider = Provider<BenchmarksApi>(
|
||||
(ref) => BenchmarksApi(ref.watch(apiProvider).dio),
|
||||
);
|
||||
|
||||
/// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own:
|
||||
/// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject. See
|
||||
/// `docs/ai/offline-cache.md`.
|
||||
final benchmarkRowsProvider = FutureProvider.autoDispose<Cached<List<BenchmarkRow>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
return ref.watch(benchmarksApiProvider).compare(scope: scope);
|
||||
});
|
||||
final benchmarkRowsProvider =
|
||||
FutureProvider.autoDispose<Cached<List<BenchmarkRow>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
return ref.watch(benchmarksApiProvider).compare(scope: scope);
|
||||
});
|
||||
|
||||
/// The comparison block on Портфель.
|
||||
///
|
||||
@@ -73,13 +75,16 @@ class _PeriodBlock extends StatelessWidget {
|
||||
if (row.dateFrom != null && row.dateTo != null)
|
||||
Text(
|
||||
'${ruDate(row.dateFrom!)} – ${ruDate(row.dateTo!)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'портфель ${formatPercent(row.portfolioTwr)}',
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(color: signColor(context, row.portfolioTwr)),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: signColor(context, row.portfolioTwr),
|
||||
),
|
||||
),
|
||||
if (row.portfolioDaysSkipped > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
@@ -89,13 +94,14 @@ class _PeriodBlock extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final b in row.benchmarks) _BenchmarkRowView(result: b),
|
||||
if (row.hasSkippedDays)
|
||||
if (row.benchmarksSkipDays)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
'Сетка дат не полностью совпадает: часть дней пропущена, '
|
||||
'сравнение не строго like-for-like.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
'У индекса нет котировок в часть дней окна — сравнение не строго день в день.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 20),
|
||||
@@ -131,8 +137,9 @@ class _BenchmarkRowView extends StatelessWidget {
|
||||
child: Text(
|
||||
formatPercent(result.excess),
|
||||
textAlign: TextAlign.right,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: signColor(context, result.excess)),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: signColor(context, result.excess),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -148,7 +155,8 @@ class _PriceIndexChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: 'Ценовой индекс: не учитывает дивиденды и систематически занижает '
|
||||
message:
|
||||
'Ценовой индекс: не учитывает дивиденды и систематически занижает '
|
||||
'результат держателя. Сравнение с ним — нижняя граница, а не эталон.',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
@@ -156,7 +164,10 @@ class _PriceIndexChip extends StatelessWidget {
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text('ценовой индекс', style: Theme.of(context).textTheme.labelSmall),
|
||||
child: Text(
|
||||
'ценовой индекс',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -171,7 +182,8 @@ class _SkippedChip extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: 'В расчёте $side пропущено $days дн. — в эти дни не было цены',
|
||||
message:
|
||||
'В расчёте $side не учтено $days дн.: в эти дни у части позиций не было цены',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -42,14 +42,14 @@ class BenchmarkResult {
|
||||
bool get isPriceIndex => kind == 'price';
|
||||
|
||||
static BenchmarkResult fromJson(Map<String, dynamic> json) => BenchmarkResult(
|
||||
benchmarkId: asInt(json['benchmark_id']) ?? 0,
|
||||
code: asString(json['code']) ?? '—',
|
||||
kind: asString(json['kind']) ?? 'total_return',
|
||||
twr: asString(json['twr']),
|
||||
twrAnnualized: asString(json['twr_annualized']),
|
||||
daysSkipped: asInt(json['days_skipped']) ?? 0,
|
||||
excess: asString(json['excess']),
|
||||
);
|
||||
benchmarkId: asInt(json['benchmark_id']) ?? 0,
|
||||
code: asString(json['code']) ?? '—',
|
||||
kind: asString(json['kind']) ?? 'total_return',
|
||||
twr: asString(json['twr']),
|
||||
twrAnnualized: asString(json['twr_annualized']),
|
||||
daysSkipped: asInt(json['days_skipped']) ?? 0,
|
||||
excess: asString(json['excess']),
|
||||
);
|
||||
}
|
||||
|
||||
class BenchmarkRow {
|
||||
@@ -74,18 +74,22 @@ class BenchmarkRow {
|
||||
final int portfolioDaysSkipped;
|
||||
final List<BenchmarkResult> benchmarks;
|
||||
|
||||
bool get hasSkippedDays =>
|
||||
portfolioDaysSkipped > 0 || benchmarks.any((b) => b.daysSkipped > 0);
|
||||
bool get hasSkippedDays => portfolioDaysSkipped > 0 || benchmarksSkipDays;
|
||||
|
||||
/// Only the index side: the portfolio's own gaps are already marked next to its number.
|
||||
bool get benchmarksSkipDays => benchmarks.any((b) => b.daysSkipped > 0);
|
||||
|
||||
static BenchmarkRow fromJson(Map<String, dynamic> json) => BenchmarkRow(
|
||||
period: asString(json['period']) ?? 'all',
|
||||
dateFrom: asDate(json['date_from']),
|
||||
dateTo: asDate(json['date_to']),
|
||||
portfolioTwr: asString(json['portfolio_twr']),
|
||||
portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']),
|
||||
portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0,
|
||||
benchmarks: asObjects(json['benchmarks']).map(BenchmarkResult.fromJson).toList(),
|
||||
);
|
||||
period: asString(json['period']) ?? 'all',
|
||||
dateFrom: asDate(json['date_from']),
|
||||
dateTo: asDate(json['date_to']),
|
||||
portfolioTwr: asString(json['portfolio_twr']),
|
||||
portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']),
|
||||
portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0,
|
||||
benchmarks: asObjects(json['benchmarks'])
|
||||
.map(BenchmarkResult.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
class BenchmarksApi {
|
||||
@@ -104,7 +108,9 @@ class BenchmarksApi {
|
||||
// `period` is repeatable; Dio serialises a list as repeated query parameters.
|
||||
queryParameters: {'scope': scope, 'period': periods},
|
||||
);
|
||||
final rows = asObjects((r.data ?? const {})['rows']).map(BenchmarkRow.fromJson).toList();
|
||||
final rows = asObjects((r.data ?? const {})['rows'])
|
||||
.map(BenchmarkRow.fromJson)
|
||||
.toList();
|
||||
return Cached(rows, fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,45 @@ import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import 'benchmarks_card.dart';
|
||||
import 'holdings_table.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
double _d(String s) => Decimal.parse(s).toDouble();
|
||||
|
||||
/// Which parts of the screen a [HoldingsTab] draws. The same providers feed all of them, so
|
||||
/// the numbers on Портфель and on Аналитика → Общее can never disagree.
|
||||
enum HoldingsSections {
|
||||
/// Everything, top to bottom.
|
||||
all,
|
||||
|
||||
/// Only the assets table — the Портфель screen.
|
||||
table,
|
||||
|
||||
/// The charts and returns, without the table — Аналитика → Общее.
|
||||
overview,
|
||||
}
|
||||
|
||||
/// Позиции: what the portfolio holds, what it is worth, and what it earned.
|
||||
class HoldingsTab extends ConsumerWidget {
|
||||
const HoldingsTab({super.key});
|
||||
const HoldingsTab({
|
||||
this.sections = HoldingsSections.all,
|
||||
this.header,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final HoldingsSections sections;
|
||||
|
||||
/// Drawn above everything else, inside the same scrolling list.
|
||||
final Widget? header;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -26,58 +49,73 @@ class HoldingsTab extends ConsumerWidget {
|
||||
final holdings = ref.watch(holdingsProvider);
|
||||
final series = ref.watch(valueSeriesProvider);
|
||||
final returns = ref.watch(portfolioReturnsProvider);
|
||||
final all = sections == HoldingsSections.all;
|
||||
final withTable = all || sections == HoldingsSections.table;
|
||||
final withCharts = all || sections == HoldingsSections.overview;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => invalidatePortfolioProviders(ref),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
AsyncValueView(
|
||||
value: summary,
|
||||
onRetry: () => ref.invalidate(portfolioSummaryProvider),
|
||||
data: (cached) => _SummaryTiles(summary: cached.data),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_Card(
|
||||
title: 'Стоимость за 365 дней',
|
||||
child: AsyncValueView(
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(valueSeriesProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
||||
: _ValueChart(rows: cached.data),
|
||||
if (header != null) ...[header!, const SizedBox(height: 16)],
|
||||
if (all) ...[
|
||||
AsyncValueView(
|
||||
value: summary,
|
||||
onRetry: () => ref.invalidate(portfolioSummaryProvider),
|
||||
data: (cached) => _SummaryTiles(summary: cached.data),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Доходность',
|
||||
child: AsyncValueView(
|
||||
value: returns,
|
||||
onRetry: () => ref.invalidate(portfolioReturnsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.percent, message: 'Пока нечего считать.')
|
||||
: _ReturnsTable(rows: cached.data),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
if (withCharts) ...[
|
||||
_Card(
|
||||
title: 'Стоимость за 365 дней',
|
||||
child: AsyncValueView(
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(valueSeriesProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.show_chart,
|
||||
message: 'Пока нет данных.',
|
||||
)
|
||||
: _ValueChart(rows: cached.data),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _Card(
|
||||
title: 'Сравнение с бенчмарками',
|
||||
child: BenchmarksCard(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Позиции',
|
||||
child: AsyncValueView(
|
||||
value: holdings,
|
||||
onRetry: () => ref.invalidate(holdingsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
message: 'Открытых позиций нет — нужна синхронизация брокера.',
|
||||
)
|
||||
: _HoldingsTable(rows: cached.data),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Доходность',
|
||||
child: AsyncValueView(
|
||||
value: returns,
|
||||
onRetry: () => ref.invalidate(portfolioReturnsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.percent,
|
||||
message: 'Пока нечего считать.',
|
||||
)
|
||||
: _ReturnsTable(rows: cached.data),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _Card(
|
||||
title: 'Сравнение с бенчмарками',
|
||||
child: BenchmarksCard(),
|
||||
),
|
||||
],
|
||||
if (withTable && withCharts) const SizedBox(height: 16),
|
||||
if (withTable)
|
||||
_Card(
|
||||
title: all ? 'Позиции' : null,
|
||||
child: AsyncValueView(
|
||||
value: holdings,
|
||||
onRetry: () => ref.invalidate(holdingsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
message: 'Открытых позиций нет — нужна синхронизация брокера.',
|
||||
)
|
||||
: HoldingsTable(rows: cached.data),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -113,7 +151,9 @@ class _SummaryTiles extends StatelessWidget {
|
||||
: MoneyText(
|
||||
summary.pnlTotalRub!,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, summary.pnlTotalRub)),
|
||||
style: TextStyle(
|
||||
color: signColor(context, summary.pnlTotalRub),
|
||||
),
|
||||
),
|
||||
note: summary.pnlTotalRub == null
|
||||
? 'часть позиций без цены'
|
||||
@@ -171,14 +211,19 @@ class _Tile extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall),
|
||||
TermLabel(label, style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 4),
|
||||
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value),
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.titleMedium!,
|
||||
child: value,
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
note!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
@@ -224,7 +269,10 @@ class _ValueChart extends StatelessWidget {
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.round();
|
||||
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
|
||||
return Text(ruMonthYearShort(rows[i].d), style: theme.textTheme.bodySmall);
|
||||
return Text(
|
||||
ruMonthYearShort(rows[i].d),
|
||||
style: theme.textTheme.bodySmall,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -277,112 +325,78 @@ class _ReturnsTable extends StatelessWidget {
|
||||
child: DataTable(
|
||||
columns: [
|
||||
DataColumn(label: Text('Период', style: headerStyle)),
|
||||
DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Потоки', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('XIRR', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('TWR', style: headerStyle), numeric: true),
|
||||
DataColumn(
|
||||
label: TermLabel(
|
||||
'Прибыль',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: TermLabel(
|
||||
'Потоки',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: TermLabel(
|
||||
'XIRR',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: TermLabel(
|
||||
'TWR',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
],
|
||||
rows: [
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
cells: [
|
||||
DataCell(Text(periodLabel(r.period))),
|
||||
DataCell(MoneyText(
|
||||
r.absPnlRub,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, r.absPnlRub)),
|
||||
)),
|
||||
DataCell(
|
||||
MoneyText(
|
||||
r.absPnlRub,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, r.absPnlRub)),
|
||||
),
|
||||
),
|
||||
DataCell(MoneyText(r.externalFlowRub, currency: 'RUB')),
|
||||
DataCell(Text(
|
||||
formatPercent(r.xirr),
|
||||
style: TextStyle(color: signColor(context, r.xirr)),
|
||||
)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
formatPercent(r.twr),
|
||||
style: TextStyle(color: signColor(context, r.twr)),
|
||||
),
|
||||
if (r.twrDaysSkipped > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: 'Пропущено ${r.twrDaysSkipped} дн.: '
|
||||
'в эти дни часть позиции была без цены',
|
||||
child: const Icon(Icons.info_outline, size: 14),
|
||||
DataCell(
|
||||
Text(
|
||||
formatPercent(r.xirr),
|
||||
style: TextStyle(color: signColor(context, r.xirr)),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
formatPercent(r.twr),
|
||||
style: TextStyle(color: signColor(context, r.twr)),
|
||||
),
|
||||
if (r.twrDaysSkipped > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message:
|
||||
'Пропущено ${r.twrDaysSkipped} дн.: '
|
||||
'в эти дни часть позиции была без цены',
|
||||
child: const Icon(Icons.info_outline, size: 14),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoldingsTable extends StatelessWidget {
|
||||
const _HoldingsTable({required this.rows});
|
||||
|
||||
final List<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 {
|
||||
const _Card({required this.title, required this.child});
|
||||
const _Card({required this.child, this.title});
|
||||
|
||||
final String title;
|
||||
final String? title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@@ -405,8 +419,10 @@ class _Card extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
if (title != null) ...[
|
||||
TermLabel(title!, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
child,
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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('Сохранить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/asset_icon.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../../core/widgets/stale_banner.dart';
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import 'instrument_edit_dialog.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
@@ -23,15 +29,51 @@ class InstrumentPage extends ConsumerWidget {
|
||||
|
||||
final int instrumentId;
|
||||
|
||||
Future<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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final detail = ref.watch(instrumentProvider(instrumentId));
|
||||
final instrument = detail.valueOrNull?.data.instrument;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(detail.valueOrNull?.data.instrument.ticker ??
|
||||
detail.valueOrNull?.data.instrument.name ??
|
||||
'Инструмент'),
|
||||
title: Text(instrument?.ticker ?? instrument?.name ?? 'Инструмент'),
|
||||
actions: [
|
||||
if (instrument != null)
|
||||
IconButton(
|
||||
tooltip: 'Редактировать',
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => _edit(context, ref, instrument),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: AsyncValueView(
|
||||
value: detail,
|
||||
@@ -41,7 +83,8 @@ class InstrumentPage extends ConsumerWidget {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (cached.fetchedAt != null) StaleBanner(fetchedAt: cached.fetchedAt!),
|
||||
if (cached.fetchedAt != null)
|
||||
StaleBanner(fetchedAt: cached.fetchedAt!),
|
||||
_Header(instrument: d.instrument, holding: d.holding),
|
||||
const SizedBox(height: 16),
|
||||
_Section(
|
||||
@@ -57,14 +100,20 @@ class InstrumentPage extends ConsumerWidget {
|
||||
_Section(
|
||||
title: 'Лоты',
|
||||
child: d.lots.isEmpty
|
||||
? const EmptyState(icon: Icons.layers_outlined, message: 'Лотов нет.')
|
||||
? const EmptyState(
|
||||
icon: Icons.layers_outlined,
|
||||
message: 'Лотов нет.',
|
||||
)
|
||||
: _LotsTable(lots: d.lots),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Section(
|
||||
title: 'События',
|
||||
child: d.events.isEmpty
|
||||
? const EmptyState(icon: Icons.receipt_long, message: 'Событий нет.')
|
||||
? const EmptyState(
|
||||
icon: Icons.receipt_long,
|
||||
message: 'Событий нет.',
|
||||
)
|
||||
: _EventsTable(events: d.events),
|
||||
),
|
||||
],
|
||||
@@ -97,10 +146,32 @@ class _Header extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(instrument.name, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(facts.join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
|
||||
Row(
|
||||
children: [
|
||||
AssetIcon(
|
||||
assetClass: instrument.assetClass,
|
||||
logoUrl: instrument.logoUrl,
|
||||
logoColor: instrument.logoColor,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(instrument.name, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
facts.join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (holding == null)
|
||||
Text('Позиция закрыта', style: theme.textTheme.bodyMedium)
|
||||
@@ -139,7 +210,10 @@ class _HoldingFacts extends StatelessWidget {
|
||||
children: [
|
||||
h.marketPrice == null
|
||||
? const Text('—')
|
||||
: MoneyText(h.marketPrice!, currency: h.priceCurrency ?? h.currency),
|
||||
: MoneyText(
|
||||
h.marketPrice!,
|
||||
currency: h.priceCurrency ?? h.currency,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate),
|
||||
],
|
||||
@@ -147,7 +221,9 @@ class _HoldingFacts extends StatelessWidget {
|
||||
),
|
||||
_Fact(
|
||||
label: 'Стоимость',
|
||||
value: h.valueRub == null ? const Text('—') : MoneyText(h.valueRub!, currency: 'RUB'),
|
||||
value: h.valueRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(h.valueRub!, currency: 'RUB'),
|
||||
),
|
||||
_Fact(
|
||||
label: 'Нереализованная',
|
||||
@@ -156,14 +232,19 @@ class _HoldingFacts extends StatelessWidget {
|
||||
: MoneyText(
|
||||
h.unrealizedPnlRub!,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, h.unrealizedPnlRub)),
|
||||
style: TextStyle(
|
||||
color: signColor(context, h.unrealizedPnlRub),
|
||||
),
|
||||
),
|
||||
),
|
||||
_Fact(
|
||||
label: 'Реализованная',
|
||||
value: MoneyText(h.realizedPnlRub ?? '0', currency: 'RUB'),
|
||||
),
|
||||
_Fact(label: 'Выплаты', value: MoneyText(h.incomeRub ?? '0', currency: 'RUB')),
|
||||
_Fact(
|
||||
label: 'Выплаты',
|
||||
value: MoneyText(h.incomeRub ?? '0', currency: 'RUB'),
|
||||
),
|
||||
_Fact(
|
||||
label: 'XIRR',
|
||||
value: Text(
|
||||
@@ -195,7 +276,10 @@ class _Fact extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
|
||||
TermLabel(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
DefaultTextStyle(style: theme.textTheme.titleSmall!, child: value),
|
||||
],
|
||||
@@ -230,8 +314,12 @@ class _PriceChart extends StatelessWidget {
|
||||
interval: (prices.length / 4).clamp(1, double.infinity),
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.round();
|
||||
if (i < 0 || i >= prices.length) return const SizedBox.shrink();
|
||||
return Text(ruMonthYearShort(prices[i].d), style: theme.textTheme.bodySmall);
|
||||
if (i < 0 || i >= prices.length)
|
||||
return const SizedBox.shrink();
|
||||
return Text(
|
||||
ruMonthYearShort(prices[i].d),
|
||||
style: theme.textTheme.bodySmall,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -245,7 +333,7 @@ class _PriceChart extends StatelessWidget {
|
||||
isCurved: false,
|
||||
color: ChartColors.slot1Blue,
|
||||
barWidth: 2,
|
||||
dotData: const FlDotData(show: false),
|
||||
dotData: FlDotData(show: prices.length < 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -268,9 +356,15 @@ class _LotsTable extends StatelessWidget {
|
||||
columns: [
|
||||
DataColumn(label: Text('Открыт', style: headerStyle)),
|
||||
DataColumn(label: Text('Куплено', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Осталось', style: headerStyle), numeric: true),
|
||||
DataColumn(
|
||||
label: Text('Осталось', style: headerStyle),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(label: Text('Цена', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Стоимость, ₽', style: headerStyle), numeric: true),
|
||||
DataColumn(
|
||||
label: Text('Стоимость, ₽', style: headerStyle),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(label: Text('Закрыт', style: headerStyle)),
|
||||
],
|
||||
rows: [
|
||||
@@ -280,11 +374,17 @@ class _LotsTable extends StatelessWidget {
|
||||
DataCell(Text(ruDate(lot.openDate))),
|
||||
DataCell(Text(formatQty(lot.qtyOpen))),
|
||||
DataCell(Text(formatQty(lot.qtyRemaining))),
|
||||
DataCell(MoneyText(lot.costPerUnit, currency: lot.costCurrency)),
|
||||
DataCell(lot.costTotalRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(lot.costTotalRub!, currency: 'RUB')),
|
||||
DataCell(Text(lot.closedAt == null ? '—' : ruDate(lot.closedAt!))),
|
||||
DataCell(
|
||||
MoneyText(lot.costPerUnit, currency: lot.costCurrency),
|
||||
),
|
||||
DataCell(
|
||||
lot.costTotalRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(lot.costTotalRub!, currency: 'RUB'),
|
||||
),
|
||||
DataCell(
|
||||
Text(lot.closedAt == null ? '—' : ruDate(lot.closedAt!)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -317,32 +417,48 @@ class _EventsTable extends StatelessWidget {
|
||||
DataRow(
|
||||
cells: [
|
||||
DataCell(Text(ruDate(e.tradeDate))),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(eventKindLabel(e.kind)),
|
||||
if (e.externalFlow) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Tooltip(
|
||||
message: 'Внешний поток — учитывается в XIRR',
|
||||
child: Icon(Icons.swap_vert, size: 14),
|
||||
),
|
||||
DataCell(
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(eventKindLabel(e.kind)),
|
||||
if (e.externalFlow) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Tooltip(
|
||||
message: 'Внешний поток — учитывается в XIRR',
|
||||
child: Icon(Icons.swap_vert, size: 14),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
)),
|
||||
DataCell(Text(e.quantity == null ? '—' : formatQty(e.quantity!))),
|
||||
DataCell(e.price == null
|
||||
? const Text('—')
|
||||
: MoneyText(e.price!, currency: e.priceCurrency ?? e.currency)),
|
||||
DataCell(MoneyText(
|
||||
e.amount,
|
||||
currency: e.currency,
|
||||
style: TextStyle(color: signColor(context, e.amount)),
|
||||
)),
|
||||
DataCell(SizedBox(
|
||||
width: 260,
|
||||
child: Text(e.description ?? '', overflow: TextOverflow.ellipsis),
|
||||
)),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(e.quantity == null ? '—' : formatQty(e.quantity!)),
|
||||
),
|
||||
DataCell(
|
||||
e.price == null
|
||||
? const Text('—')
|
||||
: MoneyText(
|
||||
e.price!,
|
||||
currency: e.priceCurrency ?? e.currency,
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
MoneyText(
|
||||
e.amount,
|
||||
currency: e.currency,
|
||||
style: TextStyle(color: signColor(context, e.amount)),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: Text(
|
||||
e.description ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -38,7 +38,8 @@ const dimensionLabels = {
|
||||
'currency': 'Валюта',
|
||||
};
|
||||
|
||||
String assetClassLabel(String? value) => assetClassLabels[value] ?? value ?? '—';
|
||||
String assetClassLabel(String? value) =>
|
||||
assetClassLabels[value] ?? value ?? '—';
|
||||
|
||||
/// A bucket key as a person reads it. `cash` and `unknown` are the two literals the
|
||||
/// allocation step emits; everything else is the source's own value.
|
||||
@@ -89,7 +90,8 @@ const eventKindLabels = {
|
||||
'other': 'Прочее',
|
||||
};
|
||||
|
||||
String eventKindLabel(EventKind kind) => eventKindLabels[kind.value] ?? kind.value;
|
||||
String eventKindLabel(EventKind kind) =>
|
||||
eventKindLabels[kind.value] ?? kind.value;
|
||||
|
||||
/// `'0.1234'` as `'+12,34 %'`. Null becomes an em dash: a return nobody could compute is
|
||||
/// not zero percent.
|
||||
@@ -111,13 +113,17 @@ Color? signColor(BuildContext context, String? value) {
|
||||
if (value == null) return null;
|
||||
final d = Decimal.parse(value);
|
||||
if (d == Decimal.zero) return null;
|
||||
return d > Decimal.zero ? ChartColors.slot3Aqua : ChartColors.slot2Orange;
|
||||
return d > Decimal.zero ? ChartColors.gain : ChartColors.loss;
|
||||
}
|
||||
|
||||
/// The price-status chip every value on screen depends on: a stale price still produces a
|
||||
/// number, a missing one produces nothing at all, and both must be visible.
|
||||
class PriceStatusChip extends StatelessWidget {
|
||||
const PriceStatusChip({required this.status, required this.priceDate, super.key});
|
||||
const PriceStatusChip({
|
||||
required this.status,
|
||||
required this.priceDate,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final DateTime? priceDate;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/cache/cached.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/stale_banner.dart';
|
||||
import 'allocation_tab.dart';
|
||||
import 'benchmarks_card.dart';
|
||||
import 'holdings_tab.dart';
|
||||
import 'providers.dart';
|
||||
import 'scope_selector.dart';
|
||||
|
||||
/// Портфель: позиции и аллокация as two tabs of one screen, sharing one scope.
|
||||
///
|
||||
@@ -37,9 +36,12 @@ class PortfolioPage extends ConsumerWidget {
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Портфель'),
|
||||
actions: const [_ScopeSelector(), SizedBox(width: 8)],
|
||||
actions: const [ScopeSelector(), SizedBox(width: 8)],
|
||||
bottom: const TabBar(
|
||||
tabs: [Tab(text: 'Позиции'), Tab(text: 'Аллокация')],
|
||||
tabs: [
|
||||
Tab(text: 'Позиции'),
|
||||
Tab(text: 'Аллокация'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
@@ -50,7 +52,12 @@ class PortfolioPage extends ConsumerWidget {
|
||||
child: StaleBanner(fetchedAt: stale),
|
||||
),
|
||||
const Expanded(
|
||||
child: TabBarView(children: [HoldingsTab(), AllocationTab()]),
|
||||
child: TabBarView(
|
||||
children: [
|
||||
HoldingsTab(sections: HoldingsSections.table),
|
||||
AllocationTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -58,39 +65,3 @@ class PortfolioPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches every portfolio screen at once. Hidden while there is nothing to choose
|
||||
/// between — a dropdown with one option is furniture, not a control.
|
||||
class _ScopeSelector extends ConsumerWidget {
|
||||
const _ScopeSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scopes = ref.watch(scopesProvider);
|
||||
final current = ref.watch(scopeProvider);
|
||||
|
||||
return AsyncValueView<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;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,51 +20,86 @@ final scopesProvider = FutureProvider.autoDispose<List<ScopeOut>>((ref) async {
|
||||
});
|
||||
|
||||
/// 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 r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsSummary(scope: scope);
|
||||
return r.cached;
|
||||
},
|
||||
);
|
||||
|
||||
final holdingsProvider = FutureProvider.autoDispose<Cached<List<HoldingOut>>>((
|
||||
ref,
|
||||
) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(scope: scope);
|
||||
return r.cached;
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsHoldings(scope: scope);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
final holdingsProvider = FutureProvider.autoDispose<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 portfolioReturnsProvider =
|
||||
FutureProvider.autoDispose<Cached<List<ReturnsOut>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsReturns(scope: scope);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
final portfolioReturnsProvider = FutureProvider.autoDispose<Cached<List<ReturnsOut>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsReturns(scope: scope);
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
|
||||
final allocationProvider = FutureProvider.autoDispose<Cached<List<AllocationBucket>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsAllocation(scope: scope);
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
final allocationProvider =
|
||||
FutureProvider.autoDispose<Cached<List<AllocationBucket>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsAllocation(scope: scope);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
/// Daily portfolio value for the last year, for the chart on Позиции.
|
||||
final valueSeriesProvider = FutureProvider.autoDispose<Cached<List<ValueDay>>>((ref) async {
|
||||
final valueSeriesProvider = FutureProvider.autoDispose<Cached<List<ValueDay>>>((
|
||||
ref,
|
||||
) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final now = DateTime.now();
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsValueSeries(
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsValueSeries(
|
||||
scope: scope,
|
||||
from: now.subtract(const Duration(days: 365)),
|
||||
to: now,
|
||||
);
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
final instrumentProvider =
|
||||
FutureProvider.autoDispose.family<Cached<InstrumentDetail>, int>((ref, instrumentId) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getInstrumentsApi()
|
||||
.instrumentsGet(instrumentId: instrumentId, scope: scope);
|
||||
return r.cached;
|
||||
});
|
||||
final instrumentProvider = FutureProvider.autoDispose
|
||||
.family<Cached<InstrumentDetail>, int>((ref, instrumentId) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getInstrumentsApi()
|
||||
.instrumentsGet(instrumentId: instrumentId, scope: scope);
|
||||
return r.cached;
|
||||
});
|
||||
|
||||
/// Every portfolio provider, refreshed together after a metrics rebuild or a pull-to-refresh.
|
||||
void invalidatePortfolioProviders(WidgetRef ref) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Аналитика: a hub for the phase-4 screens.
|
||||
import '../portfolio/holdings_tab.dart';
|
||||
import '../portfolio/overview_tiles.dart';
|
||||
import '../portfolio/scope_selector.dart';
|
||||
|
||||
/// Аналитика → Общее. On a wide surface it is Snowball's overview — the four headline figures
|
||||
/// for the chosen scope, then the value chart, returns and benchmark comparison; the other
|
||||
/// analytics screens are one tab away (`AnalyticsTabs`). On a phone there is no tab strip, so
|
||||
/// the same entry is a list of the sections instead.
|
||||
///
|
||||
/// Why a hub instead of four more destinations: the shell already carried eleven, which on
|
||||
/// a phone leaves ~36 px per label in the bottom bar. Доходы, Ребалансировка, Цели and
|
||||
/// Налоги are all "what do I do with the portfolio next" questions, so they live behind one
|
||||
/// entry that highlights for all four (see `alsoMatches` in `app_shell.dart`), while each
|
||||
/// keeps its own top-level route from the contract (`/income`, `/rebalance`, `/goals`,
|
||||
/// `/tax`) and is deep-linkable.
|
||||
class AnalyticsHubPage extends StatelessWidget {
|
||||
/// The sections keep their own top-level routes from the contract (`/income`, `/rebalance`,
|
||||
/// `/goals`, `/tax`) and are deep-linkable; `alsoMatches` in `nav_destinations.dart` keeps
|
||||
/// Аналитика highlighted inside any of them.
|
||||
class AnalyticsHubPage extends ConsumerWidget {
|
||||
const AnalyticsHubPage({super.key});
|
||||
|
||||
static const _entries = [
|
||||
@@ -23,7 +28,8 @@ class AnalyticsHubPage extends StatelessWidget {
|
||||
path: '/rebalance',
|
||||
icon: Icons.balance,
|
||||
title: 'Ребалансировка',
|
||||
subtitle: 'Целевые веса портфеля и рекомендации, что докупить или продать',
|
||||
subtitle:
|
||||
'Целевые веса портфеля и рекомендации, что докупить или продать',
|
||||
),
|
||||
(
|
||||
path: '/goals',
|
||||
@@ -39,6 +45,43 @@ class AnalyticsHubPage extends StatelessWidget {
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (MediaQuery.sizeOf(context).width >= 600) return const _Overview();
|
||||
return const _SectionList();
|
||||
}
|
||||
}
|
||||
|
||||
class _Overview extends StatelessWidget {
|
||||
const _Overview();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ScopeSelector(),
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: HoldingsTab(
|
||||
sections: HoldingsSections.overview,
|
||||
header: OverviewTiles(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionList extends StatelessWidget {
|
||||
const _SectionList();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -46,7 +89,7 @@ class AnalyticsHubPage extends StatelessWidget {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final e in _entries)
|
||||
for (final e in AnalyticsHubPage._entries)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Icon(e.icon),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'analytics_tabs.dart';
|
||||
import 'nav_destinations.dart';
|
||||
import 'nav_sidebar.dart';
|
||||
import 'top_nav.dart';
|
||||
|
||||
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 600–1200,
|
||||
/// extended rail at 1200 and above.
|
||||
/// Below this width the shell falls back to a bottom [NavigationBar]; from here up it is the
|
||||
/// [TopNav] bar.
|
||||
const _narrowBreakpoint = 600.0;
|
||||
const _wideBreakpoint = 1200.0;
|
||||
|
||||
/// Adaptive navigation shell around the current route: a bottom
|
||||
/// [NavigationBar] on narrow surfaces, a collapsed [NavigationRail] on medium
|
||||
/// ones, and the grouped [NavSidebar] on wide ones.
|
||||
/// Adaptive navigation shell around the current route: a bottom [NavigationBar] on narrow
|
||||
/// surfaces and the [TopNav] bar on wider ones, with the Аналитика tab strip under it while
|
||||
/// one of the analytics screens is open.
|
||||
///
|
||||
/// The rail and the sidebar show every destination. The bottom bar shows the four primary
|
||||
/// ones plus «Ещё», which opens the rest in a sheet: twelve destinations in a phone-width
|
||||
/// bar would leave about 30 px per label, which is not navigation but decoration.
|
||||
/// The top bar shows every destination (the ledger ones behind «Операции»). The bottom bar
|
||||
/// shows the four primary ones plus «Ещё», which opens the rest in a sheet: twelve
|
||||
/// destinations in a phone-width bar would leave about 30 px per label, which is not
|
||||
/// navigation but decoration.
|
||||
class AppShell extends StatelessWidget {
|
||||
const AppShell({required this.location, required this.child, super.key});
|
||||
|
||||
@@ -80,28 +81,32 @@ class AppShell extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final extended = width >= _wideBreakpoint;
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
body: Column(
|
||||
children: [
|
||||
if (extended)
|
||||
NavSidebar(selectedIndex: _selectedIndex, onSelect: (i) => _onSelect(context, i))
|
||||
else
|
||||
NavigationRail(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (i) => _onSelect(context, i),
|
||||
labelType: NavigationRailLabelType.selected,
|
||||
destinations: [
|
||||
for (final d in navDestinations)
|
||||
NavigationRailDestination(
|
||||
icon: Icon(d.icon),
|
||||
selectedIcon: Icon(d.selectedIcon),
|
||||
label: Text(d.label),
|
||||
),
|
||||
],
|
||||
TopNav(
|
||||
selectedIndex: _selectedIndex,
|
||||
onSelect: (i) => _onSelect(context, i),
|
||||
),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kContentMaxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (AnalyticsTabs.contains(location))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: AnalyticsTabs(location: location),
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: SafeArea(child: child)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The three groups the sidebar buckets destinations under (Grimmory-style caps headers).
|
||||
/// Order here is display order, top to bottom.
|
||||
/// The groups destinations belong to: the main tabs, the ledger screens behind «Операции», and
|
||||
/// the system ones (Здоровье, Настройки) that sit as icons on the right of the top bar.
|
||||
enum NavGroup { overview, ledger, system }
|
||||
|
||||
const navGroupLabels = {
|
||||
NavGroup.overview: 'ОБЗОР',
|
||||
NavGroup.ledger: 'ОПЕРАЦИИ',
|
||||
NavGroup.system: 'СИСТЕМА',
|
||||
};
|
||||
|
||||
class NavDestination {
|
||||
const NavDestination(this.path, this.icon, this.selectedIcon, this.label, this.group,
|
||||
{this.alsoMatches = const [], this.primary = false});
|
||||
const NavDestination(
|
||||
this.path,
|
||||
this.icon,
|
||||
this.selectedIcon,
|
||||
this.label,
|
||||
this.group, {
|
||||
this.alsoMatches = const [],
|
||||
this.primary = false,
|
||||
});
|
||||
final String path;
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
@@ -39,28 +40,97 @@ class NavDestination {
|
||||
}
|
||||
|
||||
const navDestinations = [
|
||||
NavDestination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', NavGroup.overview,
|
||||
primary: true),
|
||||
NavDestination(
|
||||
'/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета', NavGroup.overview,
|
||||
primary: true),
|
||||
'/',
|
||||
Icons.dashboard_outlined,
|
||||
Icons.dashboard,
|
||||
'Обзор',
|
||||
NavGroup.overview,
|
||||
primary: true,
|
||||
),
|
||||
// One entry for the phase-4 screens. They keep their own top-level routes from the
|
||||
// contract and stay deep-linkable; `/analytics` is what the bars point at, and
|
||||
// `alsoMatches` keeps it highlighted while you are inside any of them.
|
||||
NavDestination(
|
||||
'/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель', NavGroup.overview,
|
||||
primary: true),
|
||||
// One entry for the four phase-4 screens. They keep their own top-level routes from the
|
||||
// contract and stay deep-linkable; the hub at `/analytics` is what the bottom bar and the
|
||||
// rail point at, and `alsoMatches` keeps it highlighted while you are inside any of them.
|
||||
NavDestination('/analytics', Icons.insights_outlined, Icons.insights, 'Аналитика', NavGroup.overview,
|
||||
alsoMatches: ['/income', '/rebalance', '/goals', '/tax'], primary: true),
|
||||
NavDestination('/events', Icons.event_note_outlined, Icons.event_note, 'События', NavGroup.ledger),
|
||||
NavDestination('/imports', Icons.upload_file_outlined, Icons.upload_file, 'Импорт', NavGroup.ledger,
|
||||
alsoMatches: ['/instruments/pending']),
|
||||
NavDestination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки', NavGroup.ledger),
|
||||
NavDestination('/categories', Icons.category_outlined, Icons.category, 'Категории', NavGroup.ledger),
|
||||
'/analytics',
|
||||
Icons.insights_outlined,
|
||||
Icons.insights,
|
||||
'Аналитика',
|
||||
NavGroup.overview,
|
||||
alsoMatches: ['/income', '/rebalance', '/goals', '/tax', '/portfolios'],
|
||||
primary: true,
|
||||
),
|
||||
NavDestination(
|
||||
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции', NavGroup.ledger),
|
||||
NavDestination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила', NavGroup.ledger),
|
||||
'/portfolio',
|
||||
Icons.pie_chart_outline,
|
||||
Icons.pie_chart,
|
||||
'Портфель',
|
||||
NavGroup.overview,
|
||||
primary: true,
|
||||
),
|
||||
NavDestination(
|
||||
'/health', Icons.monitor_heart_outlined, Icons.monitor_heart, 'Здоровье', NavGroup.system),
|
||||
NavDestination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки', NavGroup.system),
|
||||
'/accounts',
|
||||
Icons.account_balance_outlined,
|
||||
Icons.account_balance,
|
||||
'Счета',
|
||||
NavGroup.overview,
|
||||
primary: true,
|
||||
),
|
||||
NavDestination(
|
||||
'/events',
|
||||
Icons.event_note_outlined,
|
||||
Icons.event_note,
|
||||
'События',
|
||||
NavGroup.ledger,
|
||||
),
|
||||
NavDestination(
|
||||
'/imports',
|
||||
Icons.upload_file_outlined,
|
||||
Icons.upload_file,
|
||||
'Импорт',
|
||||
NavGroup.ledger,
|
||||
alsoMatches: ['/instruments/pending'],
|
||||
),
|
||||
NavDestination(
|
||||
'/cashflow',
|
||||
Icons.swap_horiz_outlined,
|
||||
Icons.swap_horiz,
|
||||
'Потоки',
|
||||
NavGroup.ledger,
|
||||
),
|
||||
NavDestination(
|
||||
'/categories',
|
||||
Icons.category_outlined,
|
||||
Icons.category,
|
||||
'Категории',
|
||||
NavGroup.ledger,
|
||||
),
|
||||
NavDestination(
|
||||
'/transactions',
|
||||
Icons.receipt_long_outlined,
|
||||
Icons.receipt_long,
|
||||
'Операции',
|
||||
NavGroup.ledger,
|
||||
),
|
||||
NavDestination(
|
||||
'/rules',
|
||||
Icons.rule_folder_outlined,
|
||||
Icons.rule_folder,
|
||||
'Правила',
|
||||
NavGroup.ledger,
|
||||
),
|
||||
NavDestination(
|
||||
'/health',
|
||||
Icons.monitor_heart_outlined,
|
||||
Icons.monitor_heart,
|
||||
'Здоровье',
|
||||
NavGroup.system,
|
||||
),
|
||||
NavDestination(
|
||||
'/settings',
|
||||
Icons.settings_outlined,
|
||||
Icons.settings,
|
||||
'Настройки',
|
||||
NavGroup.system,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -17,6 +17,7 @@ import 'features/login/login_page.dart';
|
||||
import 'features/pending/pending_page.dart';
|
||||
import 'features/portfolio/instrument_page.dart';
|
||||
import 'features/portfolio/portfolio_page.dart';
|
||||
import 'features/portfolios/portfolios_page.dart';
|
||||
import 'features/rebalance/rebalance_page.dart';
|
||||
import 'features/rules/rules_page.dart';
|
||||
import 'features/settings/settings_page.dart';
|
||||
@@ -53,14 +54,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(path: '/portfolio', builder: (_, _) => const PortfolioPage()),
|
||||
GoRoute(
|
||||
path: '/portfolio/instrument/:id',
|
||||
builder: (_, state) =>
|
||||
InstrumentPage(instrumentId: int.parse(state.pathParameters['id']!)),
|
||||
builder: (_, state) => InstrumentPage(
|
||||
instrumentId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
// Аналитика: one navigation destination, four contract routes. The hub is what
|
||||
// the shell points at; each screen below keeps its own top-level path and stays
|
||||
// deep-linkable (see `alsoMatches` in `app_shell.dart`).
|
||||
GoRoute(path: '/analytics', builder: (_, _) => const AnalyticsHubPage()),
|
||||
GoRoute(
|
||||
path: '/analytics',
|
||||
builder: (_, _) => const AnalyticsHubPage(),
|
||||
),
|
||||
GoRoute(path: '/income', builder: (_, _) => const IncomePage()),
|
||||
GoRoute(
|
||||
path: '/portfolios',
|
||||
builder: (_, _) => const PortfoliosPage(),
|
||||
),
|
||||
GoRoute(path: '/rebalance', builder: (_, _) => const RebalancePage()),
|
||||
GoRoute(path: '/goals', builder: (_, _) => const GoalsPage()),
|
||||
GoRoute(path: '/tax', builder: (_, _) => const TaxPage()),
|
||||
@@ -68,8 +77,9 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(path: '/imports', builder: (_, _) => const ImportsPage()),
|
||||
GoRoute(
|
||||
path: '/imports/:id',
|
||||
builder: (_, state) =>
|
||||
ImportPreviewPage(importId: int.parse(state.pathParameters['id']!)),
|
||||
builder: (_, state) => ImportPreviewPage(
|
||||
importId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/instruments/pending',
|
||||
@@ -78,9 +88,14 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(path: '/cashflow', builder: (_, _) => const CashflowPage()),
|
||||
GoRoute(
|
||||
path: '/categories',
|
||||
builder: (_, state) => CategoriesPage(initialMonth: state.uri.queryParameters['month']),
|
||||
builder: (_, state) => CategoriesPage(
|
||||
initialMonth: state.uri.queryParameters['month'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/transactions',
|
||||
builder: (_, _) => const TransactionsPage(),
|
||||
),
|
||||
GoRoute(path: '/transactions', builder: (_, _) => const TransactionsPage()),
|
||||
GoRoute(path: '/rules', builder: (_, _) => const RulesPage()),
|
||||
GoRoute(
|
||||
path: '/health',
|
||||
|
||||
Reference in New Issue
Block a user