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? shops, }) : shops = shops ?? []; final int id; String username; String? phone; String password; final bool isAdmin; bool active; bool sendResults; List 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 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 { /// How many times results were fetched, for refresh tests. int resultsCalls = 0; /// When true, results fail as if the network were down. bool failResults = false; FakeApi() { _seed(); } @override final DateTime now = DateTime(2026, 10, 16, 17, 0); AppUser? _me; final List _shops = []; final List<_U> _users = []; final List<_O> _orders = []; int _nextShop = 1, _nextUser = 1, _nextOrder = 1; static const _prizes = >{ '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? 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 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 = {}, b = {}; 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 _t() => Future.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 _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 _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 _sum(Iterable<_O> os) { final m = {}; 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 get sessionEnded => const Stream.empty(); @override Future latestDrawDate() async => DateTime(2026, 10, 16); @override Future 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 restore() async => _me; @override void logout() => _me = null; // ---- results ----------------------------------------------------------- @override Future> 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 results(DateTime date) async { resultsCalls++; await _t(); if (failResults) throw ApiException('Unable to reach the server.'); final d = dateOnly(date); final scope = _scope(); final rows = []; 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> 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(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> activeShops() async { final scope = _scope(); return _shops.where((s) => s.active && scope.contains(s.id)).toList(); } @override Future 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 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 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> 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(int orderId) async { await _t(); return _toOrder(_scopedOrder(orderId)); } @override Future 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 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 cancelOrder(int orderId) async { await _t(); _scopedOrder(orderId).cancelled = true; } // ---- users ------------------------------------------------------------- @override Future> 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 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 createUser({ required String username, String? phone, required String password, required List 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 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 deleteUser(int id) async { await _t(); _admin(); _editable(id); _users.removeWhere((u) => u.id == id); } @override Future setUserShops(int id, List shopIds) async { await _t(); _admin(); _editable(id).shops = List.of(shopIds); } }