srs/lib/features/users/user_detail_screen.dart
Aswin Biju 749d18192a
All checks were successful
deploy / deploy (push) Successful in 4m58s
refactor: updated UI and other trivial fixes
2026-11-05 10:13:32 +05:30

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