Dev_Manish_Bug #86

Closed
manish.zure wants to merge 4 commits from Dev_Manish_Bug into main
5 changed files with 203 additions and 193 deletions
Showing only changes of commit 30d18d5ac6 - Show all commits

View File

@ -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";

View File

@ -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";

View File

@ -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();

View File

@ -367,9 +367,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
? 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<AdvancePaymentScreen>
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),

View File

@ -39,19 +39,23 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
final TextEditingController _dueDateController = TextEditingController();
final TextEditingController _tagTextController = TextEditingController();
// local selected lists used while editing
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.put(ServiceProjectDetailsController());
controller = Get.find<ServiceProjectDetailsController>();
// 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<JobDetailsScreen> with UIMixin {
job.dueDate ?? '',
format: "yyyy-MM-dd");
_selectedAssignees.value = job.assignees ?? [];
_selectedTags.value = job.tags ?? [];
}
});
}
@ -76,7 +79,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
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();
final job = controller.jobDetail.value?.data;
if (job == null) return;
@ -116,39 +130,56 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> 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<Map<String, dynamic>> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<void> _handleTagAction() async {
final job = controller.jobDetail.value?.data;
if (job == null) return;
@ -408,10 +470,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<JobDetailsScreen> 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)),
]),