srs/lib/widgets/ui.dart
Aswin Biju 32bdca2ef9
All checks were successful
deploy / deploy (push) Successful in 4m50s
refactor: updated login screen and favicon
2026-11-05 15:33:48 +05:30

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