* Improved Task and Ticket Detail Screen

* Made Office assignment searchable in Task and Ticket Creation and Edit Screen
This commit is contained in:
2026-05-10 17:54:03 +08:00
parent 4d0d4d5ab3
commit 30b301765b
7 changed files with 1404 additions and 524 deletions
+356 -162
View File
@@ -30,9 +30,9 @@ import '../../utils/app_time.dart';
import '../../utils/snackbar.dart';
import '../../utils/subject_suggestions.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/office_picker.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/sync_pending_badge.dart';
import '../../widgets/task_assignment_section.dart';
@@ -125,6 +125,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
bool _actionSaving = false;
bool _actionSaved = false;
bool _actionProcessing = false;
bool _isUpdatingStatus = false;
bool _pauseActionInFlight = false;
Timer? _elapsedTicker;
DateTime _elapsedNow = AppTime.now();
@@ -458,62 +459,82 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
const _TaskPendingBanner(),
const SizedBox(height: 12),
],
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
// ── Hero zone ────────────────────────────────────────────────
Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Text(
task.title.isNotEmpty
? task.title
: 'Task ${task.taskNumber ?? task.id}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
task.title.isNotEmpty
? task.title
: 'Task ${task.taskNumber ?? task.id}',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
SyncPendingBadge(
isPending: isPending,
compact: true,
),
const SizedBox(width: 4),
Builder(
builder: (ctx) {
final profile = profileAsync.maybeWhen(
data: (p) => p,
orElse: () => null,
);
final canEdit =
profile != null &&
(profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff' ||
profile.id == task.creatorId);
if (!canEdit) return const SizedBox.shrink();
return IconButton(
tooltip: 'Edit task',
onPressed: () =>
_showEditTaskDialog(ctx, ref, task),
icon: const Icon(Icons.edit),
);
},
),
],
),
SyncPendingBadge(isPending: isPending, compact: true),
const SizedBox(width: 8),
Builder(
builder: (ctx) {
final profile = profileAsync.maybeWhen(
data: (p) => p,
orElse: () => null,
);
final canEdit =
profile != null &&
(profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff' ||
profile.id == task.creatorId);
if (!canEdit) return const SizedBox.shrink();
return IconButton(
tooltip: 'Edit task',
onPressed: () =>
_showEditTaskDialog(ctx, ref, task),
icon: const Icon(Icons.edit),
);
},
const SizedBox(height: 6),
Text(
_createdByLabel(profilesAsync, task, ticket),
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.center,
child: Text(
_createdByLabel(profilesAsync, task, ticket),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium,
),
),
// ── Meta strip ───────────────────────────────────────────────
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Wrap(
spacing: 12,
runSpacing: 8,
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_buildStatusChip(
@@ -522,8 +543,13 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
canUpdateStatus,
hasAssignedItStaff,
),
_MetaBadge(label: 'Office', value: officeName),
_MetaBadge(
icon: Icons.business_rounded,
label: 'Office',
value: officeName,
),
_MetaBadge(
icon: Icons.tag_rounded,
label: 'Task #',
value: isPending && task.taskNumber == null
? 'Pending'
@@ -586,7 +612,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
);
} catch (_) {}
},
icon: const Icon(Icons.print),
icon: const Icon(Icons.print_outlined),
),
],
),
@@ -622,7 +648,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
],
const SizedBox(height: 16),
// Collapsible tabbed details: Assignees / Type & Category / Signatories
ExpansionTile(
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: ExpansionTile(
title: const Text('Details'),
initiallyExpanded: isWide,
childrenPadding: const EdgeInsets.symmetric(horizontal: 0),
@@ -2647,6 +2677,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
),
],
),
),
),
],
);
@@ -2683,8 +2715,29 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
labelColor: Theme.of(context).colorScheme.onSurface,
indicatorColor: Theme.of(context).colorScheme.primary,
tabs: const [
Tab(text: 'Chat'),
Tab(text: 'Activity'),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline_rounded,
size: 15,
),
SizedBox(width: 6),
Text('Chat'),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timeline_rounded, size: 15),
SizedBox(width: 6),
Text('Activity'),
],
),
),
],
),
),
@@ -2789,62 +2842,86 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
).textTheme.labelMedium,
),
),
Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration:
const InputDecoration(
hintText: 'Message...',
),
textInputAction:
TextInputAction.send,
enabled: canSendMessages,
onChanged: (_) =>
_handleComposerChanged(
profilesAsync
.valueOrNull ??
[],
ref.read(
currentUserIdProvider,
Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(28),
),
padding: const EdgeInsets.only(
left: 16,
right: 4,
top: 2,
bottom: 2,
),
child: Row(
children: [
Expanded(
child: TextField(
controller:
_messageController,
decoration: InputDecoration(
hintText: 'Message...',
border: InputBorder.none,
isDense: true,
contentPadding:
const EdgeInsets
.symmetric(
vertical: 10,
),
),
textInputAction:
TextInputAction.send,
enabled: canSendMessages,
onChanged: (_) =>
_handleComposerChanged(
profilesAsync
.valueOrNull ??
[],
ref.read(
currentUserIdProvider,
),
canSendMessages,
typingChannelId,
),
canSendMessages,
typingChannelId,
),
onSubmitted: (_) =>
_handleSendMessage(
task,
profilesAsync
.valueOrNull ??
[],
ref.read(
currentUserIdProvider,
onSubmitted: (_) =>
_handleSendMessage(
task,
profilesAsync
.valueOrNull ??
[],
ref.read(
currentUserIdProvider,
),
canSendMessages,
typingChannelId,
),
canSendMessages,
typingChannelId,
),
),
),
),
const SizedBox(width: 12),
IconButton(
tooltip: 'Send',
onPressed: canSendMessages
? () => _handleSendMessage(
task,
profilesAsync
.valueOrNull ??
[],
ref.read(
currentUserIdProvider,
),
canSendMessages,
typingChannelId,
)
: null,
icon: const Icon(Icons.send),
),
],
IconButton(
tooltip: 'Send',
onPressed: canSendMessages
? () =>
_handleSendMessage(
task,
profilesAsync
.valueOrNull ??
[],
ref.read(
currentUserIdProvider,
),
canSendMessages,
typingChannelId,
)
: null,
icon: const Icon(
Icons.send_rounded,
),
),
],
),
),
],
),
@@ -2969,7 +3046,36 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
List<Profile> profiles,
) {
if (messages.isEmpty) {
return const Center(child: Text('No messages yet.'));
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline_rounded,
size: 44,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.35),
),
const SizedBox(height: 12),
Text(
'No messages yet',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Be the first to send a message',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
],
),
);
}
final profileById = {for (final profile in profiles) profile.id: profile};
final currentUserId = ref.read(currentUserIdProvider);
@@ -3276,34 +3382,55 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
DateTime at, {
IconData icon = Icons.circle,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
final cs = Theme.of(context).colorScheme;
final theme = Theme.of(context);
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 28,
child: Center(
child: Icon(
icon,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
),
Expanded(
width: 32,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
Text(
'$actor${AppTime.formatDate(at)} ${AppTime.formatTime(at)}',
style: Theme.of(context).textTheme.labelSmall,
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: cs.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(icon, size: 14, color: cs.onPrimaryContainer),
),
Expanded(
child: Container(
width: 1.5,
margin: const EdgeInsets.symmetric(vertical: 4),
color: cs.outlineVariant,
),
),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(title, style: theme.textTheme.bodyMedium),
const SizedBox(height: 3),
Text(
'$actor${AppTime.formatDate(at)} ${AppTime.formatTime(at)}',
style: theme.textTheme.labelSmall?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
),
),
],
),
);
@@ -3866,7 +3993,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
return AlertDialog(
shape: dialogShape,
title: const Text('Edit Task'),
content: SingleChildScrollView(
content: SizedBox(
width: 600,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -4034,22 +4163,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
b.name.toLowerCase(),
),
);
return DropdownButtonFormField<String?>(
initialValue: selectedOffice,
decoration: const InputDecoration(
labelText: 'Office',
),
items: [
const DropdownMenuItem(
value: null,
child: Text('Unassigned'),
),
for (final o in officesSorted)
DropdownMenuItem(
value: o.id,
child: Text(o.name),
),
],
return OfficeSelectorField(
offices: officesSorted,
selectedOfficeId: selectedOffice,
allowUnassigned: true,
onChanged: saving
? null
: (v) => setDialogState(
@@ -4068,6 +4185,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
],
),
),
),
actions: [
TextButton(
onPressed: saving
@@ -4238,9 +4356,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
bool canUpdateStatus,
bool hasAssignedItStaff,
) {
final chip = StatusPill(
label: task.status.toUpperCase(),
isEmphasized: task.status != 'queued',
final chip = _TaskStatusChip(
status: task.status,
isLoading: _isUpdatingStatus,
);
final isTerminal =
@@ -4404,9 +4522,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
return;
}
// Update DB only — Supabase realtime stream will emit the
// updated task list, so explicit invalidation here causes a
// visible loading/refresh and is unnecessary.
if (mounted) setState(() => _isUpdatingStatus = true);
try {
await ref
.read(tasksControllerProvider)
@@ -4421,6 +4537,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
showErrorSnackBarGlobal(e.toString());
}
}
} finally {
if (mounted) setState(() => _isUpdatingStatus = false);
}
},
itemBuilder: (context) => statusOptions
@@ -4684,30 +4802,106 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
// PDF preview/building moved to `task_pdf.dart`.
}
class _MetaBadge extends StatelessWidget {
const _MetaBadge({required this.label, required this.value, this.isMono});
final String label;
final String value;
final bool? isMono;
class _TaskStatusChip extends StatelessWidget {
const _TaskStatusChip({required this.status, this.isLoading = false});
final String status;
final bool isLoading;
@override
Widget build(BuildContext context) {
final border = Theme.of(context).colorScheme.outlineVariant;
final background = Theme.of(context).colorScheme.surfaceContainerLow;
final textStyle = Theme.of(context).textTheme.labelSmall;
final cs = Theme.of(context).colorScheme;
final (icon, bg, fg) = switch (status) {
'queued' => (
Icons.inbox_rounded,
cs.secondaryContainer,
cs.onSecondaryContainer,
),
'in_progress' => (
Icons.play_arrow_rounded,
cs.primaryContainer,
cs.onPrimaryContainer,
),
'completed' => (
Icons.check_circle_rounded,
cs.tertiaryContainer,
cs.onTertiaryContainer,
),
'cancelled' => (
Icons.cancel_rounded,
cs.errorContainer,
cs.onErrorContainer,
),
_ => (
Icons.help_outline_rounded,
cs.surfaceContainerHighest,
cs.onSurface,
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: border),
color: bg,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: textStyle),
const SizedBox(width: 6),
if (isLoading)
SizedBox(
width: 13,
height: 13,
child: CircularProgressIndicator(strokeWidth: 2, color: fg),
)
else
Icon(icon, size: 13, color: fg),
const SizedBox(width: 5),
Text(
status.toUpperCase().replaceAll('_', ' '),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: fg,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
],
),
);
}
}
class _MetaBadge extends StatelessWidget {
const _MetaBadge({
required this.label,
required this.value,
this.isMono,
this.icon,
});
final String label;
final String value;
final bool? isMono;
final IconData? icon;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final textStyle = Theme.of(context).textTheme.labelSmall;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outlineVariant),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 12, color: cs.onSurfaceVariant),
const SizedBox(width: 5),
],
Text(label, style: textStyle?.copyWith(color: cs.onSurfaceVariant)),
const SizedBox(width: 5),
if (isMono == true)
MonoText(value, style: textStyle)
else
+5 -20
View File
@@ -10,6 +10,7 @@ import 'package:go_router/go_router.dart';
import '../../models/notification_item.dart';
import '../../models/office.dart';
import '../../widgets/office_picker.dart';
import '../../models/profile.dart';
import '../../models/task.dart';
import '../../models/task_assignment.dart';
@@ -961,31 +962,15 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
b.name.toLowerCase(),
),
);
if (selectedOfficeId == null ||
!officesSorted.any((o) => o.id == selectedOfficeId)) {
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(),
OfficeSelectorField(
offices: officesSorted,
selectedOfficeId: selectedOfficeId,
onChanged: saving
? null
: (value) => setState(
() => selectedOfficeId = value,
),
: (id) => setState(() => selectedOfficeId = id),
),
const SizedBox(height: 12),
// optional request metadata inputs