diff --git a/lib/core/api.dart b/lib/core/api.dart index e526210..053be97 100644 --- a/lib/core/api.dart +++ b/lib/core/api.dart @@ -22,8 +22,8 @@ abstract class Api { Future shopDetail(int shopId, DateTime date); Future> activeShops(); Future createShop(String name, String code); - Future renameShop(int shopId, String name); - Future setShopActive(int shopId, bool active); + Future renameShop(int shopId, String name); + Future setShopActive(int shopId, bool active); Future> orders(DateTime date); Future order(int orderId); @@ -31,8 +31,8 @@ abstract class Api { Future removeLine(int orderId, String ending); Future cancelOrder(int orderId); - Future> users(); - Future user(int id); + Future> users(); + Future user(int id); Future createUser({ required String username, String? phone, @@ -46,8 +46,8 @@ abstract class Api { bool clearPhone = false, String? password, bool? active, - bool? sendResults, + bool? sendResults, }); Future setUserShops(int id, List shopIds); - Future deleteUser(int id); + Future deleteUser(int id); } diff --git a/lib/core/models.dart b/lib/core/models.dart index e8d48e7..d3696b7 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -13,7 +13,7 @@ class AppUser { final int id; final String username; - final String? phone; + final String? phone; final bool isAdmin; final bool active; final List shopIds; @@ -53,17 +53,18 @@ class DrawInfo { }); final DateTime date; - final String? scheme; - final String? code; + final String? scheme; + final String? code; final bool published; - final bool hasWin; + final bool hasWin; String get label => published ? '$scheme · $code' : 'No result yet'; } + class PrizeHit { const PrizeHit({required this.tier, required this.amount}); final int tier; - final int amount; + final int amount; } class EndingQty { diff --git a/lib/core/providers.dart b/lib/core/providers.dart index 2ba2de1..41e94cb 100644 --- a/lib/core/providers.dart +++ b/lib/core/providers.dart @@ -32,11 +32,12 @@ class SessionNotifier extends Notifier { Future 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 logout() async { diff --git a/lib/core/theme.dart b/lib/core/theme.dart index f328a1b..5645a12 100644 --- a/lib/core/theme.dart +++ b/lib/core/theme.dart @@ -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. diff --git a/lib/features/auth/login_screen.dart b/lib/features/auth/login_screen.dart index f38a57a..f68dcd0 100644 --- a/lib/features/auth/login_screen.dart +++ b/lib/features/auth/login_screen.dart @@ -125,12 +125,7 @@ class _LoginScreenState extends ConsumerState { 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), diff --git a/lib/features/orders/orders_screen.dart b/lib/features/orders/orders_screen.dart index 6845df4..75c9acf 100644 --- a/lib/features/orders/orders_screen.dart +++ b/lib/features/orders/orders_screen.dart @@ -16,15 +16,19 @@ class OrdersScreen extends ConsumerStatefulWidget { } class _OrdersScreenState extends ConsumerState { - String? _code; + String? _code; @override Widget build(BuildContext context) { final date = ref.watch(selectedDateProvider); final orders = ref.watch(ordersProvider(date)); + Future 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 { ), ), Expanded( - child: asyncView(orders, (all) { - final codes = {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 = {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))), + ); + }, + ), ), ], ); diff --git a/lib/features/results/calendar_picker.dart b/lib/features/results/calendar_picker.dart index a126e20..86d453e 100644 --- a/lib/features/results/calendar_picker.dart +++ b/lib/features/results/calendar_picker.dart @@ -159,17 +159,16 @@ class _CalendarPickerState extends ConsumerState<_CalendarPicker> { final today = dateOnly(ref.read(apiProvider).now); final days = ref.watch(monthProvider((_month.year, _month.month))); final byDay = { - for (final d in days.asData?.value ?? const []) d.date.day: d, + for (final d in days.value ?? const []) 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); - final lead = first.weekday - 1; + final lead = first.weekday - 1; final count = DateTime(_month.year, _month.month + 1, 0).day; final rows = ((lead + count) / 7).ceil(); diff --git a/lib/features/results/results_screen.dart b/lib/features/results/results_screen.dart index e3b25c1..89a37bf 100644 --- a/lib/features/results/results_screen.dart +++ b/lib/features/results/results_screen.dart @@ -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 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), diff --git a/lib/features/shops/shops_screen.dart b/lib/features/shops/shops_screen.dart index 22fec23..a3c6437 100644 --- a/lib/features/shops/shops_screen.dart +++ b/lib/features/shops/shops_screen.dart @@ -24,7 +24,9 @@ class _ShopsScreenState extends ConsumerState { 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 refresh() => refreshData(context, ref, shopsProvider(date)); + final wide = isWide(context); return Column( children: [ @@ -33,9 +35,21 @@ class _ShopsScreenState extends ConsumerState { 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 { ), ), 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))), + }, + ), ), ], ); diff --git a/lib/features/users/user_detail_screen.dart b/lib/features/users/user_detail_screen.dart index f94f321..cccab58 100644 --- a/lib/features/users/user_detail_screen.dart +++ b/lib/features/users/user_detail_screen.dart @@ -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 []; + ref.watch(activeShopsProvider).value ?? const []; 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!], diff --git a/lib/features/users/users_screen.dart b/lib/features/users/users_screen.dart index c4aa4a4..8f0cbd6 100644 --- a/lib/features/users/users_screen.dart +++ b/lib/features/users/users_screen.dart @@ -31,11 +31,11 @@ class _UsersScreenState extends ConsumerState { @override Widget build(BuildContext context) { final users = ref.watch(usersProvider); - final shops = - ref.watch(activeShopsProvider).asData?.value ?? const []; + final shops = ref.watch(activeShopsProvider).value ?? const []; 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 refresh() => refreshData(context, ref, usersProvider); return Column( children: [ @@ -46,68 +46,232 @@ class _UsersScreenState extends ConsumerState { : 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, diff --git a/lib/widgets/ui.dart b/lib/widgets/ui.dart index 25ed052..69b8bef 100644 --- a/lib/widgets/ui.dart +++ b/lib/widgets/ui.dart @@ -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( ), ), 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 refreshData( + BuildContext context, + WidgetRef ref, + ProviderListenable> screen, +) async { + ref.read(dataVersionProvider.notifier).bump(); + final before = ref.read(screen).error; + final done = Completer(); + 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 extends StatefulWidget { + const RefreshableView({ + super.key, + required this.value, + required this.builder, + required this.skeleton, + required this.onRefresh, + }); + final AsyncValue value; + final Widget Function(T data) builder; + + final Widget skeleton; + final Future Function() onRefresh; + + @override + State> createState() => _RefreshableViewState(); +} + +class _RefreshableViewState extends State> { + bool _pulling = false; + bool _retrying = false; + + Future _pull() async { + setState(() => _pulling = true); + try { + await widget.onRefresh(); + } finally { + if (mounted) setState(() => _pulling = false); + } + } + + Future _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 Function() onRefresh; + + @override + State createState() => _RefreshButtonState(); +} + +class _RefreshButtonState extends State { + bool _busy = false; + + Future _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 createState() => _SkeletonState(); +} + +class _SkeletonState extends State + 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 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 showSheet( diff --git a/test/app_test.dart b/test/app_test.dart index 4cb41a2..a083f08 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -5,20 +5,22 @@ import 'package:srs_app/app.dart'; import 'support/fake_api.dart'; import 'package:srs_app/core/providers.dart'; -Future boot(WidgetTester t) async { - t.view.physicalSize = const Size(430 * 2, 900 * 2); +Future 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 signIn(WidgetTester t, String u, String p) async { @@ -147,4 +149,96 @@ void main() { ); expect(t.widget(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); + }); + }); } diff --git a/test/support/fake_api.dart b/test/support/fake_api.dart index d20831a..3bd050d 100644 --- a/test/support/fake_api.dart +++ b/test/support/fake_api.dart @@ -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 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 = [];