srs/lib/features/shops/shop_sheet.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

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