Initial commit

Add SRS app: results, shops, orders and users, wired to srs-api
Flutter web/PWA for the lottery agency, built on Riverpod and go_router,
talking to srs-api over HTTP (HttpApi, token kept in SharedPreferences).

- Results, Shops, Orders and Users tabs (Users for admins only), with
  phone, tablet and desktop layouts and shared widgets and theme tokens
- Users: list with search and Active/Inactive filters; user detail with
  shops, sign-in details, and an Access card
- WhatsApp results switch on each user (sendResults on
  PATCH /api/users/:id). Shows the WhatsApp logo (Simple Icons, CC0,
  drawn with flutter_svg), green only when results will really go out.
  Can't be turned on without a phone number ("Add a phone number first"
  opens the phone sheet), and is paused while the user is inactive. The
  users list marks people who get results with the logo.
- Each Access switch has a one-line description; the explanation
  paragraphs are gone
- Remove demo mode: the app always uses the real AP
  test/support and is used only by tests.
This commit is contained in:
Aswin B. S 2026-11-04 23:21:50 +05:30
commit 45fcdcebfb
51 changed files with 8943 additions and 0 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
API_BASE_URL=http://127.0.0.1:8080

47
.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Local settings (copy .env.example to .env)
.env
# Widget Preview related
.widget_preview/

18
.metadata Normal file
View File

@ -0,0 +1,18 @@
version:
revision: "c197ca5199e9ee5661e865596c9c07294dfab51c"
channel: "stable"
project_type: app
migration:
platforms:
- platform: root
create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
- platform: web
create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

13
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "SRS (local API)",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"deviceId": "chrome",
"args": ["--web-port", "5000", "--dart-define-from-file=.env"]
}
]
}

24
analysis_options.yaml Normal file
View File

@ -0,0 +1,24 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- web/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

BIN
assets/fonts/Inter-Bold.otf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WhatsApp</title><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

107
lib/app.dart Normal file
View File

@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/providers.dart';
import 'core/theme.dart';
import 'features/auth/login_screen.dart';
import 'features/orders/order_detail_screen.dart';
import 'features/orders/orders_screen.dart';
import 'features/results/results_screen.dart';
import 'features/shops/shop_detail_screen.dart';
import 'features/shops/shops_screen.dart';
import 'features/shops/tickets_screen.dart';
import 'features/users/user_detail_screen.dart';
import 'features/users/user_form_screen.dart';
import 'features/users/users_screen.dart';
import 'widgets/main_shell.dart';
final routerProvider = Provider<GoRouter>((ref) {
final refresh = ValueNotifier<int>(0);
ref.listen(sessionProvider, (_, _) => refresh.value++);
ref.onDispose(refresh.dispose);
Page<void> tab(GoRouterState s, Widget w) =>
NoTransitionPage<void>(key: s.pageKey, child: w);
return GoRouter(
initialLocation: '/results',
refreshListenable: refresh,
redirect: (context, state) {
final user = ref.read(sessionProvider);
final loc = state.matchedLocation;
if (user == null) return loc == '/login' ? null : '/login';
if (loc == '/login') return '/results';
if (loc.startsWith('/users') && !user.isAdmin) return '/results';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, _) => const LoginScreen()),
// Every signed-in screen lives in the shell, so the sidebar stays in
// place on wide windows while detail screens open beside it.
ShellRoute(
builder: (_, s, child) =>
MainShell(location: s.matchedLocation, child: child),
routes: [
GoRoute(
path: '/results',
pageBuilder: (_, s) => tab(s, const ResultsScreen()),
),
GoRoute(
path: '/shops',
pageBuilder: (_, s) => tab(s, const ShopsScreen()),
routes: [
GoRoute(
path: ':id',
builder: (_, s) => ShopDetailScreen(shopId: _id(s)),
routes: [
GoRoute(
path: 'tickets',
builder: (_, s) => TicketsScreen(shopId: _id(s)),
),
],
),
],
),
GoRoute(
path: '/orders',
pageBuilder: (_, s) => tab(s, const OrdersScreen()),
routes: [
GoRoute(
path: ':id',
builder: (_, s) => OrderDetailScreen(orderId: _id(s)),
),
],
),
GoRoute(
path: '/users',
pageBuilder: (_, s) => tab(s, const UsersScreen()),
routes: [
GoRoute(path: 'new', builder: (_, _) => const UserFormScreen()),
GoRoute(
path: ':id',
builder: (_, s) => UserDetailScreen(userId: _id(s)),
),
],
),
],
),
],
);
});
int _id(GoRouterState s) => int.parse(s.pathParameters['id']!);
class SrsApp extends ConsumerWidget {
const SrsApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp.router(
title: 'SRS',
debugShowCheckedModeBanner: false,
theme: buildTheme(),
routerConfig: ref.watch(routerProvider),
);
}
}

53
lib/core/api.dart Normal file
View File

@ -0,0 +1,53 @@
import 'models.dart';
class ApiException implements Exception {
ApiException(this.message);
final String message;
@override
String toString() => message;
}
abstract class Api {
DateTime get now;
Stream<void> get sessionEnded;
Future<DateTime> latestDrawDate();
Future<AppUser> login(String username, String password);
Future<AppUser?> restore();
void logout();
Future<List<DrawInfo>> month(int year, int month);
Future<DrawResults> results(DateTime date);
Future<List<ShopDay>> shops(DateTime date);
Future<ShopDetail> shopDetail(int shopId, DateTime date);
Future<List<Shop>> activeShops();
Future<Shop> createShop(String name, String code);
Future<void> renameShop(int shopId, String name);
Future<void> setShopActive(int shopId, bool active);
Future<List<Order>> orders(DateTime date);
Future<Order> order(int orderId);
Future<void> setLineQty(int orderId, String ending, int qty);
Future<void> removeLine(int orderId, String ending);
Future<void> cancelOrder(int orderId);
Future<List<AppUser>> users();
Future<AppUser> user(int id);
Future<AppUser> createUser({
required String username,
String? phone,
required String password,
required List<int> shopIds,
});
Future<void> updateUser(
int id, {
String? username,
String? phone,
bool clearPhone = false,
String? password,
bool? active,
bool? sendResults,
});
Future<void> setUserShops(int id, List<int> shopIds);
Future<void> deleteUser(int id);
}

11
lib/core/config.dart Normal file
View File

@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart';
class AppConfig {
const AppConfig._();
static const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
static bool get unconfigured => kDebugMode && apiBaseUrl.isEmpty;
static const setupHint =
'No API_BASE_URL set. For local development run with '
'--dart-define-from-file=.env (copy .env.example to .env first).';
}

117
lib/core/format.dart Normal file
View File

@ -0,0 +1,117 @@
const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const _monthsLong = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
String inr(int n) {
final neg = n < 0;
final s = n.abs().toString();
if (s.length <= 3) return '${neg ? '-' : ''}$s';
final last = s.substring(s.length - 3);
var rest = s.substring(0, s.length - 3);
final parts = <String>[];
while (rest.length > 2) {
parts.insert(0, rest.substring(rest.length - 2));
rest = rest.substring(0, rest.length - 2);
}
if (rest.isNotEmpty) parts.insert(0, rest);
return '${neg ? '-' : ''}${parts.join(',')},$last';
}
String rupees(int n) => '₹${inr(n)}';
DateTime dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
bool sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
DateTime addDays(DateTime d, int n) => DateTime(d.year, d.month, d.day + n);
/// Fri, 16 Oct 2026
String dateLong(DateTime d) =>
'${_weekdays[d.weekday - 1]}, ${d.day} ${_months[d.month - 1]} ${d.year}';
/// Fri, 16 Oct
String dateShort(DateTime d) =>
'${_weekdays[d.weekday - 1]}, ${d.day} ${_months[d.month - 1]}';
/// October 2026
String monthYear(DateTime d) => '${_monthsLong[d.month - 1]} ${d.year}';
/// 12:41 PM
String time12(DateTime d) {
final h = d.hour % 12 == 0 ? 12 : d.hour % 12;
final m = d.minute.toString().padLeft(2, '0');
return '$h:$m ${d.hour >= 12 ? 'PM' : 'AM'}';
}
String isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
String plural(int n, String one, [String? many]) =>
'${inr(n)} ${n == 1 ? one : (many ?? '${one}s')}';
/// 919000012345 -> +91 90000 12345
String fmtPhone(String? digits) {
if (digits == null || digits.isEmpty) return '';
if (digits.length == 12 && digits.startsWith('91')) {
return '+91 ${digits.substring(2, 7)} ${digits.substring(7)}';
}
return '+$digits';
}
String initials(String name) {
final parts = name.split(RegExp(r'[._\- ]+')).where((p) => p.isNotEmpty);
final list = parts.toList();
if (list.isEmpty) return '?';
if (list.length == 1) {
final w = list.first;
return w.substring(0, w.length >= 2 ? 2 : 1).toUpperCase();
}
return (list[0][0] + list[1][0]).toUpperCase();
}
String tierLabel(int tier) => '${tier}th prize';
/// "5th prize", or "5th + 8th prize" for an ending in two tiers.
String tiersLabel(List<int> tiers) {
final sorted = [...tiers]..sort();
if (sorted.length < 2) return sorted.isEmpty ? '' : tierLabel(sorted.first);
return '${sorted.map((t) => '${t}th').join(' + ')} prize';
}
/// A server instant (UTC) as the Indian wall-clock time the screens show.
DateTime istWallClock(DateTime instant) {
final t = instant.toUtc().add(const Duration(hours: 5, minutes: 30));
return DateTime(t.year, t.month, t.day, t.hour, t.minute, t.second);
}
/// Parses a YYYY-MM-DD calendar date.
DateTime parseDate(String iso) => DateTime(
int.parse(iso.substring(0, 4)),
int.parse(iso.substring(5, 7)),
int.parse(iso.substring(8, 10)),
);

361
lib/core/http_api.dart Normal file
View File

@ -0,0 +1,361 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'api.dart';
import 'config.dart';
import 'format.dart';
import 'models.dart';
class HttpApi implements Api {
HttpApi({
required String baseUrl,
required SharedPreferences prefs,
http.Client? client,
this.timeout = const Duration(seconds: 20),
}) : _base = baseUrl.replaceAll(RegExp(r'/+$'), ''),
_prefs = prefs,
_client = client ?? http.Client();
static const _tokenKey = 'token';
static String get _unreachable => AppConfig.unconfigured
? 'Unable to reach the server. ${AppConfig.setupHint}'
: 'Unable to reach the server. Check your connection and try again.';
final String _base;
final SharedPreferences _prefs;
final http.Client _client;
final Duration timeout;
final _ended = StreamController<void>.broadcast();
@override
DateTime get now => istWallClock(DateTime.now());
@override
Stream<void> get sessionEnded => _ended.stream;
// ---- transport ---------------------------------------------------------
Uri _uri(String path, [Map<String, String>? query]) {
final origin = _base.isEmpty ? Uri.base.origin : _base;
return Uri.parse('$origin/api$path').replace(queryParameters: query);
}
/// Sends a request and returns the decoded JSON body (null for 204).
/// Any failure becomes an [ApiException] whose message can be shown as is.
Future<Object?> _send(
String method,
String path, {
Map<String, String>? query,
Object? body,
bool signedIn = true,
}) async {
final request = http.Request(method, _uri(path, query));
final token = _prefs.getString(_tokenKey);
if (signedIn && token != null) {
request.headers['authorization'] = 'Bearer $token';
}
if (body != null) {
request.headers['content-type'] = 'application/json';
request.body = jsonEncode(body);
}
final http.Response response;
try {
response = await http.Response.fromStream(
await _client.send(request).timeout(timeout),
);
} on Exception {
throw ApiException(_unreachable);
}
if (response.statusCode >= 200 && response.statusCode < 300) {
return response.body.isEmpty ? null : jsonDecode(response.body);
}
if (response.statusCode == 401 && signedIn) _endSession();
throw ApiException(_errorMessage(response));
}
String _errorMessage(http.Response r) {
try {
final m = (jsonDecode(r.body) as Map<String, dynamic>)['message'];
if (m is String && m.isNotEmpty) return m;
} on Object {
// ignore: avoid_catching_errors
}
return r.statusCode >= 500
? 'Something went wrong on the server. Try again shortly.'
: 'The request could not be completed.';
}
void _endSession() {
_prefs.remove(_tokenKey);
_ended.add(null);
}
Future<Map<String, dynamic>> _object(
String method,
String path, {
Map<String, String>? query,
Object? body,
bool signedIn = true,
}) async =>
await _send(method, path, query: query, body: body, signedIn: signedIn)
as Map<String, dynamic>;
Future<List<Map<String, dynamic>>> _list(
String path,
String key, {
Map<String, String>? query,
}) async {
final json = await _object('GET', path, query: query);
return (json[key] as List).cast<Map<String, dynamic>>();
}
// ---- mapping -----------------------------------------------------------
AppUser _user(Map<String, dynamic> j) => AppUser(
id: j['id'] as int,
username: j['username'] as String,
phone: j['phone'] as String?,
isAdmin: j['isAdmin'] as bool,
active: j['active'] as bool,
shopIds: (j['shopIds'] as List).cast<int>(),
sendResults: j['sendResults'] as bool? ?? false,
);
Shop _shop(Map<String, dynamic> j) => Shop(
id: j['id'] as int,
code: j['code'] as String,
name: j['name'] as String,
active: j['active'] as bool,
);
DrawInfo _draw(Map<String, dynamic> j) => DrawInfo(
date: parseDate(j['date'] as String),
scheme: j['scheme'] as String?,
code: j['code'] as String?,
published: j['published'] as bool,
hasWin: j['hasWin'] as bool,
);
EndingQty _line(Map<String, dynamic> j) => EndingQty(
ending: j['ending'] as String,
qty: j['qty'] as int,
hits: [
for (final h in (j['hits'] as List).cast<Map<String, dynamic>>())
PrizeHit(tier: h['tier'] as int, amount: h['amount'] as int),
],
);
Order _order(Map<String, dynamic> j) => Order(
id: j['id'] as int,
shop: _shop(j['shop'] as Map<String, dynamic>),
drawDate: parseDate(j['drawDate'] as String),
time: istWallClock(DateTime.parse(j['createdAt'] as String)),
isPhoto: j['isPhoto'] as bool,
edited: j['edited'] as bool,
rawText: j['rawText'] as String?,
lines: [
for (final l in (j['lines'] as List).cast<Map<String, dynamic>>())
_line(l),
],
);
String _day(DateTime d) => isoDate(d);
// ---- auth --------------------------------------------------------------
@override
Future<DateTime> latestDrawDate() async =>
parseDate((await _object('GET', '/draws/latest'))['date'] as String);
@override
Future<AppUser> login(String username, String password) async {
final json = await _object(
'POST',
'/auth/login',
body: {'username': username.trim(), 'password': password},
signedIn: false,
);
await _prefs.setString(_tokenKey, json['token'] as String);
return _user(json['user'] as Map<String, dynamic>);
}
@override
Future<AppUser?> restore() async {
if (_prefs.getString(_tokenKey) == null) return null;
try {
return _user(await _object('GET', '/me'));
} on ApiException {
return null;
}
}
@override
void logout() => _prefs.remove(_tokenKey);
// ---- results -----------------------------------------------------------
@override
Future<List<DrawInfo>> month(int year, int month) async {
final text = '$year-${month.toString().padLeft(2, '0')}';
return [
for (final d in await _list('/draws', 'days', query: {'month': text}))
_draw(d),
];
}
@override
Future<DrawResults> results(DateTime date) async {
final json = await _object('GET', '/draws/${_day(date)}/results');
return DrawResults(
draw: _draw(json['draw'] as Map<String, dynamic>),
shops: [
for (final s in (json['shops'] as List).cast<Map<String, dynamic>>())
ShopResult(
shop: _shop(s['shop'] as Map<String, dynamic>),
tickets: s['tickets'] as int,
orders: s['orders'] as int,
winTickets: s['winTickets'] as int,
prize: s['prize'] as int,
),
],
);
}
// ---- shops -------------------------------------------------------------
@override
Future<List<ShopDay>> shops(DateTime date) async => [
for (final s in await _list('/shops', 'shops', query: {'date': _day(date)}))
ShopDay(
shop: _shop(s['shop'] as Map<String, dynamic>),
tickets: s['tickets'] as int,
orders: s['orders'] as int,
endings: s['endings'] as int,
),
];
@override
Future<ShopDetail> shopDetail(int shopId, DateTime date) async {
final json = await _object('GET', '/shops/$shopId/draws/${_day(date)}');
return ShopDetail(
shop: _shop(json['shop'] as Map<String, dynamic>),
draw: _draw(json['draw'] as Map<String, dynamic>),
endings: [
for (final e in (json['endings'] as List).cast<Map<String, dynamic>>())
_line(e),
],
orders: [
for (final o in (json['orders'] as List).cast<Map<String, dynamic>>())
_order(o),
],
);
}
@override
Future<List<Shop>> activeShops() async => [
for (final s in await _list('/shops/options', 'shops')) _shop(s),
];
@override
Future<Shop> createShop(String name, String code) async => _shop(
await _object('POST', '/shops', body: {'name': name, 'code': code}),
);
@override
Future<void> renameShop(int shopId, String name) =>
_send('PATCH', '/shops/$shopId', body: {'name': name});
@override
Future<void> setShopActive(int shopId, bool active) =>
_send('PATCH', '/shops/$shopId', body: {'active': active});
// ---- orders ------------------------------------------------------------
@override
Future<List<Order>> orders(DateTime date) async => [
for (final o in await _list(
'/orders',
'orders',
query: {'date': _day(date)},
))
_order(o),
];
@override
Future<Order> order(int orderId) async =>
_order(await _object('GET', '/orders/$orderId'));
@override
Future<void> setLineQty(int orderId, String ending, int qty) =>
_send('PUT', '/orders/$orderId/lines/$ending', body: {'qty': qty});
@override
Future<void> removeLine(int orderId, String ending) =>
_send('DELETE', '/orders/$orderId/lines/$ending');
@override
Future<void> cancelOrder(int orderId) => _send('DELETE', '/orders/$orderId');
// ---- users -------------------------------------------------------------
@override
Future<List<AppUser>> users() async => [
for (final u in await _list('/users', 'users')) _user(u),
];
@override
Future<AppUser> user(int id) async =>
_user(await _object('GET', '/users/$id'));
@override
Future<AppUser> createUser({
required String username,
String? phone,
required String password,
required List<int> shopIds,
}) async => _user(
await _object(
'POST',
'/users',
body: {
'username': username,
'phone': phone,
'password': password,
'shopIds': shopIds,
},
),
);
@override
Future<void> updateUser(
int id, {
String? username,
String? phone,
bool clearPhone = false,
String? password,
bool? active,
bool? sendResults,
}) => _send(
'PATCH',
'/users/$id',
body: {
'username': ?username,
if (clearPhone) 'phone': null else 'phone': ?phone,
'password': ?password,
'active': ?active,
'sendResults': ?sendResults,
},
);
@override
Future<void> setUserShops(int id, List<int> shopIds) =>
_send('PUT', '/users/$id/shops', body: {'shopIds': shopIds});
@override
Future<void> deleteUser(int id) => _send('DELETE', '/users/$id');
}

173
lib/core/models.dart Normal file
View File

