srs/lib/features/results/results_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

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