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.
339 lines
10 KiB
Dart
339 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/account_sheet.dart';
|
|
import 'calendar_picker.dart';
|
|
import '../../widgets/ui.dart';
|
|
|
|
class ResultsScreen extends ConsumerWidget {
|
|
const ResultsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final date = ref.watch(selectedDateProvider);
|
|
final res = ref.watch(resultsProvider(date));
|
|
return Column(
|
|
children: [
|
|
ScreenHeader(
|
|
title: 'Results',
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Builder(
|
|
builder: (anchor) => RoundIcon(
|
|
Icons.calendar_month_rounded,
|
|
label: 'Calendar',
|
|
onTap: () => showCalendarPicker(anchor),
|
|
),
|
|
),
|
|
// Wider windows show the account in the sidebar.
|
|
if (!isWide(context)) ...[
|
|
const SizedBox(width: S.s10),
|
|
RoundIcon(
|
|
Icons.person_outline_rounded,
|
|
label: 'Account',
|
|
onTap: () => showAccountSheet(context, ref),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: asyncView(
|
|
res,
|
|
(r) => _Body(results: r, date: date),
|
|
retry: () => ref.invalidate(resultsProvider(date)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Body extends ConsumerWidget {
|
|
const _Body({required this.results, required this.date});
|
|
final DrawResults results;
|
|
final DateTime date;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final r = results;
|
|
final d = r.draw;
|
|
final n = ref.read(selectedDateProvider.notifier);
|
|
return PageList(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(d.label, style: ts(16, w: w7)),
|
|
const SizedBox(height: S.s2),
|
|
Text(
|
|
'${dateLong(d.date)} · 3:00 PM',
|
|
style: ts(13, c: C.muted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
RoundIcon(
|
|
Icons.chevron_left_rounded,
|
|
label: 'Previous draw',
|
|
size: 40,
|
|
onTap: () => n.set(addDays(date, -1)),
|
|
),
|
|
const SizedBox(width: S.s8),
|
|
RoundIcon(
|
|
Icons.chevron_right_rounded,
|
|
label: 'Next draw',
|
|
size: 40,
|
|
onTap: () => n.set(addDays(date, 1)),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: S.s16),
|
|
if (d.published) ..._published(context, r) else ..._notOut(context, r),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _published(BuildContext context, DrawResults r) {
|
|
final top = r.topShop;
|
|
return [
|
|
_HeroRow(
|
|
hero: NavyCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'TOTAL PRIZE',
|
|
style: ts(11, w: w6, c: C.softOnNavy, ls: 1.2),
|
|
),
|
|
const SizedBox(height: S.s10),
|
|
Text(
|
|
rupees(r.totalPrize),
|
|
style: ts(
|
|
40,
|
|
w: w7,
|
|
c: r.totalPrize > 0 ? C.gold : C.softOnNavy,
|
|
ls: -1,
|
|
),
|
|
),
|
|
const SizedBox(height: S.s10),
|
|
Text(
|
|
r.totalPrize > 0
|
|
? '${plural(r.winningTickets, 'winning ticket')} · ${plural(r.winningShops, 'shop')}'
|
|
: 'No winning tickets in this draw',
|
|
style: ts(13, w: w5, c: C.softOnNavy),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
stats: [
|
|
StatCard(label: 'TOTAL TICKETS', value: inr(r.totalTickets)),
|
|
StatCard(
|
|
label: 'TOP SHOP',
|
|
value: top?.shop.code ?? '—',
|
|
valueColor: top == null ? C.muted : C.ink,
|
|
),
|
|
],
|
|
),
|
|
SectionTitle(
|
|
'Shops',
|
|
trailing: r.shops.isEmpty ? null : plural(r.shops.length, 'shop'),
|
|
),
|
|
if (r.shops.isEmpty)
|
|
const SrsCard(
|
|
child: EmptyState(
|
|
icon: Icons.receipt_long_outlined,
|
|
title: 'No orders for this draw',
|
|
body: 'Orders placed for your shops on this date will appear here.',
|
|
),
|
|
)
|
|
else
|
|
CardGrid(children: [for (final s in r.shops) _ShopRow(s: s)]),
|
|
];
|
|
}
|
|
|
|
List<Widget> _notOut(BuildContext context, DrawResults r) {
|
|
return [
|
|
_HeroRow(
|
|
hero: NavyCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: S.s10,
|
|
vertical: S.s6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: C.goldSoft,
|
|
borderRadius: BorderRadius.circular(R.pill),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.schedule_rounded, size: 14, color: C.gold),
|
|
const SizedBox(width: S.s6),
|
|
Text(
|
|
'AWAITING RESULTS',
|
|
style: ts(10, w: w7, c: C.gold, ls: 1),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: S.s14),
|
|
Text(
|
|
'Results not yet published',
|
|
style: ts(26, w: w7, c: Colors.white, ls: -0.4),
|
|
),
|
|
const SizedBox(height: S.s8),
|
|
Text(
|
|
'Official results are usually published after 4 PM. Prize details will appear here once available.',
|
|
style: ts(13, c: C.softOnNavy, h: 1.5),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
stats: [
|
|
StatCard(label: 'ORDERS', value: inr(r.orders)),
|
|
StatCard(label: 'TICKETS', value: inr(r.totalTickets)),
|
|
],
|
|
),
|
|
SectionTitle(
|
|
'Shops',
|
|
trailing: r.shops.isEmpty ? null : plural(r.shops.length, 'shop'),
|
|
),
|
|
if (r.shops.isEmpty)
|
|
const SrsCard(
|
|
child: EmptyState(
|
|
icon: Icons.inbox_outlined,
|
|
title: 'No orders',
|
|
body: 'No orders have been placed for this draw.',
|
|
),
|
|
)
|
|
else
|
|
CardGrid(
|
|
children: [for (final s in r.shops) _ShopRow(s: s, pending: true)],
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
/// The navy hero with its two stat cards: stacked under it on phones,
|
|
/// beside it on wide windows.
|
|
class _HeroRow extends StatelessWidget {
|
|
const _HeroRow({required this.hero, required this.stats});
|
|
final Widget hero;
|
|
final List<Widget> stats;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => LayoutBuilder(
|
|
builder: (context, c) {
|
|
if (c.maxWidth < 760) {
|
|
return Column(
|
|
children: [
|
|
hero,
|
|
const SizedBox(height: S.s12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: stats[0]),
|
|
const SizedBox(width: S.s12),
|
|
Expanded(child: stats[1]),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(flex: 2, child: hero),
|
|
const SizedBox(width: S.s12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(child: stats[0]),
|
|
const SizedBox(height: S.s12),
|
|
Expanded(child: stats[1]),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
class _ShopRow extends StatelessWidget {
|
|
const _ShopRow({required this.s, this.pending = false});
|
|
final ShopResult s;
|
|
final bool pending;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final win = s.prize > 0;
|
|
return SrsCard(
|
|
onTap: () => context.push('/shops/${s.shop.id}'),
|
|
padding: const EdgeInsets.all(S.s14),
|
|
child: Row(
|
|
children: [
|
|
CodeTile(s.shop.code, dark: win),
|
|
const SizedBox(width: S.s14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
s.shop.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: ts(15, w: w6),
|
|
),
|
|
const SizedBox(height: S.s4),
|
|
Text(
|
|
pending
|
|
? plural(s.tickets, 'ticket')
|
|
: win
|
|
? plural(s.winTickets, 'winning ticket')
|
|
: 'No winning tickets',
|
|
style: ts(12, c: C.muted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (!pending)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
rupees(s.prize),
|
|
style: ts(16, w: w7, c: win ? C.green : C.muted),
|
|
),
|
|
if (win) ...[
|
|
const SizedBox(height: S.s2),
|
|
Text(
|
|
plural(s.winTickets, 'ticket'),
|
|
style: ts(11, c: C.muted),
|
|
),
|
|
],
|
|
],
|
|
)
|
|
else
|
|
const Icon(Icons.chevron_right_rounded, color: C.placeholder),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|