Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar на узком. Токены в flutter_secure_storage, на web access живёт в памяти. Интерцептор подставляет токен и делает ровно один refresh на 401. Деньги приходят строками и форматируются через Decimal: парсить их в double значило бы терять копейки ровно там, где бэкенд их бережёт.
95 lines
3.2 KiB
Dart
95 lines
3.2 KiB
Dart
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 600–1200,
|
||
/// 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)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|