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 createState() => _PressableState(); } class _PressableState extends State { 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( 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 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 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 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 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? onChanged, onSubmitted; final List? formatters; final TextCapitalization textCapitalization; final List? autofill; @override State createState() => _SrsFieldState(); } class _SrsFieldState extends State { 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 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( AsyncValue 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 showSheet( BuildContext context, Widget Function(BuildContext) builder, ) { if (isWide(context)) { // Tablet and desktop: a centred dialog instead of a full-width sheet. return showDialog( 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( 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 confirm( BuildContext context, { required String title, required String body, required String confirmLabel, bool danger = true, }) async { final r = await showDialog( 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)), ), ], ); } }