feat: loading skeletons, pull-to-refresh and reload bar on list screens
All checks were successful
deploy / deploy (push) Successful in 5m18s

This commit is contained in:
Aswin B. S 2026-11-05 08:04:55 +05:30
parent 78c77f708c
commit 125f4d3e86
14 changed files with 1006 additions and 239 deletions

View File

@ -59,6 +59,7 @@ class DrawInfo {
final bool hasWin;
String get label => published ? '$scheme · $code' : 'No result yet';
}
class PrizeHit {
const PrizeHit({required this.tier, required this.amount});

View File

@ -32,11 +32,12 @@ class SessionNotifier extends Notifier<AppUser?> {
Future<void> login(String username, String password) async {
final api = ref.read(apiProvider);
final u = await api.login(username, password);
state = u;
try {
ref.read(selectedDateProvider.notifier).set(await api.latestDrawDate());
} on ApiException {// ignore: empty_catches
} on ApiException {
// ignore: empty_catches
}
state = u;
}
Future<void> logout() async {

View File

@ -34,6 +34,7 @@ class C {
static const shadowNav = Color(0x14101B33);
static const disabled = Color(0xFFF0EEE8);
static const fieldDisabled = Color(0xFFEFEDE6);
static const skeleton = Color(0xFFECE8DF);
}
/// Width breakpoints, measured on the window, not the device.

View File

@ -125,12 +125,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
top: Radius.circular(R.hero),
),
),
padding: const EdgeInsets.fromLTRB(
S.s24,
S.s28,
S.s24,
S.s24,
),
padding: const EdgeInsets.fromLTRB(S.s24, S.s28, S.s24, S.s24),
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),

View File

@ -22,9 +22,13 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
Widget build(BuildContext context) {
final date = ref.watch(selectedDateProvider);
final orders = ref.watch(ordersProvider(date));
Future<void> refresh() => refreshData(context, ref, ordersProvider(date));
return Column(
children: [
const ScreenHeader(title: 'Orders'),
ScreenHeader(
title: 'Orders',
trailing: isWide(context) ? RefreshButton(onRefresh: refresh) : null,
),
const PagePad(
top: S.s4,
child: Align(
@ -33,77 +37,100 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
),
),
Expanded(
child: asyncView(orders, (all) {
final codes = <String>{for (final o in all) o.shop.code}.toList()
..sort();
final active = codes.contains(_code) ? _code : null;
final list = active == null
? all
: all.where((o) => o.shop.code == active).toList();
final tickets = list.fold(0, (a, o) => a + o.tickets);
return PageList(
child: RefreshableView(
value: orders,
onRefresh: refresh,
skeleton: const SkeletonList(
top: S.s14,
children: [
if (codes.length > 1)
SizedBox(
height: 38,
child: ListView(
scrollDirection: Axis.horizontal,
Padding(
padding: EdgeInsets.fromLTRB(S.s2, S.s18, S.s2, S.s10),
child: SizedBox(
height: 18,
child: Row(
children: [
_chip(
'All',
active == null,
() => setState(() => _code = null),
),
for (final c in codes)
Bone(width: 180, height: 14),
Spacer(),
Bone(width: 110, height: 10),
],
),
),
),
TileGridBone(),
],
),
builder: (all) {
final codes = <String>{for (final o in all) o.shop.code}.toList()
..sort();
final active = codes.contains(_code) ? _code : null;
final list = active == null
? all
: all.where((o) => o.shop.code == active).toList();
final tickets = list.fold(0, (a, o) => a + o.tickets);
return PageList(
top: S.s14,
children: [
if (codes.length > 1)
SizedBox(
height: 38,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_chip(
c,
active == c,
() => setState(() => _code = c),
'All',
active == null,
() => setState(() => _code = null),
),
for (final c in codes)
_chip(
c,
active == c,
() => setState(() => _code = c),
),
],
),
),
if (list.isEmpty)
const EmptyState(
icon: Icons.receipt_long_outlined,
title: 'No orders for this date',
body:
'Orders received via WhatsApp will appear here. Select another date to view earlier orders.',
)
else ...[
Padding(
padding: const EdgeInsets.fromLTRB(
S.s2,
S.s18,
S.s2,
S.s10,
),
child: Row(
children: [
Expanded(
child: Text(dateLong(date), style: ts(15, w: w7)),
),
Text(
'${plural(list.length, 'order')} · ${plural(tickets, 'ticket')}',
style: ts(12, c: C.muted),
),
],
),
),
CardGrid(
children: [
for (final o in list)
OrderCard(
order: o,
onTap: () => context.push('/orders/${o.id}'),
),
],
),
),
if (list.isEmpty)
const EmptyState(
icon: Icons.receipt_long_outlined,
title: 'No orders for this date',
body:
'Orders received via WhatsApp will appear here. Select another date to view earlier orders.',
)
else ...[
Padding(
padding: const EdgeInsets.fromLTRB(
S.s2,
S.s18,
S.s2,
S.s10,
),
child: Row(
children: [
Expanded(
child: Text(dateLong(date), style: ts(15, w: w7)),
),
Text(
'${plural(list.length, 'order')} · ${plural(tickets, 'ticket')}',
style: ts(12, c: C.muted),
),
],
),
),
CardGrid(
children: [
for (final o in list)
OrderCard(
order: o,
onTap: () => context.push('/orders/${o.id}'),
),
],
),
],
],
],
);
}, retry: () => ref.invalidate(ordersProvider(date))),
);
},
),
),
],
);

