* Improved Task and Ticket Detail Screen
* Made Office assignment searchable in Task and Ticket Creation and Edit Screen
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,9 +16,9 @@ import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../utils/snackbar.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 '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
@@ -38,6 +38,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
String? _mentionQuery;
|
||||
int? _mentionStart;
|
||||
List<Profile> _mentionResults = [];
|
||||
bool _isUpdatingStatus = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -99,66 +100,83 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
final detailsContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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(
|
||||
ticket.subject,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
ticket.subject,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
final profile = currentProfileAsync.maybeWhen(
|
||||
data: (p) => p,
|
||||
orElse: () => null,
|
||||
);
|
||||
final canEdit =
|
||||
profile != null &&
|
||||
(profile.role == 'admin' ||
|
||||
profile.role == 'programmer' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff' ||
|
||||
profile.id == ticket.creatorId);
|
||||
if (!canEdit) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
tooltip: 'Edit ticket',
|
||||
onPressed: () =>
|
||||
_showEditTicketDialog(ctx, ref, ticket),
|
||||
icon: const Icon(Icons.edit),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
final profile = currentProfileAsync.maybeWhen(
|
||||
data: (p) => p,
|
||||
orElse: () => null,
|
||||
);
|
||||
final canEdit =
|
||||
profile != null &&
|
||||
(profile.role == 'admin' ||
|
||||
profile.role == 'programmer' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff' ||
|
||||
profile.id == ticket.creatorId);
|
||||
if (!canEdit) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
tooltip: 'Edit ticket',
|
||||
onPressed: () =>
|
||||
_showEditTicketDialog(ctx, ref, ticket),
|
||||
icon: const Icon(Icons.edit),
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_filedByLabel(profilesAsync, ticket),
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_filedByLabel(profilesAsync, ticket),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
// ── Meta strip ─────────────────────────────────────────────
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_buildStatusChip(context, ref, ticket, canPromote),
|
||||
_MetaBadge(
|
||||
icon: Icons.business_rounded,
|
||||
label: 'Office',
|
||||
value: _officeLabel(officesAsync, ticket),
|
||||
),
|
||||
_MetaBadge(
|
||||
icon: Icons.confirmation_number_rounded,
|
||||
label: 'Ticket ID',
|
||||
value: ticket.id,
|
||||
isMono: true,
|
||||
@@ -167,37 +185,51 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// collapse the rest of the details so tall chat areas won't push off-screen
|
||||
ExpansionTile(
|
||||
key: const Key('ticket-details-expansion'),
|
||||
title: const Text('Details'),
|
||||
initiallyExpanded: true,
|
||||
childrenPadding: const EdgeInsets.only(top: 8),
|
||||
children: [
|
||||
Text(ticket.description),
|
||||
const SizedBox(height: 12),
|
||||
_buildTatRow(context, ticket, effectiveRespondedAt),
|
||||
if (taskForTicket != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TaskAssignmentSection(
|
||||
taskId: taskForTicket.id,
|
||||
canAssign: showAssign,
|
||||
),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
child: ExpansionTile(
|
||||
key: const Key('ticket-details-expansion'),
|
||||
title: const Text('Details'),
|
||||
initiallyExpanded: true,
|
||||
childrenPadding: const EdgeInsets.only(top: 8),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(ticket.description),
|
||||
const SizedBox(height: 12),
|
||||
_buildTatRow(context, ticket, effectiveRespondedAt),
|
||||
if (taskForTicket != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TaskAssignmentSection(
|
||||
taskId: taskForTicket.id,
|
||||
canAssign: showAssign,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Open task',
|
||||
onPressed: () =>
|
||||
context.go('/tasks/${taskForTicket.id}'),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Open task',
|
||||
onPressed: () =>
|
||||
context.go('/tasks/${taskForTicket.id}'),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -232,7 +264,46 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
child: messagesAsync.when(
|
||||
data: (messages) {
|
||||
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 profilesAsync.valueOrNull ?? [])
|
||||
@@ -391,47 +462,66 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Message...',
|
||||
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,
|
||||
),
|
||||
),
|
||||
enabled: canSendMessages,
|
||||
textInputAction: TextInputAction.send,
|
||||
onChanged: canSendMessages
|
||||
? (_) => _handleComposerChanged(
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
onSubmitted: canSendMessages
|
||||
? (_) => _handleSendMessage(
|
||||
ref,
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
enabled: canSendMessages,
|
||||
textInputAction: TextInputAction.send,
|
||||
onChanged: canSendMessages
|
||||
? (_) => _handleComposerChanged(
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
onSubmitted: canSendMessages
|
||||
? (_) => _handleSendMessage(
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Send',
|
||||
onPressed: canSendMessages
|
||||
? () => _handleSendMessage(
|
||||
ref,
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
tooltip: 'Send',
|
||||
onPressed: canSendMessages
|
||||
? () => _handleSendMessage(
|
||||
ref,
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
currentUserId,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -888,19 +978,172 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
final descCtrl = TextEditingController(text: ticket.description);
|
||||
String? selectedOffice = ticket.officeId;
|
||||
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
var saving = false;
|
||||
return StatefulBuilder(
|
||||
builder: (dialogBuilderContext, setDialogState) {
|
||||
return AlertDialog(
|
||||
shape: dialogShape,
|
||||
title: const Text('Edit Ticket'),
|
||||
content: SingleChildScrollView(
|
||||
final isWide =
|
||||
MediaQuery.sizeOf(context).width >= AppBreakpoints.tablet;
|
||||
|
||||
if (isWide) {
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
var saving = false;
|
||||
return StatefulBuilder(
|
||||
builder: (dialogBuilderContext, setDialogState) {
|
||||
return AlertDialog(
|
||||
shape: dialogShape,
|
||||
title: const Text('Edit Ticket'),
|
||||
content: SizedBox(
|
||||
width: 600,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: subjectCtrl,
|
||||
enabled: !saving,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Subject',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
enabled: !saving,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
),
|
||||
maxLines: 4,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(
|
||||
b.name.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return OfficeSelectorField(
|
||||
offices: officesSorted,
|
||||
selectedOfficeId: selectedOffice,
|
||||
allowUnassigned: true,
|
||||
onChanged: saving
|
||||
? null
|
||||
: (v) =>
|
||||
setDialogState(() => selectedOffice = v),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final subject = subjectCtrl.text.trim();
|
||||
final desc = descCtrl.text.trim();
|
||||
setDialogState(() => saving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicket(
|
||||
ticketId: ticket.id,
|
||||
subject: subject.isEmpty ? null : subject,
|
||||
description:
|
||||
desc.isEmpty ? null : desc,
|
||||
officeId: selectedOffice,
|
||||
);
|
||||
ref.invalidate(ticketsProvider);
|
||||
ref.invalidate(ticketByIdProvider(ticket.id));
|
||||
if (!dialogContext.mounted ||
|
||||
!screenContext.mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(dialogContext).pop();
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket updated',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!screenContext.mounted) return;
|
||||
if (isOfflineSaveError(e)) {
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket update saved offline — will sync when connected.',
|
||||
);
|
||||
} else {
|
||||
showErrorSnackBar(
|
||||
screenContext,
|
||||
'Failed to update ticket: $e',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (dialogContext.mounted) {
|
||||
setDialogState(() => saving = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (sheetContext) {
|
||||
var saving = false;
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setDialogState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 24,
|
||||
bottom: MediaQuery.viewInsetsOf(ctx).bottom + 24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Edit Ticket',
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed:
|
||||
saving ? null : () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: subjectCtrl,
|
||||
enabled: !saving,
|
||||
@@ -924,103 +1167,104 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
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(() => selectedOffice = v),
|
||||
: (v) =>
|
||||
setDialogState(() => selectedOffice = v),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OverflowBar(
|
||||
alignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final subject = subjectCtrl.text.trim();
|
||||
final desc = descCtrl.text.trim();
|
||||
setDialogState(() => saving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicket(
|
||||
ticketId: ticket.id,
|
||||
subject: subject.isEmpty
|
||||
? null
|
||||
: subject,
|
||||
description: desc.isEmpty
|
||||
? null
|
||||
: desc,
|
||||
officeId: selectedOffice,
|
||||
);
|
||||
ref.invalidate(ticketsProvider);
|
||||
ref.invalidate(
|
||||
ticketByIdProvider(ticket.id),
|
||||
);
|
||||
if (!ctx.mounted ||
|
||||
!screenContext.mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(ctx).pop();
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket updated',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!screenContext.mounted) return;
|
||||
if (isOfflineSaveError(e)) {
|
||||
if (ctx.mounted) {
|
||||
Navigator.of(ctx).pop();
|
||||
}
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket update saved offline — will sync when connected.',
|
||||
);
|
||||
} else {
|
||||
showErrorSnackBar(
|
||||
screenContext,
|
||||
'Failed to update ticket: $e',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (ctx.mounted) {
|
||||
setDialogState(() => saving = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final subject = subjectCtrl.text.trim();
|
||||
final desc = descCtrl.text.trim();
|
||||
setDialogState(() => saving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicket(
|
||||
ticketId: ticket.id,
|
||||
subject: subject.isEmpty ? null : subject,
|
||||
description: desc.isEmpty ? null : desc,
|
||||
officeId: selectedOffice,
|
||||
);
|
||||
ref.invalidate(ticketsProvider);
|
||||
ref.invalidate(ticketByIdProvider(ticket.id));
|
||||
if (!dialogContext.mounted ||
|
||||
!screenContext.mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(dialogContext).pop();
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket updated',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!screenContext.mounted) return;
|
||||
if (isOfflineSaveError(e)) {
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
showSuccessSnackBar(
|
||||
screenContext,
|
||||
'Ticket update saved offline — will sync when connected.',
|
||||
);
|
||||
} else {
|
||||
showErrorSnackBar(
|
||||
screenContext,
|
||||
'Failed to update ticket: $e',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (dialogContext.mounted) {
|
||||
setDialogState(() => saving = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _timelineRow(String label, DateTime? value) {
|
||||
@@ -1049,9 +1293,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
bool canPromote,
|
||||
) {
|
||||
final isLocked = ticket.status == 'promoted' || ticket.status == 'closed';
|
||||
final chip = StatusPill(
|
||||
label: _statusLabel(ticket.status),
|
||||
isEmphasized: ticket.status != 'pending',
|
||||
final chip = _TicketStatusChip(
|
||||
status: ticket.status,
|
||||
isLoading: _isUpdatingStatus,
|
||||
);
|
||||
|
||||
if (isLocked) {
|
||||
@@ -1064,10 +1308,16 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
// Rely on the realtime stream to propagate the status change.
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicketStatus(ticketId: ticket.id, status: value);
|
||||
if (mounted) setState(() => _isUpdatingStatus = true);
|
||||
try {
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicketStatus(ticketId: ticket.id, status: value);
|
||||
} catch (e) {
|
||||
if (mounted) showErrorSnackBarGlobal(e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _isUpdatingStatus = false);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => availableStatuses
|
||||
.map(
|
||||
@@ -1081,9 +1331,6 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _statusLabel(String status) {
|
||||
return status.toUpperCase();
|
||||
}
|
||||
|
||||
String _statusMenuLabel(String status) {
|
||||
return switch (status) {
|
||||
@@ -1103,32 +1350,101 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaBadge extends StatelessWidget {
|
||||
const _MetaBadge({required this.label, required this.value, this.isMono});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool? isMono;
|
||||
class _TicketStatusChip extends StatelessWidget {
|
||||
const _TicketStatusChip({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) {
|
||||
'pending' => (
|
||||
Icons.hourglass_empty_rounded,
|
||||
cs.secondaryContainer,
|
||||
cs.onSecondaryContainer,
|
||||
),
|
||||
'promoted' => (
|
||||
Icons.trending_up_rounded,
|
||||
cs.primaryContainer,
|
||||
cs.onPrimaryContainer,
|
||||
),
|
||||
'closed' => (
|
||||
Icons.lock_rounded,
|
||||
cs.tertiaryContainer,
|
||||
cs.onTertiaryContainer,
|
||||
),
|
||||
_ => (
|
||||
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(
|
||||
AppSurfaces.of(context).compactCardRadius,
|
||||
),
|
||||
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(),
|
||||
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,6 +5,7 @@ import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../widgets/office_picker.dart';
|
||||
import '../../models/notification_item.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/ticket.dart';
|
||||
@@ -440,7 +441,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
) async {
|
||||
final subjectController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
Office? selectedOffice;
|
||||
String? selectedOfficeId;
|
||||
|
||||
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
|
||||
var saving = false;
|
||||
@@ -471,18 +472,10 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
if (offices.isEmpty) return const Text('No offices assigned.');
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
if (selectedOffice == null ||
|
||||
!officesSorted.any((o) => o.id == selectedOffice!.id)) {
|
||||
selectedOffice = officesSorted.first;
|
||||
}
|
||||
return DropdownButtonFormField<Office>(
|
||||
key: ValueKey(selectedOffice?.id),
|
||||
initialValue: selectedOffice,
|
||||
items: officesSorted
|
||||
.map((o) => DropdownMenuItem(value: o, child: Text(o.name)))
|
||||
.toList(),
|
||||
onChanged: saving ? null : (v) => setState(() => selectedOffice = v),
|
||||
decoration: const InputDecoration(labelText: 'Office'),
|
||||
return OfficeSelectorField(
|
||||
offices: officesSorted,
|
||||
selectedOfficeId: selectedOfficeId,
|
||||
onChanged: saving ? null : (id) => setState(() => selectedOfficeId = id),
|
||||
);
|
||||
},
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
@@ -506,7 +499,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
: () async {
|
||||
final subject = subjectController.text.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
if (subject.isEmpty || description.isEmpty || selectedOffice == null) {
|
||||
if (subject.isEmpty || description.isEmpty || selectedOfficeId == null) {
|
||||
showWarningSnackBar(closeCtx, 'Fill out all fields.');
|
||||
return;
|
||||
}
|
||||
@@ -517,7 +510,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
.createTicket(
|
||||
subject: subject,
|
||||
description: description,
|
||||
officeId: selectedOffice!.id,
|
||||
officeId: selectedOfficeId!,
|
||||
);
|
||||
if (pendingTicket != null) {
|
||||
final current = ref.read(offlinePendingTicketsProvider);
|
||||
|
||||
Reference in New Issue
Block a user