import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/profile.dart'; import '../../models/task.dart'; import '../../models/task_assignment.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/app_time.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'; class TaskDetailScreen extends ConsumerStatefulWidget { const TaskDetailScreen({super.key, required this.taskId}); final String taskId; @override ConsumerState createState() => _TaskDetailScreenState(); } class _TaskDetailScreenState extends ConsumerState { final _messageController = TextEditingController(); static const List _statusOptions = [ 'queued', 'in_progress', 'completed', ]; String? _mentionQuery; int? _mentionStart; List _mentionResults = []; @override void initState() { super.initState(); Future.microtask( () => ref .read(notificationsControllerProvider) .markReadForTask(widget.taskId), ); } @override void dispose() { _messageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final tasksAsync = ref.watch(tasksProvider); final ticketsAsync = ref.watch(ticketsProvider); final officesAsync = ref.watch(officesProvider); final profileAsync = ref.watch(currentProfileProvider); final assignmentsAsync = ref.watch(taskAssignmentsProvider); final taskMessagesAsync = ref.watch(taskMessagesProvider(widget.taskId)); final profilesAsync = ref.watch(profilesProvider); final task = _findTask(tasksAsync, widget.taskId); if (task == null) { return const ResponsiveBody( child: Center(child: Text('Task not found.')), ); } final ticketId = task.ticketId; final typingChannelId = task.id; final ticket = ticketId == null ? null : _findTicket(ticketsAsync, ticketId); final officeById = { for (final office in officesAsync.valueOrNull ?? []) office.id: office, }; final officeId = ticket?.officeId ?? task.officeId; final officeName = officeId == null ? 'Unassigned office' : (officeById[officeId]?.name ?? officeId); final description = ticket?.description ?? task.description; final canAssign = profileAsync.maybeWhen( data: (profile) => profile != null && _canAssignStaff(profile.role), orElse: () => false, ); final showAssign = canAssign && task.status != 'completed'; final assignments = assignmentsAsync.valueOrNull ?? []; final canUpdateStatus = _canUpdateStatus( profileAsync.valueOrNull, assignments, task.id, ); final typingState = ref.watch(typingIndicatorProvider(typingChannelId)); final canSendMessages = task.status != 'completed'; final messagesAsync = _mergeMessages( taskMessagesAsync, ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)), ); return ResponsiveBody( child: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= AppBreakpoints.desktop; final detailsContent = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( alignment: Alignment.center, child: Text( task.title.isNotEmpty ? task.title : 'Task ${task.id}', textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), ), const SizedBox(height: 6), Align( alignment: Alignment.center, child: Text( _createdByLabel(profilesAsync, task, 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, task, canUpdateStatus), _MetaBadge(label: 'Office', value: officeName), _MetaBadge(label: 'Task ID', value: task.id, isMono: true), ], ), if (description.isNotEmpty) ...[ const SizedBox(height: 12), Text(description), ], const SizedBox(height: 12), _buildTatSection(task), const SizedBox(height: 16), TaskAssignmentSection(taskId: task.id, canAssign: showAssign), ], ); 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) => _buildMessages( context, messages, 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 completed tasks.', style: Theme.of(context).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), canSendMessages, typingChannelId, ), onSubmitted: (_) => _handleSendMessage( task, profilesAsync.valueOrNull ?? [], ref.read(currentUserIdProvider), 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), ), ], ), ], ), ), ), ], ), ), ); if (isWide) { return Row( children: [ Expanded(flex: 2, child: detailsCard), const SizedBox(width: 16), Expanded(flex: 3, child: messagesCard), ], ); } return Column( children: [ detailsCard, const SizedBox(height: 12), Expanded(child: messagesCard), ], ); }, ), ); } String _createdByLabel( AsyncValue> profilesAsync, Task task, Ticket? ticket, ) { final creatorId = task.creatorId ?? ticket?.creatorId; if (creatorId == null || creatorId.isEmpty) { return 'Created by: Unknown'; } final profile = profilesAsync.valueOrNull ?.where((item) => item.id == creatorId) .firstOrNull; final name = profile?.fullName.isNotEmpty == true ? profile!.fullName : creatorId; return 'Created by: $name'; } Widget _buildMessages( BuildContext context, List messages, List profiles, ) { if (messages.isEmpty) { return const Center(child: Text('No messages yet.')); } final profileById = {for (final profile in profiles) profile.id: profile}; final currentUserId = ref.read(currentUserIdProvider); return ListView.builder( reverse: true, padding: const EdgeInsets.fromLTRB(0, 16, 0, 72), itemCount: messages.length, itemBuilder: (context, index) { final message = messages[index]; 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, profiles), ), ], ), ); }, ); } Widget _buildTatSection(Task task) { final animateQueue = task.status == 'queued'; final animateExecution = task.startedAt != null && task.completedAt == null; if (!animateQueue && !animateExecution) { return _buildTatContent(task, AppTime.now()); } return StreamBuilder( stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick), builder: (context, snapshot) { return _buildTatContent(task, AppTime.now()); }, ); } Widget _buildTatContent(Task task, DateTime now) { final queueDuration = task.status == 'queued' ? now.difference(task.createdAt) : _safeDuration(task.startedAt?.difference(task.createdAt)); final executionDuration = task.status == 'queued' ? null : task.startedAt == null ? null : task.completedAt == null ? now.difference(task.startedAt!) : task.completedAt!.difference(task.startedAt!); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Queue duration: ${_formatDuration(queueDuration)}'), const SizedBox(height: 8), Text('Task execution time: ${_formatDuration(executionDuration)}'), ], ); } Duration? _safeDuration(Duration? duration) { if (duration == null) { return null; } return duration.isNegative ? Duration.zero : duration; } String _formatDuration(Duration? duration) { if (duration == null) { return 'Pending'; } 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 _buildMentionText( String text, Color baseColor, List 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 _mentionSpans( String text, Color baseColor, Color mentionColor, List 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 = []; 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]}', ); } AsyncValue> _mergeMessages( AsyncValue> taskMessages, AsyncValue>? ticketMessages, ) { if (ticketMessages == null) { return taskMessages; } return taskMessages.when( data: (taskData) => ticketMessages.when( data: (ticketData) { final byId = { for (final message in taskData) message.id: message, for (final message in ticketData) message.id: message, }; final merged = byId.values.toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return AsyncValue.data(merged); }, loading: () => const AsyncLoading>(), error: (error, stackTrace) => AsyncError>(error, stackTrace), ), loading: () => const AsyncLoading>(), error: (error, stackTrace) => AsyncError>(error, stackTrace), ); } Future _handleSendMessage( Task task, List profiles, String? currentUserId, bool canSendMessages, String typingChannelId, ) async { if (!canSendMessages) return; final content = _messageController.text.trim(); if (content.isEmpty) { return; } ref.read(typingIndicatorProvider(typingChannelId).notifier).stopTyping(); final message = await ref .read(ticketsControllerProvider) .sendTaskMessage( taskId: task.id, ticketId: task.ticketId, content: content, ); final mentionUserIds = _extractMentionedUserIds( content, profiles, currentUserId, ); if (mentionUserIds.isNotEmpty && currentUserId != null) { await ref .read(notificationsControllerProvider) .createMentionNotifications( userIds: mentionUserIds, actorId: currentUserId, ticketId: task.ticketId, taskId: task.id, messageId: message.id, ); } ref.invalidate(taskMessagesProvider(task.id)); if (task.ticketId != null) { ref.invalidate(ticketMessagesProvider(task.ticketId!)); } if (mounted) { _messageController.clear(); _clearMentions(); } } void _handleComposerChanged( List profiles, String? currentUserId, bool canSendMessages, String typingChannelId, ) { if (!canSendMessages) { ref.read(typingIndicatorProvider(typingChannelId).notifier).stopTyping(); _clearMentions(); return; } ref.read(typingIndicatorProvider(typingChannelId).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; } List _extractMentionedUserIds( String content, List profiles, String? currentUserId, ) { final lower = content.toLowerCase(); final mentioned = {}; 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(); } Widget _buildMentionList(AsyncValue> 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(); } String _typingLabel( Set userIds, AsyncValue> 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...'; } Task? _findTask(AsyncValue> tasksAsync, String taskId) { return tasksAsync.maybeWhen( data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull, orElse: () => null, ); } Ticket? _findTicket(AsyncValue> ticketsAsync, String ticketId) { return ticketsAsync.maybeWhen( data: (tickets) => tickets.where((ticket) => ticket.id == ticketId).firstOrNull, orElse: () => null, ); } bool _canAssignStaff(String role) { return role == 'admin' || role == 'dispatcher' || role == 'it_staff'; } Widget _buildStatusChip( BuildContext context, Task task, bool canUpdateStatus, ) { final chip = StatusPill( label: task.status.toUpperCase(), isEmphasized: task.status != 'queued', ); if (!canUpdateStatus) { return chip; } return PopupMenuButton( onSelected: (value) async { await ref .read(tasksControllerProvider) .updateTaskStatus(taskId: task.id, status: value); ref.invalidate(tasksProvider); }, itemBuilder: (context) => _statusOptions .map( (status) => PopupMenuItem( value: status, child: Text(_statusMenuLabel(status)), ), ) .toList(), child: chip, ); } String _statusMenuLabel(String status) { return switch (status) { 'queued' => 'Queued', 'in_progress' => 'In progress', 'completed' => 'Completed', _ => status, }; } bool _canUpdateStatus( Profile? profile, List assignments, String taskId, ) { if (profile == null) { return false; } final isGlobal = profile.role == 'admin' || profile.role == 'dispatcher' || profile.role == 'it_staff'; if (isGlobal) { return true; } return assignments.any( (assignment) => assignment.taskId == taskId && assignment.userId == profile.id, ); } } 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(12), 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 on Iterable { T? get firstOrNull => isEmpty ? null : first; }