srs/lib/features/orders/order_detail_screen.dart
Aswin Biju 45fcdcebfb Initial commit
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.
2026-11-04 23:21:50 +05:30

342 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
);
}