srs/lib/widgets/ui.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

1083 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/format.dart';
import '../core/providers.dart';
import '../core/theme.dart';
import '../features/results/calendar_picker.dart';
// ------------------------------------------------------------ pressable
/// Tap target that also works with a pointer and a keyboard: click cursor,
/// hover state, a focus ring for Tab navigation, and Enter / Space to activate.
class Pressable extends StatefulWidget {
const Pressable({
super.key,
required this.onTap,
this.child,
this.builder,
this.radius = R.md,
this.label,
}) : assert(child != null || builder != null);
final VoidCallback? onTap;
final Widget? child;
/// Builds the content with the current hover state.
final Widget Function(BuildContext context, bool hovered)? builder;
final double radius;
/// Screen-reader label, needed when the content is only an icon.
final String? label;
@override
State<Pressable> createState() => _PressableState();
}
class _PressableState extends State<Pressable> {
bool _hover = false;
bool _focus = false;
@override
Widget build(BuildContext context) {
final enabled = widget.onTap != null;
final content =
widget.builder?.call(context, _hover && enabled) ?? widget.child!;
return Semantics(
button: true,
enabled: enabled,
label: widget.label,
child: FocusableActionDetector(
enabled: enabled,
mouseCursor: enabled ? SystemMouseCursors.click : MouseCursor.defer,
onShowHoverHighlight: (v) => setState(() => _hover = v),
onShowFocusHighlight: (v) => setState(() => _focus = v),
actions: {
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
widget.onTap?.call();
return null;
},
),
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: DecoratedBox(
position: DecorationPosition.foreground,
decoration: _focus
? BoxDecoration(
borderRadius: BorderRadius.circular(widget.radius),
border: Border.all(color: C.focus, width: 2),
)
: const BoxDecoration(),
child: content,
),
),
),
);
}
}
// --------------------------------------------------------------- layout
/// Pads [child] so it lines up with the page column of [maxWidth].
class PagePad extends StatelessWidget {
const PagePad({
super.key,
required this.child,
this.maxWidth = Bp.pageMax,
this.top = 0,
this.bottom = 0,
});
final Widget child;
final double maxWidth, top, bottom;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, c) {
final g = pageGutter(c.maxWidth, maxWidth);
return Padding(
padding: EdgeInsets.fromLTRB(g, top, g, bottom),
child: child,
);
},
);
}
/// Scrolling page whose content sits in a centred column. The scroll bar
/// stays at the window edge.
class PageList extends StatelessWidget {
const PageList({
super.key,
required this.children,
this.maxWidth = Bp.pageMax,
this.top = S.s4,
this.bottom = S.s28,
});
final List<Widget> children;
final double maxWidth, top, bottom;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, c) {
final g = pageGutter(c.maxWidth, maxWidth);
return ListView(
padding: EdgeInsets.fromLTRB(g, top, g, bottom),
children: children,
);
},
);
}
/// Lays cards out in as many columns as fit: one on phones, up to [maxColumns].
class CardGrid extends StatelessWidget {
const CardGrid({
super.key,
required this.children,
this.minItemWidth = 340,
this.maxColumns = 3,
this.spacing = S.s12,
this.runSpacing = S.s10,
});
final List<Widget> children;
final double minItemWidth, spacing, runSpacing;
final int maxColumns;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, c) {
final fit = ((c.maxWidth + spacing) / (minItemWidth + spacing)).floor();
final cols = fit.clamp(1, maxColumns);
final w = (c.maxWidth - spacing * (cols - 1)) / cols;
return Wrap(
spacing: spacing,
runSpacing: runSpacing,
children: [for (final x in children) SizedBox(width: w, child: x)],
);
},
);
}
/// A page in two columns on wide layouts. On narrow ones the two lists run
/// one after the other, each item its own list child so the page stays lazy.
class PaneList extends StatelessWidget {
const PaneList({
super.key,
required this.start,
required this.end,
this.breakpoint = 860,
this.startFlex = 3,
this.endFlex = 2,
this.gap = S.s20,
this.maxWidth = Bp.pageMax,
this.top = S.s4,
});
final List<Widget> start, end;
final double breakpoint, gap, maxWidth, top;
final int startFlex, endFlex;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, c) {
final g = pageGutter(c.maxWidth, maxWidth);
final pad = EdgeInsets.fromLTRB(g, top, g, S.s28);
if (c.maxWidth - 2 * g < breakpoint) {
return ListView(padding: pad, children: [...start, ...end]);
}
Widget col(List<Widget> w) =>
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: w);
return ListView(
padding: pad,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: startFlex, child: col(start)),
SizedBox(width: gap),
Expanded(flex: endFlex, child: col(end)),
],
),
],
);
},
);
}
bool isWide(BuildContext context) =>
MediaQuery.sizeOf(context).width >= Bp.medium;
// ---------------------------------------------------------------- cards
class SrsCard extends StatelessWidget {
const SrsCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(S.s16),
this.color = Colors.white,
this.onTap,
this.radius = R.card,
this.borderColor = C.border,
});
final Widget child;
final EdgeInsets padding;
final Color color;
final VoidCallback? onTap;
final double radius;
final Color borderColor;
@override
Widget build(BuildContext context) {
Widget box(bool hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
padding: padding,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: hover ? C.border2 : borderColor),
boxShadow: hover
? const [
BoxShadow(
color: C.shadowNav,
blurRadius: 16,
offset: Offset(0, 4),
),
]
: const [],
),
child: child,
);
if (onTap == null) return box(false);
return Pressable(onTap: onTap, radius: radius, builder: (_, h) => box(h));
}
}
class NavyCard extends StatelessWidget {
const NavyCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(S.s22),
});
final Widget child;
final EdgeInsets padding;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(R.hero),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [C.navy, C.navyDeep],
),
boxShadow: const [
BoxShadow(color: C.shadowNavy, blurRadius: 24, offset: Offset(0, 10)),
],
),
child: child,
);
}
class CodeTile extends StatelessWidget {
const CodeTile(this.code, {super.key, this.size = 46, this.dark = false});
final String code;
final double size;
final bool dark;
@override
Widget build(BuildContext context) => Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: dark ? C.navy : C.chip,
borderRadius: BorderRadius.circular(size * 0.3),
),
child: Text(
code.length > 4 ? code.substring(0, 4) : code,
style: ts(size * 0.28, w: w7, c: dark ? Colors.white : C.navy),
),
);
}
class Pill extends StatelessWidget {
const Pill(
this.text, {
super.key,
this.bg = C.chip,
this.fg = C.navy,
this.icon,
});
final String text;
final Color bg, fg;
final IconData? icon;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: S.s10, vertical: S.s4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(R.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 12, color: fg),
const SizedBox(width: S.s4),
],
Text(
text,
style: ts(11, w: w6, c: fg),
),
],
),
);
}
// -------------------------------------------------------------- buttons
class BigButton extends StatelessWidget {
const BigButton(
this.label, {
super.key,
required this.onPressed,
this.loading = false,
this.danger = false,
this.outline = false,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final bool loading, danger, outline;
final IconData? icon;
@override
Widget build(BuildContext context) {
final fg = outline ? (danger ? C.red : C.navy) : Colors.white;
final bg = outline ? Colors.transparent : (danger ? C.red : C.navy);
final enabled = onPressed != null && !loading;
return Opacity(
opacity: onPressed == null && !loading ? 0.45 : 1,
child: Pressable(
onTap: enabled ? onPressed : null,
radius: R.button,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 54,
padding: const EdgeInsets.symmetric(horizontal: S.s16),
alignment: Alignment.center,
decoration: BoxDecoration(
color: hover
? (outline
? (danger ? C.redTint : C.chip)
: (danger ? C.redDeep : C.navyDeep))
: bg,
borderRadius: BorderRadius.circular(R.button),
border: outline
? Border.all(color: danger ? C.red : C.border2, width: 1.5)
: null,
),
child: loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2.4, color: fg),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: fg),
const SizedBox(width: S.s8),
],
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(15, w: w6, c: fg),
),
),
],
),
),
),
);
}
}
class SmallButton extends StatelessWidget {
const SmallButton(
this.label, {
super.key,
required this.onTap,
this.icon = Icons.add_rounded,
});
final String label;
final VoidCallback onTap;
final IconData icon;
@override
Widget build(BuildContext context) => Pressable(
onTap: onTap,
radius: R.pill,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 40,
padding: const EdgeInsets.fromLTRB(S.s12, 0, S.s16, 0),
decoration: BoxDecoration(
color: hover ? C.navyDeep : C.navy,
borderRadius: BorderRadius.circular(R.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: Colors.white),
const SizedBox(width: S.s4),
Text(
label,
style: ts(13, w: w6, c: Colors.white),
),
],
),
),
);
}
class RoundIcon extends StatelessWidget {
const RoundIcon(
this.icon, {
super.key,
required this.onTap,
required this.label,
this.size = 42,
this.dark = false,
});
final IconData icon;
final VoidCallback onTap;
/// Tooltip and screen-reader label; the button shows only an icon.
final String label;
final double size;
final bool dark;
@override
Widget build(BuildContext context) => Tooltip(
message: label,
excludeFromSemantics: true,
child: Pressable(
onTap: onTap,
label: label,
radius: size / 2,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
width: size,
height: size,
decoration: BoxDecoration(
color: dark
? (hover ? C.navyDeep : C.navy)
: (hover ? C.chip : Colors.white),
shape: BoxShape.circle,
border: dark ? null : Border.all(color: hover ? C.border2 : C.border),
),
child: Icon(icon, size: 20, color: dark ? Colors.white : C.navy),
),
),
);
}
// --------------------------------------------------------------- fields
class SrsField extends StatefulWidget {
const SrsField({
super.key,
required this.controller,
required this.label,
this.hint,
this.helper,
this.password = false,
this.prefix,
this.keyboard,
this.onChanged,
this.onSubmitted,
this.enabled = true,
this.autofocus = false,
this.formatters,
this.error = false,
this.textCapitalization = TextCapitalization.none,
this.autofill,
});
final TextEditingController controller;
final String label;
final String? hint, helper;
final bool password, enabled, autofocus, error;
final String? prefix;
final TextInputType? keyboard;
final ValueChanged<String>? onChanged, onSubmitted;
final List<TextInputFormatter>? formatters;
final TextCapitalization textCapitalization;
final List<String>? autofill;
@override
State<SrsField> createState() => _SrsFieldState();
}
class _SrsFieldState extends State<SrsField> {
bool _hide = true;
OutlineInputBorder _b(Color c, [double w = 1]) => OutlineInputBorder(
borderRadius: BorderRadius.circular(R.input),
borderSide: BorderSide(color: c, width: w),
);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.label,
style: ts(12, w: w6, c: C.muted),
),
const SizedBox(height: S.s6),
TextField(
controller: widget.controller,
enabled: widget.enabled,
autofocus: widget.autofocus,
obscureText: widget.password && _hide,
keyboardType: widget.keyboard,
inputFormatters: widget.formatters,
textCapitalization: widget.textCapitalization,
autofillHints: widget.autofill,
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
style: ts(15, w: w5),
decoration: InputDecoration(
filled: true,
fillColor: widget.enabled ? Colors.white : C.fieldDisabled,
hintText: widget.hint,
hintStyle: ts(15, c: C.placeholder),
prefixText: widget.prefix,
prefixStyle: ts(15, w: w6, c: C.muted),
contentPadding: const EdgeInsets.symmetric(
horizontal: S.s16,
vertical: S.s16,
),
border: _b(widget.error ? C.red : C.border2),
enabledBorder: _b(widget.error ? C.red : C.border2),
disabledBorder: _b(C.border),
focusedBorder: _b(widget.error ? C.red : C.navy, 1.6),
suffixIcon: widget.password
? IconButton(
tooltip: _hide ? 'Show password' : 'Hide password',
onPressed: () => setState(() => _hide = !_hide),
icon: Icon(
_hide
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
size: 20,
color: C.muted,
),
)
: null,
),
),
if (widget.helper != null) ...[
const SizedBox(height: S.s6),
Text(widget.helper!, style: ts(12, c: C.muted)),
],
],
);
}
}
class SearchField extends StatelessWidget {
const SearchField({super.key, required this.hint, required this.onChanged});
final String hint;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) => TextField(
onChanged: onChanged,
style: ts(14),
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
hintText: hint,
hintStyle: ts(14, c: C.placeholder),
prefixIcon: const Icon(
Icons.search_rounded,
color: C.placeholder,
size: 20,
),
contentPadding: const EdgeInsets.symmetric(vertical: S.s14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(R.input),
borderSide: const BorderSide(color: C.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(R.input),
borderSide: const BorderSide(color: C.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(R.input),
borderSide: const BorderSide(color: C.navy, width: 1.4),
),
),
);
}
class ErrorLine extends StatelessWidget {
const ErrorLine(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) => Row(
children: [
const Icon(Icons.error_outline_rounded, size: 16, color: C.red),
const SizedBox(width: S.s6),
Expanded(
child: Text(
text,
style: ts(13, w: w5, c: C.red),
),
),
],
);
}
// -------------------------------------------------------------- headers
class ScreenHeader extends StatelessWidget {
const ScreenHeader({
super.key,
required this.title,
this.trailing,
this.back = false,
this.subtitle,
this.maxWidth = Bp.pageMax,
});
final String title;
final String? subtitle;
final Widget? trailing;
final bool back;
/// Width of the page column the header lines up with.
final double maxWidth;
@override
Widget build(BuildContext context) {
final wide = isWide(context);
return PagePad(
maxWidth: maxWidth,
top: wide ? S.s28 : S.s14,
bottom: wide ? S.s14 : S.s8,
child: _row(wide, context),
);
}
Widget _row(bool wide, BuildContext context) => Row(
children: [
if (back) ...[
RoundIcon(
Icons.arrow_back_rounded,
label: 'Back',
onTap: () =>
context.canPop() ? context.pop() : context.go('/results'),
),
const SizedBox(width: S.s12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(
back ? (wide ? 24 : 20) : (wide ? 30 : 26),
w: w7,
ls: -0.4,
),
),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: S.s2),
child: Text(subtitle!, style: ts(13, c: C.muted)),
),
],
),
),
?trailing,
],
);
}
class SectionTitle extends StatelessWidget {
const SectionTitle(this.text, {super.key, this.trailing});
final String text;
final String? trailing;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(S.s2, S.s22, S.s2, S.s10),
child: Row(
children: [
Expanded(
child: Text(text, style: ts(17, w: w7)),
),
if (trailing != null) Text(trailing!, style: ts(12, c: C.muted)),
],
),
);
}
class StatCard extends StatelessWidget {
const StatCard({
super.key,
required this.label,
required this.value,
this.valueColor = C.ink,
this.tile,
});
final String label, value;
final Color valueColor;
final String? tile;
@override
Widget build(BuildContext context) => SrsCard(
padding: const EdgeInsets.all(S.s16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: ts(11, w: w6, c: C.muted, ls: 0.8),
),
const SizedBox(height: S.s8),
Row(
children: [
if (tile != null) ...[
CodeTile(tile!, size: 30),
const SizedBox(width: S.s8),
],
Flexible(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(22, w: w7, c: valueColor, ls: -0.3),
),
),
],
),
],
),
);
}
// ------------------------------------------------------- async + states
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
this.body,
});
final IconData icon;
final String title;
final String? body;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: S.s36, horizontal: S.s24),
child: Column(
children: [
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
color: C.chip,
shape: BoxShape.circle,
),
child: Icon(icon, color: C.navy, size: 28),
),
const SizedBox(height: S.s16),
Text(
title,
textAlign: TextAlign.center,
style: ts(16, w: w7),
),
if (body != null) ...[
const SizedBox(height: S.s6),
Text(
body!,
textAlign: TextAlign.center,
style: ts(13, c: C.muted, h: 1.45),
),
],
],
),
);
}
Widget asyncView<T>(
AsyncValue<T> v,
Widget Function(T data) builder, {
VoidCallback? retry,
}) {
return v.when(
skipLoadingOnReload: true,
data: builder,
loading: () => const Center(
child: SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 3, color: C.navy),
),
),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(S.s28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_rounded, size: 36, color: C.muted),
const SizedBox(height: S.s12),
Text(
e is Exception ? e.toString() : 'Something went wrong.',
textAlign: TextAlign.center,
style: ts(14, c: C.muted),
),
if (retry != null) ...[
const SizedBox(height: S.s14),
TextButton(
onPressed: retry,
child: Text(
'Retry',
style: ts(14, w: w6, c: C.navy),
),
),
],
],
),
),
),
);
}
// ------------------------------------------------------ sheets & dialogs
Future<T?> showSheet<T>(
BuildContext context,
Widget Function(BuildContext) builder,
) {
if (isWide(context)) {
// Tablet and desktop: a centred dialog instead of a full-width sheet.
return showDialog<T>(
context: context,
useRootNavigator: true,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(S.s24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(S.s28),
child: builder(ctx),
),
Positioned(
top: S.s12,
right: S.s12,
child: IconButton(
tooltip: 'Close',
onPressed: () => Navigator.pop(ctx),
icon: const Icon(Icons.close_rounded, color: C.muted),
),
),
],
),
),
),
);
}
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
useSafeArea: true,
useRootNavigator: true,
builder: (ctx) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(S.s22, S.s10, S.s22, S.s24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: S.s18),
decoration: BoxDecoration(
color: C.border2,
borderRadius: BorderRadius.circular(R.sm),
),
),
),
builder(ctx),
],
),
),
),
);
}
Future<bool> confirm(
BuildContext context, {
required String title,
required String body,
required String confirmLabel,
bool danger = true,
}) async {
final r = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
contentPadding: const EdgeInsets.fromLTRB(S.s24, S.s22, S.s24, S.s8),
actionsPadding: const EdgeInsets.fromLTRB(S.s16, S.s4, S.s16, S.s14),
title: null,
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: danger ? C.redTint : C.chip,
shape: BoxShape.circle,
),
child: Icon(
danger
? Icons.delete_outline_rounded
: Icons.help_outline_rounded,
color: danger ? C.red : C.navy,
size: 24,
),
),
const SizedBox(height: S.s16),
Text(title, style: ts(18, w: w7)),
const SizedBox(height: S.s8),
Text(body, style: ts(14, c: C.muted, h: 1.5)),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(
'Cancel',
style: ts(14, w: w6, c: C.muted),
),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(
confirmLabel,
style: ts(14, w: w7, c: danger ? C.red : C.navy),
),
),
],
),
);
return r ?? false;
}
void toast(BuildContext context, String msg) {
final m = ScaffoldMessenger.of(context);
m.hideCurrentSnackBar();
m.showSnackBar(
SnackBar(content: Text(msg), duration: const Duration(seconds: 3)),
);
}
String errText(Object e) =>
e is Exception ? e.toString() : 'Something went wrong. Please try again.';
// ----------------------------------------------------------- date strip
class DateStrip extends ConsumerWidget {
const DateStrip({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final d = ref.watch(selectedDateProvider);
final n = ref.read(selectedDateProvider.notifier);
return Row(
children: [
RoundIcon(
Icons.chevron_left_rounded,
label: 'Previous day',
size: 40,
onTap: () => n.set(addDays(d, -1)),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: S.s10),
child: Builder(
builder: (anchor) => Pressable(
onTap: () => showCalendarPicker(anchor),
radius: R.pill,
label: 'Choose date',
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: hover ? C.hover : Colors.white,
borderRadius: BorderRadius.circular(R.pill),
border: Border.all(color: hover ? C.border2 : C.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.calendar_month_rounded,
size: 16,
color: C.navy,
),
const SizedBox(width: S.s8),
Text(
dateLong(d),
style: ts(13, w: w6, c: C.navy),
),
],
),
),
),
),
),
),
RoundIcon(
Icons.chevron_right_rounded,
label: 'Next day',
size: 40,
onTap: () => n.set(addDays(d, 1)),
),
],
);
}
}