@ -0,0 +1,173 @@
import 'format.dart';
class AppUser {
const AppUser({
required this.id,
required this.username,
required this.isAdmin,
required this.active,
required this.shopIds,
this.phone,
this.sendResults = false,
});
final int id;
final String username;
final String? phone;
final bool isAdmin;
final bool active;
final List<int> shopIds;
final bool sendResults;
bool get getsResults => sendResults && active && phone != null;
}
class Shop {
const Shop({
required this.id,
required this.code,
required this.name,
this.active = true,
});
final int id;
final String code;
final String name;
final bool active;
Shop copyWith({String? name, bool? active}) => Shop(
id: id,
code: code,
name: name ?? this.name,
active: active ?? this.active,
);
}
class DrawInfo {
const DrawInfo({
required this.date,
required this.published,
this.scheme,
this.code,
this.hasWin = false,
});
final DateTime date;
final String? scheme;
final String? code;
final bool published;
final bool hasWin;
String get label => published ? '$scheme · $code' : 'No result yet';
}
class PrizeHit {
const PrizeHit({required this.tier, required this.amount});
final int tier;
final int amount;
}
class EndingQty {
const EndingQty({
required this.ending,
required this.qty,
this.hits = const [],
});
final String ending;
final int qty;
final List<PrizeHit> hits;
bool get wins => hits.isNotEmpty;
int get perTicket => hits.fold(0, (a, h) => a + h.amount);
int get prize => qty * perTicket;
String get tierText => tiersLabel([for (final h in hits) h.tier]);
}
class Order {
const Order({
required this.id,
required this.shop,
required this.drawDate,
required this.time,
required this.isPhoto,
required this.edited,
required this.lines,
this.rawText,
});
final int id;
final Shop shop;
final DateTime drawDate;
final DateTime time;
final bool isPhoto;
final bool edited;
final String? rawText;
final List<EndingQty> lines;
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);
}
class ShopResult {
const ShopResult({
required this.shop,
required this.tickets,
required this.orders,
required this.winTickets,
required this.prize,
});
final Shop shop;
final int tickets;
final int orders;
final int winTickets;
final int prize;
}
class DrawResults {
const DrawResults({required this.draw, required this.shops});
final DrawInfo draw;
final List<ShopResult> shops;
int get totalPrize => shops.fold(0, (a, s) => a + s.prize);
int get winningTickets => shops.fold(0, (a, s) => a + s.winTickets);
int get totalTickets => shops.fold(0, (a, s) => a + s.tickets);
int get orders => shops.fold(0, (a, s) => a + s.orders);
int get winningShops => shops.where((s) => s.prize > 0).length;
ShopResult? get topShop =>
shops.isNotEmpty && shops.first.prize > 0 ? shops.first : null;
}
class ShopDay {
const ShopDay({
required this.shop,
required this.tickets,
required this.orders,
required this.endings,
});
final Shop shop;
final int tickets;
final int orders;
final int endings;
}
class ShopDetail {
const ShopDetail({
required this.shop,
required this.draw,
required this.endings,
required this.orders,
});
final Shop shop;
final DrawInfo draw;
final List<EndingQty> endings;
final List<Order> orders;
int get tickets => endings.fold(0, (a, e) => a + e.qty);
List<EndingQty> get wins => endings.where((e) => e.wins).toList();
int get winTickets => wins.fold(0, (a, e) => a + e.qty);
int get prize => wins.fold(0, (a, e) => a + e.prize);
}

122
lib/core/providers.dart Normal file
View File

@ -0,0 +1,122 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api.dart';
import 'format.dart';
import 'models.dart';
final apiProvider = Provider<Api>((ref) => throw UnimplementedError());
final startDateProvider = Provider<DateTime>(
(ref) => throw UnimplementedError(),
);
class DataVersion extends Notifier<int> {
@override
int build() => 0;
void bump() => state++;
}
final dataVersionProvider = NotifierProvider<DataVersion, int>(DataVersion.new);
class SessionNotifier extends Notifier<AppUser?> {
SessionNotifier(this.initial);
final AppUser? initial;
@override
AppUser? build() {
final sub = ref.read(apiProvider).sessionEnded.listen((_) => _end());
ref.onDispose(sub.cancel);
return initial;
}
Future<void> login(String username, String password) async {
final api = ref.read(apiProvider);
final u = await api.login(username, password);
state = u;
try {
ref.read(selectedDateProvider.notifier).set(await api.latestDrawDate());
} on ApiException {// ignore: empty_catches
}
}
Future<void> logout() async {
ref.read(apiProvider).logout();
_end();
}
void _end() {
state = null;
ref.invalidate(selectedDateProvider);
}
}
final sessionProvider = NotifierProvider<SessionNotifier, AppUser?>(
() => SessionNotifier(null),
);
class SelectedDate extends Notifier<DateTime> {
@override
DateTime build() => ref.read(startDateProvider);
void set(DateTime d) => state = dateOnly(d);
}
final selectedDateProvider = NotifierProvider<SelectedDate, DateTime>(
SelectedDate.new,
);
final resultsProvider = FutureProvider.family<DrawResults, DateTime>((ref, d) {
ref.watch(dataVersionProvider);
return ref.read(apiProvider).results(d);
});
final shopsProvider = FutureProvider.family<List<ShopDay>, DateTime>((ref, d) {
ref.watch(dataVersionProvider);
return ref.read(apiProvider).shops(d);
});
final shopDetailProvider = FutureProvider.family<ShopDetail, (int, DateTime)>((
ref,
k,
) {
ref.watch(dataVersionProvider);
return ref.read(apiProvider).shopDetail(k.$1, k.$2);
});
final ordersProvider = FutureProvider.family<List<Order>, DateTime>((ref, d) {
ref.watch(dataVersionProvider);
return ref.read(apiProvider).orders(d);
});
final orderProvider = FutureProvider.family<Order, int>((ref, id) {
ref.watch(dataVersionProvider);
return ref.read(apiProvider).order(id);
});
final monthProvider = FutureProvider.family<List<DrawInfo>, (int, int)>((
ref,
k,
) {
ref.watch(dataVersionProvider);
return ref.read(apiProvider).month(k.$1, k.$2);
});
final activeShopsProvider = FutureProvider<List<Shop>>((ref) {
ref.watch(dataVersionProvider);
return ref.read(apiProvider).activeShops();
});
final usersProvider = FutureProvider<List<AppUser>>((ref) {
ref.watch(dataVersionProvider);
return ref.read(apiProvider).users();
});
final userProvider = FutureProvider.family<AppUser, int>((ref, id) {
ref.watch(dataVersionProvider);
return ref.read(apiProvider).user(id);
});
Future<T> mutate<T>(WidgetRef ref, Future<T> Function() fn) async {
final r = await fn();
ref.read(dataVersionProvider.notifier).bump();
return r;
}

159
lib/core/theme.dart Normal file
View File

@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
class C {
static const bg = Color(0xFFF4F2EC);
static const outer = Color(0xFFE7E4DB);
static const ink = Color(0xFF1F2A2E);
static const muted = Color(0xFF5E6C71); // 4.9:1 on bg, 5.4:1 on white
static const navy = Color(0xFF1B2A49);
static const navyDeep = Color(0xFF101B33);
static const chip = Color(0xFFE1E6F0);
static const green = Color(0xFF2F7656); // 5.5:1 on white, 4.7:1 on greenTint
static const greenTint = Color(0xFFE3F1EA);
static const gold = Color(0xFFD4A64A);
static const goldTint = Color(0xFFF7EBD0);
static const goldText = Color(0xFF735614); // 5.4:1 on goldTint
static const border = Color(0xFFE6E0D4);
static const border2 = Color(0xFFC9CED8);
static const placeholder = Color(0xFF7A8689); // hints and chevrons, 3.8:1
static const softOnNavy = Color(0xFFB8C2DA);
static const red = Color(0xFFB04A37); // 5.4:1 on white
static const redDeep = Color(0xFF953D2C);
static const redTint = Color(0xFFFBE9E5);
static const white = Colors.white;
static const hover = Color(0xFFF8F7F3);
static const focus = Color(0xFF3D5A99);
// Overlays, shadows and disabled fills
static const onNavy5 = Color(0x0DFFFFFF);
static const onNavy8 = Color(0x14FFFFFF);
static const onNavy20 = Color(0x33FFFFFF);
static const goldSoft = Color(0x26D4A64A);
static const goldGlow = Color(0x55D4A64A);
static const shadowNavy = Color(0x331B2A49);
static const shadowNav = Color(0x14101B33);
static const disabled = Color(0xFFF0EEE8);
static const fieldDisabled = Color(0xFFEFEDE6);
}
/// Width breakpoints, measured on the window, not the device.
class Bp {
static const medium = 600.0; // rail navigation, wider gutters
static const expanded = 1024.0; // full sidebar
static const pageMax = 1120.0; // widest content column
static const readMax = 760.0; // detail and form screens
}
/// Horizontal padding that keeps content in a centred column of [max] width.
double pageGutter(double width, [double max = Bp.pageMax]) {
if (width < Bp.medium) return S.s20;
final side = (width - max) / 2;
return side > S.s32 ? side : S.s32;
}
/// Spacing scale (logical pixels).
class S {
static const s2 = 2.0;
static const s4 = 4.0;
static const s6 = 6.0;
static const s8 = 8.0;
static const s10 = 10.0;
static const s12 = 12.0;
static const s14 = 14.0;
static const s16 = 16.0;
static const s18 = 18.0;
static const s20 = 20.0;
static const s22 = 22.0;
static const s24 = 24.0;
static const s28 = 28.0;
static const s32 = 32.0;
static const s36 = 36.0;
}
/// Corner radii.
class R {
static const sm = 8.0; // checkbox, handles
static const md = 12.0; // chips, tiles
static const input = 14.0; // fields, small cards
static const button = 16.0;
static const card = 18.0;
static const dialog = 24.0;
static const hero = 28.0; // hero cards, sheets
static const pill = 99.0;
}
TextStyle ts(
double size, {
FontWeight w = FontWeight.w500,
Color c = C.ink,
double? h,
double? ls,
}) => TextStyle(
fontFamily: 'Inter',
fontSize: size,
fontWeight: w,
color: c,
height: h,
letterSpacing: ls,
// Tabular figures keep ticket counts, endings and rupee amounts aligned.
fontFeatures: const [FontFeature.tabularFigures()],
);
const w4 = FontWeight.w400;
const w5 = FontWeight.w500;
const w6 = FontWeight.w600;
const w7 = FontWeight.w700;
ThemeData buildTheme() {
final scheme =
ColorScheme.fromSeed(
seedColor: C.navy,
brightness: Brightness.light,
).copyWith(
primary: C.navy,
onPrimary: Colors.white,
surface: C.bg,
error: C.red,
);
return ThemeData(
useMaterial3: true,
fontFamily: 'Inter',
colorScheme: scheme,
scaffoldBackgroundColor: C.bg,
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
hoverColor: C.hover,
focusColor: C.chip,
tooltipTheme: TooltipThemeData(
waitDuration: const Duration(milliseconds: 500),
textStyle: ts(12, w: w5, c: Colors.white),
decoration: BoxDecoration(
color: C.navyDeep,
borderRadius: BorderRadius.circular(R.sm),
),
),
textSelectionTheme: const TextSelectionThemeData(cursorColor: C.navy),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: C.navyDeep,
contentTextStyle: ts(13, w: w6, c: Colors.white),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(R.input),
),
),
dialogTheme: DialogThemeData(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(R.dialog),
),
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(R.hero)),
),
),
);
}

View File

@ -0,0 +1,246 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _user = TextEditingController();
final _pass = TextEditingController();
bool _busy = false;
String? _error;
@override
void dispose() {
_user.dispose();
_pass.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_busy) return;
if (_user.text.trim().isEmpty || _pass.text.isEmpty) {
setState(() => _error = 'Enter your username and password');
return;
}
setState(() {
_busy = true;
_error = null;
});
try {
await ref.read(sessionProvider.notifier).login(_user.text, _pass.text);
} on ApiException catch (e) {
if (mounted) setState(() => _error = e.message);
} catch (_) {
if (mounted) {
setState(() => _error = 'Unable to sign in. Please try again.');
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: C.navy,
body: LayoutBuilder(
builder: (context, c) {
final landscape = c.maxWidth > c.maxHeight && c.maxWidth >= 600;
if (c.maxWidth >= 900 || landscape) {
return Row(
children: [
Expanded(child: _BrandPanel(large: c.maxHeight >= 560)),
Container(
width: c.maxWidth >= 1280
? 560
: (c.maxWidth >= 900 ? 480 : 420),
color: C.bg,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(S.s36),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: _form(),
),
),
),
),
],
);
}
final hero = (c.maxHeight * 0.36).clamp(230.0, 340.0);
return Column(
children: [
SizedBox(
height: hero,
width: double.infinity,
child: const _BrandPanel(large: false),
),
Expanded(
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: C.bg,
borderRadius: BorderRadius.vertical(
top: Radius.circular(R.hero),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
S.s24,
S.s28,
S.s24,
S.s24,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: _form(),
),
),
),
),
),
],
);
},
),
);
}
Widget _form() => AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Welcome back', style: ts(24, w: w7, ls: -0.3)),
const SizedBox(height: S.s4),
Text('Sign in to continue', style: ts(14, c: C.muted)),
const SizedBox(height: S.s24),
SrsField(
controller: _user,
label: 'Username',
hint: 'Enter username',
error: _error != null,
enabled: !_busy,
autofocus: true,
autofill: const [AutofillHints.username],
onChanged: (_) => setState(() => _error = null),
),
const SizedBox(height: S.s16),
SrsField(
controller: _pass,
label: 'Password',
hint: 'Enter password',
password: true,
error: _error != null,
enabled: !_busy,
autofill: const [AutofillHints.password],
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _submit(),
),
if (_error != null) ...[
const SizedBox(height: S.s12),
ErrorLine(_error!),
],
const SizedBox(height: S.s22),
BigButton('Sign in', loading: _busy, onPressed: _submit),
const SizedBox(height: S.s18),
Center(
child: Text(
'Forgot your password? Contact your administrator.',
textAlign: TextAlign.center,
style: ts(13, c: C.muted),
),
),
],
),
);
}
class _BrandPanel extends StatelessWidget {
const _BrandPanel({required this.large});
final bool large;
@override
Widget build(BuildContext context) {
final coin = large ? 112.0 : 84.0;
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [C.navy, C.navyDeep],
),
),
child: Stack(
children: [
Positioned(
right: large ? -120 : -50,
top: large ? -80 : -30,
child: _ring(large ? 420 : 190, C.onNavy8),
),
Positioned(
left: large ? -90 : -40,
bottom: large ? -60 : 10,
child: _ring(large ? 300 : 120, C.onNavy5),
),
SafeArea(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: coin,
height: coin,
decoration: const BoxDecoration(
color: C.gold,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: C.goldGlow,
blurRadius: 30,
offset: Offset(0, 10),
),
],
),
child: Icon(
Icons.confirmation_number_rounded,
color: C.navy,
size: coin * 0.48,
),
),
SizedBox(height: large ? S.s24 : S.s18),
Text(
'SRS',
style: ts(large ? 44 : 34, w: w7, c: Colors.white, ls: 2),
),
const SizedBox(height: S.s6),
Text(
'Your sequences, at a glance',
style: ts(large ? 16 : 14, c: C.softOnNavy),
),
],
),
),
),
],
),
);
}
Widget _ring(double size, Color color) => Container(
width: size,
height: size,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}

View File

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
class OrderCard extends StatelessWidget {
const OrderCard({super.key, required this.order, required this.onTap});
final Order order;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final o = order;
return SrsCard(
onTap: onTap,
padding: const EdgeInsets.all(S.s14),
child: Row(
children: [
CodeTile(o.shop.code),
const SizedBox(width: S.s14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
o.shop.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(15, w: w6),
),
),
if (o.edited) ...[
const SizedBox(width: S.s8),
const Pill('Edited', bg: C.goldTint, fg: C.goldText),
],
],
),
const SizedBox(height: S.s4),
Row(
children: [
Icon(
o.isPhoto
? Icons.photo_camera_outlined
: Icons.chat_bubble_outline_rounded,
size: 13,
color: C.muted,
),
const SizedBox(width: S.s6),
Flexible(
child: Text(
'${o.shop.code} · ${time12(o.time)} · ${o.isPhoto ? 'Photo' : 'Text'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(12, c: C.muted),
),
),
],
),
],
),
),
const SizedBox(width: S.s8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(inr(o.tickets), style: ts(18, w: w7)),
Text('tickets', style: ts(11, c: C.muted)),
],
),
const SizedBox(width: S.s4),
const Icon(Icons.chevron_right_rounded, color: C.placeholder),
],
),
);
}
}

View File

@ -0,0 +1,341 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
class OrderDetailScreen extends ConsumerStatefulWidget {
const OrderDetailScreen({super.key, required this.orderId});
final int orderId;
@override
ConsumerState<OrderDetailScreen> createState() => _OrderDetailScreenState();
}
class _OrderDetailScreenState extends ConsumerState<OrderDetailScreen> {
bool _full = false;
bool _busy = false;
Future<void> _run(Future<void> Function() fn, {String? done}) async {
if (_busy) return;
setState(() => _busy = true);
try {
await mutate(ref, fn);
if (mounted && done != null) toast(context, done);
} catch (e) {
if (mounted) toast(context, errText(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _setQty(Order o, EndingQty l, int qty) {
if (qty < 1 || qty > 9999) return Future.value();
return _run(() => ref.read(apiProvider).setLineQty(o.id, l.ending, qty));
}
Future<void> _remove(Order o, EndingQty l) async {
final last = o.lines.length == 1;
final ok = await confirm(
context,
title: last ? 'Remove last ending?' : 'Remove ${l.ending}?',
body: last
? 'This is the only ending in this order. Removing it will delete the order.'
: '${plural(l.qty, 'ticket')} will be removed from this order and the shop totals.',
confirmLabel: 'Remove',
);
if (!ok || !mounted) return;
await _run(
() => ref.read(apiProvider).removeLine(o.id, l.ending),
done: last ? 'Order deleted' : 'Ending removed',
);
if (last && mounted) context.pop();
}
Future<void> _delete(Order o) async {
final ok = await confirm(
context,
title: 'Delete this order?',
body:
'${plural(o.tickets, 'ticket')} will be removed from ${o.shop.name}’s totals and results. This action cannot be undone.',
confirmLabel: 'Delete order',
);
if (!ok || !mounted) return;
await _run(
() => ref.read(apiProvider).cancelOrder(o.id),
done: 'Order deleted',
);
if (mounted) context.pop();
}
@override
Widget build(BuildContext context) {
final order = ref.watch(orderProvider(widget.orderId));
return Scaffold(
body: SafeArea(
child: asyncView(
order,
(o) => Column(
children: [
const ScreenHeader(title: 'Order details', back: true),
Expanded(child: _body(o)),
],
),
retry: () => ref.invalidate(orderProvider(widget.orderId)),
),
),
);
}
Widget _body(Order o) {
final out =
o.lines.any((l) => l.wins) ||
o.drawDate.isBefore(ref.read(apiProvider).now);
final lines = (o.rawText ?? '').split('\n');
final shown = _full ? lines : lines.take(4).toList();
return PaneList(
top: 0,
breakpoint: 820,
startFlex: 1,
endFlex: 1,
gap: S.s24,
start: [
SrsCard(
padding: const EdgeInsets.all(S.s16),
child: Row(
children: [
CodeTile(o.shop.code, size: 52, dark: true),
const SizedBox(width: S.s14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
'${inr(o.tickets)} tickets',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(18, w: w7),
),
),
if (o.edited) ...[
const SizedBox(width: S.s8),
const Pill('Edited', bg: C.goldTint, fg: C.goldText),
],
],
),
const SizedBox(height: S.s4),
Text(
'${o.shop.code} · ${time12(o.time)} · ${o.isPhoto ? 'Photo' : 'Text'}',
style: ts(12, c: C.muted),
),
const SizedBox(height: S.s2),
Text(dateLong(o.drawDate), style: ts(12, c: C.muted)),
],
),
),
if (o.prize > 0)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
rupees(o.prize),
style: ts(16, w: w7, c: C.green),
),
Text('won', style: ts(11, c: C.muted)),
],
),
],
),
),
const SectionTitle('Original message'),
if (o.isPhoto)
Container(
height: 150,
alignment: Alignment.center,
decoration: BoxDecoration(
color: C.chip,
borderRadius: BorderRadius.circular(R.card),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_outlined, size: 36, color: C.navy),
const SizedBox(height: S.s8),
Text(
'Handwritten slip',
style: ts(13, w: w6, c: C.navy),
),
const SizedBox(height: S.s2),
Text('Received via WhatsApp', style: ts(12, c: C.muted)),
],
),
)
else
SrsCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shown.join('\n'),
style: ts(13, w: w5, c: C.ink, h: 1.6),
),
if (lines.length > 4) ...[
const SizedBox(height: S.s10),
Pressable(
onTap: () => setState(() => _full = !_full),
radius: R.sm,
child: Text(
_full ? 'Show less' : 'Show full message',
style: ts(13, w: w6, c: C.navy),
),
),
],
],
),
),
],
end: [
SectionTitle(
'Endings',
trailing:
'${plural(o.lines.length, 'ending')} · ${plural(o.tickets, 'ticket')}',
),
for (final l in o.lines) ...[
_LineRow(
line: l,
busy: _busy,
onMinus: () => _setQty(o, l, l.qty - 1),
onPlus: () => _setQty(o, l, l.qty + 1),
onRemove: () => _remove(o, l),
),
const SizedBox(height: S.s8),
],
const SizedBox(height: S.s6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: S.s4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded, size: 15, color: C.muted),
const SizedBox(width: S.s8),
Expanded(
child: Text(
out
? 'Changes are reflected in shop totals and results.'
: 'Changes are reflected in shop totals and, once published, in results.',
style: ts(12, c: C.muted, h: 1.45),
),
),
],
),
),
const SizedBox(height: S.s22),
BigButton(
'Delete order',
outline: true,
danger: true,
icon: Icons.delete_outline_rounded,
onPressed: _busy ? null : () => _delete(o),
),
],
);
}
}
class _LineRow extends StatelessWidget {
const _LineRow({
required this.line,
required this.busy,
required this.onMinus,
required this.onPlus,
required this.onRemove,
});
final EndingQty line;
final bool busy;
final VoidCallback onMinus, onPlus, onRemove;
@override
Widget build(BuildContext context) {
final win = line.wins;
return Container(
padding: const EdgeInsets.fromLTRB(S.s14, S.s10, S.s8, S.s10),
decoration: BoxDecoration(
color: win ? C.greenTint : Colors.white,
borderRadius: BorderRadius.circular(R.button),
border: Border.all(color: win ? C.green.withAlpha(110) : C.border),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
line.ending,
style: ts(18, w: w7, c: win ? C.green : C.ink, ls: 1),
),
if (win) ...[
const SizedBox(height: S.s2),
Text(
'${line.tierText} · ${rupees(line.prize)}',
style: ts(12, w: w6, c: C.green),
),
],
],
),
),
_step(
Icons.remove_rounded,
'Fewer tickets',
line.qty > 1 && !busy ? onMinus : null,
),
SizedBox(
width: 44,
child: Center(
child: Text('${line.qty}', style: ts(16, w: w7)),
),
),
_step(
Icons.add_rounded,
'More tickets',
line.qty < 9999 && !busy ? onPlus : null,
),
const SizedBox(width: S.s6),
IconButton(
onPressed: busy ? null : onRemove,
icon: const Icon(
Icons.delete_outline_rounded,
size: 21,
color: C.red,
),
tooltip: 'Remove ending',
),
],
),
);
}
Widget _step(IconData icon, String label, VoidCallback? tap) => Pressable(
onTap: tap,
label: label,
radius: 18,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
width: 36,
height: 36,
decoration: BoxDecoration(
color: tap == null ? C.disabled : (hover ? C.border2 : C.chip),
shape: BoxShape.circle,
),
child: Icon(icon, size: 18, color: tap == null ? C.placeholder : C.navy),
),
);
}

