Add SRS app: results, shops, orders and users, wired to srs-api
Flutter web/PWA for the lottery agency, built on Riverpod and go_router,
talking to srs-api over HTTP (HttpApi, token kept in SharedPreferences).
- Results, Shops, Orders and Users tabs (Users for admins only), with
phone, tablet and desktop layouts and shared widgets and theme tokens
- Users: list with search and Active/Inactive filters; user detail with
shops, sign-in details, and an Access card
- WhatsApp results switch on each user (sendResults on
PATCH /api/users/:id). Shows the WhatsApp logo (Simple Icons, CC0,
drawn with flutter_svg), green only when results will really go out.
Can't be turned on without a phone number ("Add a phone number first"
opens the phone sheet), and is paused while the user is inactive. The
users list marks people who get results with the logo.
- Each Access switch has a one-line description; the explanation
paragraphs are gone
- Remove demo mode: the app always uses the real AP
test/support and is used only by tests.
394 lines
11 KiB
Dart
394 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 '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;
|
|
|
|
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),
|
|
),
|
|
),
|
|
);
|
|
}
|