import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:go_router/go_router.dart'; import '../../models/notification_item.dart'; import '../../models/office.dart'; import '../../models/profile.dart'; import '../../models/task.dart'; import '../../models/task_assignment.dart'; import '../../models/ticket.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/realtime_controller.dart'; import '../../providers/typing_provider.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/reconnect_overlay.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; import '../../utils/subject_suggestions.dart'; import '../../widgets/gemini_button.dart'; import '../../widgets/gemini_animated_text_field.dart'; // request metadata options used in task creation/editing dialogs const List _requestTypeOptions = [ 'Install', 'Repair', 'Upgrade', 'Replace', 'Other', ]; const List _requestCategoryOptions = [ 'Software', 'Hardware', 'Network', ]; class TasksListScreen extends ConsumerStatefulWidget { const TasksListScreen({super.key}); @override ConsumerState createState() => _TasksListScreenState(); } class _TasksListScreenState extends ConsumerState with SingleTickerProviderStateMixin { final TextEditingController _subjectController = TextEditingController(); final TextEditingController _taskNumberController = TextEditingController(); String? _selectedOfficeId; String? _selectedStatus; String? _selectedAssigneeId; DateTimeRange? _selectedDateRange; late final TabController _tabController; @override void dispose() { _subjectController.dispose(); _taskNumberController.dispose(); _tabController.dispose(); super.dispose(); } @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); // Rebuild when tab changes so filter header can show/hide the // "Assigned staff" dropdown for the All Tasks tab. _tabController.addListener(() { if (mounted) setState(() {}); }); } bool get _hasTaskFilters { return _subjectController.text.trim().isNotEmpty || _taskNumberController.text.trim().isNotEmpty || _selectedOfficeId != null || _selectedStatus != null || (_tabController.index == 1 && _selectedAssigneeId != null) || _selectedDateRange != null; } @override Widget build(BuildContext context) { final tasksAsync = ref.watch(tasksProvider); ref.watch(tasksQueryProvider); final ticketsAsync = ref.watch(ticketsProvider); final officesAsync = ref.watch(officesProvider); final profileAsync = ref.watch(currentProfileProvider); final notificationsAsync = ref.watch(notificationsProvider); final profilesAsync = ref.watch(profilesProvider); final assignmentsAsync = ref.watch(taskAssignmentsProvider); final realtime = ref.watch(realtimeControllerProvider); // Show skeleton only on initial load (no previous data) or when the // tasks channel is recovering. Use per-channel check so unrelated // channel issues (e.g. notifications) don't skeleton the tasks list. final showSkeleton = realtime.isChannelRecovering('tasks') || realtime.isChannelRecovering('task_assignments') || (!tasksAsync.hasValue && tasksAsync.isLoading) || (!ticketsAsync.hasValue && ticketsAsync.isLoading) || (!officesAsync.hasValue && officesAsync.isLoading) || (!profilesAsync.hasValue && profilesAsync.isLoading) || (!assignmentsAsync.hasValue && assignmentsAsync.isLoading) || (!profileAsync.hasValue && profileAsync.isLoading); final effectiveShowSkeleton = showSkeleton; final canCreate = profileAsync.maybeWhen( data: (profile) => profile != null && (profile.role == 'admin' || profile.role == 'dispatcher' || profile.role == 'it_staff'), orElse: () => false, ); final ticketById = { for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket, }; final officeById = { for (final office in officesAsync.valueOrNull ?? []) office.id: office, }; final profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; return Stack( children: [ ResponsiveBody( maxWidth: double.infinity, child: Skeletonizer( enabled: effectiveShowSkeleton, // Always render the full layout structure using valueOrNull so // that Skeletonizer has real widget shapes to shimmer. This // avoids the blank flash caused by the old `.when(loading: // () => SizedBox.shrink())` approach and keeps previous data // visible during provider refreshes. child: Builder( builder: (context) { // Show error only when there is genuinely no data. if (tasksAsync.hasError && !tasksAsync.hasValue) { return Center( child: Text('Failed to load tasks: ${tasksAsync.error}'), ); } final tasks = tasksAsync.valueOrNull ?? []; // True empty state — data loaded but nothing returned. if (tasks.isEmpty && !effectiveShowSkeleton) { return const Center(child: Text('No tasks yet.')); } final offices = officesAsync.valueOrNull ?? []; final officesSorted = List.from(offices) ..sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); final officeOptions = >[ const DropdownMenuItem( value: null, child: Text('All offices'), ), ...officesSorted.map( (office) => DropdownMenuItem( value: office.id, child: Text(office.name), ), ), ]; final staffOptions = _staffOptions(profilesAsync.valueOrNull); final statusOptions = _taskStatusOptions(tasks); // derive latest assignee per task from task assignments stream final assignments = assignmentsAsync.valueOrNull ?? []; final assignmentsByTask = {}; for (final a in assignments) { final current = assignmentsByTask[a.taskId]; if (current == null || a.createdAt.isAfter(current.createdAt)) { assignmentsByTask[a.taskId] = a; } } final latestAssigneeByTaskId = {}; for (final entry in assignmentsByTask.entries) { latestAssigneeByTaskId[entry.key] = entry.value.userId; } // Track ALL assigned users per task (not just latest) for "My Tasks" filtering final assignedUsersByTaskId = >{}; for (final a in assignments) { assignedUsersByTaskId .putIfAbsent(a.taskId, () => {}) .add(a.userId); } final filteredTasks = _applyTaskFilters( tasks, ticketById: ticketById, subjectQuery: _subjectController.text, taskNumber: _taskNumberController.text, officeId: _selectedOfficeId, status: _selectedStatus, assigneeId: _selectedAssigneeId, dateRange: _selectedDateRange, latestAssigneeByTaskId: latestAssigneeByTaskId, ); final filterHeader = Wrap( spacing: 12, runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ SizedBox( width: 220, child: TextField( controller: _subjectController, onChanged: (_) => setState(() {}), decoration: const InputDecoration( labelText: 'Subject', prefixIcon: Icon(Icons.search), ), ), ), SizedBox( width: 200, child: DropdownButtonFormField( isExpanded: true, key: ValueKey(_selectedOfficeId), initialValue: _selectedOfficeId, items: officeOptions, onChanged: (value) => setState(() => _selectedOfficeId = value), decoration: const InputDecoration(labelText: 'Office'), ), ), SizedBox( width: 160, child: TextField( controller: _taskNumberController, onChanged: (_) => setState(() {}), decoration: const InputDecoration( labelText: 'Task #', prefixIcon: Icon(Icons.filter_alt), ), ), ), if (_tabController.index == 1) SizedBox( width: 220, child: DropdownButtonFormField( isExpanded: true, key: ValueKey(_selectedAssigneeId), initialValue: _selectedAssigneeId, items: staffOptions, onChanged: (value) => setState(() => _selectedAssigneeId = value), decoration: const InputDecoration( labelText: 'Assigned staff', ), ), ), SizedBox( width: 180, child: DropdownButtonFormField( isExpanded: true, key: ValueKey(_selectedStatus), initialValue: _selectedStatus, items: statusOptions, onChanged: (value) => setState(() => _selectedStatus = value), decoration: const InputDecoration(labelText: 'Status'), ), ), OutlinedButton.icon( onPressed: () async { final next = await showDateRangePicker( context: context, firstDate: DateTime(2020), lastDate: AppTime.now().add( const Duration(days: 365), ), currentDate: AppTime.now(), initialDateRange: _selectedDateRange, ); if (!mounted) return; setState(() => _selectedDateRange = next); }, icon: const Icon(Icons.date_range), label: Text( _selectedDateRange == null ? 'Date range' : AppTime.formatDateRange(_selectedDateRange!), ), ), if (_hasTaskFilters) TextButton.icon( onPressed: () => setState(() { _subjectController.clear(); _selectedOfficeId = null; _selectedStatus = null; _selectedAssigneeId = null; _selectedDateRange = null; }), icon: const Icon(Icons.close), label: const Text('Clear'), ), ], ); // reusable helper for rendering a list given a subset of tasks Widget makeList(List tasksList) { final summary = _StatusSummaryRow( counts: _taskStatusCounts(tasksList), ); return TasQAdaptiveList( items: tasksList, onRowTap: (task) => context.go('/tasks/${task.id}'), summaryDashboard: summary, filterHeader: filterHeader, skeletonMode: effectiveShowSkeleton, onRequestRefresh: () { // For server-side pagination, update the query provider ref.read(tasksQueryProvider.notifier).state = const TaskQuery(offset: 0, limit: 50); }, onPageChanged: null, isLoading: false, columns: [ TasQColumn( header: 'Task #', technical: true, cellBuilder: (context, task) => Text(task.taskNumber ?? task.id), ), TasQColumn( header: 'Subject', cellBuilder: (context, task) { final ticket = task.ticketId == null ? null : ticketById[task.ticketId]; return Text( task.title.isNotEmpty ? task.title : (ticket?.subject ?? 'Task ${task.id}'), ); }, ), TasQColumn( header: 'Office', cellBuilder: (context, task) { final ticket = task.ticketId == null ? null : ticketById[task.ticketId]; final officeId = ticket?.officeId ?? task.officeId; return Text( officeId == null ? 'Unassigned office' : (officeById[officeId]?.name ?? officeId), ); }, ), TasQColumn( header: 'Assigned Agent', cellBuilder: (context, task) { final assigneeId = latestAssigneeByTaskId[task.id]; return Text(_assignedAgent(profileById, assigneeId)); }, ), TasQColumn( header: 'Status', cellBuilder: (context, task) => Row( mainAxisSize: MainAxisSize.min, children: [ _StatusBadge(status: task.status), if (task.status == 'completed' && task.hasIncompleteDetails) ...[ const SizedBox(width: 4), const Icon( Icons.warning_amber_rounded, size: 16, color: Colors.orange, ), ], ], ), ), TasQColumn( header: 'Timestamp', technical: true, cellBuilder: (context, task) => Text(_formatTimestamp(task.createdAt)), ), ], mobileTileBuilder: (context, task, actions) { final ticketId = task.ticketId; final ticket = ticketId == null ? null : ticketById[ticketId]; final officeId = ticket?.officeId ?? task.officeId; final officeName = officeId == null ? 'Unassigned office' : (officeById[officeId]?.name ?? officeId); final assigned = _assignedAgent( profileById, latestAssigneeByTaskId[task.id], ); final subtitle = _buildSubtitle(officeName, task.status); final hasMention = _hasTaskMention( notificationsAsync, task, ); final typingState = ref.watch( typingIndicatorProvider(task.id), ); final showTyping = typingState.userIds.isNotEmpty; return Card( child: ListTile( leading: _buildQueueBadge(context, task), dense: true, visualDensity: VisualDensity.compact, title: Text( task.title.isNotEmpty ? task.title : (ticket?.subject ?? 'Task ${task.taskNumber ?? task.id}'), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(subtitle), const SizedBox(height: 2), Text('Assigned: $assigned'), const SizedBox(height: 4), MonoText('ID ${task.taskNumber ?? task.id}'), const SizedBox(height: 2), Text(_formatTimestamp(task.createdAt)), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ _StatusBadge(status: task.status), if (task.status == 'completed' && task.hasIncompleteDetails) ...[ const SizedBox(width: 4), const Icon( Icons.warning_amber_rounded, size: 16, color: Colors.orange, ), ], if (showTyping) ...[ const SizedBox(width: 6), TypingDots( size: 6, color: Theme.of(context).colorScheme.primary, ), ], if (hasMention) const Padding( padding: EdgeInsets.only(left: 8), child: Icon( Icons.circle, size: 10, color: Colors.red, ), ), ], ), onTap: () => context.go('/tasks/${task.id}'), ), ); }, ); } final currentUserId = profileAsync.valueOrNull?.id; final myTasks = currentUserId == null ? [] : filteredTasks .where( (t) => assignedUsersByTaskId[t.id]?.contains( currentUserId, ) ?? false, ) .toList(); return Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.only(top: 16, bottom: 8), child: Align( alignment: Alignment.center, child: Text( 'Tasks', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge ?.copyWith(fontWeight: FontWeight.w700), ), ), ), Expanded( child: Column( children: [ TabBar( controller: _tabController, tabs: const [ Tab(text: 'My Tasks'), Tab(text: 'All Tasks'), ], ), Expanded( child: TabBarView( controller: _tabController, children: [ makeList(myTasks), makeList(filteredTasks), ], ), ), ], ), ), ], ); }, ), ), ), if (canCreate) Positioned( right: 16, bottom: 16, child: SafeArea( child: FloatingActionButton.extended( onPressed: () => _showCreateTaskDialog(context, ref), icon: const Icon(Icons.add), label: const Text('New Task'), ), ), ), const ReconnectIndicator(), ], ); } Future _showCreateTaskDialog( BuildContext context, WidgetRef ref, ) async { final titleController = TextEditingController(); final descriptionController = TextEditingController(); final existingSubjects = [ ...((ref.read(tasksProvider).valueOrNull ?? const []).map( (task) => task.title, )), ...((ref.read(ticketsProvider).valueOrNull ?? const []).map( (ticket) => ticket.subject, )), ]; String? selectedOfficeId; String? selectedRequestType; String? requestTypeOther; String? selectedRequestCategory; await showDialog( context: context, builder: (dialogContext) { bool saving = false; bool titleProcessing = false; bool descProcessing = false; bool titleDeepSeek = false; bool descDeepSeek = false; final officesAsync = ref.watch(officesProvider); return StatefulBuilder( builder: (context, setState) { return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Create Task'), content: SizedBox( width: 360, child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: GeminiAnimatedBorder( isProcessing: titleProcessing, useDeepSeekColors: titleDeepSeek, child: TypeAheadFormField( textFieldConfiguration: TextFieldConfiguration( controller: titleController, decoration: const InputDecoration( labelText: 'Task title', ), enabled: !saving, ), suggestionsCallback: (pattern) async { return SubjectSuggestionEngine.suggest( existingSubjects: existingSubjects, query: pattern, limit: 8, ); }, itemBuilder: (context, suggestion) => ListTile( dense: true, title: Text(suggestion), ), onSuggestionSelected: (suggestion) { titleController ..text = suggestion ..selection = TextSelection.collapsed( offset: suggestion.length, ); }, ), ), ), GeminiButton( textController: titleController, onTextUpdated: (updatedText) { setState(() { titleController.text = updatedText; }); }, onProcessingStateChanged: (isProcessing) { setState(() { titleProcessing = isProcessing; }); }, onProviderChanged: (isDeepSeek) { setState(() => titleDeepSeek = isDeepSeek); }, tooltip: 'Improve task title with Gemini', ), ], ), const SizedBox(height: 12), Row( children: [ Expanded( child: GeminiAnimatedTextField( controller: descriptionController, labelText: 'Description', maxLines: 3, enabled: !saving, isProcessing: descProcessing, useDeepSeekColors: descDeepSeek, ), ), Padding( padding: const EdgeInsets.only(left: 8.0), child: GeminiButton( textController: descriptionController, onTextUpdated: (updatedText) { setState(() { descriptionController.text = updatedText; }); }, onProcessingStateChanged: (isProcessing) { setState(() { descProcessing = isProcessing; }); }, onProviderChanged: (isDeepSeek) { setState(() => descDeepSeek = isDeepSeek); }, tooltip: 'Improve description with Gemini', ), ), ], ), const SizedBox(height: 12), officesAsync.when( data: (offices) { if (offices.isEmpty) { return const Text('No offices available.'); } final officesSorted = List.from(offices) ..sort( (a, b) => a.name.toLowerCase().compareTo( b.name.toLowerCase(), ), ); selectedOfficeId ??= officesSorted.first.id; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( initialValue: selectedOfficeId, decoration: const InputDecoration( labelText: 'Office', ), items: officesSorted .map( (office) => DropdownMenuItem( value: office.id, child: Text(office.name), ), ) .toList(), onChanged: saving ? null : (value) => setState( () => selectedOfficeId = value, ), ), const SizedBox(height: 12), // optional request metadata inputs DropdownButtonFormField( initialValue: selectedRequestType, decoration: const InputDecoration( labelText: 'Request type (optional)', ), items: _requestTypeOptions .map( (t) => DropdownMenuItem( value: t, child: Text(t), ), ) .toList(), onChanged: saving ? null : (value) => setState( () => selectedRequestType = value, ), ), if (selectedRequestType == 'Other') ...[ const SizedBox(height: 8), TextField( decoration: const InputDecoration( labelText: 'Please specify', ), onChanged: (v) => requestTypeOther = v, ), ], const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedRequestCategory, decoration: const InputDecoration( labelText: 'Request category (optional)', ), items: _requestCategoryOptions .map( (t) => DropdownMenuItem( value: t, child: Text(t), ), ) .toList(), onChanged: saving ? null : (value) => setState( () => selectedRequestCategory = value, ), ), ], ); }, loading: () => const Align( alignment: Alignment.centerLeft, child: CircularProgressIndicator(), ), error: (error, _) => Text('Failed to load offices: $error'), ), ], ), ), actions: [ TextButton( onPressed: saving ? null : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: saving ? null : () async { final title = SubjectSuggestionEngine.normalizeDisplay( titleController.text.trim(), ); final description = descriptionController.text.trim(); final officeId = selectedOfficeId; if (title.isEmpty || officeId == null) { return; } setState(() => saving = true); await ref .read(tasksControllerProvider) .createTask( title: title, description: description, officeId: officeId, requestType: selectedRequestType, requestTypeOther: requestTypeOther, requestCategory: selectedRequestCategory, ); if (context.mounted) { Navigator.of(dialogContext).pop(); showSuccessSnackBar( context, 'Task "$title" has been created successfully.', ); } }, child: saving ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Create'), ), ], ); }, ); }, ); } bool _hasTaskMention( AsyncValue> notificationsAsync, Task task, ) { return notificationsAsync.maybeWhen( data: (items) => items.any( (item) => item.isUnread && (item.taskId == task.id || item.ticketId == task.ticketId), ), orElse: () => false, ); } Widget _buildQueueBadge(BuildContext context, Task task) { final queueOrder = task.queueOrder; final isQueued = task.status == 'queued'; if (!isQueued || queueOrder == null) { return const Icon(Icons.fact_check_outlined); } return Container( width: 40, height: 40, alignment: Alignment.center, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular( AppSurfaces.of(context).compactCardRadius, ), ), child: Text( '#$queueOrder', style: Theme.of(context).textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), ); } String _buildSubtitle(String officeName, String status) { final statusLabel = status.toUpperCase(); return '$officeName · $statusLabel'; } } List> _staffOptions(List? profiles) { final items = profiles ?? const []; final sorted = [...items] ..sort((a, b) => _profileLabel(a).compareTo(_profileLabel(b))); return [ const DropdownMenuItem(value: null, child: Text('All staff')), ...sorted.map( (profile) => DropdownMenuItem( value: profile.id, child: Text(_profileLabel(profile)), ), ), ]; } String _profileLabel(Profile profile) { return profile.fullName.isNotEmpty ? profile.fullName : profile.id; } List> _taskStatusOptions(List tasks) { final statuses = tasks.map((task) => task.status).toSet().toList()..sort(); return [ const DropdownMenuItem(value: null, child: Text('All statuses')), ...statuses.map( (status) => DropdownMenuItem(value: status, child: Text(status)), ), ]; } List _applyTaskFilters( List tasks, { required Map ticketById, required String subjectQuery, required String taskNumber, required String? officeId, required String? status, required String? assigneeId, required DateTimeRange? dateRange, required Map latestAssigneeByTaskId, }) { final query = subjectQuery.trim().toLowerCase(); final tnQuery = taskNumber.trim().toLowerCase(); return tasks.where((task) { if (tnQuery.isNotEmpty && !(task.taskNumber?.toLowerCase().contains(tnQuery) ?? false)) { return false; } final ticket = task.ticketId == null ? null : ticketById[task.ticketId]; final subject = task.title.isNotEmpty ? task.title : (ticket?.subject ?? 'Task ${task.id}'); if (query.isNotEmpty && !subject.toLowerCase().contains(query) && !(task.taskNumber?.toLowerCase().contains(query) ?? false) && !task.id.toLowerCase().contains(query)) { return false; } final resolvedOfficeId = ticket?.officeId ?? task.officeId; if (officeId != null && resolvedOfficeId != officeId) { return false; } if (status != null && task.status != status) { return false; } final currentAssignee = latestAssigneeByTaskId[task.id]; if (assigneeId != null && currentAssignee != assigneeId) { return false; } if (dateRange != null) { final start = DateTime( dateRange.start.year, dateRange.start.month, dateRange.start.day, ); final end = DateTime( dateRange.end.year, dateRange.end.month, dateRange.end.day, 23, 59, 59, ); if (task.createdAt.isBefore(start) || task.createdAt.isAfter(end)) { return false; } } return true; }).toList(); } Map _taskStatusCounts(List tasks) { final counts = {}; for (final task in tasks) { // Do not include cancelled tasks in dashboard summaries if (task.status == 'cancelled') continue; counts.update(task.status, (value) => value + 1, ifAbsent: () => 1); } return counts; } class _StatusSummaryRow extends StatelessWidget { const _StatusSummaryRow({required this.counts}); final Map counts; @override Widget build(BuildContext context) { if (counts.isEmpty) { return const SizedBox.shrink(); } final entries = counts.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); return LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth; final maxPerRow = maxWidth >= 1000 ? 4 : maxWidth >= 720 ? 3 : maxWidth >= 480 ? 2 : entries.length; final perRow = entries.length < maxPerRow ? entries.length : maxPerRow; final spacing = maxWidth < 480 ? 8.0 : 12.0; final itemWidth = perRow == 0 ? maxWidth : (maxWidth - spacing * (perRow - 1)) / perRow; return Wrap( spacing: spacing, runSpacing: spacing, children: [ for (final entry in entries) SizedBox( width: itemWidth, child: _StatusSummaryCard( status: entry.key, count: entry.value, ), ), ], ); }, ); } } class _StatusSummaryCard extends StatelessWidget { const _StatusSummaryCard({required this.status, required this.count}); final String status; final int count; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final background = switch (status) { 'critical' => scheme.errorContainer, 'queued' => scheme.surfaceContainerHighest, 'in_progress' => scheme.secondaryContainer, 'completed' => scheme.primaryContainer, _ => scheme.surfaceContainerHigh, }; final foreground = switch (status) { 'critical' => scheme.onErrorContainer, 'queued' => scheme.onSurfaceVariant, 'in_progress' => scheme.onSecondaryContainer, 'completed' => scheme.onPrimaryContainer, _ => scheme.onSurfaceVariant, }; return Card( color: background, // summary cards are compact — use compact token for consistent density shape: AppSurfaces.of(context).compactShape, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( status.toUpperCase(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: foreground, fontWeight: FontWeight.w600, letterSpacing: 0.4, ), ), const SizedBox(height: 6), Text( count.toString(), style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: foreground, fontWeight: FontWeight.w700, ), ), ], ), ), ); } } String _assignedAgent(Map profileById, String? userId) { if (userId == null || userId.isEmpty) { return 'Unassigned'; } final profile = profileById[userId]; if (profile == null) { return userId; } return profile.fullName.isNotEmpty ? profile.fullName : profile.id; } String _formatTimestamp(DateTime value) { final year = value.year.toString().padLeft(4, '0'); final month = value.month.toString().padLeft(2, '0'); final day = value.day.toString().padLeft(2, '0'); final hour = value.hour.toString().padLeft(2, '0'); final minute = value.minute.toString().padLeft(2, '0'); return '$year-$month-$day $hour:$minute'; } class _StatusBadge extends StatelessWidget { const _StatusBadge({required this.status}); final String status; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final background = switch (status) { 'critical' => scheme.errorContainer, 'queued' => scheme.surfaceContainerHighest, 'in_progress' => scheme.secondaryContainer, 'completed' => scheme.primaryContainer, _ => scheme.surfaceContainerHighest, }; final foreground = switch (status) { 'critical' => scheme.onErrorContainer, 'queued' => scheme.onSurfaceVariant, 'in_progress' => scheme.onSecondaryContainer, 'completed' => scheme.onPrimaryContainer, _ => scheme.onSurfaceVariant, }; return Badge( backgroundColor: background, label: Text( status.toUpperCase(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: foreground, fontWeight: FontWeight.w600, letterSpacing: 0.3, ), ), ); } }