View File

@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/format.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
import 'order_card.dart';
class OrdersScreen extends ConsumerStatefulWidget {
const OrdersScreen({super.key});
@override
ConsumerState<OrdersScreen> createState() => _OrdersScreenState();
}
class _OrdersScreenState extends ConsumerState<OrdersScreen> {
String? _code;
@override
Widget build(BuildContext context) {
final date = ref.watch(selectedDateProvider);
final orders = ref.watch(ordersProvider(date));
return Column(
children: [
const ScreenHeader(title: 'Orders'),
const PagePad(
top: S.s4,
child: Align(
alignment: Alignment.centerLeft,
child: SizedBox(width: 420, child: DateStrip()),
),
),
Expanded(
child: asyncView(orders, (all) {
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 tickets = list.fold(0, (a, o) => a + o.tickets);
return PageList(
top: S.s14,
children: [
if (codes.length > 1)
SizedBox(
height: 38,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_chip(
'All',
active == null,
() => setState(() => _code = null),
),
for (final c in codes)
_chip(
c,
active == c,
() => setState(() => _code = c),
),
],
),
),
if (list.isEmpty)
const EmptyState(
icon: Icons.receipt_long_outlined,
title: 'No orders for this date',
body:
'Orders received via WhatsApp will appear here. Select another date to view earlier orders.',
)
else ...[
Padding(
padding: const EdgeInsets.fromLTRB(
S.s2,
S.s18,
S.s2,
S.s10,
),
child: Row(
children: [
Expanded(
child: Text(dateLong(date), style: ts(15, w: w7)),
),
Text(
'${plural(list.length, 'order')} · ${plural(tickets, 'ticket')}',
style: ts(12, c: C.muted),
),
],
),
),
CardGrid(
children: [
for (final o in list)
OrderCard(
order: o,
onTap: () => context.push('/orders/${o.id}'),
),
],
),
],
],
);
}, retry: () => ref.invalidate(ordersProvider(date))),
),
],
);
}
Widget _chip(String t, bool on, VoidCallback tap) => Padding(
padding: const EdgeInsets.only(right: S.s8),
child: Pressable(
onTap: tap,
radius: R.pill,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
padding: const EdgeInsets.symmetric(horizontal: S.s16),
alignment: Alignment.center,
decoration: BoxDecoration(
color: on ? C.navy : (hover ? C.hover : Colors.white),
borderRadius: BorderRadius.circular(R.pill),
border: Border.all(
color: on ? C.navy : (hover ? C.border2 : C.border),
),
),
child: Text(
t,
style: ts(13, w: w6, c: on ? Colors.white : C.ink),
),
),
),
);
}

View File

@ -0,0 +1,394 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
const _panelWidth = 360.0;
Future<void> showCalendarPicker(BuildContext anchor) {
if (!isWide(anchor)) {
return showModalBottomSheet<void>(
context: anchor,
isScrollControlled: true,
useSafeArea: true,
useRootNavigator: true,
builder: (ctx) => const SingleChildScrollView(
padding: EdgeInsets.fromLTRB(S.s16, S.s10, S.s16, S.s20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [_Grabber(), _CalendarPicker()],
),
),
);
}
final box = anchor.findRenderObject() as RenderBox?;
final rect = box != null && box.hasSize
? box.localToGlobal(Offset.zero) & box.size
: null;
return showGeneralDialog<void>(
context: anchor,
barrierDismissible: true,
barrierLabel: 'Close calendar',
barrierColor: const Color(0x1A101B33),
transitionDuration: const Duration(milliseconds: 160),
pageBuilder: (ctx, _, _) => CustomSingleChildLayout(
delegate: _AnchorLayout(rect),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(R.dialog),
boxShadow: const [
BoxShadow(
color: C.shadowNavy,
blurRadius: 32,
offset: Offset(0, 12),
),
],
),
child: Material(
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(R.dialog),
side: const BorderSide(color: C.border),
),
clipBehavior: Clip.antiAlias,
child: const SizedBox(
width: _panelWidth,
child: Padding(
padding: EdgeInsets.all(S.s16),
child: _CalendarPicker(),
),
),
),
),
),
transitionBuilder: (ctx, a, _, child) {
if (MediaQuery.disableAnimationsOf(ctx)) return child;
final t = CurvedAnimation(parent: a, curve: Curves.easeOutCubic);
return FadeTransition(
opacity: t,
child: ScaleTransition(
scale: Tween(begin: 0.97, end: 1.0).animate(t),
alignment: Alignment.topCenter,
child: child,
),
);
},
);
}
class _AnchorLayout extends SingleChildLayoutDelegate {
_AnchorLayout(this.anchor);
final Rect? anchor;
@override
BoxConstraints getConstraintsForChild(BoxConstraints c) =>
BoxConstraints.loose(Size(c.maxWidth - 32, c.maxHeight - 32));
@override
Offset getPositionForChild(Size size, Size child) {
const m = S.s16;
final a = anchor;
if (a == null) {
return Offset(
(size.width - child.width) / 2,
(size.height - child.height) / 2,
);
}
final left = (a.center.dx - child.width / 2).clamp(
m,
size.width - child.width - m,
);
var top = a.bottom + S.s8;
if (top + child.height > size.height - m) {
final above = a.top - S.s8 - child.height;
top = above >= m ? above : (size.height - child.height) / 2;
}
return Offset(left, top);
}
@override
bool shouldRelayout(_AnchorLayout old) => old.anchor != anchor;
}
class _Grabber extends StatelessWidget {
const _Grabber();
@override
Widget build(BuildContext context) => Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: S.s12),
decoration: BoxDecoration(
color: C.border2,
borderRadius: BorderRadius.circular(R.sm),
),
);
}
class _CalendarPicker extends ConsumerStatefulWidget {
const _CalendarPicker();
@override
ConsumerState<_CalendarPicker> createState() => _CalendarPickerState();
}
class _CalendarPickerState extends ConsumerState<_CalendarPicker> {
late DateTime _picked = ref.read(selectedDateProvider);
late DateTime _month = DateTime(_picked.year, _picked.month);
void _shift(int by) =>
setState(() => _month = DateTime(_month.year, _month.month + by));
void _pick(DateTime d) => setState(() {
_picked = d;
_month = DateTime(d.year, d.month);
});
void _confirm() {
ref.read(selectedDateProvider.notifier).set(_picked);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final today = dateOnly(ref.read(apiProvider).now);
final days = ref.watch(monthProvider((_month.year, _month.month)));
final byDay = <int, DrawInfo>{
for (final d in days.asData?.value ?? const <DrawInfo>[]) d.date.day: d,
};
final pickedInfo = ref
.watch(monthProvider((_picked.year, _picked.month)))
.asData
?.value
.where((d) => sameDay(d.date, _picked))
.firstOrNull;
final first = DateTime(_month.year, _month.month);
final lead = first.weekday - 1;
final count = DateTime(_month.year, _month.month + 1, 0).day;
final rows = ((lead + count) / 7).ceil();
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
_pick(addDays(_picked, -1)),
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
_pick(addDays(_picked, 1)),
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
_pick(addDays(_picked, -7)),
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
_pick(addDays(_picked, 7)),
},
child: Focus(
autofocus: true,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const SizedBox(width: S.s4),
Expanded(
child: Text(monthYear(_month), style: ts(18, w: w7)),
),
if (!sameDay(DateTime(today.year, today.month), _month) ||
!sameDay(_picked, today))
Padding(
padding: const EdgeInsets.only(right: S.s4),
child: Pressable(
onTap: () => _pick(today),
radius: R.pill,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 32,
padding: const EdgeInsets.symmetric(horizontal: S.s12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: hover ? C.chip : Colors.transparent,
borderRadius: BorderRadius.circular(R.pill),
),
child: Text(
'Today',
style: ts(13, w: w6, c: C.navy),
),
),
),
),
RoundIcon(
Icons.chevron_left_rounded,
label: 'Previous month',
size: 36,
onTap: () => _shift(-1),
),
const SizedBox(width: S.s6),
RoundIcon(
Icons.chevron_right_rounded,
label: 'Next month',
size: 36,
onTap: () => _shift(1),
),
],
),
const SizedBox(height: S.s14),
Row(
children: [
for (final w in const ['M', 'T', 'W', 'T', 'F', 'S', 'S'])
Expanded(
child: Center(
child: Text(
w,
style: ts(12, w: w6, c: C.muted),
),
),
),
],
),
const SizedBox(height: S.s6),
for (var r = 0; r < rows; r++)
Row(
children: [
for (var c = 0; c < 7; c++)
Expanded(
child: _cell(r * 7 + c - lead + 1, count, byDay, today),
),
],
),
const SizedBox(height: S.s12),
_Summary(date: _picked, info: pickedInfo),
const SizedBox(height: S.s14),
BigButton(
'Show results for ${dateShort(_picked)}',
onPressed: _confirm,
),
],
),
),
);
}
Widget _cell(int day, int count, Map<int, DrawInfo> byDay, DateTime today) {
if (day < 1 || day > count) return const SizedBox(height: 48);
final date = DateTime(_month.year, _month.month, day);
final sel = sameDay(date, _picked);
final isToday = sameDay(date, today);
final info = byDay[day];
final out = info?.published == true;
final win = info?.hasWin == true;
final status = [
if (out) 'results published',
if (win) 'winning tickets',
].join(', ');
return Pressable(
onTap: () => _pick(date),
label: status.isEmpty ? dateLong(date) : '${dateLong(date)}, $status',
radius: R.md,
builder: (_, hover) => Padding(
padding: const EdgeInsets.all(S.s2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
height: 44,
decoration: BoxDecoration(
color: sel ? C.navy : (hover ? C.chip : Colors.transparent),
borderRadius: BorderRadius.circular(R.md),
border: !sel && isToday
? Border.all(color: C.navy, width: 1.4)
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$day',
style: ts(
14,
w: sel || isToday ? w7 : w5,
c: sel ? Colors.white : C.ink,
),
),
const SizedBox(height: S.s4),
SizedBox(
height: 4,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (out)
Container(
width: 12,
height: 4,
decoration: BoxDecoration(
color: sel ? C.softOnNavy : C.green,
borderRadius: BorderRadius.circular(2),
),
),
if (out && win) const SizedBox(width: 3),
if (win)
Container(
width: 4,
height: 4,
decoration: const BoxDecoration(
color: C.gold,
shape: BoxShape.circle,
),
),
],
),
),
],
),
),
),
);
}
}
class _Summary extends StatelessWidget {
const _Summary({required this.date, required this.info});
final DateTime date;
final DrawInfo? info;
@override
Widget build(BuildContext context) {
final out = info?.published == true;
final win = info?.hasWin == true;
final (icon, color, text) = !out
? (Icons.schedule_rounded, C.goldText, 'Results not published yet')
: win
? (Icons.star_rounded, C.goldText, 'Results out, with winning tickets')
: (Icons.check_circle_rounded, C.green, 'Results out');
return Container(
padding: const EdgeInsets.all(S.s12),
decoration: BoxDecoration(
color: C.bg,
borderRadius: BorderRadius.circular(R.input),
),
child: Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: S.s10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
out && info!.code != null
? '${info!.scheme ?? 'Draw'} ${info!.code}'
: dateLong(date),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(14, w: w6),
),
const SizedBox(height: S.s2),
Text(text, style: ts(12, c: C.muted)),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,338 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/account_sheet.dart';
import 'calendar_picker.dart';
import '../../widgets/ui.dart';
class ResultsScreen extends ConsumerWidget {
const ResultsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final date = ref.watch(selectedDateProvider);
final res = ref.watch(resultsProvider(date));
return Column(
children: [
ScreenHeader(
title: 'Results',
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Builder(
builder: (anchor) => RoundIcon(
Icons.calendar_month_rounded,
label: 'Calendar',
onTap: () => showCalendarPicker(anchor),
),
),
// Wider windows show the account in the sidebar.
if (!isWide(context)) ...[
const SizedBox(width: S.s10),
RoundIcon(
Icons.person_outline_rounded,
label: 'Account',
onTap: () => showAccountSheet(context, ref),
),
],
],
),
),
Expanded(
child: asyncView(
res,
(r) => _Body(results: r, date: date),
retry: () => ref.invalidate(resultsProvider(date)),
),
),
],
);
}
}
class _Body extends ConsumerWidget {
const _Body({required this.results, required this.date});
final DrawResults results;
final DateTime date;
@override
Widget build(BuildContext context, WidgetRef ref) {
final r = results;
final d = r.draw;
final n = ref.read(selectedDateProvider.notifier);
return PageList(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(d.label, style: ts(16, w: w7)),
const SizedBox(height: S.s2),
Text(
'${dateLong(d.date)} · 3:00 PM',
style: ts(13, c: C.muted),
),
],
),
),
RoundIcon(
Icons.chevron_left_rounded,
label: 'Previous draw',
size: 40,
onTap: () => n.set(addDays(date, -1)),
),
const SizedBox(width: S.s8),
RoundIcon(
Icons.chevron_right_rounded,
label: 'Next draw',
size: 40,
onTap: () => n.set(addDays(date, 1)),
),
],
),
const SizedBox(height: S.s16),
if (d.published) ..._published(context, r) else ..._notOut(context, r),
],
);
}
List<Widget> _published(BuildContext context, DrawResults r) {
final top = r.topShop;
return [
_HeroRow(
hero: NavyCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'TOTAL PRIZE',
style: ts(11, w: w6, c: C.softOnNavy, ls: 1.2),
),
const SizedBox(height: S.s10),
Text(
rupees(r.totalPrize),
style: ts(
40,
w: w7,
c: r.totalPrize > 0 ? C.gold : C.softOnNavy,
ls: -1,
),
),
const SizedBox(height: S.s10),
Text(
r.totalPrize > 0
? '${plural(r.winningTickets, 'winning ticket')} · ${plural(r.winningShops, 'shop')}'
: 'No winning tickets in this draw',
style: ts(13, w: w5, c: C.softOnNavy),
),
],
),
),
stats: [
StatCard(label: 'TOTAL TICKETS', value: inr(r.totalTickets)),
StatCard(
label: 'TOP SHOP',
value: top?.shop.code ?? '—',
valueColor: top == null ? C.muted : C.ink,
),
],
),
SectionTitle(
'Shops',
trailing: r.shops.isEmpty ? null : plural(r.shops.length, 'shop'),
),
if (r.shops.isEmpty)
const SrsCard(
child: EmptyState(
icon: Icons.receipt_long_outlined,
title: 'No orders for this draw',
body: 'Orders placed for your shops on this date will appear here.',
),
)
else
CardGrid(children: [for (final s in r.shops) _ShopRow(s: s)]),
];
}
List<Widget> _notOut(BuildContext context, DrawResults r) {
return [
_HeroRow(
hero: NavyCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: S.s10,
vertical: S.s6,
),
decoration: BoxDecoration(
color: C.goldSoft,
borderRadius: BorderRadius.circular(R.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.schedule_rounded, size: 14, color: C.gold),
const SizedBox(width: S.s6),
Text(
'AWAITING RESULTS',
style: ts(10, w: w7, c: C.gold, ls: 1),
),
],
),
),
const SizedBox(height: S.s14),
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.',
style: ts(13, c: C.softOnNavy, h: 1.5),
),
],
),
),
stats: [
StatCard(label: 'ORDERS', value: inr(r.orders)),
StatCard(label: 'TICKETS', value: inr(r.totalTickets)),
],
),
SectionTitle(
'Shops',
trailing: r.shops.isEmpty ? null : plural(r.shops.length, 'shop'),
),
if (r.shops.isEmpty)
const SrsCard(
child: EmptyState(
icon: Icons.inbox_outlined,
title: 'No orders',
body: 'No orders have been placed for this draw.',
),
)
else
CardGrid(
children: [for (final s in r.shops) _ShopRow(s: s, pending: true)],
),
];
}
}
/// The navy hero with its two stat cards: stacked under it on phones,
/// beside it on wide windows.
class _HeroRow extends StatelessWidget {
const _HeroRow({required this.hero, required this.stats});
final Widget hero;
final List<Widget> stats;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, c) {
if (c.maxWidth < 760) {
return Column(
children: [
hero,
const SizedBox(height: S.s12),
Row(
children: [
Expanded(child: stats[0]),
const SizedBox(width: S.s12),
Expanded(child: stats[1]),
],
),
],
);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 2, child: hero),
const SizedBox(width: S.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: stats[0]),
const SizedBox(height: S.s12),
Expanded(child: stats[1]),
],
),
),
],
),
);
},
);
}
class _ShopRow extends StatelessWidget {
const _ShopRow({required this.s, this.pending = false});
final ShopResult s;
final bool pending;
@override
Widget build(BuildContext context) {
final win = s.prize > 0;
return SrsCard(
onTap: () => context.push('/shops/${s.shop.id}'),
padding: const EdgeInsets.all(S.s14),
child: Row(
children: [
CodeTile(s.shop.code, dark: win),
const SizedBox(width: S.s14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s.shop.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(15, w: w6),
),
const SizedBox(height: S.s4),
Text(
pending
? plural(s.tickets, 'ticket')
: win
? plural(s.winTickets, 'winning ticket')
: 'No winning tickets',
style: ts(12, c: C.muted),
),
],
),
),
if (!pending)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
rupees(s.prize),
style: ts(16, w: w7, c: win ? C.green : C.muted),
),
if (win) ...[
const SizedBox(height: S.s2),
Text(
plural(s.winTickets, 'ticket'),
style: ts(11, c: C.muted),
),
],
],
)
else
const Icon(Icons.chevron_right_rounded, color: C.placeholder),
],
),
);
}
}

