tasq/lib/screens/tasks/task_detail_screen.dart

4628 lines
230 KiB
Dart

// 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<String> requestTypeOptions = [
'Install',
'Repair',
'Upgrade',
'Replace',
'Other',
];
const List<String> requestCategoryOptions = ['Software', 'Hardware', 'Network'];
class TaskDetailScreen extends ConsumerStatefulWidget {
const TaskDetailScreen({super.key, required this.taskId});
final String taskId;
@override
ConsumerState<TaskDetailScreen> createState() => _TaskDetailScreenState();
}
class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
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<double> _savePulse;
static const List<String> _statusOptions = [
'queued',
'in_progress',
'completed',
'cancelled',
];
String? _mentionQuery;
int? _mentionStart;
List<Profile> _mentionResults = [];
// Attachments state
List<String>? _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 ?? <TaskAssignment>[];
final profileById = {
for (final profile in profilesAsync.valueOrNull ?? <Profile>[])
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 ??
<TaskActivityLog>[];
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<dynamic>;
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 ?? <TaskActivityLog>[];
final assignmentList = assignments;
final profilesList =
profilesAsync.valueOrNull ?? <Profile>[];
// 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 = <String, dynamic>{
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) {
showSuccessSnackBarGlobal(
'Task resumed',
);
}
} else {
await ref
.read(
tasksControllerProvider,
)
.pauseTask(
taskId: task.id,
);
ref.invalidate(
taskActivityLogsProvider(
task.id,
),
);
if (mounted) {
showInfoSnackBarGlobal(
'Task paused',
);
}
}
} catch (e) {
if (mounted) {
showErrorSnackBarGlobal(
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<String?>(
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<String?>(
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<String>(
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<dynamic>?)
?.map(
(r) =>
r['name'] as String,
)
.whereType<String>()
.toList() ??
<String>[];
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<String>(
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<dynamic>?)
?.map(
(r) =>
r['name'] as String,
)
.whereType<String>()
.toList() ??
<String>[];
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<String>(
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<dynamic>?)
?.map(
(r) =>
r['name'] as String,
)
.whereType<String>()
.toList() ??
<String>[];
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<String?>(
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<String?>(
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<List<Profile>> 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<TicketMessage> messages,
List<Profile> 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<TaskAssignment> assignments,
AsyncValue<List<TicketMessage>> messagesAsync,
List<Profile> profiles,
) {
final logsAsync = ref.watch(taskActivityLogsProvider(task.id));
final logs = logsAsync.valueOrNull ?? <TaskActivityLog>[];
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 = <DateTime>[];
if (startedByAssignee != null) {
candidates.add(startedByAssignee);
}
if (candidates.isNotEmpty) {
candidates.sort();
responseAt = candidates.first;
responseDuration = responseAt.difference(assignedAt);
}
}
// Render timeline (oldest -> newest)
final timeline = <Widget>[];
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<TaskActivityLog> logs) {
DateTime? started;
for (final entry in logs.reversed) {
if (entry.actionType == 'started') {
started = entry.createdAt;
break;
}
}
return started ?? task.startedAt;
}
DateTime? _latestPauseSince(List<TaskActivityLog> 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<TaskActivityLog> 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<TaskActivityLog> 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<TaskActivityLog> 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<TaskActivityLog> 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<Profile> profiles,
) {
final mentionColor = Theme.of(context).colorScheme.primary;
final spans = _mentionSpans(text, baseColor, mentionColor, profiles);
return RichText(
text: TextSpan(
children: spans,
style: TextStyle(color: baseColor),
),
);
}
List<TextSpan> _mentionSpans(
String text,
Color baseColor,
Color mentionColor,
List<Profile> profiles,
) {
final mentionLabels = profiles
.map(
(profile) => profile.fullName.isEmpty ? profile.id : profile.fullName,
)
.where((label) => label.isNotEmpty)
.map(_escapeRegExp)
.toList();
final pattern = mentionLabels.isEmpty
? r'@\S+'
: '@(?:${mentionLabels.join('|')})';
final matches = RegExp(pattern, caseSensitive: false).allMatches(text);
if (matches.isEmpty) {
return [
TextSpan(
text: text,
style: TextStyle(color: baseColor),
),
];
}
final spans = <TextSpan>[];
var lastIndex = 0;
for (final match in matches) {
if (match.start > lastIndex) {
spans.add(
TextSpan(
text: text.substring(lastIndex, match.start),
style: TextStyle(color: baseColor),
),
);
}
spans.add(
TextSpan(
text: text.substring(match.start, match.end),
style: TextStyle(color: mentionColor, fontWeight: FontWeight.w700),
),
);
lastIndex = match.end;
}
if (lastIndex < text.length) {
spans.add(
TextSpan(
text: text.substring(lastIndex),
style: TextStyle(color: baseColor),
),
);
}
return spans;
}
String _escapeRegExp(String value) {
return value.replaceAllMapped(
RegExp(r'[\\^$.*+?()[\]{}|]'),
(match) => '\\${match[0]}',
);
}
AsyncValue<List<TicketMessage>> _mergeMessages(
AsyncValue<List<TicketMessage>> taskMessages,
AsyncValue<List<TicketMessage>>? ticketMessages,
) {
if (ticketMessages == null) {
return taskMessages;
}
return taskMessages.when(
data: (taskData) => ticketMessages.when(
data: (ticketData) {
final byId = <int, TicketMessage>{
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<List<TicketMessage>>(),
error: (error, stackTrace) =>
AsyncError<List<TicketMessage>>(error, stackTrace),
),
loading: () => const AsyncLoading<List<TicketMessage>>(),
error: (error, stackTrace) =>
AsyncError<List<TicketMessage>>(error, stackTrace),
);
}
Future<void> _handleSendMessage(
Task task,
List<Profile> 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<Profile> 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<String> _extractMentionedUserIds(
String content,
List<Profile> profiles,
String? currentUserId,
) {
final lower = content.toLowerCase();
final mentioned = <String>{};
for (final profile in profiles) {
if (profile.id == currentUserId) continue;
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
if (label.isEmpty) continue;
final token = '@${label.toLowerCase()}';
if (lower.contains(token)) {
mentioned.add(profile.id);
}
}
return mentioned.toList();
}
Widget _buildMentionList(AsyncValue<List<Profile>> profilesAsync) {
if (_mentionResults.isEmpty) {
return const SizedBox.shrink();
}
return Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).compactCardRadius,
),
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _mentionResults.length,
separatorBuilder: (context, index) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final profile = _mentionResults[index];
final label = profile.fullName.isEmpty
? profile.id
: profile.fullName;
return ListTile(
dense: true,
title: Text(label),
onTap: () => _insertMention(profile),
);
},
),
);
}
void _insertMention(Profile profile) {
final start = _mentionStart;
if (start == null) {
_clearMentions();
return;
}
final text = _messageController.text;
final cursor = _messageController.selection.baseOffset;
final end = cursor < 0 ? text.length : cursor;
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
final mentionText = '@$label ';
final updated = text.replaceRange(start, end, mentionText);
final newCursor = start + mentionText.length;
_messageController.text = updated;
_messageController.selection = TextSelection.collapsed(offset: newCursor);
_clearMentions();
}
String _typingLabel(
Set<String> userIds,
AsyncValue<List<Profile>> profilesAsync,
) {
final profileById = {
for (final profile in profilesAsync.valueOrNull ?? [])
profile.id: profile,
};
final names = userIds
.map((id) => profileById[id]?.fullName ?? id)
.where((name) => name.isNotEmpty)
.toList();
if (names.isEmpty) {
return 'Someone is typing...';
}
if (names.length == 1) {
return '${names.first} is typing...';
}
if (names.length == 2) {
return '${names[0]} and ${names[1]} are typing...';
}
return '${names[0]}, ${names[1]} and others are typing...';
}
Future<void> _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 = <String>[
...((ref.read(tasksProvider).valueOrNull ?? const <Task>[]).map(
(task) => task.title,
)),
...((ref.read(ticketsProvider).valueOrNull ?? const <Ticket>[]).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<void>(
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<String>(
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<Office>.from(offices)
..sort(
(a, b) => a.name.toLowerCase().compareTo(
b.name.toLowerCase(),
),
);
return DropdownButtonFormField<String?>(
initialValue: selectedOffice,
decoration: const InputDecoration(
labelText: 'Office',
),
items: [
const DropdownMenuItem(
value: null,
child: Text('Unassigned'),
),
for (final o in officesSorted)
DropdownMenuItem(
value: o.id,
child: Text(o.name),
),
],
onChanged: 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<void> _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([
<String, dynamic>{'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<String>(
onSelected: (value) async {
// If cancelling, require a reason — show dialog with spinner.
if (value == 'cancelled') {
final reasonCtrl = TextEditingController();
await m3ShowDialog<void>(
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) {
showWarningSnackBarGlobal(
'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) {
showErrorSnackBarGlobal(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<TaskAssignment> assignments,
String taskId,
) {
if (profile == null) {
return false;
}
final isGlobal =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
if (isGlobal) {
return true;
}
return assignments.any(
(assignment) =>
assignment.taskId == taskId && assignment.userId == profile.id,
);
}
Future<void> _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) {
showErrorSnackBarGlobal('Failed to read file');
}
return;
}
// Check file size (max 25MB)
const maxSizeBytes = 25 * 1024 * 1024;
if (bytes.length > maxSizeBytes) {
if (mounted) {
showErrorSnackBarGlobal('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');
showSuccessSnackBarGlobal('File uploaded successfully');
// Reload attachments list (non-blocking)
_loadAttachments(taskId);
debugPrint('Attachment reload triggered');
} else {
showErrorSnackBarGlobal('Upload failed: $errorMessage');
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Error: $e');
}
}
}
Future<void> _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<void> _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) {
showSuccessSnackBarGlobal('File saved to: $savePath');
} else {
showInfoSnackBarGlobal('Download cancelled');
}
}
} catch (e) {
debugPrint('Download error: $e');
if (mounted) {
showErrorSnackBarGlobal('Download error: $e');
}
}
}
Future<void> _deleteTaskAttachment(String taskId, String fileName) async {
try {
final confirmed = await m3ShowDialog<bool>(
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) {
showSuccessSnackBarGlobal('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<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}