Major UI overhaul
This commit is contained in:
@@ -9,7 +9,9 @@ import '../../providers/admin_user_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/user_offices_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
|
||||
class UserManagementScreen extends ConsumerStatefulWidget {
|
||||
const UserManagementScreen({super.key});
|
||||
@@ -28,6 +30,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
];
|
||||
|
||||
final _fullNameController = TextEditingController();
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
String? _selectedUserId;
|
||||
String? _selectedRole;
|
||||
@@ -43,6 +46,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_fullNameController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -54,19 +58,17 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
final assignmentsAsync = ref.watch(userOfficesProvider);
|
||||
final messagesAsync = ref.watch(ticketMessagesAllProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: ResponsiveBody(
|
||||
maxWidth: 1080,
|
||||
child: !isAdmin
|
||||
? const Center(child: Text('Admin access required.'))
|
||||
: _buildContent(
|
||||
context,
|
||||
profilesAsync,
|
||||
officesAsync,
|
||||
assignmentsAsync,
|
||||
messagesAsync,
|
||||
),
|
||||
),
|
||||
return ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: !isAdmin
|
||||
? const Center(child: Text('Admin access required.'))
|
||||
: _buildContent(
|
||||
context,
|
||||
profilesAsync,
|
||||
officesAsync,
|
||||
assignmentsAsync,
|
||||
messagesAsync,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,45 +116,19 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'User Management',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: _buildUserTable(
|
||||
context,
|
||||
profiles,
|
||||
offices,
|
||||
assignments,
|
||||
lastActiveByUser,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserTable(
|
||||
BuildContext context,
|
||||
List<Profile> profiles,
|
||||
List<Office> offices,
|
||||
List<UserOffice> assignments,
|
||||
Map<String, DateTime> lastActiveByUser,
|
||||
) {
|
||||
if (profiles.isEmpty) {
|
||||
return const Center(child: Text('No users found.'));
|
||||
}
|
||||
final officeNameById = {
|
||||
for (final office in offices) office.id: office.name,
|
||||
};
|
||||
|
||||
final query = _searchController.text.trim().toLowerCase();
|
||||
final filteredProfiles = query.isEmpty
|
||||
? profiles
|
||||
: profiles.where((profile) {
|
||||
final label =
|
||||
profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
||||
return label.toLowerCase().contains(query) ||
|
||||
profile.id.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
|
||||
final officeCountByUser = <String, int>{};
|
||||
for (final assignment in assignments) {
|
||||
@@ -163,140 +139,127 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 720),
|
||||
child: SingleChildScrollView(
|
||||
child: DataTable(
|
||||
headingRowHeight: 46,
|
||||
dataRowMinHeight: 48,
|
||||
dataRowMaxHeight: 64,
|
||||
columnSpacing: 24,
|
||||
horizontalMargin: 16,
|
||||
dividerThickness: 1,
|
||||
headingRowColor: WidgetStateProperty.resolveWith(
|
||||
(states) =>
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('User')),
|
||||
DataColumn(label: Text('Email')),
|
||||
DataColumn(label: Text('Role')),
|
||||
DataColumn(label: Text('Offices')),
|
||||
DataColumn(label: Text('Status')),
|
||||
DataColumn(label: Text('Last active')),
|
||||
],
|
||||
rows: profiles.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final profile = entry.value;
|
||||
final label = profile.fullName.isEmpty
|
||||
? profile.id
|
||||
: profile.fullName;
|
||||
final status = _statusCache[profile.id];
|
||||
final hasError = _statusErrors.contains(profile.id);
|
||||
final isLoading = _statusLoading.contains(profile.id);
|
||||
final email = hasError
|
||||
? 'Unavailable'
|
||||
: (status?.email ?? (isLoading ? 'Loading...' : 'N/A'));
|
||||
final statusLabel = hasError
|
||||
? 'Unavailable'
|
||||
: (status == null
|
||||
? (isLoading ? 'Loading...' : 'Unknown')
|
||||
: (status.isLocked ? 'Locked' : 'Active'));
|
||||
final officeCount = officeCountByUser[profile.id] ?? 0;
|
||||
final officeLabel = officeCount == 0 ? 'None' : '$officeCount';
|
||||
final officeNames = assignments
|
||||
.where((assignment) => assignment.userId == profile.id)
|
||||
.map(
|
||||
(assignment) =>
|
||||
officeNameById[assignment.officeId] ??
|
||||
assignment.officeId,
|
||||
)
|
||||
.toList();
|
||||
final officesText = officeNames.isEmpty
|
||||
? 'No offices'
|
||||
: officeNames.join(', ');
|
||||
final lastActive = _formatLastActive(
|
||||
lastActiveByUser[profile.id]?.toLocal(),
|
||||
);
|
||||
|
||||
return DataRow.byIndex(
|
||||
index: index,
|
||||
onSelectChanged: (selected) {
|
||||
if (selected != true) return;
|
||||
_showUserDialog(context, profile, offices, assignments);
|
||||
},
|
||||
color: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceTint.withValues(alpha: 0.12);
|
||||
}
|
||||
if (index.isEven) {
|
||||
return Theme.of(
|
||||
context,
|
||||
).colorScheme.surface.withValues(alpha: 0.6);
|
||||
}
|
||||
return Theme.of(context).colorScheme.surface;
|
||||
}),
|
||||
cells: [
|
||||
DataCell(Text(label)),
|
||||
DataCell(Text(email)),
|
||||
DataCell(Text(profile.role)),
|
||||
DataCell(
|
||||
Tooltip(message: officesText, child: Text(officeLabel)),
|
||||
),
|
||||
DataCell(Text(statusLabel)),
|
||||
DataCell(Text(lastActive)),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
final listBody = TasQAdaptiveList<Profile>(
|
||||
items: filteredProfiles,
|
||||
filterHeader: SizedBox(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Search name',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
columns: [
|
||||
TasQColumn<Profile>(
|
||||
header: 'User',
|
||||
cellBuilder: (context, profile) {
|
||||
final label =
|
||||
profile.fullName.isEmpty ? profile.id : profile.fullName;
|
||||
return Text(label);
|
||||
},
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Email',
|
||||
cellBuilder: (context, profile) {
|
||||
final status = _statusCache[profile.id];
|
||||
final hasError = _statusErrors.contains(profile.id);
|
||||
final email =
|
||||
hasError ? 'Unavailable' : (status?.email ?? 'Unknown');
|
||||
return Text(email);
|
||||
},
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Role',
|
||||
cellBuilder: (context, profile) => Text(profile.role),
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Offices',
|
||||
cellBuilder: (context, profile) {
|
||||
final officesAssigned = officeCountByUser[profile.id] ?? 0;
|
||||
return Text(officesAssigned == 0 ? 'None' : '$officesAssigned');
|
||||
},
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Status',
|
||||
cellBuilder: (context, profile) {
|
||||
final status = _statusCache[profile.id];
|
||||
final hasError = _statusErrors.contains(profile.id);
|
||||
final isLoading = _statusLoading.contains(profile.id);
|
||||
final statusLabel =
|
||||
_userStatusLabel(status, hasError, isLoading);
|
||||
return _StatusBadge(label: statusLabel);
|
||||
},
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Last active',
|
||||
cellBuilder: (context, profile) {
|
||||
final lastActive = lastActiveByUser[profile.id];
|
||||
return Text(_formatLastActiveLabel(lastActive));
|
||||
},
|
||||
),
|
||||
],
|
||||
onRowTap: (profile) =>
|
||||
_showUserDialog(context, profile, offices, assignments),
|
||||
mobileTileBuilder: (context, profile, actions) {
|
||||
final label =
|
||||
profile.fullName.isEmpty ? profile.id : profile.fullName;
|
||||
final status = _statusCache[profile.id];
|
||||
final hasError = _statusErrors.contains(profile.id);
|
||||
final isLoading = _statusLoading.contains(profile.id);
|
||||
final email = hasError ? 'Unavailable' : (status?.email ?? 'Unknown');
|
||||
final officesAssigned = officeCountByUser[profile.id] ?? 0;
|
||||
final lastActive = lastActiveByUser[profile.id];
|
||||
final statusLabel = _userStatusLabel(status, hasError, isLoading);
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Text(label),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 2),
|
||||
Text('Role: ${profile.role}'),
|
||||
Text('Offices: $officesAssigned'),
|
||||
Text('Last active: ${_formatLastActiveLabel(lastActive)}'),
|
||||
const SizedBox(height: 4),
|
||||
MonoText('ID ${profile.id}'),
|
||||
Text('Email: $email'),
|
||||
],
|
||||
),
|
||||
trailing: _StatusBadge(label: statusLabel),
|
||||
onTap: () =>
|
||||
_showUserDialog(context, profile, offices, assignments),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _ensureStatusLoaded(String userId) {
|
||||
if (_statusCache.containsKey(userId) || _statusLoading.contains(userId)) {
|
||||
return;
|
||||
}
|
||||
_statusLoading.add(userId);
|
||||
_statusErrors.remove(userId);
|
||||
ref
|
||||
.read(adminUserControllerProvider)
|
||||
.fetchStatus(userId)
|
||||
.then((status) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_statusCache[userId] = status;
|
||||
_statusLoading.remove(userId);
|
||||
});
|
||||
})
|
||||
.catchError((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_statusLoading.remove(userId);
|
||||
_statusErrors.add(userId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _prefetchStatuses(List<Profile> profiles) {
|
||||
final ids = profiles.map((profile) => profile.id).toSet();
|
||||
final missing = ids.difference(_prefetchedUserIds);
|
||||
if (missing.isEmpty) return;
|
||||
_prefetchedUserIds = ids;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
for (final userId in missing) {
|
||||
_ensureStatusLoaded(userId);
|
||||
}
|
||||
});
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'User Management',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: listBody),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showUserDialog(
|
||||
@@ -344,6 +307,44 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _ensureStatusLoaded(String userId) {
|
||||
if (_statusCache.containsKey(userId) || _statusLoading.contains(userId)) {
|
||||
return;
|
||||
}
|
||||
_statusLoading.add(userId);
|
||||
_statusErrors.remove(userId);
|
||||
ref
|
||||
.read(adminUserControllerProvider)
|
||||
.fetchStatus(userId)
|
||||
.then((status) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_statusCache[userId] = status;
|
||||
_statusLoading.remove(userId);
|
||||
});
|
||||
})
|
||||
.catchError((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_statusLoading.remove(userId);
|
||||
_statusErrors.add(userId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _prefetchStatuses(List<Profile> profiles) {
|
||||
final ids = profiles.map((profile) => profile.id).toSet();
|
||||
final missing = ids.difference(_prefetchedUserIds);
|
||||
if (missing.isEmpty) return;
|
||||
_prefetchedUserIds = ids;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
for (final userId in missing) {
|
||||
_ensureStatusLoaded(userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildUserForm(
|
||||
BuildContext context,
|
||||
Profile profile,
|
||||
@@ -657,17 +658,58 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastActive(DateTime? value) {
|
||||
if (value == null) return 'N/A';
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(value);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
|
||||
if (diff.inDays < 1) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||
final month = value.month.toString().padLeft(2, '0');
|
||||
final day = value.day.toString().padLeft(2, '0');
|
||||
return '${value.year}-$month-$day';
|
||||
String _userStatusLabel(
|
||||
AdminUserStatus? status,
|
||||
bool hasError,
|
||||
bool isLoading,
|
||||
) {
|
||||
if (isLoading) return 'Loading';
|
||||
if (hasError) return 'Status error';
|
||||
if (status == null) return 'Unknown';
|
||||
return status.isLocked ? 'Locked' : 'Active';
|
||||
}
|
||||
|
||||
String _formatLastActiveLabel(DateTime? value) {
|
||||
if (value == null) return 'N/A';
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(value);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
|
||||
if (diff.inDays < 1) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||
final month = value.month.toString().padLeft(2, '0');
|
||||
final day = value.day.toString().padLeft(2, '0');
|
||||
return '${value.year}-$month-$day';
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isError = label.toLowerCase().contains('error');
|
||||
final background = isError
|
||||
? scheme.errorContainer
|
||||
: scheme.secondaryContainer;
|
||||
final foreground = isError
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSecondaryContainer;
|
||||
|
||||
return Badge(
|
||||
backgroundColor: background,
|
||||
label: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user