1432 lines
38 KiB
Dart
1432 lines
38 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
|
|
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(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
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.subtitleLoading = false,
|
|
this.maxWidth = Bp.pageMax,
|
|
});
|
|
final String title;
|
|
final String? subtitle;
|
|
final bool subtitleLoading;
|
|
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)),
|
|
)
|
|
else if (subtitleLoading)
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: S.s2),
|
|
child: SizedBox(
|
|
height: 16,
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Skeleton(
|
|
announce: false,
|
|
child: Bone(width: 120, height: 10),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
?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: _LoadError(error: e, retry: retry),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _LoadError extends StatelessWidget {
|
|
const _LoadError({required this.error, this.retry});
|
|
final Object error;
|
|
final VoidCallback? retry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => 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(
|
|
error is Exception ? error.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),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> refreshData(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
ProviderListenable<AsyncValue<Object?>> screen,
|
|
) async {
|
|
ref.read(dataVersionProvider.notifier).bump();
|
|
final before = ref.read(screen).error;
|
|
final done = Completer<bool>();
|
|
void finish(bool ok) {
|
|
if (!done.isCompleted) done.complete(ok);
|
|
}
|
|
|
|
final sub = ref.listenManual(
|
|
screen,
|
|
(_, next) {
|
|
if (next.hasError && !identical(next.error, before)) {
|
|
finish(false);
|
|
} else if (!next.isLoading) {
|
|
finish(!next.hasError);
|
|
}
|
|
},
|
|
onError: (e, _) {
|
|
if (!identical(e, before)) finish(false);
|
|
},
|
|
);
|
|
if (!ref.read(screen).isLoading) done.complete(true);
|
|
final ok = await done.future.timeout(
|
|
const Duration(seconds: 15),
|
|
onTimeout: () => false,
|
|
);
|
|
sub.close();
|
|
if (!ok && context.mounted && ref.read(screen).hasValue) {
|
|
toast(context, "Couldn't refresh. Check your connection and try again.");
|
|
}
|
|
}
|
|
|
|
class RefreshableView<T> extends StatefulWidget {
|
|
const RefreshableView({
|
|
super.key,
|
|
required this.value,
|
|
required this.builder,
|
|
required this.skeleton,
|
|
required this.onRefresh,
|
|
});
|
|
final AsyncValue<T> value;
|
|
final Widget Function(T data) builder;
|
|
|
|
final Widget skeleton;
|
|
final Future<void> Function() onRefresh;
|
|
|
|
@override
|
|
State<RefreshableView<T>> createState() => _RefreshableViewState<T>();
|
|
}
|
|
|
|
class _RefreshableViewState<T> extends State<RefreshableView<T>> {
|
|
bool _pulling = false;
|
|
bool _retrying = false;
|
|
|
|
Future<void> _pull() async {
|
|
setState(() => _pulling = true);
|
|
try {
|
|
await widget.onRefresh();
|
|
} finally {
|
|
if (mounted) setState(() => _pulling = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _retry() async {
|
|
setState(() => _retrying = true);
|
|
try {
|
|
await widget.onRefresh();
|
|
} finally {
|
|
if (mounted) setState(() => _retrying = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final v = widget.value;
|
|
final Widget body;
|
|
if (v.hasValue) {
|
|
body = KeyedSubtree(
|
|
key: const ValueKey('data'),
|
|
child: widget.builder(v.requireValue),
|
|
);
|
|
} else if (v.hasError && !_retrying) {
|
|
body = LayoutBuilder(
|
|
key: const ValueKey('error'),
|
|
builder: (context, c) => ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
children: [
|
|
SizedBox(
|
|
height: c.maxHeight,
|
|
child: Center(
|
|
child: _LoadError(error: v.error!, retry: _retry),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
} else {
|
|
body = KeyedSubtree(
|
|
key: const ValueKey('loading'),
|
|
child: widget.skeleton,
|
|
);
|
|
}
|
|
final still = MediaQuery.disableAnimationsOf(context);
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: RefreshIndicator(
|
|
onRefresh: _pull,
|
|
color: C.navy,
|
|
backgroundColor: Colors.white,
|
|
child: AnimatedSwitcher(
|
|
duration: still
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 150),
|
|
layoutBuilder: (current, previous) => Stack(
|
|
fit: StackFit.expand,
|
|
children: [...previous, ?current],
|
|
),
|
|
child: body,
|
|
),
|
|
),
|
|
),
|
|
if (v.hasValue && v.isLoading && !_pulling)
|
|
const Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: LinearProgressIndicator(
|
|
minHeight: 2,
|
|
color: C.navy,
|
|
backgroundColor: Colors.transparent,
|
|
semanticsLabel: 'Refreshing',
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class Skeleton extends StatefulWidget {
|
|
const Skeleton({super.key, required this.child, this.announce = true});
|
|
final Widget child;
|
|
final bool announce;
|
|
|
|
@override
|
|
State<Skeleton> createState() => _SkeletonState();
|
|
}
|
|
|
|
class _SkeletonState extends State<Skeleton>
|
|
with SingleTickerProviderStateMixin {
|
|
late final _c = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 900),
|
|
);
|
|
late final _fade = Tween(
|
|
begin: 1.0,
|
|
end: 0.55,
|
|
).animate(CurvedAnimation(parent: _c, curve: Curves.easeInOut));
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (MediaQuery.disableAnimationsOf(context)) {
|
|
_c.value = 0;
|
|
} else if (!_c.isAnimating) {
|
|
_c.repeat(reverse: true);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_c.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pulse = ExcludeSemantics(
|
|
child: FadeTransition(opacity: _fade, child: widget.child),
|
|
);
|
|
if (!widget.announce) return pulse;
|
|
return Semantics(
|
|
container: true,
|
|
label: 'Loading',
|
|
liveRegion: true,
|
|
child: pulse,
|
|
);
|
|
}
|
|
}
|
|
|
|
class SkeletonList extends StatelessWidget {
|
|
const SkeletonList({super.key, required this.children, this.top = S.s4});
|
|
final List<Widget> children;
|
|
final double top;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Skeleton(
|
|
child: PageList(top: top, children: children),
|
|
);
|
|
}
|
|
|
|
class Bone extends StatelessWidget {
|
|
const Bone({
|
|
super.key,
|
|
this.width,
|
|
this.factor,
|
|
this.height = 12,
|
|
this.radius = R.sm,
|
|
this.color = C.skeleton,
|
|
});
|
|
final double? width, factor;
|
|
final double height, radius;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final box = Container(
|
|
width: width,
|
|
height: height,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
borderRadius: BorderRadius.circular(radius),
|
|
),
|
|
);
|
|
if (factor == null) return box;
|
|
return FractionallySizedBox(
|
|
alignment: Alignment.centerLeft,
|
|
widthFactor: factor,
|
|
child: box,
|
|
);
|
|
}
|
|
}
|
|
|
|
class TileCardBone extends StatelessWidget {
|
|
const TileCardBone({super.key, this.figure = true, this.nameFactor = 0.62});
|
|
final bool figure;
|
|
final double nameFactor;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SrsCard(
|
|
padding: const EdgeInsets.all(S.s14),
|
|
child: Row(
|
|
children: [
|
|
const Bone(width: 46, height: 46, radius: 46 * 0.3),
|
|
const SizedBox(width: S.s14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Bone(factor: nameFactor, height: 14),
|
|
const SizedBox(height: S.s10),
|
|
const Bone(factor: 0.4, height: 10),
|
|
],
|
|
),
|
|
),
|
|
if (figure) ...[
|
|
const SizedBox(width: S.s8),
|
|
const Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Bone(width: 40, height: 16),
|
|
SizedBox(height: S.s6),
|
|
Bone(width: 30, height: 8),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class TileGridBone extends StatelessWidget {
|
|
const TileGridBone({super.key, this.count = 6, this.figure = true});
|
|
final int count;
|
|
final bool figure;
|
|
|
|
static const _names = [0.62, 0.48, 0.7, 0.55, 0.66, 0.5];
|
|
|
|
@override
|
|
Widget build(BuildContext context) => CardGrid(
|
|
children: [
|
|
for (var i = 0; i < count; i++)
|
|
TileCardBone(figure: figure, nameFactor: _names[i % _names.length]),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------ 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)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class BrandCoin extends StatelessWidget {
|
|
const BrandCoin({super.key, required this.size, this.glow = true});
|
|
final double size;
|
|
final bool glow;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
boxShadow: glow
|
|
? [
|
|
BoxShadow(
|
|
color: C.goldGlow,
|
|
blurRadius: size * 0.36,
|
|
offset: Offset(0, size * 0.12),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Image.asset(
|
|
'assets/images/logo_coin.png',
|
|
width: size,
|
|
height: size,
|
|
excludeFromSemantics: true,
|
|
filterQuality: FilterQuality.medium,
|
|
),
|
|
);
|
|
}
|