View File

@ -0,0 +1,294 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
import 'shop_sheet.dart';
class ShopDetailScreen extends ConsumerWidget {
const ShopDetailScreen({super.key, required this.shopId});
final int shopId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final date = ref.watch(selectedDateProvider);
final detail = ref.watch(shopDetailProvider((shopId, date)));
final isAdmin = ref.watch(sessionProvider)?.isAdmin ?? false;
return Scaffold(
body: SafeArea(
child: asyncView(
detail,
(d) => Column(
children: [
ScreenHeader(
title: d.shop.name,
back: true,
maxWidth: _maxW,
trailing: isAdmin
? RoundIcon(
Icons.edit_outlined,
label: 'Edit shop',
onTap: () => showShopSheet(
context,
shop: d.shop,
onDeactivated: () {
if (context.mounted) context.go('/shops');
},
),
)
: null,
),
Expanded(
child: _Body(d: d, date: date),
),
],
),
retry: () => ref.invalidate(shopDetailProvider((shopId, date))),
),
),
);
}
}
const _maxW = 960.0;
class _Body extends StatelessWidget {
const _Body({required this.d, required this.date});
final ShopDetail d;
final DateTime date;
@override
Widget build(BuildContext context) {
final out = d.draw.published;
final wins = d.wins;
return PageList(
maxWidth: _maxW,
top: 0,
children: [
Row(
children: [
Pill(d.shop.code, bg: C.chip, fg: C.navy),
const SizedBox(width: S.s10),
Expanded(
child: Text(
'${d.draw.label} · ${dateShort(date)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(13, c: C.muted),
),
),
],
),
const SizedBox(height: S.s16),
if (out)
NavyCard(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WINNING ENDINGS',
style: ts(10, w: w6, c: C.softOnNavy, ls: 1),
),
const SizedBox(height: S.s8),
Text(
'${wins.length}',
style: ts(34, w: w7, c: Colors.white, ls: -0.5),
),
const SizedBox(height: S.s2),
Text(
'of ${inr(d.endings.length)} endings',
style: ts(12, c: C.softOnNavy),
),
],
),
),
Container(width: 1, height: 64, color: C.onNavy20),
const SizedBox(width: S.s22),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'PRIZE AMOUNT',
style: ts(10, w: w6, c: C.softOnNavy, ls: 1),
),
const SizedBox(height: S.s8),
Text(
rupees(d.prize),
style: ts(
30,
w: w7,
c: d.prize > 0 ? C.gold : C.softOnNavy,
ls: -0.5,
),
),
const SizedBox(height: S.s2),
Text(
plural(d.winTickets, 'winning ticket'),
style: ts(12, c: C.softOnNavy),
),
],
),
),
],
),
)
else
NavyCard(
child: Row(
children: [
const Icon(Icons.schedule_rounded, color: C.gold, size: 28),
const SizedBox(width: S.s14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Results not yet published',
style: ts(18, w: w7, c: Colors.white),
),
const SizedBox(height: S.s4),
Text(
'Prize details will appear here once results are published.',
style: ts(12, c: C.softOnNavy),
),
],
),
),
],
),
),
const SizedBox(height: S.s12),
Row(
children: [
Expanded(
child: StatCard(label: 'TICKETS', value: inr(d.tickets)),
),
const SizedBox(width: S.s12),
Expanded(
child: StatCard(
label: 'PRIZE WON',
value: out ? rupees(d.prize) : '—',
valueColor: out && d.prize > 0 ? C.green : C.muted,
),
),
],
),
if (out) ...[
SectionTitle(
'Winning endings',
trailing: wins.isEmpty ? null : '${wins.length}',
),
if (wins.isEmpty)
const SrsCard(
child: EmptyState(
icon: Icons.sentiment_neutral_rounded,
title: 'No winning endings',
body:
'None of this shop’s endings matched a prize in this draw.',
),
)
else
CardGrid(
minItemWidth: 320,
maxColumns: 2,
children: [for (final w in wins) _WinRow(w: w)],
),
],
const SizedBox(height: S.s14),
SrsCard(
onTap: d.tickets == 0
? null
: () => context.push('/shops/${d.shop.id}/tickets'),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: C.chip,
borderRadius: BorderRadius.circular(R.md),
),
child: const Icon(
Icons.confirmation_number_outlined,
color: C.navy,
size: 22,
),
),
const SizedBox(width: S.s14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Tickets · ${inr(d.tickets)}', style: ts(15, w: w6)),
const SizedBox(height: S.s2),
Text(
d.tickets == 0
? 'No orders for this draw'
: '${plural(d.endings.length, 'ending')} · ${plural(d.orders.length, 'order')}',
style: ts(12, c: C.muted),
),
],
),
),
if (d.tickets > 0)
const Icon(Icons.chevron_right_rounded, color: C.placeholder),
],
),
),
],
);
}
}
class _WinRow extends StatelessWidget {
const _WinRow({required this.w});
final EndingQty w;
@override
Widget build(BuildContext context) => SrsCard(
padding: const EdgeInsets.all(S.s14),
child: Row(
children: [
Container(
width: 62,
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: C.greenTint,
borderRadius: BorderRadius.circular(R.input),
),
child: Text(
w.ending,
style: ts(17, w: w7, c: C.green, ls: 0.5),
),
),
const SizedBox(width: S.s14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('× ${plural(w.qty, 'ticket')}', style: ts(14, w: w6)),
const SizedBox(height: S.s4),
Text(
'${w.tierText} · ${rupees(w.perTicket)} each',
style: ts(12, c: C.muted),
),
],
),
),
Text(
rupees(w.prize),
style: ts(16, w: w7, c: C.green),
),
],
),
);
}

View File

@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
Future<void> showShopSheet(
BuildContext context, {
Shop? shop,
VoidCallback? onDeactivated,
}) {
return showSheet<void>(
context,
(ctx) => _ShopForm(shop: shop, onDeactivated: onDeactivated),
);
}
class _ShopForm extends ConsumerStatefulWidget {
const _ShopForm({this.shop, this.onDeactivated});
final Shop? shop;
final VoidCallback? onDeactivated;
@override
ConsumerState<_ShopForm> createState() => _ShopFormState();
}
class _ShopFormState extends ConsumerState<_ShopForm> {
late final _name = TextEditingController(text: widget.shop?.name ?? '');
final _code = TextEditingController();
late bool _active = widget.shop?.active ?? true;
bool _codeTouched = false;
bool _busy = false;
String? _error;
bool get _edit => widget.shop != null;
@override
void dispose() {
_name.dispose();
_code.dispose();
super.dispose();
}
void _nameChanged(String v) {
if (!_edit && !_codeTouched) {
final letters = v.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
_code.text = letters.length > 3 ? letters.substring(0, 3) : letters;
}
setState(() => _error = null);
}
Future<void> _save() async {
setState(() {
_busy = true;
_error = null;
});
final api = ref.read(apiProvider);
try {
await mutate(
ref,
() => _edit
? api.renameShop(widget.shop!.id, _name.text)
: api.createShop(_name.text, _code.text),
);
if (!mounted) return;
final msg = _edit ? 'Shop updated' : 'Shop added';
Navigator.pop(context);
toast(context, msg);
} catch (e) {
if (mounted) setState(() => _error = errText(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _toggle(bool on) async {
final shop = widget.shop!;
final ok = await confirm(
context,
title: on ? 'Activate ${shop.name}?' : 'Deactivate ${shop.name}?',
body: on
? 'This shop will be available again when saving orders.'
: 'This shop will no longer be available when saving orders. Existing orders are kept.',
confirmLabel: on ? 'Activate' : 'Deactivate',
danger: !on,
);
if (!ok || !mounted) return;
setState(() {
_busy = true;
_error = null;
});
try {
await mutate(ref, () => ref.read(apiProvider).setShopActive(shop.id, on));
if (!mounted) return;
setState(() => _active = on);
if (!on) {
final cb = widget.onDeactivated;
Navigator.pop(context);
toast(context, 'Shop deactivated');
cb?.call();
} else {
toast(context, 'Shop activated');
}
} catch (e) {
if (mounted) setState(() => _error = errText(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final code = _edit ? widget.shop!.code : _code.text.trim().toUpperCase();
final name = _name.text.trim();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_edit ? 'Edit shop' : 'Add shop', style: ts(20, w: w7)),
const SizedBox(height: S.s4),
Text(
_edit
? 'You can rename a shop. The short code cannot be changed.'
: 'Create a shop to group and track its orders.',
style: ts(13, c: C.muted),
),
const SizedBox(height: S.s20),
SrsField(
controller: _name,
label: 'Shop name',
hint: 'e.g. Kazhakkoottam',
autofocus: true,
textCapitalization: TextCapitalization.words,
onChanged: _nameChanged,
),
const SizedBox(height: S.s16),
SrsField(
controller: _edit
? TextEditingController(text: widget.shop!.code)
: _code,
label: 'Short code',
hint: 'e.g. KZK',
enabled: !_edit,
textCapitalization: TextCapitalization.characters,
helper: _edit
? 'The short code is permanent.'
: 'Up to 20 characters, no spaces.',
onChanged: (_) => setState(() {
_codeTouched = true;
_error = null;
}),
),
const SizedBox(height: S.s16),
Container(
padding: const EdgeInsets.all(S.s14),
decoration: BoxDecoration(
color: C.bg,
borderRadius: BorderRadius.circular(R.input),
),
child: Row(
children: [
CodeTile(code.isEmpty ? '···' : code, size: 38),
const SizedBox(width: S.s12),
Expanded(
child: Text(
'${code.isEmpty ? 'CODE' : code} · ${name.isEmpty ? 'Shop name' : name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(
14,
w: w6,
c: code.isEmpty && name.isEmpty ? C.placeholder : C.ink,
),
),
),
],
),
),
if (_error != null) ...[
const SizedBox(height: S.s12),
ErrorLine(_error!),
],
const SizedBox(height: S.s20),
BigButton(
_edit ? 'Save changes' : 'Add shop',
loading: _busy,
onPressed: name.isEmpty || (!_edit && code.isEmpty) ? null : _save,
),
if (_edit) ...[
const SizedBox(height: S.s14),
Container(
padding: const EdgeInsets.fromLTRB(S.s16, S.s10, S.s10, S.s10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(R.input),
border: Border.all(color: C.border),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_active ? 'Active' : 'Inactive',
style: ts(14, w: w6),
),
const SizedBox(height: S.s2),
Text(
_active
? 'Available when saving orders.'
: 'Unavailable when saving orders. Existing orders are kept.',
style: ts(12, c: C.muted),
),
],
),
),
Switch(
value: _active,
onChanged: _busy ? null : _toggle,
activeTrackColor: C.green,
activeThumbColor: Colors.white,
),
],
),
),
],
],
);
}
}

View File

@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
import 'shop_sheet.dart';
class ShopsScreen extends ConsumerStatefulWidget {
const ShopsScreen({super.key});
@override
ConsumerState<ShopsScreen> createState() => _ShopsScreenState();
}
class _ShopsScreenState extends ConsumerState<ShopsScreen> {
String _q = '';
@override
Widget build(BuildContext context) {
final date = ref.watch(selectedDateProvider);
final isAdmin = ref.watch(sessionProvider)?.isAdmin ?? false;
final shops = ref.watch(shopsProvider(date));
final count = shops.asData?.value.where((s) => s.shop.active).length;
return Column(
children: [
ScreenHeader(
title: 'Shops',
subtitle: count == null
? null
: '${plural(count, 'shop')} · ${dateShort(date)}',
trailing: isAdmin
? SmallButton('Add shop', onTap: () => showShopSheet(context))
: null,
),
PagePad(
top: S.s4,
bottom: S.s6,
child: _Toolbar(
search: SearchField(
hint: 'Search shops',
onChanged: (v) => setState(() => _q = v.trim().toLowerCase()),
),
),
),
Expanded(
child: asyncView(shops, (list) {
final f = list
.where(
(s) =>
_q.isEmpty ||
s.shop.name.toLowerCase().contains(_q) ||
s.shop.code.toLowerCase().contains(_q),
)
.toList();
if (f.isEmpty) {
return ListView(
children: [
EmptyState(
icon: Icons.storefront_outlined,
title: list.isEmpty
? 'No shops yet'
: 'No results for “$_q”',
body: list.isEmpty
? (isAdmin
? 'Add a shop to get started.'
: 'No shops are assigned to your account. Contact your administrator.')
: null,
),
],
);
}
return PageList(
top: S.s8,
children: [
CardGrid(children: [for (final s in f) _ShopTile(s: s)]),
],
);
}, retry: () => ref.invalidate(shopsProvider(date))),
),
],
);
}
}
class _Toolbar extends StatelessWidget {
const _Toolbar({required this.search});
final Widget search;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, c) {
if (c.maxWidth < 760) {
return Column(
children: [
const DateStrip(),
const SizedBox(height: S.s12),
search,
],
);
}
return Row(
children: [
const SizedBox(width: 380, child: DateStrip()),
const SizedBox(width: S.s16),
Expanded(child: search),
],
);
},
);
}
class _ShopTile extends StatelessWidget {
const _ShopTile({required this.s});
final ShopDay s;
@override
Widget build(BuildContext context) =>
Opacity(opacity: s.shop.active ? 1 : 0.62, child: _card(context));
Widget _card(BuildContext context) => SrsCard(
onTap: () => s.shop.active
? context.push('/shops/${s.shop.id}')
: showShopSheet(context, shop: s.shop),
padding: const EdgeInsets.all(S.s14),
child: Row(
children: [
CodeTile(s.shop.code),
const SizedBox(width: S.s14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s.shop.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(15, w: w6),
),
const SizedBox(height: S.s4),
Text(
!s.shop.active
? 'Inactive'
: s.orders == 0
? 'No orders'
: '${plural(s.tickets, 'ticket')} · ${plural(s.orders, 'order')}',
style: ts(12, c: C.muted),
),
],
),
),
if (s.shop.active)
const Icon(Icons.chevron_right_rounded, color: C.placeholder)
else
const Pill('Inactive', bg: C.redTint, fg: C.red),
],
),
);
}

View File

@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
import '../orders/order_card.dart';
class TicketsScreen extends ConsumerStatefulWidget {
const TicketsScreen({super.key, required this.shopId});
final int shopId;
@override
ConsumerState<TicketsScreen> createState() => _TicketsScreenState();
}
class _TicketsScreenState extends ConsumerState<TicketsScreen> {
bool _byEnding = true;
String _q = '';
@override
Widget build(BuildContext context) {
final date = ref.watch(selectedDateProvider);
final detail = ref.watch(shopDetailProvider((widget.shopId, date)));
return Scaffold(
body: SafeArea(
child: asyncView(
detail,
(d) => Column(
children: [
ScreenHeader(
title: '${d.shop.name} tickets',
subtitle: '${plural(d.tickets, 'ticket')} · ${dateShort(date)}',
back: true,
),
PagePad(
top: S.s4,
bottom: S.s10,
child: Container(
constraints: const BoxConstraints(maxWidth: 420),
padding: const EdgeInsets.all(S.s4),
decoration: BoxDecoration(
color: C.chip,
borderRadius: BorderRadius.circular(R.input),
),
child: Row(
children: [
_seg(
'By ending',
_byEnding,
() => setState(() => _byEnding = true),
),
_seg(
'By order',
!_byEnding,
() => setState(() => _byEnding = false),
),
],
),
),
),
if (_byEnding)
PagePad(
bottom: S.s8,
child: SearchField(
hint: 'Search endings (e.g. 0987)',
onChanged: (v) => setState(() => _q = v.trim()),
),
),
Expanded(child: _byEnding ? _endings(d) : _orders(d)),
],
),
retry: () =>
ref.invalidate(shopDetailProvider((widget.shopId, date))),
),
),
);
}
Widget _seg(String t, bool on, VoidCallback tap) => Expanded(
child: Pressable(
onTap: tap,
radius: R.md,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 160),
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: on ? Colors.white : (hover ? C.hover : Colors.transparent),
borderRadius: BorderRadius.circular(R.md),
),
child: Text(
t,
style: ts(13, w: on ? w7 : w5, c: on ? C.navy : C.muted),
),
),
),
);
Widget _endings(ShopDetail d) {
final list = d.endings
.where((e) => _q.isEmpty || e.ending.contains(_q))
.toList();
if (list.isEmpty) {
return ListView(
children: const [
EmptyState(
icon: Icons.search_off_rounded,
title: 'No matching endings',
),
],
);
}
return PageList(
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [for (final e in list) _chip(e)],
),
],
);
}
Widget _chip(EndingQty e) {
final win = e.wins;
return Container(
padding: const EdgeInsets.symmetric(horizontal: S.s12, vertical: S.s8),
decoration: BoxDecoration(
color: win ? C.greenTint : Colors.white,
borderRadius: BorderRadius.circular(R.md),
border: Border.all(color: win ? C.green : C.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
e.ending,
style: ts(14, w: w7, c: win ? C.green : C.ink, ls: 0.4),
),
const SizedBox(width: S.s6),
Text(
'×${inr(e.qty)}',
style: ts(12, w: w6, c: win ? C.green : C.muted),
),
],
),
);
}
Widget _orders(ShopDetail d) {
if (d.orders.isEmpty) {
return ListView(
children: const [
EmptyState(icon: Icons.receipt_long_outlined, title: 'No orders'),
],
);
}
return PageList(
children: [
CardGrid(
children: [
for (final o in d.orders)
OrderCard(order: o, onTap: () => context.push('/orders/${o.id}')),
],
),
],
);
}
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../../core/models.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
class ShopPicker extends StatelessWidget {
const ShopPicker({
super.key,
required this.shops,
required this.selected,
required this.onToggle,
});
final List<Shop> shops;
final Set<int> selected;
final ValueChanged<int> onToggle;
@override
Widget build(BuildContext context) {
if (shops.isEmpty) {
return Text(
'No active shops available. Add a shop first.',
style: ts(13, c: C.muted),
);
}
return Column(
children: [
for (final s in shops)
Padding(
padding: const EdgeInsets.only(bottom: S.s8),
child: Pressable(
onTap: () => onToggle(s.id),
radius: R.input,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
padding: const EdgeInsets.symmetric(
horizontal: S.s14,
vertical: S.s12,
),
decoration: BoxDecoration(
color: selected.contains(s.id)
? C.chip
: (hover ? C.hover : Colors.white),
borderRadius: BorderRadius.circular(R.input),
border: Border.all(
color: selected.contains(s.id) ? C.navy : C.border,
),
),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: selected.contains(s.id) ? C.navy : Colors.white,
borderRadius: BorderRadius.circular(R.sm),
border: Border.all(
color: selected.contains(s.id) ? C.navy : C.border2,
width: 1.5,
),
),
child: selected.contains(s.id)
? const Icon(
Icons.check_rounded,
size: 16,
color: Colors.white,
)
: null,
),
const SizedBox(width: S.s12),
Text(
s.code,
style: ts(13, w: w7, c: C.navy),
),
const SizedBox(width: S.s10),
Expanded(
child: Text(
s.name,
style: ts(14, w: w5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
],
);
}
}

View File

@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/theme.dart';
class UserAvatar extends StatelessWidget {
const UserAvatar({super.key, required this.user, this.size = 42});
final AppUser user;
final double size;
@override
Widget build(BuildContext context) {
final admin = user.isAdmin;
final dot = (size * 0.3).clamp(10.0, 18.0);
return SizedBox(
width: size + 2,
height: size + 2,
child: Stack(
children: [
Opacity(
opacity: user.active ? 1 : 0.55,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: admin ? C.navy : C.chip,
borderRadius: BorderRadius.circular(size * 0.3),
),
child: Text(
initials(user.username),
style: ts(size * 0.36, w: w7, c: admin ? C.gold : C.navy),
),
),
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: dot,
height: dot,
decoration: BoxDecoration(
color: user.active ? C.green : C.placeholder,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
),
);
}
}
class StatusLabel extends StatelessWidget {
const StatusLabel({super.key, required this.active});
final bool active;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: active ? C.green : C.placeholder,
shape: BoxShape.circle,
),
),
const SizedBox(width: S.s8),
Text(
active ? 'Active' : 'Inactive',
style: ts(13, w: w5, c: active ? C.ink : C.muted),
),
],
);
}
class ShopCodes extends StatelessWidget {
const ShopCodes({super.key, required this.shops});
final List<Shop> shops;
@override
Widget build(BuildContext context) {
if (shops.isEmpty) {
return Text('No shops', style: ts(13, c: C.placeholder));
}
const tile = 40.0, gap = S.s6, more = 36.0;
return LayoutBuilder(
builder: (context, c) {
var fit = ((c.maxWidth + gap) / (tile + gap)).floor();
if (fit < shops.length) {
fit = ((c.maxWidth - more) / (tile + gap)).floor().clamp(
0,
shops.length,
);
}
final shown = shops.take(fit).toList();
final rest = shops.length - shown.length;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final s in shown)
Padding(
padding: const EdgeInsets.only(right: gap),
child: Tooltip(
message: s.name,
child: Container(
width: tile,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: C.chip,
borderRadius: BorderRadius.circular(R.sm),
),
child: Text(
s.code.length > 4 ? s.code.substring(0, 4) : s.code,
style: ts(11, w: w7, c: C.navy, ls: 0.3),
),
),
),
),
if (rest > 0)
Tooltip(
message: shops.skip(shown.length).map((s) => s.name).join(', '),
child: Container(
height: 26,
constraints: const BoxConstraints(minWidth: 32),
padding: const EdgeInsets.symmetric(horizontal: S.s6),
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: C.border2),
borderRadius: BorderRadius.circular(R.sm),
),
child: Text(
'+$rest',
style: ts(12, w: w7, c: C.navy),
),
),
),
],
);
},
);
}
}
class WhatsAppLogo extends StatelessWidget {
const WhatsAppLogo({super.key, this.size = 20, this.color});
final double size;
final Color? color;
static const brand = Color(0xFF25D366);
@override
Widget build(BuildContext context) => ExcludeSemantics(
child: SvgPicture.asset(
'assets/icons/whatsapp.svg',
width: size,
height: size,
colorFilter: ColorFilter.mode(color ?? brand, BlendMode.srcIn),
),
);
}

