tasq/lib/screens/tasks/tasks_list_screen.dart

1052 lines
40 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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';
// request metadata options used in task creation/editing dialogs
const List<String> _requestTypeOptions = [
'Install',
'Repair',
'Upgrade',
'Replace',
'Other',
];
const List<String> _requestCategoryOptions = [
'Software',
'Hardware',
'Network',
];
class TasksListScreen extends ConsumerStatefulWidget {
const TasksListScreen({super.key});
@override
ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
}
class _TasksListScreenState extends ConsumerState<TasksListScreen>
with SingleTickerProviderStateMixin {
final TextEditingController _subjectController = TextEditingController();
final TextEditingController _taskNumberController = TextEditingController();
String? _selectedOfficeId;
String? _selectedStatus;
String? _selectedAssigneeId;
DateTimeRange? _selectedDateRange;
late final TabController _tabController;
bool _isSwitchingTab = false;
@override
void dispose() {
_subjectController.dispose();
_taskNumberController.dispose();
_tabController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() {
// briefly show a skeleton when switching tabs so the UI can
// navigate ahead and avoid a janky synchronous rebuild.
if (!_isSwitchingTab) {
setState(() => _isSwitchingTab = true);
Future.delayed(const Duration(milliseconds: 150), () {
if (!mounted) return;
setState(() => _isSwitchingTab = false);
});
}
});
}
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);
final showSkeleton =
realtime.isAnyStreamRecovering ||
tasksAsync.maybeWhen(loading: () => true, orElse: () => false) ||
ticketsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
officesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
profilesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
assignmentsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
profileAsync.maybeWhen(loading: () => true, orElse: () => false);
final effectiveShowSkeleton = showSkeleton || _isSwitchingTab;
final canCreate = profileAsync.maybeWhen(
data: (profile) =>
profile != null &&
(profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff'),
orElse: () => false,
);
final ticketById = <String, Ticket>{
for (final ticket in ticketsAsync.valueOrNull ?? <Ticket>[])
ticket.id: ticket,
};
final officeById = <String, Office>{
for (final office in officesAsync.valueOrNull ?? <Office>[])
office.id: office,
};
final profileById = <String, Profile>{
for (final profile in profilesAsync.valueOrNull ?? <Profile>[])
profile.id: profile,
};
return Stack(
children: [
ResponsiveBody(
maxWidth: double.infinity,
child: Skeletonizer(
enabled: effectiveShowSkeleton,
child: tasksAsync.when(
data: (tasks) {
if (tasks.isEmpty) {
return const Center(child: Text('No tasks yet.'));
}
final offices = officesAsync.valueOrNull ?? <Office>[];
final officesSorted = List<Office>.from(offices)
..sort(
(a, b) =>
a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
final officeOptions = <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('All offices'),
),
...officesSorted.map(
(office) => DropdownMenuItem<String?>(
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 ?? <TaskAssignment>[];
final assignmentsByTask = <String, TaskAssignment>{};
for (final a in assignments) {
final current = assignmentsByTask[a.taskId];
if (current == null ||
a.createdAt.isAfter(current.createdAt)) {
assignmentsByTask[a.taskId] = a;
}
}
final latestAssigneeByTaskId = <String, String?>{};
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 = <String, Set<String>>{};
for (final a in assignments) {
assignedUsersByTaskId
.putIfAbsent(a.taskId, () => <String>{})
.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<String?>(
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<String?>(
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<String?>(
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<Task> tasksList) {
final summary = _StatusSummaryRow(
counts: _taskStatusCounts(tasksList),
);
return TasQAdaptiveList<Task>(
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<Task>(
header: 'Task #',
technical: true,
cellBuilder: (context, task) =>
Text(task.taskNumber ?? task.id),
),
TasQColumn<Task>(
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<Task>(
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<Task>(
header: 'Assigned Agent',
cellBuilder: (context, task) {
final assigneeId = latestAssigneeByTaskId[task.id];
return Text(_assignedAgent(profileById, assigneeId));
},
),
TasQColumn<Task>(
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<Task>(
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
? <Task>[]
: 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),
],
),
),
],
),
),
],
);
},
loading: () => const SizedBox.shrink(),
error: (error, _) =>
Center(child: Text('Failed to load tasks: $error')),
),
),
),
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<void> _showCreateTaskDialog(
BuildContext context,
WidgetRef ref,
) async {
final titleController = TextEditingController();
final descriptionController = TextEditingController();
String? selectedOfficeId;
String? selectedRequestType;
String? requestTypeOther;
String? selectedRequestCategory;
await showDialog<void>(
context: context,
builder: (dialogContext) {
bool saving = 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: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Task title',
),
enabled: !saving,
),
const SizedBox(height: 12),
TextField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
),
maxLines: 3,
enabled: !saving,
),
const SizedBox(height: 12),
officesAsync.when(
data: (offices) {
if (offices.isEmpty) {
return const Text('No offices available.');
}
final officesSorted = List<Office>.from(offices)
..sort(
(a, b) => a.name.toLowerCase().compareTo(
b.name.toLowerCase(),
),
);
selectedOfficeId ??= officesSorted.first.id;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
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<String>(
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<String>(
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 = 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<List<NotificationItem>> 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<DropdownMenuItem<String?>> _staffOptions(List<Profile>? profiles) {
final items = profiles ?? const <Profile>[];
final sorted = [...items]
..sort((a, b) => _profileLabel(a).compareTo(_profileLabel(b)));
return [
const DropdownMenuItem<String?>(value: null, child: Text('All staff')),
...sorted.map(
(profile) => DropdownMenuItem<String?>(
value: profile.id,
child: Text(_profileLabel(profile)),
),
),
];
}
String _profileLabel(Profile profile) {
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
}
List<DropdownMenuItem<String?>> _taskStatusOptions(List<Task> tasks) {
final statuses = tasks.map((task) => task.status).toSet().toList()..sort();
return [
const DropdownMenuItem<String?>(value: null, child: Text('All statuses')),
...statuses.map(
(status) => DropdownMenuItem<String?>(value: status, child: Text(status)),
),
];
}
List<Task> _applyTaskFilters(
List<Task> tasks, {
required Map<String, Ticket> ticketById,
required String subjectQuery,
required String taskNumber,
required String? officeId,
required String? status,
required String? assigneeId,
required DateTimeRange? dateRange,
required Map<String, String?> 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<String, int> _taskStatusCounts(List<Task> tasks) {
final counts = <String, int>{};
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<String, int> 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<String, Profile> 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,
),
),
);
}
}