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.
233 lines
7.0 KiB
Dart
233 lines
7.0 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|