View File

@ -0,0 +1,715 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
import 'shop_picker.dart';
import 'user_bits.dart';
class UserDetailScreen extends ConsumerWidget {
const UserDetailScreen({super.key, required this.userId});
final int userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider(userId));
final shops =
ref.watch(activeShopsProvider).asData?.value ?? const <Shop>[];
return Scaffold(
body: SafeArea(
child: asyncView(
user,
(u) => Column(
children: [
const ScreenHeader(
title: 'User details',
back: true,
maxWidth: _maxW,
),
Expanded(
child: _Body(u: u, shops: shops),
),
],
),
retry: () => ref.invalidate(userProvider(userId)),
),
),
);
}
}
const _maxW = 1040.0;
class _Body extends ConsumerWidget {
const _Body({required this.u, required this.shops});
final AppUser u;
final List<Shop> shops;
Future<void> _delete(BuildContext context, WidgetRef ref) async {
final ok = await confirm(
context,
title: 'Delete ${u.username}?',
body:
'This permanently removes the account. Saved orders are kept. This action cannot be undone.',
confirmLabel: 'Delete user',
);
if (!ok || !context.mounted) return;
try {
await mutate(ref, () => ref.read(apiProvider).deleteUser(u.id));
if (!context.mounted) return;
context.pop();
toast(context, 'User deleted');
} catch (e) {
if (context.mounted) toast(context, errText(e));
}
}
Future<void> _toggleActive(BuildContext context, WidgetRef ref) async {
final deactivate = u.active;
final ok = await confirm(
context,
title: deactivate
? 'Deactivate ${u.username}?'
: 'Activate ${u.username}?',
body: deactivate
? 'This user will no longer be able to sign in. Saved orders are kept.'
: 'This user will be able to sign in again with their current password.',
confirmLabel: deactivate ? 'Deactivate' : 'Activate',
danger: deactivate,
);
if (!ok || !context.mounted) return;
try {
await mutate(
ref,
() => ref.read(apiProvider).updateUser(u.id, active: !deactivate),
);
if (context.mounted) {
toast(context, deactivate ? 'User deactivated' : 'User activated');
}
} catch (e) {
if (context.mounted) toast(context, errText(e));
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final mine = shops.where((s) => u.shopIds.contains(s.id)).toList();
return PaneList(
maxWidth: _maxW,
breakpoint: 760,
startFlex: 1,
endFlex: 1,
gap: S.s24,
start: [
_Profile(u: u),
_Heading(
'Shops',
action: mine.isEmpty ? 'Assign shops' : 'Edit shops',
onAction: () => _shopsSheet(context, ref),
),
SrsCard(
padding: EdgeInsets.zero,
child: mine.isEmpty
? Padding(
padding: const EdgeInsets.all(S.s16),
child: Text(
'No shops yet. ${u.username} cannot see any orders or results until a shop is assigned.',
style: ts(13, c: C.muted, h: 1.45),
),
)
: Column(
children: [
for (var i = 0; i < mine.length; i++) ...[
if (i > 0) const Divider(height: 1, color: C.border),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: S.s16,
vertical: S.s12,
),
child: Row(
children: [
CodeTile(mine[i].code, size: 40),
const SizedBox(width: S.s12),
Expanded(
child: Text(mine[i].name, style: ts(15, w: w6)),
),
],
),
),
],
],
),
),
],
end: [
const _Heading('Sign-in details'),
SrsCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_SettingRow(
label: 'Username',
value: u.username,
onTap: () => _editSheet(context, ref, _Field.username),
first: true,
),
const Divider(height: 1, color: C.border),
_SettingRow(
label: 'Phone',
value: u.phone == null ? 'Not set' : fmtPhone(u.phone),
muted: u.phone == null,
onTap: () => _editSheet(context, ref, _Field.phone),
),
const Divider(height: 1, color: C.border),
_SettingRow(
label: 'Password',
value: 'Set a new one',
muted: true,
onTap: () => _editSheet(context, ref, _Field.password),
last: true,
),
],
),
),
const _Heading('Access'),
SrsCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_ToggleRow(
leading: Icon(
Icons.key_rounded,
size: 20,
color: u.active ? C.green : C.muted,
),
title: 'Can sign in',
description: 'Off blocks the app. Orders stay.',
value: u.active,
onChanged: (_) => _toggleActive(context, ref),
),
const Divider(height: 1, color: C.border),
_ResultsRow(
u: u,
onAddPhone: () => _editSheet(context, ref, _Field.phone),
),
],
),
),
const SizedBox(height: S.s24),
BigButton(
'Delete user',
outline: true,
danger: true,
icon: Icons.delete_outline_rounded,
onPressed: () => _delete(context, ref),
),
const SizedBox(height: S.s10),
Center(
child: Text(
'Their saved orders are kept.',
style: ts(12, c: C.muted),
),
),
],
);
}
void _editSheet(BuildContext context, WidgetRef ref, _Field f) {
showSheet<void>(context, (_) => _EditField(user: u, field: f));
}
void _shopsSheet(BuildContext context, WidgetRef ref) {
showSheet<void>(context, (_) => _ShopsEditor(user: u, shops: shops));
}
}
class _Profile extends StatelessWidget {
const _Profile({required this.u});
final AppUser u;
@override
Widget build(BuildContext context) => SrsCard(
padding: const EdgeInsets.all(S.s20),
child: Row(
children: [
UserAvatar(user: u, size: 68),
const SizedBox(width: S.s16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
u.username,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(24, w: w7, ls: -0.3),
),
const SizedBox(height: S.s8),
Wrap(
spacing: S.s10,
runSpacing: S.s6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
StatusLabel(active: u.active),
if (u.phone != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.phone_outlined,
size: 14,
color: C.muted,
),
const SizedBox(width: S.s4),
Text(fmtPhone(u.phone), style: ts(13, c: C.muted)),
],
),
],
),
],
),
),
],
),
);
}
class _Heading extends StatelessWidget {
const _Heading(this.text, {this.action, this.onAction});
final String text;
final String? action;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(S.s2, S.s22, 0, S.s10),
child: Row(
children: [
Expanded(
child: Text(text, style: ts(17, w: w7)),
),
if (action != null) _LinkButton(action!, onTap: onAction),
],
),
);
}
/// A quiet navy text action with a hover pill, for actions inside a section.
class _LinkButton extends StatelessWidget {
const _LinkButton(this.label, {required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => Pressable(
onTap: onTap,
radius: R.pill,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 32,
padding: const EdgeInsets.symmetric(horizontal: S.s12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: hover ? C.chip : Colors.transparent,
borderRadius: BorderRadius.circular(R.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.edit_outlined, size: 15, color: C.navy),
const SizedBox(width: S.s6),
Text(
label,
style: ts(13, w: w6, c: C.navy),
),
],
),
),
);
}
/// A setting with an on/off switch: icon, title, an optional short status
/// line, and the switch.
class _ToggleRow extends StatelessWidget {
const _ToggleRow({
required this.leading,
required this.title,
required this.description,
required this.value,
required this.onChanged,
this.status,
});
final Widget leading;
/// The title, then one short line saying what the setting does.
final String title, description;
final Widget? status;
final bool value;
/// Null disables the switch.
final ValueChanged<bool>? onChanged;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(S.s16, S.s12, S.s10, S.s12),
child: Row(
children: [
SizedBox(width: 24, child: Center(child: leading)),
const SizedBox(width: S.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The switch carries the name for screen readers.
ExcludeSemantics(child: Text(title, style: ts(15, w: w6))),
const SizedBox(height: S.s2),
Text(description, style: ts(12, c: C.muted, h: 1.35)),
if (status != null) ...[const SizedBox(height: S.s4), status!],
],
),
),
const SizedBox(width: S.s8),
Semantics(
label: title,
child: Switch(
value: value,
onChanged: onChanged,
activeTrackColor: C.green,
activeThumbColor: Colors.white,
),
),
],
),
);
}
/// "WhatsApp results". srs-api sends a summary for the user's shops after
/// each draw, only to active users with a phone number; the status line says
/// where it goes, or why it won't.
class _ResultsRow extends ConsumerStatefulWidget {
const _ResultsRow({required this.u, required this.onAddPhone});
final AppUser u;
final VoidCallback onAddPhone;
@override
ConsumerState<_ResultsRow> createState() => _ResultsRowState();
}
class _ResultsRowState extends ConsumerState<_ResultsRow> {
/// The value being saved, shown until the reloaded user agrees.
bool? _pending;
@override
void didUpdateWidget(_ResultsRow old) {
super.didUpdateWidget(old);
if (_pending == widget.u.sendResults) _pending = null;
}
Future<void> _set(bool on) async {
if (_pending != null) return;
setState(() => _pending = on);
try {
await mutate(
ref,
() => ref.read(apiProvider).updateUser(widget.u.id, sendResults: on),
);
if (mounted) {
toast(context, on ? 'WhatsApp results on' : 'WhatsApp results off');
}
} catch (e) {
if (!mounted) return;
setState(() => _pending = null);
toast(context, errText(e));
}
}
@override
Widget build(BuildContext context) {
final u = widget.u;
final on = _pending ?? u.sendResults;
final noPhone = u.phone == null;
final note = ts(12, w: w6, c: C.ink);
final Widget? status = noPhone
? Pressable(
onTap: widget.onAddPhone,
radius: R.sm,
builder: (_, hover) => Text(
'Add a phone number first',
style: ts(
12,
w: w6,
c: C.navy,
).copyWith(decoration: hover ? TextDecoration.underline : null),
),
)
: !on
? null
: !u.active
? Text('Paused while inactive', style: note)
: null;
return _ToggleRow(
leading: WhatsAppLogo(
size: 20,
color: on && !noPhone && u.active ? null : C.muted,
),
title: 'WhatsApp results',
description: 'Sends results after each draw.',
status: status,
value: on,
// Turning it on needs a phone number; turning it off never does.
onChanged: _pending != null || (!on && noPhone) ? null : _set,
);
}
}
/// A whole-row control: label on top, value below, chevron at the end.
class _SettingRow extends StatelessWidget {
const _SettingRow({
required this.label,
required this.value,
required this.onTap,
this.muted = false,
this.first = false,
this.last = false,
});
final String label, value;
final VoidCallback onTap;
final bool muted, first, last;
@override
Widget build(BuildContext context) => Pressable(
onTap: onTap,
label: 'Change ${label.toLowerCase()}',
radius: 0,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.fromLTRB(S.s16, S.s12, S.s12, S.s12),
decoration: BoxDecoration(
color: hover ? C.hover : Colors.transparent,
borderRadius: BorderRadius.vertical(
top: Radius.circular(first ? R.card : 0),
bottom: Radius.circular(last ? R.card : 0),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: ts(12, w: w6, c: C.muted),
),
const SizedBox(height: S.s4),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(15, w: w6, c: muted ? C.muted : C.ink),
),
],
),
),
Text(
'Change',
style: ts(13, w: w6, c: C.navy),
),
const SizedBox(width: S.s2),
const Icon(Icons.chevron_right_rounded, color: C.placeholder),
],
),
),
);
}
enum _Field { username, phone, password }
class _EditField extends ConsumerStatefulWidget {
const _EditField({required this.user, required this.field});
final AppUser user;
final _Field field;
@override
ConsumerState<_EditField> createState() => _EditFieldState();
}
class _EditFieldState extends ConsumerState<_EditField> {
late final _c = TextEditingController(
text: switch (widget.field) {
_Field.username => widget.user.username,
_Field.phone => (widget.user.phone ?? '').replaceFirst(
RegExp(r'^91'),
'',
),
_Field.password => '',
},
);
bool _busy = false;
String? _error;
@override
void dispose() {
_c.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() {
_busy = true;
_error = null;
});
final api = ref.read(apiProvider);
final id = widget.user.id;
try {
await mutate(ref, () {
switch (widget.field) {
case _Field.username:
return api.updateUser(id, username: _c.text);
case _Field.phone:
return _c.text.trim().isEmpty
? api.updateUser(id, clearPhone: true)
: api.updateUser(id, phone: _c.text);
case _Field.password:
return api.updateUser(id, password: _c.text);
}
});
if (!mounted) return;
Navigator.pop(context);
toast(context, switch (widget.field) {
_Field.username => 'Username changed',
_Field.phone => 'Phone updated',
_Field.password => 'Password changed',
});
} catch (e) {
if (mounted) setState(() => _error = errText(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final f = widget.field;
final title = switch (f) {
_Field.username => 'Change username',
_Field.phone => 'Change phone',
_Field.password => 'Set new password',
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: ts(20, w: w7)),
const SizedBox(height: S.s4),
Text(
f == _Field.password
? 'Users cannot change their own password. Share the new password with ${widget.user.username}.'
: 'For ${widget.user.username}',
style: ts(13, c: C.muted),
),
const SizedBox(height: S.s20),
SrsField(
controller: _c,
autofocus: true,
label: switch (f) {
_Field.username => 'Username',
_Field.phone => 'Phone',
_Field.password => 'New password',
},
hint: f == _Field.phone ? '90000 12345' : null,
prefix: f == _Field.phone ? '+91 ' : null,
password: f == _Field.password,
keyboard: f == _Field.phone ? TextInputType.phone : null,
formatters: f == _Field.phone
? [
FilteringTextInputFormatter.allow(RegExp(r'[0-9 ]')),
LengthLimitingTextInputFormatter(12),
]
: null,
helper: f == _Field.password
? 'At least 8 characters.'
: f == _Field.phone
? 'Leave blank to remove the phone number.'
: null,
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _save(),
),
if (_error != null) ...[
const SizedBox(height: S.s12),
ErrorLine(_error!),
],
const SizedBox(height: S.s20),
BigButton('Save', loading: _busy, onPressed: _save),
],
);
}
}
class _ShopsEditor extends ConsumerStatefulWidget {
const _ShopsEditor({required this.user, required this.shops});
final AppUser user;
final List<Shop> shops;
@override
ConsumerState<_ShopsEditor> createState() => _ShopsEditorState();
}
class _ShopsEditorState extends ConsumerState<_ShopsEditor> {
late final Set<int> _sel = widget.user.shopIds.toSet();
bool _busy = false;
String? _error;
Future<void> _save() async {
setState(() {
_busy = true;
_error = null;
});
try {
await mutate(
ref,
() => ref.read(apiProvider).setUserShops(widget.user.id, _sel.toList()),
);
if (!mounted) return;
Navigator.pop(context);
toast(context, 'Shops updated');
} catch (e) {
if (mounted) setState(() => _error = errText(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Assigned shops', style: ts(20, w: w7)),
const SizedBox(height: S.s4),
Text(
'This user can only view data for the selected shops.',
style: ts(13, c: C.muted),
),
const SizedBox(height: S.s18),
ShopPicker(
shops: widget.shops,
selected: _sel,
onToggle: (id) => setState(
() => _sel.contains(id) ? _sel.remove(id) : _sel.add(id),
),
),
if (_error != null) ...[
const SizedBox(height: S.s8),
ErrorLine(_error!),
],
const SizedBox(height: S.s14),
BigButton('Save', loading: _busy, onPressed: _save),
],
);
}
}

View File

@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/providers.dart';
import '../../widgets/ui.dart';
import 'shop_picker.dart';
import '../../core/theme.dart';
const _maxW = 640.0;
class UserFormScreen extends ConsumerStatefulWidget {
const UserFormScreen({super.key});
@override
ConsumerState<UserFormScreen> createState() => _UserFormScreenState();
}
class _UserFormScreenState extends ConsumerState<UserFormScreen> {
final _name = TextEditingController();
final _phone = TextEditingController();
final _pass = TextEditingController();
final _shops = <int>{};
bool _busy = false;
String? _error;
@override
void dispose() {
_name.dispose();
_phone.dispose();
_pass.dispose();
super.dispose();
}
bool get _ready => _name.text.trim().length >= 3 && _pass.text.length >= 8;
Future<void> _create() async {
setState(() {
_busy = true;
_error = null;
});
try {
await mutate(
ref,
() => ref
.read(apiProvider)
.createUser(
username: _name.text,
phone: _phone.text.trim().isEmpty ? null : _phone.text,
password: _pass.text,
shopIds: _shops.toList(),
),
);
if (!mounted) return;
context.pop();
toast(context, 'User created');
} catch (e) {
if (mounted) setState(() => _error = errText(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final shops = ref.watch(activeShopsProvider);
return Scaffold(
body: SafeArea(
child: Column(
children: [
const ScreenHeader(title: 'Add user', back: true, maxWidth: _maxW),
Expanded(
child: PageList(
maxWidth: _maxW,
top: S.s8,
children: [
SrsField(
controller: _name,
label: 'Username',
hint: 'e.g. kzk.staff',
helper:
'Letters, numbers, dots, dashes and underscores. Used to sign in.',
onChanged: (_) => setState(() => _error = null),
),
const SizedBox(height: S.s18),
SrsField(
controller: _phone,
label: 'Phone (optional)',
hint: '90000 12345',
prefix: '+91 ',
keyboard: TextInputType.phone,
formatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9 ]')),
LengthLimitingTextInputFormatter(12),
],
onChanged: (_) => setState(() => _error = null),
),
const SizedBox(height: S.s18),
SrsField(
controller: _pass,
label: 'Password',
hint: 'Enter password',
password: true,
helper: 'At least 8 characters.',
onChanged: (_) => setState(() => _error = null),
),
const SectionTitle('Shop access'),
asyncView(
shops,
(list) => ShopPicker(
shops: list,
selected: _shops,
onToggle: (id) => setState(
() => _shops.contains(id)
? _shops.remove(id)
: _shops.add(id),
),
),
),
if (_error != null) ...[
const SizedBox(height: S.s8),
ErrorLine(_error!),
],
const SizedBox(height: S.s22),
BigButton(
'Create user',
loading: _busy,
onPressed: _ready ? _create : null,
),
],
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/format.dart';
import '../../core/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
import 'user_bits.dart';
enum _Filter { all, active, off }
class UsersScreen extends ConsumerStatefulWidget {
const UsersScreen({super.key});
@override
ConsumerState<UsersScreen> createState() => _UsersScreenState();
}
class _UsersScreenState extends ConsumerState<UsersScreen> {
_Filter _filter = _Filter.all;
String _q = '';
bool _keep(AppUser u) => switch (_filter) {
_Filter.all => true,
_Filter.active => u.active,
_Filter.off => !u.active,
};
@override
Widget build(BuildContext context) {
final users = ref.watch(usersProvider);
final shops =
ref.watch(activeShopsProvider).asData?.value ?? const <Shop>[];
final byId = {for (final s in shops) s.id: s};
final list = users.asData?.value;
final off = list?.where((u) => !u.active).length ?? 0;
return Column(
children: [
ScreenHeader(
title: 'Users',
subtitle: list == null
? null
: off == 0
? plural(list.length, 'user')
: '${plural(list.length, 'user')}, $off inactive',
trailing: SmallButton(
'Add user',
onTap: () => context.push('/users/new'),
),
),
Expanded(
child: asyncView(users, (all) {
final counts = {
_Filter.all: all.length,
_Filter.active: all.where((u) => u.active).length,
_Filter.off: all.where((u) => !u.active).length,
};
final shown =
all
.where(_keep)
.where(
(u) =>
_q.isEmpty ||
u.username.toLowerCase().contains(_q) ||
(u.phone ?? '').contains(_q),
)
.toList()
// Inactive accounts last.
..sort((a, b) {
final r = (a.active ? 0 : 1).compareTo(b.active ? 0 : 1);
return r != 0 ? r : a.username.compareTo(b.username);
});
return PageList(
top: S.s4,
children: [
_Toolbar(
filter: _filter,
counts: counts,
onFilter: (f) => setState(() => _filter = f),
onSearch: (v) => setState(() => _q = v.trim().toLowerCase()),
),
const SizedBox(height: S.s14),
if (shown.isEmpty)
SrsCard(
child: EmptyState(
icon: Icons.person_search_rounded,
title: _q.isNotEmpty
? 'No one matches “$_q”'
: 'No one here',
body: _filter == _Filter.off
? 'Every user can sign in.'
: all.isEmpty
? 'Add a user to let them sign in and see their shops.'
: 'Try another filter, or add a user.',
),
)
else
_Roster(users: shown, shops: byId),
],
);
}, retry: () => ref.invalidate(usersProvider)),
),
],
);
}
}
class _Toolbar extends StatelessWidget {
const _Toolbar({
required this.filter,
required this.counts,
required this.onFilter,
required this.onSearch,
});
final _Filter filter;
final Map<_Filter, int> counts;
final ValueChanged<_Filter> onFilter;
final ValueChanged<String> onSearch;
static const _labels = {
_Filter.all: 'All',
_Filter.active: 'Active',
_Filter.off: 'Inactive',
};
@override
Widget build(BuildContext context) {
final chips = SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final f in _Filter.values)
if (f == _Filter.all || counts[f]! > 0)
Padding(
padding: const EdgeInsets.only(right: S.s8),
child: _FilterChip(
label: _labels[f]!,
count: counts[f]!,
on: f == filter,
onTap: () => onFilter(f),
),
),
],
),
);
final search = SearchField(
hint: 'Search by name or phone',
onChanged: onSearch,
);
return LayoutBuilder(
builder: (context, c) => c.maxWidth < 760
? Column(
children: [
search,
const SizedBox(height: S.s12),
chips,
],
)
: Row(
children: [
Expanded(child: chips),
const SizedBox(width: S.s16),
SizedBox(width: 320, child: search),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
const _FilterChip({
required this.label,
required this.count,
required this.on,
required this.onTap,
});
final String label;
final int count;
final bool on;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Semantics(
selected: on,
child: Pressable(
onTap: onTap,
radius: R.pill,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 38,
padding: const EdgeInsets.fromLTRB(S.s14, 0, S.s8, 0),
decoration: BoxDecoration(
color: on ? C.navy : (hover ? C.hover : Colors.white),
borderRadius: BorderRadius.circular(R.pill),
border: Border.all(
color: on ? C.navy : (hover ? C.border2 : C.border),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: ts(13, w: w6, c: on ? Colors.white : C.ink),
),
const SizedBox(width: S.s6),
Container(
constraints: const BoxConstraints(minWidth: 22),
height: 22,
padding: const EdgeInsets.symmetric(horizontal: S.s6),
alignment: Alignment.center,
decoration: BoxDecoration(
color: on ? C.onNavy20 : C.chip,
borderRadius: BorderRadius.circular(R.pill),
),
child: Text(
'$count',
style: ts(12, w: w7, c: on ? Colors.white : C.navy),
),
),
],
),
),
),
);
}
/// One card holding every row: a table with column headings on wide windows,
/// a compact list on phones.
class _Roster extends StatelessWidget {
const _Roster({required this.users, required this.shops});
final List<AppUser> users;
final Map<int, Shop> shops;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, c) {
final wide = c.maxWidth >= 720;
return SrsCard(
padding: EdgeInsets.zero,
child: Column(
children: [
if (wide) const _HeadingRow(),
for (var i = 0; i < users.length; i++) ...[
if (i > 0 || wide) const Divider(height: 1, color: C.border),
_UserRow(
u: users[i],
shops: shops,
wide: wide,
first: i == 0 && !wide,
last: i == users.length - 1,
),
],
],
),
);
},
);
}
const _phoneCol = 150.0;
const _statusCol = 120.0;
class _HeadingRow extends StatelessWidget {
const _HeadingRow();
@override
Widget build(BuildContext context) {
final h = ts(12, w: w6, c: C.muted);
return Padding(
padding: const EdgeInsets.fromLTRB(S.s20, S.s14, S.s20, S.s12),
child: Row(
children: [
Expanded(flex: 3, child: Text('Name', style: h)),
SizedBox(
width: _phoneCol,
child: Text('Phone', style: h),
),
Expanded(flex: 3, child: Text('Shops', style: h)),
SizedBox(
width: _statusCol,
child: Text('Status', style: h),
),
const SizedBox(width: 28),
],
),
);
}
}
class _UserRow extends StatelessWidget {
const _UserRow({
required this.u,
required this.shops,
required this.wide,
required this.first,
required this.last,
});
final AppUser u;
final Map<int, Shop> shops;
final bool wide, first, last;
@override
Widget build(BuildContext context) {
final mine = [
for (final id in u.shopIds)
if (shops[id] != null) shops[id]!,
];
final shopsCell = ShopCodes(shops: mine);
final name = Row(
children: [
UserAvatar(user: u, size: 42),
const SizedBox(width: S.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
u.username,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(15, w: w6, c: u.active ? C.ink : C.muted),
),
),
if (u.getsResults) ...[
const SizedBox(width: S.s6),
const Tooltip(
message: 'Gets results on WhatsApp',
child: WhatsAppLogo(size: 14),
),
],
],
),
if (!wide) ...[
const SizedBox(height: S.s4),
Text(
u.phone == null ? 'No phone' : fmtPhone(u.phone),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(12, c: C.muted),
),
const SizedBox(height: S.s8),
shopsCell,
],
],
),
),
],
);
const trailing = Icon(Icons.chevron_right_rounded, color: C.placeholder);
final radius = BorderRadius.vertical(
top: Radius.circular(first ? R.card : 0),
bottom: Radius.circular(last ? R.card : 0),
);
return Pressable(
onTap: () => context.push('/users/${u.id}'),
radius: 0,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: BoxDecoration(
color: hover ? C.hover : Colors.transparent,
borderRadius: radius,
),
padding: EdgeInsets.symmetric(
horizontal: wide ? S.s20 : S.s14,
vertical: S.s12,
),
child: wide
? Row(
children: [
Expanded(flex: 3, child: name),
SizedBox(
width: _phoneCol,
child: Text(
u.phone == null ? '—' : fmtPhone(u.phone),
style: ts(13, c: u.phone == null ? C.placeholder : C.ink),
),
),
Expanded(flex: 3, child: shopsCell),
SizedBox(
width: _statusCol,
child: StatusLabel(active: u.active),
),
SizedBox(width: 28, child: trailing),
],
)
: Row(
children: [
Expanded(child: name),
const SizedBox(width: S.s8),
trailing,
],
),
),
);
}
}

