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 []; 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 shops; Future _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 _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(context, (_) => _EditField(user: u, field: f)); } void _shopsSheet(BuildContext context, WidgetRef ref) { showSheet(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? 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 _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 _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 shops; @override ConsumerState<_ShopsEditor> createState() => _ShopsEditorState(); } class _ShopsEditorState extends ConsumerState<_ShopsEditor> { late final Set _sel = widget.user.shopIds.toSet(); bool _busy = false; String? _error; Future _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), ], ); } }