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.
716 lines
21 KiB
Dart
716 lines
21 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../core/format.dart';
|
|
import '../../core/models.dart';
|
|
import '../../core/providers.dart';
|
|
import '../../core/theme.dart';
|
|
import '../../widgets/ui.dart';
|
|
import 'shop_picker.dart';
|
|
import 'user_bits.dart';
|
|
|
|
class UserDetailScreen extends ConsumerWidget {
|
|
const UserDetailScreen({super.key, required this.userId});
|
|
final int userId;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final user = ref.watch(userProvider(userId));
|
|
final shops =
|
|
ref.watch(activeShopsProvider).asData?.value ?? const <Shop>[];
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: asyncView(
|
|
user,
|
|
(u) => Column(
|
|
children: [
|
|
const ScreenHeader(
|
|
title: 'User details',
|
|
back: true,
|
|
maxWidth: _maxW,
|
|
),
|
|
Expanded(
|
|
child: _Body(u: u, shops: shops),
|
|
),
|
|
],
|
|
),
|
|
retry: () => ref.invalidate(userProvider(userId)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const _maxW = 1040.0;
|
|
|
|
class _Body extends ConsumerWidget {
|
|
const _Body({required this.u, required this.shops});
|
|
final AppUser u;
|
|
final List<Shop> shops;
|
|
|
|
Future<void> _delete(BuildContext context, WidgetRef ref) async {
|
|
final ok = await confirm(
|
|
context,
|
|
title: 'Delete ${u.username}?',
|
|
body:
|
|
'This permanently removes the account. Saved orders are kept. This action cannot be undone.',
|
|
confirmLabel: 'Delete user',
|
|
);
|
|
if (!ok || !context.mounted) return;
|
|
try {
|
|
await mutate(ref, () => ref.read(apiProvider).deleteUser(u.id));
|
|
if (!context.mounted) return;
|
|
context.pop();
|
|
toast(context, 'User deleted');
|
|
} catch (e) {
|
|
if (context.mounted) toast(context, errText(e));
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleActive(BuildContext context, WidgetRef ref) async {
|
|
final deactivate = u.active;
|
|
final ok = await confirm(
|
|
context,
|
|
title: deactivate
|
|
? 'Deactivate ${u.username}?'
|
|
: 'Activate ${u.username}?',
|
|
body: deactivate
|
|
? 'This user will no longer be able to sign in. Saved orders are kept.'
|
|
: 'This user will be able to sign in again with their current password.',
|
|
confirmLabel: deactivate ? 'Deactivate' : 'Activate',
|
|
danger: deactivate,
|
|
);
|
|
if (!ok || !context.mounted) return;
|
|
try {
|
|
await mutate(
|
|
ref,
|
|
() => ref.read(apiProvider).updateUser(u.id, active: !deactivate),
|
|
);
|
|
if (context.mounted) {
|
|
toast(context, deactivate ? 'User deactivated' : 'User activated');
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) toast(context, errText(e));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final mine = shops.where((s) => u.shopIds.contains(s.id)).toList();
|
|
return PaneList(
|
|
maxWidth: _maxW,
|
|
breakpoint: 760,
|
|
startFlex: 1,
|
|
endFlex: 1,
|
|
gap: S.s24,
|
|
start: [
|
|
_Profile(u: u),
|
|
_Heading(
|
|
'Shops',
|
|
action: mine.isEmpty ? 'Assign shops' : 'Edit shops',
|
|
onAction: () => _shopsSheet(context, ref),
|
|
),
|
|
SrsCard(
|
|
padding: EdgeInsets.zero,
|
|
child: mine.isEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.all(S.s16),
|
|
child: Text(
|
|
'No shops yet. ${u.username} cannot see any orders or results until a shop is assigned.',
|
|
style: ts(13, c: C.muted, h: 1.45),
|
|
),
|
|
)
|
|
: Column(
|
|
children: [
|
|
for (var i = 0; i < mine.length; i++) ...[
|
|
if (i > 0) const Divider(height: 1, color: C.border),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: S.s16,
|
|
vertical: S.s12,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
CodeTile(mine[i].code, size: 40),
|
|
const SizedBox(width: S.s12),
|
|
Expanded(
|
|
child: Text(mine[i].name, style: ts(15, w: w6)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
end: [
|
|
const _Heading('Sign-in details'),
|
|
SrsCard(
|
|
padding: EdgeInsets.zero,
|
|
child: Column(
|
|
children: [
|
|
_SettingRow(
|
|
label: 'Username',
|
|
value: u.username,
|
|
onTap: () => _editSheet(context, ref, _Field.username),
|
|
first: true,
|
|
),
|
|
const Divider(height: 1, color: C.border),
|
|
_SettingRow(
|
|
label: 'Phone',
|
|
value: u.phone == null ? 'Not set' : fmtPhone(u.phone),
|
|
muted: u.phone == null,
|
|
onTap: () => _editSheet(context, ref, _Field.phone),
|
|
),
|
|
const Divider(height: 1, color: C.border),
|
|
_SettingRow(
|
|
label: 'Password',
|
|
value: 'Set a new one',
|
|
muted: true,
|
|
onTap: () => _editSheet(context, ref, _Field.password),
|
|
last: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const _Heading('Access'),
|
|
SrsCard(
|
|
padding: EdgeInsets.zero,
|
|
child: Column(
|
|
children: [
|
|
_ToggleRow(
|
|
leading: Icon(
|
|
Icons.key_rounded,
|
|
size: 20,
|
|
color: u.active ? C.green : C.muted,
|
|
),
|
|
title: 'Can sign in',
|
|
description: 'Off blocks the app. Orders stay.',
|
|
value: u.active,
|
|
onChanged: (_) => _toggleActive(context, ref),
|
|
),
|
|
const Divider(height: 1, color: C.border),
|
|
_ResultsRow(
|
|
u: u,
|
|
onAddPhone: () => _editSheet(context, ref, _Field.phone),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: S.s24),
|
|
BigButton(
|
|
'Delete user',
|
|
outline: true,
|
|
danger: true,
|
|
icon: Icons.delete_outline_rounded,
|
|
onPressed: () => _delete(context, ref),
|
|
),
|
|
const SizedBox(height: S.s10),
|
|
Center(
|
|
child: Text(
|
|
'Their saved orders are kept.',
|
|
style: ts(12, c: C.muted),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _editSheet(BuildContext context, WidgetRef ref, _Field f) {
|
|
showSheet<void>(context, (_) => _EditField(user: u, field: f));
|
|
}
|
|
|
|
void _shopsSheet(BuildContext context, WidgetRef ref) {
|
|
showSheet<void>(context, (_) => _ShopsEditor(user: u, shops: shops));
|
|
}
|
|
}
|
|
|
|
class _Profile extends StatelessWidget {
|
|
const _Profile({required this.u});
|
|
final AppUser u;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SrsCard(
|
|
padding: const EdgeInsets.all(S.s20),
|
|
child: Row(
|
|
children: [
|
|
UserAvatar(user: u, size: 68),
|
|
const SizedBox(width: S.s16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
u.username,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: ts(24, w: w7, ls: -0.3),
|
|
),
|
|
const SizedBox(height: S.s8),
|
|
Wrap(
|
|
spacing: S.s10,
|
|
runSpacing: S.s6,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
StatusLabel(active: u.active),
|
|
if (u.phone != null)
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.phone_outlined,
|
|
size: 14,
|
|
color: C.muted,
|
|
),
|
|
const SizedBox(width: S.s4),
|
|
Text(fmtPhone(u.phone), style: ts(13, c: C.muted)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _Heading extends StatelessWidget {
|
|
const _Heading(this.text, {this.action, this.onAction});
|
|
final String text;
|
|
final String? action;
|
|
final VoidCallback? onAction;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(S.s2, S.s22, 0, S.s10),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(text, style: ts(17, w: w7)),
|
|
),
|
|
if (action != null) _LinkButton(action!, onTap: onAction),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// A quiet navy text action with a hover pill, for actions inside a section.
|
|
class _LinkButton extends StatelessWidget {
|
|
const _LinkButton(this.label, {required this.onTap});
|
|
final String label;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Pressable(
|
|
onTap: onTap,
|
|
radius: R.pill,
|
|
builder: (_, hover) => AnimatedContainer(
|
|
duration: const Duration(milliseconds: 140),
|
|
height: 32,
|
|
padding: const EdgeInsets.symmetric(horizontal: S.s12),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: hover ? C.chip : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(R.pill),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.edit_outlined, size: 15, color: C.navy),
|
|
const SizedBox(width: S.s6),
|
|
Text(
|
|
label,
|
|
style: ts(13, w: w6, c: C.navy),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// A setting with an on/off switch: icon, title, an optional short status
|
|
/// line, and the switch.
|
|
class _ToggleRow extends StatelessWidget {
|
|
const _ToggleRow({
|
|
required this.leading,
|
|
required this.title,
|
|
required this.description,
|
|
required this.value,
|
|
required this.onChanged,
|
|
this.status,
|
|
});
|
|
final Widget leading;
|
|
|
|
/// The title, then one short line saying what the setting does.
|
|
final String title, description;
|
|
final Widget? status;
|
|
final bool value;
|
|
|
|
/// Null disables the switch.
|
|
final ValueChanged<bool>? onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(S.s16, S.s12, S.s10, S.s12),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(width: 24, child: Center(child: leading)),
|
|
const SizedBox(width: S.s12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// The switch carries the name for screen readers.
|
|
ExcludeSemantics(child: Text(title, style: ts(15, w: w6))),
|
|
const SizedBox(height: S.s2),
|
|
Text(description, style: ts(12, c: C.muted, h: 1.35)),
|
|
if (status != null) ...[const SizedBox(height: S.s4), status!],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: S.s8),
|
|
Semantics(
|
|
label: title,
|
|
child: Switch(
|
|
value: value,
|
|
onChanged: onChanged,
|
|
activeTrackColor: C.green,
|
|
activeThumbColor: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// "WhatsApp results". srs-api sends a summary for the user's shops after
|
|
/// each draw, only to active users with a phone number; the status line says
|
|
/// where it goes, or why it won't.
|
|
class _ResultsRow extends ConsumerStatefulWidget {
|
|
const _ResultsRow({required this.u, required this.onAddPhone});
|
|
final AppUser u;
|
|
final VoidCallback onAddPhone;
|
|
|
|
@override
|
|
ConsumerState<_ResultsRow> createState() => _ResultsRowState();
|
|
}
|
|
|
|
class _ResultsRowState extends ConsumerState<_ResultsRow> {
|
|
/// The value being saved, shown until the reloaded user agrees.
|
|
bool? _pending;
|
|
|
|
@override
|
|
void didUpdateWidget(_ResultsRow old) {
|
|
super.didUpdateWidget(old);
|
|
if (_pending == widget.u.sendResults) _pending = null;
|
|
}
|
|
|
|
Future<void> _set(bool on) async {
|
|
if (_pending != null) return;
|
|
setState(() => _pending = on);
|
|
try {
|
|
await mutate(
|
|
ref,
|
|
() => ref.read(apiProvider).updateUser(widget.u.id, sendResults: on),
|
|
);
|
|
if (mounted) {
|
|
toast(context, on ? 'WhatsApp results on' : 'WhatsApp results off');
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _pending = null);
|
|
toast(context, errText(e));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final u = widget.u;
|
|
final on = _pending ?? u.sendResults;
|
|
final noPhone = u.phone == null;
|
|
final note = ts(12, w: w6, c: C.ink);
|
|
final Widget? status = noPhone
|
|
? Pressable(
|
|
onTap: widget.onAddPhone,
|
|
radius: R.sm,
|
|
builder: (_, hover) => Text(
|
|
'Add a phone number first',
|
|
style: ts(
|
|
12,
|
|
w: w6,
|
|
c: C.navy,
|
|
).copyWith(decoration: hover ? TextDecoration.underline : null),
|
|
),
|
|
)
|
|
: !on
|
|
? null
|
|
: !u.active
|
|
? Text('Paused while inactive', style: note)
|
|
: null;
|
|
return _ToggleRow(
|
|
leading: WhatsAppLogo(
|
|
size: 20,
|
|
color: on && !noPhone && u.active ? null : C.muted,
|
|
),
|
|
title: 'WhatsApp results',
|
|
description: 'Sends results after each draw.',
|
|
status: status,
|
|
value: on,
|
|
// Turning it on needs a phone number; turning it off never does.
|
|
onChanged: _pending != null || (!on && noPhone) ? null : _set,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A whole-row control: label on top, value below, chevron at the end.
|
|
class _SettingRow extends StatelessWidget {
|
|
const _SettingRow({
|
|
required this.label,
|
|
required this.value,
|
|
required this.onTap,
|
|
this.muted = false,
|
|
this.first = false,
|
|
this.last = false,
|
|
});
|
|
final String label, value;
|
|
final VoidCallback onTap;
|
|
final bool muted, first, last;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Pressable(
|
|
onTap: onTap,
|
|
label: 'Change ${label.toLowerCase()}',
|
|
radius: 0,
|
|
builder: (_, hover) => AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
padding: const EdgeInsets.fromLTRB(S.s16, S.s12, S.s12, S.s12),
|
|
decoration: BoxDecoration(
|
|
color: hover ? C.hover : Colors.transparent,
|
|
borderRadius: BorderRadius.vertical(
|
|
top: Radius.circular(first ? R.card : 0),
|
|
bottom: Radius.circular(last ? R.card : 0),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: ts(12, w: w6, c: C.muted),
|
|
),
|
|
const SizedBox(height: S.s4),
|
|
Text(
|
|
value,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: ts(15, w: w6, c: muted ? C.muted : C.ink),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
'Change',
|
|
style: ts(13, w: w6, c: C.navy),
|
|
),
|
|
const SizedBox(width: S.s2),
|
|
const Icon(Icons.chevron_right_rounded, color: C.placeholder),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
enum _Field { username, phone, password }
|
|
|
|
class _EditField extends ConsumerStatefulWidget {
|
|
const _EditField({required this.user, required this.field});
|
|
final AppUser user;
|
|
final _Field field;
|
|
|
|
@override
|
|
ConsumerState<_EditField> createState() => _EditFieldState();
|
|
}
|
|
|
|
class _EditFieldState extends ConsumerState<_EditField> {
|
|
late final _c = TextEditingController(
|
|
text: switch (widget.field) {
|
|
_Field.username => widget.user.username,
|
|
_Field.phone => (widget.user.phone ?? '').replaceFirst(
|
|
RegExp(r'^91'),
|
|
'',
|
|
),
|
|
_Field.password => '',
|
|
},
|
|
);
|
|
bool _busy = false;
|
|
String? _error;
|
|
|
|
@override
|
|
void dispose() {
|
|
_c.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
setState(() {
|
|
_busy = true;
|
|
_error = null;
|
|
});
|
|
final api = ref.read(apiProvider);
|
|
final id = widget.user.id;
|
|
try {
|
|
await mutate(ref, () {
|
|
switch (widget.field) {
|
|
case _Field.username:
|
|
return api.updateUser(id, username: _c.text);
|
|
case _Field.phone:
|
|
return _c.text.trim().isEmpty
|
|
? api.updateUser(id, clearPhone: true)
|
|
: api.updateUser(id, phone: _c.text);
|
|
case _Field.password:
|
|
return api.updateUser(id, password: _c.text);
|
|
}
|
|
});
|
|
if (!mounted) return;
|
|
Navigator.pop(context);
|
|
toast(context, switch (widget.field) {
|
|
_Field.username => 'Username changed',
|
|
_Field.phone => 'Phone updated',
|
|
_Field.password => 'Password changed',
|
|
});
|
|
} catch (e) {
|
|
if (mounted) setState(() => _error = errText(e));
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final f = widget.field;
|
|
final title = switch (f) {
|
|
_Field.username => 'Change username',
|
|
_Field.phone => 'Change phone',
|
|
_Field.password => 'Set new password',
|
|
};
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: ts(20, w: w7)),
|
|
const SizedBox(height: S.s4),
|
|
Text(
|
|
f == _Field.password
|
|
? 'Users cannot change their own password. Share the new password with ${widget.user.username}.'
|
|
: 'For ${widget.user.username}',
|
|
style: ts(13, c: C.muted),
|
|
),
|
|
const SizedBox(height: S.s20),
|
|
SrsField(
|
|
controller: _c,
|
|
autofocus: true,
|
|
label: switch (f) {
|
|
_Field.username => 'Username',
|
|
_Field.phone => 'Phone',
|
|
_Field.password => 'New password',
|
|
},
|
|
hint: f == _Field.phone ? '90000 12345' : null,
|
|
prefix: f == _Field.phone ? '+91 ' : null,
|
|
password: f == _Field.password,
|
|
keyboard: f == _Field.phone ? TextInputType.phone : null,
|
|
formatters: f == _Field.phone
|
|
? [
|
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9 ]')),
|
|
LengthLimitingTextInputFormatter(12),
|
|
]
|
|
: null,
|
|
helper: f == _Field.password
|
|
? 'At least 8 characters.'
|
|
: f == _Field.phone
|
|
? 'Leave blank to remove the phone number.'
|
|
: null,
|
|
onChanged: (_) => setState(() => _error = null),
|
|
onSubmitted: (_) => _save(),
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: S.s12),
|
|
ErrorLine(_error!),
|
|
],
|
|
const SizedBox(height: S.s20),
|
|
BigButton('Save', loading: _busy, onPressed: _save),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ShopsEditor extends ConsumerStatefulWidget {
|
|
const _ShopsEditor({required this.user, required this.shops});
|
|
final AppUser user;
|
|
final List<Shop> shops;
|
|
|
|
@override
|
|
ConsumerState<_ShopsEditor> createState() => _ShopsEditorState();
|
|
}
|
|
|
|
class _ShopsEditorState extends ConsumerState<_ShopsEditor> {
|
|
late final Set<int> _sel = widget.user.shopIds.toSet();
|
|
bool _busy = false;
|
|
String? _error;
|
|
|
|
Future<void> _save() async {
|
|
setState(() {
|
|
_busy = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
await mutate(
|
|
ref,
|
|
() => ref.read(apiProvider).setUserShops(widget.user.id, _sel.toList()),
|
|
);
|
|
if (!mounted) return;
|
|
Navigator.pop(context);
|
|
toast(context, 'Shops updated');
|
|
} catch (e) {
|
|
if (mounted) setState(() => _error = errText(e));
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Assigned shops', style: ts(20, w: w7)),
|
|
const SizedBox(height: S.s4),
|
|
Text(
|
|
'This user can only view data for the selected shops.',
|
|
style: ts(13, c: C.muted),
|
|
),
|
|
const SizedBox(height: S.s18),
|
|
ShopPicker(
|
|
shops: widget.shops,
|
|
selected: _sel,
|
|
onToggle: (id) => setState(
|
|
() => _sel.contains(id) ? _sel.remove(id) : _sel.add(id),
|
|
),
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: S.s8),
|
|
ErrorLine(_error!),
|
|
],
|
|
const SizedBox(height: S.s14),
|
|
BigButton('Save', loading: _busy, onPressed: _save),
|
|
],
|
|
);
|
|
}
|
|
}
|