41
lib/main.dart Normal file
View File

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'core/api.dart';
import 'core/config.dart';
import 'core/format.dart';
import 'core/http_api.dart';
import 'core/providers.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
if (AppConfig.unconfigured) debugPrint('SRS: ${AppConfig.setupHint}');
final Api api = HttpApi(
baseUrl: AppConfig.apiBaseUrl,
prefs: await SharedPreferences.getInstance(),
);
final user = await api.restore();
final start = user == null ? dateOnly(api.now) : await _latestOrToday(api);
runApp(
ProviderScope(
overrides: [
apiProvider.overrideWithValue(api),
startDateProvider.overrideWithValue(start),
sessionProvider.overrideWith(() => SessionNotifier(user)),
],
child: const SrsApp(),
),
);
}
Future<DateTime> _latestOrToday(Api api) async {
try {
return await api.latestDrawDate();
} on ApiException {
return dateOnly(api.now);
}
}

View File

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/format.dart';
import '../core/providers.dart';
import '../core/theme.dart';
import 'ui.dart';
/// Signed-in user and Sign out. A sheet on phones, a dialog on wider windows.
void showAccountSheet(BuildContext context, WidgetRef ref) {
final u = ref.read(sessionProvider);
if (u == null) return;
showSheet<void>(context, (ctx) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Avatar(name: u.username, size: 52),
const SizedBox(width: S.s14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(u.username, style: ts(17, w: w7)),
const SizedBox(height: S.s4),
Row(
children: [
Pill(u.isAdmin ? 'Admin' : 'User'),
const SizedBox(width: S.s8),
Text(
u.isAdmin
? 'All shops'
: plural(u.shopIds.length, 'shop'),
style: ts(12, c: C.muted),
),
],
),
],
),
),
],
),
const SizedBox(height: S.s22),
BigButton(
'Sign out',
outline: true,
icon: Icons.logout_rounded,
onPressed: () {
Navigator.pop(ctx);
ref.read(sessionProvider.notifier).logout();
},
),
],
);
});
}
/// Initials in a rounded square.
class Avatar extends StatelessWidget {
const Avatar({
super.key,
required this.name,
this.size = 46,
this.dark = false,
});
final String name;
final double size;
final bool dark;
@override
Widget build(BuildContext context) => Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: dark ? C.navy : C.chip,
borderRadius: BorderRadius.circular(size * 0.3),
),
child: Text(
initials(name),
style: ts(size * 0.33, w: w7, c: dark ? Colors.white : C.navy),
),
);
}

393
lib/widgets/main_shell.dart Normal file
View File

@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/providers.dart';
import '../core/theme.dart';
import 'account_sheet.dart';
import 'ui.dart';
class _Tab {
const _Tab(this.path, this.label, this.icon, this.iconOn);
final String path, label;
final IconData icon, iconOn;
}
const _tabs = [
_Tab(
'/results',
'Results',
Icons.emoji_events_outlined,
Icons.emoji_events_rounded,
),
_Tab('/shops', 'Shops', Icons.storefront_outlined, Icons.storefront_rounded),
_Tab(
'/orders',
'Orders',
Icons.receipt_long_outlined,
Icons.receipt_long_rounded,
),
_Tab('/users', 'Users', Icons.people_outline_rounded, Icons.people_rounded),
];
/// App frame. The same sections at every width; only the navigation changes:
/// a tab bar on phones, a rail on tablets, a sidebar on desktop.
class MainShell extends ConsumerWidget {
const MainShell({super.key, required this.location, required this.child});
final String location;
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(sessionProvider);
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;
if (width >= Bp.medium) {
return Scaffold(
body: Row(
children: [
_Sidebar(
tabs: tabs,
current: current,
extended: width >= Bp.expanded,
),
Expanded(child: SafeArea(child: child)),
],
),
);
}
// Phones: the tab bar stays visible on detail screens too, so people
// always know which section they are in (HIG, Tab bars).
return Scaffold(
body: SafeArea(bottom: false, child: child),
bottomNavigationBar: _BottomBar(tabs: tabs, current: current),
);
}
}
// ------------------------------------------------------------ phone tab bar
class _BottomBar extends StatelessWidget {
const _BottomBar({required this.tabs, required this.current});
final List<_Tab> tabs;
final _Tab? current;
@override
Widget build(BuildContext context) => Container(
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(color: C.shadowNav, blurRadius: 20, offset: Offset(0, -4)),
],
),
child: SafeArea(
top: false,
child: SizedBox(
height: 72,
child: Row(
children: [
for (final t in tabs)
Expanded(
child: _TabItem(
tab: t,
active: t == current,
onTap: () => context.go(t.path),
),
),
],
),
),
),
);
}
class _TabItem extends StatelessWidget {
const _TabItem({
required this.tab,
required this.active,
required this.onTap,
});
final _Tab tab;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Pressable(
onTap: onTap,
radius: R.md,
builder: (_, hover) => Semantics(
selected: active,
// Scales down rather than overflowing while the window has no real
// size yet (Chrome reports a near-zero window for a frame or two).
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 56,
height: 32,
decoration: BoxDecoration(
color: active ? C.chip : (hover ? C.hover : Colors.transparent),
borderRadius: BorderRadius.circular(R.pill),
),
child: Icon(
active ? tab.iconOn : tab.icon,
size: 22,
color: active ? C.navy : C.muted,
),
),
const SizedBox(height: S.s4),
Text(
tab.label,
style: ts(11, w: active ? w7 : w5, c: active ? C.navy : C.muted),
),
],
),
),
),
);
}
// --------------------------------------------------------- rail and sidebar
class _Sidebar extends ConsumerWidget {
const _Sidebar({
required this.tabs,
required this.current,
required this.extended,
});
final List<_Tab> tabs;
final _Tab? current;
final bool extended;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(sessionProvider);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
width: extended ? 248 : 88,
decoration: const BoxDecoration(
color: Colors.white,
border: Border(right: BorderSide(color: C.border)),
),
child: SafeArea(
right: false,
// Scrolls on short windows (a phone in landscape); otherwise the
// account button sits at the bottom.
child: LayoutBuilder(
builder: (context, c) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: c.maxHeight),
child: IntrinsicHeight(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: extended ? S.s16 : S.s12,
vertical: S.s20,
),
child: Column(
crossAxisAlignment: extended
? CrossAxisAlignment.stretch
: CrossAxisAlignment.center,
children: [
_Brand(extended: extended),
const SizedBox(height: S.s28),
for (final t in tabs) ...[
_SideItem(
tab: t,
active: t == current,
extended: extended,
onTap: () => context.go(t.path),
),
const SizedBox(height: S.s4),
],
const Spacer(),
if (user != null)
_AccountButton(
name: user.username,
role: user.isAdmin ? 'Admin' : 'User',
extended: extended,
onTap: () => showAccountSheet(context, ref),
),
],
),
),
),
),
),
),
),
);
}
}
/// The gold ticket coin from the sign-in screen, carried into the frame.
class _Brand extends StatelessWidget {
const _Brand({required this.extended});
final bool extended;
@override
Widget build(BuildContext context) {
final coin = Container(
width: 40,
height: 40,
decoration: const BoxDecoration(color: C.gold, shape: BoxShape.circle),
child: const Icon(
Icons.confirmation_number_rounded,
color: C.navy,
size: 20,
),
);
if (!extended) return coin;
return Row(
children: [
coin,
const SizedBox(width: S.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SRS',
style: ts(18, w: w7, c: C.navy, ls: 1.5),
),
Text(
'Lottery agency',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(12, c: C.muted),
),
],
),
),
],
);
}
}
class _SideItem extends StatelessWidget {
const _SideItem({
required this.tab,
required this.active,
required this.extended,
required this.onTap,
});
final _Tab tab;
final bool active, extended;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final icon = Icon(
active ? tab.iconOn : tab.icon,
size: 22,
color: active ? C.navy : C.muted,
);
final label = Text(
tab.label,
maxLines: 1,
style: ts(
extended ? 14 : 11,
w: active ? w7 : w5,
c: active ? C.navy : C.ink,
),
);
return Semantics(
selected: active,
child: Pressable(
onTap: onTap,
radius: R.md,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: extended ? 44 : 60,
width: extended ? null : 64,
padding: EdgeInsets.symmetric(horizontal: extended ? S.s12 : 0),
decoration: BoxDecoration(
color: active ? C.chip : (hover ? C.hover : Colors.transparent),
borderRadius: BorderRadius.circular(R.md),
),
child: extended
? Row(
children: [
icon,
const SizedBox(width: S.s12),
Expanded(child: label),
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
const SizedBox(height: S.s4),
label,
],
),
),
),
);
}
}
class _AccountButton extends StatelessWidget {
const _AccountButton({
required this.name,
required this.role,
required this.extended,
required this.onTap,
});
final String name, role;
final bool extended;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Tooltip(
message: 'Account',
excludeFromSemantics: true,
child: Pressable(
onTap: onTap,
radius: R.md,
label: extended ? null : 'Account, $name',
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
padding: const EdgeInsets.all(S.s8),
decoration: BoxDecoration(
color: hover ? C.hover : Colors.transparent,
borderRadius: BorderRadius.circular(R.md),
border: Border.all(color: extended ? C.border : Colors.transparent),
),
child: extended
? Row(
children: [
Avatar(name: name, size: 36),
const SizedBox(width: S.s10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(13, w: w6),
),
Text(role, style: ts(12, c: C.muted)),
],
),
),
const Icon(
Icons.unfold_more_rounded,
size: 18,
color: C.placeholder,
),
],
)
: Avatar(name: name, size: 40),
),
),
);
}

1082
lib/widgets/ui.dart Normal file

File diff suppressed because it is too large Load Diff

559
pubspec.lock Normal file
View File

@ -0,0 +1,559 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4
url: "https://pub.dev"
source: hosted
version: "3.0.8"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
cupertino_ui:
dependency: transitive
description:
name: cupertino_ui
sha256: e9dfe7fac704028f8928cbe4028a0be5e8a709498e8daf8247de99e99a32aef3
url: "https://pub.dev"
source: hosted
version: "1.0.2"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3"
url: "https://pub.dev"
source: hosted
version: "3.4.3"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "100c19dcc4ff60083e95a44a6f44f3a1bb887d353c5d86f6a9b688aa3cb46236"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: f1c2d3c2b0b2096b435d7af033dd4e586fa31f51d988c5985e64495cd926d872
url: "https://pub.dev"
source: hosted
version: "18.1.2"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:
name: jni
sha256: "2e91e6087291affd10b31b6c872f3e3ac7dc5d93b51b5ba485d930c8f08bde6c"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
listen:
dependency: transitive
description:
name: listen
sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
material_ui:
dependency: transitive
description:
name: material_ui
sha256: fbfb53cab6c4629438feeade5f3d305f72944bc9d9f25786b19106d32b80ec45
url: "https://pub.dev"
source: hosted
version: "1.2.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
url: "https://pub.dev"
source: hosted
version: "3.2.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a"
url: "https://pub.dev"
source: hosted
version: "3.4.3"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev"
source: hosted
version: "2.5.7"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "82885f6ac633044e7a4e4d8d745724a67aa0dc50af5d8a81ec29379f63c29035"
url: "https://pub.dev"
source: hosted
version: "1.2.6"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "4c9d2e7121eea8b4f160e6e435900a0ae7214cfc97c2d6cf84c25532b789b50d"
url: "https://pub.dev"
source: hosted
version: "1.3.4"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
url: "https://pub.dev"
source: hosted
version: "15.4.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"

104
pubspec.yaml Normal file
View File

@ -0,0 +1,104 @@
name: srs_app
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: '>=3.8.0 <4.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_riverpod: ^3.4.3
go_router: ^18.1.2
http: ^1.6.0
shared_preferences: ^2.5.6
flutter_svg: ^2.3.2
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
uses-material-design: true
assets:
- assets/icons/whatsapp.svg
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Regular.otf
weight: 400
- asset: assets/fonts/Inter-Medium.otf
weight: 500
- asset: assets/fonts/Inter-SemiBold.otf
weight: 600
- asset: assets/fonts/Inter-Bold.otf
weight: 700
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

150
test/app_test.dart Normal file
View File

