srs/test/support/fake_api.dart
Aswin Biju 45fcdcebfb 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.
2026-11-04 23:21:50 +05:30

771 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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