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;
|
final bool hasWin;
|
||||||
String get label => published ? '$scheme · $code' : 'No result yet';
|
String get label => published ? '$scheme · $code' : 'No result yet';
|
||||||
}
|
}
|
||||||
|
|
||||||
class PrizeHit {
|
class PrizeHit {
|
||||||
const PrizeHit({required this.tier, required this.amount});
|
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 {
|
Future<void> login(String username, String password) async {
|
||||||
final api = ref.read(apiProvider);
|
final api = ref.read(apiProvider);
|
||||||
final u = await api.login(username, password);
|
final u = await api.login(username, password);
|
||||||
state = u;
|
|
||||||
try {
|
try {
|
||||||
ref.read(selectedDateProvider.notifier).set(await api.latestDrawDate());
|
ref.read(selectedDateProvider.notifier).set(await api.latestDrawDate());
|
||||||
} on ApiException {// ignore: empty_catches
|
} on ApiException {
|
||||||
|
// ignore: empty_catches
|
||||||
}
|
}
|
||||||
|
state = u;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
|
|||||||
@ -34,6 +34,7 @@ class C {
|
|||||||
static const shadowNav = Color(0x14101B33);
|
static const shadowNav = Color(0x14101B33);
|
||||||
static const disabled = Color(0xFFF0EEE8);
|
static const disabled = Color(0xFFF0EEE8);
|
||||||
static const fieldDisabled = Color(0xFFEFEDE6);
|
static const fieldDisabled = Color(0xFFEFEDE6);
|
||||||
|
static const skeleton = Color(0xFFECE8DF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Width breakpoints, measured on the window, not the device.
|
/// Width breakpoints, measured on the window, not the device.
|
||||||
|
|||||||
@ -125,12 +125,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
top: Radius.circular(R.hero),
|
top: Radius.circular(R.hero),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(S.s24, S.s28, S.s24, S.s24),
|
||||||
S.s24,
|
|
||||||
S.s28,
|
|
||||||
S.s24,
|
|
||||||
S.s24,
|
|
||||||
),
|
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
|||||||
@ -22,9 +22,13 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final date = ref.watch(selectedDateProvider);
|
final date = ref.watch(selectedDateProvider);
|
||||||
final orders = ref.watch(ordersProvider(date));
|
final orders = ref.watch(ordersProvider(date));
|
||||||
|
Future<void> refresh() => refreshData(context, ref, ordersProvider(date));
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const ScreenHeader(title: 'Orders'),
|
ScreenHeader(
|
||||||
|
title: 'Orders',
|
||||||
|
trailing: isWide(context) ? RefreshButton(onRefresh: refresh) : null,
|
||||||
|
),
|
||||||
const PagePad(
|
const PagePad(
|
||||||
top: S.s4,
|
top: S.s4,
|
||||||
child: Align(
|
child: Align(
|
||||||
@ -33,77 +37,100 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: asyncView(orders, (all) {
|
child: RefreshableView(
|
||||||
final codes = <String>{for (final o in all) o.shop.code}.toList()
|
value: orders,
|
||||||
..sort();
|
onRefresh: refresh,
|
||||||
final active = codes.contains(_code) ? _code : null;
|
skeleton: const SkeletonList(
|
||||||
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,
|
top: S.s14,
|
||||||
children: [
|
children: [
|
||||||
if (codes.length > 1)
|
Padding(
|
||||||
SizedBox(
|
padding: EdgeInsets.fromLTRB(S.s2, S.s18, S.s2, S.s10),
|
||||||
height: 38,
|
child: SizedBox(
|
||||||
child: ListView(
|
height: 18,
|
||||||
scrollDirection: Axis.horizontal,
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_chip(
|
Bone(width: 180, height: 14),
|
||||||
'All',
|
Spacer(),
|
||||||
active == null,
|
Bone(width: 110, height: 10),
|
||||||
() => setState(() => _code = null),
|
],
|
||||||
),
|
),
|
||||||
for (final c in codes)
|
),
|
||||||
|
),
|
||||||
|
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(
|
_chip(
|
||||||
c,
|
'All',
|
||||||
active == c,
|
active == null,
|
||||||
() => setState(() => _code = c),
|
() => 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))),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -159,13 +159,12 @@ class _CalendarPickerState extends ConsumerState<_CalendarPicker> {
|
|||||||
final today = dateOnly(ref.read(apiProvider).now);
|
final today = dateOnly(ref.read(apiProvider).now);
|
||||||
final days = ref.watch(monthProvider((_month.year, _month.month)));
|
final days = ref.watch(monthProvider((_month.year, _month.month)));
|
||||||
final byDay = <int, DrawInfo>{
|
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
|
final pickedInfo = ref
|
||||||
.watch(monthProvider((_picked.year, _picked.month)))
|
.watch(monthProvider((_picked.year, _picked.month)))
|
||||||
.asData
|
.value
|
||||||
?.value
|
?.where((d) => sameDay(d.date, _picked))
|
||||||
.where((d) => sameDay(d.date, _picked))
|
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
|
|
||||||
final first = DateTime(_month.year, _month.month);
|
final first = DateTime(_month.year, _month.month);
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class ResultsScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final date = ref.watch(selectedDateProvider);
|
final date = ref.watch(selectedDateProvider);
|
||||||
final res = ref.watch(resultsProvider(date));
|
final res = ref.watch(resultsProvider(date));
|
||||||
|
Future<void> refresh() => refreshData(context, ref, resultsProvider(date));
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ScreenHeader(
|
ScreenHeader(
|
||||||
@ -24,6 +25,10 @@ class ResultsScreen extends ConsumerWidget {
|
|||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (isWide(context)) ...[
|
||||||
|
RefreshButton(onRefresh: refresh),
|
||||||
|
const SizedBox(width: S.s10),
|
||||||
|
],
|
||||||
Builder(
|
Builder(
|
||||||
builder: (anchor) => RoundIcon(
|
builder: (anchor) => RoundIcon(
|
||||||
Icons.calendar_month_rounded,
|
Icons.calendar_month_rounded,
|
||||||
@ -44,10 +49,11 @@ class ResultsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: asyncView(
|
child: RefreshableView(
|
||||||
res,
|
value: res,
|
||||||
(r) => _Body(results: r, date: date),
|
onRefresh: refresh,
|
||||||
retry: () => ref.invalidate(resultsProvider(date)),
|
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});
|
const _Body({required this.results, required this.date});
|
||||||
final DrawResults results;
|
final DrawResults results;
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final r = results;
|
final r = results;
|
||||||
final d = r.draw;
|
final d = r.draw;
|
||||||
final n = ref.read(selectedDateProvider.notifier);
|
|
||||||
return PageList(
|
return PageList(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
_DrawRow(
|
||||||
children: [
|
date: date,
|
||||||
Expanded(
|
info: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Text(d.label, style: ts(16, w: w7)),
|
||||||
Text(d.label, style: ts(16, w: w7)),
|
const SizedBox(height: S.s2),
|
||||||
const SizedBox(height: S.s2),
|
Text('${dateLong(d.date)} · 3:00 PM', style: ts(13, c: C.muted)),
|
||||||
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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: S.s16),
|
const SizedBox(height: S.s16),
|
||||||
if (d.published) ..._published(context, r) else ..._notOut(context, r),
|
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 date = ref.watch(selectedDateProvider);
|
||||||
final isAdmin = ref.watch(sessionProvider)?.isAdmin ?? false;
|
final isAdmin = ref.watch(sessionProvider)?.isAdmin ?? false;
|
||||||
final shops = ref.watch(shopsProvider(date));
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@ -33,9 +35,21 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
|
|||||||
subtitle: count == null
|
subtitle: count == null
|
||||||
? null
|
? null
|
||||||
: '${plural(count, 'shop')} · ${dateShort(date)}',
|
: '${plural(count, 'shop')} · ${dateShort(date)}',
|
||||||
trailing: isAdmin
|
subtitleLoading: shops.isLoading,
|
||||||
? SmallButton('Add shop', onTap: () => showShopSheet(context))
|
trailing: !wide && !isAdmin
|
||||||
: null,
|
? 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(
|
PagePad(
|
||||||
top: S.s4,
|
top: S.s4,
|
||||||
@ -48,39 +62,48 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: asyncView(shops, (list) {
|
child: RefreshableView(
|
||||||
final f = list
|
value: shops,
|
||||||
.where(
|
onRefresh: refresh,
|
||||||
(s) =>
|
skeleton: const SkeletonList(
|
||||||
_q.isEmpty ||
|
top: S.s8,
|
||||||
s.shop.name.toLowerCase().contains(_q) ||
|
children: [TileGridBone(figure: false)],
|
||||||
s.shop.code.toLowerCase().contains(_q),
|
),
|
||||||
)
|
builder: (list) {
|
||||||
.toList();
|
final f = list
|
||||||
if (f.isEmpty) {
|
.where(
|
||||||
return ListView(
|
(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: [
|
children: [
|
||||||
EmptyState(
|
CardGrid(children: [for (final s in f) _ShopTile(s: s)]),
|
||||||
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: [
|
|
||||||
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) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final user = ref.watch(userProvider(userId));
|
final user = ref.watch(userProvider(userId));
|
||||||
final shops =
|
final shops =
|
||||||
ref.watch(activeShopsProvider).asData?.value ?? const <Shop>[];
|
ref.watch(activeShopsProvider).value ?? const <Shop>[];
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: asyncView(
|
child: asyncView(
|
||||||
@ -366,7 +366,9 @@ class _ToggleRow extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// The switch carries the name for screen readers.
|
// 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),
|
const SizedBox(height: S.s2),
|
||||||
Text(description, style: ts(12, c: C.muted, h: 1.35)),
|
Text(description, style: ts(12, c: C.muted, h: 1.35)),
|
||||||
if (status != null) ...[const SizedBox(height: S.s4), status!],
|
if (status != null) ...[const SizedBox(height: S.s4), status!],
|
||||||
|
|||||||
@ -31,11 +31,11 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final users = ref.watch(usersProvider);
|
final users = ref.watch(usersProvider);
|
||||||
final shops =
|
final shops = ref.watch(activeShopsProvider).value ?? const <Shop>[];
|
||||||
ref.watch(activeShopsProvider).asData?.value ?? const <Shop>[];
|
|
||||||
final byId = {for (final s in shops) s.id: s};
|
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;
|
final off = list?.where((u) => !u.active).length ?? 0;
|
||||||
|
Future<void> refresh() => refreshData(context, ref, usersProvider);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@ -46,68 +46,232 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
|||||||
: off == 0
|
: off == 0
|
||||||
? plural(list.length, 'user')
|
? plural(list.length, 'user')
|
||||||
: '${plural(list.length, 'user')}, $off inactive',
|
: '${plural(list.length, 'user')}, $off inactive',
|
||||||
trailing: SmallButton(
|
subtitleLoading: users.isLoading,
|
||||||
'Add user',
|
trailing: Row(
|
||||||
onTap: () => context.push('/users/new'),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (isWide(context)) ...[
|
||||||
|
RefreshButton(onRefresh: refresh),
|
||||||
|
const SizedBox(width: S.s10),
|
||||||
|
],
|
||||||
|
SmallButton('Add user', onTap: () => context.push('/users/new')),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: asyncView(users, (all) {
|
child: RefreshableView(
|
||||||
final counts = {
|
value: users,
|
||||||
_Filter.all: all.length,
|
onRefresh: refresh,
|
||||||
_Filter.active: all.where((u) => u.active).length,
|
skeleton: const _Loading(),
|
||||||
_Filter.off: all.where((u) => !u.active).length,
|
builder: (all) {
|
||||||
};
|
final counts = {
|
||||||
final shown =
|
_Filter.all: all.length,
|
||||||
all
|
_Filter.active: all.where((u) => u.active).length,
|
||||||
.where(_keep)
|
_Filter.off: all.where((u) => !u.active).length,
|
||||||
.where(
|
};
|
||||||
(u) =>
|
final shown =
|
||||||
_q.isEmpty ||
|
all
|
||||||
u.username.toLowerCase().contains(_q) ||
|
.where(_keep)
|
||||||
(u.phone ?? '').contains(_q),
|
.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()
|
else
|
||||||
// Inactive accounts last.
|
_Roster(users: shown, shops: byId),
|
||||||
..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)),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _Toolbar extends StatelessWidget {
|
||||||
const _Toolbar({
|
const _Toolbar({
|
||||||
required this.filter,
|
required this.filter,
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../core/format.dart';
|
import '../core/format.dart';
|
||||||
@ -125,6 +128,7 @@ class PageList extends StatelessWidget {
|
|||||||
builder: (context, c) {
|
builder: (context, c) {
|
||||||
final g = pageGutter(c.maxWidth, maxWidth);
|
final g = pageGutter(c.maxWidth, maxWidth);
|
||||||
return ListView(
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: EdgeInsets.fromLTRB(g, top, g, bottom),
|
padding: EdgeInsets.fromLTRB(g, top, g, bottom),
|
||||||
children: children,
|
children: children,
|
||||||
);
|
);
|
||||||
@ -662,10 +666,12 @@ class ScreenHeader extends StatelessWidget {
|
|||||||
this.trailing,
|
this.trailing,
|
||||||
this.back = false,
|
this.back = false,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
|
this.subtitleLoading = false,
|
||||||
this.maxWidth = Bp.pageMax,
|
this.maxWidth = Bp.pageMax,
|
||||||
});
|
});
|
||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
|
final bool subtitleLoading;
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
final bool back;
|
final bool back;
|
||||||
|
|
||||||
@ -712,6 +718,20 @@ class ScreenHeader extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: S.s2),
|
padding: const EdgeInsets.only(top: S.s2),
|
||||||
child: Text(subtitle!, style: ts(13, c: C.muted)),
|
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(
|
error: (e, _) => Center(
|
||||||
child: Padding(
|
child: _LoadError(error: e, retry: retry),
|
||||||
padding: const EdgeInsets.all(S.s28),
|
),
|
||||||
child: Column(
|
);
|
||||||
mainAxisSize: MainAxisSize.min,
|
}
|
||||||
children: [
|
|
||||||
const Icon(Icons.cloud_off_rounded, size: 36, color: C.muted),
|
class _LoadError extends StatelessWidget {
|
||||||
const SizedBox(height: S.s12),
|
const _LoadError({required this.error, this.retry});
|
||||||
Text(
|
final Object error;
|
||||||
e is Exception ? e.toString() : 'Something went wrong.',
|
final VoidCallback? retry;
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: ts(14, c: C.muted),
|
@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
|
// ------------------------------------------------------ sheets & dialogs
|
||||||
|
|
||||||
Future<T?> showSheet<T>(
|
Future<T?> showSheet<T>(
|
||||||
|
|||||||
@ -5,20 +5,22 @@ import 'package:srs_app/app.dart';
|
|||||||
import 'support/fake_api.dart';
|
import 'support/fake_api.dart';
|
||||||
import 'package:srs_app/core/providers.dart';
|
import 'package:srs_app/core/providers.dart';
|
||||||
|
|
||||||
Future<void> boot(WidgetTester t) async {
|
Future<FakeApi> boot(WidgetTester t, {double width = 430}) async {
|
||||||
t.view.physicalSize = const Size(430 * 2, 900 * 2);
|
final api = FakeApi();
|
||||||
|
t.view.physicalSize = Size(width * 2, 900 * 2);
|
||||||
t.view.devicePixelRatio = 2;
|
t.view.devicePixelRatio = 2;
|
||||||
addTearDown(t.view.reset);
|
addTearDown(t.view.reset);
|
||||||
await t.pumpWidget(
|
await t.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
apiProvider.overrideWithValue(FakeApi()),
|
apiProvider.overrideWithValue(api),
|
||||||
startDateProvider.overrideWithValue(DateTime(2026, 10, 16)),
|
startDateProvider.overrideWithValue(DateTime(2026, 10, 16)),
|
||||||
],
|
],
|
||||||
child: const SrsApp(),
|
child: const SrsApp(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> signIn(WidgetTester t, String u, String p) async {
|
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);
|
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.
|
/// (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.
|
/// Results are computed on read (orders x prizes), as srs-api does.
|
||||||
class FakeApi implements Api {
|
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() {
|
FakeApi() {
|
||||||
_seed();
|
_seed();
|
||||||
}
|
}
|
||||||
@ -453,7 +459,9 @@ class FakeApi implements Api {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<DrawResults> results(DateTime date) async {
|
Future<DrawResults> results(DateTime date) async {
|
||||||
|
resultsCalls++;
|
||||||
await _t();
|
await _t();
|
||||||
|
if (failResults) throw ApiException('Unable to reach the server.');
|
||||||
final d = dateOnly(date);
|
final d = dateOnly(date);
|
||||||
final scope = _scope();
|
final scope = _scope();
|
||||||
final rows = <ShopResult>[];
|
final rows = <ShopResult>[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user