// ignore_for_file: use_build_context_synchronously 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/task_activity_log.dart'; import '../../models/ticket.dart'; import '../../models/ticket_message.dart'; import '../../models/office.dart'; import '../../providers/notifications_provider.dart'; import 'dart:async'; import 'dart:convert'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import '../../providers/services_provider.dart'; import 'task_pdf.dart'; import '../../providers/supabase_provider.dart'; import 'package:flutter_typeahead/flutter_typeahead.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 '../../utils/snackbar.dart'; import '../../widgets/app_breakpoints.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/status_pill.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/task_assignment_section.dart'; import '../../widgets/typing_dots.dart'; // Simple image embed builder to support data-URI and network images class _ImageEmbedBuilder extends quill.EmbedBuilder { const _ImageEmbedBuilder(); @override String get key => quill.BlockEmbed.imageType; @override Widget build(BuildContext context, quill.EmbedContext embedContext) { final data = embedContext.node.value.data as String; if (data.startsWith('data:image/')) { try { final base64Str = data.split(',').last; final bytes = base64Decode(base64Str); return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 240), child: Image.memory(bytes, fit: BoxFit.contain), ); } catch (_) { return const SizedBox.shrink(); } } // Fallback to network image return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 240), child: Image.network(data, fit: BoxFit.contain), ); } } // Local request metadata options (kept consistent with other screens) const List requestTypeOptions = [ 'Install', 'Repair', 'Upgrade', 'Replace', 'Other', ]; const List requestCategoryOptions = ['Software', 'Hardware', 'Network']; class TaskDetailScreen extends ConsumerStatefulWidget { const TaskDetailScreen({super.key, required this.taskId}); final String taskId; @override ConsumerState createState() => _TaskDetailScreenState(); } class _TaskDetailScreenState extends ConsumerState with SingleTickerProviderStateMixin { final _messageController = TextEditingController(); // Controllers for editable signatories final _requestedController = TextEditingController(); final _notedController = TextEditingController(); final _receivedController = TextEditingController(); // Rich text editor for Action taken quill.QuillController? _actionController; Timer? _actionDebounce; late final FocusNode _actionFocusNode; late final ScrollController _actionScrollController; Timer? _requestedDebounce; Timer? _notedDebounce; Timer? _receivedDebounce; // Seeding/state tracking for signatory fields String? _seededTaskId; bool _requestedSaving = false; bool _requestedSaved = false; bool _notedSaving = false; bool _notedSaved = false; bool _receivedSaving = false; bool _receivedSaved = false; bool _typeSaving = false; bool _typeSaved = false; bool _categorySaving = false; bool _categorySaved = false; bool _actionSaving = false; bool _actionSaved = false; late final AnimationController _saveAnimController; late final Animation _savePulse; 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), ); _saveAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 700), ); _savePulse = Tween(begin: 1.0, end: 0.78).animate( CurvedAnimation(parent: _saveAnimController, curve: Curves.easeInOut), ); // create an empty action controller by default; will seed per-task later _actionController = quill.QuillController.basic(); _actionFocusNode = FocusNode(); _actionScrollController = ScrollController(); // Debugging: to enable a scroll jump detector, add a listener here. // Keep it disabled in production to avoid analyzer dead_code warnings. } @override void dispose() { _messageController.dispose(); _requestedController.dispose(); _notedController.dispose(); _receivedController.dispose(); _requestedDebounce?.cancel(); _notedDebounce?.cancel(); _receivedDebounce?.cancel(); _actionDebounce?.cancel(); _actionController?.dispose(); _actionFocusNode.dispose(); _actionScrollController.dispose(); _saveAnimController.dispose(); super.dispose(); } bool get _anySaving => _requestedSaving || _notedSaving || _receivedSaving || _typeSaving || _categorySaving || _actionSaving; void _updateSaveAnim() { if (_anySaving) { if (!_saveAnimController.isAnimating) { _saveAnimController.repeat(reverse: true); } } else { if (_saveAnimController.isAnimating) { _saveAnimController.stop(); _saveAnimController.reset(); } } } @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)), ); WidgetsBinding.instance.addPostFrameCallback((_) => _updateSaveAnim()); return ResponsiveBody( child: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= AppBreakpoints.desktop; // Seed controllers once per task to reflect persisted values if (_seededTaskId != task.id) { _seededTaskId = task.id; _requestedController.text = task.requestedBy ?? ''; _notedController.text = task.notedBy ?? ''; _receivedController.text = task.receivedBy ?? ''; _requestedSaved = _requestedController.text.isNotEmpty; _notedSaved = _notedController.text.isNotEmpty; _receivedSaved = _receivedController.text.isNotEmpty; // Seed action taken plain text controller from persisted JSON or raw text try { _actionDebounce?.cancel(); _actionController?.dispose(); if (task.actionTaken != null && task.actionTaken!.isNotEmpty) { try { final docJson = jsonDecode(task.actionTaken!) as List; final doc = quill.Document.fromJson(docJson); _actionController = quill.QuillController( document: doc, selection: const TextSelection.collapsed(offset: 0), ); } catch (_) { _actionController = quill.QuillController.basic(); } } else { _actionController = quill.QuillController.basic(); } } catch (_) { _actionController = quill.QuillController.basic(); } // Attach auto-save listener for action taken (debounced) _actionController?.addListener(() { _actionDebounce?.cancel(); _actionDebounce = Timer( const Duration(milliseconds: 700), () async { final plain = _actionController?.document.toPlainText().trim() ?? ''; setState(() { _actionSaving = true; _actionSaved = false; }); try { final deltaJson = jsonEncode( _actionController?.document.toDelta().toJson(), ); await ref .read(tasksControllerProvider) .updateTask(taskId: task.id, actionTaken: deltaJson); setState(() { _actionSaved = plain.isNotEmpty; }); } catch (_) { // ignore } finally { setState(() { _actionSaving = false; }); if (_actionSaved) { Future.delayed(const Duration(seconds: 2), () { if (mounted) { setState(() => _actionSaved = false); } }); } } }, ); }); } final detailsContent = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( task.title.isNotEmpty ? task.title : 'Task ${task.taskNumber ?? task.id}', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(width: 8), Builder( builder: (ctx) { final profile = profileAsync.maybeWhen( data: (p) => p, orElse: () => null, ); final canEdit = profile != null && (profile.role == 'admin' || profile.role == 'dispatcher' || profile.role == 'it_staff' || profile.id == task.creatorId); if (!canEdit) return const SizedBox.shrink(); return IconButton( tooltip: 'Edit task', onPressed: () => _showEditTaskDialog(ctx, ref, task), icon: const Icon(Icons.edit), ); }, ), ], ), ), const SizedBox(height: 6), Align( alignment: Alignment.center, child: Text( _createdByLabel(profilesAsync, task, ticket), textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelMedium, ), ), const SizedBox(height: 12), Row( children: [ Expanded( child: Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ _buildStatusChip(context, task, canUpdateStatus), _MetaBadge(label: 'Office', value: officeName), _MetaBadge( label: 'Task #', value: task.taskNumber ?? task.id, isMono: true, ), ], ), ), IconButton( tooltip: 'Preview/print task', onPressed: () async { try { final logsAsync = ref.read( taskActivityLogsProvider(task.id), ); final logs = logsAsync.valueOrNull ?? []; final assignmentList = assignments; final profilesList = profilesAsync.valueOrNull ?? []; final servicesAsync = ref.read(servicesProvider); final servicesById = { for (final s in servicesAsync.valueOrNull ?? []) s.id: s, }; final serviceName = officeId == null ? '' : (officeById[officeId]?.serviceId == null ? '' : (servicesById[officeById[officeId]! .serviceId] ?.name ?? '')); await showTaskPdfPreview( context, task, ticket, officeName, serviceName, logs, assignmentList, profilesList, ); } catch (_) {} }, icon: const Icon(Icons.print), ), ], ), if (description.isNotEmpty) ...[ const SizedBox(height: 12), Text(description), ], // warning banner for completed tasks with missing metadata if (task.status == 'completed' && task.hasIncompleteDetails) ...[ const SizedBox(height: 12), Row( children: [ const Icon( Icons.warning_amber_rounded, color: Colors.orange, ), const SizedBox(width: 6), Expanded( child: Text( 'Task completed but some details are still empty.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ], ), ], const SizedBox(height: 16), // Collapsible tabbed details: Assignees / Type & Category / Signatories ExpansionTile( title: const Text('Details'), initiallyExpanded: isWide, childrenPadding: const EdgeInsets.symmetric(horizontal: 0), children: [ DefaultTabController( length: 4, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TabBar( labelColor: Theme.of(context).colorScheme.onSurface, indicatorColor: Theme.of(context).colorScheme.primary, tabs: const [ Tab(text: 'Assignees'), Tab(text: 'Type & Category'), Tab(text: 'Signatories'), Tab(text: 'Action taken'), ], ), const SizedBox(height: 8), SizedBox( height: isWide ? 360 : 300, child: TabBarView( children: [ // Assignees SingleChildScrollView( child: Padding( padding: const EdgeInsets.only(top: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TaskAssignmentSection( taskId: task.id, canAssign: showAssign, ), const SizedBox(height: 12), Align( alignment: Alignment.bottomRight, child: _buildTatSection(task), ), ], ), ), ), // Type & Category SingleChildScrollView( child: Padding( padding: const EdgeInsets.only(top: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!canUpdateStatus) ...[ _MetaBadge( label: 'Type', value: task.requestType ?? 'None', ), const SizedBox(height: 8), _MetaBadge( label: 'Category', value: task.requestCategory ?? 'None', ), ] else ...[ const Text('Type'), const SizedBox(height: 6), DropdownButtonFormField( initialValue: task.requestType, decoration: InputDecoration( suffixIcon: _typeSaving ? SizedBox( width: 16, height: 16, child: ScaleTransition( scale: _savePulse, child: const Icon( Icons.save, size: 14, ), ), ) : _typeSaved ? SizedBox( width: 16, height: 16, child: Stack( alignment: Alignment.center, children: const [ Icon( Icons.save, size: 14, color: Colors.green, ), Positioned( right: -2, bottom: -2, child: Icon( Icons.check, size: 10, color: Colors.white, ), ), ], ), ) : null, ), items: [ const DropdownMenuItem( value: null, child: Text('None'), ), for (final t in requestTypeOptions) DropdownMenuItem( value: t, child: Text(t), ), ], onChanged: (v) async { setState(() { _typeSaving = true; _typeSaved = false; }); try { await ref .read(tasksControllerProvider) .updateTask( taskId: task.id, requestType: v, ); setState( () => _typeSaved = v != null && v.isNotEmpty, ); } catch (_) { } finally { setState( () => _typeSaving = false, ); if (_typeSaved) { Future.delayed( const Duration(seconds: 2), () { if (mounted) { setState( () => _typeSaved = false, ); } }, ); } } }, ), if (task.requestType == 'Other') ...[ const SizedBox(height: 8), TextFormField( initialValue: task.requestTypeOther, decoration: InputDecoration( hintText: 'Details', suffixIcon: _typeSaving ? SizedBox( width: 16, height: 16, child: ScaleTransition( scale: _savePulse, child: const Icon( Icons.save, size: 14, ), ), ) : _typeSaved ? SizedBox( width: 16, height: 16, child: Stack( alignment: Alignment.center, children: const [ Icon( Icons.save, size: 14, color: Colors.green, ), Positioned( right: -2, bottom: -2, child: Icon( Icons.check, size: 10, color: Colors.white, ), ), ], ), ) : null, ), onChanged: (text) async { setState(() { _typeSaving = true; _typeSaved = false; }); try { await ref .read( tasksControllerProvider, ) .updateTask( taskId: task.id, requestTypeOther: text.isEmpty ? null : text, ); setState( () => _typeSaved = text.isNotEmpty, ); } catch (_) { } finally { setState( () => _typeSaving = false, ); if (_typeSaved) { Future.delayed( const Duration(seconds: 2), () { if (mounted) { setState( () => _typeSaved = false, ); } }, ); } } }, ), ], const SizedBox(height: 8), const Text('Category'), const SizedBox(height: 6), DropdownButtonFormField( initialValue: task.requestCategory, decoration: InputDecoration( suffixIcon: _categorySaving ? SizedBox( width: 16, height: 16, child: ScaleTransition( scale: _savePulse, child: const Icon( Icons.save, size: 14, ), ), ) : _categorySaved ? SizedBox( width: 16, height: 16, child: Stack( alignment: Alignment.center, children: const [ Icon( Icons.save, size: 14, color: Colors.green, ), Positioned( right: -2, bottom: -2, child: Icon( Icons.check, size: 10, color: Colors.white, ), ), ], ), ) : null, ), items: [ const DropdownMenuItem( value: null, child: Text('None'), ), for (final c in requestCategoryOptions) DropdownMenuItem( value: c, child: Text(c), ), ], onChanged: (v) async { setState(() { _categorySaving = true; _categorySaved = false; }); try { await ref .read(tasksControllerProvider) .updateTask( taskId: task.id, requestCategory: v, ); setState( () => _categorySaved = v != null && v.isNotEmpty, ); } catch (_) { } finally { setState( () => _categorySaving = false, ); if (_categorySaved) { Future.delayed( const Duration(seconds: 2), () { if (mounted) { setState( () => _categorySaved = false, ); } }, ); } } }, ), ], const SizedBox(height: 12), ], ), ), ), // Signatories (editable) SingleChildScrollView( child: Padding( padding: const EdgeInsets.only(top: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Requested by', style: Theme.of( context, ).textTheme.bodySmall, ), const SizedBox(height: 6), TypeAheadFormField( textFieldConfiguration: TextFieldConfiguration( controller: _requestedController, decoration: InputDecoration( hintText: 'Requester name or id', suffixIcon: _requestedSaving ? SizedBox( width: 16, height: 16, child: ScaleTransition( scale: _savePulse, child: const Icon( Icons.save, size: 14, ), ), ) : _requestedSaved ? SizedBox( width: 16, height: 16, child: Stack( alignment: Alignment.center, children: const [ Icon( Icons.save, size: 14, color: Colors.green, ), Positioned( right: -2, bottom: -2, child: Icon( Icons.check, size: 10, color: Colors.white, ), ), ], ), ) : null, ), onChanged: (v) { _requestedDebounce?.cancel(); _requestedDebounce = Timer( const Duration(milliseconds: 700), () async { final name = v.trim(); setState(() { _requestedSaving = true; _requestedSaved = false; }); try { await ref .read( tasksControllerProvider, ) .updateTask( taskId: task.id, requestedBy: name.isEmpty ? null : name, ); if (name.isNotEmpty) { try { await ref .read( supabaseClientProvider, ) .from('clients') .upsert({ 'name': name, }); } catch (_) {} } setState(() { _requestedSaved = name.isNotEmpty; }); } catch (_) { } finally { setState(() { _requestedSaving = false; }); if (_requestedSaved) { Future.delayed( const Duration( seconds: 2, ), () { if (mounted) { setState( () => _requestedSaved = false, ); } }, ); } } }, ); }, ), suggestionsCallback: (pattern) async { final profiles = ref .watch(profilesProvider) .valueOrNull ?? []; final fromProfiles = profiles .map( (p) => p.fullName.isEmpty ? p.id : p.fullName, ) .where( (n) => n.toLowerCase().contains( pattern.toLowerCase(), ), ) .toList(); try { final clientRows = await ref .read(supabaseClientProvider) .from('clients') .select('name') .ilike('name', '%$pattern%'); final clientNames = (clientRows as List?) ?.map( (r) => r['name'] as String, ) .whereType() .toList() ?? []; final merged = { ...fromProfiles, ...clientNames, }.toList(); return merged; } catch (_) { return fromProfiles; } }, itemBuilder: (context, suggestion) => ListTile(title: Text(suggestion)), onSuggestionSelected: (suggestion) async { _requestedDebounce?.cancel(); _requestedController.text = suggestion; setState(() { _requestedSaving = true; _requestedSaved = false; }); try { await ref .read( tasksControllerProvider, ) .updateTask( taskId: task.id, requestedBy: suggestion.isEmpty ? null : suggestion, ); if (suggestion.isNotEmpty) { try { await ref .read( supabaseClientProvider, ) .from('clients') .upsert({ 'name': suggestion, }); } catch (_) {} } setState( () => _requestedSaved = suggestion.isNotEmpty, ); } catch (_) { } finally { setState( () => _requestedSaving = false, ); if (_requestedSaved) { Future.delayed( const Duration(seconds: 2), () { if (mounted) { setState( () => _requestedSaved = false, ); } }, ); } } }, ), const SizedBox(height: 12), Text( 'Noted by (Supervisor/Senior)', style: Theme.of( context, ).textTheme.bodySmall, ), const SizedBox(height: 6), TypeAheadFormField( textFieldConfiguration: TextFieldConfiguration( controller: _notedController, decoration: InputDecoration( hintText: 'Supervisor/Senior', suffixIcon: _notedSaving ? SizedBox( width: 16, height: 16, child: ScaleTransition( scale: _savePulse, child: const Icon( Icons.save, size: 14, ), ), ) : _notedSaved ? SizedBox( width: 16, height: 16, child: Stack( alignment: Alignment.center, children: const [ Icon( Icons.save, size: 14, color: Colors.green, ), Positioned( right: -2, bottom: -2, child: Icon( Icons.check, size: 10, color: Colors.white, ), ), ], ), ) : null, ), onChanged: (v) { _notedDebounce?.cancel(); _notedDebounce = Timer( const Duration(milliseconds: 700), () async { final name = v.trim(); setState(() { _notedSaving = true; _notedSaved = false; }); try { await ref .read( tasksControllerProvider, ) .updateTask( taskId: task.id, notedBy: name.isEmpty ? null : name, ); if (name.isNotEmpty) { try { await ref .read( supabaseClientProvider, ) .from('clients') .upsert({ 'name': name, }); } catch (_) {} } setState(() { _notedSaved = name.isNotEmpty; }); } catch (_) { // ignore } finally { setState(() { _notedSaving = false; }); if (_notedSaved) { Future.delayed( const Duration( seconds: 2, ), () { if (mounted) { setState( () => _notedSaved = false, ); } }, ); } } }, ); }, ), suggestionsCallback: (pattern) async { final profiles = ref .watch(profilesProvider) .valueOrNull ?? []; final fromProfiles = profiles .map( (p) => p.fullName.isEmpty ? p.id : p.fullName, ) .where( (n) => n.toLowerCase().contains( pattern.toLowerCase(), ), ) .toList(); try { final clientRows = await ref .read(supabaseClientProvider) .from('clients') .select('name') .ilike('name', '%$pattern%'); final clientNames = (clientRows as List?) ?.map( (r) => r['name'] as String, ) .whereType() .toList() ?? []; final merged = { ...fromProfiles, ...clientNames, }.toList(); return merged; } catch (_) { return fromProfiles; } }, itemBuilder: (context, suggestion) => ListTile(title: Text(suggestion)), onSuggestionSelected: (suggestion) async { _notedDebounce?.cancel(); _notedController.text = suggestion; setState(() { _notedSaving = true; _notedSaved = false; }); try { await ref .read( tasksControllerProvider, ) .updateTask( taskId: task.id, notedBy: suggestion.isEmpty ? null : suggestion, ); if (suggestion.isNotEmpty) { try { await ref .read( supabaseClientProvider, ) .from('clients') .upsert({ 'name': suggestion, }); } catch (_) {} } setState( () => _notedSaved = suggestion.isNotEmpty, ); } catch (_) { } finally { setState( () => _notedSaving = false, ); if (_notedSaved) { Future.delayed( const Duration(seconds: 2), () { if (mounted) { setState( () => _notedSaved = false, ); } }, ); } } }, ), const SizedBox(height: 12), Text( 'Received by', style: Theme.of( context, ).textTheme.bodySmall, ), const SizedBox(height: 6), TypeAheadFormField( textFieldConfiguration: TextFieldConfiguration( controller: _receivedController, decoration: InputDecoration( hintText: 'Receiver name or id', suffixIcon: _receivedSaving ? SizedBox( width: 16, height: 16, child: ScaleTransition( scale: _savePulse, child: const Icon( Icons.save, size: 14, ), ), ) : _receivedSaved ? SizedBox( width: 16, height: 16, child: Stack( alignment: Alignment.center, children: const [ Icon( Icons.save, size: 14, color: Colors.green, ), Positioned( right: -2, bottom: -2, child: Icon( Icons.check, size: 10, color: Colors.white, ), ), ], ), ) : null, ), onChanged: (v) { _receivedDebounce?.cancel(); _receivedDebounce = Timer( const Duration(milliseconds: 700), () async { final name = v.trim(); setState(() { _receivedSaving = true; _receivedSaved = false; }); try { await ref .read( tasksControllerProvider, ) .updateTask( taskId: task.id, receivedBy: name.isEmpty ? null : name, ); if (name.isNotEmpty) { try { await ref .read( supabaseClientProvider, ) .from('clients') .upsert({ 'name': name, }); } catch (_) {} } setState(() { _receivedSaved = name.isNotEmpty; }); } catch (_) { // ignore } finally { setState(() { _receivedSaving = false; }); if (_receivedSaved) { Future.delayed( const Duration( seconds: 2, ), () { if (mounted) { setState( () => _receivedSaved = false, ); } }, ); } } }, ); }, ), suggestionsCallback: (pattern) async { final profiles = ref .watch(profilesProvider) .valueOrNull ?? []; final fromProfiles = profiles .map( (p) => p.fullName.isEmpty ? p.id : p.fullName, ) .where( (n) => n.toLowerCase().contains( pattern.toLowerCase(), ), ) .toList(); try { final clientRows = await ref .read(supabaseClientProvider) .from('clients') .select('name') .ilike('name', '%$pattern%'); final clientNames = (clientRows as List?) ?.map( (r) => r['name'] as String, ) .whereType() .toList() ?? []; final merged = { ...fromProfiles, ...clientNames, }.toList(); return merged; } catch (_) { return fromProfiles; } }, itemBuilder: (context, suggestion) => ListTile(title: Text(suggestion)), onSuggestionSelected: (suggestion) async { _receivedDebounce?.cancel(); _receivedController.text = suggestion; setState(() { _receivedSaving = true; _receivedSaved = false; }); try { await ref .read( tasksControllerProvider, ) .updateTask( taskId: task.id, receivedBy: suggestion.isEmpty ? null : suggestion, ); if (suggestion.isNotEmpty) { try { await ref .read( supabaseClientProvider, ) .from('clients') .upsert({ 'name': suggestion, }); } catch (_) {} } setState( () => _receivedSaved = suggestion.isNotEmpty, ); } catch (_) { } finally { setState( () => _receivedSaving = false, ); if (_receivedSaved) { Future.delayed( const Duration(seconds: 2), () { if (mounted) { setState( () => _receivedSaved = false, ); } }, ); } } }, ), ], ), ), ), // Action taken (rich text) Padding( padding: const EdgeInsets.only(top: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Action taken'), const SizedBox(height: 6), // Toolbar + editor with inline save indicator Container( height: isWide ? 260 : 220, padding: const EdgeInsets.all(8), decoration: BoxDecoration( border: Border.all( color: Theme.of( context, ).colorScheme.outline, ), borderRadius: BorderRadius.circular(8), ), child: Stack( children: [ Column( children: [ Row( children: [ IconButton( tooltip: 'Bold', icon: const Icon( Icons.format_bold, ), onPressed: () => _actionController ?.formatSelection( quill .Attribute .bold, ), ), IconButton( tooltip: 'Italic', icon: const Icon( Icons.format_italic, ), onPressed: () => _actionController ?.formatSelection( quill .Attribute .italic, ), ), IconButton( tooltip: 'Underline', icon: const Icon( Icons.format_underlined, ), onPressed: () => _actionController ?.formatSelection( quill .Attribute .underline, ), ), IconButton( tooltip: 'Bullet list', icon: const Icon( Icons .format_list_bulleted, ), onPressed: () => _actionController ?.formatSelection( quill .Attribute .ul, ), ), IconButton( tooltip: 'Numbered list', icon: const Icon( Icons .format_list_numbered, ), onPressed: () => _actionController ?.formatSelection( quill .Attribute .ol, ), ), const SizedBox(width: 8), IconButton( tooltip: 'Heading 2', icon: const Icon( Icons.format_size, ), onPressed: () => _actionController ?.formatSelection( quill .Attribute .h2, ), ), IconButton( tooltip: 'Heading 3', icon: const Icon( Icons.format_size, size: 18, ), onPressed: () => _actionController ?.formatSelection( quill .Attribute .h3, ), ), IconButton( tooltip: 'Undo', icon: const Icon( Icons.undo, ), onPressed: () => _actionController ?.undo(), ), IconButton( tooltip: 'Redo', icon: const Icon( Icons.redo, ), onPressed: () => _actionController ?.redo(), ), IconButton( tooltip: 'Insert link', icon: const Icon( Icons.link, ), onPressed: () async { final urlCtrl = TextEditingController(); final res = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text( 'Insert link', ), content: TextField( controller: urlCtrl, decoration: const InputDecoration( hintText: 'https://', ), ), actions: [ TextButton( onPressed: () => Navigator.of( ctx, ).pop(), child: const Text( 'Cancel', ), ), TextButton( onPressed: () => Navigator.of( ctx, ).pop( urlCtrl.text .trim(), ), child: const Text( 'Insert', ), ), ], ), ); if (res == null || res.isEmpty) { return; } final sel = _actionController ?.selection ?? const TextSelection.collapsed( offset: 0, ); final start = sel.baseOffset; final end = sel.extentOffset; if (!sel.isCollapsed && end > start) { final len = end - start; try { _actionController ?.document .delete( start, len, ); } catch (_) {} _actionController ?.document .insert(start, res); } else { _actionController ?.document .insert(start, res); } }, ), IconButton( tooltip: 'Insert image', icon: const Icon( Icons.image, ), onPressed: () async { try { final r = await FilePicker .platform .pickFiles( withData: true, type: FileType .image, ); if (r == null || r.files.isEmpty) { return; } final file = r.files.first; final bytes = file.bytes; if (bytes == null) { return; } final ext = file.extension ?? 'png'; String? url; try { url = await ref .read( tasksControllerProvider, ) .uploadActionImage( taskId: task.id, bytes: bytes, extension: ext, ); } catch (e) { showErrorSnackBar( context, 'Upload error: $e', ); return; } if (url == null) { showErrorSnackBar( context, 'Image upload failed (no URL returned)', ); return; } final trimmedUrl = url .trim(); final idx = _actionController ?.selection .baseOffset ?? 0; // ignore: avoid_print print( 'inserting image embed idx=$idx url=$trimmedUrl', ); _actionController ?.document .insert( idx, quill .BlockEmbed.image( trimmedUrl, ), ); } catch (_) {} }, ), ], ), const SizedBox(height: 6), Expanded( child: MouseRegion( cursor: SystemMouseCursors.text, child: quill.QuillEditor.basic( controller: _actionController!, focusNode: _actionFocusNode, scrollController: _actionScrollController, config: quill.QuillEditorConfig( embedBuilders: const [ _ImageEmbedBuilder(), ], scrollable: true, padding: EdgeInsets.zero, ), ), ), ), ], ), Positioned( right: 6, bottom: 6, child: _actionSaving ? SizedBox( width: 20, height: 20, child: ScaleTransition( scale: _savePulse, child: const Icon( Icons.save, size: 16, ), ), ) : _actionSaved ? SizedBox( width: 20, height: 20, child: Stack( alignment: Alignment.center, children: const [ Icon( Icons.save, size: 16, color: Colors.green, ), Positioned( right: -2, bottom: -2, child: Icon( Icons.check, size: 10, color: Colors.white, ), ), ], ), ) : const SizedBox.shrink(), ), ], ), ), ], ), ), ], ), ), ], ), ), ], ), ], ); final detailsCard = Card( child: Padding( padding: const EdgeInsets.all(20), child: SingleChildScrollView(child: detailsContent), ), ); // Tabbed area: Chat + Activity final tabbedCard = Card( child: DefaultTabController( length: 2, child: Column( children: [ Material( color: Theme.of(context).colorScheme.surface, child: TabBar( labelColor: Theme.of(context).colorScheme.onSurface, indicatorColor: Theme.of(context).colorScheme.primary, tabs: const [ Tab(text: 'Chat'), Tab(text: 'Activity'), ], ), ), SizedBox(height: 8), Expanded( child: TabBarView( children: [ // Chat tab (existing messages UI) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 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), ), ], ), ], ), ), ), ], ), ), // Activity tab Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: _buildActivityTab( task, assignments, messagesAsync, profilesAsync.valueOrNull ?? [], ), ), ], ), ), ], ), ), ); if (isWide) { return Row( children: [ Expanded(flex: 2, child: detailsCard), const SizedBox(width: 16), Expanded(flex: 3, child: tabbedCard), ], ); } // Mobile/tablet: allow vertical scrolling of detail card while // keeping the chat/activity panel filling the remaining viewport // (and scrolling internally). Use a CustomScrollView to provide a // bounded height for the tabbed card via SliverFillRemaining. return CustomScrollView( slivers: [ SliverToBoxAdapter(child: detailsCard), const SliverToBoxAdapter(child: SizedBox(height: 12)), SliverFillRemaining(hasScrollBody: true, child: tabbedCard), ], ); }, ), ); } 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(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, profiles), ), ], ), ); }, ); } Widget _buildActivityTab( Task task, List assignments, AsyncValue> messagesAsync, List profiles, ) { final logsAsync = ref.watch(taskActivityLogsProvider(task.id)); final logs = logsAsync.valueOrNull ?? []; final profileById = {for (final p in profiles) p.id: p}; // Find the latest assignment (by createdAt) final assignedForTask = assignments.where((a) => a.taskId == task.id).toList() ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); final latestAssignment = assignedForTask.isEmpty ? null : assignedForTask.last; DateTime? startedByAssignee; if (latestAssignment != null) { for (final l in logs) { if (l.actionType == 'started') { if (l.actorId == latestAssignment.userId && l.createdAt.isAfter(latestAssignment.createdAt)) { startedByAssignee = l.createdAt; break; } } } } Duration? responseDuration; DateTime? responseAt; if (latestAssignment != null) { final assignedAt = latestAssignment.createdAt; final candidates = []; if (startedByAssignee != null) { candidates.add(startedByAssignee); } if (candidates.isNotEmpty) { candidates.sort(); responseAt = candidates.first; responseDuration = responseAt.difference(assignedAt); } } // Render timeline (oldest -> newest) final timeline = []; if (logs.isEmpty) { timeline.add(const Text('No activity yet.')); } else { final chronological = List.from(logs.reversed); for (final l in chronological) { final actorName = l.actorId == null ? 'System' : (profileById[l.actorId]?.fullName ?? l.actorId!); switch (l.actionType) { case 'created': timeline.add(_activityRow('Task created', actorName, l.createdAt)); break; case 'assigned': final meta = l.meta ?? {}; final userId = meta['user_id'] as String?; final auto = meta['auto'] == true; final name = userId == null ? 'Unknown' : (profileById[userId]?.fullName ?? userId); timeline.add( _activityRow( auto ? 'Auto-assigned to $name' : 'Assigned to $name', actorName, l.createdAt, ), ); break; case 'reassigned': final meta = l.meta ?? {}; final to = (meta['to'] as List?) ?? []; final toNames = to .map((id) => profileById[id]?.fullName ?? id) .join(', '); timeline.add( _activityRow('Reassigned to $toNames', actorName, l.createdAt), ); break; case 'started': timeline.add(_activityRow('Task started', actorName, l.createdAt)); break; case 'completed': timeline.add( _activityRow('Task completed', actorName, l.createdAt), ); break; default: timeline.add(_activityRow(l.actionType, actorName, l.createdAt)); } } } if (responseDuration != null) { final assigneeName = profileById[latestAssignment!.userId]?.fullName ?? latestAssignment.userId; timeline.add(const SizedBox(height: 12)); timeline.add( Row( children: [ const Icon(Icons.timer, size: 18), const SizedBox(width: 8), Expanded( child: Text( 'Response Time: ${_formatDuration(responseDuration)} ($assigneeName responded at ${responseAt!.toLocal().toString()})', style: Theme.of(context).textTheme.bodyMedium, ), ), ], ), ); } return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: timeline, ), ); } 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, ).asBroadcastStream(), 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)}'), ], ); } Widget _activityRow(String title, String actor, DateTime at) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.circle, size: 12, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 4), Text( '$actor • ${at.toLocal()}', style: Theme.of(context).textTheme.labelSmall, ), ], ), ), ], ), ); } 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; } // Safely stop typing — controller may have been auto-disposed by Riverpod. final typingController = _maybeTypingController(typingChannelId); typingController?.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) { _maybeTypingController(typingChannelId)?.stopTyping(); _clearMentions(); return; } _maybeTypingController(typingChannelId)?.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 [channelId]. // Returns null if the provider has been disposed or is not mounted. TypingIndicatorController? _maybeTypingController(String channelId) { try { final controller = ref.read(typingIndicatorProvider(channelId).notifier); return controller.mounted ? controller : null; } on StateError { // provider was disposed concurrently return null; } catch (_) { return null; } } 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( 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(); } 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...'; } Future _showEditTaskDialog( BuildContext context, WidgetRef ref, Task task, ) async { final officesAsync = ref.watch(officesOnceProvider); final titleCtrl = TextEditingController(text: task.title); final descCtrl = TextEditingController(text: task.description); String? selectedOffice = task.officeId; await showDialog( context: context, builder: (dialogContext) { return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Edit Task'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: titleCtrl, decoration: const InputDecoration(labelText: 'Title'), ), 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.from(offices) ..sort( (a, b) => a.name.toLowerCase().compareTo( b.name.toLowerCase(), ), ); return DropdownButtonFormField( 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, _) => const SizedBox.shrink(), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), ElevatedButton( onPressed: () async { final outerContext = context; final title = titleCtrl.text.trim(); final desc = descCtrl.text.trim(); try { await ref .read(tasksControllerProvider) .updateTaskFields( taskId: task.id, title: title.isEmpty ? null : title, description: desc.isEmpty ? null : desc, officeId: selectedOffice, ); if (!mounted) return; Navigator.of(outerContext).pop(); showSuccessSnackBar(outerContext, 'Task updated'); } catch (e) { if (!mounted) return; showErrorSnackBar(outerContext, 'Failed to update task: $e'); } }, child: const Text('Save'), ), ], ); }, ); } 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 { // Update DB only — Supabase realtime stream will emit the // updated task list, so explicit invalidation here causes a // visible loading/refresh and is unnecessary. try { await ref .read(tasksControllerProvider) .updateTaskStatus(taskId: task.id, status: value); } catch (e) { // surface validation or other errors to user if (mounted) { showErrorSnackBar(context, e.toString()); } } }, 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, ); } // PDF preview/building moved to `task_pdf.dart`. } class _MetaBadge extends StatelessWidget { const _MetaBadge({required this.label, required this.value, this.isMono}); final String label; final String value; final bool? isMono; @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; }