View File

@ -159,13 +159,12 @@ class _CalendarPickerState extends ConsumerState<_CalendarPicker> {
final today = dateOnly(ref.read(apiProvider).now);
final days = ref.watch(monthProvider((_month.year, _month.month)));
final byDay = <int, DrawInfo>{
for (final d in days.asData?.value ?? const <DrawInfo>[]) d.date.day: d,
for (final d in days.value ?? const <DrawInfo>[]) d.date.day: d,
};
final pickedInfo = ref
.watch(monthProvider((_picked.year, _picked.month)))
.asData
?.value
.where((d) => sameDay(d.date, _picked))
.value
?.where((d) => sameDay(d.date, _picked))
.firstOrNull;
final first = DateTime(_month.year, _month.month);

View File

@ -17,6 +17,7 @@ class ResultsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final date = ref.watch(selectedDateProvider);
final res = ref.watch(resultsProvider(date));
Future<void> refresh() => refreshData(context, ref, resultsProvider(date));
return Column(
children: [
ScreenHeader(
@ -24,6 +25,10 @@ class ResultsScreen extends ConsumerWidget {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isWide(context)) ...[
RefreshButton(onRefresh: refresh),
const SizedBox(width: S.s10),
],
Builder(
builder: (anchor) => RoundIcon(
Icons.calendar_month_rounded,
@ -44,10 +49,11 @@ class ResultsScreen extends ConsumerWidget {
),
),
Expanded(
child: asyncView(
res,
(r) => _Body(results: r, date: date),
retry: () => ref.invalidate(resultsProvider(date)),
child: RefreshableView(
value: res,
onRefresh: refresh,
skeleton: _Loading(date: date),
builder: (r) => _Body(results: r, date: date),
),
),
],
@ -55,47 +61,147 @@ class ResultsScreen extends ConsumerWidget {
}
}
class _Body extends ConsumerWidget {
class _DrawRow extends ConsumerWidget {
const _DrawRow({required this.date, required this.info});
final DateTime date;
final Widget info;
@override
Widget build(BuildContext context, WidgetRef ref) {
final n = ref.read(selectedDateProvider.notifier);
return Row(
children: [
Expanded(child: info),
RoundIcon(
Icons.chevron_left_rounded,
label: 'Previous draw',
size: 40,
onTap: () => n.set(addDays(date, -1)),
),
const SizedBox(width: S.s8),
RoundIcon(
Icons.chevron_right_rounded,
label: 'Next draw',
size: 40,
onTap: () => n.set(addDays(date, 1)),
),
],
);
}
}
/// Skeleton of the Results page.
class _Loading extends StatelessWidget {
const _Loading({required this.date});
final DateTime date;
@override
Widget build(BuildContext context) {
const onNavy = C.onNavy8;
Widget line(double h, Bone bone) => SizedBox(
height: h,
child: Align(alignment: Alignment.centerLeft, child: bone),
);
Widget stat() => SrsCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
line(16, const Bone(width: 72, height: 10)),
const SizedBox(height: S.s8),
line(30, const Bone(width: 96, height: 22)),
],
),
);
return PageList(
children: [
_DrawRow(
date: date,
info: Skeleton(
announce: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
line(22, const Bone(width: 120, height: 14)),
const SizedBox(height: S.s2),
line(18, const Bone(width: 180, height: 10)),
],
),
),
),
const SizedBox(height: S.s16),
Skeleton(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_HeroRow(
hero: NavyCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
line(
15,
const Bone(width: 90, height: 10, color: onNavy),
),
const SizedBox(height: S.s10),
line(
58,
const Bone(
width: 200,
height: 38,
radius: R.md,
color: onNavy,
),
),
const SizedBox(height: S.s10),
line(
18,
const Bone(width: 170, height: 10, color: onNavy),
),
],
),
),
stats: [stat(), stat()],
),
const Padding(
padding: EdgeInsets.fromLTRB(S.s2, S.s22, S.s2, S.s10),
child: SizedBox(
height: 21,
child: Align(
alignment: Alignment.centerLeft,
child: Bone(width: 64, height: 14),
),
),
),
const TileGridBone(count: 4),
],
),
),
],
);
}
}
class _Body extends StatelessWidget {
const _Body({required this.results, required this.date});
final DrawResults results;
final DateTime date;
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final r = results;
final d = r.draw;
final n = ref.read(selectedDateProvider.notifier);
return PageList(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(d.label, style: ts(16, w: w7)),
const SizedBox(height: S.s2),
Text(
'${dateLong(d.date)} · 3:00 PM',
style: ts(13, c: C.muted),
),
],
),
),
RoundIcon(
Icons.chevron_left_rounded,
label: 'Previous draw',
size: 40,
onTap: () => n.set(addDays(date, -1)),
),
const SizedBox(width: S.s8),
RoundIcon(
Icons.chevron_right_rounded,
label: 'Next draw',
size: 40,
onTap: () => n.set(addDays(date, 1)),
),
],
_DrawRow(
date: date,
info: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(d.label, style: ts(16, w: w7)),
const SizedBox(height: S.s2),
Text('${dateLong(d.date)} · 3:00 PM', style: ts(13, c: C.muted)),
],
),
),
const SizedBox(height: S.s16),
if (d.published) ..._published(context, r) else ..._notOut(context, r),

