diff --git a/lib/controller/attendance/attendance_screen_controller.dart b/lib/controller/attendance/attendance_screen_controller.dart index cbb6836..9d6ecb7 100644 --- a/lib/controller/attendance/attendance_screen_controller.dart +++ b/lib/controller/attendance/attendance_screen_controller.dart @@ -39,6 +39,7 @@ class AttendanceController extends GetxController { final isLoadingRegularizationLogs = true.obs; final isLoadingLogView = true.obs; final uploadingStates = {}.obs; + var showPendingOnly = false.obs; @override void onInit() { @@ -73,6 +74,36 @@ class AttendanceController extends GetxController { "Attendance data refreshed from notification for project $projectId"); } + // 🔍 Search query + final searchQuery = ''.obs; + + // Computed filtered employees + List get filteredEmployees { + if (searchQuery.value.isEmpty) return employees; + return employees + .where((e) => + e.name.toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList(); + } + + // Computed filtered logs + List get filteredLogs { + if (searchQuery.value.isEmpty) return attendanceLogs; + return attendanceLogs + .where((log) => + (log.name).toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList(); + } + +// Computed filtered regularization logs + List get filteredRegularizationLogs { + if (searchQuery.value.isEmpty) return regularizationLogs; + return regularizationLogs + .where((log) => + log.name.toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList(); + } + Future fetchProjects() async { isLoadingProjects.value = true; diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 9f3727d..139c2d9 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -7,6 +7,8 @@ import 'package:get/get.dart'; import 'package:geocoding/geocoding.dart'; import 'package:geolocator/geolocator.dart'; import 'package:intl/intl.dart'; +import 'package:mime/mime.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/services/api_service.dart'; @@ -15,21 +17,30 @@ import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; -import 'package:mime/mime.dart'; -import 'package:image_picker/image_picker.dart'; class AddExpenseController extends GetxController { // --- Text Controllers --- - final amountController = TextEditingController(); - final descriptionController = TextEditingController(); - final supplierController = TextEditingController(); - final transactionIdController = TextEditingController(); - final gstController = TextEditingController(); - final locationController = TextEditingController(); - final transactionDateController = TextEditingController(); - final noOfPersonsController = TextEditingController(); + final controllers = [ + TextEditingController(), // amount + TextEditingController(), // description + TextEditingController(), // supplier + TextEditingController(), // transactionId + TextEditingController(), // gst + TextEditingController(), // location + TextEditingController(), // transactionDate + TextEditingController(), // noOfPersons + TextEditingController(), // employeeSearch + ]; - final employeeSearchController = TextEditingController(); + TextEditingController get amountController => controllers[0]; + TextEditingController get descriptionController => controllers[1]; + TextEditingController get supplierController => controllers[2]; + TextEditingController get transactionIdController => controllers[3]; + TextEditingController get gstController => controllers[4]; + TextEditingController get locationController => controllers[5]; + TextEditingController get transactionDateController => controllers[6]; + TextEditingController get noOfPersonsController => controllers[7]; + TextEditingController get employeeSearchController => controllers[8]; // --- Reactive State --- final isLoading = false.obs; @@ -59,29 +70,19 @@ class AddExpenseController extends GetxController { final expenseController = Get.find(); final ImagePicker _picker = ImagePicker(); + @override void onInit() { super.onInit(); - fetchMasterData(); - fetchGlobalProjects(); - employeeSearchController.addListener(() { - searchEmployees(employeeSearchController.text); - }); + loadMasterData(); + employeeSearchController.addListener( + () => searchEmployees(employeeSearchController.text), + ); } @override void onClose() { - for (var c in [ - amountController, - descriptionController, - supplierController, - transactionIdController, - gstController, - locationController, - transactionDateController, - noOfPersonsController, - employeeSearchController, - ]) { + for (var c in controllers) { c.dispose(); } super.onClose(); @@ -92,11 +93,19 @@ class AddExpenseController extends GetxController { if (query.trim().isEmpty) return employeeSearchResults.clear(); isSearchingEmployees.value = true; try { - final data = - await ApiService.searchEmployeesBasic(searchString: query.trim()); - employeeSearchResults.assignAll( - (data ?? []).map((e) => EmployeeModel.fromJson(e)), + final data = await ApiService.searchEmployeesBasic( + searchString: query.trim(), ); + + if (data is List) { + employeeSearchResults.assignAll( + data + .map((e) => EmployeeModel.fromJson(e as Map)) + .toList(), + ); + } else { + employeeSearchResults.clear(); + } } catch (e) { logSafe("Error searching employees: $e", level: LogLevel.error); employeeSearchResults.clear(); @@ -105,64 +114,77 @@ class AddExpenseController extends GetxController { } } - // --- Form Population: Edit Mode --- + // --- Form Population (Edit) --- Future populateFieldsForEdit(Map data) async { isEditMode.value = true; editingExpenseId = '${data['id']}'; selectedProject.value = data['projectName'] ?? ''; - amountController.text = data['amount']?.toString() ?? ''; + amountController.text = '${data['amount'] ?? ''}'; supplierController.text = data['supplerName'] ?? ''; descriptionController.text = data['description'] ?? ''; transactionIdController.text = data['transactionId'] ?? ''; locationController.text = data['location'] ?? ''; - noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); + noOfPersonsController.text = '${data['noOfPersons'] ?? 0}'; - // Transaction Date - if (data['transactionDate'] != null) { - try { - final parsed = DateTime.parse(data['transactionDate']); - selectedTransactionDate.value = parsed; - transactionDateController.text = - DateFormat('dd-MM-yyyy').format(parsed); - } catch (_) { - selectedTransactionDate.value = null; - transactionDateController.clear(); - } - } - - // Dropdown - selectedExpenseType.value = - expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); - selectedPaymentMode.value = - paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); - - // Paid By - final paidById = '${data['paidById']}'; - selectedPaidBy.value = - allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim()); - if (selectedPaidBy.value == null && data['paidByFirstName'] != null) { - await searchEmployees( - '${data['paidByFirstName']} ${data['paidByLastName']}'); - selectedPaidBy.value = employeeSearchResults - .firstWhereOrNull((e) => e.id.trim() == paidById.trim()); - } - - // Attachments - existingAttachments.clear(); - if (data['attachments'] is List) { - existingAttachments.addAll( - List>.from(data['attachments']) - .map((e) => {...e, 'isActive': true}), - ); - } + _setTransactionDate(data['transactionDate']); + _setDropdowns(data); + await _setPaidBy(data); + _setAttachments(data['attachments']); _logPrefilledData(); } + void _setTransactionDate(dynamic dateStr) { + if (dateStr == null) { + selectedTransactionDate.value = null; + transactionDateController.clear(); + return; + } + try { + final parsed = DateTime.parse(dateStr); + selectedTransactionDate.value = parsed; + transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed); + } catch (_) { + selectedTransactionDate.value = null; + transactionDateController.clear(); + } + } + + void _setDropdowns(Map data) { + selectedExpenseType.value = + expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); + selectedPaymentMode.value = + paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); + } + + Future _setPaidBy(Map data) async { + final paidById = '${data['paidById']}'; + selectedPaidBy.value = + allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim()); + + if (selectedPaidBy.value == null && data['paidByFirstName'] != null) { + await searchEmployees( + '${data['paidByFirstName']} ${data['paidByLastName']}', + ); + selectedPaidBy.value = employeeSearchResults + .firstWhereOrNull((e) => e.id.trim() == paidById.trim()); + } + } + + void _setAttachments(dynamic attachmentsData) { + existingAttachments.clear(); + if (attachmentsData is List) { + existingAttachments.addAll( + List>.from(attachmentsData).map( + (e) => {...e, 'isActive': true}, + ), + ); + } + } + void _logPrefilledData() { - logSafe('--- Prefilled Expense Data ---', level: LogLevel.info); - [ + final info = [ 'ID: $editingExpenseId', 'Project: ${selectedProject.value}', 'Amount: ${amountController.text}', @@ -177,7 +199,10 @@ class AddExpenseController extends GetxController { 'Paid By: ${selectedPaidBy.value?.name}', 'Attachments: ${attachments.length}', 'Existing Attachments: ${existingAttachments.length}', - ].forEach((str) => logSafe(str, level: LogLevel.info)); + ]; + for (var line in info) { + logSafe(line, level: LogLevel.info); + } } // --- Pickers --- @@ -199,7 +224,6 @@ class AddExpenseController extends GetxController { now.minute, now.second, ); - selectedTransactionDate.value = finalDateTime; transactionDateController.text = DateFormat('dd MMM yyyy').format(finalDateTime); @@ -214,8 +238,9 @@ class AddExpenseController extends GetxController { allowMultiple: true, ); if (result != null) { - attachments - .addAll(result.paths.whereType().map((path) => File(path))); + attachments.addAll( + result.paths.whereType().map(File.new), + ); } } catch (e) { _errorSnackbar("Attachment error: $e"); @@ -224,12 +249,20 @@ class AddExpenseController extends GetxController { void removeAttachment(File file) => attachments.remove(file); + Future pickFromCamera() async { + try { + final pickedFile = await _picker.pickImage(source: ImageSource.camera); + if (pickedFile != null) attachments.add(File(pickedFile.path)); + } catch (e) { + _errorSnackbar("Camera error: $e"); + } + } + // --- Location --- Future fetchCurrentLocation() async { isFetchingLocation.value = true; try { - final permission = await _ensureLocationPermission(); - if (!permission) return; + if (!await _ensureLocationPermission()) return; final position = await Geolocator.getCurrentPosition(); final placemarks = @@ -241,7 +274,7 @@ class AddExpenseController extends GetxController { placemarks.first.street, placemarks.first.locality, placemarks.first.administrativeArea, - placemarks.first.country + placemarks.first.country, ].where((e) => e?.isNotEmpty == true).join(", ") : "${position.latitude}, ${position.longitude}"; } catch (e) { @@ -271,19 +304,23 @@ class AddExpenseController extends GetxController { // --- Data Fetching --- Future loadMasterData() async => - await Future.wait([fetchMasterData(), fetchGlobalProjects()]); + Future.wait([fetchMasterData(), fetchGlobalProjects()]); Future fetchMasterData() async { try { final types = await ApiService.getMasterExpenseTypes(); - if (types is List) - expenseTypes.value = - types.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + if (types is List) { + expenseTypes.value = types + .map((e) => ExpenseTypeModel.fromJson(e as Map)) + .toList(); + } final modes = await ApiService.getMasterPaymentModes(); - if (modes is List) - paymentModes.value = - modes.map((e) => PaymentModeModel.fromJson(e)).toList(); + if (modes is List) { + paymentModes.value = modes + .map((e) => PaymentModeModel.fromJson(e as Map)) + .toList(); + } } catch (_) { _errorSnackbar("Failed to fetch master data"); } @@ -295,8 +332,8 @@ class AddExpenseController extends GetxController { if (response != null) { final names = []; for (var item in response) { - final name = item['name']?.toString().trim(), - id = item['id']?.toString().trim(); + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); if (name != null && id != null) { projectsMap[name] = id; names.add(name); @@ -309,17 +346,6 @@ class AddExpenseController extends GetxController { } } - Future pickFromCamera() async { - try { - final pickedFile = await _picker.pickImage(source: ImageSource.camera); - if (pickedFile != null) { - attachments.add(File(pickedFile.path)); - } - } catch (e) { - _errorSnackbar("Camera error: $e"); - } - } - // --- Submission --- Future submitOrUpdateExpense() async { if (isSubmitting.value) return; @@ -332,24 +358,7 @@ class AddExpenseController extends GetxController { } final payload = await _buildExpensePayload(); - - final success = isEditMode.value && editingExpenseId != null - ? await ApiService.editExpenseApi( - expenseId: editingExpenseId!, payload: payload) - : await ApiService.createExpenseApi( - projectId: payload['projectId'], - expensesTypeId: payload['expensesTypeId'], - paymentModeId: payload['paymentModeId'], - paidById: payload['paidById'], - transactionDate: DateTime.parse(payload['transactionDate']), - transactionId: payload['transactionId'], - description: payload['description'], - location: payload['location'], - supplerName: payload['supplerName'], - amount: payload['amount'], - noOfPersons: payload['noOfPersons'], - billAttachments: payload['billAttachments'], - ); + final success = await _submitToApi(payload); if (success) { await expenseController.fetchExpenses(); @@ -370,61 +379,71 @@ class AddExpenseController extends GetxController { } } + Future _submitToApi(Map payload) async { + if (isEditMode.value && editingExpenseId != null) { + return ApiService.editExpenseApi( + expenseId: editingExpenseId!, + payload: payload, + ); + } + return ApiService.createExpenseApi( + projectId: payload['projectId'], + expensesTypeId: payload['expensesTypeId'], + paymentModeId: payload['paymentModeId'], + paidById: payload['paidById'], + transactionDate: DateTime.parse(payload['transactionDate']), + transactionId: payload['transactionId'], + description: payload['description'], + location: payload['location'], + supplerName: payload['supplerName'], + amount: payload['amount'], + noOfPersons: payload['noOfPersons'], + billAttachments: payload['billAttachments'], + ); + } + Future> _buildExpensePayload() async { final now = DateTime.now(); - // --- Existing Attachments Payload (for edit mode only) --- - final List> existingAttachmentPayloads = - isEditMode.value - ? existingAttachments - .map>((e) => { - "documentId": e['documentId'], - "fileName": e['fileName'], - "contentType": e['contentType'], - "fileSize": 0, - "description": "", - "url": e['url'], - "isActive": e['isActive'] ?? true, - "base64Data": "", // <-- always empty now - }) - .toList() - : >[]; - - // --- New Attachments Payload (always include if attachments exist) --- - final List> newAttachmentPayloads = - attachments.isNotEmpty - ? await Future.wait(attachments.map((file) async { - final bytes = await file.readAsBytes(); - final length = await file.length(); - return { - "fileName": file.path.split('/').last, - "base64Data": base64Encode(bytes), - "contentType": - lookupMimeType(file.path) ?? 'application/octet-stream', - "fileSize": length, + final existingPayload = isEditMode.value + ? existingAttachments + .map((e) => { + "documentId": e['documentId'], + "fileName": e['fileName'], + "contentType": e['contentType'], + "fileSize": 0, "description": "", - }; - })) - : >[]; + "url": e['url'], + "isActive": e['isActive'] ?? true, + "base64Data": "", + }) + .toList() + : >[]; + + final newPayload = await Future.wait( + attachments.map((file) async { + final bytes = await file.readAsBytes(); + return { + "fileName": file.path.split('/').last, + "base64Data": base64Encode(bytes), + "contentType": + lookupMimeType(file.path) ?? 'application/octet-stream', + "fileSize": await file.length(), + "description": "", + }; + }), + ); - // --- Selected Expense Type --- final type = selectedExpenseType.value!; - // --- Combine all attachments --- - final List> combinedAttachments = [ - ...existingAttachmentPayloads, - ...newAttachmentPayloads - ]; - - // --- Build Payload --- - final payload = { + return { if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, "projectId": projectsMap[selectedProject.value]!, "expensesTypeId": type.id, "paymentModeId": selectedPaymentMode.value!.id, "paidById": selectedPaidBy.value!.id, - "transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc()) - .toIso8601String(), + "transactionDate": + (selectedTransactionDate.value ?? now).toUtc().toIso8601String(), "transactionId": transactionIdController.text, "description": descriptionController.text, "location": locationController.text, @@ -433,11 +452,13 @@ class AddExpenseController extends GetxController { "noOfPersons": type.noOfPersonsRequired == true ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 : 0, - "billAttachments": - combinedAttachments.isEmpty ? null : combinedAttachments, + "billAttachments": [ + ...existingPayload, + ...newPayload, + ].isEmpty + ? null + : [...existingPayload, ...newPayload], }; - - return payload; } String validateForm() { @@ -450,28 +471,27 @@ class AddExpenseController extends GetxController { if (amountController.text.trim().isEmpty) missing.add("Amount"); if (descriptionController.text.trim().isEmpty) missing.add("Description"); - // Date Required - if (selectedTransactionDate.value == null) missing.add("Transaction Date"); - if (selectedTransactionDate.value != null && - selectedTransactionDate.value!.isAfter(DateTime.now())) { + if (selectedTransactionDate.value == null) { + missing.add("Transaction Date"); + } else if (selectedTransactionDate.value!.isAfter(DateTime.now())) { missing.add("Valid Transaction Date"); } - final amount = double.tryParse(amountController.text.trim()); - if (amount == null) missing.add("Valid Amount"); + if (double.tryParse(amountController.text.trim()) == null) { + missing.add("Valid Amount"); + } - // Attachment: at least one required at all times - bool hasActiveExisting = + final hasActiveExisting = existingAttachments.any((e) => e['isActive'] != false); - if (attachments.isEmpty && !hasActiveExisting) missing.add("Attachment"); + if (attachments.isEmpty && !hasActiveExisting) { + missing.add("Attachment"); + } return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}."; } // --- Snackbar Helper --- - void _errorSnackbar(String msg, [String title = "Error"]) => showAppSnackbar( - title: title, - message: msg, - type: SnackbarType.error, - ); + void _errorSnackbar(String msg, [String title = "Error"]) { + showAppSnackbar(title: title, message: msg, type: SnackbarType.error); + } } diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart index 4a7512c..cf446f5 100644 --- a/lib/helpers/utils/date_time_utils.dart +++ b/lib/helpers/utils/date_time_utils.dart @@ -1,11 +1,11 @@ import 'package:intl/intl.dart'; -import 'package:marco/helpers/services/app_logger.dart'; +// import 'package:marco/helpers/services/app_logger.dart'; class DateTimeUtils { /// Converts a UTC datetime string to local time and formats it. static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { try { - logSafe('Received UTC string: $utcTimeString'); // 🔹 Log input + // logSafe('Received UTC string: $utcTimeString'); // 🔹 Log input final parsed = DateTime.parse(utcTimeString); final utcDateTime = DateTime.utc( @@ -23,13 +23,13 @@ class DateTimeUtils { final formatted = _formatDateTime(localDateTime, format: format); - logSafe('Converted Local DateTime: $localDateTime'); // 🔹 Log raw local datetime - logSafe('Formatted Local DateTime: $formatted'); // 🔹 Log formatted string + // logSafe('Converted Local DateTime: $localDateTime'); // 🔹 Log raw local datetime + // logSafe('Formatted Local DateTime: $formatted'); // 🔹 Log formatted string return formatted; } catch (e, stackTrace) { - logSafe('DateTime conversion failed: $e', - error: e, stackTrace: stackTrace); + // logSafe('DateTime conversion failed: $e', + // error: e, stackTrace: stackTrace); return 'Invalid Date'; } } @@ -38,10 +38,10 @@ class DateTimeUtils { static String formatDate(DateTime date, String format) { try { final formatted = DateFormat(format).format(date); - logSafe('Formatted DateTime ($date) => $formatted'); // 🔹 Log input/output + // logSafe('Formatted DateTime ($date) => $formatted'); // 🔹 Log input/output return formatted; } catch (e, stackTrace) { - logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace); + // logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace); return 'Invalid Date'; } } diff --git a/lib/helpers/widgets/expense/expense_form_widgets.dart b/lib/helpers/widgets/expense/expense_form_widgets.dart new file mode 100644 index 0000000..027b82f --- /dev/null +++ b/lib/helpers/widgets/expense/expense_form_widgets.dart @@ -0,0 +1,420 @@ +// expense_form_widgets.dart +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/controller/expense/add_expense_controller.dart'; + +/// 🔹 Common Colors & Styles +final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]); +final _tileDecoration = BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), +); + +/// ========================== +/// Section Title +/// ========================== +class SectionTitle extends StatelessWidget { + final IconData icon; + final String title; + final bool requiredField; + + const SectionTitle({ + required this.icon, + required this.title, + this.requiredField = false, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final color = Colors.grey[700]; + return Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + children: [ + TextSpan(text: title), + if (requiredField) + const TextSpan( + text: ' *', + style: TextStyle(color: Colors.red), + ), + ], + ), + ), + ], + ); + } +} + +/// ========================== +/// Custom Text Field +/// ========================== +class CustomTextField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final int maxLines; + final TextInputType keyboardType; + final String? Function(String?)? validator; + + const CustomTextField({ + required this.controller, + required this.hint, + this.maxLines = 1, + this.keyboardType = TextInputType.text, + this.validator, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + validator: validator, + decoration: InputDecoration( + hintText: hint, + hintStyle: _hintStyle, + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + ), + ); + } +} + +/// ========================== +/// Dropdown Tile +/// ========================== +class DropdownTile extends StatelessWidget { + final String title; + final VoidCallback onTap; + + const DropdownTile({ + required this.title, + required this.onTap, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: _tileDecoration, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(title, + style: const TextStyle(fontSize: 14, color: Colors.black87), + overflow: TextOverflow.ellipsis), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ); + } +} + +/// ========================== +/// Tile Container +/// ========================== +class TileContainer extends StatelessWidget { + final Widget child; + + const TileContainer({required this.child, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) => + Container(padding: const EdgeInsets.all(14), decoration: _tileDecoration, child: child); +} + +/// ========================== +/// Attachments Section +/// ========================== +class AttachmentsSection extends StatelessWidget { + final RxList attachments; + final RxList> existingAttachments; + final ValueChanged onRemoveNew; + final ValueChanged>? onRemoveExisting; + final VoidCallback onAdd; + + const AttachmentsSection({ + required this.attachments, + required this.existingAttachments, + required this.onRemoveNew, + this.onRemoveExisting, + required this.onAdd, + Key? key, + }) : super(key: key); + + static const allowedImageExtensions = ['jpg', 'jpeg', 'png']; + + bool _isImageFile(File file) { + final ext = file.path.split('.').last.toLowerCase(); + return allowedImageExtensions.contains(ext); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + final activeExisting = existingAttachments + .where((doc) => doc['isActive'] != false) + .toList(); + + final imageFiles = attachments.where(_isImageFile).toList(); + final imageExisting = activeExisting + .where((d) => + (d['contentType']?.toString().startsWith('image/') ?? false)) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (activeExisting.isNotEmpty) ...[ + const Text("Existing Attachments", + style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: activeExisting.map((doc) { + final isImage = + doc['contentType']?.toString().startsWith('image/') ?? + false; + final url = doc['url']; + final fileName = doc['fileName'] ?? 'Unnamed'; + + return _buildExistingTile( + context, + doc, + isImage, + url, + fileName, + imageExisting, + ); + }).toList(), + ), + const SizedBox(height: 16), + ], + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...attachments.map((file) => GestureDetector( + onTap: () => _onNewTap(context, file, imageFiles), + child: _AttachmentTile( + file: file, + onRemove: () => onRemoveNew(file), + ), + )), + _buildActionTile(Icons.attach_file, onAdd), + _buildActionTile(Icons.camera_alt, + () => Get.find().pickFromCamera()), + ], + ), + ], + ); + }); + } + + /// helper for new file tap + void _onNewTap(BuildContext context, File file, List imageFiles) { + if (_isImageFile(file)) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageFiles, + initialIndex: imageFiles.indexOf(file), + ), + ); + } else { + showAppSnackbar( + title: 'Info', + message: 'Preview for this file type is not supported.', + type: SnackbarType.info, + ); + } + } + + /// helper for existing file tile + Widget _buildExistingTile( + BuildContext context, + Map doc, + bool isImage, + String? url, + String fileName, + List> imageExisting, + ) { + return Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: () async { + if (isImage) { + final sources = imageExisting.map((e) => e['url']).toList(); + final idx = imageExisting.indexOf(doc); + showDialog( + context: context, + builder: (_) => + ImageViewerDialog(imageSources: sources, initialIndex: idx), + ); + } else if (url != null && await canLaunchUrlString(url)) { + await launchUrlString(url, mode: LaunchMode.externalApplication); + } else { + showAppSnackbar( + title: 'Error', + message: 'Could not open the document.', + type: SnackbarType.error, + ); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: _tileDecoration.copyWith( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(isImage ? Icons.image : Icons.insert_drive_file, + size: 20, color: Colors.grey[600]), + const SizedBox(width: 7), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Text(fileName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12)), + ), + ], + ), + ), + ), + if (onRemoveExisting != null) + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.red, size: 18), + onPressed: () => onRemoveExisting?.call(doc), + ), + ), + ], + ); + } + + Widget _buildActionTile(IconData icon, VoidCallback onTap) => GestureDetector( + onTap: onTap, + child: Container( + width: 80, + height: 80, + decoration: _tileDecoration.copyWith( + border: Border.all(color: Colors.grey.shade400), + ), + child: Icon(icon, size: 30, color: Colors.grey), + ), + ); +} + +/// ========================== +/// Attachment Tile +/// ========================== +class _AttachmentTile extends StatelessWidget { + final File file; + final VoidCallback onRemove; + + const _AttachmentTile({required this.file, required this.onRemove}); + + @override + Widget build(BuildContext context) { + final fileName = file.path.split('/').last; + final extension = fileName.split('.').last.toLowerCase(); + final isImage = AttachmentsSection.allowedImageExtensions.contains(extension); + + final (icon, color) = _fileIcon(extension); + + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 80, + height: 80, + decoration: _tileDecoration, + child: isImage + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file(file, fit: BoxFit.cover), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 30), + const SizedBox(height: 4), + Text(extension.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: color)), + ], + ), + ), + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.red, size: 18), + onPressed: onRemove, + ), + ), + ], + ); + } + + /// map extensions to icons/colors + static (IconData, Color) _fileIcon(String ext) { + switch (ext) { + case 'pdf': + return (Icons.picture_as_pdf, Colors.redAccent); + case 'doc': + case 'docx': + return (Icons.description, Colors.blueAccent); + case 'xls': + case 'xlsx': + return (Icons.table_chart, Colors.green); + case 'txt': + return (Icons.article, Colors.grey); + default: + return (Icons.insert_drive_file, Colors.blueGrey); + } + } +} diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index 2a30f14..a59d160 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -295,12 +295,15 @@ class _AddEmployeeBottomSheetState extends State if (value == null || value.trim().isEmpty) { return "Phone Number is required"; } + if (value.trim().length != 10) { + return "Phone Number must be exactly 10 digits"; + } if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) { return "Enter a valid 10-digit number"; } return null; }, - keyboardType: TextInputType.phone, + keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10), diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 49713c3..09a5438 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -1,7 +1,5 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/model/expense/expense_type_model.dart'; @@ -11,8 +9,8 @@ import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/validators.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; -import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; +import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart'; /// Show bottom sheet wrapper Future showAddExpenseBottomSheet({ @@ -95,18 +93,50 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), items: options - .map( - (opt) => PopupMenuItem( - value: opt, - child: Text(getLabel(opt)), - ), - ) + .map((opt) => PopupMenuItem( + value: opt, + child: Text(getLabel(opt)), + )) .toList(), ); if (selected != null) onSelected(selected); } + /// Validate required selections + bool _validateSelections() { + if (controller.selectedProject.value.isEmpty) { + _showError("Please select a project"); + return false; + } + if (controller.selectedExpenseType.value == null) { + _showError("Please select an expense type"); + return false; + } + if (controller.selectedPaymentMode.value == null) { + _showError("Please select a payment mode"); + return false; + } + if (controller.selectedPaidBy.value == null) { + _showError("Please select a person who paid"); + return false; + } + if (controller.attachments.isEmpty && + controller.existingAttachments.isEmpty) { + _showError("Please attach at least one document"); + return false; + } + return true; + } + + void _showError(String msg) { + showAppSnackbar( + title: "Error", + message: msg, + type: SnackbarType.error, + ); + } + @override Widget build(BuildContext context) { return Obx( @@ -117,70 +147,17 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { isSubmitting: controller.isSubmitting.value, onCancel: Get.back, onSubmit: () { - if (_formKey.currentState!.validate()) { - // Additional dropdown validation - if (controller.selectedProject.value.isEmpty) { - showAppSnackbar( - title: "Error", - message: "Please select a project", - type: SnackbarType.error, - ); - return; - } - - if (controller.selectedExpenseType.value == null) { - showAppSnackbar( - title: "Error", - message: "Please select an expense type", - type: SnackbarType.error, - ); - return; - } - - if (controller.selectedPaymentMode.value == null) { - showAppSnackbar( - title: "Error", - message: "Please select a payment mode", - type: SnackbarType.error, - ); - return; - } - - if (controller.selectedPaidBy.value == null) { - showAppSnackbar( - title: "Error", - message: "Please select a person who paid", - type: SnackbarType.error, - ); - return; - } - - if (controller.attachments.isEmpty && - controller.existingAttachments.isEmpty) { - showAppSnackbar( - title: "Error", - message: "Please attach at least one document", - type: SnackbarType.error, - ); - return; - } - - // Validation passed, submit + if (_formKey.currentState!.validate() && _validateSelections()) { controller.submitOrUpdateExpense(); } else { - showAppSnackbar( - title: "Error", - message: "Please fill all required fields correctly", - type: SnackbarType.error, - ); + _showError("Please fill all required fields correctly"); } }, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 🔹 Project - _buildDropdown( + _buildDropdownField( icon: Icons.work_outline, title: "Project", requiredField: true, @@ -195,11 +172,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ), dropdownKey: _projectDropdownKey, ), + _gap(), - MySpacing.height(16), - - // 🔹 Expense Type - _buildDropdown( + _buildDropdownField( icon: Icons.category_outlined, title: "Expense Type", requiredField: true, @@ -214,38 +189,30 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { dropdownKey: _expenseTypeDropdownKey, ), - // 🔹 Persons if required + // Persons if required if (controller.selectedExpenseType.value?.noOfPersonsRequired == true) ...[ - MySpacing.height(16), - _SectionTitle( - icon: Icons.people_outline, - title: "No. of Persons", - requiredField: true), - MySpacing.height(6), - _CustomTextField( + _gap(), + _buildTextFieldSection( + icon: Icons.people_outline, + title: "No. of Persons", controller: controller.noOfPersonsController, hint: "Enter No. of Persons", keyboardType: TextInputType.number, validator: Validators.requiredField, ), ], + _gap(), - MySpacing.height(16), - - // 🔹 GST - _SectionTitle( - icon: Icons.confirmation_number_outlined, title: "GST No."), - MySpacing.height(6), - _CustomTextField( + _buildTextFieldSection( + icon: Icons.confirmation_number_outlined, + title: "GST No.", controller: controller.gstController, hint: "Enter GST No.", ), + _gap(), - MySpacing.height(16), - - // 🔹 Payment Mode - _buildDropdown( + _buildDropdownField( icon: Icons.payment, title: "Payment Mode", requiredField: true, @@ -259,42 +226,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ), dropdownKey: _paymentModeDropdownKey, ), + _gap(), - MySpacing.height(16), + _buildPaidBySection(), + _gap(), - // 🔹 Paid By - _SectionTitle( - icon: Icons.person_outline, - title: "Paid By", - requiredField: true), - MySpacing.height(6), - GestureDetector( - onTap: _showEmployeeList, - child: _TileContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - controller.selectedPaidBy.value == null - ? "Select Paid By" - : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', - style: const TextStyle(fontSize: 14), - ), - const Icon(Icons.arrow_drop_down, size: 22), - ], - ), - ), - ), - - MySpacing.height(16), - - // 🔹 Amount - _SectionTitle( - icon: Icons.currency_rupee, - title: "Amount", - requiredField: true), - MySpacing.height(6), - _CustomTextField( + _buildTextFieldSection( + icon: Icons.currency_rupee, + title: "Amount", controller: controller.amountController, hint: "Enter Amount", keyboardType: TextInputType.number, @@ -302,144 +241,40 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ? null : "Enter valid amount", ), + _gap(), - MySpacing.height(16), - - // 🔹 Supplier - _SectionTitle( + _buildTextFieldSection( icon: Icons.store_mall_directory_outlined, title: "Supplier Name/Transporter Name/Other", - requiredField: true, - ), - MySpacing.height(6), - _CustomTextField( controller: controller.supplierController, hint: "Enter Supplier Name/Transporter Name or Other", validator: Validators.nameValidator, ), + _gap(), - MySpacing.height(16), - - // 🔹 Transaction ID - _SectionTitle( - icon: Icons.confirmation_number_outlined, - title: "Transaction ID"), - MySpacing.height(6), - _CustomTextField( + _buildTextFieldSection( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID", controller: controller.transactionIdController, hint: "Enter Transaction ID", - validator: (value) { - if (value != null && value.isNotEmpty) { - return Validators.transactionIdValidator(value); - } - return null; - }, + validator: (v) => (v != null && v.isNotEmpty) + ? Validators.transactionIdValidator(v) + : null, ), + _gap(), - MySpacing.height(16), + _buildTransactionDateField(), + _gap(), - // 🔹 Transaction Date - _SectionTitle( - icon: Icons.calendar_today, - title: "Transaction Date", - requiredField: true), - MySpacing.height(6), - GestureDetector( - onTap: () => controller.pickTransactionDate(context), - child: AbsorbPointer( - child: _CustomTextField( - controller: controller.transactionDateController, - hint: "Select Transaction Date", - validator: Validators.requiredField, - ), - ), - ), + _buildLocationField(), + _gap(), - MySpacing.height(16), + _buildAttachmentsSection(), + _gap(), - // 🔹 Location - _SectionTitle( - icon: Icons.location_on_outlined, title: "Location"), - MySpacing.height(6), - TextFormField( - controller: controller.locationController, - decoration: InputDecoration( - hintText: "Enter Location", - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8)), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - suffixIcon: controller.isFetchingLocation.value - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : IconButton( - icon: const Icon(Icons.my_location), - tooltip: "Use Current Location", - onPressed: controller.fetchCurrentLocation, - ), - ), - ), - - MySpacing.height(16), - - // 🔹 Attachments - _SectionTitle( - icon: Icons.attach_file, - title: "Attachments", - requiredField: true), - MySpacing.height(6), - _AttachmentsSection( - attachments: controller.attachments, - existingAttachments: controller.existingAttachments, - onRemoveNew: controller.removeAttachment, - onRemoveExisting: (item) async { - await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ConfirmDialog( - title: "Remove Attachment", - message: - "Are you sure you want to remove this attachment?", - confirmText: "Remove", - icon: Icons.delete, - confirmColor: Colors.redAccent, - onConfirm: () async { - final index = - controller.existingAttachments.indexOf(item); - if (index != -1) { - controller.existingAttachments[index]['isActive'] = - false; - controller.existingAttachments.refresh(); - } - showAppSnackbar( - title: 'Removed', - message: 'Attachment has been removed.', - type: SnackbarType.success, - ); - }, - ), - ); - }, - onAdd: controller.pickAttachments, - ), - - MySpacing.height(16), - - // 🔹 Description - _SectionTitle( - icon: Icons.description_outlined, - title: "Description", - requiredField: true), - MySpacing.height(6), - _CustomTextField( + _buildTextFieldSection( + icon: Icons.description_outlined, + title: "Description", controller: controller.descriptionController, hint: "Enter Description", maxLines: 3, @@ -453,7 +288,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ); } - Widget _buildDropdown({ + Widget _gap([double h = 16]) => MySpacing.height(h); + + Widget _buildDropdownField({ required IconData icon, required String title, required bool requiredField, @@ -464,450 +301,165 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SectionTitle(icon: icon, title: title, requiredField: requiredField), + SectionTitle(icon: icon, title: title, requiredField: requiredField), MySpacing.height(6), - _DropdownTile(key: dropdownKey, title: value, onTap: onTap), + DropdownTile(key: dropdownKey, title: value, onTap: onTap), ], ); } -} -class _SectionTitle extends StatelessWidget { - final IconData icon; - final String title; - final bool requiredField; - - const _SectionTitle({ - required this.icon, - required this.title, - this.requiredField = false, - }); - - @override - Widget build(BuildContext context) { - final color = Colors.grey[700]; - return Row( - crossAxisAlignment: CrossAxisAlignment.center, + Widget _buildTextFieldSection({ + required IconData icon, + required String title, + required TextEditingController controller, + String? hint, + TextInputType? keyboardType, + FormFieldValidator? validator, + int maxLines = 1, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 8), - RichText( - text: TextSpan( - style: DefaultTextStyle.of(context).style.copyWith( - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - children: [ - TextSpan(text: title), - if (requiredField) - const TextSpan( - text: ' *', - style: TextStyle(color: Colors.red), - ), - ], - ), + SectionTitle( + icon: icon, title: title, requiredField: validator != null), + MySpacing.height(6), + CustomTextField( + controller: controller, + hint: hint ?? "", + keyboardType: + keyboardType ?? TextInputType.text, + validator: validator, + maxLines: maxLines, ), ], ); } -} -class _CustomTextField extends StatelessWidget { - final TextEditingController controller; - final String hint; - final int maxLines; - final TextInputType keyboardType; - final String? Function(String?)? validator; // 👈 for validation - - const _CustomTextField({ - required this.controller, - required this.hint, - this.maxLines = 1, - this.keyboardType = TextInputType.text, - this.validator, - }); - - @override - Widget build(BuildContext context) { - return TextFormField( - controller: controller, - maxLines: maxLines, - keyboardType: keyboardType, - validator: validator, // 👈 applied - decoration: InputDecoration( - hintText: hint, - hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]), - filled: true, - fillColor: Colors.grey.shade100, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), - ), - ), - ); - } -} - -class _DropdownTile extends StatelessWidget { - final String title; - final VoidCallback onTap; - - const _DropdownTile({ - required this.title, - required this.onTap, - Key? key, // Add optional key parameter - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - title, - style: const TextStyle(fontSize: 14, color: Colors.black87), - overflow: TextOverflow.ellipsis, - ), - ), - const Icon(Icons.arrow_drop_down), - ], - ), - ), - ); - } -} - -class _TileContainer extends StatelessWidget { - final Widget child; - - const _TileContainer({required this.child}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade400), - ), - child: child, - ); - } -} - -class _AttachmentsSection extends StatelessWidget { - final RxList attachments; - final RxList> existingAttachments; - final ValueChanged onRemoveNew; - final ValueChanged>? onRemoveExisting; - final VoidCallback onAdd; - - const _AttachmentsSection({ - required this.attachments, - required this.existingAttachments, - required this.onRemoveNew, - this.onRemoveExisting, - required this.onAdd, - }); - - @override - Widget build(BuildContext context) { - return Obx(() { - final activeExistingAttachments = - existingAttachments.where((doc) => doc['isActive'] != false).toList(); - - // Allowed image extensions for local files - final allowedImageExtensions = ['jpg', 'jpeg', 'png']; - - // To show all new attachments in UI but filter only images for dialog - final imageFiles = attachments.where((file) { - final extension = file.path.split('.').last.toLowerCase(); - return allowedImageExtensions.contains(extension); - }).toList(); - - // Filter existing attachments to only images (for dialog) - final imageExistingAttachments = activeExistingAttachments - .where((d) => - (d['contentType']?.toString().startsWith('image/') ?? false)) - .toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (activeExistingAttachments.isNotEmpty) ...[ - Text( - "Existing Attachments", - style: const TextStyle(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: activeExistingAttachments.map((doc) { - final isImage = - doc['contentType']?.toString().startsWith('image/') ?? - false; - final url = doc['url']; - final fileName = doc['fileName'] ?? 'Unnamed'; - - return Stack( - clipBehavior: Clip.none, - children: [ - GestureDetector( - onTap: () async { - if (isImage) { - // Open dialog only with image attachments (URLs) - final imageSources = imageExistingAttachments - .map((e) => e['url']) - .toList(); - final initialIndex = imageExistingAttachments - .indexWhere((d) => d == doc); - - showDialog( - context: context, - builder: (_) => ImageViewerDialog( - imageSources: imageSources, - initialIndex: initialIndex, - ), - ); - } else { - // Open non-image attachment externally or show error - if (url != null && await canLaunchUrlString(url)) { - await launchUrlString( - url, - mode: LaunchMode.externalApplication, - ); - } else { - showAppSnackbar( - title: 'Error', - message: 'Could not open the document.', - type: SnackbarType.error, - ); - } - } - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(6), - color: Colors.grey.shade100, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isImage ? Icons.image : Icons.insert_drive_file, - size: 20, - color: Colors.grey[600], - ), - const SizedBox(width: 7), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120), - child: Text( - fileName, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ), - ), - ], - ), - ), - ), - if (onRemoveExisting != null) - Positioned( - top: -6, - right: -6, - child: IconButton( - icon: const Icon(Icons.close, - color: Colors.red, size: 18), - onPressed: () { - onRemoveExisting?.call(doc); - }, - ), - ), - ], - ); - }).toList(), - ), - const SizedBox(height: 16), - ], - - // New attachments section: show all files, but only open dialog for images - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ...attachments.map((file) { - final extension = file.path.split('.').last.toLowerCase(); - final isImage = allowedImageExtensions.contains(extension); - - return GestureDetector( - onTap: () { - if (isImage) { - // Show dialog only for image files - final initialIndex = imageFiles.indexOf(file); - showDialog( - context: context, - builder: (_) => ImageViewerDialog( - imageSources: imageFiles, - initialIndex: initialIndex, - ), - ); - } else { - // For non-image, you can show snackbar or do nothing or handle differently - showAppSnackbar( - title: 'Info', - message: 'Preview for this file type is not supported.', - type: SnackbarType.info, - ); - } - }, - child: _AttachmentTile( - file: file, - onRemove: () => onRemoveNew(file), - ), - ); - }), - - // 📎 File Picker Button - GestureDetector( - onTap: onAdd, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(8), - color: Colors.grey.shade100, - ), - child: const Icon(Icons.attach_file, - size: 30, color: Colors.grey), - ), - ), - - // 📷 Camera Button - GestureDetector( - onTap: () => Get.find().pickFromCamera(), - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(8), - color: Colors.grey.shade100, - ), - child: const Icon(Icons.camera_alt, - size: 30, color: Colors.grey), - ), - ), - ], - ), - ], - ); - }); - } -} - -class _AttachmentTile extends StatelessWidget { - final File file; - final VoidCallback onRemove; - - const _AttachmentTile({required this.file, required this.onRemove}); - - @override - Widget build(BuildContext context) { - final fileName = file.path.split('/').last; - final extension = fileName.split('.').last.toLowerCase(); - final isImage = ['jpg', 'jpeg', 'png'].contains(extension); - - IconData fileIcon = Icons.insert_drive_file; - Color iconColor = Colors.blueGrey; - - switch (extension) { - case 'pdf': - fileIcon = Icons.picture_as_pdf; - iconColor = Colors.redAccent; - break; - case 'doc': - case 'docx': - fileIcon = Icons.description; - iconColor = Colors.blueAccent; - break; - case 'xls': - case 'xlsx': - fileIcon = Icons.table_chart; - iconColor = Colors.green; - break; - case 'txt': - fileIcon = Icons.article; - iconColor = Colors.grey; - break; - } - - return Stack( - clipBehavior: Clip.none, + Widget _buildPaidBySection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - color: Colors.grey.shade100, - ), - child: isImage - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file(file, fit: BoxFit.cover), - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(fileIcon, color: iconColor, size: 30), - const SizedBox(height: 4), - Text( - extension.toUpperCase(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: iconColor), - overflow: TextOverflow.ellipsis, - ), - ], + const SectionTitle( + icon: Icons.person_outline, title: "Paid By", requiredField: true), + MySpacing.height(6), + GestureDetector( + onTap: _showEmployeeList, + child: TileContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.selectedPaidBy.value == null + ? "Select Paid By" + : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', + style: const TextStyle(fontSize: 14), ), - ), - Positioned( - top: -6, - right: -6, - child: IconButton( - icon: const Icon(Icons.close, color: Colors.red, size: 18), - onPressed: onRemove, + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), ), ), ], ); } + + Widget _buildTransactionDateField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.calendar_today, + title: "Transaction Date", + requiredField: true), + MySpacing.height(6), + GestureDetector( + onTap: () => controller.pickTransactionDate(context), + child: AbsorbPointer( + child: CustomTextField( + controller: controller.transactionDateController, + hint: "Select Transaction Date", + validator: Validators.requiredField, + ), + ), + ), + ], + ); + } + + Widget _buildLocationField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle(icon: Icons.location_on_outlined, title: "Location"), + MySpacing.height(6), + TextFormField( + controller: controller.locationController, + decoration: InputDecoration( + hintText: "Enter Location", + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + suffixIcon: controller.isFetchingLocation.value + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location), + tooltip: "Use Current Location", + onPressed: controller.fetchCurrentLocation, + ), + ), + ), + ], + ); + } + + Widget _buildAttachmentsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.attach_file, title: "Attachments", requiredField: true), + MySpacing.height(6), + AttachmentsSection( + attachments: controller.attachments, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + onRemoveExisting: (item) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ConfirmDialog( + title: "Remove Attachment", + message: "Are you sure you want to remove this attachment?", + confirmText: "Remove", + icon: Icons.delete, + confirmColor: Colors.redAccent, + onConfirm: () async { + final index = controller.existingAttachments.indexOf(item); + if (index != -1) { + controller.existingAttachments[index]['isActive'] = false; + controller.existingAttachments.refresh(); + } + showAppSnackbar( + title: 'Removed', + message: 'Attachment has been removed.', + type: SnackbarType.success, + ); + }, + ), + ); + }, + onAdd: controller.pickAttachments, + ), + ], + ); + } } diff --git a/lib/view/Attendence/attendance_logs_tab.dart b/lib/view/Attendence/attendance_logs_tab.dart index 53dc1c2..7309524 100644 --- a/lib/view/Attendence/attendance_logs_tab.dart +++ b/lib/view/Attendence/attendance_logs_tab.dart @@ -11,37 +11,95 @@ import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/model/attendance/log_details_view.dart'; import 'package:marco/model/attendance/attendence_action_button.dart'; -class AttendanceLogsTab extends StatelessWidget { +class AttendanceLogsTab extends StatefulWidget { final AttendanceController controller; const AttendanceLogsTab({super.key, required this.controller}); + @override + State createState() => _AttendanceLogsTabState(); +} + +class _AttendanceLogsTabState extends State { + Widget _buildStatusHeader() { + return Obx(() { + final showPending = widget.controller.showPendingOnly.value; + if (!showPending) return const SizedBox.shrink(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: Colors.orange.shade50, + child: Row( + children: [ + const Icon( + Icons.pending_actions, + color: Colors.orange, + size: 18, + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + "Showing Pending Actions Only", + style: TextStyle( + color: Colors.orange, + fontWeight: FontWeight.w600, + ), + ), + ), + InkWell( + onTap: () { + widget.controller.showPendingOnly.value = false; + }, + child: const Icon( + Icons.close, + size: 18, + color: Colors.orange, + ), + ), + ], + ), + ); + }); + } + @override Widget build(BuildContext context) { return Obx(() { - final logs = List.of(controller.attendanceLogs); + final logs = List.of(widget.controller.filteredLogs); logs.sort((a, b) { final aDate = a.checkIn ?? DateTime(0); final bDate = b.checkIn ?? DateTime(0); return bDate.compareTo(aDate); }); - final dateRangeText = controller.startDateAttendance != null && - controller.endDateAttendance != null - ? '${DateTimeUtils.formatDate(controller.startDateAttendance!, 'dd MMM yyyy')} - ' - '${DateTimeUtils.formatDate(controller.endDateAttendance!, 'dd MMM yyyy')}' + // Use controller's observable for pending filter + final showPendingOnly = widget.controller.showPendingOnly.value; + + final filteredLogs = showPendingOnly + ? logs + .where((employee) => + employee.activity == 1 || employee.activity == 2) + .toList() + : logs; + + final dateRangeText = widget.controller.startDateAttendance != null && + widget.controller.endDateAttendance != null + ? '${DateTimeUtils.formatDate(widget.controller.startDateAttendance!, 'dd MMM yyyy')} - ' + '${DateTimeUtils.formatDate(widget.controller.endDateAttendance!, 'dd MMM yyyy')}' : 'Select date range'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header row: Title and Date Range Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ MyText.titleMedium("Attendance Logs", fontWeight: 600), - controller.isLoading.value + widget.controller.isLoading.value ? SkeletonLoaders.dateSkeletonLoader() : MyText.bodySmall( dateRangeText, @@ -52,29 +110,37 @@ class AttendanceLogsTab extends StatelessWidget { ], ), ), - if (controller.isLoadingAttendanceLogs.value) + + // ✅ Pending status header + _buildStatusHeader(), + MySpacing.height(8), + + // Content: Skeleton, Empty, or List + if (widget.controller.isLoadingAttendanceLogs.value) SkeletonLoaders.employeeListSkeletonLoader() - else if (logs.isEmpty) - const SizedBox( + else if (filteredLogs.isEmpty) + SizedBox( height: 120, child: Center( - child: Text("No Attendance Logs Found for this Project"), + child: Text(showPendingOnly + ? "No Pending Actions Found" + : "No Attendance Logs Found for this Project"), ), ) else MyCard.bordered( paddingAll: 8, child: Column( - children: List.generate(logs.length, (index) { - final employee = logs[index]; + children: List.generate(filteredLogs.length, (index) { + final employee = filteredLogs[index]; final currentDate = employee.checkIn != null ? DateTimeUtils.formatDate( employee.checkIn!, 'dd MMM yyyy') : ''; final previousDate = - index > 0 && logs[index - 1].checkIn != null + index > 0 && filteredLogs[index - 1].checkIn != null ? DateTimeUtils.formatDate( - logs[index - 1].checkIn!, 'dd MMM yyyy') + filteredLogs[index - 1].checkIn!, 'dd MMM yyyy') : ''; final showDateHeader = index == 0 || currentDate != previousDate; @@ -159,12 +225,12 @@ class AttendanceLogsTab extends StatelessWidget { children: [ AttendanceActionButton( employee: employee, - attendanceController: controller, + attendanceController: widget.controller, ), MySpacing.width(8), AttendanceLogViewButton( employee: employee, - attendanceController: controller, + attendanceController: widget.controller, ), ], ), @@ -174,7 +240,7 @@ class AttendanceLogsTab extends StatelessWidget { ], ), ), - if (index != logs.length - 1) + if (index != filteredLogs.length - 1) Divider(color: Colors.grey.withOpacity(0.3)), ], ); diff --git a/lib/view/Attendence/attendance_screen.dart b/lib/view/Attendence/attendance_screen.dart index 116d27e..c79b20c 100644 --- a/lib/view/Attendence/attendance_screen.dart +++ b/lib/view/Attendence/attendance_screen.dart @@ -14,6 +14,7 @@ import 'package:marco/view/Attendence/regularization_requests_tab.dart'; import 'package:marco/view/Attendence/attendance_logs_tab.dart'; import 'package:marco/view/Attendence/todays_attendance_tab.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; + class AttendanceScreen extends StatefulWidget { const AttendanceScreen({super.key}); @@ -113,63 +114,188 @@ class _AttendanceScreenState extends State with UIMixin { ); } - Widget _buildFilterAndRefreshRow() { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - MyText.bodyMedium("Filter", fontWeight: 600), - Tooltip( - message: 'Filter Project', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: () async { - final result = await showModalBottomSheet>( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), - builder: (context) => AttendanceFilterBottomSheet( - controller: attendanceController, - permissionController: permissionController, - selectedTab: selectedTab, - ), - ); - - if (result != null) { - final selectedProjectId = - projectController.selectedProjectId.value; - final selectedView = result['selectedTab'] as String?; - - if (selectedProjectId.isNotEmpty) { - try { - await attendanceController - .fetchEmployeesByProject(selectedProjectId); - await attendanceController - .fetchAttendanceLogs(selectedProjectId); - await attendanceController - .fetchRegularizationLogs(selectedProjectId); - await attendanceController - .fetchProjectData(selectedProjectId); - } catch (_) {} - - attendanceController - .update(['attendance_dashboard_controller']); - } - - if (selectedView != null && selectedView != selectedTab) { - setState(() => selectedTab = selectedView); - } - } - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon(Icons.tune, size: 18), + Widget _buildFilterSearchRow() { + return Padding( + padding: MySpacing.xy(8, 8), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: Obx(() { + final query = attendanceController.searchQuery.value; + return TextField( + controller: TextEditingController(text: query) + ..selection = TextSelection.collapsed(offset: query.length), + onChanged: (value) { + attendanceController.searchQuery.value = value; + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + suffixIcon: query.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close, + size: 18, color: Colors.grey), + onPressed: () { + attendanceController.searchQuery.value = ''; + }, + ) + : null, + hintText: 'Search by name', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ); + }), ), ), - ), - ], + + MySpacing.width(8), + + // 🛠️ Filter Icon (no red dot here anymore) + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: IconButton( + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + icon: const Icon(Icons.tune, size: 20, color: Colors.black87), + onPressed: () async { + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(12)), + ), + builder: (context) => AttendanceFilterBottomSheet( + controller: attendanceController, + permissionController: permissionController, + selectedTab: selectedTab, + ), + ); + + if (result != null) { + final selectedProjectId = + projectController.selectedProjectId.value; + final selectedView = result['selectedTab'] as String?; + + if (selectedProjectId.isNotEmpty) { + try { + await attendanceController + .fetchEmployeesByProject(selectedProjectId); + await attendanceController + .fetchAttendanceLogs(selectedProjectId); + await attendanceController + .fetchRegularizationLogs(selectedProjectId); + await attendanceController + .fetchProjectData(selectedProjectId); + } catch (_) {} + + attendanceController + .update(['attendance_dashboard_controller']); + } + + if (selectedView != null && selectedView != selectedTab) { + setState(() => selectedTab = selectedView); + } + } + }, + ), + ), + MySpacing.width(8), + + // ⋮ Pending Actions Menu (red dot here instead) + if (selectedTab == 'attendanceLogs') + Obx(() { + final showPending = attendanceController.showPendingOnly.value; + return Stack( + children: [ + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.more_vert, + size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + itemBuilder: (context) => [ + const PopupMenuItem( + enabled: false, + height: 30, + child: Text( + "Preferences", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + PopupMenuItem( + value: 0, + enabled: false, + child: Obx(() => Row( + children: [ + const SizedBox(width: 10), + const Expanded( + child: Text('Show Pending Actions')), + Switch.adaptive( + value: attendanceController + .showPendingOnly.value, + activeColor: Colors.indigo, + onChanged: (val) { + attendanceController + .showPendingOnly.value = val; + Navigator.pop(context); + }, + ), + ], + )), + ), + ], + ), + ), + if (showPending) + Positioned( + top: 6, + right: 6, + child: Container( + height: 8, + width: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }), + ], + ), ); } @@ -222,8 +348,7 @@ class _AttendanceScreenState extends State with UIMixin { crossAxisAlignment: CrossAxisAlignment.start, children: [ MySpacing.height(flexSpacing), - _buildFilterAndRefreshRow(), - MySpacing.height(flexSpacing), + _buildFilterSearchRow(), MyFlex( children: [ MyFlexItem( diff --git a/lib/view/Attendence/regularization_requests_tab.dart b/lib/view/Attendence/regularization_requests_tab.dart index 323dcbf..6db7739 100644 --- a/lib/view/Attendence/regularization_requests_tab.dart +++ b/lib/view/Attendence/regularization_requests_tab.dart @@ -27,7 +27,7 @@ class RegularizationRequestsTab extends StatelessWidget { child: MyText.titleMedium("Regularization Requests", fontWeight: 600), ), Obx(() { - final employees = controller.regularizationLogs; + final employees = controller.filteredRegularizationLogs; if (controller.isLoadingRegularizationLogs.value) { return SkeletonLoaders.employeeListSkeletonLoader(); @@ -37,7 +37,8 @@ class RegularizationRequestsTab extends StatelessWidget { return const SizedBox( height: 120, child: Center( - child: Text("No Regularization Requests Found for this Project"), + child: + Text("No Regularization Requests Found for this Project"), ), ); } diff --git a/lib/view/Attendence/todays_attendance_tab.dart b/lib/view/Attendence/todays_attendance_tab.dart index d87ec76..3ac6d35 100644 --- a/lib/view/Attendence/todays_attendance_tab.dart +++ b/lib/view/Attendence/todays_attendance_tab.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/attendance/attendance_screen_controller.dart'; -import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_container.dart'; @@ -20,7 +20,7 @@ class TodaysAttendanceTab extends StatelessWidget { Widget build(BuildContext context) { return Obx(() { final isLoading = controller.isLoadingEmployees.value; - final employees = controller.employees; + final employees = controller.filteredEmployees; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -30,10 +30,11 @@ class TodaysAttendanceTab extends StatelessWidget { child: Row( children: [ Expanded( - child: MyText.titleMedium("Today's Attendance", fontWeight: 600), + child: + MyText.titleMedium("Today's Attendance", fontWeight: 600), ), MyText.bodySmall( - DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'), + DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'), fontWeight: 600, color: Colors.grey[700], ), @@ -43,7 +44,9 @@ class TodaysAttendanceTab extends StatelessWidget { if (isLoading) SkeletonLoaders.employeeListSkeletonLoader() else if (employees.isEmpty) - const SizedBox(height: 120, child: Center(child: Text("No Employees Assigned"))) + const SizedBox( + height: 120, + child: Center(child: Text("No Employees Assigned"))) else MyCard.bordered( paddingAll: 8, @@ -57,7 +60,10 @@ class TodaysAttendanceTab extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Avatar(firstName: employee.firstName, lastName: employee.lastName, size: 31), + Avatar( + firstName: employee.firstName, + lastName: employee.lastName, + size: 31), MySpacing.width(16), Expanded( child: Column( @@ -66,27 +72,39 @@ class TodaysAttendanceTab extends StatelessWidget { Wrap( spacing: 6, children: [ - MyText.bodyMedium(employee.name, fontWeight: 600), - MyText.bodySmall('(${employee.designation})', fontWeight: 600, color: Colors.grey[700]), + MyText.bodyMedium(employee.name, + fontWeight: 600), + MyText.bodySmall( + '(${employee.designation})', + fontWeight: 600, + color: Colors.grey[700]), ], ), MySpacing.height(8), - if (employee.checkIn != null || employee.checkOut != null) + if (employee.checkIn != null || + employee.checkOut != null) Row( children: [ if (employee.checkIn != null) Row( children: [ - const Icon(Icons.arrow_circle_right, size: 16, color: Colors.green), + const Icon( + Icons.arrow_circle_right, + size: 16, + color: Colors.green), MySpacing.width(4), - Text(DateTimeUtils.formatDate(employee.checkIn!, 'hh:mm a')), + Text(DateTimeUtils.formatDate( + employee.checkIn!, + 'hh:mm a')), ], ), if (employee.checkOut != null) ...[ MySpacing.width(16), - const Icon(Icons.arrow_circle_left, size: 16, color: Colors.red), + const Icon(Icons.arrow_circle_left, + size: 16, color: Colors.red), MySpacing.width(4), - Text(DateTimeUtils.formatDate(employee.checkOut!, 'hh:mm a')), + Text(DateTimeUtils.formatDate( + employee.checkOut!, 'hh:mm a')), ], ], ), diff --git a/lib/view/taskPlanning/daily_task_planning.dart b/lib/view/taskPlanning/daily_task_planning.dart index dbf1a6f..b8bcb71 100644 --- a/lib/view/taskPlanning/daily_task_planning.dart +++ b/lib/view/taskPlanning/daily_task_planning.dart @@ -18,7 +18,8 @@ class DailyTaskPlanningScreen extends StatefulWidget { DailyTaskPlanningScreen({super.key}); @override - State createState() => _DailyTaskPlanningScreenState(); + State createState() => + _DailyTaskPlanningScreenState(); } class _DailyTaskPlanningScreenState extends State @@ -270,12 +271,11 @@ class _DailyTaskPlanningScreenState extends State "${buildingKey}_${floor.floorName}_${area.areaName}"; final isExpanded = floorExpansionState[floorWorkAreaKey] ?? false; - final totalPlanned = area.workItems - .map((wi) => wi.workItem.plannedWork ?? 0) - .fold(0, (prev, curr) => prev + curr); - final totalCompleted = area.workItems - .map((wi) => wi.workItem.completedWork ?? 0) - .fold(0, (prev, curr) => prev + curr); + final workItems = area.workItems; + final totalPlanned = workItems.fold( + 0, (sum, wi) => sum + (wi.workItem.plannedWork ?? 0)); + final totalCompleted = workItems.fold(0, + (sum, wi) => sum + (wi.workItem.completedWork ?? 0)); final totalProgress = totalPlanned == 0 ? 0.0 : (totalCompleted / totalPlanned).clamp(0.0, 1.0);