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.
771 lines
19 KiB
Dart
771 lines
19 KiB
Dart
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);
|
||
}
|
||
}
|