View File

@ -24,7 +24,9 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
final date = ref.watch(selectedDateProvider);
final isAdmin = ref.watch(sessionProvider)?.isAdmin ?? false;
final shops = ref.watch(shopsProvider(date));
final count = shops.asData?.value.where((s) => s.shop.active).length;
final count = shops.value?.where((s) => s.shop.active).length;
Future<void> refresh() => refreshData(context, ref, shopsProvider(date));
final wide = isWide(context);
return Column(
children: [
@ -33,9 +35,21 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
subtitle: count == null
? null
: '${plural(count, 'shop')} · ${dateShort(date)}',
trailing: isAdmin
? SmallButton('Add shop', onTap: () => showShopSheet(context))
: null,
subtitleLoading: shops.isLoading,
trailing: !wide && !isAdmin
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (wide) RefreshButton(onRefresh: refresh),
if (wide && isAdmin) const SizedBox(width: S.s10),
if (isAdmin)
SmallButton(
'Add shop',
onTap: () => showShopSheet(context),
),
],
),
),
PagePad(
top: S.s4,
@ -48,39 +62,48 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
),
),
Expanded(
child: asyncView(shops, (list) {
final f = list
.where(
(s) =>
_q.isEmpty ||
s.shop.name.toLowerCase().contains(_q) ||
s.shop.code.toLowerCase().contains(_q),
)
.toList();
if (f.isEmpty) {
return ListView(
child: RefreshableView(
value: shops,
onRefresh: refresh,
skeleton: const SkeletonList(
top: S.s8,
children: [TileGridBone(figure: false)],
),
builder: (list) {
final f = list
.where(
(s) =>
_q.isEmpty ||
s.shop.name.toLowerCase().contains(_q) ||
s.shop.code.toLowerCase().contains(_q),
)
.toList();
if (f.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
EmptyState(
icon: Icons.storefront_outlined,
title: list.isEmpty
? 'No shops yet'
: 'No results for “$_q”',
body: list.isEmpty
? (isAdmin
? 'Add a shop to get started.'
: 'No shops are assigned to your account. Contact your administrator.')
: null,
),
],
);
}
return PageList(
top: S.s8,
children: [
EmptyState(
icon: Icons.storefront_outlined,
title: list.isEmpty
? 'No shops yet'
: 'No results for “$_q”',
body: list.isEmpty
? (isAdmin
? 'Add a shop to get started.'
: 'No shops are assigned to your account. Contact your administrator.')
: null,
),
CardGrid(children: [for (final s in f) _ShopTile(s: s)]),
],
);
}
return PageList(
top: S.s8,
children: [
CardGrid(children: [for (final s in f) _ShopTile(s: s)]),
],
);
}, retry: () => ref.invalidate(shopsProvider(date))),
},
),
),
],
);

