tasq/lib/screens/tickets/ticket_detail_screen.dart

1148 lines
41 KiB
Dart

import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:tasq/utils/app_time.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../models/office.dart';
import '../../models/profile.dart';
import '../../models/ticket.dart';
import '../../models/ticket_message.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 '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.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';
class TicketDetailScreen extends ConsumerStatefulWidget {
const TicketDetailScreen({super.key, required this.ticketId});
final String ticketId;
@override
ConsumerState<TicketDetailScreen> createState() => _TicketDetailScreenState();
}
class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
final _messageController = TextEditingController();
static const List<String> _statusOptions = ['pending', 'promoted', 'closed'];
String? _mentionQuery;
int? _mentionStart;
List<Profile> _mentionResults = [];
@override
void initState() {
super.initState();
Future.microtask(
() => ref
.read(notificationsControllerProvider)
.markReadForTicket(widget.ticketId),
);
}
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Use per-item providers to avoid rebuilding when unrelated tickets/tasks
// change. Only the specific ticket for this screen triggers rebuilds.
final ticket = ref.watch(ticketByIdProvider(widget.ticketId));
final messagesAsync = ref.watch(ticketMessagesProvider(widget.ticketId));
final profilesAsync = ref.watch(profilesProvider);
final officesAsync = ref.watch(officesProvider);
final currentProfileAsync = ref.watch(currentProfileProvider);
final typingState = ref.watch(typingIndicatorProvider(widget.ticketId));
final canPromote = currentProfileAsync.maybeWhen(
data: (profile) => profile != null && _canPromote(profile.role),
orElse: () => false,
);
final canSendMessages = ticket != null && ticket.status != 'closed';
final canAssign = currentProfileAsync.maybeWhen(
data: (profile) => profile != null && _canAssignStaff(profile.role),
orElse: () => false,
);
final showAssign = canAssign && ticket?.status != 'closed';
final taskForTicket = ticket == null
? null
: ref.watch(taskByTicketIdProvider(ticket.id));
final hasStaffMessage = _hasStaffMessage(
messagesAsync.valueOrNull ?? const [],
profilesAsync.valueOrNull ?? const [],
);
final effectiveRespondedAt = ticket?.promotedAt != null && !hasStaffMessage
? ticket!.promotedAt
: ticket?.respondedAt;
return ResponsiveBody(
child: LayoutBuilder(
builder: (context, constraints) {
if (ticket == null) {
return const Center(child: Text('Ticket not found.'));
}
final isWide = constraints.maxWidth >= AppBreakpoints.desktop;
final detailsContent = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
ticket.subject,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
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),
Align(
alignment: Alignment.center,
child: Text(
_filedByLabel(profilesAsync, ticket),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_buildStatusChip(context, ref, ticket, canPromote),
_MetaBadge(
label: 'Office',
value: _officeLabel(officesAsync, ticket),
),
_MetaBadge(
label: 'Ticket ID',
value: ticket.id,
isMono: true,
),
],
),
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,
),
),
const SizedBox(width: 8),
IconButton(
tooltip: 'Open task',
onPressed: () =>
context.go('/tasks/${taskForTicket.id}'),
icon: const Icon(Icons.open_in_new),
),
],
),
],
],
),
],
);
final detailsCard = Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.topLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
),
detailsContent,
],
),
),
),
);
final messagesCard = Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
children: [
Expanded(
child: messagesAsync.when(
data: (messages) {
if (messages.isEmpty) {
return const Center(child: Text('No messages yet.'));
}
final profileById = {
for (final profile in profilesAsync.valueOrNull ?? [])
profile.id: profile,
};
return ListView.builder(
reverse: true,
padding: const EdgeInsets.fromLTRB(0, 16, 0, 72),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final currentUserId =
Supabase.instance.client.auth.currentUser?.id;
final isMe =
currentUserId != null &&
message.senderId == currentUserId;
final senderName = message.senderId == null
? 'System'
: profileById[message.senderId]?.fullName ??
message.senderId!;
final bubbleColor = isMe
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest;
final textColor = isMe
? Theme.of(
context,
).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface;
return Align(
alignment: isMe
? Alignment.centerRight
: Alignment.centerLeft,
child: Column(
crossAxisAlignment: isMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (!isMe)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
senderName,
style: Theme.of(
context,
).textTheme.labelSmall,
),
),
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(
minWidth: 160,
maxWidth: 520,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(
AppSurfaces.of(context).cardRadius,
),
topRight: Radius.circular(
AppSurfaces.of(context).cardRadius,
),
bottomLeft: Radius.circular(
isMe
? AppSurfaces.of(
context,
).cardRadius
: 4,
),
bottomRight: Radius.circular(
isMe
? 4
: AppSurfaces.of(
context,
).cardRadius,
),
),
),
child: _buildMentionText(
message.content,
textColor,
profilesAsync.valueOrNull ?? [],
),
),
],
),
);
},
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text('Failed to load messages: $error'),
),
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (typingState.userIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_typingLabel(
typingState.userIds,
profilesAsync,
),
style: Theme.of(
context,
).textTheme.labelSmall,
),
const SizedBox(width: 8),
TypingDots(
size: 8,
color: Theme.of(
context,
).colorScheme.primary,
),
],
),
),
),
if (_mentionQuery != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildMentionList(profilesAsync),
),
if (!canSendMessages)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Messaging is disabled for closed tickets.',
style: Theme.of(context).textTheme.labelMedium,
),
),
Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: 'Message...',
),
enabled: canSendMessages,
textInputAction: TextInputAction.send,
onChanged: canSendMessages
? (_) => _handleComposerChanged(
profilesAsync.valueOrNull ?? [],
Supabase
.instance
.client
.auth
.currentUser
?.id,
canSendMessages,
)
: null,
onSubmitted: canSendMessages
? (_) => _handleSendMessage(
ref,
profilesAsync.valueOrNull ?? [],
Supabase
.instance
.client
.auth
.currentUser
?.id,
canSendMessages,
)
: null,
),
),
const SizedBox(width: 12),
IconButton(
tooltip: 'Send',
onPressed: canSendMessages
? () => _handleSendMessage(
ref,
profilesAsync.valueOrNull ?? [],
Supabase
.instance
.client
.auth
.currentUser
?.id,
canSendMessages,
)
: null,
icon: const Icon(Icons.send),
),
],
),
],
),
),
),
],
),
),
);
final mainContent = isWide
? Row(
children: [
Expanded(flex: 2, child: detailsCard),
const SizedBox(width: 16),
Expanded(flex: 3, child: messagesCard),
],
)
: Column(
children: [
detailsCard,
const SizedBox(height: 12),
Expanded(child: messagesCard),
],
);
return mainContent;
},
),
);
}
String _filedByLabel(AsyncValue<List<Profile>> profilesAsync, Ticket ticket) {
final creatorId = ticket.creatorId;
if (creatorId == null || creatorId.isEmpty) {
return 'Filed by: Unknown';
}
final profile = profilesAsync.valueOrNull
?.where((item) => item.id == creatorId)
.firstOrNull;
final name = profile?.fullName.isNotEmpty == true
? profile!.fullName
: creatorId;
return 'Filed by: $name';
}
bool _hasStaffMessage(List<TicketMessage> messages, List<Profile> profiles) {
if (messages.isEmpty || profiles.isEmpty) {
return false;
}
final staffIds = profiles
.where(
(profile) =>
profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff',
)
.map((profile) => profile.id)
.toSet();
if (staffIds.isEmpty) {
return false;
}
return messages.any(
(message) =>
message.senderId != null && staffIds.contains(message.senderId!),
);
}
Future<void> _handleSendMessage(
WidgetRef ref,
List<Profile> profiles,
String? currentUserId,
bool canSendMessages,
) async {
if (!canSendMessages) return;
final content = _messageController.text.trim();
if (content.isEmpty) return;
_maybeTypingController(widget.ticketId)?.stopTyping();
// Capture mentions and clear the composer immediately so the UI
// remains snappy. Perform the network send and notification creation
// in a fire-and-forget background Future.
final mentionUserIds = _extractMentionedUserIds(
content,
profiles,
currentUserId,
);
if (mounted) {
_messageController.clear();
_clearMentions();
}
Future(() async {
try {
final message = await ref
.read(ticketsControllerProvider)
.sendTicketMessage(ticketId: widget.ticketId, content: content);
if (mentionUserIds.isNotEmpty && currentUserId != null) {
try {
await ref
.read(notificationsControllerProvider)
.createMentionNotifications(
userIds: mentionUserIds,
actorId: currentUserId,
ticketId: widget.ticketId,
messageId: message.id,
);
} catch (_) {}
}
} catch (e, st) {
debugPrint('sendTicketMessage error: $e\n$st');
}
});
}
List<String> _extractMentionedUserIds(
String content,
List<Profile> profiles,
String? currentUserId,
) {
final lower = content.toLowerCase();
final mentioned = <String>{};
for (final profile in profiles) {
if (profile.id == currentUserId) continue;
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
if (label.isEmpty) continue;
final token = '@${label.toLowerCase()}';
if (lower.contains(token)) {
mentioned.add(profile.id);
}
}
return mentioned.toList();
}
void _handleComposerChanged(
List<Profile> profiles,
String? currentUserId,
bool canSendMessages,
) {
if (!canSendMessages) {
_maybeTypingController(widget.ticketId)?.stopTyping();
_clearMentions();
return;
}
_maybeTypingController(widget.ticketId)?.userTyping();
final text = _messageController.text;
final cursor = _messageController.selection.baseOffset;
if (cursor < 0) {
_clearMentions();
return;
}
final textBeforeCursor = text.substring(0, cursor);
final atIndex = textBeforeCursor.lastIndexOf('@');
if (atIndex == -1) {
_clearMentions();
return;
}
if (atIndex > 0 && !_isWhitespace(textBeforeCursor[atIndex - 1])) {
_clearMentions();
return;
}
final query = textBeforeCursor.substring(atIndex + 1);
if (query.contains(RegExp(r'\s'))) {
_clearMentions();
return;
}
final normalizedQuery = query.toLowerCase();
final candidates = profiles.where((profile) {
if (profile.id == currentUserId) {
return false;
}
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
return label.toLowerCase().contains(normalizedQuery);
}).toList();
setState(() {
_mentionQuery = query;
_mentionStart = atIndex;
_mentionResults = candidates.take(6).toList();
});
}
void _clearMentions() {
if (_mentionQuery == null && _mentionResults.isEmpty) {
return;
}
setState(() {
_mentionQuery = null;
_mentionStart = null;
_mentionResults = [];
});
}
// Safely obtain the typing controller for [ticketId].
TypingIndicatorController? _maybeTypingController(String ticketId) {
try {
final controller = ref.read(typingIndicatorProvider(ticketId).notifier);
return controller.mounted ? controller : null;
} on StateError {
return null;
} catch (_) {
return null;
}
}
bool _isWhitespace(String char) {
return char.trim().isEmpty;
}
String _typingLabel(
Set<String> userIds,
AsyncValue<List<Profile>> profilesAsync,
) {
final profileById = {
for (final profile in profilesAsync.valueOrNull ?? [])
profile.id: profile,
};
final names = userIds
.map((id) => profileById[id]?.fullName ?? id)
.where((name) => name.isNotEmpty)
.toList();
if (names.isEmpty) {
return 'Someone is typing...';
}
if (names.length == 1) {
return '${names.first} is typing...';
}
if (names.length == 2) {
return '${names[0]} and ${names[1]} are typing...';
}
return '${names[0]}, ${names[1]} and others are typing...';
}
Widget _buildMentionList(AsyncValue<List<Profile>> profilesAsync) {
if (_mentionResults.isEmpty) {
return const SizedBox.shrink();
}
return Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).compactCardRadius,
),
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _mentionResults.length,
separatorBuilder: (context, index) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final profile = _mentionResults[index];
final label = profile.fullName.isEmpty
? profile.id
: profile.fullName;
return ListTile(
dense: true,
title: Text(label),
onTap: () => _insertMention(profile),
);
},
),
);
}
void _insertMention(Profile profile) {
final start = _mentionStart;
if (start == null) {
_clearMentions();
return;
}
final text = _messageController.text;
final cursor = _messageController.selection.baseOffset;
final end = cursor < 0 ? text.length : cursor;
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
final mentionText = '@$label ';
final updated = text.replaceRange(start, end, mentionText);
final newCursor = start + mentionText.length;
_messageController.text = updated;
_messageController.selection = TextSelection.collapsed(offset: newCursor);
_clearMentions();
}
Widget _buildTatRow(
BuildContext context,
Ticket ticket,
DateTime? respondedAtOverride,
) {
final respondedAt = respondedAtOverride ?? ticket.respondedAt;
final responseDuration = respondedAt?.difference(ticket.createdAt);
final triageEnd = _earliestDate(ticket.promotedAt, ticket.closedAt);
final triageStart = respondedAt;
final triageDuration = triageStart == null || triageEnd == null
? null
: triageEnd.difference(triageStart);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Response time: ${responseDuration == null ? 'Pending' : _formatDuration(responseDuration)}',
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
'Triage duration: ${triageDuration == null ? 'Pending' : _formatDuration(triageDuration)}',
),
),
IconButton(
tooltip: 'View timeline',
onPressed: () => _showTimelineDialog(context, ticket),
icon: const Icon(Icons.access_time),
),
],
),
],
);
}
Widget _buildMentionText(
String text,
Color baseColor,
List<Profile> profiles,
) {
final mentionColor = Theme.of(context).colorScheme.primary;
final spans = _mentionSpans(text, baseColor, mentionColor, profiles);
return RichText(
text: TextSpan(
children: spans,
style: TextStyle(color: baseColor),
),
);
}
List<TextSpan> _mentionSpans(
String text,
Color baseColor,
Color mentionColor,
List<Profile> profiles,
) {
final mentionLabels = profiles
.map(
(profile) => profile.fullName.isEmpty ? profile.id : profile.fullName,
)
.where((label) => label.isNotEmpty)
.map(_escapeRegExp)
.toList();
final pattern = mentionLabels.isEmpty
? r'@\S+'
: '@(?:${mentionLabels.join('|')})';
final matches = RegExp(pattern, caseSensitive: false).allMatches(text);
if (matches.isEmpty) {
return [
TextSpan(
text: text,
style: TextStyle(color: baseColor),
),
];
}
final spans = <TextSpan>[];
var lastIndex = 0;
for (final match in matches) {
if (match.start > lastIndex) {
spans.add(
TextSpan(
text: text.substring(lastIndex, match.start),
style: TextStyle(color: baseColor),
),
);
}
spans.add(
TextSpan(
text: text.substring(match.start, match.end),
style: TextStyle(color: mentionColor, fontWeight: FontWeight.w700),
),
);
lastIndex = match.end;
}
if (lastIndex < text.length) {
spans.add(
TextSpan(
text: text.substring(lastIndex),
style: TextStyle(color: baseColor),
),
);
}
return spans;
}
String _escapeRegExp(String value) {
return value.replaceAllMapped(
RegExp(r'[\\^$.*+?()[\]{}|]'),
(match) => '\\${match[0]}',
);
}
DateTime? _earliestDate(DateTime? first, DateTime? second) {
if (first == null) return second;
if (second == null) return first;
return first.isBefore(second) ? first : second;
}
String _officeLabel(AsyncValue<List<Office>> officesAsync, Ticket ticket) {
final offices = officesAsync.valueOrNull ?? [];
final office = offices
.where((item) => item.id == ticket.officeId)
.firstOrNull;
return office?.name ?? ticket.officeId;
}
Future<void> _showTimelineDialog(BuildContext context, Ticket ticket) async {
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Ticket Timeline'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_timelineRow('Created', ticket.createdAt),
_timelineRow('Responded', ticket.respondedAt),
_timelineRow('Promoted', ticket.promotedAt),
_timelineRow('Closed', ticket.closedAt),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Close'),
),
],
);
},
);
}
Future<void> _showEditTicketDialog(
BuildContext context,
WidgetRef ref,
Ticket ticket,
) async {
final screenContext = context;
final dialogShape = AppSurfaces.of(context).dialogShape;
final officesAsync = ref.watch(officesOnceProvider);
final subjectCtrl = TextEditingController(text: ticket.subject);
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(
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 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),
),
],
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;
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) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('$label: ${value == null ? '' : AppTime.formatDate(value)}'),
);
}
String _formatDuration(Duration duration) {
if (duration.inSeconds < 60) {
return 'Less than a minute';
}
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
Widget _buildStatusChip(
BuildContext context,
WidgetRef ref,
Ticket ticket,
bool canPromote,
) {
final isLocked = ticket.status == 'promoted' || ticket.status == 'closed';
final chip = StatusPill(
label: _statusLabel(ticket.status),
isEmphasized: ticket.status != 'pending',
);
if (isLocked) {
return chip;
}
final availableStatuses = canPromote
? _statusOptions
: _statusOptions.where((status) => status != 'promoted').toList();
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);
},
itemBuilder: (context) => availableStatuses
.map(
(status) => PopupMenuItem(
value: status,
child: Text(_statusMenuLabel(status)),
),
)
.toList(),
child: chip,
);
}
String _statusLabel(String status) {
return status.toUpperCase();
}
String _statusMenuLabel(String status) {
return switch (status) {
'pending' => 'Pending',
'promoted' => 'Promote to Task',
'closed' => 'Close',
_ => status,
};
}
bool _canPromote(String role) {
return role == 'admin' || role == 'dispatcher' || role == 'it_staff';
}
bool _canAssignStaff(String role) {
return role == 'admin' || role == 'dispatcher' || role == 'it_staff';
}
}
class _MetaBadge extends StatelessWidget {
const _MetaBadge({required this.label, required this.value, this.isMono});
final String label;
final String value;
final bool? isMono;
@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;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).compactCardRadius,
),
border: Border.all(color: border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: textStyle),
const SizedBox(width: 6),
if (isMono == true)
MonoText(value, style: textStyle)
else
Text(value, style: textStyle),
],
),
);
}
}
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}