Compare commits

...

4 Commits

8 changed files with 272 additions and 224 deletions

View File

@ -63,26 +63,38 @@ class AddServiceProjectJobController extends GetxController {
return; return;
} }
final assigneeIds = selectedAssignees.map((e) => e.id).toList();
isLoading.value = true; isLoading.value = true;
final success = await ApiService.createServiceProjectJobApi( final jobId = await ApiService.createServiceProjectJobApi(
title: titleCtrl.text.trim(), title: titleCtrl.text.trim(),
description: descCtrl.text.trim(), description: descCtrl.text.trim(),
projectId: projectId, projectId: projectId,
branchId: selectedBranch.value?.id, branchId: selectedBranch.value?.id,
assignees: assigneeIds.map((id) => {"id": id}).toList(), assignees: selectedAssignees // payload mapping
.map((e) => {"employeeId": e.id, "isActive": true})
.toList(),
startDate: startDate.value!, startDate: startDate.value!,
dueDate: dueDate.value!, dueDate: dueDate.value!,
tags: enteredTags.map((tag) => {"name": tag}).toList(), tags: enteredTags
.map((tag) => {
"id": null,
"name": tag,
"isActive": true
})
.toList(),
); );
isLoading.value = false; isLoading.value = false;
if (success) { if (jobId != null) {
if (Get.isRegistered<ServiceProjectDetailsController>()) { if (Get.isRegistered<ServiceProjectDetailsController>()) {
Get.find<ServiceProjectDetailsController>().refreshJobsAfterAdd(); final detailsCtrl = Get.find<ServiceProjectDetailsController>();
// 🔥 1. Refresh job LIST
detailsCtrl.refreshJobsAfterAdd();
// 🔥 2. Refresh job DETAILS (FULL DATA - including tags and assignees)
await detailsCtrl.fetchJobDetail(jobId);
} }
Get.back(); Get.back();

View File

@ -1,3 +1,4 @@
// service_project_details_screen_controller.dart
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/service_project/service_projects_details_model.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(); final lowerSearch = searchText.toLowerCase();
return job.title.toLowerCase().contains(lowerSearch) || return job.title.toLowerCase().contains(lowerSearch) ||
(job.description.toLowerCase().contains(lowerSearch)) || (job.description.toLowerCase().contains(lowerSearch)) ||
(job.tags?.any( (job.tags?.any((tag) => tag.name.toLowerCase().contains(lowerSearch)) ?? false);
(tag) => tag.name.toLowerCase().contains(lowerSearch)) ??
false);
}).toList(); }).toList();
} }
} }
@ -93,10 +92,7 @@ class ServiceProjectDetailsController extends GetxController {
teamErrorMessage.value = ''; teamErrorMessage.value = '';
try { try {
final result = await ApiService.getServiceProjectAllocationList( final result = await ApiService.getServiceProjectAllocationList(projectId: projectId.value, isActive: true);
projectId: projectId.value,
isActive: true,
);
if (result != null) { if (result != null) {
teamList.value = result; teamList.value = result;
@ -120,14 +116,12 @@ class ServiceProjectDetailsController extends GetxController {
errorMessage.value = ''; errorMessage.value = '';
try { try {
final result = final result = await ApiService.getServiceProjectDetailApi(projectId.value);
await ApiService.getServiceProjectDetailApi(projectId.value);
if (result != null && result.data != null) { if (result != null && result.data != null) {
projectDetail.value = result.data!; projectDetail.value = result.data!;
} else { } else {
errorMessage.value = errorMessage.value = result?.message ?? "Failed to fetch project details";
result?.message ?? "Failed to fetch project details";
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error: $e"; errorMessage.value = "Error: $e";
@ -146,8 +140,7 @@ class ServiceProjectDetailsController extends GetxController {
attendanceMessage.value = ''; attendanceMessage.value = '';
try { try {
final result = final result = await ApiService.getJobAttendanceLog(attendanceId: attendanceId);
await ApiService.getJobAttendanceLog(attendanceId: attendanceId);
if (result != null) { if (result != null) {
attendanceLog.value = result; attendanceLog.value = result;
@ -210,10 +203,7 @@ class ServiceProjectDetailsController extends GetxController {
pageNumber = 1; pageNumber = 1;
hasMoreJobs.value = true; hasMoreJobs.value = true;
await Future.wait([ await Future.wait([fetchProjectDetail(), fetchProjectJobs()]);
fetchProjectDetail(),
fetchProjectJobs(),
]);
} }
// -------------------- Job Detail -------------------- // -------------------- Job Detail --------------------
@ -258,13 +248,11 @@ class ServiceProjectDetailsController extends GetxController {
} }
if (permission == LocationPermission.deniedForever) { if (permission == LocationPermission.deniedForever) {
attendanceMessage.value = attendanceMessage.value = "Location permission permanently denied. Enable it from settings.";
"Location permission permanently denied. Enable it from settings.";
return null; return null;
} }
return await Geolocator.getCurrentPosition( return await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
desiredAccuracy: LocationAccuracy.high);
} catch (e) { } catch (e) {
attendanceMessage.value = "Failed to get location: $e"; attendanceMessage.value = "Failed to get location: $e";
return null; return null;
@ -295,8 +283,7 @@ class ServiceProjectDetailsController extends GetxController {
if (attachment != null) { if (attachment != null) {
final bytes = await attachment.readAsBytes(); final bytes = await attachment.readAsBytes();
final base64Data = base64Encode(bytes); final base64Data = base64Encode(bytes);
final mimeType = final mimeType = lookupMimeType(attachment.path) ?? 'application/octet-stream';
lookupMimeType(attachment.path) ?? 'application/octet-stream';
attachmentPayload = { attachmentPayload = {
"documentId": jobId, "documentId": jobId,
"fileName": attachment.path.split('/').last, "fileName": attachment.path.split('/').last,
@ -317,13 +304,10 @@ class ServiceProjectDetailsController extends GetxController {
"attachment": attachmentPayload, "attachment": attachmentPayload,
}; };
final success = await ApiService.updateServiceProjectJobAttendance( final success = await ApiService.updateServiceProjectJobAttendance(payload: payload);
payload: payload,
);
if (success) { if (success) {
attendanceMessage.value = attendanceMessage.value = action == 0 ? "Tagged In successfully" : "Tagged Out successfully";
action == 0 ? "Tagged In successfully" : "Tagged Out successfully";
await fetchJobDetail(jobId); await fetchJobDetail(jobId);
} else { } else {
attendanceMessage.value = "Failed to update attendance"; attendanceMessage.value = "Failed to update attendance";

View File

@ -1,9 +1,9 @@
class ApiEndpoints { 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://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.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://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"; static const String getMasterCurrencies = "/Master/currencies/list";

View File

@ -595,8 +595,7 @@ class ApiService {
return null; return null;
} }
/// Create a new Service Project Job static Future<String?> createServiceProjectJobApi({
static Future<bool> createServiceProjectJobApi({
required String title, required String title,
required String description, required String description,
required String projectId, required String projectId,
@ -623,32 +622,22 @@ class ApiService {
try { try {
final response = await _postRequest(endpoint, body); final response = await _postRequest(endpoint, body);
if (response == null) { if (response == null) return null;
logSafe("Create Service Project Job failed: null response",
level: LogLevel.error);
return false;
}
logSafe(
"Create Service Project Job response status: ${response.statusCode}");
logSafe("Create Service Project Job response body: ${response.body}");
final json = jsonDecode(response.body); final json = jsonDecode(response.body);
if (json['success'] == true) { if (json['success'] == true) {
logSafe("Service Project Job created successfully: ${json['data']}"); final jobId = json['data']?['id'];
return true; logSafe("Service Project Job created successfully: $jobId");
} else { return jobId;
logSafe(
"Failed to create Service Project Job: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
} }
return null;
} catch (e, stack) { } catch (e, stack) {
logSafe("Exception during createServiceProjectJobApi: $e", logSafe("Exception during createServiceProjectJobApi: $e",
level: LogLevel.error); level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug); logSafe("StackTrace: $stack", level: LogLevel.debug);
return false; return null;
} }
} }
@ -671,8 +660,7 @@ class ApiService {
'pageNumber': pageNumber.toString(), 'pageNumber': pageNumber.toString(),
'pageSize': pageSize.toString(), 'pageSize': pageSize.toString(),
'isActive': isActive.toString(), 'isActive': isActive.toString(),
if (isArchive) if (isArchive) 'isArchive': 'true',
'isArchive': 'true',
}; };
final response = await _getRequest(endpoint, queryParams: queryParams); final response = await _getRequest(endpoint, queryParams: queryParams);

View File

@ -220,8 +220,9 @@ class _AddServiceProjectJobBottomSheetState
.where((s) => s.isNotEmpty); .where((s) => s.isNotEmpty);
for (final p in parts) { for (final p in parts) {
if (!controller.enteredTags.contains(p)) { final clean = p.replaceAll('_', ' ');
controller.enteredTags.add(p); if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
} }
} }
} }
@ -239,8 +240,9 @@ class _AddServiceProjectJobBottomSheetState
.where((s) => s.isNotEmpty); .where((s) => s.isNotEmpty);
for (final p in parts) { for (final p in parts) {
if (!controller.enteredTags.contains(p)) { final clean = p.replaceAll('_', ' ');
controller.enteredTags.add(p); if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
} }
} }
controller.tagCtrl.clear(); controller.tagCtrl.clear();
@ -256,8 +258,9 @@ class _AddServiceProjectJobBottomSheetState
.where((s) => s.isNotEmpty); .where((s) => s.isNotEmpty);
for (final p in parts) { for (final p in parts) {
if (!controller.enteredTags.contains(p)) { final clean = p.replaceAll('_', ' ');
controller.enteredTags.add(p); if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
} }
} }
controller.tagCtrl.clear(); controller.tagCtrl.clear();

View File

@ -367,9 +367,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
? DateFormat('dd MMM yyyy').format(parsedDate) ? DateFormat('dd MMM yyyy').format(parsedDate)
: (dateStr.isNotEmpty ? dateStr : ''); : (dateStr.isNotEmpty ? dateStr : '');
final formattedTime =
parsedDate != null ? DateFormat('hh:mm a').format(parsedDate) : '';
final project = item.name ?? ''; final project = item.name ?? '';
final desc = item.title ?? ''; final desc = item.title ?? '';
final amount = (item.amount ?? 0).toDouble(); final amount = (item.amount ?? 0).toDouble();
@ -399,16 +396,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
style: style:
TextStyle(fontSize: 12, color: Colors.grey.shade600), 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), const SizedBox(height: 4),

View File

@ -248,6 +248,44 @@ class _UserProfileBarState extends State<UserProfileBar>
final tenants = tenantSwitchController.tenants; final tenants = tenantSwitchController.tenants;
if (tenants.isEmpty) return _noTenantContainer(); if (tenants.isEmpty) return _noTenantContainer();
// If only one organization, don't show switch option
if (tenants.length == 1) {
final selectedTenant = tenants.first;
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 1),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 20,
height: 20,
color: Colors.grey.shade200,
child: TenantLogo(logoImage: selectedTenant.logoImage),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
selectedTenant.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
color: contentTheme.primary),
),
),
const Icon(Icons.check_circle, color: Colors.green, size: 18),
],
),
);
}
final selectedTenant = TenantService.currentTenant; final selectedTenant = TenantService.currentTenant;
final sortedTenants = List.of(tenants); final sortedTenants = List.of(tenants);

View File

@ -39,19 +39,23 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
final TextEditingController _dueDateController = TextEditingController(); final TextEditingController _dueDateController = TextEditingController();
final TextEditingController _tagTextController = TextEditingController(); final TextEditingController _tagTextController = TextEditingController();
// local selected lists used while editing
final RxList<Assignee> _selectedAssignees = <Assignee>[].obs; final RxList<Assignee> _selectedAssignees = <Assignee>[].obs;
final RxList<Tag> _selectedTags = <Tag>[].obs; final RxList<Tag> _selectedTags = <Tag>[].obs;
final RxBool isEditing = false.obs; final RxBool isEditing = false.obs;
File? imageAttachment; File? imageAttachment;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = Get.put(ServiceProjectDetailsController()); controller = Get.find<ServiceProjectDetailsController>();
// fetch and seed local selected lists
controller.fetchJobDetail(widget.jobId).then((_) { controller.fetchJobDetail(widget.jobId).then((_) {
final job = controller.jobDetail.value?.data; final job = controller.jobDetail.value?.data;
if (job != null) { 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 ?? ''; _titleController.text = job.title ?? '';
_descriptionController.text = job.description ?? ''; _descriptionController.text = job.description ?? '';
_startDateController.text = DateTimeUtils.convertUtcToLocal( _startDateController.text = DateTimeUtils.convertUtcToLocal(
@ -61,7 +65,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
job.dueDate ?? '', job.dueDate ?? '',
format: "yyyy-MM-dd"); format: "yyyy-MM-dd");
_selectedAssignees.value = job.assignees ?? []; _selectedAssignees.value = job.assignees ?? [];
_selectedTags.value = job.tags ?? [];
} }
}); });
} }
@ -76,7 +79,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
super.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 { Future<void> _editJob() async {
_processTagsInput();
final job = controller.jobDetail.value?.data; final job = controller.jobDetail.value?.data;
if (job == null) return; if (job == null) return;
@ -116,39 +130,56 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
}); });
} }
final originalAssignees = job.assignees; // Assignees payload (keep same approach)
final assigneesPayload = originalAssignees?.map((a) { final originalAssignees = job.assignees ?? [];
final assigneesPayload = originalAssignees.map((a) {
final isSelected = _selectedAssignees.any((s) => s.id == a.id); final isSelected = _selectedAssignees.any((s) => s.id == a.id);
return {"employeeId": a.id, "isActive": isSelected}; return {"employeeId": a.id, "isActive": isSelected};
}).toList(); }).toList();
// add newly added assignees
for (var s in _selectedAssignees) { for (var s in _selectedAssignees) {
if (!(originalAssignees?.any((a) => a.id == s.id) ?? false)) { if (!(originalAssignees.any((a) => a.id == s.id))) {
assigneesPayload?.add({"employeeId": s.id, "isActive": true}); assigneesPayload.add({"employeeId": s.id, "isActive": true});
} }
} }
operations.add( operations.add(
{"op": "replace", "path": "/assignees", "value": assigneesPayload}); {"op": "replace", "path": "/assignees", "value": assigneesPayload});
final originalTags = job.tags; // TAGS: build robust payload using original tags and current selection
final replaceTagsPayload = originalTags?.map((t) { final originalTags = job.tags ?? [];
final isSelected = _selectedTags.any((s) => s.id == t.id); final currentTags = _selectedTags.toList();
return {"id": t.id, "name": t.name, "isActive": isSelected};
}).toList();
final addTagsPayload = _selectedTags // Only add tags operation if something changed
.where((t) => t.id == "0") if (_tagsAreDifferent(originalTags, currentTags)) {
.map((t) => {"name": t.name, "isActive": true}) final List<Map<String, dynamic>> finalTagsPayload = [];
.toList();
if ((replaceTagsPayload?.isNotEmpty ?? false)) { // 1) For existing original tags - we need to mark isActive true/false depending on whether they're in currentTags
operations for (var ot in originalTags) {
.add({"op": "replace", "path": "/tags", "value": replaceTagsPayload}); 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) { // 2) Add newly created tags from currentTags that don't have a valid id (id == "0" or null)
operations.add({"op": "add", "path": "/tags", "value": addTagsPayload}); 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) { if (operations.isEmpty) {
@ -169,10 +200,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
title: "Success", title: "Success",
message: "Job updated successfully", message: "Job updated successfully",
type: SnackbarType.success); type: SnackbarType.success);
// re-fetch job detail and update local selected tags from server response
await controller.fetchJobDetail(widget.jobId); await controller.fetchJobDetail(widget.jobId);
final updatedJob = controller.jobDetail.value?.data; final updatedJob = controller.jobDetail.value?.data;
if (updatedJob != null) { 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; 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 { Future<void> _handleTagAction() async {
final job = controller.jobDetail.value?.data; final job = controller.jobDetail.value?.data;
if (job == null) return; if (job == null) return;
@ -408,10 +470,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
border: Border.all(color: Colors.grey.shade400), border: Border.all(color: Colors.grey.shade400),
), ),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text("Tap to select assignees",
"Tap to select assignees", style: TextStyle(fontSize: 14, color: Colors.grey[700])),
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
), ),
), ),
], ],
@ -422,19 +482,24 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Widget _tagEditor() { Widget _tagEditor() {
return Obx(() { return Obx(() {
final editing = isEditing.value; final editing = isEditing.value;
final tags = _selectedTags; final job = controller.jobDetail.value?.data;
final displayTags = editing ? _selectedTags : (job?.tags ?? []);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Wrap( Wrap(
spacing: 6, spacing: 6,
children: tags children: displayTags
.map( .map(
(t) => Chip( (t) => Chip(
label: Text(t.name ?? ''), label: Text(t.name ?? ''),
onDeleted: editing onDeleted: editing
? () { ? () {
_selectedTags.remove(t); _selectedTags.removeWhere((x) =>
(x.id != null && x.id == t.id) ||
(x.name == t.name));
} }
: null, : null,
), ),
@ -445,17 +510,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (editing) if (editing)
TextField( TextField(
controller: _tagTextController, controller: _tagTextController,
onSubmitted: (v) { onChanged: (value) {
final value = v.trim(); // If space or comma typed process tags immediately
if (value.isNotEmpty && !tags.any((t) => t.name == value)) { if (value.endsWith(" ") || value.contains(",")) {
_selectedTags.add(Tag(id: "0", name: value)); _processTagsInput();
} }
_tagTextController.clear(); },
onSubmitted: (_) {
// Still supports ENTER
_processTagsInput();
}, },
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Type and press enter to add tags", hintText: "Type tags (space or comma to add multiple tags)",
border: border: OutlineInputBorder(
OutlineInputBorder(borderRadius: BorderRadius.circular(5)), borderRadius: BorderRadius.circular(5),
),
isDense: true, isDense: true,
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 12), const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
@ -495,11 +564,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
const Spacer(), const Spacer(),
Obx(() => IconButton( Obx(() => IconButton(
icon: Icon( icon: Icon(
isAttendanceExpanded.value isAttendanceExpanded.value
? Icons.expand_less ? Icons.expand_less
: Icons.expand_more, : Icons.expand_more,
color: Colors.grey[600], color: Colors.grey[600]),
),
onPressed: () async { onPressed: () async {
isAttendanceExpanded.value = isAttendanceExpanded.value =
!isAttendanceExpanded.value; !isAttendanceExpanded.value;
@ -526,22 +594,17 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
height: 16, height: 16,
width: 16, width: 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.white, color: Colors.white, strokeWidth: 2))
strokeWidth: 2,
),
)
: Icon(action == 0 ? Icons.login : Icons.logout), : Icon(action == 0 ? Icons.login : Icons.logout),
label: MyText.bodyMedium( label: MyText.bodyMedium(
action == 0 ? "Tag In" : "Tag Out", action == 0 ? "Tag In" : "Tag Out",
fontWeight: 600, fontWeight: 600,
color: Colors.white, color: Colors.white),
),
onPressed: isLoading ? null : _handleTagAction, onPressed: isLoading ? null : _handleTagAction,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5)),
),
backgroundColor: backgroundColor:
action == 0 ? Colors.green : Colors.red, action == 0 ? Colors.green : Colors.red,
), ),
@ -563,10 +626,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (logs.isEmpty) { if (logs.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
child: MyText.bodyMedium( child: MyText.bodyMedium("No attendance logs available",
"No attendance logs available", color: Colors.grey[600]),
color: Colors.grey[600],
),
); );
} }
@ -601,25 +662,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Row( Row(
children: [ children: [
Icon( Icon(
log.action == 0 ? Icons.login : Icons.logout, log.action == 0
color: log.action == 0 ? Icons.login
? Colors.green : Icons.logout,
: Colors.red, color: log.action == 0
size: 18, ? Colors.green
), : Colors.red,
size: 18),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(employeeName,
employeeName, style: const TextStyle(
style: const TextStyle( fontWeight: FontWeight.w600))),
fontWeight: FontWeight.w600), Text("$date | $time",
), style: TextStyle(
), fontSize: 12, color: Colors.grey[700])),
Text(
"$date | $time",
style: TextStyle(
fontSize: 12, color: Colors.grey[700]),
),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -627,12 +684,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
// Comment // Comment
if (log.comment?.isNotEmpty == true) if (log.comment?.isNotEmpty == true)
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(log.comment!,
log.comment!, style: const TextStyle(fontSize: 13))),
style: const TextStyle(fontSize: 13),
),
),
// Location // Location
if (log.latitude != null && log.longitude != null) if (log.latitude != null && log.longitude != null)
@ -657,14 +711,12 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Icon(Icons.location_on, Icon(Icons.location_on,
size: 14, color: Colors.blue), size: 14, color: Colors.blue),
SizedBox(width: 4), SizedBox(width: 4),
Text( Text("View Location",
"View Location", style: TextStyle(
style: TextStyle( fontSize: 12,
fontSize: 12, color: Colors.blue,
color: Colors.blue, decoration:
decoration: TextDecoration.underline)),
TextDecoration.underline),
),
], ],
), ),
), ),
@ -679,16 +731,13 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
child: Image.network( child: Image.network(
log.document!.preSignedUrl, log.document!.preSignedUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
height: 250, height: 250,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) =>
const Icon( const Icon(Icons.broken_image,
Icons.broken_image, size: 50,
size: 50, color: Colors.grey)),
color: Colors.grey,
),
),
), ),
), ),
child: ClipRRect( child: ClipRRect(
@ -701,10 +750,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
width: 50, width: 50,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon( errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image, Icons.broken_image,
size: 40, size: 40,
color: Colors.grey, color: Colors.grey),
),
), ),
), ),
), ),
@ -728,14 +776,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
flex: 3, flex: 3,
child: MyText.bodySmall(label, child: MyText.bodySmall(label,
fontWeight: 600, color: Colors.grey.shade700), fontWeight: 600, color: Colors.grey.shade700)),
), Expanded(flex: 5, child: MyText.bodyMedium(value, fontWeight: 500)),
Expanded(
flex: 5,
child: MyText.bodyMedium(value, fontWeight: 500),
),
], ],
); );
} }
@ -763,19 +807,15 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Job Details Screen", title: "Job Details Screen",
onBackPressed: () => Get.back(), onBackPressed: () => Get.back(),
projectName: projectName, projectName: projectName),
),
floatingActionButton: Obx(() => FloatingActionButton.extended( floatingActionButton: Obx(() => FloatingActionButton.extended(
onPressed: onPressed:
isEditing.value ? _editJob : () => isEditing.value = true, isEditing.value ? _editJob : () => isEditing.value = true,
backgroundColor: contentTheme.primary, backgroundColor: contentTheme.primary,
label: MyText.bodyMedium( label: MyText.bodyMedium(isEditing.value ? "Save" : "Edit",
isEditing.value ? "Save" : "Edit", color: Colors.white, fontWeight: 600),
color: Colors.white,
fontWeight: 600,
),
icon: Icon(isEditing.value ? Icons.save : Icons.edit), icon: Icon(isEditing.value ? Icons.save : Icons.edit),
)), )),
body: Obx(() { body: Obx(() {
@ -800,20 +840,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
children: [ children: [
_buildAttendanceCard(), _buildAttendanceCard(),
_buildSectionCard( _buildSectionCard(
title: "Job Info", title: "Job Info",
titleIcon: Icons.task_outlined, titleIcon: Icons.task_outlined,
children: [ children: [
_editableRow("Title", _titleController), _editableRow("Title", _titleController),
_editableRow("Description", _descriptionController), _editableRow("Description", _descriptionController),
_dateRangePicker(), _dateRangePicker(),
], ]),
),
MySpacing.height(12), MySpacing.height(12),
_buildSectionCard( _buildSectionCard(
title: "Project Branch", title: "Project Branch",
titleIcon: Icons.account_tree_outlined, titleIcon: Icons.account_tree_outlined,
children: [_branchDisplay()], children: [_branchDisplay()]),
),
MySpacing.height(16), MySpacing.height(16),
_buildSectionCard( _buildSectionCard(
title: "Assignees", title: "Assignees",
@ -871,12 +909,11 @@ class JobTimeline extends StatelessWidget {
isFirst: index == 0, isFirst: index == 0,
isLast: index == reversedLogs.length - 1, isLast: index == reversedLogs.length - 1,
indicatorStyle: const IndicatorStyle( indicatorStyle: const IndicatorStyle(
width: 16, width: 16,
height: 16, height: 16,
indicator: DecoratedBox( indicator: DecoratedBox(
decoration: decoration: BoxDecoration(
BoxDecoration(color: Colors.blue, shape: BoxShape.circle)), color: Colors.blue, shape: BoxShape.circle))),
),
beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2), beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2),
endChild: Padding( endChild: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@ -891,13 +928,12 @@ class JobTimeline extends StatelessWidget {
const SizedBox(height: 10), const SizedBox(height: 10),
Row(children: [ Row(children: [
Container( Container(
padding: padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2), const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4)), borderRadius: BorderRadius.circular(4)),
child: MyText.bodySmall(initials, fontWeight: 600), child: MyText.bodySmall(initials, fontWeight: 600)),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded(child: MyText.bodySmall(updatedBy)), Expanded(child: MyText.bodySmall(updatedBy)),
]), ]),