View File

@ -19,7 +19,7 @@ class UserDetailScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider(userId));
final shops =
ref.watch(activeShopsProvider).asData?.value ?? const <Shop>[];
ref.watch(activeShopsProvider).value ?? const <Shop>[];
return Scaffold(
body: SafeArea(
child: asyncView(
@ -366,7 +366,9 @@ class _ToggleRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The switch carries the name for screen readers.
ExcludeSemantics(child: Text(title, style: ts(15, w: w6))),
ExcludeSemantics(
child: Text(title, style: ts(15, w: w6)),
),
const SizedBox(height: S.s2),
Text(description, style: ts(12, c: C.muted, h: 1.35)),
if (status != null) ...[const SizedBox(height: S.s4), status!],

View File

@ -31,11 +31,11 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
@override
Widget build(BuildContext context) {
final users = ref.watch(usersProvider);
final shops =
ref.watch(activeShopsProvider).asData?.value ?? const <Shop>[];
final shops = ref.watch(activeShopsProvider).value ?? const <Shop>[];
final byId = {for (final s in shops) s.id: s};
final list = users.asData?.value;
final list = users.value;
final off = list?.where((u) => !u.active).length ?? 0;
Future<void> refresh() => refreshData(context, ref, usersProvider);
return Column(
children: [
@ -46,68 +46,232 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
: off == 0
? plural(list.length, 'user')
: '${plural(list.length, 'user')}, $off inactive',
trailing: SmallButton(
'Add user',
onTap: () => context.push('/users/new'),
subtitleLoading: users.isLoading,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isWide(context)) ...[
RefreshButton(onRefresh: refresh),
const SizedBox(width: S.s10),
],
SmallButton('Add user', onTap: () => context.push('/users/new')),
],
),
),
Expanded(
child: asyncView(users, (all) {
final counts = {
_Filter.all: all.length,
_Filter.active: all.where((u) => u.active).length,
_Filter.off: all.where((u) => !u.active).length,
};
final shown =
all
.where(_keep)
.where(
(u) =>
_q.isEmpty ||
u.username.toLowerCase().contains(_q) ||
(u.phone ?? '').contains(_q),
child: RefreshableView(
value: users,
onRefresh: refresh,
skeleton: const _Loading(),
builder: (all) {
final counts = {
_Filter.all: all.length,
_Filter.active: all.where((u) => u.active).length,
_Filter.off: all.where((u) => !u.active).length,
};
final shown =
all
.where(_keep)
.where(
(u) =>
_q.isEmpty ||
u.username.toLowerCase().contains(_q) ||
(u.phone ?? '').contains(_q),
)
.toList()
// Inactive accounts last.
..sort((a, b) {
final r = (a.active ? 0 : 1).compareTo(b.active ? 0 : 1);
return r != 0 ? r : a.username.compareTo(b.username);
});
return PageList(
top: S.s4,
children: [
_Toolbar(
filter: _filter,
counts: counts,
onFilter: (f) => setState(() => _filter = f),
onSearch: (v) =>
setState(() => _q = v.trim().toLowerCase()),
),
const SizedBox(height: S.s14),
if (shown.isEmpty)
SrsCard(
child: EmptyState(
icon: Icons.person_search_rounded,
title: _q.isNotEmpty
? 'No one matches “$_q”'
: 'No one here',
body: _filter == _Filter.off
? 'Every user can sign in.'
: all.isEmpty
? 'Add a user to let them sign in and see their shops.'
: 'Try another filter, or add a user.',
),
)
.toList()
// Inactive accounts last.
..sort((a, b) {
final r = (a.active ? 0 : 1).compareTo(b.active ? 0 : 1);
return r != 0 ? r : a.username.compareTo(b.username);
});
return PageList(
top: S.s4,
children: [
_Toolbar(
filter: _filter,
counts: counts,
onFilter: (f) => setState(() => _filter = f),
onSearch: (v) => setState(() => _q = v.trim().toLowerCase()),
),
const SizedBox(height: S.s14),
if (shown.isEmpty)
SrsCard(
child: EmptyState(
icon: Icons.person_search_rounded,
title: _q.isNotEmpty
? 'No one matches “$_q”'
: 'No one here',
body: _filter == _Filter.off
? 'Every user can sign in.'
: all.isEmpty
? 'Add a user to let them sign in and see their shops.'
: 'Try another filter, or add a user.',
),
)
else
_Roster(users: shown, shops: byId),
],
);
}, retry: () => ref.invalidate(usersProvider)),
else
_Roster(users: shown, shops: byId),
],
);
},
),
),
],
);
}
}
class _Loading extends StatelessWidget {
const _Loading();
static const _names = [0.5, 0.36, 0.58, 0.42, 0.52, 0.32];
@override
Widget build(BuildContext context) {
final search = Container(
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(R.input),
border: Border.all(color: C.border),
),
padding: const EdgeInsets.symmetric(horizontal: S.s14),
alignment: Alignment.centerLeft,
child: const Bone(width: 150, height: 10),
);
const chips = Row(
mainAxisSize: MainAxisSize.min,
children: [
Bone(width: 66, height: 38, radius: R.pill),
SizedBox(width: S.s8),
Bone(width: 92, height: 38, radius: R.pill),
SizedBox(width: S.s8),
Bone(width: 104, height: 38, radius: R.pill),
],
);
return SkeletonList(
children: [
LayoutBuilder(
builder: (context, c) => c.maxWidth < 760
? Column(
children: [
search,
const SizedBox(height: S.s12),
chips,
],
)
: Row(
children: [
const Expanded(child: chips),
const SizedBox(width: S.s16),
SizedBox(width: 320, child: search),
],
),
),
const SizedBox(height: S.s14),
LayoutBuilder(
builder: (context, c) {
final wide = c.maxWidth >= 720;
return SrsCard(
padding: EdgeInsets.zero,
child: Column(
children: [
if (wide)
const Padding(
padding: EdgeInsets.fromLTRB(S.s20, S.s14, S.s20, S.s12),
child: SizedBox(
height: 18,
child: Align(
alignment: Alignment.centerLeft,
child: Bone(width: 44, height: 10),
),
),
),
for (var i = 0; i < _names.length; i++) ...[
if (i > 0 || wide)
const Divider(height: 1, color: C.border),
_row(wide, _names[i]),
],
],
),
);
},
),
],
);
}
static Widget _line(double h, Widget bone) => SizedBox(
height: h,
child: Align(alignment: Alignment.centerLeft, child: bone),
);
Widget _row(bool wide, double name) {
final who = Row(
children: [
const SizedBox(
width: 44,
height: 44,
child: Align(
alignment: Alignment.topLeft,
child: Bone(width: 42, height: 42, radius: 42 * 0.3),
),
),
const SizedBox(width: S.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_line(20, Bone(factor: wide ? name + 0.2 : name, height: 14)),
if (!wide) ...[
const SizedBox(height: S.s4),
_line(16, const Bone(width: 110, height: 10)),
const SizedBox(height: S.s8),
_line(28, const Bone(width: 86, height: 24, radius: R.sm)),
],
],
),
),
],
);
return Padding(
padding: EdgeInsets.symmetric(
horizontal: wide ? S.s20 : S.s14,
vertical: S.s12,
),
child: wide
? Row(
children: [
Expanded(flex: 3, child: who),
const SizedBox(
width: _phoneCol,
child: Align(
alignment: Alignment.centerLeft,
child: Bone(width: 104, height: 11),
),
),
const Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerLeft,
child: Bone(width: 96, height: 18, radius: R.pill),
),
),
const SizedBox(
width: _statusCol,
child: Align(
alignment: Alignment.centerLeft,
child: Bone(width: 60, height: 11),
),
),
const SizedBox(width: 28),
],
)
: who,
);
}
}
class _Toolbar extends StatelessWidget {
const _Toolbar({
required this.filter,

View File

@ -1,6 +1,9 @@
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';
@ -125,6 +128,7 @@ class PageList extends StatelessWidget {
builder: (context, c) {
final g = pageGutter(c.maxWidth, maxWidth);
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(g, top, g, bottom),
children: children,
);
@ -662,10 +666,12 @@ class ScreenHeader extends StatelessWidget {
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;
@ -712,6 +718,20 @@ class ScreenHeader extends StatelessWidget {
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),
),
),
),
),
],
),
@ -846,35 +866,361 @@ Widget asyncView<T>(
),
),
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),
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),
),
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 RefreshButton extends StatefulWidget {
const RefreshButton({super.key, required this.onRefresh});
final Future<void> Function() onRefresh;
@override
State<RefreshButton> createState() => _RefreshButtonState();
}
class _RefreshButtonState extends State<RefreshButton> {
bool _busy = false;
Future<void> _tap() async {
if (_busy) return;
_busy = true;
try {
await widget.onRefresh();
} finally {
_busy = false;
}
}
@override
Widget build(BuildContext context) =>
RoundIcon(Icons.refresh_rounded, label: 'Refresh', onTap: _tap);
}
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>(

View File

@ -5,20 +5,22 @@ import 'package:srs_app/app.dart';
import 'support/fake_api.dart';
import 'package:srs_app/core/providers.dart';
Future<void> boot(WidgetTester t) async {
t.view.physicalSize = const Size(430 * 2, 900 * 2);
Future<FakeApi> boot(WidgetTester t, {double width = 430}) async {
final api = FakeApi();
t.view.physicalSize = Size(width * 2, 900 * 2);
t.view.devicePixelRatio = 2;
addTearDown(t.view.reset);
await t.pumpWidget(
ProviderScope(
overrides: [
apiProvider.overrideWithValue(FakeApi()),
apiProvider.overrideWithValue(api),
startDateProvider.overrideWithValue(DateTime(2026, 10, 16)),
],
child: const SrsApp(),
),
);
await t.pumpAndSettle();
return api;
}
Future<void> signIn(WidgetTester t, String u, String p) async {
@ -147,4 +149,96 @@ void main() {
);
expect(t.widget<Switch>(find.byType(Switch).last).onChanged, isNull);
});
group('loading and refresh', () {
testWidgets('a new date shows a skeleton until its results arrive', (
t,
) async {
await boot(t);
await signIn(t, 'owner', 'admin123');
await t.tap(find.byTooltip('Previous draw'));
await t.pump();
await t.pump(const Duration(milliseconds: 50));
expect(find.bySemanticsLabel('Loading'), findsOneWidget);
// The draw buttons stay usable while the draw loads.
expect(find.byTooltip('Next draw'), findsWidgets);
await t.pumpAndSettle();
expect(find.bySemanticsLabel('Loading'), findsNothing);
});
testWidgets('pulling down reloads results and keeps them on screen', (
t,
) async {
final api = await boot(t);
await signIn(t, 'owner', 'admin123');
final before = api.resultsCalls;
await t.fling(find.text('₹7,700'), const Offset(0, 400), 1000);
await t.pump();
await t.pump(const Duration(milliseconds: 50));
expect(find.text('₹7,700'), findsOneWidget);
expect(find.bySemanticsLabel('Loading'), findsNothing);
await t.pumpAndSettle();
expect(api.resultsCalls, before + 1);
expect(find.text('₹7,700'), findsOneWidget);
});
testWidgets('a failed pull keeps the data and says so quickly', (t) async {
final api = await boot(t);
await signIn(t, 'owner', 'admin123');
api.failResults = true;
await t.fling(find.text('₹7,700'), const Offset(0, 400), 1000);
await t.pump();
await t.pump(const Duration(seconds: 1));
// One frame for the error to reach listeners, one for the toast.
await t.pump();
await t.pump(const Duration(milliseconds: 300));
expect(
find.text("Couldn't refresh. Check your connection and try again."),
findsOneWidget,
);
expect(find.text('₹7,700'), findsOneWidget);
api.failResults = false;
await t.pumpAndSettle(const Duration(seconds: 1));
});
testWidgets('a failed first load shows Retry, which shows the skeleton', (
t,
) async {
final api = await boot(t);
await signIn(t, 'owner', 'admin123');
api.failResults = true;
await t.tap(find.byTooltip('Previous draw'));
await t.pump();
await t.pump(const Duration(seconds: 1));
expect(find.text('Unable to reach the server.'), findsOneWidget);
api.failResults = false;
await t.tap(find.text('Retry'));
await t.pump();
await t.pump(const Duration(milliseconds: 50));
expect(find.text('Retry'), findsNothing);
await t.pumpAndSettle();
expect(find.text('Unable to reach the server.'), findsNothing);
expect(find.text('Retry'), findsNothing);
});
testWidgets('Refresh button shows on wide windows only, and reloads', (
t,
) async {
final api = await boot(t, width: 1280);
await signIn(t, 'owner', 'admin123');
final before = api.resultsCalls;
await t.tap(find.byTooltip('Refresh'));
await t.pump();
await t.pump(const Duration(milliseconds: 50));
expect(find.bySemanticsLabel('Refreshing'), findsOneWidget);
await t.pumpAndSettle();
expect(api.resultsCalls, before + 1);
expect(find.bySemanticsLabel('Refreshing'), findsNothing);
t.view.physicalSize = const Size(430 * 2, 900 * 2);
await t.pumpAndSettle();
expect(find.byTooltip('Refresh'), findsNothing);
});
});
}

View File

@ -47,6 +47,12 @@ class _O {
/// (SK-74, Fri 16 Oct 2026). Used only by the tests; never shipped in the app.
/// Results are computed on read (orders x prizes), as srs-api does.
class FakeApi implements Api {
/// How many times results were fetched, for refresh tests.
int resultsCalls = 0;
/// When true, results fail as if the network were down.
bool failResults = false;
FakeApi() {
_seed();
}
@ -453,7 +459,9 @@ class FakeApi implements Api {
@override
Future<DrawResults> results(DateTime date) async {
resultsCalls++;
await _t();
if (failResults) throw ApiException('Unable to reach the server.');
final d = dateOnly(date);
final scope = _scope();
final rows = <ShopResult>[];