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.
295 lines
9.1 KiB
Dart
295 lines
9.1 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 'shop_sheet.dart';
|
||
|
||
class ShopDetailScreen extends ConsumerWidget {
|
||
const ShopDetailScreen({super.key, required this.shopId});
|
||
final int shopId;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final date = ref.watch(selectedDateProvider);
|
||
final detail = ref.watch(shopDetailProvider((shopId, date)));
|
||
final isAdmin = ref.watch(sessionProvider)?.isAdmin ?? false;
|
||
return Scaffold(
|
||
body: SafeArea(
|
||
child: asyncView(
|
||
detail,
|
||
(d) => Column(
|
||
children: [
|
||
ScreenHeader(
|
||
title: d.shop.name,
|
||
back: true,
|
||
maxWidth: _maxW,
|
||
trailing: isAdmin
|
||
? RoundIcon(
|
||
Icons.edit_outlined,
|
||
label: 'Edit shop',
|
||
onTap: () => showShopSheet(
|
||
context,
|
||
shop: d.shop,
|
||
onDeactivated: () {
|
||
if (context.mounted) context.go('/shops');
|
||
},
|
||
),
|
||
)
|
||
: null,
|
||
),
|
||
Expanded(
|
||
child: _Body(d: d, date: date),
|
||
),
|
||
],
|
||
),
|
||
retry: () => ref.invalidate(shopDetailProvider((shopId, date))),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
const _maxW = 960.0;
|
||
|
||
class _Body extends StatelessWidget {
|
||
const _Body({required this.d, required this.date});
|
||
final ShopDetail d;
|
||
final DateTime date;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final out = d.draw.published;
|
||
final wins = d.wins;
|
||
return PageList(
|
||
maxWidth: _maxW,
|
||
top: 0,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Pill(d.shop.code, bg: C.chip, fg: C.navy),
|
||
const SizedBox(width: S.s10),
|
||
Expanded(
|
||
child: Text(
|
||
'${d.draw.label} · ${dateShort(date)}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: ts(13, c: C.muted),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: S.s16),
|
||
if (out)
|
||
NavyCard(
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'WINNING ENDINGS',
|
||
style: ts(10, w: w6, c: C.softOnNavy, ls: 1),
|
||
),
|
||
const SizedBox(height: S.s8),
|
||
Text(
|
||
'${wins.length}',
|
||
style: ts(34, w: w7, c: Colors.white, ls: -0.5),
|
||
),
|
||
const SizedBox(height: S.s2),
|
||
Text(
|
||
'of ${inr(d.endings.length)} endings',
|
||
style: ts(12, c: C.softOnNavy),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Container(width: 1, height: 64, color: C.onNavy20),
|
||
const SizedBox(width: S.s22),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'PRIZE AMOUNT',
|
||
style: ts(10, w: w6, c: C.softOnNavy, ls: 1),
|
||
),
|
||
const SizedBox(height: S.s8),
|
||
Text(
|
||
rupees(d.prize),
|
||
style: ts(
|
||
30,
|
||
w: w7,
|
||
c: d.prize > 0 ? C.gold : C.softOnNavy,
|
||
ls: -0.5,
|
||
),
|
||
),
|
||
const SizedBox(height: S.s2),
|
||
Text(
|
||
plural(d.winTickets, 'winning ticket'),
|
||
style: ts(12, c: C.softOnNavy),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
else
|
||
NavyCard(
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.schedule_rounded, color: C.gold, size: 28),
|
||
const SizedBox(width: S.s14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Results not yet published',
|
||
style: ts(18, w: w7, c: Colors.white),
|
||
),
|
||
const SizedBox(height: S.s4),
|
||
Text(
|
||
'Prize details will appear here once results are published.',
|
||
style: ts(12, c: C.softOnNavy),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: S.s12),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: StatCard(label: 'TICKETS', value: inr(d.tickets)),
|
||
),
|
||
const SizedBox(width: S.s12),
|
||
Expanded(
|
||
child: StatCard(
|
||
label: 'PRIZE WON',
|
||
value: out ? rupees(d.prize) : '—',
|
||
valueColor: out && d.prize > 0 ? C.green : C.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (out) ...[
|
||
SectionTitle(
|
||
'Winning endings',
|
||
trailing: wins.isEmpty ? null : '${wins.length}',
|
||
),
|
||
if (wins.isEmpty)
|
||
const SrsCard(
|
||
child: EmptyState(
|
||
icon: Icons.sentiment_neutral_rounded,
|
||
title: 'No winning endings',
|
||
body:
|
||
'None of this shop’s endings matched a prize in this draw.',
|
||
),
|
||
)
|
||
else
|
||
CardGrid(
|
||
minItemWidth: 320,
|
||
maxColumns: 2,
|
||
children: [for (final w in wins) _WinRow(w: w)],
|
||
),
|
||
],
|
||
const SizedBox(height: S.s14),
|
||
SrsCard(
|
||
onTap: d.tickets == 0
|
||
? null
|
||
: () => context.push('/shops/${d.shop.id}/tickets'),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
color: C.chip,
|
||
borderRadius: BorderRadius.circular(R.md),
|
||
),
|
||
child: const Icon(
|
||
Icons.confirmation_number_outlined,
|
||
color: C.navy,
|
||
size: 22,
|
||
),
|
||
),
|
||
const SizedBox(width: S.s14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Tickets · ${inr(d.tickets)}', style: ts(15, w: w6)),
|
||
const SizedBox(height: S.s2),
|
||
Text(
|
||
d.tickets == 0
|
||
? 'No orders for this draw'
|
||
: '${plural(d.endings.length, 'ending')} · ${plural(d.orders.length, 'order')}',
|
||
style: ts(12, c: C.muted),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (d.tickets > 0)
|
||
const Icon(Icons.chevron_right_rounded, color: C.placeholder),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _WinRow extends StatelessWidget {
|
||
const _WinRow({required this.w});
|
||
final EndingQty w;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => SrsCard(
|
||
padding: const EdgeInsets.all(S.s14),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 62,
|
||
height: 46,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: C.greenTint,
|
||
borderRadius: BorderRadius.circular(R.input),
|
||
),
|
||
child: Text(
|
||
w.ending,
|
||
style: ts(17, w: w7, c: C.green, ls: 0.5),
|
||
),
|
||
),
|
||
const SizedBox(width: S.s14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('× ${plural(w.qty, 'ticket')}', style: ts(14, w: w6)),
|
||
const SizedBox(height: S.s4),
|
||
Text(
|
||
'${w.tierText} · ${rupees(w.perTicket)} each',
|
||
style: ts(12, c: C.muted),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Text(
|
||
rupees(w.prize),
|
||
style: ts(16, w: w7, c: C.green),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|