refactor: updated UI and other trivial fixes
All checks were successful
deploy / deploy (push) Successful in 4m58s

This commit is contained in:
Aswin B. S 2026-11-05 10:13:32 +05:30
parent 125f4d3e86
commit 749d18192a
18 changed files with 323 additions and 141 deletions

View File

@ -70,7 +70,12 @@ class HttpApi implements Api {
} }
if (response.statusCode >= 200 && response.statusCode < 300) { 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(); if (response.statusCode == 401 && signedIn) _endSession();
throw ApiException(_errorMessage(response)); throw ApiException(_errorMessage(response));

View File

@ -104,6 +104,15 @@ class Order {
final String? rawText; final String? rawText;
final List<EndingQty> lines; final List<EndingQty> 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 tickets => lines.fold(0, (a, l) => a + l.qty);
int get prize => lines.fold(0, (a, l) => a + l.prize); 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); int get winTickets => lines.where((l) => l.wins).fold(0, (a, l) => a + l.qty);

View File

@ -32,11 +32,7 @@ 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);
try { ref.read(selectedDateProvider.notifier).set(api.now);
ref.read(selectedDateProvider.notifier).set(await api.latestDrawDate());
} on ApiException {
// ignore: empty_catches
}
state = u; state = u;
} }

View File

@ -53,7 +53,7 @@ class OrderCard extends StatelessWidget {
const SizedBox(width: S.s6), const SizedBox(width: S.s6),
Flexible( Flexible(
child: Text( child: Text(
'${o.shop.code} · ${time12(o.time)} · ${o.isPhoto ? 'Photo' : 'Text'}', '${o.shop.code} · ${time12(o.time)} · ${o.kind}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: ts(12, c: C.muted), style: ts(12, c: C.muted),

View File

@ -132,7 +132,7 @@ class _OrderDetailScreenState extends ConsumerState<OrderDetailScreen> {
), ),
const SizedBox(height: S.s4), const SizedBox(height: S.s4),
Text( 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), style: ts(12, c: C.muted),
), ),
const SizedBox(height: S.s2), const SizedBox(height: S.s2),
@ -176,8 +176,9 @@ class _OrderDetailScreenState extends ConsumerState<OrderDetailScreen> {
Text('Received via WhatsApp', style: ts(12, c: C.muted)), 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( SrsCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -17,6 +17,7 @@ class OrdersScreen extends ConsumerStatefulWidget {
class _OrdersScreenState extends ConsumerState<OrdersScreen> { class _OrdersScreenState extends ConsumerState<OrdersScreen> {
String? _code; String? _code;
String _q = '';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -25,15 +26,25 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
Future<void> refresh() => refreshData(context, ref, ordersProvider(date)); Future<void> refresh() => refreshData(context, ref, ordersProvider(date));
return Column( return Column(
children: [ children: [
ScreenHeader( const ScreenHeader(title: 'Orders'),
title: 'Orders', PagePad(
trailing: isWide(context) ? RefreshButton(onRefresh: refresh) : null,
),
const PagePad(
top: S.s4, top: S.s4,
child: Align( child: Align(
alignment: Alignment.centerLeft, 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( Expanded(
@ -63,9 +74,12 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
final codes = <String>{for (final o in all) o.shop.code}.toList() final codes = <String>{for (final o in all) o.shop.code}.toList()
..sort(); ..sort();
final active = codes.contains(_code) ? _code : null; final active = codes.contains(_code) ? _code : null;
final list = active == null final list = [
? all for (final o in all)
: all.where((o) => o.shop.code == active).toList(); if ((active == null || o.shop.code == active) &&
o.matches(_q))
o,
];
final tickets = list.fold(0, (a, o) => a + o.tickets); final tickets = list.fold(0, (a, o) => a + o.tickets);
return PageList( return PageList(
top: S.s14, top: S.s14,
@ -91,7 +105,12 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
), ),
), ),
if (list.isEmpty) if (list.isEmpty)
const EmptyState( _q.isNotEmpty
? const EmptyState(
icon: Icons.search_off_rounded,
title: 'No matching orders',
)
: const EmptyState(
icon: Icons.receipt_long_outlined, icon: Icons.receipt_long_outlined,
title: 'No orders for this date', title: 'No orders for this date',
body: body:

View File

@ -25,10 +25,6 @@ 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,
@ -199,7 +195,7 @@ class _Body extends StatelessWidget {
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), style: ts(13, c: C.muted)),
], ],
), ),
), ),
@ -272,37 +268,48 @@ class _Body extends StatelessWidget {
_HeroRow( _HeroRow(
hero: NavyCard( hero: NavyCard(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.fromLTRB(S.s4, S.s4, S.s12, S.s4),
horizontal: S.s10,
vertical: S.s6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: C.goldSoft, color: C.goldSoft,
borderRadius: BorderRadius.circular(R.pill), borderRadius: BorderRadius.circular(R.pill),
border: Border.all(color: C.gold.withAlpha(90)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.schedule_rounded, size: 14, color: C.gold), Container(
const SizedBox(width: S.s6), 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( Text(
'AWAITING RESULTS', '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( Text(
'Results not yet published', 'Results not yet published',
style: ts(26, w: w7, c: Colors.white, ls: -0.4), style: ts(26, w: w7, c: Colors.white, ls: -0.4),
), ),
const SizedBox(height: S.s8), const SizedBox(height: S.s8),
Text( 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), style: ts(13, c: C.softOnNavy, h: 1.5),
), ),
], ],

View File

@ -26,7 +26,6 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
final shops = ref.watch(shopsProvider(date)); final shops = ref.watch(shopsProvider(date));
final count = shops.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)); Future<void> refresh() => refreshData(context, ref, shopsProvider(date));
final wide = isWide(context);
return Column( return Column(
children: [ children: [
@ -36,20 +35,9 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
? null ? null
: '${plural(count, 'shop')} · ${dateShort(date)}', : '${plural(count, 'shop')} · ${dateShort(date)}',
subtitleLoading: shops.isLoading, subtitleLoading: shops.isLoading,
trailing: !wide && !isAdmin trailing: isAdmin
? null ? SmallButton('Add shop', onTap: () => showShopSheet(context))
: Row( : null,
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,

View File

@ -21,6 +21,11 @@ class _TicketsScreenState extends ConsumerState<TicketsScreen> {
bool _byEnding = true; bool _byEnding = true;
String _q = ''; String _q = '';
void _mode(bool byEnding) => setState(() {
_byEnding = byEnding;
_q = '';
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = ref.watch(selectedDateProvider); final date = ref.watch(selectedDateProvider);
@ -48,25 +53,19 @@ class _TicketsScreenState extends ConsumerState<TicketsScreen> {
), ),
child: Row( child: Row(
children: [ children: [
_seg( _seg('By ending', _byEnding, () => _mode(true)),
'By ending', _seg('By order', !_byEnding, () => _mode(false)),
_byEnding,
() => setState(() => _byEnding = true),
),
_seg(
'By order',
!_byEnding,
() => setState(() => _byEnding = false),
),
], ],
), ),
), ),
), ),
if (_byEnding)
PagePad( PagePad(
bottom: S.s8, bottom: S.s8,
child: SearchField( child: SearchField(
hint: 'Search endings (e.g. 0987)', key: ValueKey(_byEnding),
hint: _byEnding
? 'Search endings (e.g. 0987)'
: 'Search message or ending',
onChanged: (v) => setState(() => _q = v.trim()), onChanged: (v) => setState(() => _q = v.trim()),
), ),
), ),
@ -116,19 +115,32 @@ class _TicketsScreenState extends ConsumerState<TicketsScreen> {
} }
return PageList( return PageList(
children: [ children: [
Wrap( LayoutBuilder(
spacing: 8, builder: (context, c) {
runSpacing: 8, const gap = 8.0, min = 104.0;
children: [for (final e in list) _chip(e)], 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; final win = e.wins;
return Container( 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( decoration: BoxDecoration(
color: win ? C.greenTint : Colors.white, color: win ? C.greenTint : Colors.white,
borderRadius: BorderRadius.circular(R.md), borderRadius: BorderRadius.circular(R.md),
@ -152,10 +164,20 @@ class _TicketsScreenState extends ConsumerState<TicketsScreen> {
} }
Widget _orders(ShopDetail d) { 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( return ListView(
children: const [ children: [
EmptyState(icon: Icons.receipt_long_outlined, title: 'No orders'), 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<TicketsScreen> {
children: [ children: [
CardGrid( CardGrid(
children: [ children: [
for (final o in d.orders) for (final o in orders)
OrderCard(order: o, onTap: () => context.push('/orders/${o.id}')), OrderCard(order: o, onTap: () => context.push('/orders/${o.id}')),
], ],
), ),

View File

@ -18,8 +18,7 @@ class UserDetailScreen extends ConsumerWidget {
@override @override
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).value ?? const <Shop>[];
ref.watch(activeShopsProvider).value ?? const <Shop>[];
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
child: asyncView( child: asyncView(

View File

@ -47,15 +47,9 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
? plural(list.length, 'user') ? plural(list.length, 'user')
: '${plural(list.length, 'user')}, $off inactive', : '${plural(list.length, 'user')}, $off inactive',
subtitleLoading: users.isLoading, subtitleLoading: users.isLoading,
trailing: Row( trailing: SmallButton(
mainAxisSize: MainAxisSize.min, 'Add user',
children: [ onTap: () => context.push('/users/new'),
if (isWide(context)) ...[
RefreshButton(onRefresh: refresh),
const SizedBox(width: S.s10),
],
SmallButton('Add user', onTap: () => context.push('/users/new')),
],
), ),
), ),
Expanded( Expanded(

View File

@ -17,7 +17,7 @@ Future<void> main() async {
); );
final user = await api.restore(); final user = await api.restore();
final start = user == null ? dateOnly(api.now) : await _latestOrToday(api); final start = dateOnly(api.now);
runApp( runApp(
ProviderScope( ProviderScope(
@ -30,11 +30,3 @@ Future<void> main() async {
), ),
); );
} }
Future<DateTime> _latestOrToday(Api api) async {
try {
return await api.latestDrawDate();
} on ApiException {
return dateOnly(api.now);
}
}

View File

@ -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<AutoRefresh> createState() => _AutoRefreshState();
}
class _AutoRefreshState extends ConsumerState<AutoRefresh>
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;
}
}

View File

@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../core/providers.dart'; import '../core/providers.dart';
import '../core/theme.dart'; import '../core/theme.dart';
import 'account_sheet.dart'; import 'account_sheet.dart';
import 'auto_refresh.dart';
import 'ui.dart'; import 'ui.dart';
class _Tab { class _Tab {
@ -43,7 +44,10 @@ class MainShell extends ConsumerWidget {
final tabs = user?.isAdmin == true ? _tabs : _tabs.sublist(0, 3); final tabs = user?.isAdmin == true ? _tabs : _tabs.sublist(0, 3);
final width = MediaQuery.sizeOf(context).width; final width = MediaQuery.sizeOf(context).width;
final current = tabs.where((t) => location.startsWith(t.path)).firstOrNull; 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) { if (width >= Bp.medium) {
return Scaffold( return Scaffold(
body: Row( body: Row(

View File

@ -1047,32 +1047,6 @@ class _RefreshableViewState<T> extends State<RefreshableView<T>> {
} }
} }
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 { class Skeleton extends StatefulWidget {
const Skeleton({super.key, required this.child, this.announce = true}); const Skeleton({super.key, required this.child, this.announce = true});
final Widget child; final Widget child;

View File

@ -150,6 +150,44 @@ void main() {
expect(t.widget<Switch>(find.byType(Switch).last).onChanged, isNull); expect(t.widget<Switch>(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', () { group('loading and refresh', () {
testWidgets('a new date shows a skeleton until its results arrive', ( testWidgets('a new date shows a skeleton until its results arrive', (
t, t,
@ -221,24 +259,71 @@ void main() {
expect(find.text('Unable to reach the server.'), findsNothing); expect(find.text('Unable to reach the server.'), findsNothing);
expect(find.text('Retry'), findsNothing); expect(find.text('Retry'), findsNothing);
}); });
});
testWidgets('Refresh button shows on wide windows only, and reloads', ( group('automatic refresh', () {
Future<void> 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, t,
) async { ) async {
final api = await boot(t, width: 1280); final api = await boot(t);
await signIn(t, 'owner', 'admin123'); await signIn(t, 'owner', 'admin123');
final before = api.resultsCalls; final start = 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 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(); 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));
}); });
}); });
} }

View File

@ -49,6 +49,7 @@ class _O {
class FakeApi implements Api { class FakeApi implements Api {
/// How many times results were fetched, for refresh tests. /// How many times results were fetched, for refresh tests.
int resultsCalls = 0; int resultsCalls = 0;
int ordersCalls = 0;
/// When true, results fail as if the network were down. /// When true, results fail as if the network were down.
bool failResults = false; bool failResults = false;
@ -375,7 +376,7 @@ class FakeApi implements Api {
edited: o.edited, edited: o.edited,
lines: lines, lines: lines,
rawText: o.isPhoto rawText: o.isPhoto
? null ? 'Same as last time, please add 4410 - 2'
: '${shop.code}\n${keys.map((e) => '$e - ${o.lines[e]}').join('\n')}', : '${shop.code}\n${keys.map((e) => '$e - ${o.lines[e]}').join('\n')}',
); );
} }
@ -620,6 +621,7 @@ class FakeApi implements Api {
@override @override
Future<List<Order>> orders(DateTime date) async { Future<List<Order>> orders(DateTime date) async {
ordersCalls++;
await _t(); await _t();
final list = _ordersFor(dateOnly(date)).map(_toOrder).toList() final list = _ordersFor(dateOnly(date)).map(_toOrder).toList()
..sort((x, y) => y.time.compareTo(x.time)); ..sort((x, y) => y.time.compareTo(x.time));