@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:srs_app/app.dart';
import 'support/fake_api.dart';
import 'package:srs_app/core/providers.dart';
Future<void> boot(WidgetTester t) async {
t.view.physicalSize = const Size(430 * 2, 900 * 2);
t.view.devicePixelRatio = 2;
addTearDown(t.view.reset);
await t.pumpWidget(
ProviderScope(
overrides: [
apiProvider.overrideWithValue(FakeApi()),
startDateProvider.overrideWithValue(DateTime(2026, 10, 16)),
],
child: const SrsApp(),
),
);
await t.pumpAndSettle();
}
Future<void> signIn(WidgetTester t, String u, String p) async {
await t.enterText(find.byType(TextField).at(0), u);
await t.enterText(find.byType(TextField).at(1), p);
await t.tap(find.text('Sign in').last);
await t.pumpAndSettle(const Duration(milliseconds: 400));
}
void main() {
testWidgets('wrong password shows the error line', (t) async {
await boot(t);
expect(find.text('Welcome back'), findsOneWidget);
expect(
find.text('Forgot your password? Contact your administrator.'),
findsOneWidget,
);
await signIn(t, 'owner', 'bad-password');
expect(find.text('Wrong username or password'), findsOneWidget);
});
testWidgets('admin sees results, four tabs and can open a shop', (t) async {
await boot(t);
await signIn(t, 'owner', 'admin123');
expect(find.text('₹7,700'), findsOneWidget);
expect(find.text('Suvarna Keralam · SK-74'), findsOneWidget);
expect(find.text('Users'), findsOneWidget);
await t.tap(find.text('Shops').last);
await t.pumpAndSettle();
expect(find.text('Add shop'), findsOneWidget);
expect(find.text('180 tickets · 2 orders'), findsOneWidget);
});
testWidgets('normal user has three tabs and no Add shop button', (t) async {
await boot(t);
await signIn(t, 'kzk.staff', 'staff123');
expect(find.text('₹6,300'), findsOneWidget);
expect(find.text('Users'), findsNothing);
await t.tap(find.text('Shops').last);
await t.pumpAndSettle();
expect(find.text('Add shop'), findsNothing);
});
testWidgets('order detail lets a user delete the order after confirming', (
t,
) async {
await boot(t);
await signIn(t, 'kzk.staff', 'staff123');
await t.tap(find.text('Orders').last);
await t.pumpAndSettle();
expect(find.text('Orders'), findsWidgets);
await t.tap(find.textContaining('Photo').first);
await t.pumpAndSettle();
await t.scrollUntilVisible(
find.text('Delete order'),
400,
scrollable: find.byType(Scrollable).first,
);
await t.tap(find.text('Delete order'));
await t.pumpAndSettle();
expect(find.text('Delete this order?'), findsOneWidget);
await t.tap(find.text('Delete order').last);
await t.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Delete this order?'), findsNothing);
});
testWidgets('admin: user toggle and delete both ask for confirmation', (
t,
) async {
await boot(t);
await signIn(t, 'owner', 'admin123');
await t.tap(find.text('Users').last);
await t.pumpAndSettle();
await t.tap(find.text('skm.staff'));
await t.pumpAndSettle();
await t.tap(find.byType(Switch).first);
await t.pumpAndSettle();
expect(find.text('Deactivate skm.staff?'), findsOneWidget);
await t.tap(find.text('Cancel'));
await t.pumpAndSettle();
expect(t.widget<Switch>(find.byType(Switch).first).value, isTrue);
// To the end of the page, clear of the tab bar.
await t.drag(find.byType(Scrollable).first, const Offset(0, -600));
await t.pumpAndSettle();
await t.tap(find.text('Delete user'));
await t.pumpAndSettle();
expect(find.text('Delete skm.staff?'), findsOneWidget);
await t.tap(find.text('Delete user').last);
await t.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('skm.staff'), findsNothing);
});
testWidgets('admin: results messages switch, and no phone means no switch', (
t,
) async {
await boot(t);
await signIn(t, 'owner', 'admin123');
await t.tap(find.text('Users').last);
await t.pumpAndSettle();
expect(find.byTooltip('Gets results on WhatsApp'), findsOneWidget);
await t.tap(find.text('kzk.staff'));
await t.pumpAndSettle();
final results = find.byType(Switch).last;
await t.scrollUntilVisible(
find.text('WhatsApp results'),
300,
scrollable: find.byType(Scrollable).first,
);
expect(t.widget<Switch>(results).value, isTrue);
expect(find.text('+91 90000 11111'), findsWidgets);
await t.tap(results);
await t.pumpAndSettle(const Duration(milliseconds: 400));
expect(find.text('WhatsApp results off'), findsOneWidget);
expect(t.widget<Switch>(find.byType(Switch).last).value, isFalse);
await t.tap(find.byTooltip('Back'));
await t.pumpAndSettle(const Duration(milliseconds: 400));
expect(find.byTooltip('Gets results on WhatsApp'), findsNothing);
await t.tap(find.text('skm.staff'));
await t.pumpAndSettle();
await t.scrollUntilVisible(
find.text('Add a phone number first'),
300,
scrollable: find.byType(Scrollable).first,
);
expect(t.widget<Switch>(find.byType(Switch).last).onChanged, isNull);
});
}

210
test/core_test.dart Normal file
View File

@ -0,0 +1,210 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:srs_app/core/api.dart';
import 'support/fake_api.dart';
import 'package:srs_app/core/format.dart';
void main() {
group('format', () {
test('Indian digit grouping', () {
expect(inr(0), '0');
expect(inr(999), '999');
expect(inr(1000), '1,000');
expect(inr(7700), '7,700');
expect(inr(519900), '5,19,900');
expect(inr(12345678), '1,23,45,678');
expect(rupees(3700), '₹3,700');
});
test('dates and times', () {
expect(dateLong(DateTime(2026, 10, 16)), 'Fri, 16 Oct 2026');
expect(dateShort(DateTime(2026, 10, 16)), 'Fri, 16 Oct');
expect(time12(DateTime(2026, 10, 16, 12, 41)), '12:41 PM');
expect(time12(DateTime(2026, 10, 16, 0, 5)), '12:05 AM');
expect(fmtPhone('919000012345'), '+91 90000 12345');
});
test('server instants are shown on the Indian clock', () {
expect(
istWallClock(DateTime.utc(2026, 10, 16, 5, 30)),
DateTime(2026, 10, 16, 11, 0),
);
expect(
istWallClock(DateTime.utc(2026, 10, 16, 20, 0)),
DateTime(2026, 10, 17, 1, 30),
);
expect(parseDate('2026-10-16'), DateTime(2026, 10, 16));
});
test('prize tiers read naturally', () {
expect(tiersLabel([5]), '5th prize');
expect(tiersLabel([8, 5]), '5th + 8th prize');
expect(tiersLabel([]), '');
});
});
group('FakeApi fixture (SK-74, Fri 16 Oct 2026)', () {
final d = DateTime(2026, 10, 16);
test('admin sees the design numbers', () async {
final api = FakeApi();
await api.login('owner', 'admin123');
final r = await api.results(d);
expect(r.draw.published, isTrue);
expect(r.draw.label, 'Suvarna Keralam · SK-74');
expect(r.totalPrize, 7700);
expect(r.winningTickets, 16);
expect(r.totalTickets, 500);
expect(r.orders, 6);
expect(r.winningShops, 4);
expect(r.topShop!.shop.code, 'KZK');
expect(r.topShop!.prize, 3700);
expect(r.topShop!.winTickets, 10);
});
test('KZK detail: 180 tickets, 52 endings, 2 orders', () async {
final api = FakeApi();
await api.login('owner', 'admin123');
final shops = await api.shops(d);
final kzk = shops.firstWhere((s) => s.shop.code == 'KZK');
expect(kzk.tickets, 180);
expect(kzk.endings, 52);
expect(kzk.orders, 2);
final det = await api.shopDetail(kzk.shop.id, d);
expect(det.prize, 3700);
expect(det.wins.map((w) => w.ending).toSet(), {
'0376',
'0987',
'1360',
'2564',
});
});
test('results for tomorrow are not out; counts only', () async {
final api = FakeApi();
await api.login('owner', 'admin123');
final r = await api.results(DateTime(2026, 10, 17));
expect(r.draw.published, isFalse);
expect(r.totalPrize, 0);
expect(r.totalTickets, 30);
});
});
group('roles', () {
final d = DateTime(2026, 10, 16);
test('user only sees assigned shops', () async {
final api = FakeApi();
await api.login('kzk.staff', 'staff123');
final r = await api.results(d);
expect(r.shops.map((s) => s.shop.code).toSet(), {'KZK', 'SKM'});
expect(r.totalPrize, 3700 + 2600);
final shops = await api.shops(d);
expect(shops.length, 2);
expect(() => api.shopDetail(3, d), throwsA(isA<ApiException>()));
});
test('user cannot manage shops or users', () async {
final api = FakeApi();
await api.login('kzk.staff', 'staff123');
expect(() => api.createShop('X', 'XXX'), throwsA(isA<ApiException>()));
expect(() => api.users(), throwsA(isA<ApiException>()));
});
test('wrong password and inactive users cannot sign in', () async {
final api = FakeApi();
expect(() => api.login('owner', 'nope'), throwsA(isA<ApiException>()));
expect(
() => api.login('ptm.staff', 'staff123'),
throwsA(isA<ApiException>()),
);
});
});
group('editing', () {
final d = DateTime(2026, 10, 16);
test(
'changing a quantity updates totals and marks the order edited',
() async {
final api = FakeApi();
await api.login('owner', 'admin123');
final orders = await api.orders(d);
final ptm = orders.firstWhere((o) => o.shop.code == 'PTM');
await api.setLineQty(ptm.id, '3821', 3);
final r = await api.results(d);
expect(r.totalPrize, 7700 + 400);
expect((await api.order(ptm.id)).edited, isTrue);
},
);
test(
'removing the last ending deletes the order; cancel removes tickets',
() async {
final api = FakeApi();
await api.login('owner', 'admin123');
final orders = await api.orders(DateTime(2026, 10, 17));
final one = orders.firstWhere((o) => o.shop.code == 'KZK');
for (final l in one.lines) {
await api.removeLine(one.id, l.ending);
}
expect(() => api.order(one.id), throwsA(isA<ApiException>()));
final ulr = orders.firstWhere((o) => o.shop.code == 'ULR');
await api.cancelOrder(ulr.id);
expect((await api.orders(DateTime(2026, 10, 17))), isEmpty);
},
);
test('shop and user rules', () async {
final d = DateTime(2026, 10, 16);
final api = FakeApi();
await api.login('owner', 'admin123');
expect(
() => api.createShop('Another', 'KZK'),
throwsA(isA<ApiException>()),
);
final s = await api.createShop('Medical College', 'MCH');
await api.renameShop(s.id, 'Medical College Jn');
await api.setShopActive(s.id, false);
expect((await api.activeShops()).any((x) => x.id == s.id), isFalse);
expect(
(await api.shops(d)).any((x) => x.shop.id == s.id && !x.shop.active),
isTrue,
);
await api.setShopActive(s.id, true);
expect((await api.activeShops()).any((x) => x.id == s.id), isTrue);
expect(
() =>
api.createUser(username: 'ab', password: 'longenough', shopIds: []),
throwsA(isA<ApiException>()),
);
expect(
() =>
api.createUser(username: 'newuser', password: 'short', shopIds: []),
throwsA(isA<ApiException>()),
);
final u = await api.createUser(
username: 'newuser',
phone: '98765 43210',
password: 'longenough',
shopIds: [1],
);
expect(u.phone, '919876543210');
await api.updateUser(u.id, active: false);
await api.deleteUser(u.id);
expect((await api.users()).any((x) => x.id == u.id), isFalse);
expect(() => api.deleteUser(1), throwsA(isA<ApiException>()));
final u2 = await api.createUser(
username: 'second',
password: 'longenough',
shopIds: [],
);
await api.updateUser(u2.id, active: false);
api.logout();
expect(
() => api.login('second', 'longenough'),
throwsA(isA<ApiException>()),
);
});
});
}

343
test/http_api_test.dart Normal file
View File

@ -0,0 +1,343 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:srs_app/core/api.dart';
import 'package:srs_app/core/http_api.dart';
const _user = {
'id': 7,
'username': 'kzk.staff',
'phone': '919847012345',
'isAdmin': false,
'active': true,
'shopIds': [1, 2],
};
const _shop = {'id': 1, 'code': 'KZK', 'name': 'Kozhikode', 'active': true};
const _order = {
'id': 11,
'shop': _shop,
'drawDate': '2026-10-16',
'createdAt': '2026-10-16T05:30:00Z',
'edited': true,
'isPhoto': false,
'rawText': '1234-2',
'lines': [
{
'ending': '1234',
'qty': 2,
'hits': [
{'tier': 5, 'amount': 1000},
{'tier': 8, 'amount': 100},
],
},
{'ending': '5678', 'qty': 1, 'hits': <Object>[]},
],
};
http.Response _json(Object body, [int status = 200]) => http.Response(
jsonEncode(body),
status,
headers: {'content-type': 'application/json'},
);
Future<(HttpApi, List<http.Request>, SharedPreferences)> _api(
http.Response Function(http.Request) reply, {
Map<String, Object> prefs = const {},
}) async {
SharedPreferences.setMockInitialValues(prefs);
final store = await SharedPreferences.getInstance();
final seen = <http.Request>[];
final api = HttpApi(
baseUrl: 'https://srs.test/',
prefs: store,
client: MockClient((r) async {
seen.add(r);
return reply(r);
}),
);
return (api, seen, store);
}
void main() {
group('signing in', () {
test('keeps the token and sends it on later calls', () async {
final (api, seen, store) = await _api(
(r) => r.url.path == '/api/auth/login'
? _json({'token': 'abc', 'user': _user})
: _json({'date': '2026-10-16'}),
);
final u = await api.login(' kzk.staff ', 'secret-pass');
expect(u.username, 'kzk.staff');
expect(u.shopIds, [1, 2]);
expect(jsonDecode(seen.single.body), {
'username': 'kzk.staff',
'password': 'secret-pass',
});
expect(seen.single.headers['authorization'], isNull);
expect(store.getString('token'), 'abc');
expect(await api.latestDrawDate(), DateTime(2026, 10, 16));
expect(seen.last.url.toString(), 'https://srs.test/api/draws/latest');
expect(seen.last.headers['authorization'], 'Bearer abc');
});
test('shows the server message for a wrong password', () async {
final (api, _, store) = await _api(
(_) => _json({
'error': 'invalid_credentials',
'message': 'Incorrect username or password.',
}, 401),
);
await expectLater(
api.login('a', 'b'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'Incorrect username or password.',
),
),
);
expect(store.getString('token'), isNull);
});
test('restores the user from a saved token, or none without one', () async {
final (none, calls, _) = await _api((_) => _json(_user));
expect(await none.restore(), isNull);
expect(calls, isEmpty);
final (api, _, _) = await _api(
(_) => _json(_user),
prefs: {'token': 'abc'},
);
expect((await api.restore())!.id, 7);
});
test('forgets an old token the server no longer accepts', () async {
final (api, _, store) = await _api(
(_) => _json({'error': 'unauthorized', 'message': 'Expired.'}, 401),
prefs: {'token': 'old'},
);
expect(await api.restore(), isNull);
expect(store.getString('token'), isNull);
});
test('logout drops the token', () async {
final (api, _, store) = await _api(
(_) => _json(_user),
prefs: {'token': 'abc'},
);
api.logout();
await Future<void>.delayed(Duration.zero);
expect(store.getString('token'), isNull);
});
});
group('a session that ends', () {
test('clears the token and tells the app', () async {
final (api, _, store) = await _api(
(_) => _json({'error': 'unauthorized', 'message': 'Ended.'}, 401),
prefs: {'token': 'abc'},
);
final ended = expectLater(api.sessionEnded, emits(anything));
await expectLater(
api.orders(DateTime(2026, 10, 16)),
throwsA(isA<ApiException>()),
);
await ended;
expect(store.getString('token'), isNull);
});
});
group('failures', () {
test('an unreachable server gives a plain message', () async {
SharedPreferences.setMockInitialValues({});
final api = HttpApi(
baseUrl: 'https://srs.test',
prefs: await SharedPreferences.getInstance(),
client: MockClient((_) async => throw http.ClientException('down')),
);
await expectLater(
api.activeShops(),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
startsWith('Unable to reach the server'),
),
),
);
});
test('a server error without a message is not shown raw', () async {
final (api, _, _) = await _api((_) => http.Response('<html>', 502));
await expectLater(
api.activeShops(),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'Something went wrong on the server. Try again shortly.',
),
),
);
});
});
group('reading', () {
test(
'turns an order into models in Indian time with every prize',
() async {
final (api, seen, _) = await _api((_) => _json(_order));
final o = await api.order(11);
expect(seen.single.url.path, '/api/orders/11');
expect(o.time, DateTime(2026, 10, 16, 11, 0));
expect(o.drawDate, DateTime(2026, 10, 16));
expect(o.edited, isTrue);
expect(o.lines.first.tierText, '5th + 8th prize');
expect(o.lines.first.perTicket, 1100);
expect(o.prize, 2200);
expect(o.lines.last.wins, isFalse);
},
);
test('reads a day of results, an awaited draw included', () async {
final (api, seen, _) = await _api(
(_) => _json({
'draw': {
'date': '2026-10-17',
'scheme': null,
'code': null,
'published': false,
'hasWin': false,
},
'shops': [
{
'shop': _shop,
'orders': 2,
'tickets': 9,
'winTickets': 0,
'prize': 0,
},
],
}),
);
final r = await api.results(DateTime(2026, 10, 17));
expect(seen.single.url.path, '/api/draws/2026-10-17/results');
expect(r.draw.published, isFalse);
expect(r.draw.label, 'No result yet');
expect(r.shops.single.tickets, 9);
});
test('asks for a month and a shop day with the right addresses', () async {
final (api, seen, _) = await _api(
(r) => r.url.path == '/api/draws'
? _json({'days': <Object>[]})
: _json({
'shop': _shop,
'draw': {
'date': '2026-10-16',
'scheme': 'Suvarna Keralam',
'code': 'SK-74',
'published': true,
'hasWin': true,
},
'endings': <Object>[],
'orders': [_order],
}),
);
await api.month(2026, 3);
expect(
seen.last.url.toString(),
'https://srs.test/api/draws?month=2026-03',
);
final d = await api.shopDetail(1, DateTime(2026, 10, 16));
expect(seen.last.url.path, '/api/shops/1/draws/2026-10-16');
expect(d.draw.label, 'Suvarna Keralam · SK-74');
expect(d.orders.single.id, 11);
});
});
group('changing things', () {
test('sends each edit to the right place', () async {
final (api, seen, _) = await _api(
(r) => r.method == 'POST' ? _json(_shop, 201) : http.Response('', 204),
);
await api.setLineQty(11, '1234', 5);
await api.removeLine(11, '1234');
await api.cancelOrder(11);
await api.setShopActive(1, false);
await api.renameShop(1, 'New name');
await api.setUserShops(7, [1, 2]);
await api.deleteUser(7);
final shop = await api.createShop('Kozhikode', 'KZK');
final calls = [for (final r in seen) '${r.method} ${r.url.path}'];
expect(calls, [
'PUT /api/orders/11/lines/1234',
'DELETE /api/orders/11/lines/1234',
'DELETE /api/orders/11',
'PATCH /api/shops/1',
'PATCH /api/shops/1',
'PUT /api/users/7/shops',
'DELETE /api/users/7',
'POST /api/shops',
]);
expect(jsonDecode(seen[0].body), {'qty': 5});
expect(jsonDecode(seen[3].body), {'active': false});
expect(jsonDecode(seen[5].body), {
'shopIds': [1, 2],
});
expect(shop.code, 'KZK');
});
test('sends only the user fields that changed', () async {
final (api, seen, _) = await _api((_) => http.Response('', 204));
await api.updateUser(7, username: 'new.name');
await api.updateUser(7, clearPhone: true);
await api.updateUser(7, active: false, password: 'longenough');
await api.updateUser(7, sendResults: true);
expect(jsonDecode(seen[0].body), {'username': 'new.name'});
expect(jsonDecode(seen[1].body), {'phone': null});
expect(jsonDecode(seen[2].body), {
'password': 'longenough',
'active': false,
});
expect(jsonDecode(seen[3].body), {'sendResults': true});
});
test(
'reads the results setting, off when the server leaves it out',
() async {
final (api, _, _) = await _api(
(r) => r.url.path == '/api/users/7'
? _json({..._user, 'sendResults': true})
: _json(_user),
);
expect((await api.user(7)).sendResults, isTrue);
expect((await api.user(8)).sendResults, isFalse);
},
);
test('creates a user with a password and shops', () async {
final (api, seen, _) = await _api((_) => _json(_user, 201));
final u = await api.createUser(
username: 'kzk.staff',
password: 'longenough',
shopIds: [1, 2],
);
expect(jsonDecode(seen.single.body), {
'username': 'kzk.staff',
'phone': null,
'password': 'longenough',
'shopIds': [1, 2],
});
expect(u.id, 7);
});
});
}

