Files
fin-tracker/app/lib/features/shell/app_shell.dart
T
Dmitry b69bb4a0c9 feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
2026-09-19 10:44:38 +03:00

183 lines
7.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class _Destination {
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, 'Обзор', 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(
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции'),
_Destination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила'),
_Destination('/sync', Icons.sync_outlined, Icons.sync, 'Синк'),
_Destination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки'),
];
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 6001200,
/// extended rail at 1200 and above.
const _narrowBreakpoint = 600.0;
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});
final String location;
final Widget child;
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 length = _destinations[i].matchLength(location);
if (length > bestLength) {
best = i;
bestLength = length;
}
}
return best == -1 || bestLength < 0 ? 0 : best;
}
void _onSelect(BuildContext context, int index) {
if (index != _selectedIndex) context.go(_destinations[index].path);
}
@override
Widget build(BuildContext context) {
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(
// 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 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,
),
],
),
);
}
final extended = width >= _wideBreakpoint;
return Scaffold(
body: Row(
children: [
NavigationRail(
extended: extended,
minExtendedWidth: 220,
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => _onSelect(context, i),
labelType: extended ? NavigationRailLabelType.none : NavigationRailLabelType.selected,
destinations: [
for (final d in _destinations)
NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: Text(d.label),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: SafeArea(child: child)),
],
),
);
}
/// 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);
},
),
],
),
),
);
}
}