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.
395 lines
12 KiB
Dart
395 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/format.dart';
|
|
import '../../core/models.dart';
|
|
import '../../core/providers.dart';
|
|
import '../../core/theme.dart';
|
|
import '../../widgets/ui.dart';
|
|
|
|
const _panelWidth = 360.0;
|
|
Future<void> showCalendarPicker(BuildContext anchor) {
|
|
if (!isWide(anchor)) {
|
|
return showModalBottomSheet<void>(
|
|
context: anchor,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
useRootNavigator: true,
|
|
builder: (ctx) => const SingleChildScrollView(
|
|
padding: EdgeInsets.fromLTRB(S.s16, S.s10, S.s16, S.s20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [_Grabber(), _CalendarPicker()],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final box = anchor.findRenderObject() as RenderBox?;
|
|
final rect = box != null && box.hasSize
|
|
? box.localToGlobal(Offset.zero) & box.size
|
|
: null;
|
|
return showGeneralDialog<void>(
|
|
context: anchor,
|
|
barrierDismissible: true,
|
|
barrierLabel: 'Close calendar',
|
|
barrierColor: const Color(0x1A101B33),
|
|
transitionDuration: const Duration(milliseconds: 160),
|
|
pageBuilder: (ctx, _, _) => CustomSingleChildLayout(
|
|
delegate: _AnchorLayout(rect),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(R.dialog),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: C.shadowNavy,
|
|
blurRadius: 32,
|
|
offset: Offset(0, 12),
|
|
),
|
|
],
|
|
),
|
|
child: Material(
|
|
color: Colors.white,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(R.dialog),
|
|
side: const BorderSide(color: C.border),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: const SizedBox(
|
|
width: _panelWidth,
|
|
child: Padding(
|
|
padding: EdgeInsets.all(S.s16),
|
|
child: _CalendarPicker(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
transitionBuilder: (ctx, a, _, child) {
|
|
if (MediaQuery.disableAnimationsOf(ctx)) return child;
|
|
final t = CurvedAnimation(parent: a, curve: Curves.easeOutCubic);
|
|
return FadeTransition(
|
|
opacity: t,
|
|
child: ScaleTransition(
|
|
scale: Tween(begin: 0.97, end: 1.0).animate(t),
|
|
alignment: Alignment.topCenter,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
class _AnchorLayout extends SingleChildLayoutDelegate {
|
|
_AnchorLayout(this.anchor);
|
|
final Rect? anchor;
|
|
|
|
@override
|
|
BoxConstraints getConstraintsForChild(BoxConstraints c) =>
|
|
BoxConstraints.loose(Size(c.maxWidth - 32, c.maxHeight - 32));
|
|
|
|
@override
|
|
Offset getPositionForChild(Size size, Size child) {
|
|
const m = S.s16;
|
|
final a = anchor;
|
|
if (a == null) {
|
|
return Offset(
|
|
(size.width - child.width) / 2,
|
|
(size.height - child.height) / 2,
|
|
);
|
|
}
|
|
final left = (a.center.dx - child.width / 2).clamp(
|
|
m,
|
|
size.width - child.width - m,
|
|
);
|
|
var top = a.bottom + S.s8;
|
|
if (top + child.height > size.height - m) {
|
|
final above = a.top - S.s8 - child.height;
|
|
top = above >= m ? above : (size.height - child.height) / 2;
|
|
}
|
|
return Offset(left, top);
|
|
}
|
|
|
|
@override
|
|
bool shouldRelayout(_AnchorLayout old) => old.anchor != anchor;
|
|
}
|
|
|
|
class _Grabber extends StatelessWidget {
|
|
const _Grabber();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: S.s12),
|
|
decoration: BoxDecoration(
|
|
color: C.border2,
|
|
borderRadius: BorderRadius.circular(R.sm),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _CalendarPicker extends ConsumerStatefulWidget {
|
|
const _CalendarPicker();
|
|
|
|
@override
|
|
ConsumerState<_CalendarPicker> createState() => _CalendarPickerState();
|
|
}
|
|
|
|
class _CalendarPickerState extends ConsumerState<_CalendarPicker> {
|
|
late DateTime _picked = ref.read(selectedDateProvider);
|
|
late DateTime _month = DateTime(_picked.year, _picked.month);
|
|
|
|
void _shift(int by) =>
|
|
setState(() => _month = DateTime(_month.year, _month.month + by));
|
|
|
|
void _pick(DateTime d) => setState(() {
|
|
_picked = d;
|
|
_month = DateTime(d.year, d.month);
|
|
});
|
|
|
|
void _confirm() {
|
|
ref.read(selectedDateProvider.notifier).set(_picked);
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final today = dateOnly(ref.read(apiProvider).now);
|
|
final days = ref.watch(monthProvider((_month.year, _month.month)));
|
|
final byDay = <int, DrawInfo>{
|
|
for (final d in days.asData?.value ?? const <DrawInfo>[]) d.date.day: d,
|
|
};
|
|
final pickedInfo = ref
|
|
.watch(monthProvider((_picked.year, _picked.month)))
|
|
.asData
|
|
?.value
|
|
.where((d) => sameDay(d.date, _picked))
|
|
.firstOrNull;
|
|
|
|
final first = DateTime(_month.year, _month.month);
|
|
final lead = first.weekday - 1;
|
|
final count = DateTime(_month.year, _month.month + 1, 0).day;
|
|
final rows = ((lead + count) / 7).ceil();
|
|
|
|
return CallbackShortcuts(
|
|
bindings: {
|
|
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
|
|
_pick(addDays(_picked, -1)),
|
|
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
|
|
_pick(addDays(_picked, 1)),
|
|
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
|
_pick(addDays(_picked, -7)),
|
|
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
|
_pick(addDays(_picked, 7)),
|
|
},
|
|
child: Focus(
|
|
autofocus: true,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const SizedBox(width: S.s4),
|
|
Expanded(
|
|
child: Text(monthYear(_month), style: ts(18, w: w7)),
|
|
),
|
|
if (!sameDay(DateTime(today.year, today.month), _month) ||
|
|
!sameDay(_picked, today))
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: S.s4),
|
|
child: Pressable(
|
|
onTap: () => _pick(today),
|
|
radius: R.pill,
|
|
builder: (_, hover) => AnimatedContainer(
|
|
duration: const Duration(milliseconds: 140),
|
|
height: 32,
|
|
padding: const EdgeInsets.symmetric(horizontal: S.s12),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: hover ? C.chip : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(R.pill),
|
|
),
|
|
child: Text(
|
|
'Today',
|
|
style: ts(13, w: w6, c: C.navy),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
RoundIcon(
|
|
Icons.chevron_left_rounded,
|
|
label: 'Previous month',
|
|
size: 36,
|
|
onTap: () => _shift(-1),
|
|
),
|
|
const SizedBox(width: S.s6),
|
|
RoundIcon(
|
|
Icons.chevron_right_rounded,
|
|
label: 'Next month',
|
|
size: 36,
|
|
onTap: () => _shift(1),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: S.s14),
|
|
Row(
|
|
children: [
|
|
for (final w in const ['M', 'T', 'W', 'T', 'F', 'S', 'S'])
|
|
Expanded(
|
|
child: Center(
|
|
child: Text(
|
|
w,
|
|
style: ts(12, w: w6, c: C.muted),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: S.s6),
|
|
for (var r = 0; r < rows; r++)
|
|
Row(
|
|
children: [
|
|
for (var c = 0; c < 7; c++)
|
|
Expanded(
|
|
child: _cell(r * 7 + c - lead + 1, count, byDay, today),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: S.s12),
|
|
_Summary(date: _picked, info: pickedInfo),
|
|
const SizedBox(height: S.s14),
|
|
BigButton(
|
|
'Show results for ${dateShort(_picked)}',
|
|
onPressed: _confirm,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _cell(int day, int count, Map<int, DrawInfo> byDay, DateTime today) {
|
|
if (day < 1 || day > count) return const SizedBox(height: 48);
|
|
final date = DateTime(_month.year, _month.month, day);
|
|
final sel = sameDay(date, _picked);
|
|
final isToday = sameDay(date, today);
|
|
final info = byDay[day];
|
|
final out = info?.published == true;
|
|
final win = info?.hasWin == true;
|
|
final status = [
|
|
if (out) 'results published',
|
|
if (win) 'winning tickets',
|
|
].join(', ');
|
|
return Pressable(
|
|
onTap: () => _pick(date),
|
|
label: status.isEmpty ? dateLong(date) : '${dateLong(date)}, $status',
|
|
radius: R.md,
|
|
builder: (_, hover) => Padding(
|
|
padding: const EdgeInsets.all(S.s2),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: sel ? C.navy : (hover ? C.chip : Colors.transparent),
|
|
borderRadius: BorderRadius.circular(R.md),
|
|
border: !sel && isToday
|
|
? Border.all(color: C.navy, width: 1.4)
|
|
: null,
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'$day',
|
|
style: ts(
|
|
14,
|
|
w: sel || isToday ? w7 : w5,
|
|
c: sel ? Colors.white : C.ink,
|
|
),
|
|
),
|
|
const SizedBox(height: S.s4),
|
|
SizedBox(
|
|
height: 4,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (out)
|
|
Container(
|
|
width: 12,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: sel ? C.softOnNavy : C.green,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
if (out && win) const SizedBox(width: 3),
|
|
if (win)
|
|
Container(
|
|
width: 4,
|
|
height: 4,
|
|
decoration: const BoxDecoration(
|
|
color: C.gold,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Summary extends StatelessWidget {
|
|
const _Summary({required this.date, required this.info});
|
|
final DateTime date;
|
|
final DrawInfo? info;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final out = info?.published == true;
|
|
final win = info?.hasWin == true;
|
|
final (icon, color, text) = !out
|
|
? (Icons.schedule_rounded, C.goldText, 'Results not published yet')
|
|
: win
|
|
? (Icons.star_rounded, C.goldText, 'Results out, with winning tickets')
|
|
: (Icons.check_circle_rounded, C.green, 'Results out');
|
|
return Container(
|
|
padding: const EdgeInsets.all(S.s12),
|
|
decoration: BoxDecoration(
|
|
color: C.bg,
|
|
borderRadius: BorderRadius.circular(R.input),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 20, color: color),
|
|
const SizedBox(width: S.s10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
out && info!.code != null
|
|
? '${info!.scheme ?? 'Draw'} ${info!.code}'
|
|
: dateLong(date),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: ts(14, w: w6),
|
|
),
|
|
const SizedBox(height: S.s2),
|
|
Text(text, style: ts(12, c: C.muted)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|