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

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