716 lines
22 KiB
Dart
716 lines
22 KiB
Dart
import 'package:flutter/material.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/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});
|
|
|
|
@override
|
|
ConsumerState<UserManagementScreen> createState() =>
|
|
_UserManagementScreenState();
|
|
}
|
|
|
|
class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|
static const List<String> _roles = [
|
|
'standard',
|
|
'dispatcher',
|
|
'it_staff',
|
|
'admin',
|
|
];
|
|
|
|
final _fullNameController = TextEditingController();
|
|
final _searchController = TextEditingController();
|
|
|
|
String? _selectedUserId;
|
|
String? _selectedRole;
|
|
Set<String> _selectedOfficeIds = {};
|
|
AdminUserStatus? _selectedStatus;
|
|
bool _isSaving = false;
|
|
bool _isStatusLoading = false;
|
|
final Map<String, AdminUserStatus> _statusCache = {};
|
|
final Set<String> _statusLoading = {};
|
|
final Set<String> _statusErrors = {};
|
|
Set<String> _prefetchedUserIds = {};
|
|
|
|
@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 Center(child: Text('Failed to load data: $error'));
|
|
}
|
|
|
|
final profiles = profilesAsync.valueOrNull ?? [];
|
|
final offices = officesAsync.valueOrNull ?? [];
|
|
final assignments = assignmentsAsync.valueOrNull ?? [];
|
|
final messages = messagesAsync.valueOrNull ?? [];
|
|
|
|
_prefetchStatuses(profiles);
|
|
|
|
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 Center(child: Text('No users found.'));
|
|
}
|
|
|
|
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 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),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
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(
|
|
BuildContext context,
|
|
Profile profile,
|
|
List<Office> offices,
|
|
List<UserOffice> assignments,
|
|
) async {
|
|
await _selectUser(profile);
|
|
final currentOfficeIds = assignments
|
|
.where((assignment) => assignment.userId == profile.id)
|
|
.map((assignment) => assignment.officeId)
|
|
.toSet();
|
|
|
|
if (!context.mounted) return;
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return StatefulBuilder(
|
|
builder: (context, setDialogState) {
|
|
return AlertDialog(
|
|
title: const Text('Update user'),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 520),
|
|
child: SingleChildScrollView(
|
|
child: _buildUserForm(
|
|
context,
|
|
profile,
|
|
offices,
|
|
currentOfficeIds,
|
|
setDialogState,
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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,
|
|
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),
|
|
_buildStatusRow(profile),
|
|
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)
|
|
Column(
|
|
children: offices
|
|
.map(
|
|
(office) => 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: EdgeInsets.zero,
|
|
),
|
|
)
|
|
.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'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusRow(Profile profile) {
|
|
final email = _selectedStatus?.email;
|
|
final isLocked = _selectedStatus?.isLocked ?? false;
|
|
final lockLabel = isLocked ? 'Unlock' : 'Lock';
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Email: ${email ?? 'Loading...'}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: _isStatusLoading
|
|
? null
|
|
: () => _showPasswordResetDialog(profile.id),
|
|
icon: const Icon(Icons.password),
|
|
label: const Text('Reset password'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
OutlinedButton.icon(
|
|
onPressed: _isStatusLoading
|
|
? null
|
|
: () => _toggleLock(profile.id, !isLocked),
|
|
icon: Icon(isLocked ? Icons.lock_open : Icons.lock),
|
|
label: Text(lockLabel),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _selectUser(Profile profile) async {
|
|
setState(() {
|
|
_selectedUserId = profile.id;
|
|
_selectedRole = profile.role;
|
|
_fullNameController.text = profile.fullName;
|
|
_selectedStatus = null;
|
|
_isStatusLoading = true;
|
|
});
|
|
|
|
final assignments = ref.read(userOfficesProvider).valueOrNull ?? [];
|
|
final officeIds = assignments
|
|
.where((assignment) => assignment.userId == profile.id)
|
|
.map((assignment) => assignment.officeId)
|
|
.toSet();
|
|
setState(() => _selectedOfficeIds = officeIds);
|
|
|
|
try {
|
|
final status = await ref
|
|
.read(adminUserControllerProvider)
|
|
.fetchStatus(profile.id);
|
|
if (mounted) {
|
|
setState(() {
|
|
_selectedStatus = status;
|
|
_statusCache[profile.id] = status;
|
|
});
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
setState(
|
|
() =>
|
|
_selectedStatus = AdminUserStatus(email: null, bannedUntil: null),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isStatusLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Full name is required.')));
|
|
return false;
|
|
}
|
|
|
|
if (_selectedOfficeIds.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Select at least one office.')),
|
|
);
|
|
return false;
|
|
}
|
|
|
|
setDialogState(() => _isSaving = true);
|
|
try {
|
|
await ref
|
|
.read(adminUserControllerProvider)
|
|
.updateProfile(userId: profile.id, fullName: fullName, role: role);
|
|
|
|
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;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('User updated.')));
|
|
return true;
|
|
} catch (error) {
|
|
if (!context.mounted) return false;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Update failed: $error')));
|
|
return false;
|
|
} finally {
|
|
setDialogState(() => _isSaving = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _showPasswordResetDialog(String userId) async {
|
|
final controller = TextEditingController();
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
await showDialog<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;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Password updated.')),
|
|
);
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Reset failed: $error')),
|
|
);
|
|
}
|
|
},
|
|
child: const Text('Update password'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _toggleLock(String userId, bool locked) async {
|
|
setState(() => _isStatusLoading = true);
|
|
try {
|
|
await ref
|
|
.read(adminUserControllerProvider)
|
|
.setLock(userId: userId, locked: locked);
|
|
final status = await ref
|
|
.read(adminUserControllerProvider)
|
|
.fetchStatus(userId);
|
|
if (!mounted) return;
|
|
setState(() => _selectedStatus = status);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(locked ? 'User locked.' : 'User unlocked.')),
|
|
);
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Lock update failed: $error')));
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isStatusLoading = false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|