marco.pms.mobileapp/lib/view/service_project/service_project_job_detail_screen.dart

342 lines
11 KiB
Dart

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<JobDetailsScreen> createState() => _JobDetailsScreenState();
}
class _JobDetailsScreenState extends State<JobDetailsScreen> 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<Widget> 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<UpdateLog> 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),
],
),
),
);
},
);
}
}