750 lines
24 KiB
Dart
750 lines
24 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../../theme/m3_motion.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../models/office.dart';
|
|
import '../../models/profile.dart';
|
|
import '../../models/ticket_message.dart';
|
|
import '../../models/user_office.dart';
|
|
import '../../providers/admin_user_provider.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../providers/profile_provider.dart';
|
|
import '../../providers/tickets_provider.dart';
|
|
import '../../theme/app_surfaces.dart';
|
|
import '../../providers/user_offices_provider.dart';
|
|
|
|
import '../../utils/app_time.dart';
|
|
import '../../widgets/app_page_header.dart';
|
|
import '../../widgets/app_state_view.dart';
|
|
import '../../widgets/mono_text.dart';
|
|
import '../../widgets/responsive_body.dart';
|
|
import '../../widgets/tasq_adaptive_list.dart';
|
|
import '../../utils/snackbar.dart';
|
|
|
|
class UserManagementScreen extends ConsumerStatefulWidget {
|
|
const UserManagementScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<UserManagementScreen> createState() =>
|
|
_UserManagementScreenState();
|
|
}
|
|
|
|
class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|
static const List<String> _roles = [
|
|
'standard',
|
|
'dispatcher',
|
|
'it_staff',
|
|
'programmer',
|
|
'admin',
|
|
];
|
|
|
|
static const List<String> _religions = [
|
|
'catholic',
|
|
'islam',
|
|
'protestant',
|
|
'other',
|
|
];
|
|
|
|
final _fullNameController = TextEditingController();
|
|
final _searchController = TextEditingController();
|
|
|
|
String? _selectedUserId;
|
|
String? _selectedRole;
|
|
String _selectedReligion = 'catholic';
|
|
final Set<String> _selectedOfficeIds = {};
|
|
bool _isSaving = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_fullNameController.dispose();
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isAdmin = ref.watch(isAdminProvider);
|
|
final profilesAsync = ref.watch(profilesProvider);
|
|
final officesAsync = ref.watch(officesProvider);
|
|
final assignmentsAsync = ref.watch(userOfficesProvider);
|
|
final messagesAsync = ref.watch(ticketMessagesAllProvider);
|
|
|
|
return ResponsiveBody(
|
|
maxWidth: double.infinity,
|
|
child: !isAdmin
|
|
? const Center(child: Text('Admin access required.'))
|
|
: _buildContent(
|
|
context,
|
|
profilesAsync,
|
|
officesAsync,
|
|
assignmentsAsync,
|
|
messagesAsync,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(
|
|
BuildContext context,
|
|
AsyncValue<List<Profile>> profilesAsync,
|
|
AsyncValue<List<Office>> officesAsync,
|
|
AsyncValue<List<UserOffice>> assignmentsAsync,
|
|
AsyncValue<List<TicketMessage>> messagesAsync,
|
|
) {
|
|
if (profilesAsync.isLoading ||
|
|
officesAsync.isLoading ||
|
|
assignmentsAsync.isLoading ||
|
|
messagesAsync.isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (profilesAsync.hasError ||
|
|
officesAsync.hasError ||
|
|
assignmentsAsync.hasError ||
|
|
messagesAsync.hasError) {
|
|
final error =
|
|
profilesAsync.error ??
|
|
officesAsync.error ??
|
|
assignmentsAsync.error ??
|
|
messagesAsync.error ??
|
|
'Unknown error';
|
|
return AppErrorView(
|
|
error: error,
|
|
onRetry: () {
|
|
ref.invalidate(profilesProvider);
|
|
ref.invalidate(officesProvider);
|
|
ref.invalidate(userOfficesProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
final profiles = profilesAsync.valueOrNull ?? [];
|
|
final offices = officesAsync.valueOrNull ?? [];
|
|
final assignments = assignmentsAsync.valueOrNull ?? [];
|
|
final messages = messagesAsync.valueOrNull ?? [];
|
|
|
|
final lastActiveByUser = <String, DateTime>{};
|
|
for (final message in messages) {
|
|
final senderId = message.senderId;
|
|
if (senderId == null) continue;
|
|
final current = lastActiveByUser[senderId];
|
|
if (current == null || message.createdAt.isAfter(current)) {
|
|
lastActiveByUser[senderId] = message.createdAt;
|
|
}
|
|
}
|
|
|
|
if (profiles.isEmpty) {
|
|
return const AppEmptyView(
|
|
icon: Icons.people_outline,
|
|
title: 'No users found',
|
|
subtitle: 'Users who sign up will appear here.',
|
|
);
|
|
}
|
|
|
|
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) {
|
|
officeCountByUser.update(
|
|
assignment.userId,
|
|
(value) => value + 1,
|
|
ifAbsent: () => 1,
|
|
);
|
|
}
|
|
|
|
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 statusAsync = ref.watch(adminUserStatusProvider(profile.id));
|
|
return statusAsync.when(
|
|
data: (s) => Text(s.email ?? 'Unknown'),
|
|
loading: () => const Text('Loading...'),
|
|
error: (error, stack) => const Text('Unknown'),
|
|
);
|
|
},
|
|
),
|
|
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 statusAsync = ref.watch(adminUserStatusProvider(profile.id));
|
|
return statusAsync.when(
|
|
data: (s) {
|
|
final statusLabel = s.isLocked ? 'Locked' : 'Active';
|
|
return _StatusBadge(label: statusLabel);
|
|
},
|
|
loading: () => _StatusBadge(label: 'Loading'),
|
|
error: (error, stack) => _StatusBadge(label: 'Unknown'),
|
|
);
|
|
},
|
|
),
|
|
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 statusAsync = ref.watch(adminUserStatusProvider(profile.id));
|
|
final officesAssigned = officeCountByUser[profile.id] ?? 0;
|
|
final lastActive = lastActiveByUser[profile.id];
|
|
|
|
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}'),
|
|
statusAsync.when(
|
|
data: (s) => Text('Email: ${s.email ?? 'Unknown'}'),
|
|
loading: () => const Text('Email: Loading...'),
|
|
error: (error, stack) => const Text('Email: Unknown'),
|
|
),
|
|
],
|
|
),
|
|
trailing: statusAsync.when(
|
|
data: (s) =>
|
|
_StatusBadge(label: s.isLocked ? 'Locked' : 'Active'),
|
|
loading: () => _StatusBadge(label: 'Loading'),
|
|
error: (error, stack) => _StatusBadge(label: 'Unknown'),
|
|
),
|
|
onTap: () =>
|
|
_showUserDialog(context, profile, offices, assignments),
|
|
),
|
|
);
|
|
},
|
|
onRequestRefresh: () {
|
|
// For server-side pagination, update the query provider
|
|
ref.read(adminUserQueryProvider.notifier).state = const AdminUserQuery(
|
|
offset: 0,
|
|
limit: 50,
|
|
);
|
|
},
|
|
onPageChanged: (firstRow) {
|
|
ref
|
|
.read(adminUserQueryProvider.notifier)
|
|
.update((q) => q.copyWith(offset: firstRow));
|
|
},
|
|
isLoading: false,
|
|
);
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const AppPageHeader(
|
|
title: 'User Management',
|
|
subtitle: 'Manage user roles and office assignments',
|
|
),
|
|
Expanded(child: listBody),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _showUserDialog(
|
|
BuildContext context,
|
|
Profile profile,
|
|
List<Office> offices,
|
|
List<UserOffice> assignments,
|
|
) async {
|
|
final currentOfficeIds = assignments
|
|
.where((assignment) => assignment.userId == profile.id)
|
|
.map((assignment) => assignment.officeId)
|
|
.toSet();
|
|
|
|
// Populate dialog-backed state so form fields reflect the selected user.
|
|
if (mounted) {
|
|
setState(() {
|
|
_selectedUserId = profile.id;
|
|
_selectedRole = profile.role;
|
|
_selectedReligion = profile.religion;
|
|
_fullNameController.text = profile.fullName;
|
|
_selectedOfficeIds
|
|
..clear()
|
|
..addAll(currentOfficeIds);
|
|
});
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
await m3ShowDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return StatefulBuilder(
|
|
builder: (context, setDialogState) {
|
|
return AlertDialog(
|
|
shape: AppSurfaces.of(context).dialogShape,
|
|
title: const Text('Update user'),
|
|
content: SizedBox(
|
|
width: 520,
|
|
child: SingleChildScrollView(
|
|
child: _buildUserForm(
|
|
context,
|
|
profile,
|
|
offices,
|
|
currentOfficeIds,
|
|
setDialogState,
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
// Clear the temporary selection state after the dialog is closed so the
|
|
// next dialog starts from a clean slate.
|
|
if (mounted) {
|
|
setState(() {
|
|
_selectedUserId = null;
|
|
_selectedRole = null;
|
|
_selectedReligion = 'catholic';
|
|
_selectedOfficeIds.clear();
|
|
_fullNameController.clear();
|
|
});
|
|
}
|
|
}
|
|
|
|
Widget _buildUserForm(
|
|
BuildContext context,
|
|
Profile profile,
|
|
List<Office> offices,
|
|
Set<String> currentOfficeIds,
|
|
StateSetter setDialogState,
|
|
) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextFormField(
|
|
controller: _fullNameController,
|
|
decoration: const InputDecoration(labelText: 'Full name'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
key: ValueKey('role_${_selectedUserId ?? 'none'}'),
|
|
initialValue: _selectedRole,
|
|
items: _roles
|
|
.map((role) => DropdownMenuItem(value: role, child: Text(role)))
|
|
.toList(),
|
|
onChanged: (value) => setDialogState(() => _selectedRole = value),
|
|
decoration: const InputDecoration(labelText: 'Role'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
key: ValueKey('religion_${_selectedUserId ?? 'none'}'),
|
|
initialValue: _selectedReligion,
|
|
items: _religions
|
|
.map(
|
|
(r) => DropdownMenuItem(
|
|
value: r,
|
|
child: Text(r[0].toUpperCase() + r.substring(1)),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (value) =>
|
|
setDialogState(() => _selectedReligion = value ?? 'catholic'),
|
|
decoration: const InputDecoration(labelText: 'Religion'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Email and lock status are retrieved from auth via Edge Function / admin API.
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final statusAsync = ref.watch(adminUserStatusProvider(profile.id));
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
statusAsync.when(
|
|
data: (s) => Text(
|
|
'Email: ${s.email ?? 'Unknown'}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
loading: () => Text(
|
|
'Email: Loading...',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
error: (error, stack) => Text(
|
|
'Email: Unknown',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: _isSaving
|
|
? null
|
|
: () => _showPasswordResetDialog(profile.id),
|
|
icon: const Icon(Icons.password),
|
|
label: const Text('Reset password'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
statusAsync.when(
|
|
data: (s) => OutlinedButton.icon(
|
|
onPressed: _isSaving
|
|
? null
|
|
: () => _toggleLock(profile.id, !s.isLocked),
|
|
icon: Icon(s.isLocked ? Icons.lock_open : Icons.lock),
|
|
label: Text(s.isLocked ? 'Unlock' : 'Lock'),
|
|
),
|
|
loading: () => OutlinedButton.icon(
|
|
onPressed: null,
|
|
icon: const Icon(Icons.lock),
|
|
label: const Text('Loading...'),
|
|
),
|
|
error: (error, stack) => OutlinedButton.icon(
|
|
onPressed: _isSaving
|
|
? null
|
|
: () => _toggleLock(profile.id, true),
|
|
icon: const Icon(Icons.lock),
|
|
label: const Text('Lock'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
Text('Offices', style: Theme.of(context).textTheme.titleSmall),
|
|
const SizedBox(height: 8),
|
|
if (offices.isEmpty) const Text('No offices available.'),
|
|
if (offices.isNotEmpty)
|
|
Container(
|
|
height: 240,
|
|
clipBehavior: Clip.hardEdge,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Theme.of(context).dividerColor),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
children: offices.map((office) {
|
|
return CheckboxListTile(
|
|
value: _selectedOfficeIds.contains(office.id),
|
|
onChanged: _isSaving
|
|
? null
|
|
: (selected) {
|
|
setDialogState(() {
|
|
if (selected == true) {
|
|
_selectedOfficeIds.add(office.id);
|
|
} else {
|
|
_selectedOfficeIds.remove(office.id);
|
|
}
|
|
});
|
|
},
|
|
title: Text(office.name),
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: _isSaving
|
|
? null
|
|
: () async {
|
|
final saved = await _saveChanges(
|
|
context,
|
|
profile,
|
|
currentOfficeIds,
|
|
setDialogState,
|
|
);
|
|
if (saved && context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: _isSaving
|
|
? const SizedBox(
|
|
height: 18,
|
|
width: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Save changes'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<bool> _saveChanges(
|
|
BuildContext context,
|
|
Profile profile,
|
|
Set<String> currentOfficeIds,
|
|
StateSetter setDialogState,
|
|
) async {
|
|
final role = _selectedRole ?? profile.role;
|
|
final fullName = _fullNameController.text.trim();
|
|
if (fullName.isEmpty) {
|
|
showWarningSnackBar(context, 'Full name is required.');
|
|
return false;
|
|
}
|
|
|
|
if (_selectedOfficeIds.isEmpty) {
|
|
showWarningSnackBar(context, 'Select at least one office.');
|
|
return false;
|
|
}
|
|
|
|
setDialogState(() => _isSaving = true);
|
|
try {
|
|
await ref
|
|
.read(adminUserControllerProvider)
|
|
.updateProfile(
|
|
userId: profile.id,
|
|
fullName: fullName,
|
|
role: role,
|
|
religion: _selectedReligion,
|
|
);
|
|
|
|
final toAdd = _selectedOfficeIds.difference(currentOfficeIds);
|
|
final toRemove = currentOfficeIds.difference(_selectedOfficeIds);
|
|
final controller = ref.read(userOfficesControllerProvider);
|
|
|
|
for (final officeId in toAdd) {
|
|
await controller.assignUserOffice(
|
|
userId: profile.id,
|
|
officeId: officeId,
|
|
);
|
|
}
|
|
|
|
for (final officeId in toRemove) {
|
|
await controller.removeUserOffice(
|
|
userId: profile.id,
|
|
officeId: officeId,
|
|
);
|
|
}
|
|
|
|
ref.invalidate(profilesProvider);
|
|
ref.invalidate(userOfficesProvider);
|
|
|
|
if (!context.mounted) return true;
|
|
showSuccessSnackBar(context, 'User "$fullName" updated successfully.');
|
|
return true;
|
|
} catch (error) {
|
|
if (!context.mounted) return false;
|
|
showErrorSnackBar(context, 'Update failed: $error');
|
|
return false;
|
|
} finally {
|
|
setDialogState(() => _isSaving = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _showPasswordResetDialog(String userId) async {
|
|
final controller = TextEditingController();
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
await m3ShowDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
title: const Text('Set temporary password'),
|
|
content: Form(
|
|
key: formKey,
|
|
child: TextFormField(
|
|
controller: controller,
|
|
decoration: const InputDecoration(labelText: 'New password'),
|
|
obscureText: true,
|
|
validator: (value) {
|
|
if (value == null || value.trim().length < 8) {
|
|
return 'Use at least 8 characters.';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
if (!formKey.currentState!.validate()) return;
|
|
try {
|
|
await ref
|
|
.read(adminUserControllerProvider)
|
|
.setPassword(
|
|
userId: userId,
|
|
password: controller.text.trim(),
|
|
);
|
|
if (!dialogContext.mounted) return;
|
|
Navigator.of(dialogContext).pop();
|
|
if (!mounted) return;
|
|
showSuccessSnackBar(context, 'Password updated.');
|
|
} catch (error) {
|
|
final msg = error.toString();
|
|
if (msg.contains('Unauthorized') ||
|
|
msg.contains('bad_jwt') ||
|
|
msg.contains('expired')) {
|
|
await ref.read(authControllerProvider).signOut();
|
|
if (!mounted) return;
|
|
showInfoSnackBar(
|
|
context,
|
|
'Session expired — please sign in again.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
showErrorSnackBar(context, 'Reset failed: $error');
|
|
}
|
|
},
|
|
child: const Text('Update password'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _toggleLock(String userId, bool locked) async {
|
|
setState(() => _isSaving = true);
|
|
try {
|
|
// Use AdminUserController (Edge Function or direct DB) to lock/unlock.
|
|
await ref
|
|
.read(adminUserControllerProvider)
|
|
.setLock(userId: userId, locked: locked);
|
|
|
|
// Refresh profile streams so other UI updates observe the change.
|
|
ref.invalidate(profilesProvider);
|
|
ref.invalidate(currentProfileProvider);
|
|
|
|
if (!mounted) return;
|
|
showInfoSnackBar(
|
|
context,
|
|
locked ? 'User locked (app-level).' : 'User unlocked (app-level).',
|
|
);
|
|
} catch (error) {
|
|
final msg = error.toString();
|
|
if (msg.contains('Unauthorized') ||
|
|
msg.contains('bad_jwt') ||
|
|
msg.contains('expired')) {
|
|
await ref.read(authControllerProvider).signOut();
|
|
if (!mounted) return;
|
|
showInfoSnackBar(context, 'Session expired — please sign in again.');
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
showErrorSnackBar(context, 'Lock update failed: $error');
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isSaving = false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
String _formatLastActiveLabel(DateTime? value) {
|
|
if (value == null) return 'N/A';
|
|
final now = AppTime.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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|