feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила

Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar
на узком. Токены в flutter_secure_storage, на web access живёт в памяти.
Интерцептор подставляет токен и делает ровно один refresh на 401.

Деньги приходят строками и форматируются через Decimal: парсить их в double
значило бы терять копейки ровно там, где бэкенд их бережёт.
This commit is contained in:
Dmitry
2026-09-18 13:44:09 +03:00
parent b9c12fa1a1
commit ce0966fca2
99 changed files with 6284 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class _Destination {
const _Destination(this.path, this.icon, this.selectedIcon, this.label);
final String path;
final IconData icon;
final IconData selectedIcon;
final String label;
}
const _destinations = [
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'),
_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.
class AppShell extends StatelessWidget {
const AppShell({required this.location, required this.child, super.key});
final String location;
final Widget child;
int get _selectedIndex {
final i = _destinations.indexWhere((d) => d.path == location);
return i == -1 ? 0 : i;
}
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) {
return Scaffold(
body: SafeArea(child: child),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => _onSelect(context, i),
destinations: [
for (final d in _destinations)
NavigationDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: d.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)),
],
),
);
}
}