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

1147 lines
42 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/controller/service_project/service_project_details_screen_controller.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:timeline_tile/timeline_tile.dart';
import 'package:on_field_work/model/service_project/service_project_job_detail_model.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/widgets/date_range_picker.dart';
import 'package:on_field_work/model/employees/multiple_select_bottomsheet.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/expense/comment_bottom_sheet.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/service_project/job_status_response.dart';
import 'package:on_field_work/helpers/widgets/serviceProject/add_comment_widget.dart';
class JobDetailsScreen extends StatefulWidget {
final String jobId;
final String? projectName;
const JobDetailsScreen({super.key, required this.jobId, this.projectName});
@override
State<JobDetailsScreen> createState() => _JobDetailsScreenState();
}
class _JobDetailsScreenState extends State<JobDetailsScreen> 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<Assignee> _selectedAssignees = <Assignee>[].obs;
final RxList<Tag> _selectedTags = <Tag>[].obs;
final RxBool isEditing = false.obs;
File? imageAttachment;
@override
void initState() {
super.initState();
controller = Get.find<ServiceProjectDetailsController>();
// Fetch job detail first
controller.fetchJobDetail(widget.jobId).then((_) async {
final job = controller.jobDetail.value?.data;
if (job != null) {
// Populate form fields
_selectedTags.value =
(job.tags ?? []).map((t) => Tag(id: t.id, name: t.name)).toList();
_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 ?? [];
// 🔹 Fetch job status only if existing status ID present
final existingStatusId = job.status?.id;
if (existingStatusId != null) {
await controller.fetchJobStatus(statusId: existingStatusId);
// Set selectedJobStatus to match existing status ID
if (controller.jobStatusList.isNotEmpty) {
controller.selectedJobStatus.value =
controller.jobStatusList.firstWhere(
(s) => s.id == existingStatusId,
orElse: () => controller.jobStatusList.first,
);
}
}
}
});
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
_startDateController.dispose();
_dueDateController.dispose();
_tagTextController.dispose();
super.dispose();
}
bool _tagsAreDifferent(List<Tag> original, List<Tag> current) {
// Compare by id / name sets (simple equality)
final origIds = original.map((t) => t.id ?? '').toSet();
final currIds = current.map((t) => t.id ?? '').toSet();
final origNames = original.map((t) => t.name?.trim() ?? '').toSet();
final currNames = current.map((t) => t.name?.trim() ?? '').toSet();
return !(origIds == currIds && origNames == currNames);
}
Future<void> _editJob() async {
_processTagsInput(); // process any new tag input
final job = controller.jobDetail.value?.data;
if (job == null) return;
final List<Map<String, dynamic>> operations = [];
// 1⃣ Title
final trimmedTitle = _titleController.text.trim();
if (trimmedTitle != job.title) {
operations
.add({"op": "replace", "path": "/title", "value": trimmedTitle});
}
// 2⃣ Description
final trimmedDescription = _descriptionController.text.trim();
if (trimmedDescription != job.description) {
operations.add({
"op": "replace",
"path": "/description",
"value": trimmedDescription
});
}
// 3⃣ Start & Due Date
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()
});
}
// 4⃣ Assignees
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});
// 5⃣ Tags
final originalTags = job.tags ?? [];
final currentTags = _selectedTags.toList();
if (_tagsAreDifferent(originalTags, currentTags)) {
final List<Map<String, dynamic>> finalTagsPayload = [];
for (var ot in originalTags) {
final isSelected = currentTags.any((ct) =>
(ct.id != null && ct.id == ot.id) ||
(ct.name?.trim() == ot.name?.trim()));
finalTagsPayload.add({
"id": ot.id,
"name": ot.name,
"isActive": isSelected,
});
}
for (var ct in currentTags.where((c) => c.id == null || c.id == "0")) {
finalTagsPayload.add({"name": ct.name, "isActive": true});
}
operations
.add({"op": "replace", "path": "/tags", "value": finalTagsPayload});
}
// 6⃣ Job Status
final selectedStatus = controller.selectedJobStatus.value;
if (selectedStatus != null && selectedStatus.id != job.status?.id) {
operations.add({
"op": "replace",
"path": "/statusId", // make sure API expects this field
"value": selectedStatus.id
});
}
// 7⃣ Check if anything changed
if (operations.isEmpty) {
showAppSnackbar(
title: "Info",
message: "No changes detected to save.",
type: SnackbarType.info);
return;
}
// 8⃣ Call API
final success = await ApiService.editServiceProjectJobApi(
jobId: job.id ?? "",
operations: operations,
);
if (success) {
showAppSnackbar(
title: "Success",
message: "Job updated successfully",
type: SnackbarType.success);
// Re-fetch job detail & update tags locally
await controller.fetchJobDetail(widget.jobId);
final updatedJob = controller.jobDetail.value?.data;
if (updatedJob != null) {
_selectedTags.value = (updatedJob.tags ?? [])
.map((t) => Tag(id: t.id, name: t.name))
.toList();
setState(() {});
}
isEditing.value = false;
} else {
showAppSnackbar(
title: "Error",
message: "Failed to update job. Check inputs or try again.",
type: SnackbarType.error);
}
}
void _processTagsInput() {
final input = _tagTextController.text;
// Remove comma behaviour → treat whole input as one tag
String tag = input.trim();
if (tag.isEmpty) {
_tagTextController.clear();
return;
}
// Convert underscore to space
tag = tag.replaceAll("_", " ");
// Avoid duplicate tags (case-insensitive)
if (!_selectedTags
.any((t) => (t.name ?? "").toLowerCase() == tag.toLowerCase())) {
_selectedTags.add(Tag(id: "0", name: tag));
}
// Clear text field
_tagTextController.clear();
}
Future<void> _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<Widget> 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<DateTime>(startDate),
endDate: Rx<DateTime>(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<EmployeeModel>((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<List<EmployeeModel>>(
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 job = controller.jobDetail.value?.data;
final displayTags = editing ? _selectedTags : (job?.tags ?? []);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
children: displayTags
.map(
(t) => Chip(
label: Text(t.name ?? ''),
onDeleted: editing
? () {
_selectedTags.removeWhere((x) =>
(x.id != null && x.id == t.id) ||
(x.name == t.name));
}
: null,
),
)
.toList(),
),
const SizedBox(height: 8),
if (editing)
TextField(
controller: _tagTextController,
onChanged: (value) {
// If space or comma typed → process tags immediately
if (value.endsWith(" ") || value.contains(",")) {
_processTagsInput();
}
},
onSubmitted: (_) {
// Still supports ENTER
_processTagsInput();
},
decoration: InputDecoration(
hintText: "Type tags (space or comma to add multiple 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),
),
),
),
),
],
),
),
);
},
);
}),
],
),
),
);
});
}
Widget _rowItem(String label, String value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: MyText.bodySmall(label,
fontWeight: 600, color: Colors.grey.shade700)),
Expanded(flex: 5, child: MyText.bodyMedium(value, fontWeight: 500)),
],
);
}
Widget _branchDisplay() {
final job = controller.jobDetail.value?.data;
final branch = job?.projectBranch;
if (branch == null) {
return MyText.labelMedium("No branch assigned");
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_rowItem("Branch Name", branch.branchName ?? "-"),
MySpacing.height(8),
_rowItem("Branch Type", branch.branchType ?? "-"),
],
);
}
Widget _buildJobStatusCard() {
final job = controller.jobDetail.value?.data;
if (job == null) return const SizedBox();
// Existing status info
final statusName = job.status?.displayName ?? "N/A";
Color statusColor;
switch (job.status?.level) {
case 1:
statusColor = Colors.green;
break;
case 2:
statusColor = Colors.orange;
break;
case 3:
statusColor = Colors.blue;
break;
case 4:
statusColor = Colors.red;
break;
default:
statusColor = Colors.grey;
}
final editing = isEditing.value;
// Ensure selectedJobStatus initialized
if (editing && controller.selectedJobStatus.value == null) {
final existingStatusId = job.status?.id;
if (existingStatusId != null && controller.jobStatusList.isNotEmpty) {
controller.selectedJobStatus.value =
controller.jobStatusList.firstWhere(
(s) => s.id == existingStatusId,
orElse: () => controller.jobStatusList.first,
);
}
}
return _buildSectionCard(
title: "Job Status",
titleIcon: Icons.flag_outlined,
children: [
// 1⃣ Display existing status
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: statusColor.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(Icons.flag, color: statusColor, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
statusName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: statusColor),
),
const SizedBox(height: 2),
Text(
"Level: ${job.status?.level ?? '-'}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
],
),
),
],
),
const SizedBox(height: 16),
// 2⃣ PopupMenuButton for new selection
if (editing)
Obx(() {
final selectedStatus = controller.selectedJobStatus.value;
final statuses = controller.jobStatusList;
return PopupMenuButton<JobStatus>(
onSelected: (val) => controller.selectedJobStatus.value = val,
itemBuilder: (_) => statuses
.map(
(s) => PopupMenuItem(
value: s,
child: Text(s.displayName ?? "N/A"),
),
)
.toList(),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedStatus?.displayName ?? "Select Job Status",
style:
TextStyle(color: Colors.grey.shade700, fontSize: 14),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}),
],
);
}
@override
Widget build(BuildContext context) {
final projectName = widget.projectName;
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Job Details Screen",
onBackPressed: () => Get.back(),
projectName: projectName,
backgroundColor: appBarColor,
),
floatingActionButton: Obx(() => FloatingActionButton.extended(
onPressed:
isEditing.value ? _editJob : () => isEditing.value = true,
backgroundColor: appBarColor,
label: MyText.bodyMedium(
isEditing.value ? "Save" : "Edit",
color: Colors.white,
fontWeight: 600,
),
icon: Icon(isEditing.value ? Icons.save : Icons.edit),
)),
body: Stack(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Bottom fade (for smooth transition above FAB)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 60, // adjust based on FAB height
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
],
),
),
),
),
// Main scrollable content
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: [
_buildJobStatusCard(),
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",
titleIcon: Icons.task_outlined,
children: [
_editableRow("Title", _titleController),
_editableRow("Description", _descriptionController),
_dateRangePicker(),
],
),
MySpacing.height(12),
_buildSectionCard(
title: "Project Branch",
titleIcon: Icons.account_tree_outlined,
children: [_branchDisplay()],
),
MySpacing.height(16),
_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 ?? [])]),
// ⭐ NEW CARD ADDED HERE
MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard(
title: "Comment Section",
titleIcon: Icons.comment_outlined,
children: [
AddCommentWidget(
jobId: job.id ?? "",
jobTicketId: job.jobTicketUId ?? ""),
]),
// ⭐ END NEW CARD
MySpacing.height(80),
],
),
);
}),
],
),
);
}
}
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);
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),
]),
),
);
},
);
}
}