diff --git a/lib/core/http_api.dart b/lib/core/http_api.dart index a59e49d..1e1aa41 100644 --- a/lib/core/http_api.dart +++ b/lib/core/http_api.dart @@ -70,7 +70,12 @@ class HttpApi implements Api { } if (response.statusCode >= 200 && response.statusCode < 300) { - return response.body.isEmpty ? null : jsonDecode(response.body); + if (response.body.isEmpty) return null; + try { + return jsonDecode(response.body); + } on FormatException { + throw ApiException(_unreachable); + } } if (response.statusCode == 401 && signedIn) _endSession(); throw ApiException(_errorMessage(response)); diff --git a/lib/core/models.dart b/lib/core/models.dart index d3696b7..e140753 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -104,6 +104,15 @@ class Order { final String? rawText; final List lines; + bool get hasText => (rawText ?? '').trim().isNotEmpty; + String get kind => isPhoto ? (hasText ? 'Photo + text' : 'Photo') : 'Text'; + bool matches(String q) => + q.isEmpty || + shop.name.toLowerCase().contains(q) || + shop.code.toLowerCase().contains(q) || + (rawText ?? '').toLowerCase().contains(q) || + lines.any((l) => l.ending.contains(q)); + int get tickets => lines.fold(0, (a, l) => a + l.qty); int get prize => lines.fold(0, (a, l) => a + l.prize); int get winTickets => lines.where((l) => l.wins).fold(0, (a, l) => a + l.qty); diff --git a/lib/core/providers.dart b/lib/core/providers.dart index 41e94cb..4699cb9 100644 --- a/lib/core/providers.dart +++ b/lib/core/providers.dart @@ -32,11 +32,7 @@ class SessionNotifier extends Notifier { Future login(String username, String password) async { final api = ref.read(apiProvider); final u = await api.login(username, password); - try { - ref.read(selectedDateProvider.notifier).set(await api.latestDrawDate()); - } on ApiException { - // ignore: empty_catches - } + ref.read(selectedDateProvider.notifier).set(api.now); state = u; } diff --git a/lib/core/theme.dart b/lib/core/theme.dart index 5645a12..fbeabab 100644 --- a/lib/core/theme.dart +++ b/lib/core/theme.dart @@ -34,7 +34,7 @@ class C { static const shadowNav = Color(0x14101B33); static const disabled = Color(0xFFF0EEE8); static const fieldDisabled = Color(0xFFEFEDE6); - static const skeleton = Color(0xFFECE8DF); + static const skeleton = Color(0xFFECE8DF); } /// Width breakpoints, measured on the window, not the device. diff --git a/lib/features/orders/order_card.dart b/lib/features/orders/order_card.dart index b2b9f3e..8a09c2e 100644 --- a/lib/features/orders/order_card.dart +++ b/lib/features/orders/order_card.dart @@ -53,7 +53,7 @@ class OrderCard extends StatelessWidget { const SizedBox(width: S.s6), Flexible( child: Text( - '${o.shop.code} · ${time12(o.time)} · ${o.isPhoto ? 'Photo' : 'Text'}', + '${o.shop.code} · ${time12(o.time)} · ${o.kind}', maxLines: 1, overflow: TextOverflow.ellipsis, style: ts(12, c: C.muted), diff --git a/lib/features/orders/order_detail_screen.dart b/lib/features/orders/order_detail_screen.dart index 17b30c3..5882a5d 100644 --- a/lib/features/orders/order_detail_screen.dart +++ b/lib/features/orders/order_detail_screen.dart @@ -132,7 +132,7 @@ class _OrderDetailScreenState extends ConsumerState { ), const SizedBox(height: S.s4), Text( - '${o.shop.code} · ${time12(o.time)} · ${o.isPhoto ? 'Photo' : 'Text'}', + '${o.shop.code} · ${time12(o.time)} · ${o.kind}', style: ts(12, c: C.muted), ), const SizedBox(height: S.s2), @@ -176,8 +176,9 @@ class _OrderDetailScreenState extends ConsumerState { Text('Received via WhatsApp', style: ts(12, c: C.muted)), ], ), - ) - else + ), + if (o.isPhoto && o.hasText) const SizedBox(height: S.s10), + if (o.hasText) SrsCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/orders/orders_screen.dart b/lib/features/orders/orders_screen.dart index 75c9acf..11d4a37 100644 --- a/lib/features/orders/orders_screen.dart +++ b/lib/features/orders/orders_screen.dart @@ -17,6 +17,7 @@ class OrdersScreen extends ConsumerStatefulWidget { class _OrdersScreenState extends ConsumerState { String? _code; + String _q = ''; @override Widget build(BuildContext context) { @@ -25,15 +26,25 @@ class _OrdersScreenState extends ConsumerState { Future refresh() => refreshData(context, ref, ordersProvider(date)); return Column( children: [ - ScreenHeader( - title: 'Orders', - trailing: isWide(context) ? RefreshButton(onRefresh: refresh) : null, - ), - const PagePad( + const ScreenHeader(title: 'Orders'), + PagePad( top: S.s4, child: Align( alignment: Alignment.centerLeft, - child: SizedBox(width: 420, child: DateStrip()), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + children: [ + const DateStrip(), + const SizedBox(height: S.s10), + SearchField( + hint: 'Search shop, message or ending', + onChanged: (v) => + setState(() => _q = v.trim().toLowerCase()), + ), + ], + ), + ), ), ), Expanded( @@ -63,9 +74,12 @@ class _OrdersScreenState extends ConsumerState { 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 list = [ + for (final o in all) + if ((active == null || o.shop.code == active) && + o.matches(_q)) + o, + ]; final tickets = list.fold(0, (a, o) => a + o.tickets); return PageList( top: S.s14, @@ -91,12 +105,17 @@ class _OrdersScreenState extends ConsumerState { ), ), 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.', - ) + _q.isNotEmpty + ? const EmptyState( + icon: Icons.search_off_rounded, + title: 'No matching orders', + ) + : 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( diff --git a/lib/features/results/results_screen.dart b/lib/features/results/results_screen.dart index 89a37bf..893c9a4 100644 --- a/lib/features/results/results_screen.dart +++ b/lib/features/results/results_screen.dart @@ -25,10 +25,6 @@ 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, @@ -199,7 +195,7 @@ class _Body extends StatelessWidget { 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)), + Text(dateLong(d.date), style: ts(13, c: C.muted)), ], ), ), @@ -272,37 +268,48 @@ class _Body extends StatelessWidget { _HeroRow( hero: NavyCard( child: Column( + mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - padding: const EdgeInsets.symmetric( - horizontal: S.s10, - vertical: S.s6, - ), + padding: const EdgeInsets.fromLTRB(S.s4, S.s4, S.s12, S.s4), decoration: BoxDecoration( color: C.goldSoft, borderRadius: BorderRadius.circular(R.pill), + border: Border.all(color: C.gold.withAlpha(90)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.schedule_rounded, size: 14, color: C.gold), - const SizedBox(width: S.s6), + Container( + width: 20, + height: 20, + decoration: const BoxDecoration( + color: C.gold, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.hourglass_top_rounded, + size: 12, + color: C.navy, + ), + ), + const SizedBox(width: S.s8), Text( 'AWAITING RESULTS', - style: ts(10, w: w7, c: C.gold, ls: 1), + style: ts(11, w: w7, c: C.gold, ls: 1.1), ), ], ), ), - const SizedBox(height: S.s14), + const SizedBox(height: S.s12), Text( 'Results not yet published', style: ts(26, w: w7, c: Colors.white, ls: -0.4), ), const SizedBox(height: S.s8), Text( - 'Official results are usually published after 4 PM. Prize details will appear here once available.', + 'Official results are usually published after 4 PM.', style: ts(13, c: C.softOnNavy, h: 1.5), ), ], diff --git a/lib/features/shops/shops_screen.dart b/lib/features/shops/shops_screen.dart index a3c6437..e914426 100644 --- a/lib/features/shops/shops_screen.dart +++ b/lib/features/shops/shops_screen.dart @@ -26,7 +26,6 @@ class _ShopsScreenState extends ConsumerState { final shops = ref.watch(shopsProvider(date)); final count = shops.value?.where((s) => s.shop.active).length; Future refresh() => refreshData(context, ref, shopsProvider(date)); - final wide = isWide(context); return Column( children: [ @@ -36,20 +35,9 @@ class _ShopsScreenState extends ConsumerState { ? null : '${plural(count, 'shop')} · ${dateShort(date)}', 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), - ), - ], - ), + trailing: isAdmin + ? SmallButton('Add shop', onTap: () => showShopSheet(context)) + : null, ), PagePad( top: S.s4, diff --git a/lib/features/shops/tickets_screen.dart b/lib/features/shops/tickets_screen.dart index 38e8ef4..dec8c9a 100644 --- a/lib/features/shops/tickets_screen.dart +++ b/lib/features/shops/tickets_screen.dart @@ -21,6 +21,11 @@ class _TicketsScreenState extends ConsumerState { bool _byEnding = true; String _q = ''; + void _mode(bool byEnding) => setState(() { + _byEnding = byEnding; + _q = ''; + }); + @override Widget build(BuildContext context) { final date = ref.watch(selectedDateProvider); @@ -48,28 +53,22 @@ class _TicketsScreenState extends ConsumerState { ), child: Row( children: [ - _seg( - 'By ending', - _byEnding, - () => setState(() => _byEnding = true), - ), - _seg( - 'By order', - !_byEnding, - () => setState(() => _byEnding = false), - ), + _seg('By ending', _byEnding, () => _mode(true)), + _seg('By order', !_byEnding, () => _mode(false)), ], ), ), ), - if (_byEnding) - PagePad( - bottom: S.s8, - child: SearchField( - hint: 'Search endings (e.g. 0987)', - onChanged: (v) => setState(() => _q = v.trim()), - ), + PagePad( + bottom: S.s8, + child: SearchField( + key: ValueKey(_byEnding), + hint: _byEnding + ? 'Search endings (e.g. 0987)' + : 'Search message or ending', + onChanged: (v) => setState(() => _q = v.trim()), ), + ), Expanded(child: _byEnding ? _endings(d) : _orders(d)), ], ), @@ -116,19 +115,32 @@ class _TicketsScreenState extends ConsumerState { } return PageList( children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [for (final e in list) _chip(e)], + LayoutBuilder( + builder: (context, c) { + const gap = 8.0, min = 104.0; + final cols = ((c.maxWidth + gap) / (min + gap)).floor().clamp( + 1, + 12, + ); + final w = (c.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + alignment: WrapAlignment.center, + spacing: gap, + runSpacing: gap, + children: [for (final e in list) _chip(e, w)], + ); + }, ), ], ); } - Widget _chip(EndingQty e) { + Widget _chip(EndingQty e, double width) { final win = e.wins; return Container( - padding: const EdgeInsets.symmetric(horizontal: S.s12, vertical: S.s8), + width: width, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: S.s8, vertical: S.s10), decoration: BoxDecoration( color: win ? C.greenTint : Colors.white, borderRadius: BorderRadius.circular(R.md), @@ -152,10 +164,20 @@ class _TicketsScreenState extends ConsumerState { } Widget _orders(ShopDetail d) { - if (d.orders.isEmpty) { + final q = _q.toLowerCase(); + final orders = [ + for (final o in d.orders) + if (o.matches(q)) o, + ]; + if (orders.isEmpty) { return ListView( - children: const [ - EmptyState(icon: Icons.receipt_long_outlined, title: 'No orders'), + children: [ + EmptyState( + icon: q.isEmpty + ? Icons.receipt_long_outlined + : Icons.search_off_rounded, + title: q.isEmpty ? 'No orders' : 'No matching orders', + ), ], ); } @@ -163,7 +185,7 @@ class _TicketsScreenState extends ConsumerState { children: [ CardGrid( children: [ - for (final o in d.orders) + for (final o in orders) OrderCard(order: o, onTap: () => context.push('/orders/${o.id}')), ], ), diff --git a/lib/features/users/user_detail_screen.dart b/lib/features/users/user_detail_screen.dart index cccab58..93ca863 100644 --- a/lib/features/users/user_detail_screen.dart +++ b/lib/features/users/user_detail_screen.dart @@ -18,8 +18,7 @@ class UserDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final user = ref.watch(userProvider(userId)); - final shops = - ref.watch(activeShopsProvider).value ?? const []; + final shops = ref.watch(activeShopsProvider).value ?? const []; return Scaffold( body: SafeArea( child: asyncView( diff --git a/lib/features/users/users_screen.dart b/lib/features/users/users_screen.dart index 8f0cbd6..a06ad57 100644 --- a/lib/features/users/users_screen.dart +++ b/lib/features/users/users_screen.dart @@ -47,15 +47,9 @@ class _UsersScreenState extends ConsumerState { ? plural(list.length, 'user') : '${plural(list.length, 'user')}, $off inactive', 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')), - ], + trailing: SmallButton( + 'Add user', + onTap: () => context.push('/users/new'), ), ), Expanded( diff --git a/lib/main.dart b/lib/main.dart index 3def650..361ec98 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,7 @@ Future main() async { ); final user = await api.restore(); - final start = user == null ? dateOnly(api.now) : await _latestOrToday(api); + final start = dateOnly(api.now); runApp( ProviderScope( @@ -30,11 +30,3 @@ Future main() async { ), ); } - -Future _latestOrToday(Api api) async { - try { - return await api.latestDrawDate(); - } on ApiException { - return dateOnly(api.now); - } -} diff --git a/lib/widgets/auto_refresh.dart b/lib/widgets/auto_refresh.dart new file mode 100644 index 0000000..c64a0db --- /dev/null +++ b/lib/widgets/auto_refresh.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/providers.dart'; + +class AutoRefresh extends ConsumerStatefulWidget { + const AutoRefresh({super.key, required this.location, required this.child}); + final String location; + final Widget child; + + static const pollEvery = Duration(seconds: 60); + static const gap = Duration(seconds: 30); + static const resumeGap = Duration(seconds: 30); + + @override + ConsumerState createState() => _AutoRefreshState(); +} + +class _AutoRefreshState extends ConsumerState + with WidgetsBindingObserver { + Timer? _poll, _cool, _resumeCool; + + static const _lists = ['/results', '/shops', '/orders', '/users']; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _poll = Timer.periodic(AutoRefresh.pollEvery, (_) => _onPoll()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _poll?.cancel(); + _cool?.cancel(); + _resumeCool?.cancel(); + super.dispose(); + } + + bool get _onList => _lists.contains(widget.location); + + bool get _visible { + final s = WidgetsBinding.instance.lifecycleState; + return s == null || s == AppLifecycleState.resumed; + } + + // False while a sheet or dialog sits on top of the screen. + bool get _uncovered => ModalRoute.of(context)?.isCurrent ?? true; + + bool get _signedIn => ref.read(sessionProvider) != null; + + void _reload() => ref.read(dataVersionProvider.notifier).bump(); + + void _onPoll() { + if (_cool != null || + !_visible || + !_signedIn || + !_uncovered || + widget.location != '/orders') { + return; + } + _reload(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + if (_resumeCool != null || !_signedIn || !_onList || !_uncovered) return; + _reload(); + _resumeCool = Timer(AutoRefresh.resumeGap, () => _resumeCool = null); + } + + @override + Widget build(BuildContext context) { + // Every reload, whoever started it, restarts the quiet period. + ref.listen(dataVersionProvider, (_, _) { + _cool?.cancel(); + _cool = Timer(AutoRefresh.gap, () => _cool = null); + }); + return widget.child; + } +} diff --git a/lib/widgets/main_shell.dart b/lib/widgets/main_shell.dart index b610b81..7352d32 100644 --- a/lib/widgets/main_shell.dart +++ b/lib/widgets/main_shell.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../core/providers.dart'; import '../core/theme.dart'; import 'account_sheet.dart'; +import 'auto_refresh.dart'; import 'ui.dart'; class _Tab { @@ -43,7 +44,10 @@ class MainShell extends ConsumerWidget { final tabs = user?.isAdmin == true ? _tabs : _tabs.sublist(0, 3); final width = MediaQuery.sizeOf(context).width; final current = tabs.where((t) => location.startsWith(t.path)).firstOrNull; + return AutoRefresh(location: location, child: _frame(width, tabs, current)); + } + Widget _frame(double width, List<_Tab> tabs, _Tab? current) { if (width >= Bp.medium) { return Scaffold( body: Row( diff --git a/lib/widgets/ui.dart b/lib/widgets/ui.dart index 69b8bef..8d83ebd 100644 --- a/lib/widgets/ui.dart +++ b/lib/widgets/ui.dart @@ -1047,32 +1047,6 @@ class _RefreshableViewState extends State> { } } -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; diff --git a/test/app_test.dart b/test/app_test.dart index a083f08..040b881 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -150,6 +150,44 @@ void main() { expect(t.widget(find.byType(Switch).last).onChanged, isNull); }); + group('dates and orders', () { + testWidgets('opens on today, even before its results are out', (t) async { + await boot(t); + await signIn(t, 'owner', 'admin123'); + expect(find.textContaining('Fri, 16 Oct 2026'), findsOneWidget); + await t.tap(find.byTooltip('Next draw')); + await t.pumpAndSettle(); + expect(find.text('No result yet'), findsOneWidget); + expect(find.text('AWAITING RESULTS'), findsOneWidget); + expect(find.textContaining('3:00 PM'), findsNothing); + }); + + testWidgets('orders can be searched by shop, message or ending', (t) async { + await boot(t); + await signIn(t, 'owner', 'admin123'); + await t.tap(find.text('Orders').last); + await t.pumpAndSettle(); + expect(find.text('Kazhakkoottam'), findsWidgets); + await t.enterText(find.byType(TextField), 'ulloor'); + await t.pumpAndSettle(); + expect(find.text('Kazhakkoottam'), findsNothing); + await t.enterText(find.byType(TextField), 'nothing like this'); + await t.pumpAndSettle(); + expect(find.text('No matching orders'), findsOneWidget); + }); + + testWidgets('a slip sent with a note shows both', (t) async { + await boot(t); + await signIn(t, 'owner', 'admin123'); + await t.tap(find.text('Orders').last); + await t.pumpAndSettle(); + await t.tap(find.textContaining('Photo + text').first); + await t.pumpAndSettle(); + expect(find.text('Handwritten slip'), findsOneWidget); + expect(find.textContaining('please add 4410'), findsOneWidget); + }); + }); + group('loading and refresh', () { testWidgets('a new date shows a skeleton until its results arrive', ( t, @@ -221,24 +259,71 @@ void main() { expect(find.text('Unable to reach the server.'), findsNothing); expect(find.text('Retry'), findsNothing); }); + }); - testWidgets('Refresh button shows on wide windows only, and reloads', ( + group('automatic refresh', () { + Future leaveAndReturn(WidgetTester t) async { + final b = t.binding; + b.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + b.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + b.handleAppLifecycleStateChanged(AppLifecycleState.paused); + b.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + b.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + b.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await t.pump(); + await t.pump(const Duration(milliseconds: 400)); + } + + testWidgets('coming back reloads, but not more than once per 30 s', ( t, ) async { - final api = await boot(t, width: 1280); + final api = await boot(t); 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); + final start = api.resultsCalls; - t.view.physicalSize = const Size(430 * 2, 900 * 2); + await leaveAndReturn(t); + expect(api.resultsCalls, start + 1); + expect(find.text('₹7,700'), findsOneWidget); + + await leaveAndReturn(t); + expect(api.resultsCalls, start + 1); + + await t.pump(const Duration(seconds: 31)); + await leaveAndReturn(t); + expect(api.resultsCalls, start + 2); + }); + + testWidgets('Orders reloads every minute, other screens do not', (t) async { + final api = await boot(t); + await signIn(t, 'owner', 'admin123'); + await t.tap(find.text('Orders').last); await t.pumpAndSettle(); - expect(find.byTooltip('Refresh'), findsNothing); + final orders = api.ordersCalls; + final results = api.resultsCalls; + + await t.pump(const Duration(seconds: 61)); + await t.pump(const Duration(milliseconds: 400)); + expect(api.ordersCalls, greaterThan(orders)); + + await t.tap(find.text('Shops').last); + await t.pumpAndSettle(); + final after = api.ordersCalls; + await t.pump(const Duration(seconds: 61)); + await t.pump(const Duration(milliseconds: 400)); + expect(api.ordersCalls, after); + expect(api.resultsCalls, results); + }); + + testWidgets('a failed background reload is silent', (t) async { + final api = await boot(t); + await signIn(t, 'owner', 'admin123'); + api.failResults = true; + await leaveAndReturn(t); + await t.pump(const Duration(seconds: 1)); + expect(find.text('₹7,700'), findsOneWidget); + expect(find.textContaining("Couldn't refresh"), findsNothing); + api.failResults = false; + await t.pumpAndSettle(const Duration(seconds: 1)); }); }); } diff --git a/test/support/fake_api.dart b/test/support/fake_api.dart index 3bd050d..b587932 100644 --- a/test/support/fake_api.dart +++ b/test/support/fake_api.dart @@ -49,6 +49,7 @@ class _O { class FakeApi implements Api { /// How many times results were fetched, for refresh tests. int resultsCalls = 0; + int ordersCalls = 0; /// When true, results fail as if the network were down. bool failResults = false; @@ -375,7 +376,7 @@ class FakeApi implements Api { edited: o.edited, lines: lines, rawText: o.isPhoto - ? null + ? 'Same as last time, please add 4410 - 2' : '${shop.code}\n${keys.map((e) => '$e - ${o.lines[e]}').join('\n')}', ); } @@ -620,6 +621,7 @@ class FakeApi implements Api { @override Future> orders(DateTime date) async { + ordersCalls++; await _t(); final list = _ordersFor(dateOnly(date)).map(_toOrder).toList() ..sort((x, y) => y.time.compareTo(x.time));