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:marco/helpers/utils/launcher_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/widgets/avatar.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; @override void initState() { super.initState(); controller = Get.put(ServiceProjectDetailsController()); controller.fetchJobDetail(widget.jobId); } Widget _buildSectionCard({ required String title, required IconData titleIcon, required List children, }) { return Card( elevation: 2, shadowColor: Colors.black12, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(titleIcon, size: 20), MySpacing.width(8), MyText.bodyLarge( title, fontWeight: 700, fontSize: 16, ) ], ), MySpacing.height(8), const Divider(), ...children ], ), ), ); } Widget _rowTile(String label, String value, {bool copyable = false}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 3, child: MyText.bodySmall(label, color: Colors.grey[600], fontWeight: 600), ), Expanded( flex: 5, child: GestureDetector( onLongPress: copyable ? () => LauncherUtils.copyToClipboard(value, typeLabel: label) : null, child: MyText.bodyMedium(value, fontWeight: 600, color: copyable ? Colors.blue : Colors.black87), ), ), ], ), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), appBar: CustomAppBar( title: "Service Project Job Details", onBackPressed: () => Get.back(), ), 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: [ // ====== HEADER CARD ======= Card( elevation: 2, shadowColor: Colors.black12, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5)), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ const Icon(Icons.task_outlined, size: 35), MySpacing.width(16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.titleMedium(job.title, fontWeight: 700), MySpacing.height(5), MyText.bodySmall(job.project.name, color: Colors.grey[700]), ], ), ) ], ), ), ), MySpacing.height(20), // ====== Job Information ======= _buildSectionCard( title: "Job Information", titleIcon: Icons.info_outline, children: [ _rowTile("Description", job.description), _rowTile( "Start Date", DateTimeUtils.convertUtcToLocal(job.startDate, format: "dd MMM yyyy"), ), _rowTile( "Due Date", DateTimeUtils.convertUtcToLocal(job.dueDate, format: "dd MMM yyyy"), ), _rowTile("Status", job.status.displayName), ], ), MySpacing.height(16), // ====== Assignees ======= _buildSectionCard( title: "Assigned To", titleIcon: Icons.people_outline, children: job.assignees.isNotEmpty ? job.assignees.map((a) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ Avatar( firstName: a.firstName, lastName: a.lastName, size: 32, backgroundColor: a.photo.isEmpty ? null : Colors.transparent, textColor: Colors.white, ), MySpacing.width(10), MyText.bodyMedium("${a.firstName} ${a.lastName}"), ], ), ); }).toList() : [MyText.bodySmall("No assignees", color: Colors.grey)], ), MySpacing.height(16), // ====== Tags ======= if (job.tags.isNotEmpty) _buildSectionCard( title: "Tags", titleIcon: Icons.label_outline, children: [ Wrap( spacing: 6, runSpacing: 6, children: job.tags.map((tag) { return Chip( label: Text(tag.name), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5), ), ); }).toList(), ) ], ), MySpacing.height(16), // ====== Update Logs (Timeline UI) ======= if (job.updateLogs.isNotEmpty) _buildSectionCard( title: "Update Logs", titleIcon: Icons.history, children: [ JobTimeline(logs: job.updateLogs), ], ), MySpacing.height(40), ], ), ); }), ); } } 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); } // Show latest updates at the top 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: IndicatorStyle( width: 16, height: 16, indicator: Container( decoration: const 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: [ // STATUS CHANGE ROW MyText.bodyMedium( "$statusName → $nextStatusName", fontWeight: 600, ), const SizedBox(height: 8), // COMMENT if (comment.isNotEmpty) MyText.bodyMedium(comment), const SizedBox(height: 10), // Updated by 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), ], ), ), ); }, ); } }