srs/lib/features/users/users_screen.dart
Aswin Biju 125f4d3e86
All checks were successful
deploy / deploy (push) Successful in 5m18s
feat: loading skeletons, pull-to-refresh and reload bar on list screens
2026-11-05 08:04:55 +05:30

569 lines
17 KiB
Dart

import 'package:flutter/material.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 'user_bits.dart';
enum _Filter { all, active, off }
class UsersScreen extends ConsumerStatefulWidget {
const UsersScreen({super.key});
@override
ConsumerState<UsersScreen> createState() => _UsersScreenState();
}
class _UsersScreenState extends ConsumerState<UsersScreen> {
_Filter _filter = _Filter.all;
String _q = '';
bool _keep(AppUser u) => switch (_filter) {
_Filter.all => true,
_Filter.active => u.active,
_Filter.off => !u.active,
};
@override
Widget build(BuildContext context) {
final users = ref.watch(usersProvider);
final shops = ref.watch(activeShopsProvider).value ?? const <Shop>[];
final byId = {for (final s in shops) s.id: s};
final list = users.value;
final off = list?.where((u) => !u.active).length ?? 0;
Future<void> refresh() => refreshData(context, ref, usersProvider);
return Column(
children: [
ScreenHeader(
title: 'Users',
subtitle: list == null
? null
: off == 0
? plural(list.length, 'user')
: '${plural(list.length, 'user')}, $off inactive',
subtitleLoading: users.isLoading,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isWide(context)) ...[
RefreshButton(onRefresh: refresh),
const SizedBox(width: S.s10),
],
SmallButton('Add user', onTap: () => context.push('/users/new')),
],
),
),
Expanded(
child: RefreshableView(
value: users,
onRefresh: refresh,
skeleton: const _Loading(),
builder: (all) {
final counts = {
_Filter.all: all.length,
_Filter.active: all.where((u) => u.active).length,
_Filter.off: all.where((u) => !u.active).length,
};
final shown =
all
.where(_keep)
.where(
(u) =>
_q.isEmpty ||
u.username.toLowerCase().contains(_q) ||
(u.phone ?? '').contains(_q),
)
.toList()
// Inactive accounts last.
..sort((a, b) {
final r = (a.active ? 0 : 1).compareTo(b.active ? 0 : 1);
return r != 0 ? r : a.username.compareTo(b.username);
});
return PageList(
top: S.s4,
children: [
_Toolbar(
filter: _filter,
counts: counts,
onFilter: (f) => setState(() => _filter = f),
onSearch: (v) =>
setState(() => _q = v.trim().toLowerCase()),
),
const SizedBox(height: S.s14),
if (shown.isEmpty)
SrsCard(
child: EmptyState(
icon: Icons.person_search_rounded,
title: _q.isNotEmpty
? 'No one matches “$_q”'
: 'No one here',
body: _filter == _Filter.off
? 'Every user can sign in.'
: all.isEmpty
? 'Add a user to let them sign in and see their shops.'
: 'Try another filter, or add a user.',
),
)
else
_Roster(users: shown, shops: byId),
],
);
},
),
),
],
);
}
}
class _Loading extends StatelessWidget {
const _Loading();
static const _names = [0.5, 0.36, 0.58, 0.42, 0.52, 0.32];
@override
Widget build(BuildContext context) {
final search = Container(
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(R.input),
border: Border.all(color: C.border),
),
padding: const EdgeInsets.symmetric(horizontal: S.s14),
alignment: Alignment.centerLeft,
child: const Bone(width: 150, height: 10),
);
const chips = Row(
mainAxisSize: MainAxisSize.min,
children: [
Bone(width: 66, height: 38, radius: R.pill),
SizedBox(width: S.s8),
Bone(width: 92, height: 38, radius: R.pill),
SizedBox(width: S.s8),
Bone(width: 104, height: 38, radius: R.pill),
],
);
return SkeletonList(
children: [
LayoutBuilder(
builder: (context, c) => c.maxWidth < 760
? Column(
children: [
search,
const SizedBox(height: S.s12),
chips,
],
)
: Row(
children: [
const Expanded(child: chips),
const SizedBox(width: S.s16),
SizedBox(width: 320, child: search),
],
),
),
const SizedBox(height: S.s14),
LayoutBuilder(
builder: (context, c) {
final wide = c.maxWidth >= 720;
return SrsCard(
padding: EdgeInsets.zero,
child: Column(
children: [
if (wide)
const Padding(
padding: EdgeInsets.fromLTRB(S.s20, S.s14, S.s20, S.s12),
child: SizedBox(
height: 18,
child: Align(
alignment: Alignment.centerLeft,
child: Bone(width: 44, height: 10),
),
),
),
for (var i = 0; i < _names.length; i++) ...[
if (i > 0 || wide)
const Divider(height: 1, color: C.border),
_row(wide, _names[i]),
],
],
),
);
},
),
],
);
}
static Widget _line(double h, Widget bone) => SizedBox(
height: h,
child: Align(alignment: Alignment.centerLeft, child: bone),
);
Widget _row(bool wide, double name) {
final who = Row(
children: [
const SizedBox(
width: 44,
height: 44,
child: Align(
alignment: Alignment.topLeft,
child: Bone(width: 42, height: 42, radius: 42 * 0.3),
),
),
const SizedBox(width: S.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_line(20, Bone(factor: wide ? name + 0.2 : name, height: 14)),
if (!wide) ...[
const SizedBox(height: S.s4),
_line(16, const Bone(width: 110, height: 10)),
const SizedBox(height: S.s8),
_line(28, const Bone(width: 86, height: 24, radius: R.sm)),
],
],
),
),
],
);
return Padding(
padding: EdgeInsets.symmetric(
horizontal: wide ? S.s20 : S.s14,
vertical: S.s12,
),
child: wide
? Row(
children: [
Expanded(flex: 3, child: who),
const SizedBox(
width: _phoneCol,
child: Align(
alignment: Alignment.centerLeft,
child: Bone(width: 104, height: 11),
),
),
const Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerLeft,
child: Bone(width: 96, height: 18, radius: R.pill),
),
),
const SizedBox(
width: _statusCol,
child: Align(
alignment: Alignment.centerLeft,
child: Bone(width: 60, height: 11),
),
),
const SizedBox(width: 28),
],
)
: who,
);
}
}
class _Toolbar extends StatelessWidget {
const _Toolbar({
required this.filter,
required this.counts,
required this.onFilter,
required this.onSearch,
});
final _Filter filter;
final Map<_Filter, int> counts;
final ValueChanged<_Filter> onFilter;
final ValueChanged<String> onSearch;
static const _labels = {
_Filter.all: 'All',
_Filter.active: 'Active',
_Filter.off: 'Inactive',
};
@override
Widget build(BuildContext context) {
final chips = SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final f in _Filter.values)
if (f == _Filter.all || counts[f]! > 0)
Padding(
padding: const EdgeInsets.only(right: S.s8),
child: _FilterChip(
label: _labels[f]!,
count: counts[f]!,
on: f == filter,
onTap: () => onFilter(f),
),
),
],
),
);
final search = SearchField(
hint: 'Search by name or phone',
onChanged: onSearch,
);
return LayoutBuilder(
builder: (context, c) => c.maxWidth < 760
? Column(
children: [
search,
const SizedBox(height: S.s12),
chips,
],
)
: Row(
children: [
Expanded(child: chips),
const SizedBox(width: S.s16),
SizedBox(width: 320, child: search),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
const _FilterChip({
required this.label,
required this.count,
required this.on,
required this.onTap,
});
final String label;
final int count;
final bool on;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Semantics(
selected: on,
child: Pressable(
onTap: onTap,
radius: R.pill,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 38,
padding: const EdgeInsets.fromLTRB(S.s14, 0, S.s8, 0),
decoration: BoxDecoration(
color: on ? C.navy : (hover ? C.hover : Colors.white),
borderRadius: BorderRadius.circular(R.pill),
border: Border.all(
color: on ? C.navy : (hover ? C.border2 : C.border),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: ts(13, w: w6, c: on ? Colors.white : C.ink),
),
const SizedBox(width: S.s6),
Container(
constraints: const BoxConstraints(minWidth: 22),
height: 22,
padding: const EdgeInsets.symmetric(horizontal: S.s6),
alignment: Alignment.center,
decoration: BoxDecoration(
color: on ? C.onNavy20 : C.chip,
borderRadius: BorderRadius.circular(R.pill),
),
child: Text(
'$count',
style: ts(12, w: w7, c: on ? Colors.white : C.navy),
),
),
],
),
),
),
);
}
/// One card holding every row: a table with column headings on wide windows,
/// a compact list on phones.
class _Roster extends StatelessWidget {
const _Roster({required this.users, required this.shops});
final List<AppUser> users;
final Map<int, Shop> shops;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, c) {
final wide = c.maxWidth >= 720;
return SrsCard(
padding: EdgeInsets.zero,
child: Column(
children: [
if (wide) const _HeadingRow(),
for (var i = 0; i < users.length; i++) ...[
if (i > 0 || wide) const Divider(height: 1, color: C.border),
_UserRow(
u: users[i],
shops: shops,
wide: wide,
first: i == 0 && !wide,
last: i == users.length - 1,
),
],
],
),
);
},
);
}
const _phoneCol = 150.0;
const _statusCol = 120.0;
class _HeadingRow extends StatelessWidget {
const _HeadingRow();
@override
Widget build(BuildContext context) {
final h = ts(12, w: w6, c: C.muted);
return Padding(
padding: const EdgeInsets.fromLTRB(S.s20, S.s14, S.s20, S.s12),
child: Row(
children: [
Expanded(flex: 3, child: Text('Name', style: h)),
SizedBox(
width: _phoneCol,
child: Text('Phone', style: h),
),
Expanded(flex: 3, child: Text('Shops', style: h)),
SizedBox(
width: _statusCol,
child: Text('Status', style: h),
),
const SizedBox(width: 28),
],
),
);
}
}
class _UserRow extends StatelessWidget {
const _UserRow({
required this.u,
required this.shops,
required this.wide,
required this.first,
required this.last,
});
final AppUser u;
final Map<int, Shop> shops;
final bool wide, first, last;
@override
Widget build(BuildContext context) {
final mine = [
for (final id in u.shopIds)
if (shops[id] != null) shops[id]!,
];
final shopsCell = ShopCodes(shops: mine);
final name = Row(
children: [
UserAvatar(user: u, size: 42),
const SizedBox(width: S.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
u.username,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(15, w: w6, c: u.active ? C.ink : C.muted),
),
),
if (u.getsResults) ...[
const SizedBox(width: S.s6),
const Tooltip(
message: 'Gets results on WhatsApp',
child: WhatsAppLogo(size: 14),
),
],
],
),
if (!wide) ...[
const SizedBox(height: S.s4),
Text(
u.phone == null ? 'No phone' : fmtPhone(u.phone),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ts(12, c: C.muted),
),
const SizedBox(height: S.s8),
shopsCell,
],
],
),
),
],
);
const trailing = Icon(Icons.chevron_right_rounded, color: C.placeholder);
final radius = BorderRadius.vertical(
top: Radius.circular(first ? R.card : 0),
bottom: Radius.circular(last ? R.card : 0),
);
return Pressable(
onTap: () => context.push('/users/${u.id}'),
radius: 0,
builder: (_, hover) => AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: BoxDecoration(
color: hover ? C.hover : Colors.transparent,
borderRadius: radius,
),
padding: EdgeInsets.symmetric(
horizontal: wide ? S.s20 : S.s14,
vertical: S.s12,
),
child: wide
? Row(
children: [
Expanded(flex: 3, child: name),
SizedBox(
width: _phoneCol,
child: Text(
u.phone == null ? '—' : fmtPhone(u.phone),
style: ts(13, c: u.phone == null ? C.placeholder : C.ink),
),
),
Expanded(flex: 3, child: shopsCell),
SizedBox(
width: _statusCol,
child: StatusLabel(active: u.active),
),
SizedBox(width: 28, child: trailing),
],
)
: Row(
children: [
Expanded(child: name),
const SizedBox(width: S.s8),
trailing,
],
),
),
);
}
}