4628 lines
230 KiB
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;
|
|
}
|