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.
342 lines
10 KiB
Dart
342 lines
10 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';
|
||
|
||
class OrderDetailScreen extends ConsumerStatefulWidget {
|
||
const OrderDetailScreen({super.key, required this.orderId});
|
||
final int orderId;
|
||
|
||
@override
|
||
ConsumerState<OrderDetailScreen> createState() => _OrderDetailScreenState();
|
||
}
|
||
|
||
class _OrderDetailScreenState extends ConsumerState<OrderDetailScreen> {
|
||
bool _full = false;
|
||
bool _busy = false;
|
||
|
||
Future<void> _run(Future<void> Function() fn, {String? done}) async {
|
||
if (_busy) return;
|
||
setState(() => _busy = true);
|
||
try {
|
||
await mutate(ref, fn);
|
||
if (mounted && done != null) toast(context, done);
|
||
} catch (e) {
|
||
if (mounted) toast(context, errText(e));
|
||
} finally {
|
||
if (mounted) setState(() => _busy = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _setQty(Order o, EndingQty l, int qty) {
|
||
if (qty < 1 || qty > 9999) return Future.value();
|
||
return _run(() => ref.read(apiProvider).setLineQty(o.id, l.ending, qty));
|
||
}
|
||
|
||
Future<void> _remove(Order o, EndingQty l) async {
|
||
final last = o.lines.length == 1;
|
||
final ok = await confirm(
|
||
context,
|
||
title: last ? 'Remove last ending?' : 'Remove ${l.ending}?',
|
||
body: last
|
||
? 'This is the only ending in this order. Removing it will delete the order.'
|
||
: '${plural(l.qty, 'ticket')} will be removed from this order and the shop totals.',
|
||
confirmLabel: 'Remove',
|
||
);
|
||
if (!ok || !mounted) return;
|
||
await _run(
|
||
() => ref.read(apiProvider).removeLine(o.id, l.ending),
|
||
done: last ? 'Order deleted' : 'Ending removed',
|
||
);
|
||
if (last && mounted) context.pop();
|
||
}
|
||
|
||
Future<void> _delete(Order o) async {
|
||
final ok = await confirm(
|
||
context,
|
||
title: 'Delete this order?',
|
||
body:
|
||
'${plural(o.tickets, 'ticket')} will be removed from ${o.shop.name}’s totals and results. This action cannot be undone.',
|
||
confirmLabel: 'Delete order',
|
||
);
|
||
if (!ok || !mounted) return;
|
||
await _run(
|
||
() => ref.read(apiProvider).cancelOrder(o.id),
|
||
done: 'Order deleted',
|
||
);
|
||
if (mounted) context.pop();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final order = ref.watch(orderProvider(widget.orderId));
|
||
return Scaffold(
|
||
body: SafeArea(
|
||
child: asyncView(
|
||
order,
|
||
(o) => Column(
|
||
children: [
|
||
const ScreenHeader(title: 'Order details', back: true),
|
||
Expanded(child: _body(o)),
|
||
],
|
||
),
|
||
retry: () => ref.invalidate(orderProvider(widget.orderId)),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _body(Order o) {
|
||
final out =
|
||
o.lines.any((l) => l.wins) ||
|
||
o.drawDate.isBefore(ref.read(apiProvider).now);
|
||
final lines = (o.rawText ?? '').split('\n');
|
||
final shown = _full ? lines : lines.take(4).toList();
|
||
return PaneList(
|
||
top: 0,
|
||
breakpoint: 820,
|
||
startFlex: 1,
|
||
endFlex: 1,
|
||
gap: S.s24,
|
||
start: [
|
||
SrsCard(
|
||
padding: const EdgeInsets.all(S.s16),
|
||
child: Row(
|
||
children: [
|
||
CodeTile(o.shop.code, size: 52, dark: true),
|
||
const SizedBox(width: S.s14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Flexible(
|
||
child: Text(
|
||
'${inr(o.tickets)} tickets',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: ts(18, w: w7),
|
||
),
|
||
),
|
||
if (o.edited) ...[
|
||
const SizedBox(width: S.s8),
|
||
const Pill('Edited', bg: C.goldTint, fg: C.goldText),
|
||
],
|
||
],
|
||
),
|
||
const SizedBox(height: S.s4),
|
||
Text(
|
||
'${o.shop.code} · ${time12(o.time)} · ${o.isPhoto ? 'Photo' : 'Text'}',
|
||
style: ts(12, c: C.muted),
|
||
),
|
||
const SizedBox(height: S.s2),
|
||
Text(dateLong(o.drawDate), style: ts(12, c: C.muted)),
|
||
],
|
||
),
|
||
),
|
||
if (o.prize > 0)
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
rupees(o.prize),
|
||
style: ts(16, w: w7, c: C.green),
|
||
),
|
||
Text('won', style: ts(11, c: C.muted)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SectionTitle('Original message'),
|
||
if (o.isPhoto)
|
||
Container(
|
||
height: 150,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: C.chip,
|
||
borderRadius: BorderRadius.circular(R.card),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.photo_outlined, size: 36, color: C.navy),
|
||
const SizedBox(height: S.s8),
|
||
Text(
|
||
'Handwritten slip',
|
||
style: ts(13, w: w6, c: C.navy),
|
||
),
|
||
const SizedBox(height: S.s2),
|
||
Text('Received via WhatsApp', style: ts(12, c: C.muted)),
|
||
],
|
||
),
|
||
)
|
||
else
|
||
SrsCard(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
shown.join('\n'),
|
||
style: ts(13, w: w5, c: C.ink, h: 1.6),
|
||
),
|
||
if (lines.length > 4) ...[
|
||
const SizedBox(height: S.s10),
|
||
Pressable(
|
||
onTap: () => setState(() => _full = !_full),
|
||
radius: R.sm,
|
||
child: Text(
|
||
_full ? 'Show less' : 'Show full message',
|
||
style: ts(13, w: w6, c: C.navy),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
end: [
|
||
SectionTitle(
|
||
'Endings',
|
||
trailing:
|
||
'${plural(o.lines.length, 'ending')} · ${plural(o.tickets, 'ticket')}',
|
||
),
|
||
for (final l in o.lines) ...[
|
||
_LineRow(
|
||
line: l,
|
||
busy: _busy,
|
||
onMinus: () => _setQty(o, l, l.qty - 1),
|
||
onPlus: () => _setQty(o, l, l.qty + 1),
|
||
onRemove: () => _remove(o, l),
|
||
),
|
||
const SizedBox(height: S.s8),
|
||
],
|
||
const SizedBox(height: S.s6),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: S.s4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Icon(Icons.info_outline_rounded, size: 15, color: C.muted),
|
||
const SizedBox(width: S.s8),
|
||
Expanded(
|
||
child: Text(
|
||
out
|
||
? 'Changes are reflected in shop totals and results.'
|
||
: 'Changes are reflected in shop totals and, once published, in results.',
|
||
style: ts(12, c: C.muted, h: 1.45),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: S.s22),
|
||
BigButton(
|
||
'Delete order',
|
||
outline: true,
|
||
danger: true,
|
||
icon: Icons.delete_outline_rounded,
|
||
onPressed: _busy ? null : () => _delete(o),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LineRow extends StatelessWidget {
|
||
const _LineRow({
|
||
required this.line,
|
||
required this.busy,
|
||
required this.onMinus,
|
||
required this.onPlus,
|
||
required this.onRemove,
|
||
});
|
||
|
||
final EndingQty line;
|
||
final bool busy;
|
||
final VoidCallback onMinus, onPlus, onRemove;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final win = line.wins;
|
||
return Container(
|
||
padding: const EdgeInsets.fromLTRB(S.s14, S.s10, S.s8, S.s10),
|
||
decoration: BoxDecoration(
|
||
color: win ? C.greenTint : Colors.white,
|
||
borderRadius: BorderRadius.circular(R.button),
|
||
border: Border.all(color: win ? C.green.withAlpha(110) : C.border),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
line.ending,
|
||
style: ts(18, w: w7, c: win ? C.green : C.ink, ls: 1),
|
||
),
|
||
if (win) ...[
|
||
const SizedBox(height: S.s2),
|
||
Text(
|
||
'${line.tierText} · ${rupees(line.prize)}',
|
||
style: ts(12, w: w6, c: C.green),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
_step(
|
||
Icons.remove_rounded,
|
||
'Fewer tickets',
|
||
line.qty > 1 && !busy ? onMinus : null,
|
||
),
|
||
SizedBox(
|
||
width: 44,
|
||
child: Center(
|
||
child: Text('${line.qty}', style: ts(16, w: w7)),
|
||
),
|
||
),
|
||
_step(
|
||
Icons.add_rounded,
|
||
'More tickets',
|
||
line.qty < 9999 && !busy ? onPlus : null,
|
||
),
|
||
const SizedBox(width: S.s6),
|
||
IconButton(
|
||
onPressed: busy ? null : onRemove,
|
||
icon: const Icon(
|
||
Icons.delete_outline_rounded,
|
||
size: 21,
|
||
color: C.red,
|
||
),
|
||
tooltip: 'Remove ending',
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _step(IconData icon, String label, VoidCallback? tap) => Pressable(
|
||
onTap: tap,
|
||
label: label,
|
||
radius: 18,
|
||
builder: (_, hover) => AnimatedContainer(
|
||
duration: const Duration(milliseconds: 140),
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: tap == null ? C.disabled : (hover ? C.border2 : C.chip),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(icon, size: 18, color: tap == null ? C.placeholder : C.navy),
|
||
),
|
||
);
|
||
}
|