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.
174 lines
4.9 KiB
Dart
174 lines
4.9 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import '../../core/format.dart';
|
||
import '../../core/models.dart';
|
||
import '../../core/providers.dart';
|
||
import '../../core/theme.dart';
|
||
import '../../widgets/ui.dart';
|
||
import '../orders/order_card.dart';
|
||
|
||
class TicketsScreen extends ConsumerStatefulWidget {
|
||
const TicketsScreen({super.key, required this.shopId});
|
||
final int shopId;
|
||
|
||
@override
|
||
ConsumerState<TicketsScreen> createState() => _TicketsScreenState();
|
||
}
|
||
|
||
class _TicketsScreenState extends ConsumerState<TicketsScreen> {
|
||
bool _byEnding = true;
|
||
String _q = '';
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final date = ref.watch(selectedDateProvider);
|
||
final detail = ref.watch(shopDetailProvider((widget.shopId, date)));
|
||
return Scaffold(
|
||
body: SafeArea(
|
||
child: asyncView(
|
||
detail,
|
||
(d) => Column(
|
||
children: [
|
||
ScreenHeader(
|
||
title: '${d.shop.name} tickets',
|
||
subtitle: '${plural(d.tickets, 'ticket')} · ${dateShort(date)}',
|
||
back: true,
|
||
),
|
||
PagePad(
|
||
top: S.s4,
|
||
bottom: S.s10,
|
||
child: Container(
|
||
constraints: const BoxConstraints(maxWidth: 420),
|
||
padding: const EdgeInsets.all(S.s4),
|
||
decoration: BoxDecoration(
|
||
color: C.chip,
|
||
borderRadius: BorderRadius.circular(R.input),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
_seg(
|
||
'By ending',
|
||
_byEnding,
|
||
() => setState(() => _byEnding = true),
|
||
),
|
||
_seg(
|
||
'By order',
|
||
!_byEnding,
|
||
() => setState(() => _byEnding = false),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (_byEnding)
|
||
PagePad(
|
||
bottom: S.s8,
|
||
child: SearchField(
|
||
hint: 'Search endings (e.g. 0987)',
|
||
onChanged: (v) => setState(() => _q = v.trim()),
|
||
),
|
||
),
|
||
Expanded(child: _byEnding ? _endings(d) : _orders(d)),
|
||
],
|
||
),
|
||
retry: () =>
|
||
ref.invalidate(shopDetailProvider((widget.shopId, date))),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _seg(String t, bool on, VoidCallback tap) => Expanded(
|
||
child: Pressable(
|
||
onTap: tap,
|
||
radius: R.md,
|
||
builder: (_, hover) => AnimatedContainer(
|
||
duration: const Duration(milliseconds: 160),
|
||
height: 38,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: on ? Colors.white : (hover ? C.hover : Colors.transparent),
|
||
borderRadius: BorderRadius.circular(R.md),
|
||
),
|
||
child: Text(
|
||
t,
|
||
style: ts(13, w: on ? w7 : w5, c: on ? C.navy : C.muted),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
Widget _endings(ShopDetail d) {
|
||
final list = d.endings
|
||
.where((e) => _q.isEmpty || e.ending.contains(_q))
|
||
.toList();
|
||
if (list.isEmpty) {
|
||
return ListView(
|
||
children: const [
|
||
EmptyState(
|
||
icon: Icons.search_off_rounded,
|
||
title: 'No matching endings',
|
||
),
|
||
],
|
||
);
|
||
}
|
||
return PageList(
|
||
children: [
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: [for (final e in list) _chip(e)],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _chip(EndingQty e) {
|
||
final win = e.wins;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: S.s12, vertical: S.s8),
|
||
decoration: BoxDecoration(
|
||
color: win ? C.greenTint : Colors.white,
|
||
borderRadius: BorderRadius.circular(R.md),
|
||
border: Border.all(color: win ? C.green : C.border),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
e.ending,
|
||
style: ts(14, w: w7, c: win ? C.green : C.ink, ls: 0.4),
|
||
),
|
||
const SizedBox(width: S.s6),
|
||
Text(
|
||
'×${inr(e.qty)}',
|
||
style: ts(12, w: w6, c: win ? C.green : C.muted),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _orders(ShopDetail d) {
|
||
if (d.orders.isEmpty) {
|
||
return ListView(
|
||
children: const [
|
||
EmptyState(icon: Icons.receipt_long_outlined, title: 'No orders'),
|
||
],
|
||
);
|
||
}
|
||
return PageList(
|
||
children: [
|
||
CardGrid(
|
||
children: [
|
||
for (final o in d.orders)
|
||
OrderCard(order: o, onTap: () => context.push('/orders/${o.id}')),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|