770
test/support/fake_api.dart Normal file
View File

@ -0,0 +1,770 @@
import 'package:srs_app/core/api.dart';
import 'package:srs_app/core/format.dart';
import 'package:srs_app/core/models.dart';
class _U {
_U(
this.id,
this.username,
this.phone,
this.password, {
this.isAdmin = false,
this.active = true,
this.sendResults = false,
List<int>? shops,
}) : shops = shops ?? [];
final int id;
String username;
String? phone;
String password;
final bool isAdmin;
bool active;
bool sendResults;
List<int> shops;
}
class _O {
_O(
this.id,
this.shopId,
this.drawDate,
this.time,
this.isPhoto,
this.lines, {
this.edited = false,
});
final int id;
final int shopId;
final DateTime drawDate;
final DateTime time;
final bool isPhoto;
final Map<String, int> lines;
bool edited;
bool cancelled = false;
}
/// Test double: an in-memory stand-in for srs-api with a fixed fixture
/// (SK-74, Fri 16 Oct 2026). Used only by the tests; never shipped in the app.
/// Results are computed on read (orders x prizes), as srs-api does.
class FakeApi implements Api {
FakeApi() {
_seed();
}
@override
final DateTime now = DateTime(2026, 10, 16, 17, 0);
AppUser? _me;
final List<Shop> _shops = [];
final List<_U> _users = [];
final List<_O> _orders = [];
int _nextShop = 1, _nextUser = 1, _nextOrder = 1;
static const _prizes = <String, Map<String, (int, int)>>{
'2026-10-16': {
'2851': (5, 2000),
'1360': (6, 1000),
'0987': (7, 500),
'6184': (7, 500),
'0376': (8, 200),
'1903': (8, 200),
'3821': (8, 200),
'2564': (9, 100),
'0119': (9, 100),
},
};
void _seed() {
Shop shop(String code, String name) {
final s = Shop(id: _nextShop++, code: code, name: name);
_shops.add(s);
return s;
}
final kzk = shop('KZK', 'Kazhakkoottam');
final skm = shop('SKM', 'Sreekaryam');
final ulr = shop('ULR', 'Ulloor');
final ptm = shop('PTM', 'Pattom');
_U user(
String u,
String? phone,
String pw, {
bool admin = false,
bool active = true,
bool results = false,
List<int>? shops,
}) {
final x = _U(
_nextUser++,
u,
phone,
pw,
isAdmin: admin,
active: active,
sendResults: results,
shops: shops,
);
_users.add(x);
return x;
}
user('owner', '919000012345', 'admin123', admin: true);
user('manager', '919000054321', 'admin123', admin: true);
user(
'kzk.staff',
'919000011111',
'staff123',
results: true,
shops: [kzk.id, skm.id],
);
user('skm.staff', null, 'staff123', shops: [skm.id]);
user(
'ptm.staff',
'919000022222',
'staff123',
active: false,
shops: [ptm.id],
);
final d = DateTime(2026, 10, 16);
void order(
Shop s,
DateTime day,
DateTime t,
bool photo,
Map<String, int> lines, {
bool edited = false,
}) {
_orders.add(_O(_nextOrder++, s.id, day, t, photo, lines, edited: edited));
}
// KZK: 180 tickets / 52 endings across two orders (64 + 116).
const kzkAll = <(String, int)>[
('0003', 5),
('0018', 2),
('0057', 2),
('0166', 1),
('0231', 6),
('0376', 2),
('0418', 5),
('0745', 2),
('0887', 10),
('0888', 1),
('0987', 4),
('1122', 3),
('1207', 4),
('1360', 1),
('1573', 3),
('1892', 2),
('2290', 2),
('2417', 1),
('2564', 3),
('2689', 1),
('3076', 5),
('3345', 10),
('3422', 2),
('3472', 1),
('3484', 4),
('3584', 3),
('3611', 3),
('3704', 4),
('4129', 1),
('4410', 1),
('4455', 6),
('4568', 6),
('4852', 2),
('5043', 4),
('5104', 1),
('5105', 5),
('5106', 3),
('5127', 3),
('5936', 10),
('6358', 2),
('6602', 6),
('6861', 2),
('7021', 1),
('7216', 10),
('7788', 2),
('7790', 12),
('8147', 3),
('8301', 2),
('8890', 1),
('9064', 1),
('9077', 3),
('9155', 1),
];
final a = <String, int>{}, b = <String, int>{};
var left = 64;
for (final (e, q) in kzkAll) {
if (left >= q) {
a[e] = q;
left -= q;
} else if (left > 0) {
a[e] = left;
b[e] = q - left;
left = 0;
} else {
b[e] = q;
}
}
order(kzk, d, DateTime(2026, 10, 16, 12, 41), true, a);
order(kzk, d, DateTime(2026, 10, 16, 11, 52), false, b, edited: true);
order(skm, d, DateTime(2026, 10, 16, 12, 18), false, {
'0887': 1,
'0166': 1,
'3611': 1,
'4568': 2,
'6861': 1,
'0018': 1,
'0057': 2,
'2851': 1,
'6184': 1,
'0119': 1,
'9155': 20,
'5124': 5,
'0003': 3,
'7731': 2,
'4402': 4,
'1288': 3,
'3399': 2,
'8020': 3,
});
order(skm, d, DateTime(2026, 10, 16, 10, 47), false, {
'1001': 10,
'1102': 12,
'2203': 8,
'3304': 14,
'4405': 6,
'5506': 10,
'6607': 9,
'7708': 7,
'8809': 10,
});
order(ulr, d, DateTime(2026, 10, 16, 11, 30), true, {
'1360': 1,
'1903': 1,
'1010': 20,
'2020': 15,
'3030': 12,
'4040': 18,
'5050': 14,
'6060': 10,
'7070': 12,
'8080': 7,
});
order(ptm, d, DateTime(2026, 10, 16, 10, 5), false, {
'3821': 1,
'1111': 15,
'2222': 12,
'3333': 10,
'4444': 8,
'5555': 10,
'6666': 9,
'7777': 5,
});
// Orders for tomorrow's draw: results not out yet.
final n = DateTime(2026, 10, 17);
order(kzk, n, DateTime(2026, 10, 16, 15, 20), false, {
'0042': 10,
'7311': 5,
'2208': 5,
});
order(ulr, n, DateTime(2026, 10, 16, 16, 5), true, {'5521': 8, '3190': 2});
}
Future<void> _t() => Future<void>.delayed(const Duration(milliseconds: 160));
// ---- helpers -----------------------------------------------------------
bool _published(DateTime d) {
final day = dateOnly(d), today = dateOnly(now);
if (day.isBefore(today)) return true;
if (sameDay(day, today)) return now.hour >= 16;
return false;
}
(int, int)? _prize(DateTime d, String ending) {
if (!_published(d)) return null;
return _prizes[isoDate(d)]?[ending];
}
Set<int> _scope() {
final me = _me;
if (me == null) return {};
final active = _shops.where((s) => s.active).map((s) => s.id).toSet();
final u = _users.firstWhere((x) => x.id == me.id);
if (!u.active) return {};
if (u.isAdmin) return active;
return u.shops.where(active.contains).toSet();
}
static const _schemes = {
1: ('Bhagyathara', 'BT'),
2: ('Sthree Sakthi', 'SS'),
3: ('Dhanalekshmi', 'DL'),
4: ('Karunya Plus', 'KN'),
5: ('Suvarna Keralam', 'SK'),
6: ('Karunya', 'KR'),
7: ('Samrudhi', 'SM'),
};
List<PrizeHit> _hits((int, int)? p) =>
p == null ? const [] : [PrizeHit(tier: p.$1, amount: p.$2)];
DrawInfo _info(DateTime d, {bool? hasWin}) {
final s = _schemes[d.weekday]!;
final weeks = d.difference(DateTime(2026, 10, 16)).inDays ~/ 7;
final published = _published(d);
return DrawInfo(
date: d,
scheme: published ? s.$1 : null,
code: published ? '${s.$2}-${74 + weeks}' : null,
published: published,
hasWin: hasWin ?? false,
);
}
List<_O> _ordersFor(DateTime d, {int? shopId}) {
final scope = _scope();
return _orders
.where(
(o) =>
!o.cancelled &&
sameDay(o.drawDate, d) &&
scope.contains(o.shopId) &&
(shopId == null || o.shopId == shopId),
)
.toList();
}
Map<String, int> _sum(Iterable<_O> os) {
final m = <String, int>{};
for (final o in os) {
o.lines.forEach((e, q) => m[e] = (m[e] ?? 0) + q);
}
return m;
}
Shop _shop(int id) => _shops.firstWhere((s) => s.id == id);
Order _toOrder(_O o) {
final keys = o.lines.keys.toList()..sort();
final lines = [
for (final e in keys)
() {
final p = _prize(o.drawDate, e);
return EndingQty(ending: e, qty: o.lines[e]!, hits: _hits(p));
}(),
];
final shop = _shop(o.shopId);
return Order(
id: o.id,
shop: shop,
drawDate: o.drawDate,
time: o.time,
isPhoto: o.isPhoto,
edited: o.edited,
lines: lines,
rawText: o.isPhoto
? null
: '${shop.code}\n${keys.map((e) => '$e - ${o.lines[e]}').join('\n')}',
);
}
_U _admin() {
final me = _me;
if (me == null || !me.isAdmin) {
throw ApiException('You do not have permission to do this.');
}
return _users.firstWhere((u) => u.id == me.id);
}
AppUser _toUser(_U u) => AppUser(
id: u.id,
username: u.username,
phone: u.phone,
isAdmin: u.isAdmin,
active: u.active,
sendResults: u.sendResults,
shopIds: u.isAdmin
? _shops.where((s) => s.active).map((s) => s.id).toList()
: List.of(u.shops),
);
_O _scopedOrder(int id) {
final scope = _scope();
return _orders.firstWhere(
(o) => o.id == id && !o.cancelled && scope.contains(o.shopId),
orElse: () => throw ApiException('Order not found.'),
);
}
// ---- auth --------------------------------------------------------------
@override
Stream<void> get sessionEnded => const Stream.empty();
@override
Future<DateTime> latestDrawDate() async => DateTime(2026, 10, 16);
@override
Future<AppUser> login(String username, String password) async {
await _t();
final u = _users
.where((x) => x.username.toLowerCase() == username.trim().toLowerCase())
.firstOrNull;
if (u == null || u.password != password || !u.active) {
throw ApiException('Wrong username or password');
}
_me = _toUser(u);
return _me!;
}
/// Everything lives in memory, so a reload starts signed out.
@override
Future<AppUser?> restore() async => _me;
@override
void logout() => _me = null;
// ---- results -----------------------------------------------------------
@override
Future<List<DrawInfo>> month(int year, int month) async {
await _t();
final days = DateTime(year, month + 1, 0).day;
return [
for (var i = 1; i <= days; i++)
() {
final d = DateTime(year, month, i);
var win = false;
if (_published(d)) {
win = _ordersFor(
d,
).any((o) => o.lines.keys.any((e) => _prize(d, e) != null));
}
return _info(d, hasWin: win);
}(),
];
}
@override
Future<DrawResults> results(DateTime date) async {
await _t();
final d = dateOnly(date);
final scope = _scope();
final rows = <ShopResult>[];
for (final s in _shops.where((s) => s.active && scope.contains(s.id))) {
final os = _ordersFor(d, shopId: s.id);
if (os.isEmpty) continue;
var tickets = 0, wt = 0, prize = 0;
_sum(os).forEach((e, q) {
tickets += q;
final p = _prize(d, e);
if (p != null) {
wt += q;
prize += q * p.$2;
}
});
rows.add(
ShopResult(
shop: s,
tickets: tickets,
orders: os.length,
winTickets: wt,
prize: prize,
),
);
}
rows.sort((x, y) {
final c = y.prize.compareTo(x.prize);
return c != 0 ? c : y.tickets.compareTo(x.tickets);
});
final win = rows.any((r) => r.prize > 0);
return DrawResults(
draw: _info(d, hasWin: win),
shops: rows,
);
}
// ---- shops -------------------------------------------------------------
@override
Future<List<ShopDay>> shops(DateTime date) async {
await _t();
final d = dateOnly(date);
final scope = _scope();
return [
for (final s in _shops.where((s) => s.active && scope.contains(s.id)))
() {
final os = _ordersFor(d, shopId: s.id);
final sums = _sum(os);
return ShopDay(
shop: s,
tickets: sums.values.fold(0, (a, b) => a + b),
orders: os.length,
endings: sums.length,
);
}(),
// Admins also see deactivated shops so they can switch them back on.
if (_me?.isAdmin == true)
for (final s in _shops.where((s) => !s.active))
ShopDay(shop: s, tickets: 0, orders: 0, endings: 0),
];
}
@override
Future<ShopDetail> shopDetail(int shopId, DateTime date) async {
await _t();
final d = dateOnly(date);
if (!_scope().contains(shopId)) throw ApiException('Shop not found.');
final os = _ordersFor(d, shopId: shopId);
final sums = _sum(os);
final keys = sums.keys.toList()..sort();
final endings = [
for (final e in keys)
() {
final p = _prize(d, e);
return EndingQty(ending: e, qty: sums[e]!, hits: _hits(p));
}(),
];
final orders = os.map(_toOrder).toList()
..sort((x, y) => y.time.compareTo(x.time));
return ShopDetail(
shop: _shop(shopId),
draw: _info(d, hasWin: endings.any((e) => e.wins)),
endings: endings,
orders: orders,
);
}
@override
Future<List<Shop>> activeShops() async {
final scope = _scope();
return _shops.where((s) => s.active && scope.contains(s.id)).toList();
}
@override
Future<Shop> createShop(String name, String code) async {
await _t();
_admin();
final n = name.trim(), c = code.trim().toUpperCase();
if (n.isEmpty) throw ApiException('Shop name is required.');
if (n.length > 100) {
throw ApiException('Shop name must be 100 characters or fewer.');
}
if (c.isEmpty || c.length > 20 || c.contains(RegExp(r'\s'))) {
throw ApiException('Short code must be 1–20 characters with no spaces.');
}
if (_shops.any((s) => s.code.toUpperCase() == c)) {
throw ApiException('This short code is already in use.');
}
if (_shops.any(
(s) => s.active && s.name.toLowerCase() == n.toLowerCase(),
)) {
throw ApiException('A shop with this name already exists.');
}
final s = Shop(id: _nextShop++, code: c, name: n);
_shops.add(s);
return s;
}
@override
Future<void> renameShop(int shopId, String name) async {
await _t();
_admin();
final n = name.trim();
if (n.isEmpty) throw ApiException('Shop name is required.');
if (n.length > 100) {
throw ApiException('Shop name must be 100 characters or fewer.');
}
if (_shops.any(
(s) =>
s.id != shopId && s.active && s.name.toLowerCase() == n.toLowerCase(),
)) {
throw ApiException('A shop with this name already exists.');
}
final i = _shops.indexWhere((s) => s.id == shopId);
_shops[i] = _shops[i].copyWith(name: n);
}
@override
Future<void> setShopActive(int shopId, bool active) async {
await _t();
_admin();
final i = _shops.indexWhere((s) => s.id == shopId);
if (active &&
_shops.any(
(s) =>
s.id != shopId &&
s.active &&
s.name.toLowerCase() == _shops[i].name.toLowerCase(),
)) {
throw ApiException('An active shop with this name already exists.');
}
_shops[i] = _shops[i].copyWith(active: active);
}
// ---- orders ------------------------------------------------------------
@override
Future<List<Order>> orders(DateTime date) async {
await _t();
final list = _ordersFor(dateOnly(date)).map(_toOrder).toList()
..sort((x, y) => y.time.compareTo(x.time));
return list;
}
@override
Future<Order> order(int orderId) async {
await _t();
return _toOrder(_scopedOrder(orderId));
}
@override
Future<void> setLineQty(int orderId, String ending, int qty) async {
await _t();
final o = _scopedOrder(orderId);
if (!o.lines.containsKey(ending)) throw ApiException('Ending not found.');
if (qty < 1 || qty > 9999) throw ApiException('Quantity must be 1–9999.');
o.lines[ending] = qty;
o.edited = true;
}
@override
Future<void> removeLine(int orderId, String ending) async {
await _t();
final o = _scopedOrder(orderId);
o.lines.remove(ending);
o.edited = true;
if (o.lines.isEmpty) o.cancelled = true;
}
@override
Future<void> cancelOrder(int orderId) async {
await _t();
_scopedOrder(orderId).cancelled = true;
}
// ---- users -------------------------------------------------------------
@override
Future<List<AppUser>> users() async {
await _t();
_admin();
// Like srs-api: the app manages users only, never admins.
return _users.where((u) => !u.isAdmin).map(_toUser).toList();
}
@override
Future<AppUser> user(int id) async {
await _t();
_admin();
final u = _users.firstWhere((u) => u.id == id);
if (u.isAdmin) {
throw ApiException('Administrator accounts cannot be changed here.');
}
return _toUser(u);
}
static final _nameRe = RegExp(r'^[A-Za-z0-9._-]{3,50}$');
String _checkUsername(String raw, {int? self}) {
final u = raw.trim();
if (!_nameRe.hasMatch(u)) {
throw ApiException(
'Username must be 3–50 characters: letters, numbers, dots, dashes or underscores.',
);
}
if (_users.any(
(x) => x.id != self && x.username.toLowerCase() == u.toLowerCase(),
)) {
throw ApiException('This username is already taken.');
}
return u;
}
String? _checkPhone(String? raw) {
if (raw == null) return null;
var d = raw.replaceAll(RegExp(r'\D'), '');
if (d.isEmpty) return null;
if (d.length == 10) d = '91$d';
if (d.length != 12 || !d.startsWith('91')) {
throw ApiException('Enter a valid 10-digit mobile number.');
}
return d;
}
void _checkPassword(String p) {
if (p.length < 8) {
throw ApiException('Password must be at least 8 characters.');
}
}
@override
Future<AppUser> createUser({
required String username,
String? phone,
required String password,
required List<int> shopIds,
}) async {
await _t();
_admin();
final name = _checkUsername(username);
final ph = _checkPhone(phone);
_checkPassword(password);
final u = _U(_nextUser++, name, ph, password, shops: List.of(shopIds));
_users.add(u);
return _toUser(u);
}
_U _editable(int id) {
final u = _users.firstWhere((x) => x.id == id);
if (u.isAdmin) {
throw ApiException('Administrator accounts cannot be changed here.');
}
return u;
}
@override
Future<void> updateUser(
int id, {
String? username,
String? phone,
bool clearPhone = false,
String? password,
bool? active,
bool? sendResults,
}) async {
await _t();
_admin();
final u = _editable(id);
if (username != null) u.username = _checkUsername(username, self: id);
if (clearPhone) u.phone = null;
if (phone != null) u.phone = _checkPhone(phone);
if (password != null) {
_checkPassword(password);
u.password = password;
}
if (active != null) u.active = active;
if (sendResults != null) u.sendResults = sendResults;
}
@override
Future<void> deleteUser(int id) async {
await _t();
_admin();
_editable(id);
_users.removeWhere((u) => u.id == id);
}
@override
Future<void> setUserShops(int id, List<int> shopIds) async {
await _t();
_admin();
_editable(id).shops = List.of(shopIds);
}
}

BIN
web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
web/icons/Icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
web/icons/Icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

47
web/index.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="Your sequences, at a glance">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="SRS">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<meta name="theme-color" content="#1B2A49">
<title>SRS</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

35
web/manifest.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "SRS",
"short_name": "SRS",
"start_url": ".",
"display": "standalone",
"background_color": "#F4F2EC",
"theme_color": "#1B2A49",
"description": "Your sequences, at a glance",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}