1099 lines
39 KiB
Dart
1099 lines
39 KiB
Dart
import 'package:flutter/material.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/task.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) {
|
|
final ticket = _findTicket(ref, 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 tasksAsync = ref.watch(tasksProvider);
|
|
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
|
|
: _findTaskForTicket(tasksAsync, 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: [
|
|
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 == '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: 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),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
if (isWide) {
|
|
return Row(
|
|
children: [
|
|
Expanded(flex: 2, child: detailsCard),
|
|
const SizedBox(width: 16),
|
|
Expanded(flex: 3, child: messagesCard),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Mobile: avoid nesting scrollables. detailsCard itself is
|
|
// scrollable if it grows tall, and the messages area takes the
|
|
// remaining space so the chat list can always receive touch
|
|
// gestures and never end up offscreen.
|
|
return Column(
|
|
children: [
|
|
detailsCard,
|
|
const SizedBox(height: 12),
|
|
Expanded(child: messagesCard),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
Ticket? _findTicket(WidgetRef ref, String ticketId) {
|
|
final ticketsAsync = ref.watch(ticketsProvider);
|
|
return ticketsAsync.maybeWhen(
|
|
data: (tickets) =>
|
|
tickets.where((ticket) => ticket.id == ticketId).firstOrNull,
|
|
orElse: () => null,
|
|
);
|
|
}
|
|
|
|
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!),
|
|
);
|
|
}
|
|
|
|
Task? _findTaskForTicket(AsyncValue<List<Task>> tasksAsync, String ticketId) {
|
|
return tasksAsync.maybeWhen(
|
|
data: (tasks) =>
|
|
tasks.where((task) => task.ticketId == ticketId).firstOrNull,
|
|
orElse: () => null,
|
|
);
|
|
}
|
|
|
|
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();
|
|
|
|
final message = await ref
|
|
.read(ticketsControllerProvider)
|
|
.sendTicketMessage(ticketId: widget.ticketId, content: content);
|
|
final mentionUserIds = _extractMentionedUserIds(
|
|
content,
|
|
profiles,
|
|
currentUserId,
|
|
);
|
|
if (mentionUserIds.isNotEmpty && currentUserId != null) {
|
|
await ref
|
|
.read(notificationsControllerProvider)
|
|
.createMentionNotifications(
|
|
userIds: mentionUserIds,
|
|
actorId: currentUserId,
|
|
ticketId: widget.ticketId,
|
|
messageId: message.id,
|
|
);
|
|
}
|
|
ref.invalidate(ticketMessagesProvider(widget.ticketId));
|
|
if (mounted) {
|
|
_messageController.clear();
|
|
_clearMentions();
|
|
}
|
|
}
|
|
|
|
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 showDialog<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 officesAsync = ref.watch(officesOnceProvider);
|
|
final subjectCtrl = TextEditingController(text: ticket.subject);
|
|
final descCtrl = TextEditingController(text: ticket.description);
|
|
String? selectedOffice = ticket.officeId;
|
|
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
shape: AppSurfaces.of(context).dialogShape,
|
|
title: const Text('Edit Ticket'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: subjectCtrl,
|
|
decoration: const InputDecoration(labelText: 'Subject'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: descCtrl,
|
|
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: (v) => selectedOffice = v,
|
|
);
|
|
},
|
|
loading: () => const SizedBox.shrink(),
|
|
error: (error, stack) => const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
final subject = subjectCtrl.text.trim();
|
|
final desc = descCtrl.text.trim();
|
|
try {
|
|
await ref
|
|
.read(ticketsControllerProvider)
|
|
.updateTicket(
|
|
ticketId: ticket.id,
|
|
subject: subject.isEmpty ? null : subject,
|
|
description: desc.isEmpty ? null : desc,
|
|
officeId: selectedOffice,
|
|
);
|
|
if (!mounted) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
Navigator.of(context).pop();
|
|
showSuccessSnackBar(context, 'Ticket updated');
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
showErrorSnackBar(context, 'Failed to update ticket: $e');
|
|
});
|
|
}
|
|
},
|
|
child: 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;
|
|
}
|