feat: loading skeletons, pull-to-refresh and reload bar on list screens
All checks were successful
deploy / deploy (push) Successful in 5m18s
All checks were successful
deploy / deploy (push) Successful in 5m18s
This commit is contained in:
parent
78c77f708c
commit
125f4d3e86
@ -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});
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,7 +37,29 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: asyncView(orders, (all) {
|
||||
child: RefreshableView(
|
||||
value: orders,
|
||||
onRefresh: refresh,
|
||||
skeleton: const SkeletonList(
|
||||
top: S.s14,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(S.s2, S.s18, S.s2, S.s10),
|
||||
child: SizedBox(
|
||||
height: 18,
|
||||
child: Row(
|
||||
children: [
|
||||
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;
|
||||
@ -103,7 +129,8 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
|
||||
],
|
||||
],
|
||||
);
|
||||
}, retry: () => ref.invalidate(ordersProvider(date))),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,33 +61,17 @@ class ResultsScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends ConsumerWidget {
|
||||
const _Body({required this.results, required this.date});
|
||||
final DrawResults results;
|
||||
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 r = results;
|
||||
final d = r.draw;
|
||||
final n = ref.read(selectedDateProvider.notifier);
|
||||
return PageList(
|
||||
return Row(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: info),
|
||||
RoundIcon(
|
||||
Icons.chevron_left_rounded,
|
||||
label: 'Previous draw',
|
||||
@ -96,6 +86,122 @@ class _Body extends ConsumerWidget {
|
||||
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) {
|
||||
final r = results;
|
||||
final d = r.draw;
|
||||
return PageList(
|
||||
children: [
|
||||
_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),
|
||||
|
||||
@ -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,7 +62,14 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: asyncView(shops, (list) {
|
||||
child: RefreshableView(
|
||||
value: shops,
|
||||
onRefresh: refresh,
|
||||
skeleton: const SkeletonList(
|
||||
top: S.s8,
|
||||
children: [TileGridBone(figure: false)],
|
||||
),
|
||||
builder: (list) {
|
||||
final f = list
|
||||
.where(
|
||||
(s) =>
|
||||
@ -59,6 +80,7 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
|
||||
.toList();
|
||||
if (f.isEmpty) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
EmptyState(
|
||||
icon: Icons.storefront_outlined,
|
||||
@ -80,7 +102,8 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
|
||||
CardGrid(children: [for (final s in f) _ShopTile(s: s)]),
|
||||
],
|
||||
);
|
||||
}, retry: () => ref.invalidate(shopsProvider(date))),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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!],
|
||||
|
||||
@ -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,13 +46,24 @@ 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) {
|
||||
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,
|
||||
@ -80,7 +91,8 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
filter: _filter,
|
||||
counts: counts,
|
||||
onFilter: (f) => setState(() => _filter = f),
|
||||
onSearch: (v) => setState(() => _q = v.trim().toLowerCase()),
|
||||
onSearch: (v) =>
|
||||
setState(() => _q = v.trim().toLowerCase()),
|
||||
),
|
||||
const SizedBox(height: S.s14),
|
||||
if (shown.isEmpty)
|
||||
@ -101,13 +113,165 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
_Roster(users: shown, shops: byId),
|
||||
],
|
||||
);
|
||||
}, retry: () => ref.invalidate(usersProvider)),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@ -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,7 +866,18 @@ Widget asyncView<T>(
|
||||
),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Padding(
|
||||
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,
|
||||
@ -854,7 +885,7 @@ Widget asyncView<T>(
|
||||
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.',
|
||||
error is Exception ? error.toString() : 'Something went wrong.',
|
||||
textAlign: TextAlign.center,
|
||||
style: ts(14, c: C.muted),
|
||||
),
|
||||
@ -870,8 +901,323 @@ Widget asyncView<T>(
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>[];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user