srs/lib/widgets/main_shell.dart
Aswin Biju 32bdca2ef9
All checks were successful
deploy / deploy (push) Successful in 4m50s
refactor: updated login screen and favicon
2026-11-05 15:33:48 +05:30

389 lines
11 KiB
Dart

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) {
const coin = BrandCoin(size: 40, glow: false);
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),
),
),
);
}