feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков

Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
This commit is contained in:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Аналитика: a hub for the phase-4 screens.
///
/// 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 {
const AnalyticsHubPage({super.key});
static const _entries = [
(
path: '/income',
icon: Icons.payments_outlined,
title: 'Доходы',
subtitle: 'Календарь дивидендов и купонов, история выплат, прогноз на 12 месяцев',
),
(
path: '/rebalance',
icon: Icons.balance,
title: 'Ребалансировка',
subtitle: 'Целевые веса портфеля и рекомендации, что докупить или продать',
),
(
path: '/goals',
icon: Icons.flag_outlined,
title: 'Цели',
subtitle: 'Накопительные цели и прогноз их достижения по текущему тренду',
),
(
path: '/tax',
icon: Icons.receipt_long_outlined,
title: 'Налоги',
subtitle: 'Оценка налога за год и лоты с датой ЛДВ — для сверки со справкой брокера',
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Аналитика')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final e in _entries)
Card(
child: ListTile(
leading: Icon(e.icon),
title: Text(e.title),
subtitle: Text(e.subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go(e.path),
),
),
const SizedBox(height: 12),
Text(
'Сравнение с бенчмарками живёт на экране «Портфель»: обгон индекса — '
'свойство портфеля, а не отдельная тема.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
+91 -11
View File
@@ -2,18 +2,46 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class _Destination {
const _Destination(this.path, this.icon, this.selectedIcon, this.label);
const _Destination(this.path, this.icon, this.selectedIcon, this.label,
{this.alsoMatches = const [], this.primary = false});
final String path;
final IconData icon;
final IconData selectedIcon;
final String label;
/// Extra route prefixes that belong to this destination but do not start with [path] —
/// `/instruments/pending` is reached from Импорт and must keep it highlighted, and the
/// four phase-4 screens are reached from Аналитика the same way.
final List<String> alsoMatches;
/// Shown directly in the bottom bar on a phone. Everything else moves behind «Ещё».
final bool primary;
/// The longest prefix of [location] this destination claims, or -1 for no match.
int matchLength(String location) {
var best = -1;
for (final p in [path, ...alsoMatches]) {
final hit = p == '/' ? location == '/' : location.startsWith(p);
if (hit && p.length > best) best = p.length;
}
return best;
}
}
const _destinations = [
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'),
_Destination('/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель'),
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', primary: true),
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета',
primary: true),
_Destination('/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель',
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.
_Destination('/analytics', Icons.insights_outlined, Icons.insights, 'Аналитика',
alsoMatches: ['/income', '/rebalance', '/goals', '/tax'], primary: true),
_Destination('/events', Icons.event_note_outlined, Icons.event_note, 'События'),
_Destination('/imports', Icons.upload_file_outlined, Icons.upload_file, 'Импорт',
alsoMatches: ['/instruments/pending']),
_Destination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'),
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
_Destination(
@@ -31,6 +59,10 @@ const _wideBreakpoint = 1200.0;
/// Adaptive navigation shell around the current route: a bottom
/// [NavigationBar] on narrow surfaces, a [NavigationRail] (collapsed or
/// extended) otherwise.
///
/// The rail shows 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.
class AppShell extends StatelessWidget {
const AppShell({required this.location, required this.child, super.key});
@@ -40,12 +72,15 @@ class AppShell extends StatelessWidget {
int get _selectedIndex {
// longest prefix wins, so /portfolio/instrument/311 keeps Портфель selected
var best = -1;
var bestLength = -1;
for (var i = 0; i < _destinations.length; i++) {
final path = _destinations[i].path;
final matches = path == '/' ? location == '/' : location.startsWith(path);
if (matches && (best == -1 || path.length > _destinations[best].path.length)) best = i;
final length = _destinations[i].matchLength(location);
if (length > bestLength) {
best = i;
bestLength = length;
}
}
return best == -1 ? 0 : best;
return best == -1 || bestLength < 0 ? 0 : best;
}
void _onSelect(BuildContext context, int index) {
@@ -57,18 +92,36 @@ class AppShell extends StatelessWidget {
final width = MediaQuery.sizeOf(context).width;
if (width < _narrowBreakpoint) {
final primary = _destinations.where((d) => d.primary).toList();
final selected = _destinations[_selectedIndex];
final primaryIndex = primary.indexOf(selected);
return Scaffold(
body: SafeArea(child: child),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => _onSelect(context, i),
// nothing primary matches ⇒ we are inside a screen that lives behind «Ещё»,
// and «Ещё» is what should look active
selectedIndex: primaryIndex >= 0 ? primaryIndex : primary.length,
onDestinationSelected: (i) {
if (i >= primary.length) {
_showMore(context, selected);
} else {
final target = _destinations.indexOf(primary[i]);
_onSelect(context, target);
}
},
destinations: [
for (final d in _destinations)
for (final d in primary)
NavigationDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: d.label,
),
NavigationDestination(
icon: const Icon(Icons.more_horiz),
selectedIcon: const Icon(Icons.more_horiz),
label: primaryIndex >= 0 ? 'Ещё' : selected.label,
),
],
),
);
@@ -99,4 +152,31 @@ class AppShell extends StatelessWidget {
),
);
}
/// The non-primary destinations, as a sheet. The currently open one is ticked, so «Ещё»
/// still answers "where am I".
void _showMore(BuildContext context, _Destination current) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (final d in _destinations.where((d) => !d.primary))
ListTile(
leading: Icon(d == current ? d.selectedIcon : d.icon),
title: Text(d.label),
selected: d == current,
trailing: d == current ? const Icon(Icons.check) : null,
onTap: () {
Navigator.of(sheetContext).pop();
if (d != current) context.go(d.path);
},
),
],
),
),
);
}
}