From 66c013f797c73087db9350238b3b4df3f5710f8f Mon Sep 17 00:00:00 2001 From: Manish Date: Thu, 27 Nov 2025 16:19:37 +0530 Subject: [PATCH] done with tag reflection on details screen after edit --- ...ice_project_details_screen_controller.dart | 40 +-- lib/helpers/services/api_endpoints.dart | 4 +- .../add_service_project_job_bottom_sheet.dart | 15 +- lib/view/finance/advance_payment_screen.dart | 13 - .../service_project_job_detail_screen.dart | 324 ++++++++++-------- 5 files changed, 203 insertions(+), 193 deletions(-) diff --git a/lib/controller/service_project/service_project_details_screen_controller.dart b/lib/controller/service_project/service_project_details_screen_controller.dart index 46f43aa..fd2c3f9 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -1,3 +1,4 @@ +// service_project_details_screen_controller.dart import 'package:get/get.dart'; import 'package:on_field_work/helpers/services/api_service.dart'; import 'package:on_field_work/model/service_project/service_projects_details_model.dart'; @@ -76,9 +77,7 @@ class ServiceProjectDetailsController extends GetxController { final lowerSearch = searchText.toLowerCase(); return job.title.toLowerCase().contains(lowerSearch) || (job.description.toLowerCase().contains(lowerSearch)) || - (job.tags?.any( - (tag) => tag.name.toLowerCase().contains(lowerSearch)) ?? - false); + (job.tags?.any((tag) => tag.name.toLowerCase().contains(lowerSearch)) ?? false); }).toList(); } } @@ -93,10 +92,7 @@ class ServiceProjectDetailsController extends GetxController { teamErrorMessage.value = ''; try { - final result = await ApiService.getServiceProjectAllocationList( - projectId: projectId.value, - isActive: true, - ); + final result = await ApiService.getServiceProjectAllocationList(projectId: projectId.value, isActive: true); if (result != null) { teamList.value = result; @@ -120,14 +116,12 @@ class ServiceProjectDetailsController extends GetxController { errorMessage.value = ''; try { - final result = - await ApiService.getServiceProjectDetailApi(projectId.value); + final result = await ApiService.getServiceProjectDetailApi(projectId.value); if (result != null && result.data != null) { projectDetail.value = result.data!; } else { - errorMessage.value = - result?.message ?? "Failed to fetch project details"; + errorMessage.value = result?.message ?? "Failed to fetch project details"; } } catch (e) { errorMessage.value = "Error: $e"; @@ -146,8 +140,7 @@ class ServiceProjectDetailsController extends GetxController { attendanceMessage.value = ''; try { - final result = - await ApiService.getJobAttendanceLog(attendanceId: attendanceId); + final result = await ApiService.getJobAttendanceLog(attendanceId: attendanceId); if (result != null) { attendanceLog.value = result; @@ -210,10 +203,7 @@ class ServiceProjectDetailsController extends GetxController { pageNumber = 1; hasMoreJobs.value = true; - await Future.wait([ - fetchProjectDetail(), - fetchProjectJobs(), - ]); + await Future.wait([fetchProjectDetail(), fetchProjectJobs()]); } // -------------------- Job Detail -------------------- @@ -258,13 +248,11 @@ class ServiceProjectDetailsController extends GetxController { } if (permission == LocationPermission.deniedForever) { - attendanceMessage.value = - "Location permission permanently denied. Enable it from settings."; + attendanceMessage.value = "Location permission permanently denied. Enable it from settings."; return null; } - return await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high); + return await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); } catch (e) { attendanceMessage.value = "Failed to get location: $e"; return null; @@ -295,8 +283,7 @@ class ServiceProjectDetailsController extends GetxController { if (attachment != null) { final bytes = await attachment.readAsBytes(); final base64Data = base64Encode(bytes); - final mimeType = - lookupMimeType(attachment.path) ?? 'application/octet-stream'; + final mimeType = lookupMimeType(attachment.path) ?? 'application/octet-stream'; attachmentPayload = { "documentId": jobId, "fileName": attachment.path.split('/').last, @@ -317,13 +304,10 @@ class ServiceProjectDetailsController extends GetxController { "attachment": attachmentPayload, }; - final success = await ApiService.updateServiceProjectJobAttendance( - payload: payload, - ); + final success = await ApiService.updateServiceProjectJobAttendance(payload: payload); if (success) { - attendanceMessage.value = - action == 0 ? "Tagged In successfully" : "Tagged Out successfully"; + attendanceMessage.value = action == 0 ? "Tagged In successfully" : "Tagged Out successfully"; await fetchJobDetail(jobId); } else { attendanceMessage.value = "Failed to update attendance"; diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index f68ad6f..743c8e1 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,9 +1,9 @@ class ApiEndpoints { - // static const String baseUrl = "https://stageapi.marcoaiot.com/api"; + static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://mapi.marcoaiot.com/api"; - static const String baseUrl = "https://api.onfieldwork.com/api"; + // static const String baseUrl = "https://api.onfieldwork.com/api"; static const String getMasterCurrencies = "/Master/currencies/list"; diff --git a/lib/model/service_project/add_service_project_job_bottom_sheet.dart b/lib/model/service_project/add_service_project_job_bottom_sheet.dart index e6a1162..8c3b139 100644 --- a/lib/model/service_project/add_service_project_job_bottom_sheet.dart +++ b/lib/model/service_project/add_service_project_job_bottom_sheet.dart @@ -220,8 +220,9 @@ class _AddServiceProjectJobBottomSheetState .where((s) => s.isNotEmpty); for (final p in parts) { - if (!controller.enteredTags.contains(p)) { - controller.enteredTags.add(p); + final clean = p.replaceAll('_', ' '); + if (!controller.enteredTags.contains(clean)) { + controller.enteredTags.add(clean); } } } @@ -239,8 +240,9 @@ class _AddServiceProjectJobBottomSheetState .where((s) => s.isNotEmpty); for (final p in parts) { - if (!controller.enteredTags.contains(p)) { - controller.enteredTags.add(p); + final clean = p.replaceAll('_', ' '); + if (!controller.enteredTags.contains(clean)) { + controller.enteredTags.add(clean); } } controller.tagCtrl.clear(); @@ -256,8 +258,9 @@ class _AddServiceProjectJobBottomSheetState .where((s) => s.isNotEmpty); for (final p in parts) { - if (!controller.enteredTags.contains(p)) { - controller.enteredTags.add(p); + final clean = p.replaceAll('_', ' '); + if (!controller.enteredTags.contains(clean)) { + controller.enteredTags.add(clean); } } controller.tagCtrl.clear(); diff --git a/lib/view/finance/advance_payment_screen.dart b/lib/view/finance/advance_payment_screen.dart index 0334c45..dfbb2e0 100644 --- a/lib/view/finance/advance_payment_screen.dart +++ b/lib/view/finance/advance_payment_screen.dart @@ -367,9 +367,6 @@ class _AdvancePaymentScreenState extends State ? DateFormat('dd MMM yyyy').format(parsedDate) : (dateStr.isNotEmpty ? dateStr : '—'); - final formattedTime = - parsedDate != null ? DateFormat('hh:mm a').format(parsedDate) : ''; - final project = item.name ?? ''; final desc = item.title ?? ''; final amount = (item.amount ?? 0).toDouble(); @@ -399,16 +396,6 @@ class _AdvancePaymentScreenState extends State style: TextStyle(fontSize: 12, color: Colors.grey.shade600), ), - if (formattedTime.isNotEmpty) ...[ - const SizedBox(width: 6), - Text( - formattedTime, - style: TextStyle( - fontSize: 12, - color: Colors.grey.shade500, - fontStyle: FontStyle.italic), - ), - ] ], ), const SizedBox(height: 4), diff --git a/lib/view/service_project/service_project_job_detail_screen.dart b/lib/view/service_project/service_project_job_detail_screen.dart index ad1c019..0a0a68d 100644 --- a/lib/view/service_project/service_project_job_detail_screen.dart +++ b/lib/view/service_project/service_project_job_detail_screen.dart @@ -39,19 +39,23 @@ class _JobDetailsScreenState extends State with UIMixin { final TextEditingController _dueDateController = TextEditingController(); final TextEditingController _tagTextController = TextEditingController(); + // local selected lists used while editing 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 = Get.find(); + // fetch and seed local selected lists controller.fetchJobDetail(widget.jobId).then((_) { final job = controller.jobDetail.value?.data; if (job != null) { - _selectedTags.value = job.tags ?? []; + _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( @@ -61,7 +65,6 @@ class _JobDetailsScreenState extends State with UIMixin { job.dueDate ?? '', format: "yyyy-MM-dd"); _selectedAssignees.value = job.assignees ?? []; - _selectedTags.value = job.tags ?? []; } }); } @@ -76,7 +79,18 @@ class _JobDetailsScreenState extends State with UIMixin { super.dispose(); } + bool _tagsAreDifferent(List original, List 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 _editJob() async { + _processTagsInput(); final job = controller.jobDetail.value?.data; if (job == null) return; @@ -116,39 +130,56 @@ class _JobDetailsScreenState extends State with UIMixin { }); } - final originalAssignees = job.assignees; - final assigneesPayload = originalAssignees?.map((a) { + // Assignees payload (keep same approach) + 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(); + // add newly added assignees for (var s in _selectedAssignees) { - if (!(originalAssignees?.any((a) => a.id == s.id) ?? false)) { - assigneesPayload?.add({"employeeId": s.id, "isActive": true}); + 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(); + // TAGS: build robust payload using original tags and current selection + final originalTags = job.tags ?? []; + final currentTags = _selectedTags.toList(); - final addTagsPayload = _selectedTags - .where((t) => t.id == "0") - .map((t) => {"name": t.name, "isActive": true}) - .toList(); + // Only add tags operation if something changed + if (_tagsAreDifferent(originalTags, currentTags)) { + final List> finalTagsPayload = []; - if ((replaceTagsPayload?.isNotEmpty ?? false)) { - operations - .add({"op": "replace", "path": "/tags", "value": replaceTagsPayload}); - } + // 1) For existing original tags - we need to mark isActive true/false depending on whether they're in currentTags + 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, + }); + } - if (addTagsPayload.isNotEmpty) { - operations.add({"op": "add", "path": "/tags", "value": addTagsPayload}); + // 2) Add newly created tags from currentTags that don't have a valid id (id == "0" or null) + 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, + }); } if (operations.isEmpty) { @@ -169,10 +200,18 @@ class _JobDetailsScreenState extends State with UIMixin { title: "Success", message: "Job updated successfully", type: SnackbarType.success); + + // re-fetch job detail and update local selected tags from server response await controller.fetchJobDetail(widget.jobId); final updatedJob = controller.jobDetail.value?.data; + if (updatedJob != null) { - _selectedTags.value = updatedJob.tags ?? []; + _selectedTags.value = (updatedJob.tags ?? []) + .map((t) => Tag(id: t.id, name: t.name)) + .toList(); + + // UI refresh to reflect tags instantly + setState(() {}); } isEditing.value = false; @@ -184,6 +223,29 @@ class _JobDetailsScreenState extends State with UIMixin { } } + 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 _handleTagAction() async { final job = controller.jobDetail.value?.data; if (job == null) return; @@ -408,10 +470,8 @@ class _JobDetailsScreenState extends State with UIMixin { border: Border.all(color: Colors.grey.shade400), ), alignment: Alignment.centerLeft, - child: Text( - "Tap to select assignees", - style: TextStyle(fontSize: 14, color: Colors.grey[700]), - ), + child: Text("Tap to select assignees", + style: TextStyle(fontSize: 14, color: Colors.grey[700])), ), ), ], @@ -422,19 +482,24 @@ class _JobDetailsScreenState extends State with UIMixin { Widget _tagEditor() { return Obx(() { final editing = isEditing.value; - final tags = _selectedTags; + final job = controller.jobDetail.value?.data; + + final displayTags = editing ? _selectedTags : (job?.tags ?? []); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 6, - children: tags + children: displayTags .map( (t) => Chip( label: Text(t.name ?? ''), onDeleted: editing ? () { - _selectedTags.remove(t); + _selectedTags.removeWhere((x) => + (x.id != null && x.id == t.id) || + (x.name == t.name)); } : null, ), @@ -445,17 +510,21 @@ class _JobDetailsScreenState extends State with UIMixin { 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)); + onChanged: (value) { + // If space or comma typed → process tags immediately + if (value.endsWith(" ") || value.contains(",")) { + _processTagsInput(); } - _tagTextController.clear(); + }, + onSubmitted: (_) { + // Still supports ENTER + _processTagsInput(); }, decoration: InputDecoration( - hintText: "Type and press enter to add tags", - border: - OutlineInputBorder(borderRadius: BorderRadius.circular(5)), + 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), @@ -495,11 +564,10 @@ class _JobDetailsScreenState extends State with UIMixin { const Spacer(), Obx(() => IconButton( icon: Icon( - isAttendanceExpanded.value - ? Icons.expand_less - : Icons.expand_more, - color: Colors.grey[600], - ), + isAttendanceExpanded.value + ? Icons.expand_less + : Icons.expand_more, + color: Colors.grey[600]), onPressed: () async { isAttendanceExpanded.value = !isAttendanceExpanded.value; @@ -526,22 +594,17 @@ class _JobDetailsScreenState extends State with UIMixin { height: 16, width: 16, child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) + 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, - ), + 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), - ), + borderRadius: BorderRadius.circular(5)), backgroundColor: action == 0 ? Colors.green : Colors.red, ), @@ -563,10 +626,8 @@ class _JobDetailsScreenState extends State with UIMixin { if (logs.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 12), - child: MyText.bodyMedium( - "No attendance logs available", - color: Colors.grey[600], - ), + child: MyText.bodyMedium("No attendance logs available", + color: Colors.grey[600]), ); } @@ -601,25 +662,21 @@ class _JobDetailsScreenState extends State with UIMixin { Row( children: [ Icon( - log.action == 0 ? Icons.login : Icons.logout, - color: log.action == 0 - ? Colors.green - : Colors.red, - size: 18, - ), + 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]), - ), + child: Text(employeeName, + style: const TextStyle( + fontWeight: FontWeight.w600))), + Text("$date | $time", + style: TextStyle( + fontSize: 12, color: Colors.grey[700])), ], ), const SizedBox(height: 4), @@ -627,12 +684,9 @@ class _JobDetailsScreenState extends State with UIMixin { // Comment if (log.comment?.isNotEmpty == true) Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - log.comment!, - style: const TextStyle(fontSize: 13), - ), - ), + padding: const EdgeInsets.only(top: 4), + child: Text(log.comment!, + style: const TextStyle(fontSize: 13))), // Location if (log.latitude != null && log.longitude != null) @@ -657,14 +711,12 @@ class _JobDetailsScreenState extends State with UIMixin { 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), - ), + Text("View Location", + style: TextStyle( + fontSize: 12, + color: Colors.blue, + decoration: + TextDecoration.underline)), ], ), ), @@ -679,16 +731,13 @@ class _JobDetailsScreenState extends State with UIMixin { 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, - ), - ), + log.document!.preSignedUrl, + fit: BoxFit.cover, + height: 250, + errorBuilder: (_, __, ___) => + const Icon(Icons.broken_image, + size: 50, + color: Colors.grey)), ), ), child: ClipRRect( @@ -701,10 +750,9 @@ class _JobDetailsScreenState extends State with UIMixin { width: 50, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Icon( - Icons.broken_image, - size: 40, - color: Colors.grey, - ), + Icons.broken_image, + size: 40, + color: Colors.grey), ), ), ), @@ -728,14 +776,10 @@ class _JobDetailsScreenState extends State with UIMixin { 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), - ), + flex: 3, + child: MyText.bodySmall(label, + fontWeight: 600, color: Colors.grey.shade700)), + Expanded(flex: 5, child: MyText.bodyMedium(value, fontWeight: 500)), ], ); } @@ -763,19 +807,15 @@ class _JobDetailsScreenState extends State with UIMixin { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), appBar: CustomAppBar( - title: "Job Details Screen", - onBackPressed: () => Get.back(), - projectName: projectName, - ), + title: "Job Details Screen", + onBackPressed: () => Get.back(), + projectName: projectName), 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, - ), + label: MyText.bodyMedium(isEditing.value ? "Save" : "Edit", + color: Colors.white, fontWeight: 600), icon: Icon(isEditing.value ? Icons.save : Icons.edit), )), body: Obx(() { @@ -800,20 +840,18 @@ class _JobDetailsScreenState extends State with UIMixin { children: [ _buildAttendanceCard(), _buildSectionCard( - title: "Job Info", - titleIcon: Icons.task_outlined, - children: [ - _editableRow("Title", _titleController), - _editableRow("Description", _descriptionController), - _dateRangePicker(), - ], - ), + 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()], - ), + title: "Project Branch", + titleIcon: Icons.account_tree_outlined, + children: [_branchDisplay()]), MySpacing.height(16), _buildSectionCard( title: "Assignees", @@ -871,12 +909,11 @@ class JobTimeline extends StatelessWidget { 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)), - ), + 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), @@ -891,13 +928,12 @@ class JobTimeline extends StatelessWidget { 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), - ), + 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)), ]),