refactor: updated UI and other trivial fixes
All checks were successful
deploy / deploy (push) Successful in 4m58s
All checks were successful
deploy / deploy (push) Successful in 4m58s
This commit is contained in:
parent
125f4d3e86
commit
749d18192a
@ -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));
|
||||
|
||||
@ -104,6 +104,15 @@ class Order {
|
||||
final String? rawText;
|
||||
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 prize => lines.fold(0, (a, l) => a + l.prize);
|
||||
int get winTickets => lines.where((l) => l.wins).fold(0, (a, l) => a + l.qty);
|
||||
|
||||
@ -32,11 +32,7 @@ class SessionNotifier extends Notifier<AppUser?> {
|
||||
Future<void> login(String username, String password) async {
|
||||
final api = ref.read(apiProvider);
|
||||
final u = await api.login(username, password);
|
||||
try {
|
||||
ref.read(selectedDateProvider.notifier).set(await api.latestDrawDate());
|
||||
} on ApiException {
|
||||
// ignore: empty_catches
|
||||
}
|
||||
ref.read(selectedDateProvider.notifier).set(api.now);
|
||||
state = u;
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -132,7 +132,7 @@ class _OrderDetailScreenState extends ConsumerState<OrderDetailScreen> {
|
||||
),
|
||||
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<OrderDetailScreen> {
|
||||
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,
|
||||
|
||||
@ -17,6 +17,7 @@ class OrdersScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _OrdersScreenState extends ConsumerState<OrdersScreen> {
|
||||
String? _code;
|
||||
String _q = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -25,15 +26,25 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
|
||||
Future<void> 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<OrdersScreen> {
|
||||
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 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,7 +105,12 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
|
||||
),
|
||||
),
|
||||
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,
|
||||
title: 'No orders for this date',
|
||||
body:
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@ -26,7 +26,6 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
|
||||
final shops = ref.watch(shopsProvider(date));
|
||||
final count = shops.value?.where((s) => s.shop.active).length;
|
||||
Future<void> refresh() => refreshData(context, ref, shopsProvider(date));
|
||||
final wide = isWide(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@ -36,20 +35,9 @@ class _ShopsScreenState extends ConsumerState<ShopsScreen> {
|
||||
? 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,
|
||||
|
||||
@ -21,6 +21,11 @@ class _TicketsScreenState extends ConsumerState<TicketsScreen> {
|
||||
bool _byEnding = true;
|
||||
String _q = '';
|
||||
|
||||
void _mode(bool byEnding) => setState(() {
|
||||
_byEnding = byEnding;
|
||||
_q = '';
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = ref.watch(selectedDateProvider);
|
||||
@ -48,25 +53,19 @@ class _TicketsScreenState extends ConsumerState<TicketsScreen> {
|
||||
),
|
||||
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)',
|
||||
key: ValueKey(_byEnding),
|
||||
hint: _byEnding
|
||||
? 'Search endings (e.g. 0987)'
|
||||
: 'Search message or ending',
|
||||
onChanged: (v) => setState(() => _q = v.trim()),
|
||||
),
|
||||
),
|
||||
@ -116,19 +115,32 @@ class _TicketsScreenState extends ConsumerState<TicketsScreen> {
|
||||
}
|
||||
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<TicketsScreen> {
|
||||
}
|
||||
|
||||
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<TicketsScreen> {
|
||||
children: [
|
||||
CardGrid(
|
||||
children: [
|
||||
for (final o in d.orders)
|
||||
for (final o in orders)
|
||||
OrderCard(order: o, onTap: () => context.push('/orders/${o.id}')),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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 <Shop>[];
|
||||
final shops = ref.watch(activeShopsProvider).value ?? const <Shop>[];
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: asyncView(
|
||||
|
||||
@ -47,15 +47,9 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
? 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(
|
||||
|
||||
@ -17,7 +17,7 @@ Future<void> 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<void> main() async {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<DateTime> _latestOrToday(Api api) async {
|
||||
try {
|
||||
return await api.latestDrawDate();
|
||||
} on ApiException {
|
||||
return dateOnly(api.now);
|
||||
}
|
||||
}
|
||||
|
||||
85
lib/widgets/auto_refresh.dart
Normal file
85
lib/widgets/auto_refresh.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
const Skeleton({super.key, required this.child, this.announce = true});
|
||||
final Widget child;
|
||||
|
||||
@ -150,6 +150,44 @@ void main() {
|
||||
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', () {
|
||||
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<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,
|
||||
) 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<List<Order>> orders(DateTime date) async {
|
||||
ordersCalls++;
|
||||
await _t();
|
||||
final list = _ordersFor(dateOnly(date)).map(_toOrder).toList()
|
||||
..sort((x, y) => y.time.compareTo(x.time));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user