Major UI overhaul
This commit is contained in:
@@ -3,25 +3,56 @@ import 'package:flutter_riverpod/flutter_riverpod.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/ticket.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
|
||||
class TasksListScreen extends ConsumerWidget {
|
||||
class TasksListScreen extends ConsumerStatefulWidget {
|
||||
const TasksListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
|
||||
}
|
||||
|
||||
class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||
final TextEditingController _subjectController = TextEditingController();
|
||||
String? _selectedOfficeId;
|
||||
String? _selectedStatus;
|
||||
String? _selectedAssigneeId;
|
||||
DateTimeRange? _selectedDateRange;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subjectController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _hasTaskFilters {
|
||||
return _subjectController.text.trim().isNotEmpty ||
|
||||
_selectedOfficeId != null ||
|
||||
_selectedStatus != null ||
|
||||
_selectedAssigneeId != null ||
|
||||
_selectedDateRange != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
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 canCreate = profileAsync.maybeWhen(
|
||||
data: (profile) =>
|
||||
@@ -32,113 +63,301 @@ class TasksListScreen extends ConsumerWidget {
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
final ticketById = {
|
||||
for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket,
|
||||
final ticketById = <String, Ticket>{
|
||||
for (final ticket in ticketsAsync.valueOrNull ?? <Ticket>[])
|
||||
ticket.id: ticket,
|
||||
};
|
||||
final officeById = {
|
||||
for (final office in officesAsync.valueOrNull ?? []) office.id: office,
|
||||
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 Scaffold(
|
||||
body: ResponsiveBody(
|
||||
child: tasksAsync.when(
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
return const Center(child: Text('No tasks yet.'));
|
||||
}
|
||||
return Column(
|
||||
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,
|
||||
return Stack(
|
||||
children: [
|
||||
ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: tasksAsync.when(
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
return const Center(child: Text('No tasks yet.'));
|
||||
}
|
||||
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||
final officeOptions = <DropdownMenuItem<String?>>[
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('All offices'),
|
||||
),
|
||||
...offices.map(
|
||||
(office) => DropdownMenuItem<String?>(
|
||||
value: office.id,
|
||||
child: Text(office.name),
|
||||
),
|
||||
),
|
||||
];
|
||||
final staffOptions = _staffOptions(profilesAsync.valueOrNull);
|
||||
final statusOptions = _taskStatusOptions(tasks);
|
||||
final filteredTasks = _applyTaskFilters(
|
||||
tasks,
|
||||
ticketById: ticketById,
|
||||
subjectQuery: _subjectController.text,
|
||||
officeId: _selectedOfficeId,
|
||||
status: _selectedStatus,
|
||||
assigneeId: _selectedAssigneeId,
|
||||
dateRange: _selectedDateRange,
|
||||
);
|
||||
final summaryDashboard = _StatusSummaryRow(
|
||||
counts: _taskStatusCounts(filteredTasks),
|
||||
);
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
itemCount: tasks.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final task = tasks[index];
|
||||
final ticketId = task.ticketId;
|
||||
final ticket = ticketId == null
|
||||
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: 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: DateTime.now().add(const Duration(days: 365)),
|
||||
currentDate: DateTime.now(),
|
||||
initialDateRange: _selectedDateRange,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedDateRange = next);
|
||||
},
|
||||
icon: const Icon(Icons.date_range),
|
||||
label: Text(
|
||||
_selectedDateRange == null
|
||||
? 'Date range'
|
||||
: _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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
final listBody = TasQAdaptiveList<Task>(
|
||||
items: filteredTasks,
|
||||
onRowTap: (task) => context.go('/tasks/${task.id}'),
|
||||
summaryDashboard: summaryDashboard,
|
||||
filterHeader: filterHeader,
|
||||
columns: [
|
||||
TasQColumn<Task>(
|
||||
header: 'Task ID',
|
||||
technical: true,
|
||||
cellBuilder: (context, task) => Text(task.id),
|
||||
),
|
||||
TasQColumn<Task>(
|
||||
header: 'Subject',
|
||||
cellBuilder: (context, task) {
|
||||
final ticket = task.ticketId == null
|
||||
? null
|
||||
: ticketById[ticketId];
|
||||
final officeId = ticket?.officeId ?? task.officeId;
|
||||
final officeName = officeId == null
|
||||
? 'Unassigned office'
|
||||
: (officeById[officeId]?.name ?? officeId);
|
||||
final subtitle = _buildSubtitle(officeName, task.status);
|
||||
final hasMention = _hasTaskMention(
|
||||
notificationsAsync,
|
||||
task,
|
||||
);
|
||||
final typingChannelId = task.id;
|
||||
final typingState = ref.watch(
|
||||
typingIndicatorProvider(typingChannelId),
|
||||
);
|
||||
final showTyping = typingState.userIds.isNotEmpty;
|
||||
|
||||
return ListTile(
|
||||
leading: _buildQueueBadge(context, task),
|
||||
title: Text(
|
||||
task.title.isNotEmpty
|
||||
? task.title
|
||||
: (ticket?.subject ?? 'Task ${task.id}'),
|
||||
),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatusChip(context, task.status),
|
||||
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}'),
|
||||
: ticketById[task.ticketId];
|
||||
return Text(
|
||||
task.title.isNotEmpty
|
||||
? task.title
|
||||
: (ticket?.subject ?? 'Task ${task.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load tasks: $error')),
|
||||
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) =>
|
||||
Text(_assignedAgent(profileById, task.creatorId)),
|
||||
),
|
||||
TasQColumn<Task>(
|
||||
header: 'Status',
|
||||
cellBuilder: (context, task) =>
|
||||
_StatusBadge(status: task.status),
|
||||
),
|
||||
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, task.creatorId);
|
||||
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.id}'),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(subtitle),
|
||||
const SizedBox(height: 2),
|
||||
Text('Assigned: $assigned'),
|
||||
const SizedBox(height: 4),
|
||||
MonoText('ID ${task.id}'),
|
||||
const SizedBox(height: 2),
|
||||
Text(_formatTimestamp(task.createdAt)),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_StatusBadge(status: task.status),
|
||||
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}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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: listBody),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load tasks: $error')),
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: canCreate
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => _showCreateTaskDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Task'),
|
||||
)
|
||||
: null,
|
||||
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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -287,33 +506,265 @@ class TasksListScreen extends ConsumerWidget {
|
||||
final statusLabel = status.toUpperCase();
|
||||
return '$officeName · $statusLabel';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, String status) {
|
||||
return Chip(
|
||||
label: Text(status.toUpperCase()),
|
||||
backgroundColor: _statusColor(context, status),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusTextColor(context, status),
|
||||
fontWeight: FontWeight.w600,
|
||||
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? officeId,
|
||||
required String? status,
|
||||
required String? assigneeId,
|
||||
required DateTimeRange? dateRange,
|
||||
}) {
|
||||
final query = subjectQuery.trim().toLowerCase();
|
||||
return tasks.where((task) {
|
||||
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.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;
|
||||
}
|
||||
if (assigneeId != null && task.creatorId != 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) {
|
||||
counts.update(task.status, (value) => value + 1, ifAbsent: () => 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
String _formatDateRange(DateTimeRange range) {
|
||||
return '${_formatDate(range.start)} - ${_formatDate(range.end)}';
|
||||
}
|
||||
|
||||
String _formatDate(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');
|
||||
return '$year-$month-$day';
|
||||
}
|
||||
|
||||
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,
|
||||
elevation: 0,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'queued' => Colors.blueGrey.shade200,
|
||||
'in_progress' => Colors.blue.shade300,
|
||||
'completed' => Colors.green.shade300,
|
||||
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
Color _statusTextColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'queued' => Colors.blueGrey.shade900,
|
||||
'in_progress' => Colors.blue.shade900,
|
||||
'completed' => Colors.green.shade900,
|
||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user