// ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; import '../../theme/m3_motion.dart'; import 'package:flutter/services.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 '../../providers/realtime_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../utils/app_time.dart'; import '../../utils/snackbar.dart'; import '../../utils/subject_suggestions.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'; import '../../widgets/gemini_button.dart'; import '../../widgets/gemini_animated_text_field.dart'; import '../../services/ai_service.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; /// Tracks the last plain-text snapshot so the addListener callback can /// distinguish real edits from cursor / selection / focus notifications. String _actionLastPlain = ''; 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; bool _actionProcessing = false; bool _pauseActionInFlight = false; Timer? _elapsedTicker; DateTime _elapsedNow = AppTime.now(); late final AnimationController _saveAnimController; late final Animation _savePulse; static const List _statusOptions = [ 'queued', 'in_progress', 'completed', 'cancelled', ]; String? _mentionQuery; int? _mentionStart; List _mentionResults = []; // Attachments state List? _attachments; bool _loadingAttachments = false; @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(); _elapsedTicker?.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) { // Use per-item providers to avoid rebuilding when unrelated tasks/tickets // change. Only the specific task/ticket for this screen triggers rebuilds. final task = ref.watch(taskByIdProvider(widget.taskId)); 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); // Loading state: use .select() so we only rebuild when the loading flag // itself changes, not when list data changes. final isTasksLoading = ref.watch( tasksProvider.select((a) => !a.hasValue && a.isLoading), ); final isTicketsLoading = ref.watch( ticketsProvider.select((a) => !a.hasValue && a.isLoading), ); 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 : ref.watch(ticketByIdProvider(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 profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; final hasAssignedItStaff = assignments.any((assignment) { if (assignment.taskId != task.id) { return false; } return profileById[assignment.userId]?.role == 'it_staff'; }); final canUpdateStatus = _canUpdateStatus( profileAsync.valueOrNull, assignments, task.id, ); final taskLogs = ref.watch(taskActivityLogsProvider(task.id)).valueOrNull ?? []; final isTaskPaused = _isTaskCurrentlyPaused(task, taskLogs); final elapsedDuration = _currentElapsedDuration( task, taskLogs, isTaskPaused, ); 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(); _syncElapsedTicker(task, taskLogs, isTaskPaused); }); final realtime = ref.watch(realtimeControllerProvider); final isRetrieving = realtime.isChannelRecovering('tasks') || realtime.isChannelRecovering('task_assignments') || isTasksLoading || isTicketsLoading || (!officesAsync.hasValue && officesAsync.isLoading) || (!profileAsync.hasValue && profileAsync.isLoading) || (!assignmentsAsync.hasValue && assignmentsAsync.isLoading) || (!taskMessagesAsync.hasValue && taskMessagesAsync.isLoading); return Skeletonizer( enabled: isRetrieving, child: 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; // Reset attachments for new task _attachments = null; _loadingAttachments = false; // 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(); } // Snapshot current content so the listener can skip non-edit // notifications (cursor moves, selection changes, focus events). _actionLastPlain = _actionController?.document.toPlainText().trim() ?? ''; // Attach auto-save listener for action taken (debounced). // QuillController fires notifications on every cursor / // selection / formatting change — not just text edits. We // compare plaintext to avoid endlessly resetting the timer. _actionController?.addListener(() { final currentPlain = _actionController?.document.toPlainText().trim() ?? ''; if (currentPlain == _actionLastPlain) return; // no text change _actionLastPlain = currentPlain; _actionDebounce?.cancel(); _actionDebounce = Timer( const Duration(milliseconds: 700), () async { if (!mounted) return; final plain = currentPlain; if (mounted) { setState(() { _actionSaving = true; _actionSaved = false; }); } try { final deltaJson = jsonEncode( _actionController?.document.toDelta().toJson(), ); await ref .read(tasksControllerProvider) .updateTask(taskId: task.id, actionTaken: deltaJson); if (mounted) { setState(() { _actionSaved = plain.isNotEmpty; }); } } catch (e) { debugPrint('[TasQ] action-taken auto-save error: $e'); } finally { if (mounted) { 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: [ Flexible( child: 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, hasAssignedItStaff, ), _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 ?? []; // Read the services stream; if the office is linked to a service // but the stream hasn't yielded yet, fetch once and await it final servicesAsync = ref.read(servicesProvider); final servicesById = { for (final s in servicesAsync.valueOrNull ?? []) s.id: s, }; final officeServiceId = officeId == null ? null : officeById[officeId]?.serviceId; if (officeServiceId != null && (servicesAsync.valueOrNull == null || (servicesAsync.valueOrNull?.isEmpty ?? true))) { final servicesOnce = await ref.read( servicesOnceProvider.future, ); for (final s in servicesOnce) { servicesById[s.id] = s; } } final serviceName = officeServiceId == null ? '' : (servicesById[officeServiceId]?.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: 5, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TabBar( labelColor: Theme.of(context).colorScheme.onSurface, indicatorColor: Theme.of( context, ).colorScheme.primary, tabs: isWide ? const [ Tab(text: 'Assignees'), Tab(text: 'Type & Category'), Tab(text: 'Signatories'), Tab(text: 'Action taken'), Tab(text: 'Attachments'), ] : const [ Tab(icon: Icon(Icons.person)), Tab(icon: Icon(Icons.category)), Tab(icon: Icon(Icons.check_circle)), Tab(icon: Icon(Icons.description)), Tab(icon: Icon(Icons.attach_file)), ], ), const SizedBox(height: 8), SizedBox( height: isWide ? 360 : 300, child: TabBarView( children: [ // Assignees (Tab 1) Stack( children: [ Positioned.fill( child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.only( top: 8.0, bottom: 92, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TaskAssignmentSection( taskId: task.id, canAssign: showAssign, ), const SizedBox(height: 12), const SizedBox.shrink(), ], ), ), ), ), if (canUpdateStatus && task.status == 'in_progress') Positioned( right: 8, bottom: 8, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ IconButton.filledTonal( tooltip: isTaskPaused ? 'Resume task' : 'Pause task', onPressed: _pauseActionInFlight ? null : () async { setState( () => _pauseActionInFlight = true, ); try { if (isTaskPaused) { await ref .read( tasksControllerProvider, ) .resumeTask( taskId: task.id, ); ref.invalidate( taskActivityLogsProvider( task.id, ), ); if (mounted) { showSuccessSnackBar( context, 'Task resumed', ); } } else { await ref .read( tasksControllerProvider, ) .pauseTask( taskId: task.id, ); ref.invalidate( taskActivityLogsProvider( task.id, ), ); if (mounted) { showInfoSnackBar( context, 'Task paused', ); } } } catch (e) { if (mounted) { showErrorSnackBar( context, e.toString(), ); } } finally { if (mounted) { setState( () => _pauseActionInFlight = false, ); } } }, icon: _pauseActionInFlight ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Icon( isTaskPaused ? Icons.play_arrow : Icons.pause, ), ), const SizedBox(height: 4), Text( 'Elapsed ${_formatDurationClock(elapsedDuration)}', style: Theme.of( context, ).textTheme.labelSmall, ), ], ), ), ], ), // 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: [ Row( children: [ const Text('Action taken'), const Spacer(), IconButton( tooltip: 'Improve action taken with Gemini', icon: Image.asset( 'assets/gemini_icon.png', width: 24, height: 24, errorBuilder: (context, error, stackTrace) { return const Icon( Icons.auto_awesome, ); }, ), onPressed: () => _processActionTakenWithGemini( context, ref, ), ), ], ), const SizedBox(height: 6), // Toolbar + editor with inline save indicator GeminiAnimatedBorder( isProcessing: _actionProcessing, child: 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: [ isWide ? 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 m3ShowDialog( 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 (_) {} }, ), ], ) : SingleChildScrollView( scrollDirection: Axis.horizontal, child: 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 m3ShowDialog( 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 (_) {} }, ), ], ), ), 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(), ), ], ), ), ), ], ), ), // Attachments (Tab 5) SingleChildScrollView( child: Padding( padding: const EdgeInsets.only(top: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('File Attachments'), const SizedBox(height: 12), Center( child: FilledButton.icon( icon: const Icon(Icons.upload_file), label: const Text( 'Upload File (Max 25MB)', ), onPressed: () => _uploadTaskAttachment(task.id), ), ), const SizedBox(height: 16), Builder( builder: (context) { // Load attachments once per task if (_seededTaskId != null && _seededTaskId == task.id && _attachments == null && !_loadingAttachments) { WidgetsBinding.instance .addPostFrameCallback((_) { _loadAttachments(task.id); }); } if (_loadingAttachments) { return const Center( child: CircularProgressIndicator(), ); } final files = _attachments ?? []; if (files.isEmpty) { return const Center( child: Padding( padding: EdgeInsets.all(16.0), child: Text( 'No attachments yet', style: TextStyle( color: Colors.grey, ), ), ), ); } return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: files.length, separatorBuilder: (context, index) => const Divider(), itemBuilder: (context, index) { final file = files[index]; return ListTile( leading: const Icon( Icons.insert_drive_file, ), title: Text(file), subtitle: Text( 'Tap to download', style: Theme.of( context, ).textTheme.bodySmall, ), trailing: IconButton( icon: const Icon( Icons.delete, color: Colors.red, ), onPressed: () => _deleteTaskAttachment( task.id, file, ), ), onTap: () => _downloadTaskAttachment( task.id, file, ), ); }, ); }, ), ], ), ), ), ], ), ), ], ), ), ], ), ], ); final detailsCard = Card( child: Padding( padding: const EdgeInsets.all(20), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( alignment: Alignment.topLeft, child: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), ), detailsContent, ], ), ), ), ); // 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 ?? [], ), ), ], ), ), ], ), ), ); final mobileTabbedHeight = (MediaQuery.of(context).size.height * 0.65).clamp(360.0, 720.0); final mainContent = isWide ? Row( children: [ Expanded(flex: 2, child: detailsCard), const SizedBox(width: 16), Expanded(flex: 3, child: tabbedCard), ], ) : Stack( children: [ CustomScrollView( slivers: [ SliverToBoxAdapter(child: detailsCard), const SliverToBoxAdapter(child: SizedBox(height: 12)), SliverToBoxAdapter( child: SizedBox( height: mobileTabbedHeight, child: tabbedCard, ), ), ], ), if (isRetrieving) Positioned.fill( child: AbsorbPointer( absorbing: true, child: Container( color: Theme.of(context).colorScheme.surface .withAlpha((0.35 * 255).round()), alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 36), child: SizedBox( width: 280, child: Card( elevation: 0, shadowColor: Colors.transparent, child: Padding( padding: const EdgeInsets.all(12.0), child: Row( mainAxisSize: MainAxisSize.min, children: const [ SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ), SizedBox(width: 12), Expanded( child: Text('Retrieving updates…'), ), ], ), ), ), ), ), ), ), ], ); return mainContent; }, ), ), ); } 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, icon: Icons.add_task, ), ); 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, icon: Icons.person_add, ), ); 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, icon: Icons.swap_horiz, ), ); break; case 'started': { var label = 'Task started'; if (latestAssignment != null && l.actorId == latestAssignment.userId && responseDuration != null) { final assigneeName = profileById[latestAssignment.userId]?.fullName ?? latestAssignment.userId; final resp = responseAt ?? AppTime.now(); label = 'Task started — Response: ${_formatDuration(responseDuration)} ($assigneeName responded at ${AppTime.formatDate(resp)} ${AppTime.formatTime(resp)})'; } timeline.add( _activityRow( label, actorName, l.createdAt, icon: Icons.play_arrow, ), ); } break; case 'paused': timeline.add( _activityRow( 'Task paused', actorName, l.createdAt, icon: Icons.pause_circle, ), ); break; case 'resumed': timeline.add( _activityRow( 'Task resumed', actorName, l.createdAt, icon: Icons.play_circle, ), ); break; case 'completed': { var label = 'Task completed'; final start = _resolveExecutionStart(task, logs); if (start != null) { final end = task.completedAt ?? l.createdAt; final exec = _computeEffectiveExecutionDuration( task, logs, end, ); if (exec.inMilliseconds > 0) { label = 'Task completed — Execution: ${_formatDuration(exec)} (${AppTime.formatDate(start)} ${AppTime.formatTime(start)} → ${AppTime.formatDate(end)} ${AppTime.formatTime(end)})'; } } timeline.add( _activityRow( label, actorName, l.createdAt, icon: Icons.check_circle, ), ); } break; case 'cancelled': final meta = l.meta ?? {}; final reason = (meta['reason'] as String?) ?? ''; final base = reason.isNotEmpty ? 'Task cancelled — $reason' : 'Task cancelled'; timeline.add( _activityRow(base, actorName, l.createdAt, icon: Icons.cancel), ); break; default: timeline.add( _activityRow( l.actionType, actorName, l.createdAt, icon: Icons.info, ), ); } } } // If the task is cancelled but no explicit cancelled activity row exists, // show a fallback using the task's cancellation fields. final hasCancelledLog = logs.any((e) => e.actionType == 'cancelled'); if (task.status == 'cancelled' && !hasCancelledLog) { final reason = task.cancellationReason; final at = task.cancelledAt ?? AppTime.now(); String inferredActor = 'System'; try { final candidates = logs .where((e) => e.actorId != null && e.createdAt.isBefore(at)) .toList(); if (candidates.isNotEmpty) { candidates.sort((a, b) => a.createdAt.compareTo(b.createdAt)); final last = candidates.last; inferredActor = profileById[last.actorId]?.fullName ?? last.actorId!; } } catch (_) {} timeline.add( _activityRow( reason != null && reason.isNotEmpty ? 'Task cancelled — $reason' : 'Task cancelled', inferredActor, at, icon: Icons.cancel, ), ); } // Response and execution times are now merged into the related // 'Task started' and 'Task completed' timeline entries above. return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: timeline, ), ); } // TAT helpers removed; timings are shown inline in the activity timeline. Widget _activityRow( String title, String actor, DateTime at, { IconData icon = Icons.circle, }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: 28, child: Center( child: Icon( icon, size: 18, color: Theme.of(context).colorScheme.primary, ), ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 4), Text( '$actor • ${AppTime.formatDate(at)} ${AppTime.formatTime(at)}', style: Theme.of(context).textTheme.labelSmall, ), ], ), ), ], ), ); } DateTime? _resolveExecutionStart(Task task, List logs) { DateTime? started; for (final entry in logs.reversed) { if (entry.actionType == 'started') { started = entry.createdAt; break; } } return started ?? task.startedAt; } DateTime? _latestPauseSince(List logs, DateTime notBefore) { for (final entry in logs) { if (entry.actionType != 'paused') continue; if (entry.createdAt.isBefore(notBefore)) continue; return entry.createdAt; } return null; } Duration? _currentElapsedDuration( Task task, List logs, bool isPaused, ) { if (task.status != 'in_progress') { return null; } final start = _resolveExecutionStart(task, logs); if (start == null) { return null; } final pausedAt = isPaused ? _latestPauseSince(logs, start) : null; final endAt = pausedAt ?? _elapsedNow; return _computeEffectiveExecutionDuration(task, logs, endAt); } void _syncElapsedTicker( Task task, List logs, bool isPaused, ) { final shouldRun = task.status == 'in_progress' && !isPaused && _resolveExecutionStart(task, logs) != null; if (shouldRun) { if (_elapsedTicker?.isActive != true) { _elapsedNow = AppTime.now(); _elapsedTicker = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) return; setState(() { _elapsedNow = AppTime.now(); }); }); } } else { _elapsedTicker?.cancel(); _elapsedTicker = null; _elapsedNow = AppTime.now(); } } Duration _computeEffectiveExecutionDuration( Task task, List logs, DateTime endAt, ) { final start = _resolveExecutionStart(task, logs); if (start == null || !endAt.isAfter(start)) { return Duration.zero; } Duration pausedTotal = Duration.zero; DateTime? pausedSince; final events = logs.reversed.where((entry) { if (entry.createdAt.isBefore(start)) return false; if (entry.createdAt.isAfter(endAt)) return false; return entry.actionType == 'paused' || entry.actionType == 'resumed'; }); for (final event in events) { if (event.actionType == 'paused') { pausedSince ??= event.createdAt; } else if (event.actionType == 'resumed' && pausedSince != null) { if (event.createdAt.isAfter(pausedSince)) { pausedTotal += event.createdAt.difference(pausedSince); } pausedSince = null; } } if (pausedSince != null && endAt.isAfter(pausedSince)) { pausedTotal += endAt.difference(pausedSince); } final total = endAt.difference(start) - pausedTotal; if (total.isNegative) { return Duration.zero; } return total; } bool _isTaskCurrentlyPaused(Task task, List logs) { if (task.status != 'in_progress') { return false; } var started = task.startedAt != null; var paused = false; for (final entry in logs.reversed) { switch (entry.actionType) { case 'started': started = true; paused = false; break; case 'paused': if (started) { paused = true; } break; case 'resumed': if (started) { paused = false; } break; case 'completed': case 'cancelled': paused = false; break; } } return started && paused; } 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'; } String _formatDurationClock(Duration? duration) { if (duration == null || duration.isNegative) { return '00:00:00'; } final totalSeconds = duration.inSeconds; final hours = (totalSeconds ~/ 3600).toString().padLeft(2, '0'); final minutes = ((totalSeconds % 3600) ~/ 60).toString().padLeft(2, '0'); final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); return '$hours:$minutes:$seconds'; } 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(); // Capture mentioned user ids and clear the composer immediately so the // UI does not block while the network call completes. Perform the send // and mention notification creation in a background Future. final mentionUserIds = _extractMentionedUserIds( content, profiles, currentUserId, ); if (mounted) { _messageController.clear(); _clearMentions(); } Future(() async { try { final message = await ref .read(ticketsControllerProvider) .sendTaskMessage( taskId: task.id, ticketId: task.ticketId, content: content, ); if (mentionUserIds.isNotEmpty && currentUserId != null) { try { await ref .read(notificationsControllerProvider) .createMentionNotifications( userIds: mentionUserIds, actorId: currentUserId, ticketId: task.ticketId, taskId: task.id, messageId: message.id, ); } catch (_) {} } } catch (e, st) { debugPrint('sendTaskMessage error: $e\n$st'); } }); } 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 dialogShape = AppSurfaces.of(context).dialogShape; // offices will be watched inside the dialog's Consumer so the dialog // can rebuild independently when the provider completes. final titleCtrl = TextEditingController(text: task.title); final descCtrl = TextEditingController(text: task.description); final existingSubjects = [ ...((ref.read(tasksProvider).valueOrNull ?? const []).map( (task) => task.title, )), ...((ref.read(ticketsProvider).valueOrNull ?? const []).map( (ticket) => ticket.subject, )), ]; String? selectedOffice = task.officeId; // ---- Title-field AI-button visibility ---- // The button is hidden by default and shown only after the user pauses // manual typing (700 ms debounce). We can NOT rely on onChanged or // controller listeners because TypeAheadFormField fires those // unpredictably (on focus, overlay teardown, suggestion selection). // Instead we wrap the field in a KeyboardListener and only react to // actual key events. var showTitleGemini = false; var titleDeepSeek = false; var descDeepSeek = false; Timer? titleTypingTimer; try { await m3ShowDialog( context: context, builder: (dialogContext) { var saving = false; var titleProcessing = false; var descProcessing = false; return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( shape: dialogShape, title: const Text('Edit Task'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: GeminiAnimatedBorder( isProcessing: titleProcessing, useDeepSeekColors: titleDeepSeek, child: KeyboardListener( focusNode: FocusNode(), onKeyEvent: (event) { // Only react to actual key-down (typing). if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return; } // Skip modifier-only keys. if (event.character == null || event.character!.isEmpty) { return; } titleTypingTimer?.cancel(); if (showTitleGemini) { setDialogState( () => showTitleGemini = false, ); } titleTypingTimer = Timer( const Duration(milliseconds: 700), () { if (titleCtrl.text.trim().isNotEmpty) { setDialogState( () => showTitleGemini = true, ); } }, ); }, child: TypeAheadFormField( textFieldConfiguration: TextFieldConfiguration( controller: titleCtrl, enabled: !saving, decoration: const InputDecoration( labelText: 'Title', ), ), suggestionsCallback: (pattern) async { return SubjectSuggestionEngine.suggest( existingSubjects: existingSubjects, query: pattern, limit: 8, ); }, itemBuilder: (context, suggestion) => ListTile( dense: true, title: Text(suggestion), ), onSuggestionSelected: (suggestion) { titleTypingTimer?.cancel(); titleCtrl ..text = suggestion ..selection = TextSelection.collapsed( offset: suggestion.length, ); setDialogState( () => showTitleGemini = false, ); }, ), ), ), ), // Show Gemini button only after the user pauses // typing and has not selected a suggestion. if (showTitleGemini) GeminiButton( textController: titleCtrl, onTextUpdated: (updatedText) { titleTypingTimer?.cancel(); setDialogState(() { titleCtrl.text = updatedText; showTitleGemini = false; }); }, onProcessingStateChanged: (isProcessing) { setDialogState(() { titleProcessing = isProcessing; }); }, onProviderChanged: (isDeepSeek) { setDialogState( () => titleDeepSeek = isDeepSeek, ); }, tooltip: 'Improve task title with Gemini', promptBuilder: (_) => 'Fix the spelling and grammar of this IT ' 'helpdesk ticket subject. Make it concise, ' 'clear, and professional in English. ' 'Return ONLY the corrected subject, ' 'no explanations:', ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: GeminiAnimatedTextField( controller: descCtrl, enabled: !saving, labelText: 'Description', maxLines: 4, isProcessing: descProcessing, useDeepSeekColors: descDeepSeek, ), ), Padding( padding: const EdgeInsets.only(left: 8.0), child: GeminiButton( textController: descCtrl, onTextUpdated: (updatedText) { setDialogState(() { descCtrl.text = updatedText; }); }, onProcessingStateChanged: (isProcessing) { setDialogState(() { descProcessing = isProcessing; }); }, onProviderChanged: (isDeepSeek) { setDialogState(() => descDeepSeek = isDeepSeek); }, tooltip: 'Improve description with Gemini', promptBuilder: (_) { final subject = titleCtrl.text.trim(); final hint = subject.isNotEmpty ? 'about "$subject" ' : ''; return 'Improve this IT helpdesk ticket ' 'description ${hint}for clarity and ' 'professionalism. Fix grammar and translate ' 'to English. Return ONLY the improved ' 'description, no explanations:'; }, ), ), ], ), const SizedBox(height: 8), Consumer( builder: (dialogContext, dialogRef, _) { final officesAsync = dialogRef.watch( officesOnceProvider, ); return 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: saving ? null : (v) => setDialogState( () => selectedOffice = v, ), ); }, loading: () => const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: LinearProgressIndicator(), ), error: (error, _) => const SizedBox.shrink(), ); }, ), ], ), ), actions: [ TextButton( onPressed: saving ? null : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: saving ? null : () async { final title = SubjectSuggestionEngine.normalizeDisplay( titleCtrl.text.trim(), ); final desc = descCtrl.text.trim(); setDialogState(() => saving = true); try { await ref .read(tasksControllerProvider) .updateTaskFields( taskId: task.id, title: title.isEmpty ? null : title, description: desc.isEmpty ? null : desc, officeId: selectedOffice, ); ref.invalidate(tasksProvider); ref.invalidate(taskByIdProvider(task.id)); if (!mounted) return; Navigator.of(dialogContext).pop(); showSuccessSnackBar(context, 'Task updated'); } catch (e) { if (!mounted) return; showErrorSnackBar( context, 'Failed to update task: $e', ); } finally { if (dialogContext.mounted) { setDialogState(() => saving = false); } } }, child: saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save'), ), ], ); }, ); }, ); } finally { titleTypingTimer?.cancel(); } } /// Directly enhances the Action Taken field with Gemini — no dialog, /// no language detection. Builds a context-aware prompt from the task's /// title and description so the model understands what is being addressed. Future _processActionTakenWithGemini( BuildContext context, WidgetRef ref, ) async { final plainText = _actionController?.document.toPlainText().trim() ?? ''; if (plainText.isEmpty) { if (!context.mounted) return; showWarningSnackBar(context, 'Please enter some action taken text first'); return; } // Use task title/description as context for a richer prompt. final task = ref.read(taskByIdProvider(widget.taskId)); final subject = task?.title.trim() ?? ''; final description = task?.description.trim() ?? ''; final hint = StringBuffer( 'This is the action taken / workaround for an IT helpdesk ticket', ); if (subject.isNotEmpty) hint.write(' about "$subject"'); if (description.isNotEmpty) { hint.write('. Ticket description: "$description"'); } hint.write( '. Fix spelling and grammar, improve clarity, and translate to ' 'professional English. Return ONLY the improved text, no explanations:', ); if (mounted) { setState(() { _actionSaving = true; _actionProcessing = true; }); } try { final aiService = AiService(); final improvedText = await aiService.enhanceText( plainText, promptInstruction: hint.toString(), ); final trimmed = improvedText.trim(); // Build delta JSON directly — [{"insert": "text\n"}] — to avoid any // race between replaceText() and reading toDelta() immediately after. final deltaJson = jsonEncode([ {'insert': '$trimmed\n'}, ]); _actionDebounce?.cancel(); await ref .read(tasksControllerProvider) .updateTask(taskId: widget.taskId, actionTaken: deltaJson); if (_actionController != null) { // Update the snapshot BEFORE replaceText so the listener sees no // content change and skips the redundant auto-save. _actionLastPlain = trimmed; final docLen = _actionController!.document.length; _actionController!.replaceText( 0, docLen - 1, trimmed, TextSelection.collapsed(offset: trimmed.length), ); // Cancel any debounce that may have slipped through. _actionDebounce?.cancel(); } if (context.mounted) { showSuccessSnackBar(context, 'Action taken improved successfully'); } } catch (e) { if (context.mounted) { showErrorSnackBar(context, 'Error: $e'); } } finally { if (mounted) { setState(() { _actionSaving = false; _actionProcessing = false; }); } } } bool _canAssignStaff(String role) { return role == 'admin' || role == 'dispatcher' || role == 'it_staff'; } Widget _buildStatusChip( BuildContext context, Task task, bool canUpdateStatus, bool hasAssignedItStaff, ) { final chip = StatusPill( label: task.status.toUpperCase(), isEmphasized: task.status != 'queued', ); final isTerminal = task.status == 'completed' || task.status == 'cancelled' || task.status == 'closed'; if (!canUpdateStatus || isTerminal) { return chip; } // Show all status options - validation happens on selection final statusOptions = _statusOptions; return PopupMenuButton( onSelected: (value) async { // If cancelling, require a reason — show dialog with spinner. if (value == 'cancelled') { final reasonCtrl = TextEditingController(); await m3ShowDialog( context: context, builder: (dialogContext) { var isSaving = false; var reasonProcessing = false; var reasonDeepSeek = false; return StatefulBuilder( builder: (ctx, setState) { return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Cancel task'), contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 12), content: SizedBox( width: 360, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Row( children: [ Expanded( child: GeminiAnimatedTextField( controller: reasonCtrl, enabled: !isSaving, labelText: 'Reason', maxLines: 3, isProcessing: reasonProcessing, useDeepSeekColors: reasonDeepSeek, ), ), Padding( padding: const EdgeInsets.only(left: 8.0), child: GeminiButton( textController: reasonCtrl, onTextUpdated: (updatedText) { setState(() { reasonCtrl.text = updatedText; }); }, onProcessingStateChanged: (isProcessing) { setState(() { reasonProcessing = isProcessing; }); }, onProviderChanged: (isDeepSeek) { setState(() => reasonDeepSeek = isDeepSeek); }, tooltip: 'Improve cancellation reason with Gemini', promptBuilder: (_) => 'Improve this task cancellation reason for ' 'clarity, professionalism, and concise ' 'English. Keep the original intent. Return ' 'ONLY the improved reason, no explanations:', ), ), ], ), ), ), actions: [ TextButton( onPressed: isSaving ? null : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: isSaving ? null : () async { final reason = reasonCtrl.text.trim(); if (reason.isEmpty) { showErrorSnackBar( context, 'Cancellation requires a reason.', ); return; } setState(() => isSaving = true); try { await ref .read(tasksControllerProvider) .updateTaskStatus( taskId: task.id, status: 'cancelled', reason: reason, ); if (context.mounted) { showSuccessSnackBar( context, 'Task cancelled', ); Navigator.of(dialogContext).pop(); } } catch (e) { if (context.mounted) { showErrorSnackBar(context, e.toString()); } } finally { if (context.mounted) { setState(() => isSaving = false); } } }, child: isSaving ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.onPrimary, ), ), ) : const Text('Save'), ), ], ); }, ); }, ); return; } // Validate IT staff assignment before starting or completing if ((value == 'in_progress' || value == 'completed') && !hasAssignedItStaff) { showWarningSnackBar( context, 'Please assign at least one IT Staff member before ${value == 'in_progress' ? 'starting' : 'completing'} this task.', ); return; } // 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, ); } Future _uploadTaskAttachment(String taskId) async { try { final result = await FilePicker.platform.pickFiles( withData: true, allowMultiple: false, ); if (result == null || result.files.isEmpty) { return; } final file = result.files.first; final bytes = file.bytes; final fileName = file.name; if (bytes == null) { if (mounted) { showErrorSnackBar(context, 'Failed to read file'); } return; } // Check file size (max 25MB) const maxSizeBytes = 25 * 1024 * 1024; if (bytes.length > maxSizeBytes) { if (mounted) { showErrorSnackBar(context, 'File size exceeds 25MB limit'); } return; } if (!mounted) return; // Show loading dialog m3ShowDialog( context: context, barrierDismissible: false, builder: (BuildContext dialogContext) { return PopScope( canPop: false, child: const AlertDialog( title: Text('Uploading...'), content: SizedBox( height: 50, child: Center(child: CircularProgressIndicator()), ), ), ); }, ); String? errorMessage; bool uploadSuccess = false; try { debugPrint('Starting upload for file: $fileName'); await ref .read(tasksControllerProvider) .uploadTaskAttachment( taskId: taskId, fileName: fileName, bytes: bytes, ); uploadSuccess = true; debugPrint('Upload completed successfully'); } catch (e) { debugPrint('Upload failed: $e'); errorMessage = e.toString(); } // Close loading dialog first, then show feedback if (mounted && Navigator.of(context).canPop()) { debugPrint('Closing loading dialog...'); Navigator.of(context, rootNavigator: true).pop(); debugPrint('Dialog closed'); } // Small delay to ensure dialog is fully closed before showing snackbar await Future.delayed(const Duration(milliseconds: 100)); if (!mounted) return; if (uploadSuccess) { debugPrint('Showing success message and reloading attachments'); showSuccessSnackBar(context, 'File uploaded successfully'); // Reload attachments list (non-blocking) _loadAttachments(taskId); debugPrint('Attachment reload triggered'); } else { showErrorSnackBar(context, 'Upload failed: $errorMessage'); } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Error: $e'); } } } Future _loadAttachments(String taskId) async { if (!mounted) return; setState(() { _loadingAttachments = true; }); try { final supabase = ref.read(supabaseClientProvider); final files = await supabase.storage .from('task_attachments') .list(path: taskId); if (mounted) { setState(() { _attachments = files.map((f) => f.name).toList(); _loadingAttachments = false; }); debugPrint('Attachments loaded: ${_attachments?.length ?? 0} files'); } } catch (e) { debugPrint('Error getting attachments list: $e'); if (mounted) { setState(() { _attachments = []; _loadingAttachments = false; }); } } } Future _downloadTaskAttachment(String taskId, String fileName) async { try { if (mounted) { showInfoSnackBar(context, 'Downloading: $fileName'); } final supabase = ref.read(supabaseClientProvider); // Download file data from storage final Uint8List bytes; try { bytes = await supabase.storage .from('task_attachments') .download('$taskId/$fileName'); debugPrint('Downloaded ${bytes.length} bytes for $fileName'); } catch (e) { debugPrint('Storage download error: $e'); if (mounted) { showErrorSnackBar(context, 'Failed to download: $e'); } return; } if (!mounted) return; // Use FilePicker to save file (works on all platforms) String? savePath = await FilePicker.platform.saveFile( dialogTitle: 'Save attachment', fileName: fileName, bytes: bytes, ); if (mounted) { if (savePath != null && savePath.isNotEmpty) { showSuccessSnackBar(context, 'File saved to: $savePath'); } else { showInfoSnackBar(context, 'Download cancelled'); } } } catch (e) { debugPrint('Download error: $e'); if (mounted) { showErrorSnackBar(context, 'Download error: $e'); } } } Future _deleteTaskAttachment(String taskId, String fileName) async { try { final confirmed = await m3ShowDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Delete Attachment?'), content: Text('Remove "$fileName"?'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Delete'), ), ], ), ); if (confirmed == true) { final supabase = ref.read(supabaseClientProvider); await supabase.storage.from('task_attachments').remove([ '$taskId/$fileName', ]); if (mounted) { showSuccessSnackBar(context, 'Attachment deleted'); // Reload attachments list await _loadAttachments(taskId); } } } catch (e) { if (mounted) { showErrorSnackBar(context, 'Failed to delete: $e'); } } } // 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; }