Initial commit
This commit is contained in:
@@ -0,0 +1,859 @@
|
||||
import 'package:flutter/material.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 '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.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: Column(
|
||||
children: [
|
||||
if (ticket != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
ticket.subject,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: 8),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_buildStatusChip(context, ref, ticket, canPromote),
|
||||
Text('Office: ${_officeLabel(officesAsync, ticket)}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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 Divider(height: 1),
|
||||
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(maxWidth: 520),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMe ? 16 : 4),
|
||||
bottomRight: Radius.circular(isMe ? 4 : 16),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
ref.read(typingIndicatorProvider(widget.ticketId).notifier).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) {
|
||||
ref.read(typingIndicatorProvider(widget.ticketId).notifier).stopTyping();
|
||||
_clearMentions();
|
||||
return;
|
||||
}
|
||||
ref.read(typingIndicatorProvider(widget.ticketId).notifier).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 = [];
|
||||
});
|
||||
}
|
||||
|
||||
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(12),
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
String _formatDate(DateTime value) {
|
||||
final local = value.toLocal();
|
||||
final monthNames = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
final month = monthNames[local.month - 1];
|
||||
final day = local.day.toString().padLeft(2, '0');
|
||||
final year = local.year.toString();
|
||||
final hour24 = local.hour;
|
||||
final hour12 = hour24 % 12 == 0 ? 12 : hour24 % 12;
|
||||
final minute = local.minute.toString().padLeft(2, '0');
|
||||
final ampm = hour24 >= 12 ? 'PM' : 'AM';
|
||||
return '$month $day, $year $hour12:$minute $ampm';
|
||||
}
|
||||
|
||||
Future<void> _showTimelineDialog(BuildContext context, Ticket ticket) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timelineRow(String label, DateTime? value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text('$label: ${value == null ? '—' : _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 = Chip(
|
||||
label: Text(_statusLabel(ticket.status)),
|
||||
backgroundColor: _statusColor(context, ticket.status),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusTextColor(context, ticket.status),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
|
||||
if (isLocked) {
|
||||
return chip;
|
||||
}
|
||||
|
||||
final availableStatuses = canPromote
|
||||
? _statusOptions
|
||||
: _statusOptions.where((status) => status != 'promoted').toList();
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.updateTicketStatus(ticketId: ticket.id, status: value);
|
||||
ref.invalidate(ticketsProvider);
|
||||
},
|
||||
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';
|
||||
}
|
||||
|
||||
Color _statusColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'pending' => Colors.amber.shade300,
|
||||
'promoted' => Colors.blue.shade300,
|
||||
'closed' => Colors.green.shade300,
|
||||
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
};
|
||||
}
|
||||
|
||||
Color _statusTextColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'pending' => Colors.brown.shade900,
|
||||
'promoted' => Colors.blue.shade900,
|
||||
'closed' => Colors.green.shade900,
|
||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension _FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../models/notification_item.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
|
||||
class TicketsListScreen extends ConsumerWidget {
|
||||
const TicketsListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final ticketsAsync = ref.watch(ticketsProvider);
|
||||
final officesAsync = ref.watch(officesProvider);
|
||||
final notificationsAsync = ref.watch(notificationsProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: ResponsiveBody(
|
||||
child: ticketsAsync.when(
|
||||
data: (tickets) {
|
||||
if (tickets.isEmpty) {
|
||||
return const Center(child: Text('No tickets yet.'));
|
||||
}
|
||||
final officeById = {
|
||||
for (final office in officesAsync.valueOrNull ?? [])
|
||||
office.id: office,
|
||||
};
|
||||
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'Tickets',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
itemCount: tickets.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final ticket = tickets[index];
|
||||
final officeName =
|
||||
officeById[ticket.officeId]?.name ?? ticket.officeId;
|
||||
final hasMention = unreadByTicketId[ticket.id] == true;
|
||||
final typingState = ref.watch(
|
||||
typingIndicatorProvider(ticket.id),
|
||||
);
|
||||
final showTyping = typingState.userIds.isNotEmpty;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.confirmation_number_outlined),
|
||||
title: Text(ticket.subject),
|
||||
subtitle: Text(officeName),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatusChip(context, ticket.status),
|
||||
if (showTyping) ...[
|
||||
const SizedBox(width: 6),
|
||||
TypingDots(
|
||||
size: 6,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
if (hasMention)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: Icon(
|
||||
Icons.circle,
|
||||
size: 10,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.go('/tickets/${ticket.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load tickets: $error')),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _showCreateTicketDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Ticket'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCreateTicketDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
final subjectController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
Office? selectedOffice;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Create Ticket'),
|
||||
content: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final officesAsync = ref.watch(officesProvider);
|
||||
return SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: subjectController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Subject',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
if (offices.isEmpty) {
|
||||
return const Text('No offices assigned.');
|
||||
}
|
||||
selectedOffice ??= offices.first;
|
||||
return DropdownButtonFormField<Office>(
|
||||
key: ValueKey(selectedOffice?.id),
|
||||
initialValue: selectedOffice,
|
||||
items: offices
|
||||
.map(
|
||||
(office) => DropdownMenuItem(
|
||||
value: office,
|
||||
child: Text(office.name),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) =>
|
||||
setState(() => selectedOffice = value),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Office',
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (error, _) =>
|
||||
Text('Failed to load offices: $error'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final subject = subjectController.text.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
if (subject.isEmpty ||
|
||||
description.isEmpty ||
|
||||
selectedOffice == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Fill out all fields.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.createTicket(
|
||||
subject: subject,
|
||||
description: description,
|
||||
officeId: selectedOffice!.id,
|
||||
);
|
||||
ref.invalidate(ticketsProvider);
|
||||
if (context.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, bool> _unreadByTicketId(
|
||||
AsyncValue<List<NotificationItem>> notificationsAsync,
|
||||
) {
|
||||
return notificationsAsync.maybeWhen(
|
||||
data: (items) {
|
||||
final map = <String, bool>{};
|
||||
for (final item in items) {
|
||||
if (item.ticketId == null) continue;
|
||||
if (item.isUnread) {
|
||||
map[item.ticketId!] = true;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
},
|
||||
orElse: () => <String, bool>{},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, String status) {
|
||||
return Chip(
|
||||
label: Text(status.toUpperCase()),
|
||||
backgroundColor: _statusColor(context, status),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusTextColor(context, status),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _statusColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'pending' => Colors.amber.shade300,
|
||||
'promoted' => Colors.blue.shade300,
|
||||
'closed' => Colors.green.shade300,
|
||||
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
};
|
||||
}
|
||||
|
||||
Color _statusTextColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'pending' => Colors.brown.shade900,
|
||||
'promoted' => Colors.blue.shade900,
|
||||
'closed' => Colors.green.shade900,
|
||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user