import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../core/providers.dart'; import '../core/theme.dart'; import 'account_sheet.dart'; import 'auto_refresh.dart'; import 'ui.dart'; class _Tab { const _Tab(this.path, this.label, this.icon, this.iconOn); final String path, label; final IconData icon, iconOn; } const _tabs = [ _Tab( '/results', 'Results', Icons.emoji_events_outlined, Icons.emoji_events_rounded, ), _Tab('/shops', 'Shops', Icons.storefront_outlined, Icons.storefront_rounded), _Tab( '/orders', 'Orders', Icons.receipt_long_outlined, Icons.receipt_long_rounded, ), _Tab('/users', 'Users', Icons.people_outline_rounded, Icons.people_rounded), ]; /// App frame. The same sections at every width; only the navigation changes: /// a tab bar on phones, a rail on tablets, a sidebar on desktop. class MainShell extends ConsumerWidget { const MainShell({super.key, required this.location, required this.child}); final String location; final Widget child; @override Widget build(BuildContext context, WidgetRef ref) { final user = ref.watch(sessionProvider); final tabs = user?.isAdmin == true ? _tabs : _tabs.sublist(0, 3); final width = MediaQuery.sizeOf(context).width; final current = tabs.where((t) => location.startsWith(t.path)).firstOrNull; return AutoRefresh(location: location, child: _frame(width, tabs, current)); } Widget _frame(double width, List<_Tab> tabs, _Tab? current) { if (width >= Bp.medium) { return Scaffold( body: Row( children: [ _Sidebar( tabs: tabs, current: current, extended: width >= Bp.expanded, ), Expanded(child: SafeArea(child: child)), ], ), ); } // Phones: the tab bar stays visible on detail screens too, so people // always know which section they are in (HIG, Tab bars). return Scaffold( body: SafeArea(bottom: false, child: child), bottomNavigationBar: _BottomBar(tabs: tabs, current: current), ); } } // ------------------------------------------------------------ phone tab bar class _BottomBar extends StatelessWidget { const _BottomBar({required this.tabs, required this.current}); final List<_Tab> tabs; final _Tab? current; @override Widget build(BuildContext context) => Container( decoration: const BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow(color: C.shadowNav, blurRadius: 20, offset: Offset(0, -4)), ], ), child: SafeArea( top: false, child: SizedBox( height: 72, child: Row( children: [ for (final t in tabs) Expanded( child: _TabItem( tab: t, active: t == current, onTap: () => context.go(t.path), ), ), ], ), ), ), ); } class _TabItem extends StatelessWidget { const _TabItem({ required this.tab, required this.active, required this.onTap, }); final _Tab tab; final bool active; final VoidCallback onTap; @override Widget build(BuildContext context) => Pressable( onTap: onTap, radius: R.md, builder: (_, hover) => Semantics( selected: active, // Scales down rather than overflowing while the window has no real // size yet (Chrome reports a near-zero window for a frame or two). child: FittedBox( fit: BoxFit.scaleDown, child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedContainer( duration: const Duration(milliseconds: 180), width: 56, height: 32, decoration: BoxDecoration( color: active ? C.chip : (hover ? C.hover : Colors.transparent), borderRadius: BorderRadius.circular(R.pill), ), child: Icon( active ? tab.iconOn : tab.icon, size: 22, color: active ? C.navy : C.muted, ), ), const SizedBox(height: S.s4), Text( tab.label, style: ts(11, w: active ? w7 : w5, c: active ? C.navy : C.muted), ), ], ), ), ), ); } // --------------------------------------------------------- rail and sidebar class _Sidebar extends ConsumerWidget { const _Sidebar({ required this.tabs, required this.current, required this.extended, }); final List<_Tab> tabs; final _Tab? current; final bool extended; @override Widget build(BuildContext context, WidgetRef ref) { final user = ref.watch(sessionProvider); return AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, width: extended ? 248 : 88, decoration: const BoxDecoration( color: Colors.white, border: Border(right: BorderSide(color: C.border)), ), child: SafeArea( right: false, // Scrolls on short windows (a phone in landscape); otherwise the // account button sits at the bottom. child: LayoutBuilder( builder: (context, c) => SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minHeight: c.maxHeight), child: IntrinsicHeight( child: Padding( padding: EdgeInsets.symmetric( horizontal: extended ? S.s16 : S.s12, vertical: S.s20, ), child: Column( crossAxisAlignment: extended ? CrossAxisAlignment.stretch : CrossAxisAlignment.center, children: [ _Brand(extended: extended), const SizedBox(height: S.s28), for (final t in tabs) ...[ _SideItem( tab: t, active: t == current, extended: extended, onTap: () => context.go(t.path), ), const SizedBox(height: S.s4), ], const Spacer(), if (user != null) _AccountButton( name: user.username, role: user.isAdmin ? 'Admin' : 'User', extended: extended, onTap: () => showAccountSheet(context, ref), ), ], ), ), ), ), ), ), ), ); } } /// The gold ticket coin from the sign-in screen, carried into the frame. class _Brand extends StatelessWidget { const _Brand({required this.extended}); final bool extended; @override Widget build(BuildContext context) { final coin = Container( width: 40, height: 40, decoration: const BoxDecoration(color: C.gold, shape: BoxShape.circle), child: const Icon( Icons.confirmation_number_rounded, color: C.navy, size: 20, ), ); if (!extended) return coin; return Row( children: [ coin, const SizedBox(width: S.s12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'SRS', style: ts(18, w: w7, c: C.navy, ls: 1.5), ), Text( 'Lottery agency', maxLines: 1, overflow: TextOverflow.ellipsis, style: ts(12, c: C.muted), ), ], ), ), ], ); } } class _SideItem extends StatelessWidget { const _SideItem({ required this.tab, required this.active, required this.extended, required this.onTap, }); final _Tab tab; final bool active, extended; final VoidCallback onTap; @override Widget build(BuildContext context) { final icon = Icon( active ? tab.iconOn : tab.icon, size: 22, color: active ? C.navy : C.muted, ); final label = Text( tab.label, maxLines: 1, style: ts( extended ? 14 : 11, w: active ? w7 : w5, c: active ? C.navy : C.ink, ), ); return Semantics( selected: active, child: Pressable( onTap: onTap, radius: R.md, builder: (_, hover) => AnimatedContainer( duration: const Duration(milliseconds: 140), height: extended ? 44 : 60, width: extended ? null : 64, padding: EdgeInsets.symmetric(horizontal: extended ? S.s12 : 0), decoration: BoxDecoration( color: active ? C.chip : (hover ? C.hover : Colors.transparent), borderRadius: BorderRadius.circular(R.md), ), child: extended ? Row( children: [ icon, const SizedBox(width: S.s12), Expanded(child: label), ], ) : Column( mainAxisAlignment: MainAxisAlignment.center, children: [ icon, const SizedBox(height: S.s4), label, ], ), ), ), ); } } class _AccountButton extends StatelessWidget { const _AccountButton({ required this.name, required this.role, required this.extended, required this.onTap, }); final String name, role; final bool extended; final VoidCallback onTap; @override Widget build(BuildContext context) => Tooltip( message: 'Account', excludeFromSemantics: true, child: Pressable( onTap: onTap, radius: R.md, label: extended ? null : 'Account, $name', builder: (_, hover) => AnimatedContainer( duration: const Duration(milliseconds: 140), padding: const EdgeInsets.all(S.s8), decoration: BoxDecoration( color: hover ? C.hover : Colors.transparent, borderRadius: BorderRadius.circular(R.md), border: Border.all(color: extended ? C.border : Colors.transparent), ), child: extended ? Row( children: [ Avatar(name: name, size: 36), const SizedBox(width: S.s10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: ts(13, w: w6), ), Text(role, style: ts(12, c: C.muted)), ], ), ), const Icon( Icons.unfold_more_rounded, size: 18, color: C.placeholder, ), ], ) : Avatar(name: name, size: 40), ), ), ); }