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.
1083 lines
30 KiB
Dart
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)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|