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) {
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));

View File

@ -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);

View File

@ -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;
}

View File

@ -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.

View File

@ -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),

View File

@ -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,

View File

@ -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,12 +105,17 @@ class _OrdersScreenState extends ConsumerState<OrdersScreen> {
),
),
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(

View File

@ -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),
),
],

View File

@ -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,

View File

@ -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,28 +53,22 @@ 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)',
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<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}')),
],
),

View File

@ -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(

View File

@ -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(

View File

@ -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);
}
}

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/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(

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 {
const Skeleton({super.key, required this.child, this.announce = true});
final Widget child;

View File

@ -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));
});
});
}

View File

@ -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));