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'; 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 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; @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, 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)) { 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) { operations .add({"op": "replace", "path": "/tags", "value": replaceTagsPayload}); } if (addTagsPayload.isNotEmpty) { operations.add({"op": "add", "path": "/tags", "value": addTagsPayload}); } if (operations.isEmpty) { Get.snackbar("Info", "No changes detected to save."); return; } final success = await ApiService.editServiceProjectJobApi( jobId: job.id, operations: operations); if (success) { Get.snackbar("Success", "Job updated successfully"); await controller.fetchJobDetail(widget.jobId); isEditing.value = false; } else { Get.snackbar("Error", "Failed to update job. Check inputs or try again."); } } 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), ), ), ], ); }); } @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, label: Text(isEditing.value ? "Save" : "Edit"), 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: [ _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) _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; final comment = log.comment; final updatedBy = "${log.updatedBy.firstName} ${log.updatedBy.lastName}"; final initials = "${log.updatedBy.firstName.isNotEmpty ? log.updatedBy.firstName[0] : ''}${log.updatedBy.lastName.isNotEmpty ? log.updatedBy.lastName[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), ]), ), ); }, ); } }