import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/service_project/service_project_details_screen_controller.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:timeline_tile/timeline_tile.dart'; import 'package:marco/model/service_project/service_project_job_detail_model.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/widgets/date_range_picker.dart'; import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/expense/comment_bottom_sheet.dart'; import 'package:image_picker/image_picker.dart'; import 'dart:io'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; class JobDetailsScreen extends StatefulWidget { final String jobId; const JobDetailsScreen({super.key, required this.jobId}); @override State createState() => _JobDetailsScreenState(); } class _JobDetailsScreenState extends State with UIMixin { late final ServiceProjectDetailsController controller; final RxBool isAttendanceExpanded = false.obs; RxBool isAttendanceLogLoading = false.obs; final TextEditingController _titleController = TextEditingController(); final TextEditingController _descriptionController = TextEditingController(); final TextEditingController _startDateController = TextEditingController(); final TextEditingController _dueDateController = TextEditingController(); final TextEditingController _tagTextController = TextEditingController(); final RxList _selectedAssignees = [].obs; final RxList _selectedTags = [].obs; final RxBool isEditing = false.obs; File? imageAttachment; @override void initState() { super.initState(); controller = Get.put(ServiceProjectDetailsController()); controller.fetchJobDetail(widget.jobId).then((_) { final job = controller.jobDetail.value?.data; if (job != null) { _titleController.text = job.title ?? ''; _descriptionController.text = job.description ?? ''; _startDateController.text = DateTimeUtils.convertUtcToLocal( job.startDate ?? DateTime.now().toIso8601String(), format: "yyyy-MM-dd"); _dueDateController.text = DateTimeUtils.convertUtcToLocal( job.dueDate ?? '', format: "yyyy-MM-dd"); _selectedAssignees.value = job.assignees ?? []; _selectedTags.value = job.tags ?? []; } }); } @override void dispose() { _titleController.dispose(); _descriptionController.dispose(); _startDateController.dispose(); _dueDateController.dispose(); _tagTextController.dispose(); super.dispose(); } Future _editJob() async { final job = controller.jobDetail.value?.data; if (job == null) return; final List> operations = []; final trimmedTitle = _titleController.text.trim(); if (trimmedTitle != job.title) { operations .add({"op": "replace", "path": "/title", "value": trimmedTitle}); } final trimmedDescription = _descriptionController.text.trim(); if (trimmedDescription != job.description) { operations.add({ "op": "replace", "path": "/description", "value": trimmedDescription }); } final startDate = DateTime.tryParse(_startDateController.text); final dueDate = DateTime.tryParse(_dueDateController.text); if (startDate != null && startDate.toUtc() != job.startDate) { operations.add({ "op": "replace", "path": "/startDate", "value": startDate.toUtc().toIso8601String() }); } if (dueDate != null && dueDate.toUtc() != job.dueDate) { operations.add({ "op": "replace", "path": "/dueDate", "value": dueDate.toUtc().toIso8601String() }); } final originalAssignees = job.assignees; final assigneesPayload = originalAssignees?.map((a) { final isSelected = _selectedAssignees.any((s) => s.id == a.id); return {"employeeId": a.id, "isActive": isSelected}; }).toList(); for (var s in _selectedAssignees) { if (!(originalAssignees?.any((a) => a.id == s.id) ?? false)) { assigneesPayload?.add({"employeeId": s.id, "isActive": true}); } } operations.add( {"op": "replace", "path": "/assignees", "value": assigneesPayload}); final originalTags = job.tags; final replaceTagsPayload = originalTags?.map((t) { final isSelected = _selectedTags.any((s) => s.id == t.id); return {"id": t.id, "name": t.name, "isActive": isSelected}; }).toList(); final addTagsPayload = _selectedTags .where((t) => t.id == "0") .map((t) => {"name": t.name, "isActive": true}) .toList(); if ((replaceTagsPayload?.isNotEmpty ?? false)) { operations .add({"op": "replace", "path": "/tags", "value": replaceTagsPayload}); } if (addTagsPayload.isNotEmpty) { operations.add({"op": "add", "path": "/tags", "value": addTagsPayload}); } if (operations.isEmpty) { showAppSnackbar( title: "Info", message: "No changes detected to save.", type: SnackbarType.info); return; } final success = await ApiService.editServiceProjectJobApi( jobId: job.id ?? "", operations: operations, ); if (success) { showAppSnackbar( title: "Success", message: "Job updated successfully", type: SnackbarType.success); await controller.fetchJobDetail(widget.jobId); isEditing.value = false; } else { showAppSnackbar( title: "Error", message: "Failed to update job. Check inputs or try again.", type: SnackbarType.error); } } Future _handleTagAction() async { final job = controller.jobDetail.value?.data; if (job == null) return; final action = job.nextTaggingAction; File? attachmentFile; final comment = await showCommentBottomSheet( context, action == 0 ? "Tag In" : "Tag Out"); if (comment == null) return; await showDialog( context: context, builder: (_) => ConfirmDialog( title: "Attach Image?", message: "Do you want to attach an image for this action?", confirmText: "Yes", cancelText: "No", icon: Icons.camera_alt_outlined, confirmColor: Colors.blueAccent, onConfirm: () async { final picker = ImagePicker(); final picked = await picker.pickImage(source: ImageSource.camera); if (picked != null) { attachmentFile = File(picked.path); } }, ), ); await controller.updateJobAttendance( jobId: job.id ?? "", action: action == 0 ? 0 : 1, comment: comment, attachment: attachmentFile, ); final msg = controller.attendanceMessage.value; if (msg.toLowerCase().contains("failed") || msg.toLowerCase().contains("error")) { showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); return; } showAppSnackbar(title: "Success", message: msg, type: SnackbarType.success); } Widget _buildSectionCard({ required String title, required IconData titleIcon, required List children, }) { return Card( elevation: 3, shadowColor: Colors.black12, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), margin: const EdgeInsets.symmetric(vertical: 8), child: Padding( padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(titleIcon, size: 20, color: Colors.blueAccent), MySpacing.width(8), MyText.bodyLarge(title, fontWeight: 700, fontSize: 16), ]), MySpacing.height(8), const Divider(), ...children, ]), ), ); } Widget _editableRow(String label, TextEditingController controller) { return Obx(() { final editing = isEditing.value; return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall(label, color: Colors.grey[600], fontWeight: 600), const SizedBox(height: 6), editing ? TextField( controller: controller, decoration: InputDecoration( border: OutlineInputBorder( borderRadius: BorderRadius.circular(5)), isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 10, vertical: 12), ), ) : Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(5)), child: Text(controller.text, style: const TextStyle(fontSize: 14)), ), ]), ); }); } Widget _dateRangePicker() { return Obx(() { final editing = isEditing.value; final startDate = DateTime.tryParse(_startDateController.text) ?? DateTime.now(); final dueDate = DateTime.tryParse(_dueDateController.text) ?? DateTime.now().add(const Duration(days: 1)); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Select Date Range", color: Colors.grey[600], fontWeight: 600), const SizedBox(height: 8), editing ? DateRangePickerWidget( startDate: Rx(startDate), endDate: Rx(dueDate), startLabel: "Start Date", endLabel: "Due Date", onDateRangeSelected: (start, end) { if (start != null && end != null) { _startDateController.text = start.toIso8601String().split("T").first; _dueDateController.text = end.toIso8601String().split("T").first; } }, ) : Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(5)), child: Text( "${_startDateController.text} → ${_dueDateController.text}"), ), ]); }); } Widget _assigneeInputWithChips() { return Obx(() { final editing = isEditing.value; final assignees = _selectedAssignees; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (assignees.isNotEmpty) Wrap( spacing: 6, children: assignees .map( (a) => Chip( label: Text("${a.firstName} ${a.lastName}"), onDeleted: editing ? () { _selectedAssignees.remove(a); } : null, ), ) .toList(), ), const SizedBox(height: 8), if (editing) GestureDetector( onTap: () async { final initiallySelected = assignees.map((a) { return EmployeeModel( id: a.id ?? '', employeeId: a.id ?? '', firstName: a.firstName ?? '', lastName: a.lastName ?? '', name: "${a.firstName} ${a.lastName}", designation: a.jobRoleName ?? '', jobRole: a.jobRoleName ?? '', jobRoleID: a.jobRoleId ?? '', email: a.email ?? '', phoneNumber: '', activity: 0, action: 0, ); }).toList(); final selected = await showModalBottomSheet>( context: context, isScrollControlled: true, builder: (_) => EmployeeSelectionBottomSheet( multipleSelection: true, initiallySelected: initiallySelected, ), ); if (selected != null) { _selectedAssignees.value = selected.map((e) { return Assignee( id: e.id, firstName: e.firstName, lastName: e.lastName, email: e.email, photo: '', jobRoleId: e.jobRoleID, jobRoleName: e.jobRole, ); }).toList(); } }, child: Container( height: 40, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(5), border: Border.all(color: Colors.grey.shade400), ), alignment: Alignment.centerLeft, child: Text( "Tap to select assignees", style: TextStyle(fontSize: 14, color: Colors.grey[700]), ), ), ), ], ); }); } Widget _tagEditor() { return Obx(() { final editing = isEditing.value; final tags = _selectedTags; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 6, children: tags .map( (t) => Chip( label: Text(t.name ?? ''), onDeleted: editing ? () { _selectedTags.remove(t); } : null, ), ) .toList(), ), const SizedBox(height: 8), if (editing) TextField( controller: _tagTextController, onSubmitted: (v) { final value = v.trim(); if (value.isNotEmpty && !tags.any((t) => t.name == value)) { _selectedTags.add(Tag(id: "0", name: value)); } _tagTextController.clear(); }, decoration: InputDecoration( hintText: "Type and press enter to add tags", border: OutlineInputBorder(borderRadius: BorderRadius.circular(5)), isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), ), ), ], ); }); } Widget _buildAttendanceCard() { return Obx(() { final job = controller.jobDetail.value?.data; final isLoading = controller.isTagging.value; final action = job?.nextTaggingAction; final logs = controller.attendanceLog.value?.data ?? []; if (job == null) return const SizedBox(); return Card( elevation: 3, shadowColor: Colors.black12, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), margin: const EdgeInsets.symmetric(vertical: 8), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Row( children: [ Icon(Icons.access_time_outlined, size: 20, color: Colors.blueAccent), const SizedBox(width: 8), MyText.bodyLarge("Attendance", fontWeight: 700, fontSize: 16), const Spacer(), Obx(() => IconButton( icon: Icon( isAttendanceExpanded.value ? Icons.expand_less : Icons.expand_more, color: Colors.grey[600], ), onPressed: () async { isAttendanceExpanded.value = !isAttendanceExpanded.value; if (isAttendanceExpanded.value ) { await controller .fetchJobAttendanceLog(job.attendanceId ?? ''); } }, )) ], ), const SizedBox(height: 8), const Divider(), // Tag In/Tag Out Button if (action != null) Align( alignment: Alignment.center, child: SizedBox( height: 36, child: ElevatedButton.icon( icon: isLoading ? SizedBox( height: 16, width: 16, child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2, ), ) : Icon(action == 0 ? Icons.login : Icons.logout), label: MyText.bodyMedium( action == 0 ? "Tag In" : "Tag Out", fontWeight: 600, color: Colors.white, ), onPressed: isLoading ? null : _handleTagAction, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 20), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5), ), backgroundColor: action == 0 ? Colors.green : Colors.red, ), ), ), ), // Attendance Logs Obx(() { if (!isAttendanceExpanded.value) return Container(); if (isAttendanceLogLoading.value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Center(child: CircularProgressIndicator()), ); } if (logs.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 12), child: MyText.bodyMedium( "No attendance logs available", color: Colors.grey[600], ), ); } return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.only(top: 12), itemCount: logs.length, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (_, index) { final log = logs[index]; final employeeName = "${log.employee.firstName} ${log.employee.lastName}"; final date = DateTimeUtils.convertUtcToLocal( log.markedAt.toIso8601String(), format: 'd MMM yyyy'); final time = DateTimeUtils.convertUtcToLocal( log.markedAt.toIso8601String(), format: 'hh:mm a'); return Card( elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Top Row: Icon, Employee, Date, Time Row( children: [ Icon( log.action == 0 ? Icons.login : Icons.logout, color: log.action == 0 ? Colors.green : Colors.red, size: 18, ), const SizedBox(width: 6), Expanded( child: Text( employeeName, style: const TextStyle( fontWeight: FontWeight.w600), ), ), Text( "$date | $time", style: TextStyle( fontSize: 12, color: Colors.grey[700]), ), ], ), const SizedBox(height: 4), // Comment if (log.comment?.isNotEmpty == true) Padding( padding: const EdgeInsets.only(top: 4), child: Text( log.comment!, style: const TextStyle(fontSize: 13), ), ), // Location if (log.latitude != null && log.longitude != null) GestureDetector( onTap: () async { final lat = double.tryParse(log.latitude!) ?? 0.0; final lon = double.tryParse(log.longitude!) ?? 0.0; final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon'; if (await canLaunchUrl(Uri.parse(url))) { await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); } }, child: Padding( padding: const EdgeInsets.only(top: 4), child: Row( mainAxisSize: MainAxisSize.min, children: const [ Icon(Icons.location_on, size: 14, color: Colors.blue), SizedBox(width: 4), Text( "View Location", style: TextStyle( fontSize: 12, color: Colors.blue, decoration: TextDecoration.underline), ), ], ), ), ), // Attached Image if (log.document != null) Padding( padding: const EdgeInsets.only(top: 4), child: GestureDetector( onTap: () => showDialog( context: context, builder: (_) => Dialog( child: Image.network( log.document!.preSignedUrl, fit: BoxFit.cover, height: 250, errorBuilder: (_, __, ___) => const Icon( Icons.broken_image, size: 50, color: Colors.grey, ), ), ), ), child: ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.network( log.document!.thumbPreSignedUrl.isNotEmpty ? log.document!.thumbPreSignedUrl : log.document!.preSignedUrl, height: 50, width: 50, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Icon( Icons.broken_image, size: 40, color: Colors.grey, ), ), ), ), ), ], ), ), ); }, ); }), ], ), ), ); }); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), appBar: CustomAppBar( title: "Service Project Job Details", onBackPressed: () => Get.back()), floatingActionButton: Obx(() => FloatingActionButton.extended( onPressed: isEditing.value ? _editJob : () => isEditing.value = true, backgroundColor: contentTheme.primary, label: MyText.bodyMedium( isEditing.value ? "Save" : "Edit", color: Colors.white, fontWeight: 600, ), icon: Icon(isEditing.value ? Icons.save : Icons.edit), )), body: Obx(() { if (controller.isJobDetailLoading.value) { return const Center(child: CircularProgressIndicator()); } if (controller.jobDetailErrorMessage.value.isNotEmpty) { return Center( child: MyText.bodyMedium(controller.jobDetailErrorMessage.value)); } final job = controller.jobDetail.value?.data; if (job == null) { return Center(child: MyText.bodyMedium("No details available")); } return SingleChildScrollView( padding: MySpacing.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildAttendanceCard(), _buildSectionCard( title: "Job Info", titleIcon: Icons.task_outlined, children: [ _editableRow("Title", _titleController), _editableRow("Description", _descriptionController), _dateRangePicker(), ], ), MySpacing.height(12), _buildSectionCard( title: "Assignees", titleIcon: Icons.person_outline, children: [_assigneeInputWithChips()]), MySpacing.height(16), _buildSectionCard( title: "Tags", titleIcon: Icons.label_outline, children: [_tagEditor()]), MySpacing.height(16), if ((job.updateLogs?.isNotEmpty ?? false)) _buildSectionCard( title: "Update Logs", titleIcon: Icons.history, children: [JobTimeline(logs: job.updateLogs ?? [])]), MySpacing.height(80), ], ), ); }), ); } } class JobTimeline extends StatelessWidget { final List logs; const JobTimeline({super.key, required this.logs}); @override Widget build(BuildContext context) { if (logs.isEmpty) return MyText.bodyMedium('No timeline available', color: Colors.grey); final reversedLogs = logs.reversed.toList(); return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: reversedLogs.length, itemBuilder: (_, index) { final log = reversedLogs[index]; final statusName = log.status?.displayName ?? "Created"; final nextStatusName = log.nextStatus?.displayName ?? "N/A"; final comment = log.comment ?? ''; final updatedBy = "${log.updatedBy?.firstName ?? ''} ${log.updatedBy?.lastName ?? ''}"; final f = log.updatedBy?.firstName ?? ''; final l = log.updatedBy?.lastName ?? ''; final initials = "${f.isNotEmpty ? f[0] : ''}${l.isNotEmpty ? l[0] : ''}"; return TimelineTile( alignment: TimelineAlign.start, isFirst: index == 0, isLast: index == reversedLogs.length - 1, indicatorStyle: const IndicatorStyle( width: 16, height: 16, indicator: DecoratedBox( decoration: BoxDecoration(color: Colors.blue, shape: BoxShape.circle)), ), beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2), endChild: Padding( padding: const EdgeInsets.all(12), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodyMedium("$statusName → $nextStatusName", fontWeight: 600), if (comment.isNotEmpty) ...[ const SizedBox(height: 8), MyText.bodyMedium(comment), ], const SizedBox(height: 10), Row(children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(4)), child: MyText.bodySmall(initials, fontWeight: 600), ), const SizedBox(width: 6), Expanded(child: MyText.bodySmall(updatedBy)), ]), const SizedBox(height: 10), ]), ), ); }, ); } }