563 lines
17 KiB
Dart
563 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: 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,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|