diff --git a/android/app/build.gradle b/android/app/build.gradle index 86a56ac..84581b2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -5,40 +5,73 @@ plugins { id "dev.flutter.flutter-gradle-plugin" } +// Load keystore properties from key.properties file +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + android { - namespace = "com.example.marco" + // Define the namespace for your Android application + namespace = "com.marco.aiotstage" + // Set the compile SDK version based on Flutter's configuration compileSdk = flutter.compileSdkVersion + // Set the NDK version based on Flutter's configuration ndkVersion = flutter.ndkVersion + // Configure Java compatibility options compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } + // Configure Kotlin options for JVM target kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8 } + // Default configuration for your application defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.marcostage" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. + // Specify your unique Application ID. This identifies your app on Google Play. + applicationId = "com.marco.aiotstage" + // Set minimum and target SDK versions based on Flutter's configuration minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion + // Set version code and name based on Flutter's configuration (from pubspec.yaml) versionCode = flutter.versionCode versionName = flutter.versionName } + // Define signing configurations for different build types + signingConfigs { + release { + // Reference the key alias from key.properties + keyAlias keystoreProperties['keyAlias'] + // Reference the key password from key.properties + keyPassword keystoreProperties['keyPassword'] + // Reference the keystore file path from key.properties + storeFile file(keystoreProperties['storeFile']) + // Reference the keystore password from key.properties + storePassword keystoreProperties['storePassword'] + } + } + + // Define different build types (e.g., debug, release) buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.debug + // Apply the 'release' signing configuration defined above to the release build + signingConfig signingConfigs.release + // Enable code minification to reduce app size + minifyEnabled true + // Enable resource shrinking to remove unused resources + shrinkResources true + // Other release specific configurations can be added here, e.g., ProGuard rules } } } +// Configure Flutter specific settings, pointing to the root of your Flutter project flutter { source = "../.." } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8ae7375..1e9ad59 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt b/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt index fda3a29..88cc381 100644 --- a/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.marco +package com.marco.aiotstage import io.flutter.embedding.android.FlutterActivity diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 747802f..a5d7139 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -368,7 +368,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -384,7 +384,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -401,7 +401,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -416,7 +416,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -547,7 +547,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/lib/controller/dashboard/attendance_screen_controller.dart b/lib/controller/dashboard/attendance_screen_controller.dart index f477be9..339067e 100644 --- a/lib/controller/dashboard/attendance_screen_controller.dart +++ b/lib/controller/dashboard/attendance_screen_controller.dart @@ -1,22 +1,25 @@ import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; import 'package:geolocator/geolocator.dart'; import 'package:intl/intl.dart'; -import 'package:marco/helpers/services/app_logger.dart'; + +import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/widgets/my_image_compressor.dart'; + import 'package:marco/model/attendance_model.dart'; import 'package:marco/model/project_model.dart'; import 'package:marco/model/employee_model.dart'; import 'package:marco/model/attendance_log_model.dart'; import 'package:marco/model/regularization_log_model.dart'; import 'package:marco/model/attendance_log_view_model.dart'; + import 'package:marco/controller/project_controller.dart'; class AttendanceController extends GetxController { + // Data models List attendances = []; List projects = []; List employees = []; @@ -24,19 +27,18 @@ class AttendanceController extends GetxController { List regularizationLogs = []; List attendenceLogsView = []; + // States String selectedTab = 'Employee List'; - DateTime? startDateAttendance; DateTime? endDateAttendance; - RxBool isLoading = true.obs; - RxBool isLoadingProjects = true.obs; - RxBool isLoadingEmployees = true.obs; - RxBool isLoadingAttendanceLogs = true.obs; - RxBool isLoadingRegularizationLogs = true.obs; - RxBool isLoadingLogView = true.obs; - - RxMap uploadingStates = {}.obs; + final isLoading = true.obs; + final isLoadingProjects = true.obs; + final isLoadingEmployees = true.obs; + final isLoadingAttendanceLogs = true.obs; + final isLoadingRegularizationLogs = true.obs; + final isLoadingLogView = true.obs; + final uploadingStates = {}.obs; @override void onInit() { @@ -56,76 +58,46 @@ class AttendanceController extends GetxController { logSafe("Default date range set: $startDateAttendance to $endDateAttendance"); } - Future _handleLocationPermission() async { - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) { - logSafe('Location permissions are denied', level: LogLevel.warning); - return false; - } - } - if (permission == LocationPermission.deniedForever) { - logSafe('Location permissions are permanently denied', level: LogLevel.error); - return false; - } - return true; - } + // ------------------ Project & Employee ------------------ Future fetchProjects() async { isLoadingProjects.value = true; - isLoading.value = true; final response = await ApiService.getProjects(); if (response != null && response.isNotEmpty) { - projects = response.map((json) => ProjectModel.fromJson(json)).toList(); + projects = response.map((e) => ProjectModel.fromJson(e)).toList(); logSafe("Projects fetched: ${projects.length}"); } else { - logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error); projects = []; + logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error); } isLoadingProjects.value = false; - isLoading.value = false; update(['attendance_dashboard_controller']); } - Future loadAttendanceData(String projectId) async { - await fetchEmployeesByProject(projectId); - await fetchAttendanceLogs(projectId); - await fetchRegularizationLogs(projectId); - await fetchProjectData(projectId); - } - - Future fetchProjectData(String? projectId) async { - if (projectId == null) return; - isLoading.value = true; - await Future.wait([ - fetchEmployeesByProject(projectId), - fetchAttendanceLogs(projectId, dateFrom: startDateAttendance, dateTo: endDateAttendance), - fetchRegularizationLogs(projectId), - ]); - isLoading.value = false; - logSafe("Project data fetched for project ID: $projectId"); - } - Future fetchEmployeesByProject(String? projectId) async { if (projectId == null) return; + isLoadingEmployees.value = true; + final response = await ApiService.getEmployeesByProject(projectId); if (response != null) { - employees = response.map((json) => EmployeeModel.fromJson(json)).toList(); + employees = response.map((e) => EmployeeModel.fromJson(e)).toList(); for (var emp in employees) { uploadingStates[emp.id] = false.obs; } logSafe("Employees fetched: ${employees.length} for project $projectId"); - update(); } else { logSafe("Failed to fetch employees for project $projectId", level: LogLevel.error); } + isLoadingEmployees.value = false; + update(); } + // ------------------ Attendance Capture ------------------ + Future captureAndUploadAttendance( String id, String employeeId, @@ -137,6 +109,7 @@ class AttendanceController extends GetxController { }) async { try { uploadingStates[employeeId]?.value = true; + XFile? image; if (imageCapture) { image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 80); @@ -144,24 +117,39 @@ class AttendanceController extends GetxController { logSafe("Image capture cancelled.", level: LogLevel.warning); return false; } + final compressedBytes = await compressImageToUnder100KB(File(image.path)); if (compressedBytes == null) { logSafe("Image compression failed.", level: LogLevel.error); return false; } + final compressedFile = await saveCompressedImageToFile(compressedBytes); image = XFile(compressedFile.path); } - final hasLocationPermission = await _handleLocationPermission(); - if (!hasLocationPermission) return false; - final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); - final imageName = imageCapture ? ApiService.generateImageName(employeeId, employees.length + 1) : ""; + + if (!await _handleLocationPermission()) return false; + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high); + + final imageName = imageCapture + ? ApiService.generateImageName(employeeId, employees.length + 1) + : ""; final result = await ApiService.uploadAttendanceImage( - id, employeeId, image, position.latitude, position.longitude, - imageName: imageName, projectId: projectId, comment: comment, - action: action, imageCapture: imageCapture, markTime: markTime, + id, + employeeId, + image, + position.latitude, + position.longitude, + imageName: imageName, + projectId: projectId, + comment: comment, + action: action, + imageCapture: imageCapture, + markTime: markTime, ); + logSafe("Attendance uploaded for $employeeId, action: $action"); return result; } catch (e, stacktrace) { @@ -172,8 +160,133 @@ class AttendanceController extends GetxController { } } - Future selectDateRangeForAttendance(BuildContext context, AttendanceController controller) async { + Future _handleLocationPermission() async { + LocationPermission permission = await Geolocator.checkPermission(); + + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + logSafe('Location permissions are denied', level: LogLevel.warning); + return false; + } + } + + if (permission == LocationPermission.deniedForever) { + logSafe('Location permissions are permanently denied', level: LogLevel.error); + return false; + } + + return true; + } + + // ------------------ Attendance Logs ------------------ + + Future fetchAttendanceLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async { + if (projectId == null) return; + + isLoadingAttendanceLogs.value = true; + + final response = await ApiService.getAttendanceLogs(projectId, dateFrom: dateFrom, dateTo: dateTo); + if (response != null) { + attendanceLogs = response.map((e) => AttendanceLogModel.fromJson(e)).toList(); + logSafe("Attendance logs fetched: ${attendanceLogs.length}"); + } else { + logSafe("Failed to fetch attendance logs for project $projectId", level: LogLevel.error); + } + + isLoadingAttendanceLogs.value = false; + update(); + } + + Map> groupLogsByCheckInDate() { + final groupedLogs = >{}; + + for (var logItem in attendanceLogs) { + final checkInDate = logItem.checkIn != null + ? DateFormat('dd MMM yyyy').format(logItem.checkIn!) + : 'Unknown'; + groupedLogs.putIfAbsent(checkInDate, () => []).add(logItem); + } + + final sortedEntries = groupedLogs.entries.toList() + ..sort((a, b) { + if (a.key == 'Unknown') return 1; + if (b.key == 'Unknown') return -1; + final dateA = DateFormat('dd MMM yyyy').parse(a.key); + final dateB = DateFormat('dd MMM yyyy').parse(b.key); + return dateB.compareTo(dateA); + }); + + return Map>.fromEntries(sortedEntries); + } + + // ------------------ Regularization Logs ------------------ + + Future fetchRegularizationLogs(String? projectId) async { + if (projectId == null) return; + + isLoadingRegularizationLogs.value = true; + + final response = await ApiService.getRegularizationLogs(projectId); + if (response != null) { + regularizationLogs = response.map((e) => RegularizationLogModel.fromJson(e)).toList(); + logSafe("Regularization logs fetched: ${regularizationLogs.length}"); + } else { + logSafe("Failed to fetch regularization logs for project $projectId", level: LogLevel.error); + } + + isLoadingRegularizationLogs.value = false; + update(); + } + + // ------------------ Attendance Log View ------------------ + + Future fetchLogsView(String? id) async { + if (id == null) return; + + isLoadingLogView.value = true; + + final response = await ApiService.getAttendanceLogView(id); + if (response != null) { + attendenceLogsView = response.map((e) => AttendanceLogViewModel.fromJson(e)).toList(); + attendenceLogsView.sort((a, b) => + (b.activityTime ?? DateTime(2000)).compareTo(a.activityTime ?? DateTime(2000))); + logSafe("Attendance log view fetched for ID: $id"); + } else { + logSafe("Failed to fetch attendance log view for ID $id", level: LogLevel.error); + } + + isLoadingLogView.value = false; + update(); + } + + // ------------------ Combined Load ------------------ + + Future loadAttendanceData(String projectId) async { + isLoading.value = true; + await fetchProjectData(projectId); + isLoading.value = false; + } + + Future fetchProjectData(String? projectId) async { + if (projectId == null) return; + + await Future.wait([ + fetchEmployeesByProject(projectId), + fetchAttendanceLogs(projectId, + dateFrom: startDateAttendance, dateTo: endDateAttendance), + fetchRegularizationLogs(projectId), + ]); + + logSafe("Project data fetched for project ID: $projectId"); + } + + // ------------------ UI Interaction ------------------ + + Future selectDateRangeForAttendance( + BuildContext context, AttendanceController controller) async { final today = DateTime.now(); + final picked = await showDateRangePicker( context: context, firstDate: DateTime(2022), @@ -190,14 +303,13 @@ class AttendanceController extends GetxController { child: Theme( data: Theme.of(context).copyWith( colorScheme: ColorScheme.light( - primary: const Color.fromARGB(255, 95, 132, 255), + primary: const Color(0xFF5F84FF), onPrimary: Colors.white, onSurface: Colors.teal.shade800, ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom(foregroundColor: Colors.teal), ), - dialogTheme: DialogThemeData(backgroundColor: Colors.white), ), child: child!, ), @@ -210,6 +322,7 @@ class AttendanceController extends GetxController { startDateAttendance = picked.start; endDateAttendance = picked.end; logSafe("Date range selected: $startDateAttendance to $endDateAttendance"); + await controller.fetchAttendanceLogs( Get.find().selectedProject?.id, dateFrom: picked.start, @@ -217,78 +330,4 @@ class AttendanceController extends GetxController { ); } } - - Future fetchAttendanceLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async { - if (projectId == null) return; - isLoadingAttendanceLogs.value = true; - isLoading.value = true; - final response = await ApiService.getAttendanceLogs(projectId, dateFrom: dateFrom, dateTo: dateTo); - if (response != null) { - attendanceLogs = response.map((json) => AttendanceLogModel.fromJson(json)).toList(); - logSafe("Attendance logs fetched: ${attendanceLogs.length}"); - update(); - } else { - logSafe("Failed to fetch attendance logs for project $projectId", level: LogLevel.error); - } - isLoadingAttendanceLogs.value = false; - isLoading.value = false; - } - - Map> groupLogsByCheckInDate() { - final groupedLogs = >{}; - for (var logItem in attendanceLogs) { - final checkInDate = logItem.checkIn != null - ? DateFormat('dd MMM yyyy').format(logItem.checkIn!) - : 'Unknown'; - groupedLogs.putIfAbsent(checkInDate, () => []); - groupedLogs[checkInDate]!.add(logItem); - } - final sortedEntries = groupedLogs.entries.toList() - ..sort((a, b) { - if (a.key == 'Unknown') return 1; - if (b.key == 'Unknown') return -1; - final dateA = DateFormat('dd MMM yyyy').parse(a.key); - final dateB = DateFormat('dd MMM yyyy').parse(b.key); - return dateB.compareTo(dateA); - }); - final sortedMap = Map>.fromEntries(sortedEntries); - logSafe("Logs grouped and sorted by check-in date."); - return sortedMap; - } - - Future fetchRegularizationLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async { - if (projectId == null) return; - isLoadingRegularizationLogs.value = true; - isLoading.value = true; - final response = await ApiService.getRegularizationLogs(projectId); - if (response != null) { - regularizationLogs = response.map((json) => RegularizationLogModel.fromJson(json)).toList(); - logSafe("Regularization logs fetched: ${regularizationLogs.length}"); - update(); - } else { - logSafe("Failed to fetch regularization logs for project $projectId", level: LogLevel.error); - } - isLoadingRegularizationLogs.value = false; - isLoading.value = false; - } - - Future fetchLogsView(String? id) async { - if (id == null) return; - isLoadingLogView.value = true; - isLoading.value = true; - final response = await ApiService.getAttendanceLogView(id); - if (response != null) { - attendenceLogsView = response.map((json) => AttendanceLogViewModel.fromJson(json)).toList(); - attendenceLogsView.sort((a, b) { - if (a.activityTime == null || b.activityTime == null) return 0; - return b.activityTime!.compareTo(a.activityTime!); - }); - logSafe("Attendance log view fetched for ID: $id"); - update(); - } else { - logSafe("Failed to fetch attendance log view for ID $id", level: LogLevel.error); - } - isLoadingLogView.value = false; - isLoading.value = false; - } } diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index 3cfcc57..d5b9d91 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -24,6 +24,7 @@ class AddContactController extends GetxController { final RxMap tagsMap = {}.obs; final RxBool isInitialized = false.obs; final RxList selectedProjects = [].obs; + final RxBool isSubmitting = false.obs; @override void onInit() { @@ -94,6 +95,9 @@ class AddContactController extends GetxController { required String address, required String description, }) async { + if (isSubmitting.value) return; + isSubmitting.value = true; + final categoryId = categoriesMap[selectedCategory.value]; final bucketId = bucketsMap[selectedBucket.value]; final projectIds = selectedProjects @@ -101,13 +105,13 @@ class AddContactController extends GetxController { .whereType() .toList(); - // === Required validations only for name, organization, and bucket === if (name.trim().isEmpty) { showAppSnackbar( title: "Missing Name", message: "Please enter the contact name.", type: SnackbarType.warning, ); + isSubmitting.value = false; return; } @@ -117,6 +121,7 @@ class AddContactController extends GetxController { message: "Please enter the organization name.", type: SnackbarType.warning, ); + isSubmitting.value = false; return; } @@ -126,10 +131,10 @@ class AddContactController extends GetxController { message: "Please select a bucket.", type: SnackbarType.warning, ); + isSubmitting.value = false; return; } - // === Build body (include optional fields if available) === try { final tagObjects = enteredTags.map((tagName) { final tagId = tagsMap[tagName]; @@ -182,6 +187,8 @@ class AddContactController extends GetxController { message: "Something went wrong", type: SnackbarType.error, ); + } finally { + isSubmitting.value = false; } } diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart new file mode 100644 index 0000000..f4b1d35 --- /dev/null +++ b/lib/controller/expense/add_expense_controller.dart @@ -0,0 +1,434 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:intl/intl.dart'; + +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/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'; + +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 employeeSearchController = TextEditingController(); + + // --- Reactive State --- + final isLoading = false.obs; + final isSubmitting = false.obs; + final isFetchingLocation = false.obs; + final isEditMode = false.obs; + final isSearchingEmployees = false.obs; + + // --- Dropdown Selections & Data --- + final selectedPaymentMode = Rxn(); + final selectedExpenseType = Rxn(); + final selectedPaidBy = Rxn(); + final selectedProject = ''.obs; + final selectedTransactionDate = Rxn(); + + final attachments = [].obs; + final existingAttachments = >[].obs; + final globalProjects = [].obs; + final projectsMap = {}.obs; + + final expenseTypes = [].obs; + final paymentModes = [].obs; + final allEmployees = [].obs; + final employeeSearchResults = [].obs; + + String? editingExpenseId; + + final expenseController = Get.find(); + + @override + void onInit() { + super.onInit(); + fetchMasterData(); + fetchGlobalProjects(); + employeeSearchController.addListener(() { + searchEmployees(employeeSearchController.text); + }); + } + + @override + void onClose() { + for (var c in [ + amountController, + descriptionController, + supplierController, + transactionIdController, + gstController, + locationController, + transactionDateController, + noOfPersonsController, + employeeSearchController, + ]) { + c.dispose(); + } + super.onClose(); + } + + // --- Employee Search --- + Future searchEmployees(String query) async { + 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)), + ); + } catch (e) { + logSafe("Error searching employees: $e", level: LogLevel.error); + employeeSearchResults.clear(); + } finally { + isSearchingEmployees.value = false; + } + } + + // --- Form Population: Edit Mode --- + Future populateFieldsForEdit(Map data) async { + isEditMode.value = true; + editingExpenseId = '${data['id']}'; + + selectedProject.value = data['projectName'] ?? ''; + amountController.text = data['amount']?.toString() ?? ''; + supplierController.text = data['supplerName'] ?? ''; + descriptionController.text = data['description'] ?? ''; + transactionIdController.text = data['transactionId'] ?? ''; + locationController.text = data['location'] ?? ''; + noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); + + // 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}), + ); + } + + _logPrefilledData(); + } + + void _logPrefilledData() { + logSafe('--- Prefilled Expense Data ---', level: LogLevel.info); + [ + 'ID: $editingExpenseId', + 'Project: ${selectedProject.value}', + 'Amount: ${amountController.text}', + 'Supplier: ${supplierController.text}', + 'Description: ${descriptionController.text}', + 'Transaction ID: ${transactionIdController.text}', + 'Location: ${locationController.text}', + 'Transaction Date: ${transactionDateController.text}', + 'No. of Persons: ${noOfPersonsController.text}', + 'Expense Type: ${selectedExpenseType.value?.name}', + 'Payment Mode: ${selectedPaymentMode.value?.name}', + 'Paid By: ${selectedPaidBy.value?.name}', + 'Attachments: ${attachments.length}', + 'Existing Attachments: ${existingAttachments.length}', + ].forEach((str) => logSafe(str, level: LogLevel.info)); + } + + // --- Pickers --- + Future pickTransactionDate(BuildContext context) async { + final picked = await showDatePicker( + context: context, + initialDate: selectedTransactionDate.value ?? DateTime.now(), + firstDate: DateTime(DateTime.now().year - 5), + lastDate: DateTime.now(), + ); + if (picked != null) { + selectedTransactionDate.value = picked; + transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked); + } + } + + Future pickAttachments() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], + allowMultiple: true, + ); + if (result != null) { + attachments + .addAll(result.paths.whereType().map((path) => File(path))); + } + } catch (e) { + _errorSnackbar("Attachment error: $e"); + } + } + + void removeAttachment(File file) => attachments.remove(file); + + // --- Location --- + Future fetchCurrentLocation() async { + isFetchingLocation.value = true; + try { + final permission = await _ensureLocationPermission(); + if (!permission) return; + + final position = await Geolocator.getCurrentPosition(); + final placemarks = + await placemarkFromCoordinates(position.latitude, position.longitude); + + locationController.text = placemarks.isNotEmpty + ? [ + placemarks.first.name, + placemarks.first.street, + placemarks.first.locality, + placemarks.first.administrativeArea, + placemarks.first.country + ].where((e) => e?.isNotEmpty == true).join(", ") + : "${position.latitude}, ${position.longitude}"; + } catch (e) { + _errorSnackbar("Location error: $e"); + } finally { + isFetchingLocation.value = false; + } + } + + Future _ensureLocationPermission() async { + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + _errorSnackbar("Location permission denied."); + return false; + } + } + if (!await Geolocator.isLocationServiceEnabled()) { + _errorSnackbar("Location service disabled."); + return false; + } + return true; + } + + // --- Data Fetching --- + Future loadMasterData() async => + await 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(); + + final modes = await ApiService.getMasterPaymentModes(); + if (modes is List) + paymentModes.value = + modes.map((e) => PaymentModeModel.fromJson(e)).toList(); + } catch (_) { + _errorSnackbar("Failed to fetch master data"); + } + } + + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(), + id = item['id']?.toString().trim(); + if (name != null && id != null) { + projectsMap[name] = id; + names.add(name); + } + } + globalProjects.assignAll(names); + } + } catch (e) { + logSafe("Error fetching projects: $e", level: LogLevel.error); + } + } + + // --- Submission --- + Future submitOrUpdateExpense() async { + if (isSubmitting.value) return; + isSubmitting.value = true; + try { + final validationMsg = validateForm(); + if (validationMsg.isNotEmpty) { + _errorSnackbar(validationMsg, "Missing Fields"); + return; + } + + 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'], + ); + + if (success) { + await expenseController.fetchExpenses(); + Get.back(); + showAppSnackbar( + title: "Success", + message: + "Expense ${isEditMode.value ? 'updated' : 'created'} successfully!", + type: SnackbarType.success, + ); + } else { + _errorSnackbar("Operation failed. Try again."); + } + } catch (e) { + _errorSnackbar("Unexpected error: $e"); + } finally { + isSubmitting.value = false; + } + } + + Future> _buildExpensePayload() async { + final now = DateTime.now(); + final existingAttachmentPayloads = existingAttachments + .map((e) => { + "documentId": e['documentId'], + "fileName": e['fileName'], + "contentType": e['contentType'], + "fileSize": 0, + "description": "", + "url": e['url'], + "isActive": e['isActive'] ?? true, + "base64Data": e['isActive'] == false ? null : e['base64Data'], + }) + .toList(); + + final newAttachmentPayloads = + 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": "", + }; + })); + + final type = selectedExpenseType.value!; + 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(), + "transactionId": transactionIdController.text, + "description": descriptionController.text, + "location": locationController.text, + "supplerName": supplierController.text, + "amount": double.parse(amountController.text.trim()), + "noOfPersons": type.noOfPersonsRequired == true + ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 + : 0, + "billAttachments": [ + ...existingAttachmentPayloads, + ...newAttachmentPayloads + ], + }; + } + + String validateForm() { + final missing = []; + + if (selectedProject.value.isEmpty) missing.add("Project"); + if (selectedExpenseType.value == null) missing.add("Expense Type"); + if (selectedPaymentMode.value == null) missing.add("Payment Mode"); + if (selectedPaidBy.value == null) missing.add("Paid By"); + 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())) { + missing.add("Valid Transaction Date"); + } + + final amount = double.tryParse(amountController.text.trim()); + if (amount == null) missing.add("Valid Amount"); + + // Attachment: at least one required at all times + bool hasActiveExisting = + existingAttachments.any((e) => e['isActive'] != false); + 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, + ); +} diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart new file mode 100644 index 0000000..00b418c --- /dev/null +++ b/lib/controller/expense/expense_detail_controller.dart @@ -0,0 +1,187 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/model/expense/expense_detail_model.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:flutter/material.dart'; + +class ExpenseDetailController extends GetxController { + final Rx expense = Rx(null); + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + final Rx selectedReimbursedBy = Rx(null); + final RxList allEmployees = [].obs; + final RxList employeeSearchResults = [].obs; + late String _expenseId; + bool _isInitialized = false; + final employeeSearchController = TextEditingController(); + final isSearchingEmployees = false.obs; + + /// Call this once from the screen (NOT inside build) to initialize + void init(String expenseId) { + if (_isInitialized) return; + + _isInitialized = true; + _expenseId = expenseId; + + // Use Future.wait to fetch details and employees concurrently + Future.wait([ + fetchExpenseDetails(), + fetchAllEmployees(), + ]); + } + + /// Generic method to handle API calls with loading and error states + Future _apiCallWrapper( + Future Function() apiCall, String operationName) async { + isLoading.value = true; + errorMessage.value = ''; // Clear previous errors + + try { + logSafe("Initiating $operationName..."); + final result = await apiCall(); + logSafe("$operationName completed successfully."); + return result; + } catch (e, stack) { + errorMessage.value = + 'An unexpected error occurred during $operationName.'; + logSafe("Exception in $operationName: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return null; + } finally { + isLoading.value = false; + } + } + + /// Fetch expense details by stored ID + Future fetchExpenseDetails() async { + final result = await _apiCallWrapper( + () => ApiService.getExpenseDetailsApi(expenseId: _expenseId), + "fetch expense details"); + + if (result != null) { + try { + expense.value = ExpenseDetailModel.fromJson(result); + logSafe("Expense details loaded successfully: ${expense.value?.id}"); + } catch (e) { + errorMessage.value = 'Failed to parse expense details: $e'; + logSafe("Parse error in fetchExpenseDetails: $e", + level: LogLevel.error); + } + } else { + errorMessage.value = 'Failed to fetch expense details from server.'; + logSafe("fetchExpenseDetails failed: null response", + level: LogLevel.error); + } + } + + // This method seems like a utility and might be better placed in a helper or utility class + // if it's used across multiple controllers. Keeping it here for now as per original code. + List parsePermissionIds(dynamic permissionData) { + if (permissionData == null) return []; + if (permissionData is List) { + return permissionData + .map((e) => e.toString().trim()) + .where((e) => e.isNotEmpty) + .toList(); + } + if (permissionData is String) { + final clean = permissionData.replaceAll(RegExp(r'[\[\]]'), ''); + return clean + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + } + return []; + } + + Future searchEmployees(String query) async { + 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)), + ); + } catch (e) { + logSafe("Error searching employees: $e", level: LogLevel.error); + employeeSearchResults.clear(); + } finally { + isSearchingEmployees.value = false; + } + } + + /// Fetch all employees + Future fetchAllEmployees() async { + final response = await _apiCallWrapper( + () => ApiService.getAllEmployees(), "fetch all employees"); + + if (response != null && response.isNotEmpty) { + try { + allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); + logSafe("All Employees fetched: ${allEmployees.length}", + level: LogLevel.info); + } catch (e) { + errorMessage.value = 'Failed to parse employee data: $e'; + logSafe("Parse error in fetchAllEmployees: $e", level: LogLevel.error); + } + } else { + allEmployees.clear(); + logSafe("No employees found.", level: LogLevel.warning); + } + // `update()` is typically not needed for RxList directly unless you have specific GetBuilder/Obx usage that requires it + // If you are using Obx widgets, `allEmployees.assignAll` will automatically trigger a rebuild. + } + + /// Update expense with reimbursement info and status + Future updateExpenseStatusWithReimbursement({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + required String statusId, + }) async { + final success = await _apiCallWrapper( + () => ApiService.updateExpenseStatusApi( + expenseId: _expenseId, + statusId: statusId, + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimbursedById: reimburseById, + ), + "submit reimbursement", + ); + + if (success == true) { + // Explicitly check for true as _apiCallWrapper returns T? + await fetchExpenseDetails(); // Refresh details after successful update + return true; + } else { + errorMessage.value = "Failed to submit reimbursement."; + return false; + } + } + + /// Update status for this specific expense + Future updateExpenseStatus(String statusId, {String? comment}) async { + final success = await _apiCallWrapper( + () => ApiService.updateExpenseStatusApi( + expenseId: _expenseId, + statusId: statusId, + comment: comment, + ), + "update expense status", + ); + + if (success == true) { + await fetchExpenseDetails(); + return true; + } else { + errorMessage.value = "Failed to update expense status."; + return false; + } + } +} diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart new file mode 100644 index 0000000..0790ba5 --- /dev/null +++ b/lib/controller/expense/expense_screen_controller.dart @@ -0,0 +1,349 @@ +import 'dart:convert'; +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:flutter/material.dart'; + +class ExpenseController extends GetxController { + final RxList expenses = [].obs; + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + + // Master data + final RxList expenseTypes = [].obs; + final RxList paymentModes = [].obs; + final RxList expenseStatuses = [].obs; + final RxList globalProjects = [].obs; + final RxMap projectsMap = {}.obs; + RxList allEmployees = [].obs; + + // Persistent Filter States + final RxString selectedProject = ''.obs; + final RxString selectedStatus = ''.obs; + final Rx startDate = Rx(null); + final Rx endDate = Rx(null); + final RxList selectedPaidByEmployees = [].obs; + final RxList selectedCreatedByEmployees = + [].obs; + final RxString selectedDateType = 'Transaction Date'.obs; + + final employeeSearchController = TextEditingController(); + final isSearchingEmployees = false.obs; + final employeeSearchResults = [].obs; + + final List dateTypes = [ + 'Transaction Date', + 'Created At', + ]; + + int _pageSize = 20; + int _pageNumber = 1; + + @override + void onInit() { + super.onInit(); + loadInitialMasterData(); + fetchAllEmployees(); + employeeSearchController.addListener(() { + searchEmployees(employeeSearchController.text); + }); + } + + bool get isFilterApplied { + return selectedProject.value.isNotEmpty || + selectedStatus.value.isNotEmpty || + startDate.value != null || + endDate.value != null || + selectedPaidByEmployees.isNotEmpty || + selectedCreatedByEmployees.isNotEmpty; + } + + /// Load master data + Future loadInitialMasterData() async { + await fetchGlobalProjects(); + await fetchMasterData(); + } + + Future deleteExpense(String expenseId) async { + try { + logSafe("Attempting to delete expense: $expenseId"); + final success = await ApiService.deleteExpense(expenseId); + if (success) { + expenses.removeWhere((e) => e.id == expenseId); + logSafe("Expense deleted successfully."); + showAppSnackbar( + title: "Deleted", + message: "Expense has been deleted successfully.", + type: SnackbarType.success, + ); + } else { + logSafe("Failed to delete expense: $expenseId", level: LogLevel.error); + showAppSnackbar( + title: "Failed", + message: "Failed to delete expense.", + type: SnackbarType.error, + ); + } + } catch (e, stack) { + logSafe("Exception in deleteExpense: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Error", + message: "Something went wrong while deleting.", + type: SnackbarType.error, + ); + } + } + + Future searchEmployees(String searchQuery) async { + if (searchQuery.trim().isEmpty) { + employeeSearchResults.clear(); + return; + } + + isSearchingEmployees.value = true; + try { + final results = await ApiService.searchEmployeesBasic( + searchString: searchQuery.trim(), + ); + + if (results != null) { + employeeSearchResults.assignAll( + results.map((e) => EmployeeModel.fromJson(e)), + ); + } else { + employeeSearchResults.clear(); + } + } catch (e) { + logSafe("Error searching employees: $e", level: LogLevel.error); + employeeSearchResults.clear(); + } finally { + isSearchingEmployees.value = false; + } + } + + /// Fetch expenses using filters + Future fetchExpenses({ + List? projectIds, + List? statusIds, + List? createdByIds, + List? paidByIds, + DateTime? startDate, + DateTime? endDate, + int pageSize = 20, + int pageNumber = 1, + }) async { + isLoading.value = true; + errorMessage.value = ''; + expenses.clear(); + _pageSize = pageSize; + _pageNumber = pageNumber; + + final Map filterMap = { + "projectIds": projectIds ?? + (selectedProject.value.isEmpty + ? [] + : [projectsMap[selectedProject.value] ?? '']), + "statusIds": statusIds ?? + (selectedStatus.value.isEmpty ? [] : [selectedStatus.value]), + "createdByIds": + createdByIds ?? selectedCreatedByEmployees.map((e) => e.id).toList(), + "paidByIds": + paidByIds ?? selectedPaidByEmployees.map((e) => e.id).toList(), + "startDate": (startDate ?? this.startDate.value)?.toIso8601String(), + "endDate": (endDate ?? this.endDate.value)?.toIso8601String(), + "isTransactionDate": selectedDateType.value == 'Transaction Date', + }; + + try { + logSafe("Fetching expenses with filter: ${jsonEncode(filterMap)}"); + + final result = await ApiService.getExpenseListApi( + filter: jsonEncode(filterMap), + pageSize: _pageSize, + pageNumber: _pageNumber, + ); + + if (result != null) { + try { + final expenseResponse = ExpenseResponse.fromJson(result); + expenses.assignAll(expenseResponse.data.data); + + logSafe("Expenses loaded: ${expenses.length}"); + logSafe( + "Pagination Info: Page ${expenseResponse.data.currentPage} of ${expenseResponse.data.totalPages} | Total: ${expenseResponse.data.totalEntites}"); + } catch (e) { + errorMessage.value = 'Failed to parse expenses: $e'; + logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); + } + } else { + errorMessage.value = 'Failed to fetch expenses from server.'; + logSafe("fetchExpenses failed: null response", level: LogLevel.error); + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in fetchExpenses: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } finally { + isLoading.value = false; + } + } + + /// Clear all filters + void clearFilters() { + selectedProject.value = ''; + selectedStatus.value = ''; + startDate.value = null; + endDate.value = null; + selectedPaidByEmployees.clear(); + selectedCreatedByEmployees.clear(); + } + + /// Fetch master data: expense types, payment modes, and expense status + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + + final paymentModesData = await ApiService.getMasterPaymentModes(); + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } + + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + showAppSnackbar( + title: "Error", + message: "Failed to fetch master data: $e", + type: SnackbarType.error, + ); + } + } + + /// Fetch global projects + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); + if (name != null && id != null && name.isNotEmpty) { + projectsMap[name] = id; + names.add(name); + } + } + globalProjects.assignAll(names); + logSafe("Fetched ${names.length} global projects"); + } + } catch (e) { + logSafe("Failed to fetch global projects: $e", level: LogLevel.error); + } + } + + /// Fetch all employees + Future fetchAllEmployees() async { + isLoading.value = true; + try { + final response = await ApiService.getAllEmployees(); + if (response != null && response.isNotEmpty) { + allEmployees + .assignAll(response.map((json) => EmployeeModel.fromJson(json))); + logSafe( + "All Employees fetched for Manage Bucket: ${allEmployees.length}", + level: LogLevel.info, + ); + } else { + allEmployees.clear(); + logSafe("No employees found for Manage Bucket.", + level: LogLevel.warning); + } + } catch (e) { + allEmployees.clear(); + logSafe("Error fetching employees in Manage Bucket", + level: LogLevel.error, error: e); + } + isLoading.value = false; + update(); + } + + Future loadMoreExpenses() async { + if (isLoading.value) return; + + _pageNumber += 1; + isLoading.value = true; + + final Map filterMap = { + "projectIds": selectedProject.value.isEmpty + ? [] + : [projectsMap[selectedProject.value] ?? ''], + "statusIds": selectedStatus.value.isEmpty ? [] : [selectedStatus.value], + "createdByIds": selectedCreatedByEmployees.map((e) => e.id).toList(), + "paidByIds": selectedPaidByEmployees.map((e) => e.id).toList(), + "startDate": startDate.value?.toIso8601String(), + "endDate": endDate.value?.toIso8601String(), + "isTransactionDate": selectedDateType.value == 'Transaction Date', + }; + + try { + final result = await ApiService.getExpenseListApi( + filter: jsonEncode(filterMap), + pageSize: _pageSize, + pageNumber: _pageNumber, + ); + + if (result != null) { + final expenseResponse = ExpenseResponse.fromJson(result); + expenses.addAll(expenseResponse.data.data); + } + } catch (e) { + logSafe("Error in loadMoreExpenses: $e", level: LogLevel.error); + } finally { + isLoading.value = false; + } + } + + /// Update expense status + Future updateExpenseStatus(String expenseId, String statusId) async { + isLoading.value = true; + errorMessage.value = ''; + try { + logSafe("Updating status for expense: $expenseId -> $statusId"); + final success = await ApiService.updateExpenseStatusApi( + expenseId: expenseId, + statusId: statusId, + ); + if (success) { + logSafe("Expense status updated successfully."); + await fetchExpenses(); + return true; + } else { + errorMessage.value = "Failed to update expense status."; + return false; + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/controller/permission_controller.dart b/lib/controller/permission_controller.dart index 0802f33..75b1105 100644 --- a/lib/controller/permission_controller.dart +++ b/lib/controller/permission_controller.dart @@ -117,6 +117,23 @@ class PermissionController extends GetxController { return assigned; } + List get allowedPermissionIds { + final ids = permissions.map((p) => p.id).toList(); + logSafe("[PermissionController] Allowed Permission IDs: $ids", + level: LogLevel.debug); + return ids; + } + + bool hasAnyPermission(List ids) { + logSafe("[PermissionController] Checking if any of these are allowed: $ids", + level: LogLevel.debug); + final allowed = allowedPermissionIds; + final result = ids.any((id) => allowed.contains(id)); + logSafe("[PermissionController] Permission match result: $result", + level: LogLevel.debug); + return result; + } + @override void onClose() { _refreshTimer?.cancel(); diff --git a/lib/controller/task_planing/daily_task_planing_controller.dart b/lib/controller/task_planing/daily_task_planing_controller.dart index 2e0a05c..3a302b6 100644 --- a/lib/controller/task_planing/daily_task_planing_controller.dart +++ b/lib/controller/task_planing/daily_task_planing_controller.dart @@ -17,6 +17,7 @@ class DailyTaskPlaningController extends GetxController { MyFormValidator basicValidator = MyFormValidator(); List> roles = []; + RxBool isAssigningTask = false.obs; RxnString selectedRoleId = RxnString(); RxBool isLoading = false.obs; @@ -46,16 +47,21 @@ class DailyTaskPlaningController extends GetxController { } void updateSelectedEmployees() { - final selected = employees - .where((e) => uploadingStates[e.id]?.value == true) - .toList(); + final selected = + employees.where((e) => uploadingStates[e.id]?.value == true).toList(); selectedEmployees.value = selected; - logSafe("Updated selected employees", level: LogLevel.debug, ); + logSafe( + "Updated selected employees", + level: LogLevel.debug, + ); } void onRoleSelected(String? roleId) { selectedRoleId.value = roleId; - logSafe("Role selected", level: LogLevel.info, ); + logSafe( + "Role selected", + level: LogLevel.info, + ); } Future fetchRoles() async { @@ -77,6 +83,7 @@ class DailyTaskPlaningController extends GetxController { required List taskTeam, DateTime? assignmentDate, }) async { + isAssigningTask.value = true; logSafe("Starting assign task...", level: LogLevel.info); final response = await ApiService.assignDailyTask( @@ -87,6 +94,8 @@ class DailyTaskPlaningController extends GetxController { assignmentDate: assignmentDate, ); + isAssigningTask.value = false; + if (response == true) { logSafe("Task assigned successfully", level: LogLevel.info); showAppSnackbar( @@ -111,15 +120,18 @@ class DailyTaskPlaningController extends GetxController { try { final response = await ApiService.getProjects(); if (response?.isEmpty ?? true) { - logSafe("No project data found or API call failed", level: LogLevel.warning); + logSafe("No project data found or API call failed", + level: LogLevel.warning); return; } projects = response!.map((json) => ProjectModel.fromJson(json)).toList(); - logSafe("Projects fetched: ${projects.length} projects loaded", level: LogLevel.info); + logSafe("Projects fetched: ${projects.length} projects loaded", + level: LogLevel.info); update(); } catch (e, stack) { - logSafe("Error fetching projects", level: LogLevel.error, error: e, stackTrace: stack); + logSafe("Error fetching projects", + level: LogLevel.error, error: e, stackTrace: stack); } finally { isLoading.value = false; } @@ -137,12 +149,16 @@ class DailyTaskPlaningController extends GetxController { final data = response?['data']; if (data != null) { dailyTasks = [TaskPlanningDetailsModel.fromJson(data)]; - logSafe("Daily task Planning Details fetched", level: LogLevel.info, ); + logSafe( + "Daily task Planning Details fetched", + level: LogLevel.info, + ); } else { logSafe("Data field is null", level: LogLevel.warning); } } catch (e, stack) { - logSafe("Error fetching daily task data", level: LogLevel.error, error: e, stackTrace: stack); + logSafe("Error fetching daily task data", + level: LogLevel.error, error: e, stackTrace: stack); } finally { isLoading.value = false; update(); @@ -151,7 +167,8 @@ class DailyTaskPlaningController extends GetxController { Future fetchEmployeesByProject(String? projectId) async { if (projectId == null || projectId.isEmpty) { - logSafe("Project ID is required but was null or empty", level: LogLevel.error); + logSafe("Project ID is required but was null or empty", + level: LogLevel.error); return; } @@ -159,19 +176,29 @@ class DailyTaskPlaningController extends GetxController { try { final response = await ApiService.getAllEmployeesByProject(projectId); if (response != null && response.isNotEmpty) { - employees = response.map((json) => EmployeeModel.fromJson(json)).toList(); + employees = + response.map((json) => EmployeeModel.fromJson(json)).toList(); for (var emp in employees) { uploadingStates[emp.id] = false.obs; } - logSafe("Employees fetched: ${employees.length} for project $projectId", - level: LogLevel.info, ); + logSafe( + "Employees fetched: ${employees.length} for project $projectId", + level: LogLevel.info, + ); } else { employees = []; - logSafe("No employees found for project $projectId", level: LogLevel.warning, ); + logSafe( + "No employees found for project $projectId", + level: LogLevel.warning, + ); } } catch (e, stack) { - logSafe("Error fetching employees for project $projectId", - level: LogLevel.error, error: e, stackTrace: stack, ); + logSafe( + "Error fetching employees for project $projectId", + level: LogLevel.error, + error: e, + stackTrace: stack, + ); } finally { isLoading.value = false; update(); diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 3949b40..cf5e9b5 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,11 +1,11 @@ class ApiEndpoints { - static const String baseUrl = "https://stageapi.marcoaiot.com/api"; - // static const String baseUrl = "https://api.marcoaiot.com/api"; + // static const String baseUrl = "https://stageapi.marcoaiot.com/api"; + static const String baseUrl = "https://api.marcoaiot.com/api"; - // Dashboard Screen API Endpoints + // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; - // Attendance Screen API Endpoints + // Attendance Module API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; static const String getEmployeesByProject = "/attendance/project/team"; @@ -17,6 +17,7 @@ class ApiEndpoints { // Employee Screen API Endpoints static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployees = "/employee/list"; + static const String getEmployeesWithoutPermission = "/employee/basic"; static const String getRoles = "/roles/jobrole"; static const String createEmployee = "/employee/manage-mobile"; static const String getEmployeeInfo = "/employee/profile/get"; @@ -24,7 +25,7 @@ class ApiEndpoints { static const String getAssignedProjects = "/project/assigned-projects"; static const String assignProjects = "/project/assign-projects"; - // Daily Task Screen API Endpoints + // Daily Task Module API Endpoints static const String getDailyTask = "/task/list"; static const String reportTask = "/task/report"; static const String commentTask = "/task/comment"; @@ -35,7 +36,7 @@ class ApiEndpoints { static const String assignTask = "/project/task"; static const String getmasterWorkCategories = "/Master/work-categories"; - ////// Directory Screen API Endpoints + ////// Directory Module API Endpoints /////// static const String getDirectoryContacts = "/directory"; static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryContactDetail = "/directory/notes"; @@ -49,4 +50,16 @@ class ApiEndpoints { static const String createBucket = "/directory/bucket"; static const String updateBucket = "/directory/bucket"; static const String assignBucket = "/directory/assign-bucket"; + + ////// Expense Module API Endpoints + static const String getExpenseCategories = "/expense/categories"; + static const String getExpenseList = "/expense/list"; + static const String getExpenseDetails = "/expense/details"; + static const String createExpense = "/expense/create"; + static const String editExpense = "/Expense/edit"; + static const String getMasterPaymentModes = "/master/payment-modes"; + static const String getMasterExpenseStatus = "/master/expenses-status"; + static const String getMasterExpenseTypes = "/master/expenses-types"; + static const String updateExpenseStatus = "/expense/action"; + static const String deleteExpense = "/expense/delete"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index bd6e005..0062b1f 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -239,6 +239,335 @@ class ApiService { } } +// === Expense APIs === // + + /// Edit Expense API + static Future editExpenseApi({ + required String expenseId, + required Map payload, + }) async { + final endpoint = "${ApiEndpoints.editExpense}/$expenseId"; + logSafe("Editing expense $expenseId with payload: $payload"); + + try { + final response = await _putRequest( + endpoint, + payload, + customTimeout: extendedTimeout, + ); + + if (response == null) { + logSafe("Edit expense failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Edit expense response status: ${response.statusCode}"); + logSafe("Edit expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense updated successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to update expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during editExpenseApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + static Future deleteExpense(String expenseId) async { + final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId"; + + try { + final token = await _getToken(); + if (token == null) { + logSafe("Token is null. Cannot proceed with DELETE request.", + level: LogLevel.error); + return false; + } + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); + + logSafe("Sending DELETE request to $uri", level: LogLevel.debug); + + final response = + await http.delete(uri, headers: _headers(token)).timeout(timeout); + + logSafe("DELETE expense response status: ${response.statusCode}"); + logSafe("DELETE expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (response.statusCode == 200 && json['success'] == true) { + logSafe("Expense deleted successfully."); + return true; + } else { + logSafe( + "Failed to delete expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during deleteExpenseApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + /// Get Expense Details API + static Future?> getExpenseDetailsApi({ + required String expenseId, + }) async { + final endpoint = "${ApiEndpoints.getExpenseDetails}/$expenseId"; + logSafe("Fetching expense details for ID: $expenseId"); + + try { + final response = await _getRequest(endpoint); + if (response == null) { + logSafe("Expense details request failed: null response", + level: LogLevel.error); + return null; + } + + final body = response.body.trim(); + if (body.isEmpty) { + logSafe("Expense details response body is empty", + level: LogLevel.error); + return null; + } + + final jsonResponse = jsonDecode(body); + if (jsonResponse is Map) { + if (jsonResponse['success'] == true) { + logSafe("Expense details fetched successfully"); + return jsonResponse['data']; // Return the expense details object + } else { + logSafe( + "Failed to fetch expense details: ${jsonResponse['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } else { + logSafe("Unexpected response structure: $jsonResponse", + level: LogLevel.error); + } + } catch (e, stack) { + logSafe("Exception during getExpenseDetailsApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Update Expense Status API + static Future updateExpenseStatusApi({ + required String expenseId, + required String statusId, + String? comment, + String? reimburseTransactionId, + String? reimburseDate, + String? reimbursedById, + }) async { + final Map payload = { + "expenseId": expenseId, + "statusId": statusId, + }; + + if (comment != null) { + payload["comment"] = comment; + } + if (reimburseTransactionId != null) { + payload["reimburseTransactionId"] = reimburseTransactionId; + } + if (reimburseDate != null) { + payload["reimburseDate"] = reimburseDate; + } + if (reimbursedById != null) { + payload["reimburseById"] = reimbursedById; + } + + const endpoint = ApiEndpoints.updateExpenseStatus; + logSafe("Updating expense status with payload: $payload"); + + try { + final response = await _postRequest( + endpoint, + payload, + customTimeout: extendedTimeout, + ); + + if (response == null) { + logSafe("Update expense status failed: null response", + level: LogLevel.error); + return false; + } + + logSafe("Update expense status response status: ${response.statusCode}"); + logSafe("Update expense status response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense status updated successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to update expense status: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during updateExpenseStatus API: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + static Future?> getExpenseListApi({ + String? filter, + int pageSize = 20, + int pageNumber = 1, + }) async { + // Build the endpoint with query parameters + String endpoint = ApiEndpoints.getExpenseList; + final queryParams = { + 'pageSize': pageSize.toString(), + 'pageNumber': pageNumber.toString(), + }; + + if (filter != null && filter.isNotEmpty) { + queryParams['filter'] = filter; + } + + // Build the full URI + final uri = Uri.parse(endpoint).replace(queryParameters: queryParams); + logSafe("Fetching expense list with URI: $uri"); + + try { + final response = await _getRequest(uri.toString()); + if (response == null) { + logSafe("Expense list request failed: null response", + level: LogLevel.error); + return null; + } + + // Directly parse and return the entire JSON response + final body = response.body.trim(); + if (body.isEmpty) { + logSafe("Expense list response body is empty", level: LogLevel.error); + return null; + } + + final jsonResponse = jsonDecode(body); + if (jsonResponse is Map) { + logSafe("Expense list response parsed successfully"); + return jsonResponse; // Return the entire API response + } else { + logSafe("Unexpected response structure: $jsonResponse", + level: LogLevel.error); + return null; + } + } catch (e, stack) { + logSafe("Exception during getExpenseListApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return null; + } + } + + /// Fetch Master Payment Modes + static Future?> getMasterPaymentModes() async { + const endpoint = ApiEndpoints.getMasterPaymentModes; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Payment Modes') + : null); + } + + /// Fetch Master Expense Status + static Future?> getMasterExpenseStatus() async { + const endpoint = ApiEndpoints.getMasterExpenseStatus; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Expense Status') + : null); + } + + /// Fetch Master Expense Types + static Future?> getMasterExpenseTypes() async { + const endpoint = ApiEndpoints.getMasterExpenseTypes; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Expense Types') + : null); + } + + /// Create Expense API + static Future createExpenseApi({ + required String projectId, + required String expensesTypeId, + required String paymentModeId, + required String paidById, + required DateTime transactionDate, + required String transactionId, + required String description, + required String location, + required String supplerName, + required double amount, + required int noOfPersons, + required List> billAttachments, + }) async { + final payload = { + "projectId": projectId, + "expensesTypeId": expensesTypeId, + "paymentModeId": paymentModeId, + "paidById": paidById, + "transactionDate": transactionDate.toIso8601String(), + "transactionId": transactionId, + "description": description, + "location": location, + "supplerName": supplerName, + "amount": amount, + "noOfPersons": noOfPersons, + "billAttachments": billAttachments, + }; + + const endpoint = ApiEndpoints.createExpense; + logSafe("Creating expense with payload: $payload"); + + try { + final response = + await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Create expense failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Create expense response status: ${response.statusCode}"); + logSafe("Create expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during createExpense API: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + // === Dashboard Endpoints === static Future?> getDashboardAttendanceOverview( @@ -822,14 +1151,42 @@ class ApiService { } // === Employee APIs === + /// Search employees by first name and last name only (not middle name) + /// Returns a list of up to 10 employee records matching the search string. + static Future?> searchEmployeesBasic({ + String? searchString, + }) async { + // Remove ArgumentError check because searchString is optional now + + final queryParams = {}; + + // Add searchString to query parameters only if it's not null or empty + if (searchString != null && searchString.isNotEmpty) { + queryParams['searchString'] = searchString; + } + + final response = await _getRequest( + ApiEndpoints.getEmployeesWithoutPermission, + queryParams: queryParams, + ); + + if (response != null) { + return _parseResponse(response, label: 'Search Employees Basic'); + } + + return null; + } static Future?> getAllEmployeesByProject( String projectId) async { if (projectId.isEmpty) throw ArgumentError('projectId must not be empty'); - final endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId"; - return _getRequest(endpoint).then((res) => res != null - ? _parseResponse(res, label: 'Employees by Project') - : null); + final endpoint = + "${ApiEndpoints.getAllEmployeesByProject}?projectId=$projectId"; + return _getRequest(endpoint).then( + (res) => res != null + ? _parseResponse(res, label: 'Employees by Project') + : null, + ); } static Future?> getAllEmployees() async => diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index 5d55b0c..3f11fe7 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -8,41 +8,53 @@ import 'package:marco/helpers/theme/app_theme.dart'; import 'package:url_strategy/url_strategy.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/auth_service.dart'; +import 'package:flutter/material.dart'; + Future initializeApp() async { try { logSafe("💡 Starting app initialization..."); + // UI Setup setPathUrlStrategy(); - logSafe("💡 URL strategy set."); - - SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( - statusBarColor: Color.fromARGB(255, 255, 0, 0), - statusBarIconBrightness: Brightness.light, - )); - logSafe("💡 System UI overlay style set."); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarIconBrightness: Brightness.dark, + ), + ); + logSafe("💡 UI setup completed."); + // Local storage await LocalStorage.init(); logSafe("💡 Local storage initialized."); - // If a refresh token is found, try to refresh the JWT token + // Token handling final refreshToken = await LocalStorage.getRefreshToken(); - if (refreshToken != null && refreshToken.isNotEmpty) { + final hasRefreshToken = refreshToken?.isNotEmpty ?? false; + + if (hasRefreshToken) { logSafe("🔁 Refresh token found. Attempting to refresh JWT..."); final success = await AuthService.refreshToken(); - if (!success) { logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection."); - // Optionally, clear tokens and force logout here if needed + // Optionally clear tokens or handle logout here } } else { logSafe("❌ No refresh token found. Skipping refresh."); } + // Theme setup await ThemeCustomizer.init(); logSafe("💡 Theme customizer initialized."); + // Controller setup final token = LocalStorage.getString('jwt_token'); - if (token != null && token.isNotEmpty) { + final hasJwt = token?.isNotEmpty ?? false; + + if (hasJwt) { if (!Get.isRegistered()) { Get.put(PermissionController()); logSafe("💡 PermissionController injected."); @@ -53,13 +65,13 @@ Future initializeApp() async { logSafe("💡 ProjectController injected as permanent."); } - // Load data into controllers if required - await Get.find().loadData(token); + await Get.find().loadData(token!); await Get.find().fetchProjects(); } else { logSafe("⚠️ No valid JWT token found. Skipping controller initialization."); } + // Final style setup AppStyle.init(); logSafe("💡 AppStyle initialized."); diff --git a/lib/helpers/services/app_logger.dart b/lib/helpers/services/app_logger.dart index 7afdc4d..9047066 100644 --- a/lib/helpers/services/app_logger.dart +++ b/lib/helpers/services/app_logger.dart @@ -1,18 +1,14 @@ import 'dart:io'; import 'package:logger/logger.dart'; import 'package:intl/intl.dart'; -import 'package:permission_handler/permission_handler.dart'; +import 'package:path_provider/path_provider.dart'; /// Global logger instance late final Logger appLogger; - -/// Log file output handler late final FileLogOutput fileLogOutput; -/// Initialize logging (call once in `main()`) +/// Initialize logging Future initLogging() async { - await requestStoragePermission(); - fileLogOutput = FileLogOutput(); appLogger = Logger( @@ -23,21 +19,13 @@ Future initLogging() async { printEmojis: true, ), output: MultiOutput([ - ConsoleOutput(), // ✅ Console will use the top-level PrettyPrinter - fileLogOutput, // ✅ File will still use the SimpleFileLogPrinter + ConsoleOutput(), + fileLogOutput, ]), level: Level.debug, ); } -/// Request storage permission (for Android 11+) -Future requestStoragePermission() async { - final status = await Permission.manageExternalStorage.status; - if (!status.isGranted) { - await Permission.manageExternalStorage.request(); - } -} - /// Safe logger wrapper void logSafe( String message, { @@ -46,7 +34,7 @@ void logSafe( StackTrace? stackTrace, bool sensitive = false, }) { - if (sensitive) return; + if (sensitive) return; switch (level) { case LogLevel.debug: @@ -66,15 +54,15 @@ void logSafe( } } -/// Custom log output that writes to a local `.txt` file +/// Log output to file (safe path, no permission required) class FileLogOutput extends LogOutput { File? _logFile; - /// Initialize log file in Downloads/marco_logs/log_YYYY-MM-DD.txt Future _init() async { if (_logFile != null) return; - final directory = Directory('/storage/emulated/0/Download/marco_logs'); + final baseDir = await getExternalStorageDirectory(); + final directory = Directory('${baseDir!.path}/marco_logs'); if (!await directory.exists()) { await directory.create(recursive: true); } @@ -119,7 +107,6 @@ class FileLogOutput extends LogOutput { return _logFile!.readAsString(); } - /// Delete logs older than 3 days Future _cleanOldLogs(Directory directory) async { final files = directory.listSync(); final now = DateTime.now(); @@ -135,7 +122,7 @@ class FileLogOutput extends LogOutput { } } -/// A simple, readable log printer for file output +/// Simple log printer for file output class SimpleFileLogPrinter extends LogPrinter { @override List log(LogEvent event) { @@ -152,5 +139,5 @@ class SimpleFileLogPrinter extends LogPrinter { } } -/// Optional log level enum for better type safety +/// Optional enum for log levels enum LogLevel { debug, info, warning, error, verbose } diff --git a/lib/helpers/utils/base_bottom_sheet.dart b/lib/helpers/utils/base_bottom_sheet.dart new file mode 100644 index 0000000..06f32a4 --- /dev/null +++ b/lib/helpers/utils/base_bottom_sheet.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class BaseBottomSheet extends StatelessWidget { + final String title; + final Widget child; + final VoidCallback onCancel; + final VoidCallback onSubmit; + final bool isSubmitting; + final String submitText; + final Color submitColor; + final IconData submitIcon; + final bool showButtons; + final Widget? bottomContent; + + const BaseBottomSheet({ + super.key, + required this.title, + required this.child, + required this.onCancel, + required this.onSubmit, + this.isSubmitting = false, + this.submitText = 'Submit', + this.submitColor = Colors.indigo, + this.submitIcon = Icons.check_circle_outline, + this.showButtons = true, + this.bottomContent, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + + return SingleChildScrollView( + padding: mediaQuery.viewInsets, + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Container( + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 12, + offset: Offset(0, -2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MySpacing.height(5), + Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(10), + ), + ), + MySpacing.height(12), + MyText.titleLarge(title, fontWeight: 700), + MySpacing.height(12), + child, + + MySpacing.height(12), + + // 👇 Buttons (if enabled) + if (showButtons) ...[ + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: isSubmitting ? null : onSubmit, + icon: Icon(submitIcon, color: Colors.white), + label: MyText.bodyMedium( + isSubmitting ? "Submitting..." : submitText, + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: submitColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + ], + ), + // 👇 Optional Bottom Content + if (bottomContent != null) ...[ + MySpacing.height(12), + bottomContent!, + ], + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart index 2c6a732..01bae1f 100644 --- a/lib/helpers/utils/date_time_utils.dart +++ b/lib/helpers/utils/date_time_utils.dart @@ -1,11 +1,10 @@ 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('convertUtcToLocal: input="$utcTimeString", format="$format"'); - final parsed = DateTime.parse(utcTimeString); final utcDateTime = DateTime.utc( parsed.year, @@ -17,13 +16,10 @@ class DateTimeUtils { parsed.millisecond, parsed.microsecond, ); - logSafe('Parsed (assumed UTC): $utcDateTime'); final localDateTime = utcDateTime.toLocal(); - logSafe('Converted to Local: $localDateTime'); final formatted = _formatDateTime(localDateTime, format: format); - logSafe('Formatted Local Time: $formatted'); return formatted; } catch (e, stackTrace) { @@ -32,6 +28,17 @@ class DateTimeUtils { } } + /// Public utility for formatting any DateTime. + static String formatDate(DateTime date, String format) { + try { + return DateFormat(format).format(date); + } catch (e, stackTrace) { + logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace); + return 'Invalid Date'; + } + } + + /// Internal formatter with default format. static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) { return DateFormat(format).format(dateTime); } diff --git a/lib/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index 8160964..81ee465 100644 --- a/lib/helpers/utils/permission_constants.dart +++ b/lib/helpers/utils/permission_constants.dart @@ -3,9 +3,9 @@ class Permissions { static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614"; static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc"; static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566"; - static const String manageProjectInfra ="f2aee20a-b754-4537-8166-f9507b44585b"; + static const String manageProjectInfra = "f2aee20a-b754-4537-8166-f9507b44585b"; static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8"; - static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6"; + static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6"; static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3"; static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c"; static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5"; @@ -13,4 +13,13 @@ class Permissions { static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2"; static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda"; static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5"; + + // Expense Permissions + static const String expenseViewSelf = "385be49f-8fde-440e-bdbc-3dffeb8dd116"; + static const String expenseViewAll = "01e06444-9ca7-4df4-b900-8c3fa051b92f"; + static const String expenseUpload = "0f57885d-bcb2-4711-ac95-d841ace6d5a7"; + static const String expenseReview = "1f4bda08-1873-449a-bb66-3e8222bd871b"; + static const String expenseApprove = "eaafdd76-8aac-45f9-a530-315589c6deca"; + static const String expenseProcess = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11"; + static const String expenseManage = "bdee29a2-b73b-402d-8dd1-c4b1f81ccbc3"; } diff --git a/lib/helpers/widgets/expense_detail_helpers.dart b/lib/helpers/widgets/expense_detail_helpers.dart new file mode 100644 index 0000000..1a5f450 --- /dev/null +++ b/lib/helpers/widgets/expense_detail_helpers.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +/// Returns a formatted color for the expense status. +Color getExpenseStatusColor(String? status, {String? colorCode}) { + if (colorCode != null && colorCode.isNotEmpty) { + try { + return Color(int.parse(colorCode.replaceFirst('#', '0xff'))); + } catch (_) {} + } + switch (status) { + case 'Approval Pending': + return Colors.orange; + case 'Process Pending': + return Colors.blue; + case 'Rejected': + return Colors.red; + case 'Paid': + return Colors.green; + default: + return Colors.black; + } +} + +/// Formats amount to ₹ currency string. +String formatExpenseAmount(double amount) { + return NumberFormat.currency( + locale: 'en_IN', symbol: '₹ ', decimalDigits: 2) + .format(amount); +} + +/// Label/Value block as reusable widget. +Widget labelValueBlock(String label, String value) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(label, fontWeight: 600), + MySpacing.height(4), + MyText.bodySmall(value, + fontWeight: 500, softWrap: true, maxLines: null), + ], + ); + +/// Skeleton loader for lists. +Widget buildLoadingSkeleton() => ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 5, + itemBuilder: (_, __) => Container( + margin: const EdgeInsets.only(bottom: 16), + height: 80, + decoration: BoxDecoration( + color: Colors.grey[300], borderRadius: BorderRadius.circular(10)), + ), + ); + +/// Expandable description widget. +class ExpandableDescription extends StatefulWidget { + final String description; + const ExpandableDescription({Key? key, required this.description}) + : super(key: key); + + @override + State createState() => _ExpandableDescriptionState(); +} + +class _ExpandableDescriptionState extends State { + bool isExpanded = false; + @override + Widget build(BuildContext context) { + final isLong = widget.description.length > 100; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + widget.description, + maxLines: isExpanded ? null : 2, + overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis, + fontWeight: 500, + ), + if (isLong || !isExpanded) + InkWell( + onTap: () => setState(() => isExpanded = !isExpanded), + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: MyText.labelSmall( + isExpanded ? 'Show less' : 'Show more', + fontWeight: 600, + color: Colors.blue, + ), + ), + ), + ], + ); + } +} diff --git a/lib/helpers/widgets/expense_main_components.dart b/lib/helpers/widgets/expense_main_components.dart new file mode 100644 index 0000000..d502825 --- /dev/null +++ b/lib/helpers/widgets/expense_main_components.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/view/expense/expense_detail_screen.dart'; + +class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { + final ProjectController projectController; + + const ExpenseAppBar({required this.projectController, super.key}); + + @override + Size get preferredSize => const Size.fromHeight(72); + + @override + Widget build(BuildContext context) { + return AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Expenses', fontWeight: 700), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final name = projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + name, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + fontWeight: 600, + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class SearchAndFilter extends StatelessWidget { + final TextEditingController controller; + final ValueChanged onChanged; + final VoidCallback onFilterTap; + final VoidCallback onRefreshTap; + final ExpenseController expenseController; + + const SearchAndFilter({ + required this.controller, + required this.onChanged, + required this.onFilterTap, + required this.onRefreshTap, + required this.expenseController, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + hintText: 'Search expenses...', + 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), + Tooltip( + message: 'Refresh Data', + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.green, size: 24), + onPressed: onRefreshTap, + ), + ), + MySpacing.width(4), + Obx(() { + return IconButton( + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.tune, color: Colors.black), + if (expenseController.isFilterApplied) + Positioned( + top: -1, + right: -1, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + ], + ), + onPressed: onFilterTap, + ); + }), + ], + ), + ); + } +} + +class ToggleButtonsRow extends StatelessWidget { + final bool isHistoryView; + final ValueChanged onToggle; + + const ToggleButtonsRow({ + required this.isHistoryView, + required this.onToggle, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(8, 12, 8, 5), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _ToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView, + onTap: () => onToggle(false), + ), + _ToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView, + onTap: () => onToggle(true), + ), + ], + ), + ), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const _ToggleButton({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: selected ? Colors.red : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, + size: 16, color: selected ? Colors.white : Colors.grey), + const SizedBox(width: 6), + MyText.bodyMedium(label, + color: selected ? Colors.white : Colors.grey, + fontWeight: 600), + ], + ), + ), + ), + ); + } +} + +class ExpenseList extends StatelessWidget { + final List expenseList; + final Future Function()? onViewDetail; + + const ExpenseList({ + required this.expenseList, + this.onViewDetail, + super.key, + }); + + void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) { + final ExpenseController controller = Get.find(); + final RxBool isDeleting = false.obs; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Obx(() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: isDeleting.value + ? const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.delete, + size: 48, color: Colors.redAccent), + const SizedBox(height: 16), + MyText.titleLarge("Delete Expense", + fontWeight: 600, + color: + Theme.of(context).colorScheme.onBackground), + const SizedBox(height: 12), + MyText.bodySmall( + "Are you sure you want to delete this draft expense?", + textAlign: TextAlign.center, + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.7), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: + const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () async { + isDeleting.value = true; + await controller.deleteExpense(expense.id); + isDeleting.value = false; + Navigator.pop(context); + showAppSnackbar( + title: 'Deleted', + message: 'Expense has been deleted.', + type: SnackbarType.success, + ); + }, + icon: const Icon(Icons.delete_forever, + color: Colors.white), + label: MyText.bodyMedium( + "Delete", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ); + }), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (expenseList.isEmpty && !Get.find().isLoading.value) { + return Center(child: MyText.bodyMedium('No expenses found.')); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: expenseList.length, + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + final expense = expenseList[index]; + final formattedDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toIso8601String(), + format: 'dd MMM yyyy, hh:mm a', + ); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () async { + final result = await Get.to( + () => ExpenseDetailScreen(expenseId: expense.id), + arguments: {'expense': expense}, + ); + if (result == true && onViewDetail != null) { + await onViewDetail!(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium(expense.expensesType.name, + fontWeight: 600), + Row( + children: [ + MyText.bodyMedium( + '₹ ${expense.amount.toStringAsFixed(2)}', + fontWeight: 600), + if (expense.status.name.toLowerCase() == 'draft') ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => + _showDeleteConfirmation(context, expense), + child: const Icon(Icons.delete, + color: Colors.red, size: 20), + ), + ], + ], + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + MyText.bodySmall(formattedDate, fontWeight: 500), + const Spacer(), + MyText.bodySmall(expense.status.name, fontWeight: 500), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 0370bff..3dade46 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -4,36 +4,34 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/my_shadow.dart'; class SkeletonLoaders { - -static Widget buildLoadingSkeleton() { - return SizedBox( - height: 360, - child: Column( - children: List.generate(5, (index) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: List.generate(6, (i) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - width: 48, - height: 16, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(6), - ), - ); - }), + static Widget buildLoadingSkeleton() { + return SizedBox( + height: 360, + child: Column( + children: List.generate(5, (index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(6, (i) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 48, + height: 16, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ); + }), + ), ), - ), - ); - }), - ), - ); -} - + ); + }), + ), + ); + } // Employee List - Card Style static Widget employeeListSkeletonLoader() { @@ -63,25 +61,37 @@ static Widget buildLoadingSkeleton() { children: [ Row( children: [ - Container(height: 14, width: 100, color: Colors.grey.shade300), + Container( + height: 14, + width: 100, + color: Colors.grey.shade300), MySpacing.width(8), - Container(height: 12, width: 60, color: Colors.grey.shade300), + Container( + height: 12, width: 60, color: Colors.grey.shade300), ], ), MySpacing.height(8), Row( children: [ - Icon(Icons.email, size: 16, color: Colors.grey.shade300), + Icon(Icons.email, + size: 16, color: Colors.grey.shade300), MySpacing.width(4), - Container(height: 10, width: 140, color: Colors.grey.shade300), + Container( + height: 10, + width: 140, + color: Colors.grey.shade300), ], ), MySpacing.height(8), Row( children: [ - Icon(Icons.phone, size: 16, color: Colors.grey.shade300), + Icon(Icons.phone, + size: 16, color: Colors.grey.shade300), MySpacing.width(4), - Container(height: 10, width: 100, color: Colors.grey.shade300), + Container( + height: 10, + width: 100, + color: Colors.grey.shade300), ], ), ], @@ -122,16 +132,28 @@ static Widget buildLoadingSkeleton() { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(height: 12, width: 100, color: Colors.grey.shade300), + Container( + height: 12, + width: 100, + color: Colors.grey.shade300), MySpacing.height(8), - Container(height: 10, width: 80, color: Colors.grey.shade300), + Container( + height: 10, + width: 80, + color: Colors.grey.shade300), MySpacing.height(12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - Container(height: 28, width: 60, color: Colors.grey.shade300), + Container( + height: 28, + width: 60, + color: Colors.grey.shade300), MySpacing.width(8), - Container(height: 28, width: 60, color: Colors.grey.shade300), + Container( + height: 28, + width: 60, + color: Colors.grey.shade300), ], ), ], @@ -167,7 +189,8 @@ static Widget buildLoadingSkeleton() { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Container(height: 14, width: 120, color: Colors.grey.shade300), + Container( + height: 14, width: 120, color: Colors.grey.shade300), Icon(Icons.add_circle, color: Colors.grey.shade300), ], ), @@ -226,133 +249,198 @@ static Widget buildLoadingSkeleton() { }), ); } - static Widget employeeSkeletonCard() { - return MyCard.bordered( - margin: MySpacing.only(bottom: 12), - paddingAll: 12, - borderRadiusAll: 12, - shadow: MyShadow( - elevation: 1.5, - position: MyShadowPosition.bottom, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Avatar - Container( - height: 35, - width: 35, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - ), - ), - MySpacing.width(12), - // Name, org, email, phone - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container(height: 12, width: 120, color: Colors.grey.shade300), - MySpacing.height(6), - Container(height: 10, width: 80, color: Colors.grey.shade300), - MySpacing.height(8), - - // Email placeholder - Row( - children: [ - Icon(Icons.email_outlined, size: 14, color: Colors.grey.shade300), - MySpacing.width(4), - Container(height: 10, width: 140, color: Colors.grey.shade300), - ], - ), - MySpacing.height(8), - - // Phone placeholder - Row( - children: [ - Icon(Icons.phone_outlined, size: 14, color: Colors.grey.shade300), - MySpacing.width(4), - Container(height: 10, width: 100, color: Colors.grey.shade300), - MySpacing.width(8), - Container( - height: 16, - width: 16, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - ), - ), - ], - ), - MySpacing.height(8), - - // Tags placeholder - Container(height: 8, width: 80, color: Colors.grey.shade300), - ], - ), - ), - - // Arrow - Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300), - ], - ), - ); -} - - static Widget contactSkeletonCard() { - return MyCard.bordered( - margin: MySpacing.only(bottom: 12), - paddingAll: 16, - borderRadiusAll: 16, - shadow: MyShadow( - elevation: 1.5, - position: MyShadowPosition.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + static Widget expenseListSkeletonLoader() { + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: 6, // Show 6 skeleton items + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - height: 40, - width: 40, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - ), + // Title and Amount + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + height: 14, + width: 120, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + Container( + height: 14, + width: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + ], ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 12, - width: 100, + const SizedBox(height: 6), + // Date and Status + Row( + children: [ + Container( + height: 12, + width: 100, + decoration: BoxDecoration( color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), ), - MySpacing.height(6), - Container( - height: 10, - width: 60, + ), + const Spacer(), + Container( + height: 12, + width: 50, + decoration: BoxDecoration( color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), ), - ], - ), + ), + ], ), ], - ), - MySpacing.height(16), - Container(height: 10, width: 150, color: Colors.grey.shade300), - MySpacing.height(8), - Container(height: 10, width: 100, color: Colors.grey.shade300), - MySpacing.height(8), - Container(height: 10, width: 120, color: Colors.grey.shade300), - ], - ), - ); -} + ); + }, + ); + } + static Widget employeeSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 12, + borderRadiusAll: 12, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + + // Name, org, email, phone + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 12, width: 120, color: Colors.grey.shade300), + MySpacing.height(6), + Container(height: 10, width: 80, color: Colors.grey.shade300), + MySpacing.height(8), + + // Email placeholder + Row( + children: [ + Icon(Icons.email_outlined, + size: 14, color: Colors.grey.shade300), + MySpacing.width(4), + Container( + height: 10, width: 140, color: Colors.grey.shade300), + ], + ), + MySpacing.height(8), + + // Phone placeholder + Row( + children: [ + Icon(Icons.phone_outlined, + size: 14, color: Colors.grey.shade300), + MySpacing.width(4), + Container( + height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.width(8), + Container( + height: 16, + width: 16, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + ], + ), + MySpacing.height(8), + + // Tags placeholder + Container(height: 8, width: 80, color: Colors.grey.shade300), + ], + ), + ), + + // Arrow + Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300), + ], + ), + ); + } + + static Widget contactSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 16, + borderRadiusAll: 16, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 100, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 10, + width: 60, + color: Colors.grey.shade300, + ), + ], + ), + ), + ], + ), + MySpacing.height(16), + Container(height: 10, width: 150, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 120, color: Colors.grey.shade300), + ], + ), + ); + } } diff --git a/lib/helpers/widgets/my_team_model_sheet.dart b/lib/helpers/widgets/my_team_model_sheet.dart index b3f7c3c..f879073 100644 --- a/lib/helpers/widgets/my_team_model_sheet.dart +++ b/lib/helpers/widgets/my_team_model_sheet.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class TeamBottomSheet { static void show({ @@ -9,46 +11,61 @@ class TeamBottomSheet { }) { showModalBottomSheet( context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), - backgroundColor: Colors.white, - builder: (_) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title and Close Icon - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyLarge("Team Members", fontWeight: 600), - IconButton( - icon: const Icon(Icons.close, size: 20, color: Colors.black54), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const Divider(thickness: 1.2), - // Team Member Rows - ...teamMembers.map((member) => _buildTeamMemberRow(member)), - ], - ), - ), - ); - } - - static Widget _buildTeamMemberRow(dynamic member) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Avatar(firstName: member.firstName, lastName: '', size: 36), - const SizedBox(width: 10), - MyText.bodyMedium(member.firstName, fontWeight: 500), - ], - ), + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) { + return BaseBottomSheet( + title: 'Team Members', + onCancel: () => Navigator.pop(context), + onSubmit: () {}, + showButtons: false, + child: _TeamMemberList(teamMembers: teamMembers), + ); + }, + ); + } +} + +class _TeamMemberList extends StatelessWidget { + final List teamMembers; + + const _TeamMemberList({required this.teamMembers}); + + @override + Widget build(BuildContext context) { + if (teamMembers.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: MyText.bodySmall( + "No team members found.", + fontWeight: 600, + color: Colors.grey, + ), + ), + ); + } + + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: teamMembers.length, + separatorBuilder: (_, __) => const Divider(thickness: 0.8, height: 12), + itemBuilder: (_, index) { + final member = teamMembers[index]; + final String name = member.firstName ?? 'Unnamed'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Avatar(firstName: member.firstName, lastName: '', size: 36), + MySpacing.width(10), + MyText.bodyMedium(name, fontWeight: 500), + ], + ), + ); + }, ); } } diff --git a/lib/helpers/widgets/team_members_bottom_sheet.dart b/lib/helpers/widgets/team_members_bottom_sheet.dart index 6926df5..949f870 100644 --- a/lib/helpers/widgets/team_members_bottom_sheet.dart +++ b/lib/helpers/widgets/team_members_bottom_sheet.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/model/directory/contact_bucket_list_model.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class TeamMembersBottomSheet { static void show( @@ -11,8 +13,9 @@ class TeamMembersBottomSheet { bool canEdit = false, VoidCallback? onEdit, }) { - // Ensure the owner is at the top of the list final ownerId = bucket.createdBy.id; + + // Ensure owner is listed first members.sort((a, b) { if (a.id == ownerId) return -1; if (b.id == ownerId) return 1; @@ -23,201 +26,185 @@ class TeamMembersBottomSheet { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - isDismissible: true, - enableDrag: true, - builder: (context) { - return SafeArea( - child: Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - child: DraggableScrollableSheet( - expand: false, - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - builder: (context, scrollController) { - return Column( - children: [ - const SizedBox(height: 6), - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 10), - - MyText.titleMedium( - 'Bucket Details', - fontWeight: 700, - ), - - const SizedBox(height: 12), - - // Header with title and edit - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Expanded( - child: MyText.titleMedium( - bucket.name, - fontWeight: 700, - ), - ), - if (canEdit) - IconButton( - onPressed: onEdit, - icon: const Icon(Icons.edit, color: Colors.red), - tooltip: 'Edit Bucket', - ), - ], - ), - ), - - // Info - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (bucket.description.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 6), - child: MyText.bodySmall( - bucket.description, - color: Colors.grey[700], - ), - ), - Row( - children: [ - const Icon(Icons.contacts_outlined, - size: 14, color: Colors.grey), - const SizedBox(width: 4), - MyText.labelSmall( - '${bucket.numberOfContacts} contact(s)', - fontWeight: 600, - color: Colors.red, - ), - const SizedBox(width: 12), - const Icon(Icons.ios_share_outlined, - size: 14, color: Colors.grey), - const SizedBox(width: 4), - MyText.labelSmall( - 'Shared with (${members.length})', - fontWeight: 600, - color: Colors.indigo, - ), - ], - ), - Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - children: [ - const Icon(Icons.edit_outlined, - size: 14, color: Colors.grey), - const SizedBox(width: 4), - MyText.labelSmall( - canEdit - ? 'Can be edited by you' - : 'You don’t have edit access', - fontWeight: 600, - color: canEdit ? Colors.green : Colors.grey, - ), - ], - ), - ), - const SizedBox(height: 8), - const Divider(thickness: 1), - const SizedBox(height: 6), - MyText.labelLarge( - 'Shared with', - fontWeight: 700, - color: Colors.black, - ), - ], - ), - ), - - const SizedBox(height: 4), - - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: members.isEmpty - ? Center( - child: MyText.bodySmall( - "No team members found.", - fontWeight: 600, - color: Colors.grey, - ), - ) - : ListView.separated( - controller: scrollController, - itemCount: members.length, - separatorBuilder: (_, __) => - const SizedBox(height: 4), - itemBuilder: (context, index) { - final member = members[index]; - final firstName = member.firstName ?? ''; - final lastName = member.lastName ?? ''; - final isOwner = - member.id == bucket.createdBy.id; - - return ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Avatar( - firstName: firstName, - lastName: lastName, - size: 32, - ), - title: Row( - children: [ - Expanded( - child: MyText.bodyMedium( - '${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}', - fontWeight: 600, - ), - ), - if (isOwner) - Container( - margin: - const EdgeInsets.only(left: 6), - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: - BorderRadius.circular(4), - ), - child: MyText.labelSmall( - "Owner", - fontWeight: 600, - color: Colors.red, - ), - ), - ], - ), - subtitle: MyText.bodySmall( - member.jobRole ?? '', - color: Colors.grey.shade600, - ), - ); - }, - ), - ), - ), - - const SizedBox(height: 8), - ], - ); - }, - ), + builder: (_) { + return BaseBottomSheet( + title: 'Bucket Details', + onCancel: () => Navigator.pop(context), + onSubmit: () {}, // Not used, but required + showButtons: false, + child: _TeamContent( + bucket: bucket, + members: members, + canEdit: canEdit, + onEdit: onEdit, + ownerId: ownerId, + ), + ); + }, + ); + } +} + +class _TeamContent extends StatelessWidget { + final ContactBucket bucket; + final List members; + final bool canEdit; + final VoidCallback? onEdit; + final String ownerId; + + const _TeamContent({ + required this.bucket, + required this.members, + required this.canEdit, + this.onEdit, + required this.ownerId, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildHeader(), + _buildInfo(), + _buildMembersTitle(), + MySpacing.height(8), + SizedBox( + height: 300, + child: _buildMemberList(), + ), + ], + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + Expanded( + child: MyText.titleMedium(bucket.name, fontWeight: 700), + ), + if (canEdit) + IconButton( + onPressed: onEdit, + icon: const Icon(Icons.edit, color: Colors.red), + tooltip: 'Edit Bucket', + ), + ], + ), + ); + } + + Widget _buildInfo() { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (bucket.description.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: MyText.bodySmall( + bucket.description, + color: Colors.grey[700], + ), + ), + Row( + children: [ + const Icon(Icons.contacts_outlined, size: 14, color: Colors.grey), + const SizedBox(width: 4), + MyText.labelSmall( + '${bucket.numberOfContacts} contact(s)', + fontWeight: 600, + color: Colors.red, + ), + const SizedBox(width: 12), + const Icon(Icons.ios_share_outlined, size: 14, color: Colors.grey), + const SizedBox(width: 4), + MyText.labelSmall( + 'Shared with (${members.length})', + fontWeight: 600, + color: Colors.indigo, + ), + ], + ), + MySpacing.height(8), + Row( + children: [ + const Icon(Icons.edit_outlined, size: 14, color: Colors.grey), + const SizedBox(width: 4), + MyText.labelSmall( + canEdit ? 'Can be edited by you' : 'You don’t have edit access', + fontWeight: 600, + color: canEdit ? Colors.green : Colors.grey, + ), + ], + ), + MySpacing.height(12), + const Divider(thickness: 1), + ], + ), + ); + } + + Widget _buildMembersTitle() { + return Align( + alignment: Alignment.centerLeft, + child: MyText.labelLarge('Shared with', fontWeight: 700, color: Colors.black), + ); + } + + Widget _buildMemberList() { + if (members.isEmpty) { + return Center( + child: MyText.bodySmall( + "No team members found.", + fontWeight: 600, + color: Colors.grey, + ), + ); + } + + return ListView.separated( + itemCount: members.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final member = members[index]; + final firstName = member.firstName ?? ''; + final lastName = member.lastName ?? ''; + final isOwner = member.id == ownerId; + + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Avatar(firstName: firstName, lastName: lastName, size: 32), + title: Row( + children: [ + Expanded( + child: MyText.bodyMedium( + '${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}', + fontWeight: 600, + ), + ), + if (isOwner) + Container( + margin: const EdgeInsets.only(left: 6), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.labelSmall( + "Owner", + fontWeight: 600, + color: Colors.red, + ), + ), + ], + ), + subtitle: MyText.bodySmall( + member.jobRole ?? '', + color: Colors.grey.shade600, ), ); }, diff --git a/lib/main.dart b/lib/main.dart index e624408..af77945 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -66,7 +66,8 @@ class _MainWrapperState extends State { super.initState(); _initializeConnectivity(); // Listen for changes, the callback now provides a List - _connectivity.onConnectivityChanged.listen((List results) { + _connectivity.onConnectivityChanged + .listen((List results) { setState(() { _connectivityStatus = results; }); @@ -84,7 +85,8 @@ class _MainWrapperState extends State { @override Widget build(BuildContext context) { // Check if any of the connectivity results indicate no internet - final bool isOffline = _connectivityStatus.contains(ConnectivityResult.none); + final bool isOffline = + _connectivityStatus.contains(ConnectivityResult.none); // Show OfflineScreen if no internet if (isOffline) { @@ -97,4 +99,4 @@ class _MainWrapperState extends State { // Show main app if online return const MyApp(); } -} \ No newline at end of file +} diff --git a/lib/model/attendance/attendence_action_button.dart b/lib/model/attendance/attendence_action_button.dart index 08b725e..ebbbd9a 100644 --- a/lib/model/attendance/attendence_action_button.dart +++ b/lib/model/attendance/attendence_action_button.dart @@ -1,117 +1,27 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; + import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:marco/helpers/utils/attendance_actions.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AttendanceActionButton extends StatefulWidget { final dynamic employee; final AttendanceController attendanceController; const AttendanceActionButton({ - Key? key, + super.key, required this.employee, required this.attendanceController, - }) : super(key: key); + }); @override State createState() => _AttendanceActionButtonState(); } -Future _showCommentBottomSheet( - BuildContext context, String actionText) async { - final TextEditingController commentController = TextEditingController(); - String? errorText; - Get.find().selectedProject?.id; - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) { - return StatefulBuilder( - builder: (context, setModalState) { - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 24, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Add Comment for ${capitalizeFirstLetter(actionText)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 16), - TextField( - controller: commentController, - maxLines: 4, - decoration: InputDecoration( - hintText: 'Type your comment here...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - filled: true, - fillColor: Colors.grey.shade100, - errorText: errorText, - ), - onChanged: (_) { - if (errorText != null) { - setModalState(() => errorText = null); - } - }, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton( - onPressed: () { - final comment = commentController.text.trim(); - if (comment.isEmpty) { - setModalState(() { - errorText = 'Comment cannot be empty.'; - }); - return; - } - Navigator.of(context).pop(comment); - }, - child: const Text('Submit'), - ), - ), - ], - ), - ], - ), - ); - }, - ); - }, - ); -} - -String capitalizeFirstLetter(String text) { - if (text.isEmpty) return text; - return text[0].toUpperCase() + text.substring(1); -} - class _AttendanceActionButtonState extends State { late final String uniqueLogKey; @@ -119,60 +29,57 @@ class _AttendanceActionButtonState extends State { void initState() { super.initState(); uniqueLogKey = AttendanceButtonHelper.getUniqueKey( - widget.employee.employeeId, widget.employee.id); + widget.employee.employeeId, + widget.employee.id, + ); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!widget.attendanceController.uploadingStates - .containsKey(uniqueLogKey)) { - widget.attendanceController.uploadingStates[uniqueLogKey] = false.obs; - } + widget.attendanceController.uploadingStates.putIfAbsent( + uniqueLogKey, + () => false.obs, + ); }); } - Future showTimePickerForRegularization({ - required BuildContext context, - required DateTime checkInTime, - }) async { + Future _pickRegularizationTime(DateTime checkInTime) async { final pickedTime = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(DateTime.now()), ); - if (pickedTime != null) { - final selectedDateTime = DateTime( - checkInTime.year, - checkInTime.month, - checkInTime.day, - pickedTime.hour, - pickedTime.minute, + if (pickedTime == null) return null; + + final selected = DateTime( + checkInTime.year, + checkInTime.month, + checkInTime.day, + pickedTime.hour, + pickedTime.minute, + ); + + final now = DateTime.now(); + + if (selected.isBefore(checkInTime)) { + showAppSnackbar( + title: "Invalid Time", + message: "Time must be after check-in.", + type: SnackbarType.warning, ); - - final now = DateTime.now(); - - if (selectedDateTime.isBefore(checkInTime)) { - showAppSnackbar( - title: "Invalid Time", - message: "Time must be after check-in.", - type: SnackbarType.warning, - ); - return null; - } else if (selectedDateTime.isAfter(now)) { - showAppSnackbar( - title: "Invalid Time", - message: "Future time is not allowed.", - type: SnackbarType.warning, - ); - return null; - } - - return selectedDateTime; + return null; + } else if (selected.isAfter(now)) { + showAppSnackbar( + title: "Invalid Time", + message: "Future time is not allowed.", + type: SnackbarType.warning, + ); + return null; } - return null; + + return selected; } - void _handleButtonPressed(BuildContext context) async { - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = true; - + Future _handleButtonPressed() async { + final controller = widget.attendanceController; final projectController = Get.find(); final selectedProjectId = projectController.selectedProject?.id; @@ -182,53 +89,49 @@ class _AttendanceActionButtonState extends State { message: "Please select a project first", type: SnackbarType.error, ); - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; return; } - int updatedAction; + controller.uploadingStates[uniqueLogKey]?.value = true; + + int action; String actionText; bool imageCapture = true; switch (widget.employee.activity) { case 0: - updatedAction = 0; + case 4: + action = 0; actionText = ButtonActions.checkIn; break; case 1: - if (widget.employee.checkOut == null && - AttendanceButtonHelper.isOlderThanDays( - widget.employee.checkIn, 2)) { - updatedAction = 2; + final isOld = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2); + final isOldCheckout = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2); + + if (widget.employee.checkOut == null && isOld) { + action = 2; actionText = ButtonActions.requestRegularize; imageCapture = false; - } else if (widget.employee.checkOut != null && - AttendanceButtonHelper.isOlderThanDays( - widget.employee.checkOut, 2)) { - updatedAction = 2; + } else if (widget.employee.checkOut != null && isOldCheckout) { + action = 2; actionText = ButtonActions.requestRegularize; } else { - updatedAction = 1; + action = 1; actionText = ButtonActions.checkOut; } break; case 2: - updatedAction = 2; + action = 2; actionText = ButtonActions.requestRegularize; break; - case 4: - updatedAction = 0; - actionText = ButtonActions.checkIn; - break; default: - updatedAction = 0; + action = 0; actionText = "Unknown Action"; break; } DateTime? selectedTime; - // ✅ New condition: Yesterday Check-In + CheckOut action final isYesterdayCheckIn = widget.employee.checkIn != null && DateUtils.isSameDay( widget.employee.checkIn, @@ -238,67 +141,41 @@ class _AttendanceActionButtonState extends State { if (isYesterdayCheckIn && widget.employee.checkOut == null && actionText == ButtonActions.checkOut) { - selectedTime = await showTimePickerForRegularization( - context: context, - checkInTime: widget.employee.checkIn!, - ); - + selectedTime = await _pickRegularizationTime(widget.employee.checkIn!); if (selectedTime == null) { - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = - false; + controller.uploadingStates[uniqueLogKey]?.value = false; return; } } - final userComment = await _showCommentBottomSheet(context, actionText); - if (userComment == null || userComment.isEmpty) { - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; + final comment = await _showCommentBottomSheet(context, actionText); + if (comment == null || comment.isEmpty) { + controller.uploadingStates[uniqueLogKey]?.value = false; return; } bool success = false; + String? markTime; + if (actionText == ButtonActions.requestRegularize) { - final regularizeTime = selectedTime ?? - await showTimePickerForRegularization( - context: context, - checkInTime: widget.employee.checkIn!, - ); - if (regularizeTime != null) { - final formattedSelectedTime = - DateFormat("hh:mm a").format(regularizeTime); - success = await widget.attendanceController.captureAndUploadAttendance( - widget.employee.id, - widget.employee.employeeId, - selectedProjectId, - comment: userComment, - action: updatedAction, - imageCapture: imageCapture, - markTime: formattedSelectedTime, - ); + selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!); + if (selectedTime != null) { + markTime = DateFormat("hh:mm a").format(selectedTime); } } else if (selectedTime != null) { - // ✅ If selectedTime was picked in the new condition - final formattedSelectedTime = DateFormat("hh:mm a").format(selectedTime); - success = await widget.attendanceController.captureAndUploadAttendance( - widget.employee.id, - widget.employee.employeeId, - selectedProjectId, - comment: userComment, - action: updatedAction, - imageCapture: imageCapture, - markTime: formattedSelectedTime, - ); - } else { - success = await widget.attendanceController.captureAndUploadAttendance( - widget.employee.id, - widget.employee.employeeId, - selectedProjectId, - comment: userComment, - action: updatedAction, - imageCapture: imageCapture, - ); + markTime = DateFormat("hh:mm a").format(selectedTime); } + success = await controller.captureAndUploadAttendance( + widget.employee.id, + widget.employee.employeeId, + selectedProjectId, + comment: comment, + action: action, + imageCapture: imageCapture, + markTime: markTime, + ); + showAppSnackbar( title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error', message: success @@ -307,51 +184,47 @@ class _AttendanceActionButtonState extends State { type: success ? SnackbarType.success : SnackbarType.error, ); - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; + controller.uploadingStates[uniqueLogKey]?.value = false; if (success) { - widget.attendanceController.fetchEmployeesByProject(selectedProjectId); - widget.attendanceController.fetchAttendanceLogs(selectedProjectId); - await widget.attendanceController - .fetchRegularizationLogs(selectedProjectId); - await widget.attendanceController.fetchProjectData(selectedProjectId); - widget.attendanceController.update(); + controller.fetchEmployeesByProject(selectedProjectId); + controller.fetchAttendanceLogs(selectedProjectId); + await controller.fetchRegularizationLogs(selectedProjectId); + await controller.fetchProjectData(selectedProjectId); + controller.update(); } } @override Widget build(BuildContext context) { return Obx(() { - final isUploading = - widget.attendanceController.uploadingStates[uniqueLogKey]?.value ?? - false; + final controller = widget.attendanceController; - final isYesterday = AttendanceButtonHelper.isLogFromYesterday( - widget.employee.checkIn, widget.employee.checkOut); - final isTodayApproved = AttendanceButtonHelper.isTodayApproved( - widget.employee.activity, widget.employee.checkIn); - final isApprovedButNotToday = - AttendanceButtonHelper.isApprovedButNotToday( - widget.employee.activity, isTodayApproved); + final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false; + final emp = widget.employee; + + final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut); + final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn); + final isApprovedButNotToday = AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved); final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( isUploading: isUploading, isYesterday: isYesterday, - activity: widget.employee.activity, + activity: emp.activity, isApprovedButNotToday: isApprovedButNotToday, ); final buttonText = AttendanceButtonHelper.getButtonText( - activity: widget.employee.activity, - checkIn: widget.employee.checkIn, - checkOut: widget.employee.checkOut, + activity: emp.activity, + checkIn: emp.checkIn, + checkOut: emp.checkOut, isTodayApproved: isTodayApproved, ); final buttonColor = AttendanceButtonHelper.getButtonColor( isYesterday: isYesterday, isTodayApproved: isTodayApproved, - activity: widget.employee.activity, + activity: emp.activity, ); return AttendanceActionButtonUI( @@ -359,8 +232,7 @@ class _AttendanceActionButtonState extends State { isButtonDisabled: isButtonDisabled, buttonText: buttonText, buttonColor: buttonColor, - onPressed: - isButtonDisabled ? null : () => _handleButtonPressed(context), + onPressed: isButtonDisabled ? null : _handleButtonPressed, ); }); } @@ -374,20 +246,20 @@ class AttendanceActionButtonUI extends StatelessWidget { final VoidCallback? onPressed; const AttendanceActionButtonUI({ - Key? key, + super.key, required this.isUploading, required this.isButtonDisabled, required this.buttonText, required this.buttonColor, required this.onPressed, - }) : super(key: key); + }); @override Widget build(BuildContext context) { return SizedBox( height: 30, child: ElevatedButton( - onPressed: isButtonDisabled ? null : onPressed, + onPressed: onPressed, style: ElevatedButton.styleFrom( backgroundColor: buttonColor, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), @@ -405,17 +277,14 @@ class AttendanceActionButtonUI extends StatelessWidget { : Row( mainAxisSize: MainAxisSize.min, children: [ - if (buttonText.toLowerCase() == 'approved') ...[ + if (buttonText.toLowerCase() == 'approved') const Icon(Icons.check, size: 16, color: Colors.green), - const SizedBox(width: 4), - ] else if (buttonText.toLowerCase() == 'rejected') ...[ + if (buttonText.toLowerCase() == 'rejected') const Icon(Icons.close, size: 16, color: Colors.red), + if (buttonText.toLowerCase() == 'requested') + const Icon(Icons.hourglass_top, size: 16, color: Colors.orange), + if (['approved', 'rejected', 'requested'].contains(buttonText.toLowerCase())) const SizedBox(width: 4), - ] else if (buttonText.toLowerCase() == 'requested') ...[ - const Icon(Icons.hourglass_top, - size: 16, color: Colors.orange), - const SizedBox(width: 4), - ], Flexible( child: Text( buttonText, @@ -429,3 +298,68 @@ class AttendanceActionButtonUI extends StatelessWidget { ); } } + +Future _showCommentBottomSheet(BuildContext context, String actionText) async { + final commentController = TextEditingController(); + String? errorText; + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return StatefulBuilder( + builder: (context, setModalState) { + void submit() { + final comment = commentController.text.trim(); + if (comment.isEmpty) { + setModalState(() => errorText = 'Comment cannot be empty.'); + return; + } + Navigator.of(context).pop(comment); + } + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: BaseBottomSheet( + title: 'Add Comment for ${capitalizeFirstLetter(actionText)}', + onCancel: () => Navigator.of(context).pop(), + onSubmit: submit, + isSubmitting: false, + submitText: 'Submit', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: commentController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Type your comment here...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: Colors.grey.shade100, + errorText: errorText, + ), + onChanged: (_) { + if (errorText != null) { + setModalState(() => errorText = null); + } + }, + ), + ], + ), + ), + ); + }, + ); + }, + ); +} + +String capitalizeFirstLetter(String text) => + text.isEmpty ? text : text[0].toUpperCase() + text.substring(1); diff --git a/lib/model/attendance/attendence_filter_sheet.dart b/lib/model/attendance/attendence_filter_sheet.dart index e51f5d9..53d371a 100644 --- a/lib/model/attendance/attendence_filter_sheet.dart +++ b/lib/model/attendance/attendence_filter_sheet.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; -import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AttendanceFilterBottomSheet extends StatefulWidget { final AttendanceController controller; @@ -18,7 +19,7 @@ class AttendanceFilterBottomSheet extends StatefulWidget { }); @override - _AttendanceFilterBottomSheetState createState() => + State createState() => _AttendanceFilterBottomSheetState(); } @@ -53,83 +54,70 @@ class _AttendanceFilterBottomSheetState {'label': 'Regularization Requests', 'value': 'regularizationRequests'}, ]; - final filteredViewOptions = viewOptions.where((item) { - if (item['value'] == 'regularizationRequests') { - return hasRegularizationPermission; - } - return true; + final filteredOptions = viewOptions.where((item) { + return item['value'] != 'regularizationRequests' || + hasRegularizationPermission; }).toList(); - List widgets = [ + final List widgets = [ Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: EdgeInsets.only(bottom: 4), child: Align( alignment: Alignment.centerLeft, - child: MyText.titleSmall( - "View", - fontWeight: 600, - ), + child: MyText.titleSmall("View", fontWeight: 600), ), ), - ...filteredViewOptions.map((item) { + ...filteredOptions.map((item) { return RadioListTile( dense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - title: Text(item['label']!), + contentPadding: EdgeInsets.zero, + title: MyText.bodyMedium( + item['label']!, + fontWeight: 500, + ), value: item['value']!, groupValue: tempSelectedTab, onChanged: (value) => setState(() => tempSelectedTab = value!), ); - }).toList(), + }), ]; if (tempSelectedTab == 'attendanceLogs') { widgets.addAll([ const Divider(), Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: EdgeInsets.only(top: 12, bottom: 4), child: Align( alignment: Alignment.centerLeft, - child: MyText.titleSmall( - "Date Range", - fontWeight: 600, - ), + child: MyText.titleSmall("Date Range", fontWeight: 600), ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: () => widget.controller.selectDateRangeForAttendance( - context, - widget.controller, + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => widget.controller.selectDateRangeForAttendance( + context, + widget.controller, + ), + child: Ink( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(10), ), - child: Ink( - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(10), - ), - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: Row( - children: [ - Icon(Icons.date_range, color: Colors.black87), - const SizedBox(width: 12), - Expanded( - child: Text( - getLabelText(), - style: const TextStyle( - fontSize: 16, - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + const Icon(Icons.date_range, color: Colors.black87), + const SizedBox(width: 12), + Expanded( + child: MyText.bodyMedium( + getLabelText(), + fontWeight: 500, + color: Colors.black87, ), - const Icon(Icons.arrow_drop_down, color: Colors.black87), - ], - ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.black87), + ], ), ), ), @@ -141,49 +129,17 @@ class _AttendanceFilterBottomSheetState @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: SingleChildScrollView( + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: BaseBottomSheet( + title: "Attendance Filter", + onCancel: () => Navigator.pop(context), + onSubmit: () => Navigator.pop(context, { + 'selectedTab': tempSelectedTab, + }), child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[400], - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ), - ...buildMainFilters(), - const Divider(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color.fromARGB(255, 95, 132, 255), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: const Text('Apply Filter'), - onPressed: () { - Navigator.pop(context, { - 'selectedTab': tempSelectedTab, - }); - }, - ), - ), - ), - ], + crossAxisAlignment: CrossAxisAlignment.start, + children: buildMainFilters(), ), ), ); diff --git a/lib/model/attendance/log_details_view.dart b/lib/model/attendance/log_details_view.dart index deb0331..b2ad720 100644 --- a/lib/model/attendance/log_details_view.dart +++ b/lib/model/attendance/log_details_view.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/attendance_actions.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AttendanceLogViewButton extends StatelessWidget { final dynamic employee; - final dynamic attendanceController; // Use correct types as needed - + final dynamic attendanceController; const AttendanceLogViewButton({ Key? key, required this.employee, @@ -50,191 +50,164 @@ class AttendanceLogViewButton extends StatelessWidget { void _showLogsBottomSheet(BuildContext context) async { await attendanceController.fetchLogsView(employee.id.toString()); + showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - backgroundColor: Theme.of(context).cardColor, - builder: (context) => Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 16, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.titleMedium( - "Attendance Log", - fontWeight: 700, - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 12), - if (attendanceController.attendenceLogsView.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: Column( - children: const [ - Icon(Icons.info_outline, size: 40, color: Colors.grey), - SizedBox(height: 8), - Text("No attendance logs available."), - ], - ), - ) - else - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: attendanceController.attendenceLogsView.length, - separatorBuilder: (_, __) => const SizedBox(height: 16), - itemBuilder: (_, index) { - final log = attendanceController.attendenceLogsView[index]; - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, - offset: const Offset(0, 2), - ) - ], - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - flex: 3, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _getLogIcon(log), - const SizedBox(width: 10), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - MyText.bodyLarge( - log.formattedDate ?? '-', - fontWeight: 600, - ), - MyText.bodySmall( - "Time: ${log.formattedTime ?? '-'}", - color: Colors.grey[700], - ), - ], - ), - ], - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - if (log.latitude != null && - log.longitude != null) - GestureDetector( - onTap: () { - final lat = double.tryParse(log - .latitude - .toString()) ?? - 0.0; - final lon = double.tryParse(log - .longitude - .toString()) ?? - 0.0; - if (lat >= -90 && - lat <= 90 && - lon >= -180 && - lon <= 180) { - _openGoogleMaps( - context, lat, lon); - } else { - ScaffoldMessenger.of(context) - .showSnackBar( - const SnackBar( - content: Text( - 'Invalid location coordinates')), - ); - } - }, - child: const Padding( - padding: - EdgeInsets.only(right: 8.0), - child: Icon(Icons.location_on, - size: 18, color: Colors.blue), - ), + backgroundColor: Colors.transparent, + builder: (context) => BaseBottomSheet( + title: "Attendance Log", + onCancel: () => Navigator.pop(context), + onSubmit: () => Navigator.pop(context), + showButtons: false, + child: attendanceController.attendenceLogsView.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Column( + children: const [ + Icon(Icons.info_outline, size: 40, color: Colors.grey), + SizedBox(height: 8), + Text("No attendance logs available."), + ], + ), + ) + : ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: attendanceController.attendenceLogsView.length, + separatorBuilder: (_, __) => const SizedBox(height: 16), + itemBuilder: (_, index) { + final log = attendanceController.attendenceLogsView[index]; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ) + ], + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _getLogIcon(log), + const SizedBox(width: 10), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.bodyLarge( + log.formattedDate ?? '-', + fontWeight: 600, ), - Expanded( - child: MyText.bodyMedium( - log.comment?.isNotEmpty == true - ? log.comment - : "No description provided", - fontWeight: 500, + MyText.bodySmall( + "Time: ${log.formattedTime ?? '-'}", + color: Colors.grey[700], ), - ), - ], - ), - ], - ), - ), - const SizedBox(width: 16), - if (log.thumbPreSignedUrl != null) - GestureDetector( - onTap: () { - if (log.preSignedUrl != null) { - _showImageDialog( - context, log.preSignedUrl!); - } - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - log.thumbPreSignedUrl!, - height: 60, - width: 60, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) { - return const Icon(Icons.broken_image, - size: 20, color: Colors.grey); - }, - ), + ], + ), + ], ), - ) - else - const Icon(Icons.broken_image, - size: 20, color: Colors.grey), - ], - ), - ], - ), - ); - }, - ) - ], - ), - ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (log.latitude != null && + log.longitude != null) + GestureDetector( + onTap: () { + final lat = double.tryParse( + log.latitude.toString()) ?? + 0.0; + final lon = double.tryParse( + log.longitude.toString()) ?? + 0.0; + if (lat >= -90 && + lat <= 90 && + lon >= -180 && + lon <= 180) { + _openGoogleMaps( + context, lat, lon); + } else { + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text( + 'Invalid location coordinates')), + ); + } + }, + child: const Padding( + padding: + EdgeInsets.only(right: 8.0), + child: Icon(Icons.location_on, + size: 18, color: Colors.blue), + ), + ), + Expanded( + child: MyText.bodyMedium( + log.comment?.isNotEmpty == true + ? log.comment + : "No description provided", + fontWeight: 500, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 16), + if (log.thumbPreSignedUrl != null) + GestureDetector( + onTap: () { + if (log.preSignedUrl != null) { + _showImageDialog( + context, log.preSignedUrl!); + } + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + log.thumbPreSignedUrl!, + height: 60, + width: 60, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.broken_image, + size: 20, color: Colors.grey); + }, + ), + ), + ) + else + const Icon(Icons.broken_image, + size: 20, color: Colors.grey), + ], + ), + ], + ), + ); + }, + ), ), ); } diff --git a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart index a61dbac..3eaa40c 100644 --- a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/daily_task_planing_controller.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AssignTaskBottomSheet extends StatefulWidget { final String workLocation; @@ -37,17 +38,9 @@ class _AssignTaskBottomSheetState extends State { final ProjectController projectController = Get.find(); final TextEditingController targetController = TextEditingController(); final TextEditingController descriptionController = TextEditingController(); - String? selectedProjectId; - final ScrollController _employeeListScrollController = ScrollController(); - @override - void dispose() { - _employeeListScrollController.dispose(); - targetController.dispose(); - descriptionController.dispose(); - super.dispose(); - } + String? selectedProjectId; @override void initState() { @@ -61,180 +54,102 @@ class _AssignTaskBottomSheetState extends State { }); } + @override + void dispose() { + _employeeListScrollController.dispose(); + targetController.dispose(); + descriptionController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return SafeArea( - child: Container( - padding: MediaQuery.of(context).viewInsets.add(MySpacing.all(16)), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Obx(() => BaseBottomSheet( + title: "Assign Task", + child: _buildAssignTaskForm(), + onCancel: () => Get.back(), + onSubmit: _onAssignTaskPressed, + isSubmitting: controller.isAssigningTask.value, + )); + } + + Widget _buildAssignTaskForm() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _infoRow(Icons.location_on, "Work Location", + "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), + Divider(), + _infoRow(Icons.pending_actions, "Pending Task of Activity", + "${widget.pendingTask}"), + Divider(), + GestureDetector( + onTap: _onRoleMenuPressed, + child: Row( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.assignment, color: Colors.black54), - SizedBox(width: 8), - MyText.titleMedium("Assign Task", - fontSize: 18, fontWeight: 600), - ], - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Get.back(), - ), - ], - ), - Divider(), - _infoRow(Icons.location_on, "Work Location", - "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), - Divider(), - _infoRow(Icons.pending_actions, "Pending Task of Activity", - "${widget.pendingTask}"), - Divider(), - GestureDetector( - onTap: () { - final RenderBox overlay = Overlay.of(context) - .context - .findRenderObject() as RenderBox; - final Size screenSize = overlay.size; - - showMenu( - context: context, - position: RelativeRect.fromLTRB( - screenSize.width / 2 - 100, - screenSize.height / 2 - 20, - screenSize.width / 2 - 100, - screenSize.height / 2 - 20, - ), - items: [ - const PopupMenuItem( - value: 'all', - child: Text("All Roles"), - ), - ...controller.roles.map((role) { - return PopupMenuItem( - value: role['id'].toString(), - child: Text(role['name'] ?? 'Unknown Role'), - ); - }), - ], - ).then((value) { - if (value != null) { - controller.onRoleSelected(value == 'all' ? null : value); - } - }); - }, - child: Row( - children: [ - MyText.titleMedium("Select Team :", fontWeight: 600), - const SizedBox(width: 4), - Icon(Icons.filter_alt, - color: const Color.fromARGB(255, 95, 132, 255)), - ], - ), - ), - MySpacing.height(8), - Container( - constraints: BoxConstraints(maxHeight: 150), - child: _buildEmployeeList(), - ), - MySpacing.height(8), - Obx(() { - if (controller.selectedEmployees.isEmpty) return Container(); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Wrap( - spacing: 4, - runSpacing: 4, - children: controller.selectedEmployees.map((e) { - return Obx(() { - final isSelected = - controller.uploadingStates[e.id]?.value ?? false; - if (!isSelected) return Container(); - - return Chip( - label: Text(e.name, - style: const TextStyle(color: Colors.white)), - backgroundColor: - const Color.fromARGB(255, 95, 132, 255), - deleteIcon: - const Icon(Icons.close, color: Colors.white), - onDeleted: () { - controller.uploadingStates[e.id]?.value = false; - controller.updateSelectedEmployees(); - }, - ); - }); - }).toList(), - ), - ); - }), - _buildTextField( - icon: Icons.track_changes, - label: "Target for Today :", - controller: targetController, - hintText: "Enter target", - keyboardType: TextInputType.number, - validatorType: "target", - ), - MySpacing.height(24), - _buildTextField( - icon: Icons.description, - label: "Description :", - controller: descriptionController, - hintText: "Enter task description", - maxLines: 3, - validatorType: "description", - ), - MySpacing.height(24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 14), - ), - ), - ElevatedButton.icon( - onPressed: _onAssignTaskPressed, - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - label: - MyText.bodyMedium("Assign Task", color: Colors.white), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 28, vertical: 14), - ), - ), - ], - ), + MyText.titleMedium("Select Team :", fontWeight: 600), + const SizedBox(width: 4), + const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)), ], ), ), - ), + MySpacing.height(8), + Container( + constraints: const BoxConstraints(maxHeight: 150), + child: _buildEmployeeList(), + ), + MySpacing.height(8), + _buildSelectedEmployees(), + _buildTextField( + icon: Icons.track_changes, + label: "Target for Today :", + controller: targetController, + hintText: "Enter target", + keyboardType: TextInputType.number, + validatorType: "target", + ), + MySpacing.height(24), + _buildTextField( + icon: Icons.description, + label: "Description :", + controller: descriptionController, + hintText: "Enter task description", + maxLines: 3, + validatorType: "description", + ), + ], ); } + void _onRoleMenuPressed() { + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final Size screenSize = overlay.size; + + showMenu( + context: context, + position: RelativeRect.fromLTRB( + screenSize.width / 2 - 100, + screenSize.height / 2 - 20, + screenSize.width / 2 - 100, + screenSize.height / 2 - 20, + ), + items: [ + const PopupMenuItem(value: 'all', child: Text("All Roles")), + ...controller.roles.map((role) { + return PopupMenuItem( + value: role['id'].toString(), + child: Text(role['name'] ?? 'Unknown Role'), + ); + }), + ], + ).then((value) { + if (value != null) { + controller.onRoleSelected(value == 'all' ? null : value); + } + }); + } + Widget _buildEmployeeList() { return Obx(() { if (controller.isLoading.value) { @@ -255,49 +170,43 @@ class _AssignTaskBottomSheetState extends State { return Scrollbar( controller: _employeeListScrollController, thumbVisibility: true, - interactive: true, child: ListView.builder( controller: _employeeListScrollController, shrinkWrap: true, - physics: const AlwaysScrollableScrollPhysics(), itemCount: filteredEmployees.length, itemBuilder: (context, index) { final employee = filteredEmployees[index]; final rxBool = controller.uploadingStates[employee.id]; + return Obx(() => Padding( - padding: const EdgeInsets.symmetric(vertical: 0), + padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ - Theme( - data: Theme.of(context) - .copyWith(unselectedWidgetColor: Colors.black), - child: Checkbox( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - side: const BorderSide(color: Colors.black), - ), - value: rxBool?.value ?? false, - onChanged: (bool? selected) { - if (rxBool != null) { - rxBool.value = selected ?? false; - controller.updateSelectedEmployees(); - } - }, - fillColor: - WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return const Color.fromARGB(255, 95, 132, 255); - } - return Colors.transparent; - }), - checkColor: Colors.white, - side: const BorderSide(color: Colors.black), + Checkbox( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), ), + value: rxBool?.value ?? false, + onChanged: (bool? selected) { + if (rxBool != null) { + rxBool.value = selected ?? false; + controller.updateSelectedEmployees(); + } + }, + fillColor: + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const Color.fromARGB(255, 95, 132, 255); + } + return Colors.transparent; + }), + checkColor: Colors.white, + side: const BorderSide(color: Colors.black), ), const SizedBox(width: 8), Expanded( child: Text(employee.name, - style: TextStyle(fontSize: 14))), + style: const TextStyle(fontSize: 14))), ], ), )); @@ -307,6 +216,38 @@ class _AssignTaskBottomSheetState extends State { }); } + Widget _buildSelectedEmployees() { + return Obx(() { + if (controller.selectedEmployees.isEmpty) return Container(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: controller.selectedEmployees.map((e) { + return Obx(() { + final isSelected = + controller.uploadingStates[e.id]?.value ?? false; + if (!isSelected) return Container(); + + return Chip( + label: + Text(e.name, style: const TextStyle(color: Colors.white)), + backgroundColor: const Color.fromARGB(255, 95, 132, 255), + deleteIcon: const Icon(Icons.close, color: Colors.white), + onDeleted: () { + controller.uploadingStates[e.id]?.value = false; + controller.updateSelectedEmployees(); + }, + ); + }); + }).toList(), + ), + ); + }); + } + Widget _buildTextField({ required IconData icon, required String label, @@ -331,13 +272,12 @@ class _AssignTaskBottomSheetState extends State { controller: controller, keyboardType: keyboardType, maxLines: maxLines, - decoration: InputDecoration( - hintText: hintText, - border: const OutlineInputBorder(), + decoration: const InputDecoration( + hintText: '', + border: OutlineInputBorder(), ), - validator: (value) => this - .controller - .formFieldValidator(value, fieldType: validatorType), + validator: (value) => + this.controller.formFieldValidator(value, fieldType: validatorType), ), ], ); diff --git a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index a7f8ee1..72ae8c0 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'dart:io'; +import 'dart:math' as math; +// --- Assumed Imports (ensure these paths are correct in your project) --- import 'package:marco/controller/task_planing/report_task_controller.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_button.dart'; @@ -8,17 +12,32 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; -import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; -import 'dart:io'; import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +// --- Form Field Keys (Unchanged) --- +class _FormFieldKeys { + static const String assignedDate = 'assigned_date'; + static const String assignedBy = 'assigned_by'; + static const String workArea = 'work_area'; + static const String activity = 'activity'; + static const String plannedWork = 'planned_work'; + static const String completedWork = 'completed_work'; + static const String teamMembers = 'team_members'; + static const String assigned = 'assigned'; + static const String taskId = 'task_id'; + static const String comment = 'comment'; +} + +// --- Main Widget: CommentTaskBottomSheet --- class CommentTaskBottomSheet extends StatefulWidget { final Map taskData; final VoidCallback? onCommentSuccess; final String taskDataId; final String workAreaId; final String activityId; + const CommentTaskBottomSheet({ super.key, required this.taskData, @@ -39,413 +58,215 @@ class _Member { class _CommentTaskBottomSheetState extends State with UIMixin { - late ReportTaskController controller; - final ScrollController _scrollController = ScrollController(); + late final ReportTaskController controller; + List> _sortedComments = []; + @override void initState() { super.initState(); controller = Get.put(ReportTaskController(), tag: widget.taskData['taskId'] ?? UniqueKey().toString()); - final data = widget.taskData; - controller.basicValidator.getController('assigned_date')?.text = - data['assignedOn'] ?? ''; - controller.basicValidator.getController('assigned_by')?.text = - data['assignedBy'] ?? ''; - controller.basicValidator.getController('work_area')?.text = - data['location'] ?? ''; - controller.basicValidator.getController('activity')?.text = - data['activity'] ?? ''; - controller.basicValidator.getController('planned_work')?.text = - data['plannedWork'] ?? ''; - controller.basicValidator.getController('completed_work')?.text = - data['completedWork'] ?? ''; - controller.basicValidator.getController('team_members')?.text = - (data['teamMembers'] as List).join(', '); - controller.basicValidator.getController('assigned')?.text = - data['assigned'] ?? ''; - controller.basicValidator.getController('task_id')?.text = - data['taskId'] ?? ''; - controller.basicValidator.getController('comment')?.clear(); - controller.selectedImages.clear(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } + _initializeControllerData(); + + final comments = List>.from( + widget.taskData['taskComments'] as List? ?? []); + comments.sort((a, b) { + final aDate = DateTime.tryParse(a['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + final bDate = DateTime.tryParse(b['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + return bDate.compareTo(aDate); // Newest first }); + _sortedComments = comments; } - String timeAgo(String dateString) { + void _initializeControllerData() { + final data = widget.taskData; + + final fieldMappings = { + _FormFieldKeys.assignedDate: data['assignedOn'], + _FormFieldKeys.assignedBy: data['assignedBy'], + _FormFieldKeys.workArea: data['location'], + _FormFieldKeys.activity: data['activity'], + _FormFieldKeys.plannedWork: data['plannedWork'], + _FormFieldKeys.completedWork: data['completedWork'], + _FormFieldKeys.teamMembers: (data['teamMembers'] as List?)?.join(', '), + _FormFieldKeys.assigned: data['assigned'], + _FormFieldKeys.taskId: data['taskId'], + }; + + for (final entry in fieldMappings.entries) { + controller.basicValidator.getController(entry.key)?.text = + entry.value ?? ''; + } + + controller.basicValidator.getController(_FormFieldKeys.comment)?.clear(); + controller.selectedImages.clear(); + } + + String _timeAgo(String dateString) { + // This logic remains unchanged try { - DateTime date = DateTime.parse(dateString + "Z").toLocal(); - final now = DateTime.now(); - final difference = now.difference(date); - if (difference.inDays > 8) { - return DateFormat('dd-MM-yyyy').format(date); - } else if (difference.inDays >= 1) { + final date = DateTime.parse(dateString + "Z").toLocal(); + final difference = DateTime.now().difference(date); + + if (difference.inDays > 8) return DateFormat('dd-MM-yyyy').format(date); + if (difference.inDays >= 1) return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; - } else if (difference.inHours >= 1) { + if (difference.inHours >= 1) return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; - } else if (difference.inMinutes >= 1) { + if (difference.inMinutes >= 1) return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; - } else { - return 'just now'; - } + return 'just now'; } catch (e) { - print('Error parsing date: $e'); - return ''; + debugPrint('Error parsing date for timeAgo: $e'); + return dateString; } } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - left: 24, - right: 24, - top: 12, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag handle - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), + // --- REFACTORING POINT --- + // The entire widget now returns a BaseBottomSheet, passing the content as its child. + // The GetBuilder provides reactive state (like isLoading) to the BaseBottomSheet. + return GetBuilder( + tag: widget.taskData['taskId'] ?? '', + builder: (controller) { + return BaseBottomSheet( + title: "Task Details & Comments", + onCancel: () => Navigator.of(context).pop(), + onSubmit: _submitComment, + isSubmitting: controller.isLoading.value, + bottomContent: _buildCommentsSection(), + child: Form( + // moved to last + key: controller.basicValidator.formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeaderActions(), + MySpacing.height(12), + _buildTaskDetails(), + _buildReportedImages(), + _buildCommentInput(), + _buildImagePicker(), + ], ), - GetBuilder( - tag: widget.taskData['taskId'] ?? '', - builder: (controller) { - return Form( - key: controller.basicValidator.formKey, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MySpacing.height(12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MyText.titleMedium( - "Comment Task", - fontWeight: 600, - fontSize: 18, - ), - ], - ), - const SizedBox(height: 12), + ), + ); + }, + ); + } - // Second row: Right-aligned "+ Create Task" button - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - InkWell( - onTap: () { - showCreateTaskBottomSheet( - workArea: - widget.taskData['location'] ?? '', - activity: - widget.taskData['activity'] ?? '', - completedWork: - widget.taskData['completedWork'] ?? - '', - unit: widget.taskData['unit'] ?? '', - onCategoryChanged: (category) { - debugPrint( - "Category changed to: $category"); - }, - parentTaskId: widget.taskDataId, - plannedTask: int.tryParse( - widget.taskData['plannedWork'] ?? - '0') ?? - 0, - activityId: widget.activityId, - workAreaId: widget.workAreaId, - onSubmit: () { - Navigator.of(context).pop(); - }, - ); - }, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.blueAccent.withOpacity(0.1), - borderRadius: BorderRadius.circular(16), - ), - child: MyText.bodySmall( - "+ Create Task", - fontWeight: 600, - color: Colors.blueAccent, - ), - ), - ), - ], - ), - ], - ), - buildRow( - "Assigned By", - controller.basicValidator - .getController('assigned_by') - ?.text - .trim(), - icon: Icons.person_outline, - ), - buildRow( - "Work Area", - controller.basicValidator - .getController('work_area') - ?.text - .trim(), - icon: Icons.place_outlined, - ), - buildRow( - "Activity", - controller.basicValidator - .getController('activity') - ?.text - .trim(), - icon: Icons.assignment_outlined, - ), - buildRow( - "Planned Work", - controller.basicValidator - .getController('planned_work') - ?.text - .trim(), - icon: Icons.schedule_outlined, - ), - buildRow( - "Completed Work", - controller.basicValidator - .getController('completed_work') - ?.text - .trim(), - icon: Icons.done_all_outlined, - ), - buildTeamMembers(), - if ((widget.taskData['reportedPreSignedUrls'] - as List?) - ?.isNotEmpty == - true) - buildReportedImagesSection( - imageUrls: List.from( - widget.taskData['reportedPreSignedUrls'] ?? []), - context: context, - ), - Row( - children: [ - Icon(Icons.comment_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comment:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: controller.basicValidator - .getValidation('comment'), - controller: controller.basicValidator - .getController('comment'), - keyboardType: TextInputType.text, - decoration: InputDecoration( - hintText: "eg: Work done successfully", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.camera_alt_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall("Attach Photos:", - fontWeight: 600), - MySpacing.height(12), - ], - ), - ), - ], - ), - Obx(() { - final images = controller.selectedImages; - - return buildImagePickerSection( - images: images, - onCameraTap: () => - controller.pickImages(fromCamera: true), - onUploadTap: () => - controller.pickImages(fromCamera: false), - onRemoveImage: (index) => - controller.removeImageAt(index), - onPreviewImage: (index) { - showDialog( - context: context, - builder: (_) => ImageViewerDialog( - imageSources: images, - initialIndex: index, - ), - ); - }, - ); - }), - MySpacing.height(24), - buildCommentActionButtons( - onCancel: () => Navigator.of(context).pop(), - onSubmit: () async { - if (controller.basicValidator.validateForm()) { - await controller.commentTask( - projectId: controller.basicValidator - .getController('task_id') - ?.text ?? - '', - comment: controller.basicValidator - .getController('comment') - ?.text ?? - '', - images: controller.selectedImages, - ); - if (widget.onCommentSuccess != null) { - widget.onCommentSuccess!(); - } - } - }, - isLoading: controller.isLoading, - ), - MySpacing.height(10), - if ((widget.taskData['taskComments'] as List?) - ?.isNotEmpty == - true) ...[ - Row( - children: [ - MySpacing.width(10), - Icon(Icons.chat_bubble_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comments", - fontWeight: 600, - ), - ], - ), - MySpacing.height(12), - Builder( - builder: (context) { - final comments = List>.from( - widget.taskData['taskComments'] as List, - ); - return buildCommentList(comments, context); - }, - ) - ], - ], - ), - ), - ); - }, - ), - ], + // --- REFACTORING POINT --- + // The original _buildHeader is now split. The title is handled by BaseBottomSheet. + // This new widget contains the remaining actions from the header. + Widget _buildHeaderActions() { + return Align( + alignment: Alignment.centerRight, + child: InkWell( + onTap: () => _showCreateTaskBottomSheet(), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.blueAccent.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: MyText.bodySmall( + "+ Create Task", + fontWeight: 600, + color: Colors.blueAccent, + ), ), ), ); } - Widget buildReportedImagesSection({ - required List imageUrls, - required BuildContext context, - String title = "Reported Images", - }) { - if (imageUrls.isEmpty) return const SizedBox(); + Widget _buildTaskDetails() { + return Column( + children: [ + _buildDetailRow( + "Assigned By", + controller.basicValidator + .getController(_FormFieldKeys.assignedBy) + ?.text, + icon: Icons.person_outline), + _buildDetailRow( + "Work Area", + controller.basicValidator + .getController(_FormFieldKeys.workArea) + ?.text, + icon: Icons.place_outlined), + _buildDetailRow( + "Activity", + controller.basicValidator + .getController(_FormFieldKeys.activity) + ?.text, + icon: Icons.assignment_outlined), + _buildDetailRow( + "Planned Work", + controller.basicValidator + .getController(_FormFieldKeys.plannedWork) + ?.text, + icon: Icons.schedule_outlined), + _buildDetailRow( + "Completed Work", + controller.basicValidator + .getController(_FormFieldKeys.completedWork) + ?.text, + icon: Icons.done_all_outlined), + _buildTeamMembers(), + ], + ); + } + + Widget _buildReportedImages() { + final imageUrls = + List.from(widget.taskData['reportedPreSignedUrls'] ?? []); + if (imageUrls.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MySpacing.height(8), Padding( - padding: const EdgeInsets.symmetric(horizontal: 0.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - title, - fontWeight: 600, - ), - ], - ), + padding: const EdgeInsets.only(bottom: 8.0), + child: _buildSectionHeader("Reported Images", Icons.image_outlined), ), + // --- Refactoring Note --- + // Using the reusable _ImageHorizontalListView widget. + _ImageHorizontalListView( + imageSources: imageUrls, + onPreview: (index) => _showImageViewer(imageUrls, index), + ), + MySpacing.height(16), + ], + ); + } + + Widget _buildCommentInput() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Add Comment", Icons.comment_outlined), MySpacing.height(8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final url = imageUrls[index]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: index, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - url, - width: 70, - height: 70, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 70, - height: 70, - color: Colors.grey.shade200, - child: - Icon(Icons.broken_image, color: Colors.grey[600]), - ), - ), - ), - ); - }, - ), + TextFormField( + validator: + controller.basicValidator.getValidation(_FormFieldKeys.comment), + controller: + controller.basicValidator.getController(_FormFieldKeys.comment), + keyboardType: TextInputType.multiline, + maxLines: null, // Allows for multiline input + decoration: InputDecoration( + hintText: "eg: Work done successfully", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, ), ), MySpacing.height(16), @@ -453,60 +274,183 @@ class _CommentTaskBottomSheetState extends State ); } - Widget buildTeamMembers() { - final teamMembersText = - controller.basicValidator.getController('team_members')?.text ?? ''; + Widget _buildImagePicker() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Attach Photos", Icons.camera_alt_outlined), + MySpacing.height(12), + Obx(() { + final images = controller.selectedImages; + return Column( + children: [ + // --- Refactoring Note --- + // Using the reusable _ImageHorizontalListView for picked images. + _ImageHorizontalListView( + imageSources: images.toList(), + onPreview: (index) => _showImageViewer(images.toList(), index), + onRemove: (index) => controller.removeImageAt(index), + emptyStatePlaceholder: Container( + height: 70, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300, width: 1.5), + color: Colors.grey.shade100, + ), + child: Center( + child: Icon(Icons.photo_library_outlined, + size: 36, color: Colors.grey.shade400), + ), + ), + ), + MySpacing.height(16), + Row( + children: [ + _buildPickerButton( + onTap: () => controller.pickImages(fromCamera: true), + icon: Icons.camera_alt, + label: 'Capture', + ), + MySpacing.width(12), + _buildPickerButton( + onTap: () => controller.pickImages(fromCamera: false), + icon: Icons.upload_file, + label: 'Upload', + ), + ], + ), + ], + ); + }), + ], + ); + } + + Widget _buildCommentsSection() { + if (_sortedComments.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(24), + _buildSectionHeader("Comments", Icons.chat_bubble_outline), + MySpacing.height(12), + // --- Refactoring Note --- + // Using a ListView instead of a fixed-height SizedBox for better responsiveness. + // It's constrained by the parent SingleChildScrollView. + ListView.builder( + shrinkWrap: + true, // Important for ListView inside SingleChildScrollView + physics: + const NeverScrollableScrollPhysics(), // Parent handles scrolling + itemCount: _sortedComments.length, + itemBuilder: (context, index) { + final comment = _sortedComments[index]; + // --- Refactoring Note --- + // Extracted the comment item into its own widget for clarity. + return _CommentCard( + comment: comment, + timeAgo: _timeAgo(comment['date'] ?? ''), + onPreviewImage: (imageUrls, idx) => + _showImageViewer(imageUrls, idx), + ); + }, + ), + ], + ); + } + + // --- Helper and Builder methods --- + + Widget _buildDetailRow(String label, String? value, + {required IconData icon}) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2), + child: Icon(icon, size: 18, color: Colors.grey[700]), + ), + MyText.titleSmall("$label:", fontWeight: 600), + MySpacing.width(12), + Expanded( + child: MyText.bodyMedium( + value != null && value.isNotEmpty ? value : "-", + color: Colors.black87, + ), + ), + ], + ), + ); + } + + Widget _buildSectionHeader(String title, IconData icon) { + return Row( + children: [ + Icon(icon, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall(title, fontWeight: 600), + ], + ); + } + + Widget _buildTeamMembers() { + final teamMembersText = controller.basicValidator + .getController(_FormFieldKeys.teamMembers) + ?.text ?? + ''; final members = teamMembersText .split(',') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); + if (members.isEmpty) return const SizedBox.shrink(); + + const double avatarSize = 32.0; + const double avatarOverlap = 22.0; return Padding( - padding: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.only(bottom: 16.0), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleSmall( - "Team Members:", - fontWeight: 600, - ), + Icon(Icons.group_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Team:", fontWeight: 600), MySpacing.width(12), GestureDetector( - onTap: () { - TeamBottomSheet.show( + onTap: () => TeamBottomSheet.show( context: context, - teamMembers: members.map((name) => _Member(name)).toList(), - ); - }, + teamMembers: members.map((name) => _Member(name)).toList()), child: SizedBox( - height: 32, - width: 100, + height: avatarSize, + // Calculate width based on number of avatars shown + width: (math.min(members.length, 3) * avatarOverlap) + + (avatarSize - avatarOverlap), child: Stack( children: [ - for (int i = 0; i < members.length.clamp(0, 3); i++) - Positioned( - left: i * 24.0, + ...List.generate(math.min(members.length, 3), (i) { + return Positioned( + left: i * avatarOverlap, child: Tooltip( message: members[i], child: Avatar( - firstName: members[i], - lastName: '', - size: 32, - ), + firstName: members[i], + lastName: '', + size: avatarSize), ), - ), + ); + }), if (members.length > 3) Positioned( - left: 2 * 24.0, + left: 3 * avatarOverlap, child: CircleAvatar( - radius: 16, + radius: avatarSize / 2, backgroundColor: Colors.grey.shade300, - child: MyText.bodyMedium( - '+${members.length - 3}', - style: const TextStyle( - fontSize: 12, color: Colors.black87), - ), + child: MyText.bodySmall('+${members.length - 3}', + fontWeight: 600), ), ), ], @@ -518,246 +462,144 @@ class _CommentTaskBottomSheetState extends State ); } - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, -}) { - return Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red, size: 18), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), + Widget _buildPickerButton( + {required VoidCallback onTap, + required IconData icon, + required String label}) { + return Expanded( + child: MyButton.outlined( + onPressed: onTap, + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: Colors.blueAccent), + MySpacing.width(8), + MyText.bodySmall(label, color: Colors.blueAccent, fontWeight: 600), + ], ), ), - const SizedBox(width: 16), - Expanded( - child: Obx(() { - return ElevatedButton.icon( - onPressed: isLoading.value ? null : () => onSubmit(), - icon: isLoading.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.check_circle_outline, color: Colors.white, size: 18), - label: isLoading.value - ? const SizedBox() - : MyText.bodyMedium("Comment", color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ); - }), - ), - ], - ); -} - - Widget buildRow(String label, String? value, {IconData? icon}) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (icon != null) - Padding( - padding: const EdgeInsets.only(right: 8.0, top: 2), - child: Icon(icon, size: 18, color: Colors.grey[700]), - ), - MyText.titleSmall( - "$label:", - fontWeight: 600, - ), - MySpacing.width(12), - Expanded( - child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), - ), - ], - ), ); } - Widget buildCommentList( - List> comments, BuildContext context) { - comments.sort((a, b) { - final aDate = DateTime.tryParse(a['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - final bDate = DateTime.tryParse(b['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - return bDate.compareTo(aDate); // newest first - }); + // --- Action Handlers --- + + void _showCreateTaskBottomSheet() { + showCreateTaskBottomSheet( + workArea: widget.taskData['location'] ?? '', + activity: widget.taskData['activity'] ?? '', + completedWork: widget.taskData['completedWork'] ?? '', + unit: widget.taskData['unit'] ?? '', + onCategoryChanged: (category) => + debugPrint("Category changed to: $category"), + parentTaskId: widget.taskDataId, + plannedTask: int.tryParse(widget.taskData['plannedWork'] ?? '0') ?? 0, + activityId: widget.activityId, + workAreaId: widget.workAreaId, + onSubmit: () => Navigator.of(context).pop(), + ); + } + + void _showImageViewer(List sources, int initialIndex) { + showDialog( + context: context, + barrierColor: Colors.black87, + builder: (_) => ImageViewerDialog( + imageSources: sources, + initialIndex: initialIndex, + ), + ); + } + + Future _submitComment() async { + if (controller.basicValidator.validateForm()) { + await controller.commentTask( + projectId: controller.basicValidator + .getController(_FormFieldKeys.taskId) + ?.text ?? + '', + comment: controller.basicValidator + .getController(_FormFieldKeys.comment) + ?.text ?? + '', + images: controller.selectedImages, + ); + // Callback to the parent widget to refresh data if needed + widget.onCommentSuccess?.call(); + } + } +} + +// --- Refactoring Note --- +// A reusable widget for displaying a horizontal list of images. +// It can handle both network URLs (String) and local files (File). +class _ImageHorizontalListView extends StatelessWidget { + final List imageSources; // Can be List or List + final Function(int) onPreview; + final Function(int)? onRemove; + final Widget? emptyStatePlaceholder; + + const _ImageHorizontalListView({ + required this.imageSources, + required this.onPreview, + this.onRemove, + this.emptyStatePlaceholder, + }); + + @override + Widget build(BuildContext context) { + if (imageSources.isEmpty) { + return emptyStatePlaceholder ?? const SizedBox.shrink(); + } return SizedBox( - height: 300, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: comments.length, + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageSources.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), itemBuilder: (context, index) { - final comment = comments[index]; - final commentText = comment['text'] ?? '-'; - final commentedBy = comment['commentedBy'] ?? 'Unknown'; - final relativeTime = timeAgo(comment['date'] ?? ''); - final imageUrls = List.from(comment['preSignedUrls'] ?? []); - - return Container( - margin: const EdgeInsets.symmetric(vertical: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + final source = imageSources[index]; + return GestureDetector( + onTap: () => onPreview(index), + child: Stack( + clipBehavior: Clip.none, children: [ - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - firstName: commentedBy.split(' ').first, - lastName: commentedBy.split(' ').length > 1 - ? commentedBy.split(' ').last - : '', - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium( - commentedBy, - fontWeight: 700, - color: Colors.black87, - ), - MyText.bodySmall( - relativeTime, - fontSize: 12, - color: Colors.black54, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: MyText.bodyMedium( - commentText, - fontWeight: 500, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 12), - if (imageUrls.isNotEmpty) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.attach_file_outlined, - size: 18, color: Colors.grey[700]), - MyText.bodyMedium( - 'Attachments', - fontWeight: 600, - color: Colors.black87, - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: 60, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - itemBuilder: (context, imageIndex) { - final imageUrl = imageUrls[imageIndex]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: imageIndex, - ), - ); - }, - child: Stack( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[100], - boxShadow: [ - BoxShadow( - color: Colors.black26, - blurRadius: 6, - offset: Offset(2, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - Container( - color: Colors.grey[300], - child: Icon(Icons.broken_image, - color: Colors.grey[700]), - ), - ), - ), - ), - const Positioned( - right: 4, - bottom: 4, - child: Icon(Icons.zoom_in, - color: Colors.white70, size: 16), - ), - ], - ), - ); - }, - separatorBuilder: (_, __) => - const SizedBox(width: 12), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: source is File + ? Image.file(source, + width: 70, height: 70, fit: BoxFit.cover) + : Image.network( + source as String, + width: 70, + height: 70, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + Container( + width: 70, + height: 70, + color: Colors.grey.shade200, + child: Icon(Icons.broken_image, + color: Colors.grey[600]), ), ), - const SizedBox(height: 12), - ], - ], - ), ), + if (onRemove != null) + Positioned( + top: -6, + right: -6, + child: GestureDetector( + onTap: () => onRemove!(index), + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: Colors.red, shape: BoxShape.circle), + child: const Icon(Icons.close, + size: 16, color: Colors.white), + ), + ), + ), ], ), ); @@ -765,111 +607,72 @@ class _CommentTaskBottomSheetState extends State ), ); } +} - Widget buildImagePickerSection({ - required List images, - required VoidCallback onCameraTap, - required VoidCallback onUploadTap, - required void Function(int index) onRemoveImage, - required void Function(int initialIndex) onPreviewImage, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (images.isEmpty) - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300, width: 2), - color: Colors.grey.shade100, - ), - child: Center( - child: Icon(Icons.photo_camera_outlined, - size: 48, color: Colors.grey.shade400), - ), - ) - else - SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( +// --- Refactoring Note --- +// A dedicated widget for a single comment card. This cleans up the main +// widget's build method and makes the comment layout easier to manage. +class _CommentCard extends StatelessWidget { + final Map comment; + final String timeAgo; + final Function(List imageUrls, int index) onPreviewImage; + + const _CommentCard({ + required this.comment, + required this.timeAgo, + required this.onPreviewImage, + }); + + @override + Widget build(BuildContext context) { + final commentedBy = comment['commentedBy'] ?? 'Unknown'; + final commentText = comment['text'] ?? '-'; + final imageUrls = List.from(comment['preSignedUrls'] ?? []); + + return Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: commentedBy.split(' ').first, + lastName: commentedBy.split(' ').length > 1 + ? commentedBy.split(' ').last + : '', + size: 32, + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: () => onPreviewImage(index), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - file, - height: 70, - width: 70, - fit: BoxFit.cover, - ), - ), - ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => onRemoveImage(index), - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon(Icons.close, - size: 20, color: Colors.white), - ), - ), - ), + MyText.bodyMedium(commentedBy, + fontWeight: 700, color: Colors.black87), + MyText.bodySmall(timeAgo, + color: Colors.black54, fontSize: 12), ], - ); - }, - ), + ), + ), + ], ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: MyButton.outlined( - onPressed: onCameraTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.camera_alt, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Capture', color: Colors.blueAccent), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: MyButton.outlined( - onPressed: onUploadTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.upload_file, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Upload', color: Colors.blueAccent), - ], - ), - ), + MySpacing.height(12), + MyText.bodyMedium(commentText, color: Colors.black87), + if (imageUrls.isNotEmpty) ...[ + MySpacing.height(12), + _ImageHorizontalListView( + imageSources: imageUrls, + onPreview: (index) => onPreviewImage(imageUrls, index), ), ], - ), - ], + ], + ), ); } } diff --git a/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart b/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart index 650087e..4c959de 100644 --- a/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart +++ b/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart @@ -4,8 +4,7 @@ import 'package:marco/controller/task_planing/add_task_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; - - +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; void showCreateTaskBottomSheet({ required String workArea, @@ -27,197 +26,120 @@ void showCreateTaskBottomSheet({ Get.bottomSheet( StatefulBuilder( builder: (context, setState) { - return LayoutBuilder( - builder: (context, constraints) { - final isLarge = constraints.maxWidth > 600; - final horizontalPadding = - isLarge ? constraints.maxWidth * 0.2 : 16.0; + return BaseBottomSheet( + title: "Create Task", + onCancel: () => Get.back(), + onSubmit: () async { + final plannedValue = + int.tryParse(plannedTaskController.text.trim()) ?? 0; + final comment = descriptionController.text.trim(); + final selectedCategoryId = controller.selectedCategoryId.value; - return // Inside showManageTaskBottomSheet... + if (selectedCategoryId == null) { + showAppSnackbar( + title: "error", + message: "Please select a work category!", + type: SnackbarType.error, + ); + return; + } - SafeArea( - child: Material( - color: Colors.white, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - child: Container( - constraints: const BoxConstraints(maxHeight: 760), - padding: EdgeInsets.fromLTRB( - horizontalPadding, 12, horizontalPadding, 24), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final success = await controller.createTask( + parentTaskId: parentTaskId, + plannedTask: plannedValue, + comment: comment, + workAreaId: workAreaId, + activityId: activityId, + categoryId: selectedCategoryId, + ); + + if (success) { + Get.back(); + Future.delayed(const Duration(milliseconds: 300), () { + onSubmit(); + showAppSnackbar( + title: "Success", + message: "Task created successfully!", + type: SnackbarType.success, + ); + }); + } + }, + submitText: "Submit", + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _infoCardSection([ + _infoRowWithIcon( + Icons.workspaces, "Selected Work Area", workArea), + _infoRowWithIcon(Icons.list_alt, "Selected Activity", activity), + _infoRowWithIcon(Icons.check_circle_outline, "Completed Work", + completedWork), + ]), + const SizedBox(height: 16), + _sectionTitle(Icons.edit_calendar, "Planned Work"), + const SizedBox(height: 6), + _customTextField( + controller: plannedTaskController, + hint: "Enter planned work", + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + _sectionTitle(Icons.description_outlined, "Comment"), + const SizedBox(height: 6), + _customTextField( + controller: descriptionController, + hint: "Enter task description", + maxLines: 3, + ), + const SizedBox(height: 16), + _sectionTitle(Icons.category_outlined, "Selected Work Category"), + const SizedBox(height: 6), + Obx(() { + final categoryMap = controller.categoryIdNameMap; + final String selectedName = + controller.selectedCategoryId.value != null + ? (categoryMap[controller.selectedCategoryId.value!] ?? + 'Select Category') + : 'Select Category'; + + return Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + onSelected: (val) { + controller.selectCategory(val); + onCategoryChanged(val); + }, + itemBuilder: (context) => categoryMap.entries + .map((entry) => PopupMenuItem( + value: entry.key, + child: Text(entry.value), + )) + .toList(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Center( - child: MyText.titleLarge( - "Create Task", - fontWeight: 700, - ), - ), - const SizedBox(height: 20), - _infoCardSection([ - _infoRowWithIcon( - Icons.workspaces, "Selected Work Area", workArea), - _infoRowWithIcon( - Icons.list_alt, "Selected Activity", activity), - _infoRowWithIcon(Icons.check_circle_outline, - "Completed Work", completedWork), - ]), - const SizedBox(height: 16), - _sectionTitle(Icons.edit_calendar, "Planned Work"), - const SizedBox(height: 6), - _customTextField( - controller: plannedTaskController, - hint: "Enter planned work", - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - _sectionTitle(Icons.description_outlined, "Comment"), - const SizedBox(height: 6), - _customTextField( - controller: descriptionController, - hint: "Enter task description", - maxLines: 3, - ), - const SizedBox(height: 16), - _sectionTitle( - Icons.category_outlined, "Selected Work Category"), - const SizedBox(height: 6), - Obx(() { - final categoryMap = controller.categoryIdNameMap; - final String selectedName = - controller.selectedCategoryId.value != null - ? (categoryMap[controller - .selectedCategoryId.value!] ?? - 'Select Category') - : 'Select Category'; - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: Colors.grey.shade100, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: PopupMenuButton( - padding: EdgeInsets.zero, - onSelected: (val) { - controller.selectCategory(val); - onCategoryChanged(val); - }, - itemBuilder: (context) => categoryMap.entries - .map( - (entry) => PopupMenuItem( - value: entry.key, - child: Text(entry.value), - ), - ) - .toList(), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - selectedName, - style: const TextStyle( - fontSize: 14, color: Colors.black87), - ), - const Icon(Icons.arrow_drop_down), - ], - ), - ), - ); - }), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, size: 18), - label: MyText.bodyMedium("Cancel", - fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.grey), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - final plannedValue = int.tryParse( - plannedTaskController.text.trim()) ?? - 0; - final comment = - descriptionController.text.trim(); - final selectedCategoryId = - controller.selectedCategoryId.value; - if (selectedCategoryId == null) { - showAppSnackbar( - title: "error", - message: "Please select a work category!", - type: SnackbarType.error, - ); - return; - } - - final success = await controller.createTask( - parentTaskId: parentTaskId, - plannedTask: plannedValue, - comment: comment, - workAreaId: workAreaId, - activityId: activityId, - categoryId: selectedCategoryId, - ); - - if (success) { - Get.back(); - Future.delayed( - const Duration(milliseconds: 300), () { - onSubmit(); - showAppSnackbar( - title: "Success", - message: "Task created successfully!", - type: SnackbarType.success, - ); - }); - } - }, - icon: const Icon(Icons.check, size: 18), - label: MyText.bodyMedium("Submit", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ), - ], + Text( + selectedName, + style: const TextStyle( + fontSize: 14, color: Colors.black87), ), + const Icon(Icons.arrow_drop_down), ], ), ), - ), - ), - ); - }, + ); + }), + ], + ), ); }, ), diff --git a/lib/model/dailyTaskPlaning/daily_progress_report_filter.dart b/lib/model/dailyTaskPlaning/daily_progress_report_filter.dart index 11b41c3..586a0d2 100644 --- a/lib/model/dailyTaskPlaning/daily_progress_report_filter.dart +++ b/lib/model/dailyTaskPlaning/daily_progress_report_filter.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:marco/controller/permission_controller.dart'; -import 'package:marco/controller/dashboard/daily_task_controller.dart'; import 'package:intl/intl.dart'; +import 'package:marco/controller/dashboard/daily_task_controller.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -class DailyProgressReportFilter extends StatefulWidget { +class DailyProgressReportFilter extends StatelessWidget { final DailyTaskController controller; final PermissionController permissionController; @@ -14,20 +15,9 @@ class DailyProgressReportFilter extends StatefulWidget { required this.permissionController, }); - @override - State createState() => - _DailyProgressReportFilterState(); -} - -class _DailyProgressReportFilterState extends State { - @override - void initState() { - super.initState(); - } - String getLabelText() { - final startDate = widget.controller.startDateTask; - final endDate = widget.controller.endDateTask; + final startDate = controller.startDateTask; + final endDate = controller.endDateTask; if (startDate != null && endDate != null) { final start = DateFormat('dd MM yyyy').format(startDate); final end = DateFormat('dd MM yyyy').format(endDate); @@ -38,105 +28,55 @@ class _DailyProgressReportFilterState extends State { @override Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[400], - borderRadius: BorderRadius.circular(4), - ), - ), - ), + return BaseBottomSheet( + title: "Filter Tasks", + onCancel: () => Navigator.pop(context), + + onSubmit: () { + Navigator.pop(context, { + 'startDate': controller.startDateTask, + 'endDate': controller.endDateTask, + }); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall("Select Date Range", fontWeight: 600), + const SizedBox(height: 8), + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => controller.selectDateRangeForTaskData( + context, + controller, + ), + child: Ink( + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(10), ), - const Divider(), - Padding( - padding: EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Align( - alignment: Alignment.centerLeft, - child: MyText.titleSmall( - "Select Date Range", - fontWeight: 600, - ), - ), - ), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: () => widget.controller.selectDateRangeForTaskData( - context, - widget.controller, - ), - child: Ink( - decoration: BoxDecoration( - color: Colors.grey.shade100, - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 14), - child: Row( - children: [ - Icon(Icons.date_range, color: Colors.blue.shade600), - const SizedBox(width: 12), - Expanded( - child: Text( - getLabelText(), - style: const TextStyle( - fontSize: 16, - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const Icon(Icons.arrow_drop_down, color: Colors.grey), - ], - ), - ), - ), - ), - const Divider(), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(Icons.date_range, color: Colors.blue.shade600), + const SizedBox(width: 12), + Expanded( + child: Text( + getLabelText(), + style: const TextStyle( + fontSize: 16, + color: Colors.black87, + fontWeight: FontWeight.w500, ), + overflow: TextOverflow.ellipsis, ), - child: const Text('Apply Filter'), - onPressed: () { - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.pop(context, { - 'startDate': widget.controller.startDateTask, - 'endDate': widget.controller.endDateTask, - }); - }); - }, ), - ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], ), - ], + ), ), - ), + ], ), ); } diff --git a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart index c6fd28c..84974ce 100644 --- a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -1,18 +1,16 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/report_task_action_controller.dart'; -import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; -import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; -import 'dart:io'; +import 'package:marco/model/dailyTaskPlaning/report_action_widgets.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class ReportActionBottomSheet extends StatefulWidget { final Map taskData; @@ -46,8 +44,6 @@ class _ReportActionBottomSheetState extends State with UIMixin { late ReportTaskActionController controller; - final ScrollController _scrollController = ScrollController(); - String selectedAction = 'Select Action'; @override void initState() { super.initState(); @@ -56,9 +52,9 @@ class _ReportActionBottomSheetState extends State tag: widget.taskData['taskId'] ?? '', ); controller.fetchWorkStatuses(); - controller.basicValidator.getController('approved_task')?.text = - widget.taskData['approvedTask']?.toString() ?? ''; final data = widget.taskData; + controller.basicValidator.getController('approved_task')?.text = + data['approvedTask']?.toString() ?? ''; controller.basicValidator.getController('assigned_date')?.text = data['assignedOn'] ?? ''; controller.basicValidator.getController('assigned_by')?.text = @@ -75,540 +71,349 @@ class _ReportActionBottomSheetState extends State (data['teamMembers'] as List).join(', '); controller.basicValidator.getController('assigned')?.text = data['assigned'] ?? ''; - controller.basicValidator.getController('task_id')?.text = - data['taskId'] ?? ''; - controller.basicValidator.getController('comment')?.clear(); controller.basicValidator.getController('task_id')?.text = widget.taskDataId; - + controller.basicValidator.getController('comment')?.clear(); controller.selectedImages.clear(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } - }); - } - - String timeAgo(String dateString) { - try { - DateTime date = DateTime.parse(dateString + "Z").toLocal(); - final now = DateTime.now(); - final difference = now.difference(date); - if (difference.inDays > 8) { - return DateFormat('dd-MM-yyyy').format(date); - } else if (difference.inDays >= 1) { - return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; - } else if (difference.inHours >= 1) { - return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; - } else if (difference.inMinutes >= 1) { - return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; - } else { - return 'just now'; - } - } catch (e) { - print('Error parsing date: $e'); - return ''; - } } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - left: 24, - right: 24, - top: 12, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag handle - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), - ), - GetBuilder( - tag: widget.taskData['taskId'] ?? '', - builder: (controller) { - return Form( - key: controller.basicValidator.formKey, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4.0, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MyText.titleMedium( - "Take Report Action", - fontWeight: 600, - fontSize: 18, - ), - ], - ), - MySpacing.height(24), - buildRow( - "Assigned By", - controller.basicValidator - .getController('assigned_by') - ?.text - .trim(), - icon: Icons.person_outline, - ), - buildRow( - "Work Area", - controller.basicValidator - .getController('work_area') - ?.text - .trim(), - icon: Icons.place_outlined, - ), - buildRow( - "Activity", - controller.basicValidator - .getController('activity') - ?.text - .trim(), - icon: Icons.assignment_outlined, - ), - buildRow( - "Planned Work", - controller.basicValidator - .getController('planned_work') - ?.text - .trim(), - icon: Icons.schedule_outlined, - ), - buildRow( - "Completed Work", - controller.basicValidator - .getController('completed_work') - ?.text - .trim(), - icon: Icons.done_all_outlined, - ), - buildTeamMembers(), - MySpacing.height(8), - Row( - children: [ - Icon(Icons.check_circle_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Approved Task:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(10), - TextFormField( - controller: controller.basicValidator - .getController('approved_task'), - keyboardType: TextInputType.number, - validator: (value) { - if (value == null || value.isEmpty) - return 'Required'; - if (int.tryParse(value) == null) - return 'Must be a number'; - return null; - }, - decoration: InputDecoration( - hintText: "eg: 5", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(10), - - if ((widget.taskData['reportedPreSignedUrls'] - as List?) - ?.isNotEmpty == - true) - buildReportedImagesSection( - imageUrls: List.from( - widget.taskData['reportedPreSignedUrls'] ?? []), - context: context, - ), - MySpacing.height(10), - // Add this in your stateful widget - MyText.titleSmall( - "Report Actions", - fontWeight: 600, - ), - MySpacing.height(10), - - Obx(() { - final isLoading = - controller.isLoadingWorkStatus.value; - final workStatuses = controller.workStatus; - - if (isLoading) { - return const Center( - child: CircularProgressIndicator()); - } - - return PopupMenuButton( - onSelected: (String value) { - controller.selectedWorkStatusName.value = value; - controller.showAddTaskCheckbox.value = true; - }, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - itemBuilder: (BuildContext context) { - return workStatuses.map((status) { - final statusName = status.name; - - return PopupMenuItem( - value: statusName, - child: Row( - children: [ - Radio( - value: statusName, - groupValue: controller - .selectedWorkStatusName.value, - onChanged: (_) => - Navigator.pop(context, statusName), - ), - const SizedBox(width: 8), - MyText.bodySmall(statusName), - ], - ), - ); - }).toList(); - }, - child: Container( - padding: MySpacing.xy(16, 12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular( - AppStyle.buttonRadius.medium), - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - MyText.bodySmall( - controller.selectedWorkStatusName.value - .isEmpty - ? "Select Work Status" - : controller - .selectedWorkStatusName.value, - color: Colors.black87, - ), - const Icon(Icons.arrow_drop_down, size: 20), - ], - ), - ), - ); - }), - MySpacing.height(10), - Obx(() { - if (!controller.showAddTaskCheckbox.value) - return SizedBox.shrink(); - - final checkboxTheme = CheckboxThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(2)), - side: WidgetStateBorderSide.resolveWith((states) => - BorderSide( - color: states.contains(WidgetState.selected) - ? Colors.transparent - : Colors.black)), - fillColor: WidgetStateProperty.resolveWith( - (states) => - states.contains(WidgetState.selected) - ? Colors.blueAccent - : Colors.white), - checkColor: - WidgetStateProperty.all(Colors.white), - ); - - return Theme( - data: Theme.of(context) - .copyWith(checkboxTheme: checkboxTheme), - child: CheckboxListTile( - title: MyText.titleSmall( - "Add new task", - fontWeight: 600, - ), - value: controller.isAddTaskChecked.value, - onChanged: (val) => controller - .isAddTaskChecked.value = val ?? false, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - ), - ); - }), - MySpacing.height(10), - Row( - children: [ - Icon(Icons.comment_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comment:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: controller.basicValidator - .getValidation('comment'), - controller: controller.basicValidator - .getController('comment'), - keyboardType: TextInputType.text, - decoration: InputDecoration( - hintText: "eg: Work done successfully", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.camera_alt_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall("Attach Photos:", - fontWeight: 600), - MySpacing.height(12), - ], - ), - ), - ], - ), - Obx(() { - final images = controller.selectedImages; - - return buildImagePickerSection( - images: images, - onCameraTap: () => - controller.pickImages(fromCamera: true), - onUploadTap: () => - controller.pickImages(fromCamera: false), - onRemoveImage: (index) => - controller.removeImageAt(index), - onPreviewImage: (index) { - showDialog( - context: context, - builder: (_) => ImageViewerDialog( - imageSources: images, - initialIndex: index, - ), - ); - }, - ); - }), - MySpacing.height(24), - buildCommentActionButtons( - onCancel: () => Navigator.of(context).pop(), - onSubmit: () async { - if (controller.basicValidator.validateForm()) { - final selectedStatusName = - controller.selectedWorkStatusName.value; - final selectedStatus = - controller.workStatus.firstWhereOrNull( - (status) => status.name == selectedStatusName, - ); - - final reportActionId = - selectedStatus?.id.toString() ?? ''; - final approvedTaskCount = controller - .basicValidator - .getController('approved_task') - ?.text - .trim() ?? - ''; - - final shouldShowAddTaskSheet = - controller.isAddTaskChecked.value; - - final success = await controller.approveTask( - projectId: controller.basicValidator - .getController('task_id') - ?.text ?? - '', - comment: controller.basicValidator - .getController('comment') - ?.text ?? - '', - images: controller.selectedImages, - reportActionId: reportActionId, - approvedTaskCount: approvedTaskCount, - ); - if (success) { - Navigator.of(context).pop(); - if (shouldShowAddTaskSheet) { - await Future.delayed( - Duration(milliseconds: 100)); - showCreateTaskBottomSheet( - workArea: widget.taskData['location'] ?? '', - activity: widget.taskData['activity'] ?? '', - completedWork: - widget.taskData['completedWork'] ?? '', - unit: widget.taskData['unit'] ?? '', - onCategoryChanged: (category) { - debugPrint( - "Category changed to: $category"); - }, - parentTaskId: widget.taskDataId, - plannedTask: int.tryParse( - widget.taskData['plannedWork'] ?? - '0') ?? - 0, - activityId: widget.activityId, - workAreaId: widget.workAreaId, - onSubmit: () { - Navigator.of(context).pop(); - }, - ); - } - widget.onReportSuccess.call(); - } - } - }, - isLoading: controller.isLoading, - ), - - MySpacing.height(20), - if ((widget.taskData['taskComments'] as List?) - ?.isNotEmpty == - true) ...[ - Row( - children: [ - MySpacing.width(10), - Icon(Icons.chat_bubble_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comments", - fontWeight: 600, - ), - ], - ), - MySpacing.height(12), - Builder( - builder: (context) { - final comments = List>.from( - widget.taskData['taskComments'] as List, - ); - return buildCommentList(comments, context); - }, - ) - ], - ], - ), - ), - ); - }, - ), - ], - ), - ), + return GetBuilder( + tag: widget.taskData['taskId'] ?? '', + builder: (controller) { + return BaseBottomSheet( + title: "Take Report Action", + isSubmitting: controller.isLoading.value, + onCancel: () => Navigator.of(context).pop(), + onSubmit: () async {}, // not used since buttons moved + showButtons: false, // disable internal buttons + child: _buildForm(context, controller), + ); + }, ); } - Widget buildReportedImagesSection({ - required List imageUrls, - required BuildContext context, - String title = "Reported Images", - }) { - if (imageUrls.isEmpty) return const SizedBox(); + Widget _buildForm( + BuildContext context, ReportTaskActionController controller) { + return Form( + key: controller.basicValidator.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 📋 Task Details + buildRow("Assigned By", + controller.basicValidator.getController('assigned_by')?.text, + icon: Icons.person_outline), + buildRow("Work Area", + controller.basicValidator.getController('work_area')?.text, + icon: Icons.place_outlined), + buildRow("Activity", + controller.basicValidator.getController('activity')?.text, + icon: Icons.assignment_outlined), + buildRow("Planned Work", + controller.basicValidator.getController('planned_work')?.text, + icon: Icons.schedule_outlined), + buildRow("Completed Work", + controller.basicValidator.getController('completed_work')?.text, + icon: Icons.done_all_outlined), + buildTeamMembers(), + MySpacing.height(8), - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 0.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + // ✅ Approved Task Field + Row( children: [ - Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]), + Icon(Icons.check_circle_outline, + size: 18, color: Colors.grey[700]), MySpacing.width(8), - MyText.titleSmall( - title, - fontWeight: 600, + MyText.titleSmall("Approved Task:", fontWeight: 600), + ], + ), + MySpacing.height(10), + TextFormField( + controller: + controller.basicValidator.getController('approved_task'), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) return 'Required'; + if (int.tryParse(value) == null) return 'Must be a number'; + return null; + }, + decoration: InputDecoration( + hintText: "eg: 5", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + contentPadding: MySpacing.all(16), + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + + MySpacing.height(10), + if ((widget.taskData['reportedPreSignedUrls'] as List?) + ?.isNotEmpty == + true) + buildReportedImagesSection( + imageUrls: List.from( + widget.taskData['reportedPreSignedUrls'] ?? []), + context: context, + ), + + MySpacing.height(10), + MyText.titleSmall("Report Actions", fontWeight: 600), + MySpacing.height(10), + + Obx(() { + if (controller.isLoadingWorkStatus.value) + return const CircularProgressIndicator(); + return PopupMenuButton( + onSelected: (String value) { + controller.selectedWorkStatusName.value = value; + controller.showAddTaskCheckbox.value = true; + }, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + itemBuilder: (BuildContext context) { + return controller.workStatus.map((status) { + return PopupMenuItem( + value: status.name, + child: Row( + children: [ + Radio( + value: status.name, + groupValue: controller.selectedWorkStatusName.value, + onChanged: (_) => Navigator.pop(context, status.name), + ), + const SizedBox(width: 8), + MyText.bodySmall(status.name), + ], + ), + ); + }).toList(); + }, + child: Container( + padding: MySpacing.xy(16, 12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodySmall( + controller.selectedWorkStatusName.value.isEmpty + ? "Select Work Status" + : controller.selectedWorkStatusName.value, + color: Colors.black87, + ), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + ); + }), + + MySpacing.height(10), + + Obx(() { + if (!controller.showAddTaskCheckbox.value) + return const SizedBox.shrink(); + return CheckboxListTile( + title: MyText.titleSmall("Add new task", fontWeight: 600), + value: controller.isAddTaskChecked.value, + onChanged: (val) => + controller.isAddTaskChecked.value = val ?? false, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ); + }), + + MySpacing.height(24), + + // ✏️ Comment Field + Row( + children: [ + Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Comment:", fontWeight: 600), + ], + ), + MySpacing.height(8), + TextFormField( + validator: controller.basicValidator.getValidation('comment'), + controller: controller.basicValidator.getController('comment'), + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: "eg: Work done successfully", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + contentPadding: MySpacing.all(16), + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + + MySpacing.height(16), + + // 📸 Image Attachments + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.camera_alt_outlined, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall("Attach Photos:", fontWeight: 600), + MySpacing.height(12), + ], + ), ), ], ), - ), - MySpacing.height(8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final url = imageUrls[index]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: index, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - url, - width: 70, - height: 70, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 70, - height: 70, - color: Colors.grey.shade200, - child: - Icon(Icons.broken_image, color: Colors.grey[600]), - ), - ), + Obx(() { + final images = controller.selectedImages; + return buildImagePickerSection( + images: images, + onCameraTap: () => controller.pickImages(fromCamera: true), + onUploadTap: () => controller.pickImages(fromCamera: false), + onRemoveImage: (index) => controller.removeImageAt(index), + onPreviewImage: (index) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: images, + initialIndex: index, ), ); }, - ), + ); + }), + + MySpacing.height(12), + + // ✅ Submit/Cancel Buttons moved here + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium("Cancel", + color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: controller.isLoading.value + ? null + : () async { + if (controller.basicValidator.validateForm()) { + final selectedStatusName = + controller.selectedWorkStatusName.value; + final selectedStatus = controller.workStatus + .firstWhereOrNull( + (s) => s.name == selectedStatusName); + final reportActionId = + selectedStatus?.id.toString() ?? ''; + final approvedTaskCount = controller.basicValidator + .getController('approved_task') + ?.text + .trim() ?? + ''; + + final shouldShowAddTaskSheet = + controller.isAddTaskChecked.value; + + final success = await controller.approveTask( + projectId: controller.basicValidator + .getController('task_id') + ?.text ?? + '', + comment: controller.basicValidator + .getController('comment') + ?.text ?? + '', + images: controller.selectedImages, + reportActionId: reportActionId, + approvedTaskCount: approvedTaskCount, + ); + + if (success) { + Navigator.of(context).pop(); + if (shouldShowAddTaskSheet) { + await Future.delayed( + const Duration(milliseconds: 100)); + showCreateTaskBottomSheet( + workArea: widget.taskData['location'] ?? '', + activity: widget.taskData['activity'] ?? '', + completedWork: + widget.taskData['completedWork'] ?? '', + unit: widget.taskData['unit'] ?? '', + parentTaskId: widget.taskDataId, + plannedTask: int.tryParse( + widget.taskData['plannedWork'] ?? + '0') ?? + 0, + activityId: widget.activityId, + workAreaId: widget.workAreaId, + onSubmit: () => Navigator.of(context).pop(), + onCategoryChanged: (category) {}, + ); + } + widget.onReportSuccess.call(); + } + } + }, + icon: const Icon(Icons.check_circle_outline, + color: Colors.white), + label: MyText.bodyMedium( + controller.isLoading.value ? "Submitting..." : "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + ], ), - ), - MySpacing.height(16), - ], + + MySpacing.height(12), + + // 💬 Previous Comments List (only below submit) + if ((widget.taskData['taskComments'] as List?)?.isNotEmpty == + true) ...[ + Row( + children: [ + MySpacing.width(10), + Icon(Icons.chat_bubble_outline, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Comments", fontWeight: 600), + ], + ), + MySpacing.height(12), + buildCommentList( + List>.from( + widget.taskData['taskComments'] as List), + context, + timeAgo, + ), + ], + ], + ), ); } @@ -626,10 +431,7 @@ class _ReportActionBottomSheetState extends State child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleSmall( - "Team Members:", - fontWeight: 600, - ), + MyText.titleSmall("Team Members:", fontWeight: 600), MySpacing.width(12), GestureDetector( onTap: () { @@ -676,360 +478,4 @@ class _ReportActionBottomSheetState extends State ), ); } - - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, -}) { - return Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red, size: 18), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Obx(() { - return ElevatedButton.icon( - onPressed: isLoading.value ? null : () => onSubmit(), - icon: isLoading.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.send, color: Colors.white, size: 18), - label: isLoading.value - ? const SizedBox() - : MyText.bodyMedium("Submit", color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ); - }), - ), - ], - ); -} - - Widget buildRow(String label, String? value, {IconData? icon}) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (icon != null) - Padding( - padding: const EdgeInsets.only(right: 8.0, top: 2), - child: Icon(icon, size: 18, color: Colors.grey[700]), - ), - MyText.titleSmall( - "$label:", - fontWeight: 600, - ), - MySpacing.width(12), - Expanded( - child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), - ), - ], - ), - ); - } - - Widget buildCommentList( - List> comments, BuildContext context) { - comments.sort((a, b) { - final aDate = DateTime.tryParse(a['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - final bDate = DateTime.tryParse(b['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - return bDate.compareTo(aDate); // newest first - }); - - return SizedBox( - height: 300, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: comments.length, - itemBuilder: (context, index) { - final comment = comments[index]; - final commentText = comment['text'] ?? '-'; - final commentedBy = comment['commentedBy'] ?? 'Unknown'; - final relativeTime = timeAgo(comment['date'] ?? ''); - final imageUrls = List.from(comment['preSignedUrls'] ?? []); - - return Container( - margin: const EdgeInsets.symmetric(vertical: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - firstName: commentedBy.split(' ').first, - lastName: commentedBy.split(' ').length > 1 - ? commentedBy.split(' ').last - : '', - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium( - commentedBy, - fontWeight: 700, - color: Colors.black87, - ), - MyText.bodySmall( - relativeTime, - fontSize: 12, - color: Colors.black54, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: MyText.bodyMedium( - commentText, - fontWeight: 500, - color: Colors.black87, - maxLines: null, - ), - ), - ], - ), - const SizedBox(height: 12), - if (imageUrls.isNotEmpty) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.attach_file_outlined, - size: 18, color: Colors.grey[700]), - MyText.bodyMedium( - 'Attachments', - fontWeight: 600, - color: Colors.black87, - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: 60, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - itemBuilder: (context, imageIndex) { - final imageUrl = imageUrls[imageIndex]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: imageIndex, - ), - ); - }, - child: Stack( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[100], - boxShadow: [ - BoxShadow( - color: Colors.black26, - blurRadius: 6, - offset: Offset(2, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - Container( - color: Colors.grey[300], - child: Icon(Icons.broken_image, - color: Colors.grey[700]), - ), - ), - ), - ), - const Positioned( - right: 4, - bottom: 4, - child: Icon(Icons.zoom_in, - color: Colors.white70, size: 16), - ), - ], - ), - ); - }, - separatorBuilder: (_, __) => - const SizedBox(width: 12), - ), - ), - const SizedBox(height: 12), - ], - ], - ), - ), - ], - ), - ); - }, - ), - ); - } - - Widget buildImagePickerSection({ - required List images, - required VoidCallback onCameraTap, - required VoidCallback onUploadTap, - required void Function(int index) onRemoveImage, - required void Function(int initialIndex) onPreviewImage, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (images.isEmpty) - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300, width: 2), - color: Colors.grey.shade100, - ), - child: Center( - child: Icon(Icons.photo_camera_outlined, - size: 48, color: Colors.grey.shade400), - ), - ) - else - SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( - children: [ - GestureDetector( - onTap: () => onPreviewImage(index), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - file, - height: 70, - width: 70, - fit: BoxFit.cover, - ), - ), - ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => onRemoveImage(index), - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon(Icons.close, - size: 20, color: Colors.white), - ), - ), - ), - ], - ); - }, - ), - ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: MyButton.outlined( - onPressed: onCameraTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.camera_alt, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Capture', color: Colors.blueAccent), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: MyButton.outlined( - onPressed: onUploadTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.upload_file, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Upload', color: Colors.blueAccent), - ], - ), - ), - ), - ], - ), - ], - ); - } } diff --git a/lib/model/dailyTaskPlaning/report_action_widgets.dart b/lib/model/dailyTaskPlaning/report_action_widgets.dart new file mode 100644 index 0000000..3192e90 --- /dev/null +++ b/lib/model/dailyTaskPlaning/report_action_widgets.dart @@ -0,0 +1,392 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:get/get.dart'; + +/// Show labeled row with optional icon +Widget buildRow(String label, String? value, {IconData? icon}) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2), + child: Icon(icon, size: 18, color: Colors.grey[700]), + ), + MyText.titleSmall("$label:", fontWeight: 600), + MySpacing.width(12), + Expanded( + child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), + ), + ], + ), + ); +} + +/// Show uploaded network images +Widget buildReportedImagesSection({ + required List imageUrls, + required BuildContext context, + String title = "Reported Images", +}) { + if (imageUrls.isEmpty) return const SizedBox(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(8), + Row( + children: [ + Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall(title, fontWeight: 600), + ], + ), + MySpacing.height(8), + SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageUrls.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final url = imageUrls[index]; + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageUrls, + initialIndex: index, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + url, + width: 70, + height: 70, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + width: 70, + height: 70, + color: Colors.grey.shade200, + child: Icon(Icons.broken_image, color: Colors.grey[600]), + ), + ), + ), + ); + }, + ), + ), + MySpacing.height(16), + ], + ); +} + +/// Local image picker preview (with file images) +Widget buildImagePickerSection({ + required List images, + required VoidCallback onCameraTap, + required VoidCallback onUploadTap, + required void Function(int index) onRemoveImage, + required void Function(int initialIndex) onPreviewImage, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (images.isEmpty) + Container( + height: 70, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300, width: 2), + color: Colors.grey.shade100, + ), + child: Center( + child: Icon(Icons.photo_camera_outlined, + size: 48, color: Colors.grey.shade400), + ), + ) + else + SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: images.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final file = images[index]; + return Stack( + children: [ + GestureDetector( + onTap: () => onPreviewImage(index), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + file, + height: 70, + width: 70, + fit: BoxFit.cover, + ), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => onRemoveImage(index), + child: Container( + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, + size: 20, color: Colors.white), + ), + ), + ), + ], + ); + }, + ), + ), + MySpacing.height(16), + Row( + children: [ + Expanded( + child: MyButton.outlined( + onPressed: onCameraTap, + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.camera_alt, + size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Capture', color: Colors.blueAccent), + ], + ), + ), + ), + MySpacing.width(12), + Expanded( + child: MyButton.outlined( + onPressed: onUploadTap, + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.upload_file, + size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Upload', color: Colors.blueAccent), + ], + ), + ), + ), + ], + ), + ], + ); +} + +/// Comment list widget +Widget buildCommentList( + List> comments, BuildContext context, String Function(String) timeAgo) { + comments.sort((a, b) { + final aDate = DateTime.tryParse(a['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + final bDate = DateTime.tryParse(b['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + return bDate.compareTo(aDate); // newest first + }); + + return SizedBox( + height: 300, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: comments.length, + itemBuilder: (context, index) { + final comment = comments[index]; + final commentText = comment['text'] ?? '-'; + final commentedBy = comment['commentedBy'] ?? 'Unknown'; + final relativeTime = timeAgo(comment['date'] ?? ''); + final imageUrls = List.from(comment['preSignedUrls'] ?? []); + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: commentedBy.split(' ').first, + lastName: commentedBy.split(' ').length > 1 + ? commentedBy.split(' ').last + : '', + size: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium(commentedBy, + fontWeight: 700, color: Colors.black87), + MyText.bodySmall( + relativeTime, + fontSize: 12, + color: Colors.black54, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + MyText.bodyMedium(commentText, + fontWeight: 500, color: Colors.black87), + const SizedBox(height: 12), + if (imageUrls.isNotEmpty) ...[ + Row( + children: [ + Icon(Icons.attach_file_outlined, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.bodyMedium('Attachments', + fontWeight: 600, color: Colors.black87), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 60, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageUrls.length, + itemBuilder: (context, imageIndex) { + final imageUrl = imageUrls[imageIndex]; + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageUrls, + initialIndex: imageIndex, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + imageUrl, + width: 60, + height: 60, + fit: BoxFit.cover, + ), + ), + ); + }, + separatorBuilder: (_, __) => const SizedBox(width: 12), + ), + ), + ] + ], + ), + ); + }, + ), + ); +} + +/// Cancel + Submit buttons +Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future Function() onSubmit, + required RxBool isLoading, +}) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close, color: Colors.red, size: 18), + label: + MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { + return ElevatedButton.icon( + onPressed: isLoading.value ? null : () => onSubmit(), + icon: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.send, color: Colors.white, size: 18), + label: isLoading.value + ? const SizedBox() + : MyText.bodyMedium("Submit", + color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ); + }), + ), + ], + ); +} + +/// Converts a UTC timestamp to a relative time string +String timeAgo(String dateString) { + try { + DateTime date = DateTime.parse(dateString + "Z").toLocal(); + final now = DateTime.now(); + final difference = now.difference(date); + if (difference.inDays > 8) { + return "${date.day.toString().padLeft(2, '0')}-${date.month.toString().padLeft(2, '0')}-${date.year}"; + } else if (difference.inDays >= 1) { + return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; + } else if (difference.inHours >= 1) { + return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; + } else if (difference.inMinutes >= 1) { + return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; + } else { + return 'just now'; + } + } catch (e) { + return ''; + } +} diff --git a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart index 7991f4e..68fb793 100644 --- a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart @@ -6,10 +6,12 @@ import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class ReportTaskBottomSheet extends StatefulWidget { final Map taskData; final VoidCallback? onReportSuccess; + const ReportTaskBottomSheet({ super.key, required this.taskData, @@ -27,464 +29,282 @@ class _ReportTaskBottomSheetState extends State @override void initState() { super.initState(); - // Initialize the controller with a unique tag (optional) - controller = Get.put(ReportTaskController(), - tag: widget.taskData['taskId'] ?? UniqueKey().toString()); + controller = Get.put( + ReportTaskController(), + tag: widget.taskData['taskId'] ?? UniqueKey().toString(), + ); + _preFillFormFields(); + } - final taskData = widget.taskData; - controller.basicValidator.getController('assigned_date')?.text = - taskData['assignedOn'] ?? ''; - controller.basicValidator.getController('assigned_by')?.text = - taskData['assignedBy'] ?? ''; - controller.basicValidator.getController('work_area')?.text = - taskData['location'] ?? ''; - controller.basicValidator.getController('activity')?.text = - taskData['activity'] ?? ''; - controller.basicValidator.getController('team_size')?.text = - taskData['teamSize']?.toString() ?? ''; - controller.basicValidator.getController('assigned')?.text = - taskData['assigned'] ?? ''; - controller.basicValidator.getController('task_id')?.text = - taskData['taskId'] ?? ''; - controller.basicValidator.getController('completed_work')?.clear(); - controller.basicValidator.getController('comment')?.clear(); + void _preFillFormFields() { + final data = widget.taskData; + final v = controller.basicValidator; + + v.getController('assigned_date')?.text = data['assignedOn'] ?? ''; + v.getController('assigned_by')?.text = data['assignedBy'] ?? ''; + v.getController('work_area')?.text = data['location'] ?? ''; + v.getController('activity')?.text = data['activity'] ?? ''; + v.getController('team_size')?.text = data['teamSize']?.toString() ?? ''; + v.getController('assigned')?.text = data['assigned'] ?? ''; + v.getController('task_id')?.text = data['taskId'] ?? ''; + v.getController('completed_work')?.clear(); + v.getController('comment')?.clear(); } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - left: 24, - right: 24, - top: 12, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag handle - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), + return Obx(() { + return BaseBottomSheet( + title: "Report Task", + isSubmitting: controller.reportStatus.value == ApiStatus.loading, + onCancel: () => Navigator.of(context).pop(), + onSubmit: _handleSubmit, + child: Form( + key: controller.basicValidator.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRow("Assigned Date", controller.basicValidator.getController('assigned_date')?.text), + _buildRow("Assigned By", controller.basicValidator.getController('assigned_by')?.text), + _buildRow("Work Area", controller.basicValidator.getController('work_area')?.text), + _buildRow("Activity", controller.basicValidator.getController('activity')?.text), + _buildRow("Team Size", controller.basicValidator.getController('team_size')?.text), + _buildRow( + "Assigned", + "${controller.basicValidator.getController('assigned')?.text ?? '-'} " + "of ${widget.taskData['pendingWork'] ?? '-'} Pending", ), - ), - GetBuilder( - tag: widget.taskData['taskId'] ?? '', - init: controller, - builder: (_) { - return Form( - key: controller.basicValidator.formKey, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: MyText.titleMedium( - "Report Task", - fontWeight: 600, - ), - ), - MySpacing.height(16), - buildRow( - "Assigned Date", - controller.basicValidator - .getController('assigned_date') - ?.text - .trim()), - buildRow( - "Assigned By", - controller.basicValidator - .getController('assigned_by') - ?.text - .trim()), - buildRow( - "Work Area", - controller.basicValidator - .getController('work_area') - ?.text - .trim()), - buildRow( - "Activity", - controller.basicValidator - .getController('activity') - ?.text - .trim()), - buildRow( - "Team Size", - controller.basicValidator - .getController('team_size') - ?.text - .trim()), - buildRow( - "Assigned", - "${controller.basicValidator.getController('assigned')?.text.trim()} " - "of ${widget.taskData['pendingWork'] ?? '-'} Pending"), - Row( - children: [ - Icon(Icons.work_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Completed Work:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter completed work'; - } - final completed = int.tryParse(value.trim()); - final pending = widget.taskData['pendingWork'] ?? 0; - - if (completed == null) { - return 'Enter a valid number'; - } - - if (completed > pending) { - return 'Completed work cannot exceed pending work $pending'; - } - - return null; - }, - controller: controller.basicValidator - .getController('completed_work'), - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: "eg: 10", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(24), - Row( - children: [ - Icon(Icons.comment_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comment:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: controller.basicValidator - .getValidation('comment'), - controller: controller.basicValidator - .getController('comment'), - keyboardType: TextInputType.text, - decoration: InputDecoration( - hintText: "eg: Work done successfully", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(24), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.camera_alt_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall("Attach Photos:", - fontWeight: 600), - MySpacing.height(12), - ], - ), - ), - ], - ), - Obx(() { - final images = controller.selectedImages; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (images.isEmpty) - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.grey.shade300, width: 2), - color: Colors.grey.shade100, - ), - child: Center( - child: Icon(Icons.photo_camera_outlined, - size: 48, color: Colors.grey.shade400), - ), - ) - else - SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => - MySpacing.width(12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( - children: [ - GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (_) => Dialog( - child: InteractiveViewer( - child: Image.file(file), - ), - ), - ); - }, - child: ClipRRect( - borderRadius: - BorderRadius.circular(12), - child: Image.file( - file, - height: 70, - width: 70, - fit: BoxFit.cover, - ), - ), - ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => controller - .removeImageAt(index), - child: Container( - decoration: BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: Icon(Icons.close, - size: 20, - color: Colors.white), - ), - ), - ), - ], - ); - }, - ), - ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: MyButton.outlined( - onPressed: () => controller.pickImages( - fromCamera: true), - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon(Icons.camera_alt, - size: 16, - color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Capture', - color: Colors.blueAccent), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: MyButton.outlined( - onPressed: () => controller.pickImages( - fromCamera: false), - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon(Icons.upload_file, - size: 16, - color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Upload', - color: Colors.blueAccent), - ], - ), - ), - ), - ], - ), - ], - ); - }), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close, color: Colors.red, size: 18), - label: MyText.bodyMedium( - "Cancel", - color: Colors.red, - fontWeight: 600, - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + _buildCompletedWorkField(), + _buildCommentField(), + Obx(() => _buildImageSection()), + ], ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Obx(() { - final isLoading = - controller.reportStatus.value == ApiStatus.loading; - - return ElevatedButton.icon( - onPressed: isLoading - ? null - : () async { - if (controller.basicValidator.validateForm()) { - final success = await controller.reportTask( - projectId: controller.basicValidator - .getController('task_id') - ?.text ?? - '', - comment: controller.basicValidator - .getController('comment') - ?.text ?? - '', - completedTask: int.tryParse( - controller.basicValidator - .getController('completed_work') - ?.text ?? - '') ?? - 0, - checklist: [], - reportedDate: DateTime.now(), - images: controller.selectedImages, - ); - if (success && widget.onReportSuccess != null) { - widget.onReportSuccess!(); - } - } - }, - icon: isLoading - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.check_circle_outline, - color: Colors.white, size: 18), - label: isLoading - ? const SizedBox.shrink() - : MyText.bodyMedium( - "Report", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ); - }), - ), - ], -), - - ], - ), - ), - ); - }, - ), - ], - ), - ), - ); + ); + }); } - Widget buildRow(String label, String? value) { - IconData icon; - switch (label) { - case "Assigned Date": - icon = Icons.calendar_today_outlined; - break; - case "Assigned By": - icon = Icons.person_outline; - break; - case "Work Area": - icon = Icons.place_outlined; - break; - case "Activity": - icon = Icons.run_circle_outlined; - break; - case "Team Size": - icon = Icons.group_outlined; - break; - case "Assigned": - icon = Icons.assignment_turned_in_outlined; - break; - default: - icon = Icons.info_outline; + Future _handleSubmit() async { + final v = controller.basicValidator; + + if (v.validateForm()) { + final success = await controller.reportTask( + projectId: v.getController('task_id')?.text ?? '', + comment: v.getController('comment')?.text ?? '', + completedTask: int.tryParse(v.getController('completed_work')?.text ?? '') ?? 0, + checklist: [], + reportedDate: DateTime.now(), + images: controller.selectedImages, + ); + + if (success) { + widget.onReportSuccess?.call(); + } } + } + + Widget _buildRow(String label, String? value) { + final icons = { + "Assigned Date": Icons.calendar_today_outlined, + "Assigned By": Icons.person_outline, + "Work Area": Icons.place_outlined, + "Activity": Icons.run_circle_outlined, + "Team Size": Icons.group_outlined, + "Assigned": Icons.assignment_turned_in_outlined, + }; return Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 18, color: Colors.grey[700]), + Icon(icons[label] ?? Icons.info_outline, size: 18, color: Colors.grey[700]), MySpacing.width(8), - MyText.titleSmall( - "$label:", - fontWeight: 600, - ), + MyText.titleSmall("$label:", fontWeight: 600), MySpacing.width(12), Expanded( - child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), + child: MyText.bodyMedium(value?.trim().isNotEmpty == true ? value!.trim() : "-"), ), ], ), ); } -} + + Widget _buildCompletedWorkField() { + final pending = widget.taskData['pendingWork'] ?? 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.work_outline, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Completed Work:", fontWeight: 600), + ], + ), + MySpacing.height(8), + TextFormField( + controller: controller.basicValidator.getController('completed_work'), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.trim().isEmpty) return 'Please enter completed work'; + final completed = int.tryParse(value.trim()); + if (completed == null) return 'Enter a valid number'; + if (completed > pending) return 'Completed work cannot exceed pending work $pending'; + return null; + }, + decoration: InputDecoration( + hintText: "eg: 10", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + MySpacing.height(24), + ], + ); + } + + Widget _buildCommentField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Comment:", fontWeight: 600), + ], + ), + MySpacing.height(8), + TextFormField( + controller: controller.basicValidator.getController('comment'), + validator: controller.basicValidator.getValidation('comment'), + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: "eg: Work done successfully", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + MySpacing.height(24), + ], + ); + } + + Widget _buildImageSection() { + final images = controller.selectedImages; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.camera_alt_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Attach Photos:", fontWeight: 600), + ], + ), + MySpacing.height(12), + if (images.isEmpty) + Container( + height: 70, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300, width: 2), + color: Colors.grey.shade100, + ), + child: Center( + child: Icon(Icons.photo_camera_outlined, size: 48, color: Colors.grey.shade400), + ), + ) + else + SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: images.length, + separatorBuilder: (_, __) => MySpacing.width(12), + itemBuilder: (context, index) { + final file = images[index]; + return Stack( + children: [ + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => Dialog( + child: InteractiveViewer(child: Image.file(file)), + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file(file, height: 70, width: 70, fit: BoxFit.cover), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => controller.removeImageAt(index), + child: Container( + decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), + child: const Icon(Icons.close, size: 20, color: Colors.white), + ), + ), + ), + ], + ); + }, + ), + ), + MySpacing.height(16), + Row( + children: [ + Expanded( + child: MyButton.outlined( + onPressed: () => controller.pickImages(fromCamera: true), + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.camera_alt, size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Capture', color: Colors.blueAccent), + ], + ), + ), + ), + MySpacing.width(12), + Expanded( + child: MyButton.outlined( + onPressed: () => controller.pickImages(fromCamera: false), + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.upload_file, size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Upload', color: Colors.blueAccent), + ], + ), + ), + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index 31e0f4a..c8ef83a 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import 'package:flutter/services.dart'; +import 'package:get/get.dart'; import 'package:collection/collection.dart'; import 'package:marco/controller/directory/add_contact_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; @@ -8,6 +8,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/model/directory/contact_model.dart'; import 'package:marco/helpers/utils/contact_picker_helper.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AddContactBottomSheet extends StatefulWidget { final ContactModel? existingContact; @@ -21,106 +22,98 @@ class _AddContactBottomSheetState extends State { final controller = Get.put(AddContactController()); final formKey = GlobalKey(); - final nameController = TextEditingController(); - final orgController = TextEditingController(); - final addressController = TextEditingController(); - final descriptionController = TextEditingController(); - final tagTextController = TextEditingController(); - final RxBool showAdvanced = false.obs; - final RxList emailControllers = - [].obs; - final RxList emailLabels = [].obs; + final nameCtrl = TextEditingController(); + final orgCtrl = TextEditingController(); + final addrCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final tagCtrl = TextEditingController(); - final RxList phoneControllers = - [].obs; - final RxList phoneLabels = [].obs; + final showAdvanced = false.obs; + final bucketError = ''.obs; + + final emailCtrls = [].obs; + final emailLabels = [].obs; + + final phoneCtrls = [].obs; + final phoneLabels = [].obs; @override void initState() { super.initState(); controller.resetForm(); + _initFields(); + } - nameController.text = widget.existingContact?.name ?? ''; - orgController.text = widget.existingContact?.organization ?? ''; - addressController.text = widget.existingContact?.address ?? ''; - descriptionController.text = widget.existingContact?.description ?? ''; - tagTextController.clear(); + void _initFields() { + final c = widget.existingContact; + if (c != null) { + nameCtrl.text = c.name; + orgCtrl.text = c.organization; + addrCtrl.text = c.address; + descCtrl.text = c.description; - if (widget.existingContact != null) { - emailControllers.clear(); - emailLabels.clear(); - for (var email in widget.existingContact!.contactEmails) { - emailControllers.add(TextEditingController(text: email.emailAddress)); - emailLabels.add((email.label).obs); - } - if (emailControllers.isEmpty) { - emailControllers.add(TextEditingController()); - emailLabels.add('Office'.obs); - } + emailCtrls.assignAll(c.contactEmails.isEmpty + ? [TextEditingController()] + : c.contactEmails + .map((e) => TextEditingController(text: e.emailAddress))); + emailLabels.assignAll(c.contactEmails.isEmpty + ? ['Office'.obs] + : c.contactEmails.map((e) => e.label.obs)); - phoneControllers.clear(); - phoneLabels.clear(); - for (var phone in widget.existingContact!.contactPhones) { - phoneControllers.add(TextEditingController(text: phone.phoneNumber)); - phoneLabels.add((phone.label).obs); - } - if (phoneControllers.isEmpty) { - phoneControllers.add(TextEditingController()); - phoneLabels.add('Work'.obs); - } + phoneCtrls.assignAll(c.contactPhones.isEmpty + ? [TextEditingController()] + : c.contactPhones + .map((p) => TextEditingController(text: p.phoneNumber))); + phoneLabels.assignAll(c.contactPhones.isEmpty + ? ['Work'.obs] + : c.contactPhones.map((p) => p.label.obs)); - controller.enteredTags.assignAll( - widget.existingContact!.tags.map((tag) => tag.name).toList(), - ); + controller.enteredTags.assignAll(c.tags.map((e) => e.name)); ever(controller.isInitialized, (bool ready) { if (ready) { - final projectIds = widget.existingContact!.projectIds; - final bucketId = widget.existingContact!.bucketIds.firstOrNull; - final categoryName = widget.existingContact!.contactCategory?.name; + final projectIds = c.projectIds; + final bucketId = c.bucketIds.firstOrNull; + final category = c.contactCategory?.name; - if (categoryName != null) { - controller.selectedCategory.value = categoryName; - } + if (category != null) controller.selectedCategory.value = category; if (projectIds != null) { - final names = projectIds - .map((id) { - return controller.projectsMap.entries + controller.selectedProjects.assignAll( + projectIds + .map((id) => controller.projectsMap.entries .firstWhereOrNull((e) => e.value == id) - ?.key; - }) - .whereType() - .toList(); - controller.selectedProjects.assignAll(names); + ?.key) + .whereType() + .toList(), + ); } + if (bucketId != null) { final name = controller.bucketsMap.entries .firstWhereOrNull((e) => e.value == bucketId) ?.key; - if (name != null) { - controller.selectedBucket.value = name; - } + if (name != null) controller.selectedBucket.value = name; } } }); } else { - emailControllers.add(TextEditingController()); + emailCtrls.add(TextEditingController()); emailLabels.add('Office'.obs); - phoneControllers.add(TextEditingController()); + phoneCtrls.add(TextEditingController()); phoneLabels.add('Work'.obs); } } @override void dispose() { - nameController.dispose(); - orgController.dispose(); - tagTextController.dispose(); - addressController.dispose(); - descriptionController.dispose(); - emailControllers.forEach((e) => e.dispose()); - phoneControllers.forEach((p) => p.dispose()); + nameCtrl.dispose(); + orgCtrl.dispose(); + addrCtrl.dispose(); + descCtrl.dispose(); + tagCtrl.dispose(); + emailCtrls.forEach((c) => c.dispose()); + phoneCtrls.forEach((c) => c.dispose()); Get.delete(); super.dispose(); } @@ -147,158 +140,38 @@ class _AddContactBottomSheetState extends State { isDense: true, ); - Widget _buildLabeledRow( - String label, - RxString selectedLabel, - List options, - String inputLabel, - TextEditingController controller, - TextInputType inputType, - {VoidCallback? onRemove}) { - return Row( + Widget _textField(String label, TextEditingController ctrl, + {bool required = false, int maxLines = 1}) { + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium(label), - MySpacing.height(8), - _popupSelector( - hint: "Label", - selectedValue: selectedLabel, - options: options), - ], - ), + MyText.labelMedium(label), + MySpacing.height(8), + TextFormField( + controller: ctrl, + maxLines: maxLines, + decoration: _inputDecoration("Enter $label"), + validator: required + ? (v) => + (v == null || v.trim().isEmpty) ? "$label is required" : null + : null, ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium(inputLabel), - MySpacing.height(8), - TextFormField( - controller: controller, - keyboardType: inputType, - maxLength: inputType == TextInputType.phone ? 10 : null, - inputFormatters: inputType == TextInputType.phone - ? [FilteringTextInputFormatter.digitsOnly] - : [], - decoration: _inputDecoration("Enter $inputLabel").copyWith( - counterText: "", - suffixIcon: inputType == TextInputType.phone - ? IconButton( - icon: const Icon(Icons.contact_phone, - color: Colors.blue), - onPressed: () async { - final selectedPhone = - await ContactPickerHelper.pickIndianPhoneNumber( - context); - if (selectedPhone != null) { - controller.text = selectedPhone; - } - }, - ) - : null, - ), - validator: (value) { - if (value == null || value.trim().isEmpty) - return "$inputLabel is required"; - final trimmed = value.trim(); - if (inputType == TextInputType.phone) { - if (!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) { - return "Enter valid phone number"; - } - } - if (inputType == TextInputType.emailAddress && - !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$') - .hasMatch(trimmed)) { - return "Enter valid email"; - } - return null; - }, - ), - ], - ), - ), - if (onRemove != null) - Padding( - padding: const EdgeInsets.only(top: 24), - child: IconButton( - icon: const Icon(Icons.remove_circle_outline, color: Colors.red), - onPressed: onRemove, - ), - ), ], ); } - Widget _buildEmailList() => Column( - children: List.generate(emailControllers.length, (index) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _buildLabeledRow( - "Email Label", - emailLabels[index], - ["Office", "Personal", "Other"], - "Email", - emailControllers[index], - TextInputType.emailAddress, - onRemove: emailControllers.length > 1 - ? () { - emailControllers.removeAt(index); - emailLabels.removeAt(index); - } - : null, - ), - ); - }), - ); - - Widget _buildPhoneList() => Column( - children: List.generate(phoneControllers.length, (index) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _buildLabeledRow( - "Phone Label", - phoneLabels[index], - ["Work", "Mobile", "Other"], - "Phone", - phoneControllers[index], - TextInputType.phone, - onRemove: phoneControllers.length > 1 - ? () { - phoneControllers.removeAt(index); - phoneLabels.removeAt(index); - } - : null, - ), - ); - }), - ); - - Widget _popupSelector({ - required String hint, - required RxString selectedValue, - required List options, - }) { - return Obx(() => GestureDetector( + Widget _popupSelector(RxString selected, List options, String hint) => + Obx(() { + return GestureDetector( onTap: () async { - final selected = await showMenu( + final selectedItem = await showMenu( context: context, position: RelativeRect.fromLTRB(100, 300, 100, 0), - items: options.map((option) { - return PopupMenuItem( - value: option, - child: Text(option), - ); - }).toList(), + items: options + .map((e) => PopupMenuItem(value: e, child: Text(e))) + .toList(), ); - - if (selected != null) { - selectedValue.value = selected; - } + if (selectedItem != null) selected.value = selectedItem; }, child: Container( height: 48, @@ -312,45 +185,126 @@ class _AddContactBottomSheetState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - selectedValue.value.isNotEmpty ? selectedValue.value : hint, - style: const TextStyle(fontSize: 14), - ), + Text(selected.value.isNotEmpty ? selected.value : hint, + style: const TextStyle(fontSize: 14)), const Icon(Icons.expand_more, size: 20), ], ), ), - )); + ); + }); + + Widget _dynamicList( + RxList ctrls, + RxList labels, + String labelType, + List labelOptions, + TextInputType type) { + return Obx(() { + return Column( + children: List.generate(ctrls.length, (i) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("$labelType Label"), + MySpacing.height(8), + _popupSelector(labels[i], labelOptions, "Label"), + ], + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(labelType), + MySpacing.height(8), + TextFormField( + controller: ctrls[i], + keyboardType: type, + maxLength: type == TextInputType.phone ? 10 : null, + inputFormatters: type == TextInputType.phone + ? [FilteringTextInputFormatter.digitsOnly] + : [], + decoration: + _inputDecoration("Enter $labelType").copyWith( + counterText: "", + suffixIcon: type == TextInputType.phone + ? IconButton( + icon: const Icon(Icons.contact_phone, + color: Colors.blue), + onPressed: () async { + final phone = await ContactPickerHelper + .pickIndianPhoneNumber(context); + if (phone != null) ctrls[i].text = phone; + }, + ) + : null, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) + return null; + final trimmed = value.trim(); + if (type == TextInputType.phone && + !RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) { + return "Enter valid phone number"; + } + if (type == TextInputType.emailAddress && + !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(trimmed)) { + return "Enter valid email"; + } + return null; + }, + ), + ], + ), + ), + if (ctrls.length > 1) + Padding( + padding: const EdgeInsets.only(top: 24), + child: IconButton( + icon: const Icon(Icons.remove_circle_outline, + color: Colors.red), + onPressed: () { + ctrls.removeAt(i); + labels.removeAt(i); + }, + ), + ), + ], + ), + ); + }), + ); + }); } - Widget _sectionLabel(String title) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelLarge(title, fontWeight: 600), - MySpacing.height(4), - Divider(thickness: 1, color: Colors.grey.shade200), - ], - ); - - Widget _tagInputSection() { + Widget _tagInput() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 48, child: TextField( - controller: tagTextController, + controller: tagCtrl, onChanged: controller.filterSuggestions, - onSubmitted: (value) { - controller.addEnteredTag(value); - tagTextController.clear(); + onSubmitted: (v) { + controller.addEnteredTag(v); + tagCtrl.clear(); controller.clearSuggestions(); }, decoration: _inputDecoration("Start typing to add tags"), ), ), Obx(() => controller.filteredSuggestions.isEmpty - ? const SizedBox() + ? const SizedBox.shrink() : Container( margin: const EdgeInsets.only(top: 4), decoration: BoxDecoration( @@ -364,14 +318,14 @@ class _AddContactBottomSheetState extends State { child: ListView.builder( shrinkWrap: true, itemCount: controller.filteredSuggestions.length, - itemBuilder: (context, index) { - final suggestion = controller.filteredSuggestions[index]; + itemBuilder: (_, i) { + final suggestion = controller.filteredSuggestions[i]; return ListTile( dense: true, title: Text(suggestion), onTap: () { controller.addEnteredTag(suggestion); - tagTextController.clear(); + tagCtrl.clear(); controller.clearSuggestions(); }, ); @@ -392,125 +346,46 @@ class _AddContactBottomSheetState extends State { ); } - Widget _buildTextField(String label, TextEditingController controller, - {int maxLines = 1}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium(label), - MySpacing.height(8), - TextFormField( - controller: controller, - maxLines: maxLines, - decoration: _inputDecoration("Enter $label"), - validator: (value) => value == null || value.trim().isEmpty - ? "$label is required" - : null, - ), - ], - ); - } + void _handleSubmit() { + bool valid = formKey.currentState?.validate() ?? false; - Widget _buildOrganizationField() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium("Organization"), - MySpacing.height(8), - TextField( - controller: orgController, - onChanged: controller.filterOrganizationSuggestions, - decoration: _inputDecoration("Enter organization"), - ), - Obx(() => controller.filteredOrgSuggestions.isEmpty - ? const SizedBox() - : ListView.builder( - shrinkWrap: true, - itemCount: controller.filteredOrgSuggestions.length, - itemBuilder: (context, index) { - final suggestion = controller.filteredOrgSuggestions[index]; - return ListTile( - dense: true, - title: Text(suggestion), - onTap: () { - orgController.text = suggestion; - controller.filteredOrgSuggestions.clear(); - }, - ); - }, - )), - ], - ); - } + if (controller.selectedBucket.value.isEmpty) { + bucketError.value = "Bucket is required"; + valid = false; + } else { + bucketError.value = ""; + } - Widget _buildActionButtons() { - return Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () { - Get.back(); - Get.delete(); - }, - icon: const Icon(Icons.close, color: Colors.red), - label: - MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - ), - ), - ), - MySpacing.width(12), - Expanded( - child: ElevatedButton.icon( - onPressed: () { - if (formKey.currentState!.validate()) { - final emails = emailControllers - .asMap() - .entries - .where((entry) => entry.value.text.trim().isNotEmpty) - .map((entry) => { - "label": emailLabels[entry.key].value, - "emailAddress": entry.value.text.trim(), - }) - .toList(); + if (!valid) return; - final phones = phoneControllers - .asMap() - .entries - .where((entry) => entry.value.text.trim().isNotEmpty) - .map((entry) => { - "label": phoneLabels[entry.key].value, - "phoneNumber": entry.value.text.trim(), - }) - .toList(); + final emails = emailCtrls + .asMap() + .entries + .where((e) => e.value.text.trim().isNotEmpty) + .map((e) => { + "label": emailLabels[e.key].value, + "emailAddress": e.value.text.trim() + }) + .toList(); - controller.submitContact( - id: widget.existingContact?.id, - name: nameController.text.trim(), - organization: orgController.text.trim(), - emails: emails, - phones: phones, - address: addressController.text.trim(), - description: descriptionController.text.trim(), - ); - } - }, - icon: const Icon(Icons.check_circle_outline, color: Colors.white), - label: - MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - ), - ), - ), - ], + final phones = phoneCtrls + .asMap() + .entries + .where((e) => e.value.text.trim().isNotEmpty) + .map((e) => { + "label": phoneLabels[e.key].value, + "phoneNumber": e.value.text.trim() + }) + .toList(); + + controller.submitContact( + id: widget.existingContact?.id, + name: nameCtrl.text.trim(), + organization: orgCtrl.text.trim(), + emails: emails, + phones: phones, + address: addrCtrl.text.trim(), + description: descCtrl.text.trim(), ); } @@ -521,213 +396,107 @@ class _AddContactBottomSheetState extends State { return const Center(child: CircularProgressIndicator()); } - return SafeArea( - child: SingleChildScrollView( - padding: EdgeInsets.only( - top: 32, - ).add(MediaQuery.of(context).viewInsets), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(24)), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: MyText.titleMedium( - widget.existingContact != null - ? "Edit Contact" - : "Create New Contact", - fontWeight: 700, - ), - ), - MySpacing.height(24), - _sectionLabel("Required Fields"), - MySpacing.height(12), - _buildTextField("Name", nameController), - MySpacing.height(16), - _buildOrganizationField(), - MySpacing.height(16), - MyText.labelMedium("Select Bucket"), - MySpacing.height(8), - _popupSelector( - hint: "Select Bucket", - selectedValue: controller.selectedBucket, - options: controller.buckets, - ), - MySpacing.height(24), - Obx(() => GestureDetector( - onTap: () => showAdvanced.toggle(), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.labelLarge("Advanced Details (Optional)", - fontWeight: 600), - Icon(showAdvanced.value - ? Icons.expand_less - : Icons.expand_more), - ], - ), - )), - Obx(() => showAdvanced.value - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(24), - _sectionLabel("Contact Info"), - MySpacing.height(16), - _buildEmailList(), - TextButton.icon( - onPressed: () { - emailControllers.add(TextEditingController()); - emailLabels.add('Office'.obs); - }, - icon: const Icon(Icons.add), - label: const Text("Add Email"), - ), - _buildPhoneList(), - TextButton.icon( - onPressed: () { - phoneControllers.add(TextEditingController()); - phoneLabels.add('Work'.obs); - }, - icon: const Icon(Icons.add), - label: const Text("Add Phone"), - ), - MySpacing.height(24), - _sectionLabel("Other Details"), - MySpacing.height(16), - MyText.labelMedium("Category"), - MySpacing.height(8), - _popupSelector( - hint: "Select Category", - selectedValue: controller.selectedCategory, - options: controller.categories, - ), - MySpacing.height(16), - MyText.labelMedium("Select Projects"), - MySpacing.height(8), - _projectSelectorUI(), - MySpacing.height(16), - MyText.labelMedium("Tags"), - MySpacing.height(8), - _tagInputSection(), - MySpacing.height(16), - _buildTextField("Address", addressController, - maxLines: 2), - MySpacing.height(16), - _buildTextField( - "Description", descriptionController, - maxLines: 2), - ], - ) - : const SizedBox()), - MySpacing.height(24), - _buildActionButtons(), - ], - ), + return BaseBottomSheet( + title: widget.existingContact != null + ? "Edit Contact" + : "Create New Contact", + onCancel: () => Get.back(), + onSubmit: _handleSubmit, + isSubmitting: controller.isSubmitting.value, + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _textField("Name", nameCtrl, required: true), + MySpacing.height(16), + _textField("Organization", orgCtrl, required: true), + MySpacing.height(16), + MyText.labelMedium("Select Bucket"), + MySpacing.height(8), + Stack( + children: [ + _popupSelector(controller.selectedBucket, controller.buckets, + "Select Bucket"), + Positioned( + left: 0, + right: 0, + top: 56, + child: Obx(() => bucketError.value.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: Text(bucketError.value, + style: const TextStyle( + color: Colors.red, fontSize: 12)), + )), + ), + ], ), - ), + MySpacing.height(24), + Obx(() => GestureDetector( + onTap: () => showAdvanced.toggle(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.labelLarge("Advanced Details (Optional)", + fontWeight: 600), + Icon(showAdvanced.value + ? Icons.expand_less + : Icons.expand_more), + ], + ), + )), + Obx(() => showAdvanced.value + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(24), + _dynamicList( + emailCtrls, + emailLabels, + "Email", + ["Office", "Personal", "Other"], + TextInputType.emailAddress), + TextButton.icon( + onPressed: () { + emailCtrls.add(TextEditingController()); + emailLabels.add("Office".obs); + }, + icon: const Icon(Icons.add), + label: const Text("Add Email"), + ), + _dynamicList(phoneCtrls, phoneLabels, "Phone", + ["Work", "Mobile", "Other"], TextInputType.phone), + TextButton.icon( + onPressed: () { + phoneCtrls.add(TextEditingController()); + phoneLabels.add("Work".obs); + }, + icon: const Icon(Icons.add), + label: const Text("Add Phone"), + ), + MySpacing.height(16), + MyText.labelMedium("Category"), + MySpacing.height(8), + _popupSelector(controller.selectedCategory, + controller.categories, "Select Category"), + MySpacing.height(16), + MyText.labelMedium("Tags"), + MySpacing.height(8), + _tagInput(), + MySpacing.height(16), + _textField("Address", addrCtrl), + MySpacing.height(16), + _textField("Description", descCtrl), + ], + ) + : const SizedBox.shrink()), + ], ), ), ); }); } - - Widget _projectSelectorUI() { - return GestureDetector( - onTap: () async { - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Select Projects'), - content: Obx(() { - return SizedBox( - width: double.maxFinite, - child: ListView( - shrinkWrap: true, - children: controller.globalProjects.map((project) { - final isSelected = - controller.selectedProjects.contains(project); - return Theme( - data: Theme.of(context).copyWith( - unselectedWidgetColor: Colors.black, - checkboxTheme: CheckboxThemeData( - fillColor: MaterialStateProperty.resolveWith( - (states) { - if (states.contains(MaterialState.selected)) { - return Colors.white; - } - return Colors.transparent; - }), - checkColor: MaterialStateProperty.all(Colors.black), - side: - const BorderSide(color: Colors.black, width: 2), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - ), - ), - child: CheckboxListTile( - dense: true, - title: Text(project), - value: isSelected, - onChanged: (bool? selected) { - if (selected == true) { - controller.selectedProjects.add(project); - } else { - controller.selectedProjects.remove(project); - } - }, - ), - ); - }).toList(), - ), - ); - }), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Done'), - ), - ], - ); - }, - ); - }, - child: Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), - ), - alignment: Alignment.centerLeft, - child: Obx(() { - final selected = controller.selectedProjects; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - selected.isEmpty ? "Select Projects" : selected.join(', '), - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), - ), - ), - const Icon(Icons.expand_more, size: 20), - ], - ); - }), - ), - ); - } } diff --git a/lib/model/directory/create_bucket_bottom_sheet.dart b/lib/model/directory/create_bucket_bottom_sheet.dart index 51495c3..212035d 100644 --- a/lib/model/directory/create_bucket_bottom_sheet.dart +++ b/lib/model/directory/create_bucket_bottom_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/directory/create_bucket_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; @@ -38,125 +39,55 @@ class _CreateBucketBottomSheetState extends State { ); } + Widget _formContent() { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("Bucket Name"), + MySpacing.height(8), + TextFormField( + initialValue: _controller.name.value, + onChanged: _controller.updateName, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return "Bucket name is required"; + } + return null; + }, + decoration: _inputDecoration("e.g., Project Docs"), + ), + MySpacing.height(16), + MyText.labelMedium("Description"), + MySpacing.height(8), + TextFormField( + initialValue: _controller.description.value, + onChanged: _controller.updateDescription, + maxLines: 3, + decoration: _inputDecoration("Optional bucket description"), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return GetBuilder( builder: (_) { return SafeArea( top: false, - child: SingleChildScrollView( - padding: MediaQuery.of(context).viewInsets, - child: Container( - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [ - BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2)), - ], - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(10), - ), - ), - MySpacing.height(12), - Text("Create New Bucket", style: MyTextStyle.titleLarge(fontWeight: 700)), - MySpacing.height(24), - Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium("Bucket Name"), - MySpacing.height(8), - TextFormField( - initialValue: _controller.name.value, - onChanged: _controller.updateName, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return "Bucket name is required"; - } - return null; - }, - decoration: _inputDecoration("e.g., Project Docs"), - ), - MySpacing.height(16), - MyText.labelMedium("Description"), - MySpacing.height(8), - TextFormField( - initialValue: _controller.description.value, - onChanged: _controller.updateDescription, - maxLines: 3, - decoration: _inputDecoration("Optional bucket description"), - ), - MySpacing.height(24), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Navigator.pop(context, false), - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - ), - ), - ), - MySpacing.width(12), - Expanded( - child: Obx(() { - return ElevatedButton.icon( - onPressed: _controller.isCreating.value - ? null - : () async { - if (_formKey.currentState!.validate()) { - await _controller.createBucket(); - } - }, - icon: _controller.isCreating.value - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.check_circle_outline, color: Colors.white), - label: MyText.bodyMedium( - _controller.isCreating.value ? "Creating..." : "Create", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14), - ), - ); - }), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), + child: BaseBottomSheet( + title: "Create New Bucket", + child: _formContent(), + onCancel: () => Navigator.pop(context, false), + onSubmit: () async { + if (_formKey.currentState!.validate()) { + await _controller.createBucket(); + } + }, + isSubmitting: _controller.isCreating.value, ), ); }, diff --git a/lib/model/directory/directory_filter_bottom_sheet.dart b/lib/model/directory/directory_filter_bottom_sheet.dart index e39f689..ea85e09 100644 --- a/lib/model/directory/directory_filter_bottom_sheet.dart +++ b/lib/model/directory/directory_filter_bottom_sheet.dart @@ -1,170 +1,275 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -class DirectoryFilterBottomSheet extends StatelessWidget { +class DirectoryFilterBottomSheet extends StatefulWidget { const DirectoryFilterBottomSheet({super.key}); @override - Widget build(BuildContext context) { - final controller = Get.find(); + State createState() => + _DirectoryFilterBottomSheetState(); +} - return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 20, - top: 12, - left: 16, - right: 16, - ), - child: Obx(() { - return SingleChildScrollView( +class _DirectoryFilterBottomSheetState + extends State { + final DirectoryController controller = Get.find(); + final _categorySearchQuery = ''.obs; + final _bucketSearchQuery = ''.obs; + + final _categoryExpanded = false.obs; + final _bucketExpanded = false.obs; + + late final RxList _tempSelectedCategories; + late final RxList _tempSelectedBuckets; + + @override + void initState() { + super.initState(); + _tempSelectedCategories = controller.selectedCategories.toList().obs; + _tempSelectedBuckets = controller.selectedBuckets.toList().obs; + } + + void _toggleCategory(String id) { + _tempSelectedCategories.contains(id) + ? _tempSelectedCategories.remove(id) + : _tempSelectedCategories.add(id); + } + + void _toggleBucket(String id) { + _tempSelectedBuckets.contains(id) + ? _tempSelectedBuckets.remove(id) + : _tempSelectedBuckets.add(id); + } + + void _resetFilters() { + _tempSelectedCategories.clear(); + _tempSelectedBuckets.clear(); + } + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: "Filter Contacts", + onSubmit: () { + controller.selectedCategories.value = _tempSelectedCategories; + controller.selectedBuckets.value = _tempSelectedBuckets; + controller.applyFilters(); + Get.back(); + }, + onCancel: Get.back, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - /// Drag handle - Center( - child: Container( - height: 5, - width: 50, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(2.5), - ), - ), - ), - - /// Title - Center( - child: MyText.titleMedium( - "Filter Contacts", - fontWeight: 700, - ), - ), - - const SizedBox(height: 24), - - /// Categories - if (controller.contactCategories.isNotEmpty) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Obx(() { + final hasSelections = _tempSelectedCategories.isNotEmpty || + _tempSelectedBuckets.isNotEmpty; + if (!hasSelections) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodyMedium("Categories", fontWeight: 600), + MyText("Selected Filters:", fontWeight: 600), + const SizedBox(height: 4), + _buildChips(_tempSelectedCategories, + controller.contactCategories, _toggleCategory), + _buildChips(_tempSelectedBuckets, controller.contactBuckets, + _toggleBucket), ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 2, - runSpacing: 0, - children: controller.contactCategories.map((category) { - final selected = - controller.selectedCategories.contains(category.id); - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - child: FilterChip( - label: MyText.bodySmall( - category.name, - color: selected ? Colors.white : Colors.black87, - ), - selected: selected, - onSelected: (_) => - controller.toggleCategory(category.id), - selectedColor: Colors.indigo, - backgroundColor: Colors.grey.shade200, - checkmarkColor: Colors.white, - ), - ); - }).toList(), - ), - const SizedBox(height: 12), - ], - - /// Buckets - if (controller.contactBuckets.isNotEmpty) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium("Buckets", fontWeight: 600), - ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 2, - runSpacing: 0, - children: controller.contactBuckets.map((bucket) { - final selected = - controller.selectedBuckets.contains(bucket.id); - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - child: FilterChip( - label: MyText.bodySmall( - bucket.name, - color: selected ? Colors.white : Colors.black87, - ), - selected: selected, - onSelected: (_) => controller.toggleBucket(bucket.id), - selectedColor: Colors.teal, - backgroundColor: Colors.grey.shade200, - checkmarkColor: Colors.white, - ), - ); - }).toList(), - ), - ], - - const SizedBox(height: 12), - - /// Action Buttons + ); + }), Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.end, children: [ - OutlinedButton.icon( - onPressed: () { - controller.selectedCategories.clear(); - controller.selectedBuckets.clear(); - controller.searchQuery.value = ''; - controller.applyFilters(); - Get.back(); - }, - icon: const Icon(Icons.refresh, color: Colors.red), - label: MyText.bodyMedium("Clear", color: Colors.red), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 7), - ), - ), - ElevatedButton.icon( - onPressed: () { - controller.applyFilters(); - Get.back(); - }, - icon: const Icon(Icons.check_circle_outline), - label: MyText.bodyMedium("Apply", color: Colors.white), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 7), + TextButton.icon( + onPressed: _resetFilters, + icon: const Icon(Icons.restart_alt, size: 18), + label: MyText("Reset All", color: Colors.red), + style: TextButton.styleFrom( + foregroundColor: Colors.red.shade400, ), ), ], ), - const SizedBox(height: 10), + if (controller.contactCategories.isNotEmpty) + Obx(() => _buildExpandableFilterSection( + title: "Categories", + expanded: _categoryExpanded, + searchQuery: _categorySearchQuery, + allItems: controller.contactCategories, + selectedItems: _tempSelectedCategories, + onToggle: _toggleCategory, + )), + if (controller.contactBuckets.isNotEmpty) + Obx(() => _buildExpandableFilterSection( + title: "Buckets", + expanded: _bucketExpanded, + searchQuery: _bucketSearchQuery, + allItems: controller.contactBuckets, + selectedItems: _tempSelectedBuckets, + onToggle: _toggleBucket, + )), ], ), - ); - }), + ), + ), + ); + } + + Widget _buildChips(RxList selectedIds, List allItems, + Function(String) onRemoved) { + final idToName = {for (var item in allItems) item.id: item.name}; + return Wrap( + spacing: 4, + runSpacing: 4, + children: selectedIds + .map((id) => Chip( + label: MyText(idToName[id] ?? "", color: Colors.black87), + deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () => onRemoved(id), + backgroundColor: Colors.blue.shade50, + )) + .toList(), + ); + } + + Widget _buildExpandableFilterSection({ + required String title, + required RxBool expanded, + required RxString searchQuery, + required List allItems, + required RxList selectedItems, + required Function(String) onToggle, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + children: [ + GestureDetector( + onTap: () => expanded.toggle(), + child: Row( + children: [ + Icon( + expanded.value + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_right, + size: 20, + ), + const SizedBox(width: 4), + MyText( + "$title", + fontWeight: 600, + fontSize: 16, + ), + ], + ), + ), + if (expanded.value) + _buildFilterSection( + searchQuery: searchQuery, + allItems: allItems, + selectedItems: selectedItems, + onToggle: onToggle, + title: title, + ), + ], + ), + ); + } + + Widget _buildFilterSection({ + required String title, + required RxString searchQuery, + required List allItems, + required RxList selectedItems, + required Function(String) onToggle, + }) { + final filteredList = allItems.where((item) { + if (searchQuery.isEmpty) return true; + return item.name.toLowerCase().contains(searchQuery.value.toLowerCase()); + }).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 6), + TextField( + onChanged: (value) => searchQuery.value = value, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + hintText: "Search $title...", + hintStyle: const TextStyle(fontSize: 13), + prefixIcon: const Icon(Icons.search, size: 18), + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + filled: true, + fillColor: Colors.grey.shade100, + ), + ), + const SizedBox(height: 8), + if (filteredList.isEmpty) + Row( + children: [ + const Icon(Icons.sentiment_dissatisfied, color: Colors.grey), + const SizedBox(width: 10), + MyText("No results found.", + color: Colors.grey.shade600, fontSize: 14), + ], + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 230), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: filteredList.length, + itemBuilder: (context, index) { + final item = filteredList[index]; + final isSelected = selectedItems.contains(item.id); + + return GestureDetector( + onTap: () => onToggle(item.id), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: + isSelected ? Colors.blueAccent : Colors.white, + border: Border.all( + color: Colors.black, + width: 1.2, + ), + borderRadius: BorderRadius.circular(4), + ), + child: isSelected + ? const Icon(Icons.check, + size: 14, color: Colors.white) + : null, + ), + const SizedBox(width: 8), + MyText(item.name, fontSize: 14), + ], + ), + ), + ); + }, + ), + ) + ], ); } } diff --git a/lib/model/directory/edit_bucket_bottom_sheet.dart b/lib/model/directory/edit_bucket_bottom_sheet.dart index f9bb4dc..7f31992 100644 --- a/lib/model/directory/edit_bucket_bottom_sheet.dart +++ b/lib/model/directory/edit_bucket_bottom_sheet.dart @@ -1,18 +1,22 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:collection/collection.dart'; import 'package:marco/controller/directory/manage_bucket_controller.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/model/directory/contact_bucket_list_model.dart'; -import 'package:marco/model/employee_model.dart'; -import 'package:marco/controller/directory/directory_controller.dart'; -import 'package:collection/collection.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/model/directory/contact_bucket_list_model.dart'; class EditBucketBottomSheet { - static void show(BuildContext context, ContactBucket bucket, - List allEmployees, - {required String ownerId}) { + static void show( + BuildContext context, + ContactBucket bucket, + List allEmployees, { + required String ownerId, + }) { final ManageBucketController controller = Get.find(); final nameController = TextEditingController(text: bucket.name); @@ -25,7 +29,6 @@ class EditBucketBottomSheet { InputDecoration _inputDecoration(String label) { return InputDecoration( labelText: label, - hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), filled: true, fillColor: Colors.grey.shade100, border: OutlineInputBorder( @@ -36,9 +39,9 @@ class EditBucketBottomSheet { borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), @@ -46,256 +49,183 @@ class EditBucketBottomSheet { ); } + Future _handleSubmit() async { + final newName = nameController.text.trim(); + final newDesc = descController.text.trim(); + final newEmployeeIds = selectedIds.toList()..sort(); + final originalEmployeeIds = [...bucket.employeeIds]..sort(); + + final nameChanged = newName != bucket.name; + final descChanged = newDesc != bucket.description; + final employeeChanged = + !(const ListEquality().equals(newEmployeeIds, originalEmployeeIds)); + + if (!nameChanged && !descChanged && !employeeChanged) { + showAppSnackbar( + title: "No Changes", + message: "No changes were made to update the bucket.", + type: SnackbarType.warning, + ); + return; + } + + final success = await controller.updateBucket( + id: bucket.id, + name: newName, + description: newDesc, + employeeIds: newEmployeeIds, + originalEmployeeIds: originalEmployeeIds, + ); + + if (success) { + final directoryController = Get.find(); + await directoryController.fetchBuckets(); + Navigator.of(context).pop(); + } + } + + Widget _formContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameController, + decoration: _inputDecoration('Bucket Name'), + ), + MySpacing.height(16), + TextField( + controller: descController, + maxLines: 2, + decoration: _inputDecoration('Description'), + ), + MySpacing.height(20), + MyText.labelLarge('Shared With', fontWeight: 600), + MySpacing.height(8), + Obx(() => TextField( + controller: searchController, + onChanged: (value) => searchText.value = value.toLowerCase(), + decoration: InputDecoration( + hintText: 'Search employee...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: searchText.value.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + searchController.clear(); + searchText.value = ''; + }, + ) + : null, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + )), + MySpacing.height(8), + Obx(() { + final filtered = allEmployees.where((emp) { + final fullName = '${emp.firstName} ${emp.lastName}'.toLowerCase(); + return fullName.contains(searchText.value); + }).toList(); + + return SizedBox( + height: 180, + child: ListView.separated( + itemCount: filtered.length, + separatorBuilder: (_, __) => const SizedBox(height: 2), + itemBuilder: (context, index) { + final emp = filtered[index]; + final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + + return Obx(() => Theme( + data: Theme.of(context).copyWith( + unselectedWidgetColor: Colors.grey.shade500, + checkboxTheme: CheckboxThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4)), + side: const BorderSide(color: Colors.grey), + fillColor: + MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return Colors.blueAccent; + } + return Colors.white; + }), + checkColor: MaterialStateProperty.all(Colors.white), + ), + ), + child: CheckboxListTile( + dense: true, + contentPadding: EdgeInsets.zero, + visualDensity: const VisualDensity(vertical: -4), + controlAffinity: ListTileControlAffinity.leading, + value: selectedIds.contains(emp.id), + onChanged: emp.id == ownerId + ? null + : (val) { + if (val == true) { + selectedIds.add(emp.id); + } else { + selectedIds.remove(emp.id); + } + }, + title: Row( + children: [ + Expanded( + child: MyText.bodyMedium( + fullName.isNotEmpty ? fullName : 'Unnamed', + fontWeight: 600, + ), + ), + if (emp.id == ownerId) + Container( + margin: const EdgeInsets.only(left: 6), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.labelSmall( + "Owner", + fontWeight: 600, + color: Colors.red, + ), + ), + ], + ), + subtitle: emp.jobRole.isNotEmpty + ? MyText.bodySmall( + emp.jobRole, + color: Colors.grey.shade600, + ) + : null, + ), + )); + }, + ), + ); + }), + ], + ); + } + showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) { - return SingleChildScrollView( - padding: MediaQuery.of(context).viewInsets, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 12, - offset: Offset(0, -2), - ), - ], - ), - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(10), - ), - ), - ), - MySpacing.height(12), - Center( - child: MyText.titleMedium('Edit Bucket', fontWeight: 700), - ), - MySpacing.height(24), - - // Bucket Name - TextField( - controller: nameController, - decoration: _inputDecoration('Bucket Name'), - ), - MySpacing.height(16), - - // Description - TextField( - controller: descController, - maxLines: 2, - decoration: _inputDecoration('Description'), - ), - MySpacing.height(20), - - // Shared With - Align( - alignment: Alignment.centerLeft, - child: MyText.labelLarge('Shared With', fontWeight: 600), - ), - MySpacing.height(8), - - // Search - Obx(() => TextField( - controller: searchController, - onChanged: (value) => - searchText.value = value.toLowerCase(), - decoration: InputDecoration( - hintText: 'Search employee...', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: searchText.value.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 18), - onPressed: () { - searchController.clear(); - searchText.value = ''; - }, - ) - : null, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - borderSide: - BorderSide(color: Colors.blueAccent, width: 1.5), - ), - ), - )), - MySpacing.height(8), - - // Employee list - Obx(() { - final filtered = allEmployees.where((emp) { - final fullName = - '${emp.firstName} ${emp.lastName}'.toLowerCase(); - return fullName.contains(searchText.value); - }).toList(); - - return SizedBox( - height: 180, - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (context, index) { - final emp = filtered[index]; - final fullName = - '${emp.firstName} ${emp.lastName}'.trim(); - - return Obx(() => Theme( - data: Theme.of(context).copyWith( - checkboxTheme: CheckboxThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - side: const BorderSide( - color: Colors.black, width: 2), - fillColor: - MaterialStateProperty.resolveWith( - (states) { - if (states - .contains(MaterialState.selected)) { - return Colors.blueAccent; - } - return Colors.transparent; - }), - checkColor: - MaterialStateProperty.all(Colors.white), - ), - ), - child: CheckboxListTile( - dense: true, - contentPadding: EdgeInsets.zero, - visualDensity: - const VisualDensity(vertical: -4), - controlAffinity: - ListTileControlAffinity.leading, - value: selectedIds.contains(emp.id), - onChanged: emp.id == ownerId - ? null - : (val) { - if (val == true) { - selectedIds.add(emp.id); - } else { - selectedIds.remove(emp.id); - } - }, - title: Text( - fullName.isNotEmpty ? fullName : 'Unnamed', - style: const TextStyle(fontSize: 13), - ), - ), - )); - }, - ), - ); - }), - - MySpacing.height(24), - - // Action Buttons - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", - color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 12), - ), - ), - ), - MySpacing.width(12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - final newName = nameController.text.trim(); - final newDesc = descController.text.trim(); - final newEmployeeIds = selectedIds.toList()..sort(); - final originalEmployeeIds = [...bucket.employeeIds] - ..sort(); - - final nameChanged = newName != bucket.name; - final descChanged = newDesc != bucket.description; - final employeeChanged = !(ListEquality() - .equals(newEmployeeIds, originalEmployeeIds)); - - if (!nameChanged && - !descChanged && - !employeeChanged) { - showAppSnackbar( - title: "No Changes", - message: - "No changes were made to update the bucket.", - type: SnackbarType.warning, - ); - return; - } - - final success = await controller.updateBucket( - id: bucket.id, - name: newName, - description: newDesc, - employeeIds: newEmployeeIds, - originalEmployeeIds: originalEmployeeIds, - ); - - if (success) { - final directoryController = - Get.find(); - await directoryController.fetchBuckets(); - Navigator.of(context).pop(); - } - }, - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - label: MyText.bodyMedium("Save", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 12), - ), - ), - ), - ], - ), - ], - ), - ), + return BaseBottomSheet( + title: "Edit Bucket", + onCancel: () => Navigator.pop(context), + onSubmit: _handleSubmit, + child: _formContent(), ); }, ); diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index f111c4f..91fee4e 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import 'package:flutter/services.dart'; - +import 'package:get/get.dart'; import 'package:marco/controller/dashboard/add_employee_controller.dart'; import 'package:marco/controller/dashboard/employees_screen_controller.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AddEmployeeBottomSheet extends StatefulWidget { @override @@ -18,69 +18,110 @@ class _AddEmployeeBottomSheetState extends State with UIMixin { final AddEmployeeController _controller = Get.put(AddEmployeeController()); - late TextEditingController genderController; - late TextEditingController roleController; - @override - void initState() { - super.initState(); - genderController = TextEditingController(); - roleController = TextEditingController(); - } - - RelativeRect _popupMenuPosition(BuildContext context) { - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; - return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0); - } - - void _showGenderPopup(BuildContext context) async { - final selected = await showMenu( - context: context, - position: _popupMenuPosition(context), - items: Gender.values.map((gender) { - return PopupMenuItem( - value: gender, - child: Text(gender.name.capitalizeFirst!), + Widget build(BuildContext context) { + return GetBuilder( + init: _controller, + builder: (_) { + return BaseBottomSheet( + title: "Add Employee", + onCancel: () => Navigator.pop(context), + onSubmit: _handleSubmit, + child: Form( + key: _controller.basicValidator.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel("Personal Info"), + MySpacing.height(16), + _inputWithIcon( + label: "First Name", + hint: "e.g., John", + icon: Icons.person, + controller: + _controller.basicValidator.getController('first_name')!, + validator: + _controller.basicValidator.getValidation('first_name'), + ), + MySpacing.height(16), + _inputWithIcon( + label: "Last Name", + hint: "e.g., Doe", + icon: Icons.person_outline, + controller: + _controller.basicValidator.getController('last_name')!, + validator: + _controller.basicValidator.getValidation('last_name'), + ), + MySpacing.height(16), + _sectionLabel("Contact Details"), + MySpacing.height(16), + _buildPhoneInput(context), + MySpacing.height(24), + _sectionLabel("Other Details"), + MySpacing.height(16), + _buildDropdownField( + label: "Gender", + value: _controller.selectedGender?.name.capitalizeFirst ?? '', + hint: "Select Gender", + onTap: () => _showGenderPopup(context), + ), + MySpacing.height(16), + _buildDropdownField( + label: "Role", + value: _controller.roles.firstWhereOrNull((role) => + role['id'] == _controller.selectedRoleId)?['name'] ?? + "", + hint: "Select Role", + onTap: () => _showRolePopup(context), + ), + ], + ), + ), ); - }).toList(), + }, ); + } - if (selected != null) { - _controller.onGenderSelected(selected); + // Submit logic + Future _handleSubmit() async { + final result = await _controller.createEmployees(); + + if (result != null && result['success'] == true) { + final employeeData = result['data']; // ✅ Safe now + final employeeController = Get.find(); + final projectId = employeeController.selectedProjectId; + + if (projectId == null) { + await employeeController.fetchAllEmployees(); + } else { + await employeeController.fetchEmployeesByProject(projectId); + } + + employeeController.update(['employee_screen_controller']); + + _controller.basicValidator.getController("first_name")?.clear(); + _controller.basicValidator.getController("last_name")?.clear(); + _controller.basicValidator.getController("phone_number")?.clear(); + _controller.selectedGender = null; + _controller.selectedRoleId = null; _controller.update(); + + Navigator.pop(context, employeeData); } } - void _showRolePopup(BuildContext context) async { - final selected = await showMenu( - context: context, - position: _popupMenuPosition(context), - items: _controller.roles.map((role) { - return PopupMenuItem( - value: role['id'], - child: Text(role['name']), - ); - }).toList(), - ); - - if (selected != null) { - _controller.onRoleSelected(selected); - _controller.update(); - } - } - - Widget _sectionLabel(String title) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelLarge(title, fontWeight: 600), - MySpacing.height(4), - Divider(thickness: 1, color: Colors.grey.shade200), - ], - ); - } + // Section label widget + Widget _sectionLabel(String title) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelLarge(title, fontWeight: 600), + MySpacing.height(4), + Divider(thickness: 1, color: Colors.grey.shade200), + ], + ); + // Input field with icon Widget _inputWithIcon({ required String label, required String hint, @@ -104,6 +145,124 @@ class _AddEmployeeBottomSheetState extends State ); } + // Phone input with country code selector + Widget _buildPhoneInput(BuildContext context) { + return Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + color: Colors.grey.shade100, + ), + child: PopupMenuButton>( + onSelected: (country) { + _controller.selectedCountryCode = country['code']!; + _controller.update(); + }, + itemBuilder: (context) => [ + PopupMenuItem( + enabled: false, + padding: EdgeInsets.zero, + child: SizedBox( + height: 200, + width: 100, + child: ListView( + children: _controller.countries.map((country) { + return ListTile( + dense: true, + title: Text("${country['name']} (${country['code']})"), + onTap: () => Navigator.pop(context, country), + ); + }).toList(), + ), + ), + ), + ], + child: Row( + children: [ + Text(_controller.selectedCountryCode), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + MySpacing.width(12), + Expanded( + child: TextFormField( + controller: + _controller.basicValidator.getController('phone_number'), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return "Phone number is required"; + } + + final digitsOnly = value.trim(); + final minLength = _controller + .minDigitsPerCountry[_controller.selectedCountryCode] ?? + 7; + final maxLength = _controller + .maxDigitsPerCountry[_controller.selectedCountryCode] ?? + 15; + + if (!RegExp(r'^[0-9]+$').hasMatch(digitsOnly)) { + return "Only digits allowed"; + } + + if (digitsOnly.length < minLength || + digitsOnly.length > maxLength) { + return "Between $minLength–$maxLength digits"; + } + + return null; + }, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(15), + ], + decoration: _inputDecoration("e.g., 9876543210").copyWith( + suffixIcon: IconButton( + icon: const Icon(Icons.contacts), + onPressed: () => _controller.pickContact(context), + ), + ), + ), + ), + ], + ); + } + + // Gender/Role field (read-only dropdown) + Widget _buildDropdownField({ + required String label, + required String value, + required String hint, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + GestureDetector( + onTap: onTap, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + controller: TextEditingController(text: value), + decoration: _inputDecoration(hint).copyWith( + suffixIcon: const Icon(Icons.expand_more), + ), + ), + ), + ), + ], + ); + } + + // Common input decoration InputDecoration _inputDecoration(String hint) { return InputDecoration( hintText: hint, @@ -120,311 +279,53 @@ class _AddEmployeeBottomSheetState extends State ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), ), contentPadding: MySpacing.all(16), ); } - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return GetBuilder( - init: _controller, - builder: (_) { - return SingleChildScrollView( - padding: MediaQuery.of(context).viewInsets, - child: Container( - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 12, - offset: Offset(0, -2)) - ], - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag Handle - Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(10), - ), - ), - MySpacing.height(12), - Text("Add Employee", - style: MyTextStyle.titleLarge(fontWeight: 700)), - MySpacing.height(24), - Form( - key: _controller.basicValidator.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sectionLabel("Personal Info"), - MySpacing.height(16), - _inputWithIcon( - label: "First Name", - hint: "e.g., John", - icon: Icons.person, - controller: _controller.basicValidator - .getController('first_name')!, - validator: _controller.basicValidator - .getValidation('first_name'), - ), - MySpacing.height(16), - _inputWithIcon( - label: "Last Name", - hint: "e.g., Doe", - icon: Icons.person_outline, - controller: _controller.basicValidator - .getController('last_name')!, - validator: _controller.basicValidator - .getValidation('last_name'), - ), - MySpacing.height(16), - _sectionLabel("Contact Details"), - MySpacing.height(16), - MyText.labelMedium("Phone Number"), - MySpacing.height(8), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(12), - color: Colors.grey.shade100, - ), - child: PopupMenuButton>( - onSelected: (country) { - _controller.selectedCountryCode = - country['code']!; - _controller.update(); - }, - itemBuilder: (context) => [ - PopupMenuItem( - enabled: false, - padding: EdgeInsets.zero, - child: SizedBox( - height: 200, - width: 100, - child: ListView( - children: _controller.countries - .map((country) { - return ListTile( - dense: true, - title: Text( - "${country['name']} (${country['code']})"), - onTap: () => - Navigator.pop(context, country), - ); - }).toList(), - ), - ), - ), - ], - child: Row( - children: [ - Text(_controller.selectedCountryCode), - const Icon(Icons.arrow_drop_down), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: TextFormField( - controller: _controller.basicValidator - .getController('phone_number'), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return "Phone number is required"; - } - - final digitsOnly = value.trim(); - final minLength = _controller - .minDigitsPerCountry[ - _controller.selectedCountryCode] ?? - 7; - final maxLength = _controller - .maxDigitsPerCountry[ - _controller.selectedCountryCode] ?? - 15; - - if (!RegExp(r'^[0-9]+$') - .hasMatch(digitsOnly)) { - return "Only digits allowed"; - } - - if (digitsOnly.length < minLength || - digitsOnly.length > maxLength) { - return "Between $minLength–$maxLength digits"; - } - - return null; - }, - keyboardType: TextInputType.phone, - inputFormatters: [ - // Allow only digits - FilteringTextInputFormatter.digitsOnly, - // Limit to 10 digits - LengthLimitingTextInputFormatter(10), - ], - decoration: _inputDecoration("e.g., 9876543210") - .copyWith( - suffixIcon: IconButton( - icon: const Icon(Icons.contacts), - onPressed: () => - _controller.pickContact(context), - ), - ), - ), - ), - ], - ), - MySpacing.height(24), - _sectionLabel("Other Details"), - MySpacing.height(16), - MyText.labelMedium("Gender"), - MySpacing.height(8), - GestureDetector( - onTap: () => _showGenderPopup(context), - child: AbsorbPointer( - child: TextFormField( - readOnly: true, - controller: TextEditingController( - text: _controller - .selectedGender?.name.capitalizeFirst, - ), - decoration: - _inputDecoration("Select Gender").copyWith( - suffixIcon: const Icon(Icons.expand_more), - ), - ), - ), - ), - MySpacing.height(16), - MyText.labelMedium("Role"), - MySpacing.height(8), - GestureDetector( - onTap: () => _showRolePopup(context), - child: AbsorbPointer( - child: TextFormField( - readOnly: true, - controller: TextEditingController( - text: _controller.roles.firstWhereOrNull( - (role) => - role['id'] == - _controller.selectedRoleId, - )?['name'] ?? - "", - ), - decoration: - _inputDecoration("Select Role").copyWith( - suffixIcon: const Icon(Icons.expand_more), - ), - ), - ), - ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Navigator.pop(context), - icon: - const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", - color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 14), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - if (_controller.basicValidator - .validateForm()) { - final result = - await _controller.createEmployees(); - - if (result != null && - result['success'] == true) { - final employeeData = result['data']; - final employeeController = - Get.find(); - final projectId = - employeeController.selectedProjectId; - - if (projectId == null) { - await employeeController - .fetchAllEmployees(); - } else { - await employeeController - .fetchEmployeesByProject(projectId); - } - - employeeController.update( - ['employee_screen_controller']); - - _controller.basicValidator - .getController("first_name") - ?.clear(); - _controller.basicValidator - .getController("last_name") - ?.clear(); - _controller.basicValidator - .getController("phone_number") - ?.clear(); - _controller.selectedGender = null; - _controller.selectedRoleId = null; - _controller.update(); - - Navigator.pop(context, employeeData); - } - } - }, - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - label: MyText.bodyMedium("Save", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric( - horizontal: 28, vertical: 14), - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), + // Gender popup menu + void _showGenderPopup(BuildContext context) async { + final selected = await showMenu( + context: context, + position: _popupMenuPosition(context), + items: Gender.values.map((gender) { + return PopupMenuItem( + value: gender, + child: Text(gender.name.capitalizeFirst!), ); - }, + }).toList(), ); + + if (selected != null) { + _controller.onGenderSelected(selected); + _controller.update(); + } + } + + // Role popup menu + void _showRolePopup(BuildContext context) async { + final selected = await showMenu( + context: context, + position: _popupMenuPosition(context), + items: _controller.roles.map((role) { + return PopupMenuItem( + value: role['id'], + child: Text(role['name']), + ); + }).toList(), + ); + + if (selected != null) { + _controller.onRoleSelected(selected); + _controller.update(); + } + } + + RelativeRect _popupMenuPosition(BuildContext context) { + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0); } } diff --git a/lib/model/employees/employee_with_id_name_model.dart b/lib/model/employees/employee_with_id_name_model.dart new file mode 100644 index 0000000..bb9a685 --- /dev/null +++ b/lib/model/employees/employee_with_id_name_model.dart @@ -0,0 +1,30 @@ +class EmployeeModelWithIdName { + final String id; + final String firstName; + final String lastName; + final String name; + + EmployeeModelWithIdName({ + required this.id, + required this.firstName, + required this.lastName, + required this.name, + }); + + factory EmployeeModelWithIdName.fromJson(Map json) { + return EmployeeModelWithIdName( + id: json['id']?.toString() ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + name: '${json['firstName'] ?? ''} ${json['lastName'] ?? ''}'.trim(), + ); + } + + Map toJson() { + return { + 'id': id, + 'firstName': name.split(' ').first, + 'lastName': name.split(' ').length > 1 ? name.split(' ').last : '', + }; + } +} diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart new file mode 100644 index 0000000..d18e3b4 --- /dev/null +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -0,0 +1,719 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/expense/add_expense_controller.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; + +Future showAddExpenseBottomSheet({ + bool isEdit = false, + Map? existingExpense, +}) { + return Get.bottomSheet( + _AddExpenseBottomSheet( + isEdit: isEdit, + existingExpense: existingExpense, + ), + isScrollControlled: true, + ); +} + +class _AddExpenseBottomSheet extends StatefulWidget { + final bool isEdit; + final Map? existingExpense; + + const _AddExpenseBottomSheet({ + this.isEdit = false, + this.existingExpense, + }); + + @override + State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); +} + +class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { + final AddExpenseController controller = Get.put(AddExpenseController()); + final GlobalKey _projectDropdownKey = GlobalKey(); + final GlobalKey _expenseTypeDropdownKey = GlobalKey(); + final GlobalKey _paymentModeDropdownKey = GlobalKey(); + void _showEmployeeList() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + backgroundColor: Colors.transparent, + builder: (_) => ReusableEmployeeSelectorBottomSheet( + searchController: controller.employeeSearchController, + searchResults: controller.employeeSearchResults, + isSearching: controller.isSearchingEmployees, + onSearch: controller.searchEmployees, + onSelect: (emp) => controller.selectedPaidBy.value = emp, + ), + ); + + // Optional cleanup + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); + } + + Future _showOptionList( + List options, + String Function(T) getLabel, + ValueChanged onSelected, + GlobalKey triggerKey, // add this param + ) async { + final RenderBox button = + triggerKey.currentContext!.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final position = button.localToGlobal(Offset.zero, ancestor: overlay); + + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + overlay.size.width - position.dx - button.size.width, + 0, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: options + .map( + (option) => PopupMenuItem( + value: option, + child: Text(getLabel(option)), + ), + ) + .toList(), + ); + + if (selected != null) onSelected(selected); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + return BaseBottomSheet( + title: widget.isEdit ? "Edit Expense" : "Add Expense", + isSubmitting: controller.isSubmitting.value, + onCancel: Get.back, + onSubmit: () { + if (!controller.isSubmitting.value) { + controller.submitOrUpdateExpense(); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDropdown( + icon: Icons.work_outline, + title: "Project", + requiredField: true, + value: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val, + _projectDropdownKey, // pass the relevant GlobalKey here + ), + dropdownKey: _projectDropdownKey, // pass key also here + ), + MySpacing.height(16), + _buildDropdown( + icon: Icons.category_outlined, + title: "Expense Type", + requiredField: true, + value: controller.selectedExpenseType.value?.name ?? + "Select Expense Type", + onTap: () => _showOptionList( + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + _expenseTypeDropdownKey, + ), + dropdownKey: _expenseTypeDropdownKey, + ), + if (controller.selectedExpenseType.value?.noOfPersonsRequired == + true) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + icon: Icons.people_outline, + title: "No. of Persons", + requiredField: true, + ), + MySpacing.height(6), + _CustomTextField( + controller: controller.noOfPersonsController, + hint: "Enter No. of Persons", + keyboardType: TextInputType.number, + ), + ], + ), + ), + MySpacing.height(16), + _SectionTitle( + icon: Icons.confirmation_number_outlined, title: "GST No."), + MySpacing.height(6), + _CustomTextField( + controller: controller.gstController, hint: "Enter GST No."), + MySpacing.height(16), + _buildDropdown( + icon: Icons.payment, + title: "Payment Mode", + requiredField: true, + value: controller.selectedPaymentMode.value?.name ?? + "Select Payment Mode", + onTap: () => _showOptionList( + controller.paymentModes.toList(), + (p) => p.name, + (val) => controller.selectedPaymentMode.value = val, + _paymentModeDropdownKey, + ), + dropdownKey: _paymentModeDropdownKey, + ), + MySpacing.height(16), + _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), + _SectionTitle( + icon: Icons.currency_rupee, + title: "Amount", + requiredField: true), + MySpacing.height(6), + _CustomTextField( + controller: controller.amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + ), + MySpacing.height(16), + _SectionTitle( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name", + requiredField: true, + ), + MySpacing.height(6), + _CustomTextField( + controller: controller.supplierController, + hint: "Enter Supplier Name"), + MySpacing.height(16), + _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID"), + MySpacing.height(6), + _CustomTextField( + controller: controller.transactionIdController, + hint: "Enter Transaction ID"), + MySpacing.height(16), + _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", + ), + ), + ), + MySpacing.height(16), + _SectionTitle(icon: Icons.location_on_outlined, title: "Location"), + MySpacing.height(6), + TextField( + 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), + _SectionTitle( + icon: Icons.attach_file, + title: "Attachments", + requiredField: true), + MySpacing.height(6), + _AttachmentsSection( + attachments: controller.attachments, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + onRemoveExisting: (item) { + final index = controller.existingAttachments.indexOf(item); + if (index != -1) { + controller.existingAttachments[index]['isActive'] = false; + controller.existingAttachments.refresh(); + } + }, + onAdd: controller.pickAttachments, + ), + MySpacing.height(16), + _SectionTitle( + icon: Icons.description_outlined, + title: "Description", + requiredField: true), + MySpacing.height(6), + _CustomTextField( + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, + ), + ], + ), + ); + }); + } + + Widget _buildDropdown({ + required IconData icon, + required String title, + required bool requiredField, + required String value, + required VoidCallback onTap, + required GlobalKey dropdownKey, // new param + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(icon: icon, title: title, requiredField: requiredField), + MySpacing.height(6), + _DropdownTile( + key: dropdownKey, // Pass the key here + 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, + 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), + ), + ], + ), + ), + ], + ); + } +} + +class _CustomTextField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final int maxLines; + final TextInputType keyboardType; + + const _CustomTextField({ + required this.controller, + required this.hint, + this.maxLines = 1, + this.keyboardType = TextInputType.text, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + 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(); + + 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) { + final imageDocs = activeExistingAttachments + .where((d) => (d['contentType'] + ?.toString() + .startsWith('image/') ?? + false)) + .toList(); + final initialIndex = + imageDocs.indexWhere((d) => d == doc); + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: + imageDocs.map((e) => e['url']).toList(), + initialIndex: initialIndex, + ), + ); + } 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: 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 + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...attachments.map((file) => _AttachmentTile( + file: file, + onRemove: () => onRemoveNew(file), + )), + 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.add, 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, + 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, + ), + ], + ), + ), + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.red, size: 18), + onPressed: onRemove, + ), + ), + ], + ); + } +} diff --git a/lib/model/expense/comment_bottom_sheet.dart b/lib/model/expense/comment_bottom_sheet.dart new file mode 100644 index 0000000..447a629 --- /dev/null +++ b/lib/model/expense/comment_bottom_sheet.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; + +Future showCommentBottomSheet(BuildContext context, String actionText) async { + final commentController = TextEditingController(); + String? errorText; + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return StatefulBuilder( + builder: (context, setModalState) { + void submit() { + final comment = commentController.text.trim(); + if (comment.isEmpty) { + setModalState(() => errorText = 'Comment cannot be empty.'); + return; + } + Navigator.of(context).pop(comment); + } + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: BaseBottomSheet( + title: 'Add Comment for ${_capitalizeFirstLetter(actionText)}', + onCancel: () => Navigator.of(context).pop(), + onSubmit: submit, + isSubmitting: false, + submitText: 'Submit', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: commentController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Type your comment here...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: Colors.grey.shade100, + errorText: errorText, + ), + onChanged: (_) { + if (errorText != null) { + setModalState(() => errorText = null); + } + }, + ), + ], + ), + ), + ); + }, + ); + }, + ); +} + +String _capitalizeFirstLetter(String text) => + text.isEmpty ? text : text[0].toUpperCase() + text.substring(1); diff --git a/lib/model/expense/employee_selector_bottom_sheet.dart b/lib/model/expense/employee_selector_bottom_sheet.dart new file mode 100644 index 0000000..055a7c7 --- /dev/null +++ b/lib/model/expense/employee_selector_bottom_sheet.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/model/employee_model.dart'; + +class ReusableEmployeeSelectorBottomSheet extends StatelessWidget { + final TextEditingController searchController; + final RxList searchResults; + final RxBool isSearching; + final void Function(String) onSearch; + final void Function(EmployeeModel) onSelect; + + const ReusableEmployeeSelectorBottomSheet({ + super.key, + required this.searchController, + required this.searchResults, + required this.isSearching, + required this.onSearch, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: "Search Employee", + onCancel: () => Get.back(), + onSubmit: () {}, + showButtons: false, + child: Obx(() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: searchController, + decoration: InputDecoration( + hintText: "Search by name, email...", + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + onChanged: onSearch, + ), + MySpacing.height(12), + SizedBox( + height: 400, + child: isSearching.value + ? const Center(child: CircularProgressIndicator()) + : searchResults.isEmpty + ? Center( + child: MyText.bodyMedium( + "No employees found.", + fontWeight: 500, + ), + ) + : ListView.builder( + itemCount: searchResults.length, + itemBuilder: (_, index) { + final emp = searchResults[index]; + final fullName = + '${emp.firstName} ${emp.lastName}'.trim(); + return ListTile( + title: MyText.bodyLarge( + fullName.isNotEmpty ? fullName : "Unnamed", + fontWeight: 600, + ), + onTap: () { + onSelect(emp); + Get.back(); + }, + ); + }, + ), + ), + ], + ); + }), + ); + } +} diff --git a/lib/model/expense/employee_selector_for_filter_bottom_sheet.dart b/lib/model/expense/employee_selector_for_filter_bottom_sheet.dart new file mode 100644 index 0000000..d85b403 --- /dev/null +++ b/lib/model/expense/employee_selector_for_filter_bottom_sheet.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; + +class EmployeeSelectorBottomSheet extends StatefulWidget { + final RxList selectedEmployees; + final Future> Function(String) searchEmployees; + final String title; + + const EmployeeSelectorBottomSheet({ + super.key, + required this.selectedEmployees, + required this.searchEmployees, + this.title = "Select Employees", + }); + + @override + State createState() => + _EmployeeSelectorBottomSheetState(); +} + +class _EmployeeSelectorBottomSheetState + extends State { + final TextEditingController _searchController = TextEditingController(); + final RxBool isSearching = false.obs; + final RxList searchResults = [].obs; + + @override + void initState() { + super.initState(); + // Initial fetch (empty text gets all/none as you wish) + _searchEmployees(''); + } + + void _searchEmployees(String query) async { + isSearching.value = true; + List results = await widget.searchEmployees(query); + searchResults.assignAll(results); + isSearching.value = false; + } + + void _submitSelection() => + Get.back(result: widget.selectedEmployees.toList()); + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: widget.title, + onCancel: () => Get.back(), + onSubmit: _submitSelection, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Chips + Obx(() => widget.selectedEmployees.isEmpty + ? const SizedBox.shrink() + : Wrap( + spacing: 8, + children: widget.selectedEmployees + .map( + (emp) => Chip( + label: MyText(emp.name), + onDeleted: () => + widget.selectedEmployees.remove(emp), + ), + ) + .toList(), + )), + MySpacing.height(8), + + // Search box + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: "Search Employees...", + border: + OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + prefixIcon: Icon(Icons.search), + ), + onChanged: _searchEmployees, + ), + MySpacing.height(12), + + SizedBox( + height: 320, // CHANGE AS PER DESIGN! + child: Obx(() { + if (isSearching.value) { + return Center(child: CircularProgressIndicator()); + } + if (searchResults.isEmpty) { + return Padding( + padding: EdgeInsets.all(20), + child: + MyText('No results', style: MyTextStyle.bodyMedium()), + ); + } + return ListView.separated( + itemCount: searchResults.length, + separatorBuilder: (_, __) => Divider(height: 1), + itemBuilder: (context, index) { + final emp = searchResults[index]; + final isSelected = widget.selectedEmployees.contains(emp); + return ListTile( + title: MyText(emp.name), + trailing: isSelected + ? Icon(Icons.check_circle, color: Colors.indigo) + : Icon(Icons.radio_button_unchecked, + color: Colors.grey), + onTap: () { + if (isSelected) { + widget.selectedEmployees.remove(emp); + } else { + widget.selectedEmployees.add(emp); + } + }); + }, + ); + }), + ), + ], + )); + } +} diff --git a/lib/model/expense/expense_detail_model.dart b/lib/model/expense/expense_detail_model.dart new file mode 100644 index 0000000..e056d3c --- /dev/null +++ b/lib/model/expense/expense_detail_model.dart @@ -0,0 +1,278 @@ +class ExpenseDetailModel { + final String id; + final Project project; + final ExpensesType expensesType; + final PaymentMode paymentMode; + final Person paidBy; + final Person createdBy; + final String transactionDate; + final String createdAt; + final String supplerName; + final double amount; + final ExpenseStatus status; + final List nextStatus; + final bool preApproved; + final String transactionId; + final String description; + final String location; + final List documents; + final String? gstNumber; + final int noOfPersons; + final bool isActive; + + ExpenseDetailModel({ + required this.id, + required this.project, + required this.expensesType, + required this.paymentMode, + required this.paidBy, + required this.createdBy, + required this.transactionDate, + required this.createdAt, + required this.supplerName, + required this.amount, + required this.status, + required this.nextStatus, + required this.preApproved, + required this.transactionId, + required this.description, + required this.location, + required this.documents, + this.gstNumber, + required this.noOfPersons, + required this.isActive, + }); + + factory ExpenseDetailModel.fromJson(Map json) { + return ExpenseDetailModel( + id: json['id'] ?? '', + project: json['project'] != null ? Project.fromJson(json['project']) : Project.empty(), + expensesType: json['expensesType'] != null ? ExpensesType.fromJson(json['expensesType']) : ExpensesType.empty(), + paymentMode: json['paymentMode'] != null ? PaymentMode.fromJson(json['paymentMode']) : PaymentMode.empty(), + paidBy: json['paidBy'] != null ? Person.fromJson(json['paidBy']) : Person.empty(), + createdBy: json['createdBy'] != null ? Person.fromJson(json['createdBy']) : Person.empty(), + transactionDate: json['transactionDate'] ?? '', + createdAt: json['createdAt'] ?? '', + supplerName: json['supplerName'] ?? '', + amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + status: json['status'] != null ? ExpenseStatus.fromJson(json['status']) : ExpenseStatus.empty(), + nextStatus: (json['nextStatus'] as List?)?.map((e) => ExpenseStatus.fromJson(e)).toList() ?? [], + preApproved: json['preApproved'] ?? false, + transactionId: json['transactionId'] ?? '', + description: json['description'] ?? '', + location: json['location'] ?? '', + documents: (json['documents'] as List?)?.map((e) => ExpenseDocument.fromJson(e)).toList() ?? [], + gstNumber: json['gstNumber']?.toString(), + noOfPersons: json['noOfPersons'] ?? 0, + isActive: json['isActive'] ?? true, + ); + } +} + +class Project { + final String id; + final String name; + final String shortName; + final String projectAddress; + final String contactPerson; + final String startDate; + final String endDate; + final String projectStatusId; + + Project({ + required this.id, + required this.name, + required this.shortName, + required this.projectAddress, + required this.contactPerson, + required this.startDate, + required this.endDate, + required this.projectStatusId, + }); + + factory Project.fromJson(Map json) { + return Project( + id: json['id'] ?? '', + name: json['name'] ?? '', + shortName: json['shortName'] ?? '', + projectAddress: json['projectAddress'] ?? '', + contactPerson: json['contactPerson'] ?? '', + startDate: json['startDate'] ?? '', + endDate: json['endDate'] ?? '', + projectStatusId: json['projectStatusId'] ?? '', + ); + } + + factory Project.empty() => Project( + id: '', + name: '', + shortName: '', + projectAddress: '', + contactPerson: '', + startDate: '', + endDate: '', + projectStatusId: '', + ); +} + +class ExpensesType { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + + ExpensesType({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + }); + + factory ExpensesType.fromJson(Map json) { + return ExpensesType( + id: json['id'] ?? '', + name: json['name'] ?? '', + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + description: json['description'] ?? '', + ); + } + + factory ExpensesType.empty() => ExpensesType( + id: '', + name: '', + noOfPersonsRequired: false, + description: '', + ); +} + +class PaymentMode { + final String id; + final String name; + final String description; + + PaymentMode({ + required this.id, + required this.name, + required this.description, + }); + + factory PaymentMode.fromJson(Map json) { + return PaymentMode( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'] ?? '', + ); + } + + factory PaymentMode.empty() => PaymentMode( + id: '', + name: '', + description: '', + ); +} + +class Person { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String jobRoleName; + + Person({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory Person.fromJson(Map json) { + return Person( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + photo: json['photo'] is String ? json['photo'] : '', + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } + + factory Person.empty() => Person( + id: '', + firstName: '', + lastName: '', + photo: '', + jobRoleId: '', + jobRoleName: '', + ); +} + +class ExpenseStatus { + final String id; + final String name; + final String displayName; + final String description; + final String? permissionIds; + final String color; + final bool isSystem; + + ExpenseStatus({ + required this.id, + required this.name, + required this.displayName, + required this.description, + required this.permissionIds, + required this.color, + required this.isSystem, + }); + + factory ExpenseStatus.fromJson(Map json) { + return ExpenseStatus( + id: json['id'] ?? '', + name: json['name'] ?? '', + displayName: json['displayName'] ?? '', + description: json['description'] ?? '', + permissionIds: json['permissionIds']?.toString(), + color: json['color'] ?? '', + isSystem: json['isSystem'] ?? false, + ); + } + + factory ExpenseStatus.empty() => ExpenseStatus( + id: '', + name: '', + displayName: '', + description: '', + permissionIds: null, + color: '', + isSystem: false, + ); +} + +class ExpenseDocument { + final String documentId; + final String fileName; + final String contentType; + final String preSignedUrl; + final String thumbPreSignedUrl; + + ExpenseDocument({ + required this.documentId, + required this.fileName, + required this.contentType, + required this.preSignedUrl, + required this.thumbPreSignedUrl, + }); + + factory ExpenseDocument.fromJson(Map json) { + return ExpenseDocument( + documentId: json['documentId'] ?? '', + fileName: json['fileName'] ?? '', + contentType: json['contentType'] ?? '', + preSignedUrl: json['preSignedUrl'] ?? '', + thumbPreSignedUrl: json['thumbPreSignedUrl'] ?? '', + ); + } +} diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart new file mode 100644 index 0000000..bdebfb4 --- /dev/null +++ b/lib/model/expense/expense_list_model.dart @@ -0,0 +1,405 @@ +import 'dart:convert'; + +/// Parse the entire response +ExpenseResponse expenseResponseFromJson(String str) => + ExpenseResponse.fromJson(json.decode(str)); + +String expenseResponseToJson(ExpenseResponse data) => + json.encode(data.toJson()); + +class ExpenseResponse { + final bool success; + final String message; + final ExpenseData data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ExpenseResponse({ + required this.success, + required this.message, + required this.data, + required this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ExpenseResponse.fromJson(Map json) { + final dataField = json["data"]; + return ExpenseResponse( + success: json["success"] ?? false, + message: json["message"] ?? '', + data: (dataField is Map) + ? ExpenseData.fromJson(dataField) + : ExpenseData.empty(), + errors: json["errors"], + statusCode: json["statusCode"] ?? 0, + timestamp: DateTime.tryParse(json["timestamp"] ?? '') ?? DateTime.now(), + ); + } + + Map toJson() => { + "success": success, + "message": message, + "data": data.toJson(), + "errors": errors, + "statusCode": statusCode, + "timestamp": timestamp.toIso8601String(), + }; +} + +class ExpenseData { + final Filter? filter; + final int currentPage; + final int totalPages; + final int totalEntites; + final List data; + + ExpenseData({ + required this.filter, + required this.currentPage, + required this.totalPages, + required this.totalEntites, + required this.data, + }); + + factory ExpenseData.fromJson(Map json) => ExpenseData( + filter: json["filter"] != null ? Filter.fromJson(json["filter"]) : null, + currentPage: json["currentPage"] ?? 0, + totalPages: json["totalPages"] ?? 0, + totalEntites: json["totalEntites"] ?? 0, + data: (json["data"] as List? ?? []) + .map((x) => ExpenseModel.fromJson(x)) + .toList(), + ); + + factory ExpenseData.empty() => ExpenseData( + filter: null, + currentPage: 0, + totalPages: 0, + totalEntites: 0, + data: [], + ); + + Map toJson() => { + "filter": filter?.toJson(), + "currentPage": currentPage, + "totalPages": totalPages, + "totalEntites": totalEntites, + "data": List.from(data.map((x) => x.toJson())), + }; +} + +class Filter { + final List projectIds; + final List statusIds; + final List createdByIds; + final List paidById; + final DateTime? startDate; + final DateTime? endDate; + + Filter({ + required this.projectIds, + required this.statusIds, + required this.createdByIds, + required this.paidById, + required this.startDate, + required this.endDate, + }); + + factory Filter.fromJson(Map json) => Filter( + projectIds: List.from(json["projectIds"] ?? []), + statusIds: List.from(json["statusIds"] ?? []), + createdByIds: List.from(json["createdByIds"] ?? []), + paidById: List.from(json["paidById"] ?? []), + startDate: + json["startDate"] != null ? DateTime.tryParse(json["startDate"]) : null, + endDate: + json["endDate"] != null ? DateTime.tryParse(json["endDate"]) : null, + ); + + Map toJson() => { + "projectIds": projectIds, + "statusIds": statusIds, + "createdByIds": createdByIds, + "paidById": paidById, + "startDate": startDate?.toIso8601String(), + "endDate": endDate?.toIso8601String(), + }; +} + +// --- ExpenseModel and other classes remain same as you wrote --- +// I will include them here for completeness. + +class ExpenseModel { + final String id; + final Project project; + final ExpenseType expensesType; + final PaymentMode paymentMode; + final PaidBy paidBy; + final CreatedBy createdBy; + final DateTime transactionDate; + final DateTime createdAt; + final String supplerName; + final double amount; + final Status status; + final List nextStatus; + final bool preApproved; + + ExpenseModel({ + required this.id, + required this.project, + required this.expensesType, + required this.paymentMode, + required this.paidBy, + required this.createdBy, + required this.transactionDate, + required this.createdAt, + required this.supplerName, + required this.amount, + required this.status, + required this.nextStatus, + required this.preApproved, + }); + + factory ExpenseModel.fromJson(Map json) => ExpenseModel( + id: json["id"] ?? '', + project: Project.fromJson(json["project"] ?? {}), + expensesType: ExpenseType.fromJson(json["expensesType"] ?? {}), + paymentMode: PaymentMode.fromJson(json["paymentMode"] ?? {}), + paidBy: PaidBy.fromJson(json["paidBy"] ?? {}), + createdBy: CreatedBy.fromJson(json["createdBy"] ?? {}), + transactionDate: + DateTime.tryParse(json["transactionDate"] ?? '') ?? DateTime.now(), + createdAt: + DateTime.tryParse(json["createdAt"] ?? '') ?? DateTime.now(), + supplerName: json["supplerName"] ?? '', + amount: (json["amount"] ?? 0).toDouble(), + status: Status.fromJson(json["status"] ?? {}), + nextStatus: (json["nextStatus"] as List? ?? []) + .map((x) => Status.fromJson(x)) + .toList(), + preApproved: json["preApproved"] ?? false, + ); + + Map toJson() => { + "id": id, + "project": project.toJson(), + "expensesType": expensesType.toJson(), + "paymentMode": paymentMode.toJson(), + "paidBy": paidBy.toJson(), + "createdBy": createdBy.toJson(), + "transactionDate": transactionDate.toIso8601String(), + "createdAt": createdAt.toIso8601String(), + "supplerName": supplerName, + "amount": amount, + "status": status.toJson(), + "nextStatus": List.from(nextStatus.map((x) => x.toJson())), + "preApproved": preApproved, + }; +} + +class Project { + final String id; + final String name; + final String shortName; + final String projectAddress; + final String contactPerson; + final DateTime startDate; + final DateTime endDate; + final String projectStatusId; + + Project({ + required this.id, + required this.name, + required this.shortName, + required this.projectAddress, + required this.contactPerson, + required this.startDate, + required this.endDate, + required this.projectStatusId, + }); + + factory Project.fromJson(Map json) => Project( + id: json["id"] ?? '', + name: json["name"] ?? '', + shortName: json["shortName"] ?? '', + projectAddress: json["projectAddress"] ?? '', + contactPerson: json["contactPerson"] ?? '', + startDate: + DateTime.tryParse(json["startDate"] ?? '') ?? DateTime.now(), + endDate: DateTime.tryParse(json["endDate"] ?? '') ?? DateTime.now(), + projectStatusId: json["projectStatusId"] ?? '', + ); + + Map toJson() => { + "id": id, + "name": name, + "shortName": shortName, + "projectAddress": projectAddress, + "contactPerson": contactPerson, + "startDate": startDate.toIso8601String(), + "endDate": endDate.toIso8601String(), + "projectStatusId": projectStatusId, + }; +} + +class ExpenseType { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + + ExpenseType({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + }); + + factory ExpenseType.fromJson(Map json) => ExpenseType( + id: json["id"] ?? '', + name: json["name"] ?? '', + noOfPersonsRequired: json["noOfPersonsRequired"] ?? false, + description: json["description"] ?? '', + ); + + Map toJson() => { + "id": id, + "name": name, + "noOfPersonsRequired": noOfPersonsRequired, + "description": description, + }; +} + +class PaymentMode { + final String id; + final String name; + final String description; + + PaymentMode({ + required this.id, + required this.name, + required this.description, + }); + + factory PaymentMode.fromJson(Map json) => PaymentMode( + id: json["id"] ?? '', + name: json["name"] ?? '', + description: json["description"] ?? '', + ); + + Map toJson() => { + "id": id, + "name": name, + "description": description, + }; +} + +class PaidBy { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String? jobRoleName; + + PaidBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + this.jobRoleName, + }); + + factory PaidBy.fromJson(Map json) => PaidBy( + id: json["id"] ?? '', + firstName: json["firstName"] ?? '', + lastName: json["lastName"] ?? '', + photo: json["photo"] ?? '', + jobRoleId: json["jobRoleId"] ?? '', + jobRoleName: json["jobRoleName"], + ); + + Map toJson() => { + "id": id, + "firstName": firstName, + "lastName": lastName, + "photo": photo, + "jobRoleId": jobRoleId, + "jobRoleName": jobRoleName, + }; +} + +class CreatedBy { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String? jobRoleName; + + CreatedBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + this.jobRoleName, + }); + + factory CreatedBy.fromJson(Map json) => CreatedBy( + id: json["id"] ?? '', + firstName: json["firstName"] ?? '', + lastName: json["lastName"] ?? '', + photo: json["photo"] ?? '', + jobRoleId: json["jobRoleId"] ?? '', + jobRoleName: json["jobRoleName"], + ); + + Map toJson() => { + "id": id, + "firstName": firstName, + "lastName": lastName, + "photo": photo, + "jobRoleId": jobRoleId, + "jobRoleName": jobRoleName, + }; +} + +class Status { + final String id; + final String name; + final String displayName; + final String description; + final String color; + final bool isSystem; + + Status({ + required this.id, + required this.name, + required this.displayName, + required this.description, + required this.color, + required this.isSystem, + }); + + factory Status.fromJson(Map json) => Status( + id: json["id"] ?? '', + name: json["name"] ?? '', + displayName: json["displayName"] ?? '', + description: json["description"] ?? '', + color: (json["color"] ?? '').replaceAll("'", ''), + isSystem: json["isSystem"] ?? false, + ); + + Map toJson() => { + "id": id, + "name": name, + "displayName": displayName, + "description": description, + "color": color, + "isSystem": isSystem, + }; +} diff --git a/lib/model/expense/expense_status_model.dart b/lib/model/expense/expense_status_model.dart new file mode 100644 index 0000000..8970c59 --- /dev/null +++ b/lib/model/expense/expense_status_model.dart @@ -0,0 +1,25 @@ +class ExpenseStatusModel { + final String id; + final String name; + final String description; + final bool isSystem; + final bool isActive; + + ExpenseStatusModel({ + required this.id, + required this.name, + required this.description, + required this.isSystem, + required this.isActive, + }); + + factory ExpenseStatusModel.fromJson(Map json) { + return ExpenseStatusModel( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/expense/expense_type_model.dart b/lib/model/expense/expense_type_model.dart new file mode 100644 index 0000000..0cf8be9 --- /dev/null +++ b/lib/model/expense/expense_type_model.dart @@ -0,0 +1,25 @@ +class ExpenseTypeModel { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + final bool isActive; + + ExpenseTypeModel({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + required this.isActive, + }); + + factory ExpenseTypeModel.fromJson(Map json) { + return ExpenseTypeModel( + id: json['id'], + name: json['name'], + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + description: json['description'] ?? '', + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/expense/payment_types_model.dart b/lib/model/expense/payment_types_model.dart new file mode 100644 index 0000000..d3f6024 --- /dev/null +++ b/lib/model/expense/payment_types_model.dart @@ -0,0 +1,22 @@ +class PaymentModeModel { + final String id; + final String name; + final String description; + final bool isActive; + + PaymentModeModel({ + required this.id, + required this.name, + required this.description, + required this.isActive, + }); + + factory PaymentModeModel.fromJson(Map json) { + return PaymentModeModel( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/expense/reimbursement_bottom_sheet.dart b/lib/model/expense/reimbursement_bottom_sheet.dart new file mode 100644 index 0000000..c996174 --- /dev/null +++ b/lib/model/expense/reimbursement_bottom_sheet.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; + +import 'package:marco/controller/expense/expense_detail_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; + +class ReimbursementBottomSheet extends StatefulWidget { + final String expenseId; + final String statusId; + final void Function() onClose; + final Future Function({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + required String statusId, + }) onSubmit; + + const ReimbursementBottomSheet({ + super.key, + required this.expenseId, + required this.onClose, + required this.onSubmit, + required this.statusId, + }); + + @override + State createState() => + _ReimbursementBottomSheetState(); +} + +class _ReimbursementBottomSheetState extends State { + final ExpenseDetailController controller = + Get.find(); + + final TextEditingController commentCtrl = TextEditingController(); + final TextEditingController txnCtrl = TextEditingController(); + final RxString dateStr = ''.obs; + + @override + void dispose() { + commentCtrl.dispose(); + txnCtrl.dispose(); + super.dispose(); + } + + void _showEmployeeList() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + backgroundColor: Colors.transparent, + builder: (_) => ReusableEmployeeSelectorBottomSheet( + searchController: controller.employeeSearchController, + searchResults: controller.employeeSearchResults, + isSearching: controller.isSearchingEmployees, + onSearch: controller.searchEmployees, + onSelect: (emp) => controller.selectedReimbursedBy.value = emp, + ), + ); + + // Optional cleanup + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); + } + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(16), + ); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + return BaseBottomSheet( + title: "Reimbursement Info", + isSubmitting: controller.isLoading.value, + onCancel: () { + widget.onClose(); + Navigator.pop(context); + }, + onSubmit: () async { + if (commentCtrl.text.trim().isEmpty || + txnCtrl.text.trim().isEmpty || + dateStr.value.isEmpty || + controller.selectedReimbursedBy.value == null) { + showAppSnackbar( + title: "Incomplete", + message: "Please fill all fields", + type: SnackbarType.warning, + ); + return; + } + + final success = await widget.onSubmit( + comment: commentCtrl.text.trim(), + reimburseTransactionId: txnCtrl.text.trim(), + reimburseDate: dateStr.value, + reimburseById: controller.selectedReimbursedBy.value!.id, + statusId: widget.statusId, + ); + + if (success) { + Get.back(); + showAppSnackbar( + title: "Success", + message: "Reimbursement submitted", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: controller.errorMessage.value, + type: SnackbarType.error, + ); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("Comment"), + MySpacing.height(8), + TextField( + controller: commentCtrl, + decoration: _inputDecoration("Enter comment"), + ), + MySpacing.height(16), + MyText.labelMedium("Transaction ID"), + MySpacing.height(8), + TextField( + controller: txnCtrl, + decoration: _inputDecoration("Enter transaction ID"), + ), + MySpacing.height(16), + MyText.labelMedium("Reimbursement Date"), + MySpacing.height(8), + GestureDetector( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: dateStr.value.isEmpty + ? DateTime.now() + : DateFormat('yyyy-MM-dd').parse(dateStr.value), + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (picked != null) { + dateStr.value = DateFormat('yyyy-MM-dd').format(picked); + } + }, + child: AbsorbPointer( + child: TextField( + controller: TextEditingController(text: dateStr.value), + decoration: _inputDecoration("Select Date").copyWith( + suffixIcon: const Icon(Icons.calendar_today), + ), + ), + ), + ), + MySpacing.height(16), + MyText.labelMedium("Reimbursed By"), + MySpacing.height(8), + GestureDetector( + onTap: _showEmployeeList, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.selectedReimbursedBy.value == null + ? "Select Paid By" + : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ), + ], + ), + ); + }); + } +} diff --git a/lib/routes.dart b/lib/routes.dart index 391eeb9..4f27766 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -17,6 +17,7 @@ import 'package:marco/view/auth/login_option_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_auth_screen.dart'; import 'package:marco/view/directory/directory_main_screen.dart'; +import 'package:marco/view/expense/expense_screen.dart'; class AuthMiddleware extends GetMiddleware { @override @@ -65,6 +66,11 @@ getPageRoute() { name: '/dashboard/directory-main-page', page: () => DirectoryMainScreen(), middlewares: [AuthMiddleware()]), + // Expense + GetPage( + name: '/dashboard/expense-main-page', + page: () => ExpenseMainScreen(), + middlewares: [AuthMiddleware()]), // Authentication GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), diff --git a/lib/view/auth/forgot_password_screen.dart b/lib/view/auth/forgot_password_screen.dart index fbc15f3..9875b89 100644 --- a/lib/view/auth/forgot_password_screen.dart +++ b/lib/view/auth/forgot_password_screen.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:get/get.dart'; import 'package:marco/controller/auth/forgot_password_controller.dart'; -import 'package:marco/helpers/widgets/my_button.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/images.dart'; class ForgotPasswordScreen extends StatefulWidget { @@ -22,10 +21,10 @@ class _ForgotPasswordScreenState extends State final ForgotPasswordController controller = Get.put(ForgotPasswordController()); - late AnimationController _controller; - late Animation _logoAnimation; + late final AnimationController _controller; + late final Animation _logoAnimation; - bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage"); bool _isLoading = false; @override @@ -64,29 +63,9 @@ class _ForgotPasswordScreenState extends State SafeArea( child: Center( child: Column( - mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 24), - ScaleTransition( - scale: _logoAnimation, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], - ), - padding: const EdgeInsets.all(20), - child: Image.asset(Images.logoDark), - ), - ), + _buildAnimatedLogo(), const SizedBox(height: 8), Expanded( child: SingleChildScrollView( @@ -96,36 +75,10 @@ class _ForgotPasswordScreenState extends State child: Column( children: [ const SizedBox(height: 12), - MyText( - "Welcome to Marco", - fontSize: 24, - fontWeight: 800, - color: Colors.black87, - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - MyText( - "Streamline Project Management\nBoost Productivity with Automation.", - fontSize: 14, - color: Colors.black54, - textAlign: TextAlign.center, - ), + _buildWelcomeText(), if (_isBetaEnvironment) ...[ const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(6), - ), - child: MyText( - 'BETA', - color: Colors.white, - fontWeight: 600, - fontSize: 12, - ), - ), + _buildBetaBadge(), ], const SizedBox(height: 36), _buildForgotCard(), @@ -143,6 +96,66 @@ class _ForgotPasswordScreenState extends State ); } + Widget _buildAnimatedLogo() { + return ScaleTransition( + scale: _logoAnimation, + child: Container( + width: 100, + height: 100, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Image.asset(Images.logoDark), + ), + ); + } + + Widget _buildWelcomeText() { + return Column( + children: [ + MyText( + "Welcome to Marco", + fontSize: 24, + fontWeight: 600, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildBetaBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(6), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, + ), + ); + } + Widget _buildForgotCard() { return Container( padding: const EdgeInsets.all(24), @@ -165,7 +178,7 @@ class _ForgotPasswordScreenState extends State MyText( 'Forgot Password', fontSize: 20, - fontWeight: 700, + fontWeight: 600, color: Colors.black87, textAlign: TextAlign.center, ), @@ -177,70 +190,80 @@ class _ForgotPasswordScreenState extends State textAlign: TextAlign.center, ), const SizedBox(height: 30), - TextFormField( - validator: controller.basicValidator.getValidation('email'), - controller: controller.basicValidator.getController('email'), - keyboardType: TextInputType.emailAddress, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - labelText: "Email Address", - labelStyle: const TextStyle(color: Colors.black54), - filled: true, - fillColor: Colors.grey.shade100, - prefixIcon: const Icon(LucideIcons.mail, size: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - ), - ), + _buildEmailInput(), const SizedBox(height: 32), - MyButton.rounded( - onPressed: _isLoading ? null : _handleForgotPassword, - elevation: 2, - padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), - borderRadiusAll: 10, - backgroundColor: _isLoading - ? contentTheme.brandRed.withOpacity(0.6) - : contentTheme.brandRed, - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : MyText.bodyMedium( - 'Send Reset Link', - color: Colors.white, - fontWeight: 700, - fontSize: 16, - ), - ), + _buildResetButton(), const SizedBox(height: 20), - TextButton.icon( - onPressed: () async => await LocalStorage.logout(), - icon: const Icon(Icons.arrow_back, - size: 18, color: Colors.redAccent), - label: MyText.bodyMedium( - 'Back to Login', - color: contentTheme.brandRed, - fontWeight: 600, - fontSize: 14, - ), - ), + _buildBackButton(), ], ), ), ); } + + Widget _buildEmailInput() { + return TextFormField( + validator: controller.basicValidator.getValidation('email'), + controller: controller.basicValidator.getController('email'), + keyboardType: TextInputType.emailAddress, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + labelText: "Email Address", + labelStyle: const TextStyle(color: Colors.black54), + filled: true, + fillColor: Colors.grey.shade100, + prefixIcon: const Icon(LucideIcons.mail, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + ); + } + + Widget _buildResetButton() { + return MyButton.rounded( + onPressed: _isLoading ? null : _handleForgotPassword, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), + borderRadiusAll: 10, + backgroundColor: _isLoading + ? contentTheme.brandRed.withOpacity(0.6) + : contentTheme.brandRed, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : MyText.bodyMedium( + 'Send Reset Link', + color: Colors.white, + fontWeight: 600, + fontSize: 16, + ), + ); + } + + Widget _buildBackButton() { + return TextButton.icon( + onPressed: () async => await LocalStorage.logout(), + icon: const Icon(Icons.arrow_back, size: 18, color: Colors.redAccent), + label: MyText.bodyMedium( + 'Back to Login', + color: contentTheme.brandRed, + fontWeight: 600, + fontSize: 14, + ), + ); + } } -// Red background using dynamic brandRed class _RedWaveBackground extends StatelessWidget { final Color brandRed; const _RedWaveBackground({required this.brandRed}); diff --git a/lib/view/auth/login_option_screen.dart b/lib/view/auth/login_option_screen.dart index 2432577..c3364ed 100644 --- a/lib/view/auth/login_option_screen.dart +++ b/lib/view/auth/login_option_screen.dart @@ -26,9 +26,8 @@ class WelcomeScreen extends StatefulWidget { class _WelcomeScreenState extends State with SingleTickerProviderStateMixin, UIMixin { - late AnimationController _controller; - late Animation _logoAnimation; - + late final AnimationController _controller; + late final Animation _logoAnimation; bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); @override @@ -54,42 +53,39 @@ class _WelcomeScreenState extends State void _showLoginDialog(BuildContext context, LoginOption option) { showDialog( context: context, - barrierDismissible: false, // Prevent dismiss on outside tap + barrierDismissible: false, builder: (_) => Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), insetPadding: const EdgeInsets.all(24), child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Row with title and close button - Row( - children: [ - Expanded( - child: MyText( - option == LoginOption.email - ? "Login with Email" - : "Login with OTP", - fontSize: 20, - fontWeight: 700, - ), + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: MyText( + option == LoginOption.email + ? "Login with Email" + : "Login with OTP", + fontSize: 20, + fontWeight: 700, ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - const SizedBox(height: 20), - option == LoginOption.email - ? EmailLoginForm() - : const OTPLoginScreen(), - ], - ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 20), + option == LoginOption.email + ? EmailLoginForm() + : const OTPLoginScreen(), + ], ), ), ), @@ -100,6 +96,7 @@ class _WelcomeScreenState extends State @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; + final isNarrow = screenWidth < 500; return Scaffold( body: Stack( @@ -110,72 +107,18 @@ class _WelcomeScreenState extends State child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: screenWidth < 500 ? double.infinity : 420, - ), + constraints: BoxConstraints(maxWidth: isNarrow ? double.infinity : 420), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Logo with circular background - ScaleTransition( - scale: _logoAnimation, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black12, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], - ), - padding: const EdgeInsets.all(20), - child: Image.asset(Images.logoDark), - ), - ), - + _buildLogo(), const SizedBox(height: 24), - - // Welcome Text - MyText( - "Welcome to Marco", - fontSize: 26, - fontWeight: 800, - color: Colors.black87, - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - MyText( - "Streamline Project Management\nBoost Productivity with Automation.", - fontSize: 14, - color: Colors.black54, - textAlign: TextAlign.center, - ), - + _buildWelcomeText(), if (_isBetaEnvironment) ...[ const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(6), - ), - child: MyText( - 'BETA', - color: Colors.white, - fontWeight: 600, - fontSize: 12, - ), - ), + _buildBetaBadge(), ], - const SizedBox(height: 36), - _buildActionButton( context, label: "Login with Username", @@ -196,7 +139,6 @@ class _WelcomeScreenState extends State icon: LucideIcons.phone_call, option: null, ), - const SizedBox(height: 36), MyText( 'App version 1.0.0', @@ -214,6 +156,60 @@ class _WelcomeScreenState extends State ); } + Widget _buildLogo() { + return ScaleTransition( + scale: _logoAnimation, + child: Container( + width: 100, + height: 100, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))], + ), + child: Image.asset(Images.logoDark), + ), + ); + } + + Widget _buildWelcomeText() { + return Column( + children: [ + MyText( + "Welcome to Marco", + fontSize: 26, + fontWeight: 800, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildBetaBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(6), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, + ), + ); + } + Widget _buildActionButton( BuildContext context, { required String label, @@ -236,9 +232,7 @@ class _WelcomeScreenState extends State style: ElevatedButton.styleFrom( backgroundColor: contentTheme.brandRed, foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), elevation: 4, shadowColor: Colors.black26, ), @@ -254,7 +248,7 @@ class _WelcomeScreenState extends State } } -/// Custom red wave background shifted lower to reduce red area at top +// Red wave background painter class _RedWaveBackground extends StatelessWidget { final Color brandRed; const _RedWaveBackground({required this.brandRed}); @@ -270,7 +264,6 @@ class _RedWaveBackground extends StatelessWidget { class _WavePainter extends CustomPainter { final Color brandRed; - _WavePainter(this.brandRed); @override @@ -284,18 +277,8 @@ class _WavePainter extends CustomPainter { final path1 = Path() ..moveTo(0, size.height * 0.2) - ..quadraticBezierTo( - size.width * 0.25, - size.height * 0.05, - size.width * 0.5, - size.height * 0.15, - ) - ..quadraticBezierTo( - size.width * 0.75, - size.height * 0.25, - size.width, - size.height * 0.1, - ) + ..quadraticBezierTo(size.width * 0.25, size.height * 0.05, size.width * 0.5, size.height * 0.15) + ..quadraticBezierTo(size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1) ..lineTo(size.width, 0) ..lineTo(0, 0) ..close(); @@ -303,15 +286,9 @@ class _WavePainter extends CustomPainter { canvas.drawPath(path1, paint1); final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); - final path2 = Path() ..moveTo(0, size.height * 0.25) - ..quadraticBezierTo( - size.width * 0.4, - size.height * 0.1, - size.width, - size.height * 0.2, - ) + ..quadraticBezierTo(size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2) ..lineTo(size.width, 0) ..lineTo(0, 0) ..close(); diff --git a/lib/view/dashboard/Attendence/attendance_logs_tab.dart b/lib/view/dashboard/Attendence/attendance_logs_tab.dart new file mode 100644 index 0000000..f456b7d --- /dev/null +++ b/lib/view/dashboard/Attendence/attendance_logs_tab.dart @@ -0,0 +1,189 @@ +// lib/view/attendance/tabs/attendance_logs_tab.dart +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_container.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +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 { + final AttendanceController controller; + + const AttendanceLogsTab({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Obx(() { + final logs = List.of(controller.attendanceLogs); + logs.sort((a, b) { + final aDate = a.checkIn ?? DateTime(0); + final bDate = b.checkIn ?? DateTime(0); + return bDate.compareTo(aDate); + }); + + final dateFormat = DateFormat('dd MMM yyyy'); + final dateRangeText = controller.startDateAttendance != null && + controller.endDateAttendance != null + ? '${dateFormat.format(controller.endDateAttendance!)} - ${dateFormat.format(controller.startDateAttendance!)}' + : 'Select date range'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.titleMedium("Attendance Logs", fontWeight: 600), + controller.isLoading.value + ? const SizedBox( + height: 20, width: 20, child: LinearProgressIndicator()) + : MyText.bodySmall( + dateRangeText, + fontWeight: 600, + color: Colors.grey[700], + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (controller.isLoadingAttendanceLogs.value) + SkeletonLoaders.employeeListSkeletonLoader() + else if (logs.isEmpty) + const SizedBox( + height: 120, + child: Center( + child: Text("No Attendance Logs Found for this Project"), + ), + ) + else + MyCard.bordered( + paddingAll: 8, + child: Column( + children: List.generate(logs.length, (index) { + final employee = logs[index]; + final currentDate = employee.checkIn != null + ? DateFormat('dd MMM yyyy').format(employee.checkIn!) + : ''; + final previousDate = + index > 0 && logs[index - 1].checkIn != null + ? DateFormat('dd MMM yyyy') + .format(logs[index - 1].checkIn!) + : ''; + final showDateHeader = + index == 0 || currentDate != previousDate; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showDateHeader) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: MyText.bodyMedium( + currentDate, + fontWeight: 700, + ), + ), + MyContainer( + paddingAll: 8, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar( + firstName: employee.firstName, + lastName: employee.lastName, + size: 31, + ), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: MyText.bodyMedium( + employee.name, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ), + MySpacing.width(6), + Flexible( + child: MyText.bodySmall( + '(${employee.designation})', + fontWeight: 600, + color: Colors.grey[700], + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + MySpacing.height(8), + if (employee.checkIn != null || + employee.checkOut != null) + Row( + children: [ + if (employee.checkIn != null) ...[ + const Icon(Icons.arrow_circle_right, + size: 16, color: Colors.green), + MySpacing.width(4), + MyText.bodySmall( + DateFormat('hh:mm a') + .format(employee.checkIn!), + fontWeight: 600, + ), + MySpacing.width(16), + ], + if (employee.checkOut != null) ...[ + const Icon(Icons.arrow_circle_left, + size: 16, color: Colors.red), + MySpacing.width(4), + MyText.bodySmall( + DateFormat('hh:mm a') + .format(employee.checkOut!), + fontWeight: 600, + ), + ], + ], + ), + MySpacing.height(12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AttendanceActionButton( + employee: employee, + attendanceController: controller, + ), + MySpacing.width(8), + AttendanceLogViewButton( + employee: employee, + attendanceController: controller, + ), + ], + ), + ], + ), + ), + ], + ), + ), + if (index != logs.length - 1) + Divider(color: Colors.grey.withOpacity(0.3)), + ], + ); + }), + ), + ), + ], + ); + }); + } +} diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index 12d8e34..ebdd68d 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -2,844 +2,240 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/utils/my_shadow.dart'; -import 'package:marco/helpers/widgets/my_card.dart'; -import 'package:marco/helpers/widgets/my_container.dart'; import 'package:marco/helpers/widgets/my_flex.dart'; import 'package:marco/helpers/widgets/my_flex_item.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:marco/controller/permission_controller.dart'; -import 'package:intl/intl.dart'; -import 'package:marco/helpers/widgets/avatar.dart'; -import 'package:marco/model/attendance/log_details_view.dart'; -import 'package:marco/model/attendance/attendence_action_button.dart'; -import 'package:marco/model/attendance/regualrize_action_button.dart'; import 'package:marco/model/attendance/attendence_filter_sheet.dart'; import 'package:marco/controller/project_controller.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/view/dashboard/Attendence/regularization_requests_tab.dart'; +import 'package:marco/view/dashboard/Attendence/attendance_logs_tab.dart'; +import 'package:marco/view/dashboard/Attendence/todays_attendance_tab.dart'; class AttendanceScreen extends StatefulWidget { - AttendanceScreen({super.key}); + const AttendanceScreen({super.key}); @override State createState() => _AttendanceScreenState(); } class _AttendanceScreenState extends State with UIMixin { - final AttendanceController attendanceController = - Get.put(AttendanceController()); - final PermissionController permissionController = - Get.put(PermissionController()); + final attendanceController = Get.put(AttendanceController()); + final permissionController = Get.put(PermissionController()); + final projectController = Get.find(); String selectedTab = 'todaysAttendance'; + @override void initState() { super.initState(); - final projectController = Get.find(); - final attendanceController = Get.find(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - // Listen for future changes in selected project - ever(projectController.selectedProjectId!, (projectId) async { - if (projectId != null && projectId.isNotEmpty) { - try { - await attendanceController.loadAttendanceData(projectId); - attendanceController.update(['attendance_dashboard_controller']); - } catch (e) { - debugPrint("Error updating data on project change: $e"); - } - } + WidgetsBinding.instance.addPostFrameCallback((_) { + // Listen for future project selection changes + ever(projectController.selectedProjectId, (projectId) async { + if (projectId.isNotEmpty) await _loadData(projectId); }); - // Load data initially if project is already selected - final initialProjectId = projectController.selectedProjectId?.value; - if (initialProjectId != null && initialProjectId.isNotEmpty) { - try { - await attendanceController.loadAttendanceData(initialProjectId); - attendanceController.update(['attendance_dashboard_controller']); - } catch (e) { - debugPrint("Error loading initial data: $e"); - } - } + // Load initial data + final projectId = projectController.selectedProjectId.value; + if (projectId.isNotEmpty) _loadData(projectId); }); } + Future _loadData(String projectId) async { + try { + await attendanceController.loadAttendanceData(projectId); + attendanceController.update(['attendance_dashboard_controller']); + } catch (e) { + debugPrint("Error loading data: $e"); + } + } + + Future _refreshData() async { + final projectId = projectController.selectedProjectId.value; + if (projectId.isNotEmpty) await _loadData(projectId); + } + + Widget _buildAppBar() { + return AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Attendance', fontWeight: 700, color: Colors.black), + MySpacing.height(2), + GetBuilder( + builder: (projectController) { + final projectName = projectController.selectedProject?.name ?? 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + 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, color: Colors.blueAccent, size: 20), + ), + ), + ), + const SizedBox(width: 4), + MyText.bodyMedium("Refresh", fontWeight: 600), + Tooltip( + message: 'Refresh Data', + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: _refreshData, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon(Icons.refresh, color: Colors.green, size: 22), + ), + ), + ), + ], + ); + } + + Widget _buildNoProjectWidget() { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: MyText.titleMedium( + 'No Records Found', + fontWeight: 600, + color: Colors.grey[600], + ), + ), + ); + } + + Widget _buildSelectedTabContent() { + switch (selectedTab) { + case 'attendanceLogs': + return AttendanceLogsTab(controller: attendanceController); + case 'regularizationRequests': + return RegularizationRequestsTab(controller: attendanceController); + case 'todaysAttendance': + default: + return TodaysAttendanceTab(controller: attendanceController); + } + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Attendance', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.height(2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ), + appBar: PreferredSize(preferredSize: const Size.fromHeight(72), child: _buildAppBar()), body: SafeArea( - child: SingleChildScrollView( - padding: MySpacing.x(0), - child: GetBuilder( - init: attendanceController, - tag: 'attendance_dashboard_controller', - builder: (controller) { - final selectedProjectId = - Get.find().selectedProjectId?.value; - final bool noProjectSelected = - selectedProjectId == null || selectedProjectId.isEmpty; - return Column( + child: GetBuilder( + init: attendanceController, + tag: 'attendance_dashboard_controller', + builder: (controller) { + final selectedProjectId = projectController.selectedProjectId.value; + final noProjectSelected = selectedProjectId.isEmpty; + + return SingleChildScrollView( + padding: MySpacing.zero, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MySpacing.height(flexSpacing), - Row( - mainAxisAlignment: MainAxisAlignment.end, + _buildFilterAndRefreshRow(), + MySpacing.height(flexSpacing), + MyFlex( children: [ - MyText.bodyMedium("Filter", fontWeight: 600), - Tooltip( - message: 'Filter Project', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: () async { - final result = await showModalBottomSheet< - Map>( - context: context, - isScrollControlled: true, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12)), - ), - builder: (context) => AttendanceFilterBottomSheet( - controller: attendanceController, - permissionController: permissionController, - selectedTab: selectedTab, - ), - ); - - if (result != null) { - final selectedProjectId = - Get.find() - .selectedProjectId - ?.value; - - final selectedView = - result['selectedTab'] as String?; - - if (selectedProjectId != null) { - 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: MouseRegion( - cursor: SystemMouseCursors.click, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - Icons.filter_list_alt, - color: Colors.blueAccent, - size: 28, - ), - ), - ), - ), - ), - const SizedBox(width: 4), - MyText.bodyMedium("Refresh", fontWeight: 600), - Tooltip( - message: 'Refresh Data', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: () async { - final projectId = Get.find() - .selectedProjectId - ?.value; - if (projectId != null && projectId.isNotEmpty) { - try { - await attendanceController - .loadAttendanceData(projectId); - attendanceController.update( - ['attendance_dashboard_controller']); - } catch (e) { - debugPrint("Error refreshing data: $e"); - } - } - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - Icons.refresh, - color: Colors.green, - size: 28, - ), - ), - ), - ), + MyFlexItem( + sizes: 'lg-12 md-12 sm-12', + child: noProjectSelected + ? _buildNoProjectWidget() + : _buildSelectedTabContent(), ), ], ), - MySpacing.height(flexSpacing), - MyFlex(children: [ - MyFlexItem( - sizes: 'lg-12 md-12 sm-12', - child: noProjectSelected - ? Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: MyText.titleMedium( - 'No Records Found', - fontWeight: 600, - color: Colors.grey[600], - ), - ), - ) - : selectedTab == 'todaysAttendance' - ? employeeListTab() - : selectedTab == 'attendanceLogs' - ? employeeLog() - : regularizationScreen(), - ), - ]), ], - ); - }, - ), + ), + ); + }, ), ), ); } - - String _formatDate(DateTime date) { - return "${date.day}/${date.month}/${date.year}"; - } - - Widget employeeListTab() { - return Obx(() { - final isLoading = attendanceController.isLoadingEmployees.value; - final employees = attendanceController.employees; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - children: [ - Expanded( - child: MyText.titleMedium( - "Today's Attendance", - fontWeight: 600, - ), - ), - MyText.bodySmall( - _formatDate(DateTime.now()), - fontWeight: 600, - color: Colors.grey[700], - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - if (isLoading) - SkeletonLoaders.employeeListSkeletonLoader() - else if (employees.isEmpty) - SizedBox( - height: 120, - child: Center( - child: MyText.bodySmall( - "No Employees Assigned to This Project", - fontWeight: 600, - ), - ), - ) - else - MyCard.bordered( - borderRadiusAll: 4, - border: Border.all(color: Colors.grey.withOpacity(0.2)), - shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), - paddingAll: 8, - child: Column( - children: List.generate(employees.length, (index) { - final employee = employees[index]; - return Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MyContainer( - paddingAll: 5, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: employee.firstName, - lastName: employee.lastName, - size: 31, - ), - MySpacing.width(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: - WrapCrossAlignment.center, - spacing: - 6, // spacing between name and designation - children: [ - MyText.bodyMedium( - employee.name, - fontWeight: 600, - overflow: TextOverflow.visible, - maxLines: null, - ), - MyText.bodySmall( - '(${employee.designation})', - fontWeight: 600, - overflow: TextOverflow.visible, - maxLines: null, - color: Colors.grey[700], - ), - ], - ), - MySpacing.height(8), - (employee.checkIn != null || - employee.checkOut != null) - ? Row( - children: [ - if (employee.checkIn != null) ...[ - const Icon( - Icons.arrow_circle_right, - size: 16, - color: Colors.green), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format( - employee.checkIn!), - fontWeight: 600, - overflow: - TextOverflow.ellipsis, - ), - ), - MySpacing.width(16), - ], - if (employee.checkOut != - null) ...[ - const Icon( - Icons.arrow_circle_left, - size: 16, - color: Colors.red), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format( - employee.checkOut!), - fontWeight: 600, - overflow: - TextOverflow.ellipsis, - ), - ), - ], - ], - ) - : const SizedBox.shrink(), - MySpacing.height(12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - AttendanceActionButton( - employee: employee, - attendanceController: - attendanceController, - ), - if (employee.checkIn != null) ...[ - MySpacing.width(8), - AttendanceLogViewButton( - employee: employee, - attendanceController: - attendanceController, - ), - ], - ], - ), - ], - ), - ), - ], - ), - ), - ), - if (index != employees.length - 1) - Divider( - color: Colors.grey.withOpacity(0.3), - thickness: 1, - height: 1, - ), - ], - ); - }), - ), - ), - ], - ); - }); - } - - Widget employeeLog() { - return Obx(() { - final logs = List.of(attendanceController.attendanceLogs); - logs.sort((a, b) { - final aDate = a.checkIn ?? DateTime(0); - final bDate = b.checkIn ?? DateTime(0); - return bDate.compareTo(aDate); - }); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: MyText.titleMedium( - "Attendance Logs", - fontWeight: 600, - ), - ), - Obx(() { - if (attendanceController.isLoading.value) { - return const SizedBox( - height: 20, - width: 20, - child: LinearProgressIndicator(), - ); - } - final dateFormat = DateFormat('dd MMM yyyy'); - final dateRangeText = attendanceController - .startDateAttendance != - null && - attendanceController.endDateAttendance != null - ? '${dateFormat.format(attendanceController.endDateAttendance!)} - ${dateFormat.format(attendanceController.startDateAttendance!)}' - : 'Select date range'; - - return MyText.bodySmall( - dateRangeText, - fontWeight: 600, - color: Colors.grey[700], - overflow: TextOverflow.ellipsis, - ); - }), - ], - ), - ), - if (attendanceController.isLoadingAttendanceLogs.value) - SkeletonLoaders.employeeListSkeletonLoader() - else if (logs.isEmpty) - SizedBox( - height: 120, - child: Center( - child: MyText.bodySmall( - "No Attendance Logs Found for this Project", - fontWeight: 600, - ), - ), - ) - else - MyCard.bordered( - borderRadiusAll: 4, - border: Border.all(color: Colors.grey.withOpacity(0.2)), - shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), - paddingAll: 8, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: List.generate(logs.length, (index) { - final employee = logs[index]; - final currentDate = employee.checkIn != null - ? DateFormat('dd MMM yyyy').format(employee.checkIn!) - : ''; - final previousDate = - index > 0 && logs[index - 1].checkIn != null - ? DateFormat('dd MMM yyyy') - .format(logs[index - 1].checkIn!) - : ''; - - final showDateHeader = - index == 0 || currentDate != previousDate; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDateHeader) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: MyText.bodyMedium( - currentDate, - fontWeight: 700, - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MyContainer( - paddingAll: 8, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: employee.firstName, - lastName: employee.lastName, - size: 31, - ), - MySpacing.width(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: MyText.bodyMedium( - employee.name, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - MySpacing.width(6), - Flexible( - child: MyText.bodySmall( - '(${employee.designation})', - fontWeight: 600, - overflow: TextOverflow.ellipsis, - maxLines: 1, - color: Colors.grey[700], - ), - ), - ], - ), - MySpacing.height(8), - (employee.checkIn != null || - employee.checkOut != null) - ? Row( - children: [ - if (employee.checkIn != null) ...[ - const Icon( - Icons.arrow_circle_right, - size: 16, - color: Colors.green), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format( - employee.checkIn!), - fontWeight: 600, - overflow: - TextOverflow.ellipsis, - ), - ), - MySpacing.width(16), - ], - if (employee.checkOut != - null) ...[ - const Icon( - Icons.arrow_circle_left, - size: 16, - color: Colors.red), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format( - employee.checkOut!), - fontWeight: 600, - overflow: - TextOverflow.ellipsis, - ), - ), - ], - ], - ) - : const SizedBox.shrink(), - MySpacing.height(12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: AttendanceActionButton( - employee: employee, - attendanceController: - attendanceController, - ), - ), - MySpacing.width(8), - Flexible( - child: AttendanceLogViewButton( - employee: employee, - attendanceController: - attendanceController, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - if (index != logs.length - 1) - Divider( - color: Colors.grey.withOpacity(0.3), - thickness: 1, - height: 1, - ), - ], - ); - }), - ), - ), - ], - ); - }); - } - - Widget regularizationScreen() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0), - child: MyText.titleMedium( - "Regularization Requests", - fontWeight: 600, - ), - ), - Obx(() { - final employees = attendanceController.regularizationLogs; - if (attendanceController.isLoadingRegularizationLogs.value) { - return SkeletonLoaders.employeeListSkeletonLoader(); - } - - if (employees.isEmpty) { - return SizedBox( - height: 120, - child: Center( - child: MyText.bodySmall( - "No Regularization Requests Found for this Project", - fontWeight: 600, - ), - ), - ); - } - return MyCard.bordered( - borderRadiusAll: 4, - border: Border.all(color: Colors.grey.withOpacity(0.2)), - shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), - paddingAll: 8, - child: Column( - children: List.generate(employees.length, (index) { - final employee = employees[index]; - return Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MyContainer( - paddingAll: 8, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: employee.firstName, - lastName: employee.lastName, - size: 31, - ), - MySpacing.width(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: MyText.bodyMedium( - employee.name, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - MySpacing.width(6), - Flexible( - child: MyText.bodySmall( - '(${employee.role})', - fontWeight: 600, - overflow: TextOverflow.ellipsis, - maxLines: 1, - color: Colors.grey[700], - ), - ), - ], - ), - MySpacing.height(8), - Row( - children: [ - if (employee.checkIn != null) ...[ - const Icon(Icons.arrow_circle_right, - size: 16, color: Colors.green), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format(employee.checkIn!), - fontWeight: 600, - overflow: TextOverflow.ellipsis, - ), - ), - MySpacing.width(16), - ], - if (employee.checkOut != null) ...[ - const Icon(Icons.arrow_circle_left, - size: 16, color: Colors.red), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format(employee.checkOut!), - fontWeight: 600, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ], - ), - MySpacing.height(12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - RegularizeActionButton( - attendanceController: - attendanceController, - log: employee, - uniqueLogKey: employee.employeeId, - action: ButtonActions.approve, - ), - const SizedBox(width: 8), - RegularizeActionButton( - attendanceController: - attendanceController, - log: employee, - uniqueLogKey: employee.employeeId, - action: ButtonActions.reject, - ), - const SizedBox(width: 8), - if (employee.checkIn != null) ...[ - AttendanceLogViewButton( - employee: employee, - attendanceController: - attendanceController, - ), - ], - ], - ), - ], - ), - ), - ], - ), - ), - ), - if (index != employees.length - 1) - Divider( - color: Colors.grey.withOpacity(0.3), - thickness: 1, - height: 1, - ), - ], - ); - }), - ), - ); - }), - ], - ); - } } diff --git a/lib/view/dashboard/Attendence/regularization_requests_tab.dart b/lib/view/dashboard/Attendence/regularization_requests_tab.dart new file mode 100644 index 0000000..802075c --- /dev/null +++ b/lib/view/dashboard/Attendence/regularization_requests_tab.dart @@ -0,0 +1,157 @@ +// lib/view/attendance/tabs/regularization_requests_tab.dart +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_container.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/attendance/log_details_view.dart'; +import 'package:marco/model/attendance/regualrize_action_button.dart'; + +class RegularizationRequestsTab extends StatelessWidget { + final AttendanceController controller; + + const RegularizationRequestsTab({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0), + child: MyText.titleMedium("Regularization Requests", fontWeight: 600), + ), + Obx(() { + final employees = controller.regularizationLogs; + + if (controller.isLoadingRegularizationLogs.value) { + return SkeletonLoaders.employeeListSkeletonLoader(); + } + + if (employees.isEmpty) { + return const SizedBox( + height: 120, + child: Center( + child: Text("No Regularization Requests Found for this Project"), + ), + ); + } + + return MyCard.bordered( + paddingAll: 8, + child: Column( + children: List.generate(employees.length, (index) { + final employee = employees[index]; + return Column( + children: [ + MyContainer( + paddingAll: 8, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar( + firstName: employee.firstName, + lastName: employee.lastName, + size: 31, + ), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: MyText.bodyMedium( + employee.name, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ), + MySpacing.width(6), + Flexible( + child: MyText.bodySmall( + '(${employee.role})', + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ), + MySpacing.height(8), + if (employee.checkIn != null || + employee.checkOut != null) + Row( + children: [ + if (employee.checkIn != null) ...[ + const Icon(Icons.arrow_circle_right, + size: 16, color: Colors.green), + MySpacing.width(4), + MyText.bodySmall( + DateFormat('hh:mm a') + .format(employee.checkIn!), + fontWeight: 600, + ), + MySpacing.width(16), + ], + if (employee.checkOut != null) ...[ + const Icon(Icons.arrow_circle_left, + size: 16, color: Colors.red), + MySpacing.width(4), + MyText.bodySmall( + DateFormat('hh:mm a') + .format(employee.checkOut!), + fontWeight: 600, + ), + ], + ], + ), + MySpacing.height(12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + RegularizeActionButton( + attendanceController: controller, + log: employee, + uniqueLogKey: employee.employeeId, + action: ButtonActions.approve, + ), + const SizedBox(width: 8), + RegularizeActionButton( + attendanceController: controller, + log: employee, + uniqueLogKey: employee.employeeId, + action: ButtonActions.reject, + ), + const SizedBox(width: 8), + if (employee.checkIn != null) + AttendanceLogViewButton( + employee: employee, + attendanceController: controller, + ), + ], + ), + ], + ), + ), + ], + ), + ), + if (index != employees.length - 1) + Divider(color: Colors.grey.withOpacity(0.3)), + ], + ); + }), + ), + ); + }), + ], + ); + } +} diff --git a/lib/view/dashboard/Attendence/todays_attendance_tab.dart b/lib/view/dashboard/Attendence/todays_attendance_tab.dart new file mode 100644 index 0000000..caa1f06 --- /dev/null +++ b/lib/view/dashboard/Attendence/todays_attendance_tab.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_container.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +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 TodaysAttendanceTab extends StatelessWidget { + final AttendanceController controller; + + const TodaysAttendanceTab({super.key, required this.controller}); + + String _formatDate(DateTime date) { + return "${date.day}/${date.month}/${date.year}"; + } + + @override + Widget build(BuildContext context) { + return Obx(() { + final isLoading = controller.isLoadingEmployees.value; + final employees = controller.employees; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + children: [ + Expanded( + child: MyText.titleMedium("Today's Attendance", fontWeight: 600), + ), + MyText.bodySmall( + _formatDate(DateTime.now()), + fontWeight: 600, + color: Colors.grey[700], + ), + ], + ), + ), + if (isLoading) + SkeletonLoaders.employeeListSkeletonLoader() + else if (employees.isEmpty) + const SizedBox(height: 120, child: Center(child: Text("No Employees Assigned"))) + else + MyCard.bordered( + paddingAll: 8, + child: Column( + children: List.generate(employees.length, (index) { + final employee = employees[index]; + return Column( + children: [ + MyContainer( + paddingAll: 5, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: employee.firstName, lastName: employee.lastName, size: 31), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + children: [ + 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) + Row( + children: [ + if (employee.checkIn != null) + Row( + children: [ + const Icon(Icons.arrow_circle_right, size: 16, color: Colors.green), + MySpacing.width(4), + Text(DateFormat('hh:mm a').format(employee.checkIn!)), + ], + ), + if (employee.checkOut != null) ...[ + MySpacing.width(16), + const Icon(Icons.arrow_circle_left, size: 16, color: Colors.red), + MySpacing.width(4), + Text(DateFormat('hh:mm a').format(employee.checkOut!)), + ], + ], + ), + MySpacing.height(12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AttendanceActionButton( + employee: employee, + attendanceController: controller, + ), + if (employee.checkIn != null) ...[ + MySpacing.width(8), + AttendanceLogViewButton( + employee: employee, + attendanceController: controller, + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + if (index != employees.length - 1) + Divider(color: Colors.grey.withOpacity(0.3)), + ], + ); + }), + ), + ), + ], + ); + }); + } +} diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 99274eb..32c1c18 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -22,9 +22,10 @@ class DashboardScreen extends StatefulWidget { static const String attendanceRoute = "/dashboard/attendance"; static const String tasksRoute = "/dashboard/daily-task"; static const String dailyTasksRoute = "/dashboard/daily-task-planing"; - static const String dailyTasksProgressRoute = - "/dashboard/daily-task-progress"; + static const String dailyTasksProgressRoute = "/dashboard/daily-task-progress"; static const String directoryMainPageRoute = "/dashboard/directory-main-page"; + static const String expenseMainPageRoute = "/dashboard/expense-main-page"; + @override State createState() => _DashboardScreenState(); @@ -96,6 +97,8 @@ class _DashboardScreenState extends State with UIMixin { DashboardScreen.dailyTasksProgressRoute), _StatItem(LucideIcons.folder, "Directory", contentTheme.info, DashboardScreen.directoryMainPageRoute), + _StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info, + DashboardScreen.expenseMainPageRoute), ]; return GetBuilder( diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index 9550f70..f670c18 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -16,32 +16,20 @@ import 'package:marco/model/directory/add_comment_bottom_sheet.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; -class ContactDetailScreen extends StatefulWidget { - final ContactModel contact; - - const ContactDetailScreen({super.key, required this.contact}); - - @override - State createState() => _ContactDetailScreenState(); -} - +// HELPER: Delta to HTML conversion String _convertDeltaToHtml(dynamic delta) { final buffer = StringBuffer(); bool inList = false; for (var op in delta.toList()) { - final data = op.data?.toString() ?? ''; + final String data = op.data?.toString() ?? ''; final attr = op.attributes ?? {}; + final bool isListItem = attr.containsKey('list'); - final isListItem = attr.containsKey('list'); - - // Start list if (isListItem && !inList) { buffer.write('
    '); inList = true; } - - // Close list if we are not in list mode anymore if (!isListItem && inList) { buffer.write('
'); inList = false; @@ -49,15 +37,12 @@ String _convertDeltaToHtml(dynamic delta) { if (isListItem) buffer.write('
  • '); - // Apply inline styles if (attr.containsKey('bold')) buffer.write(''); if (attr.containsKey('italic')) buffer.write(''); if (attr.containsKey('underline')) buffer.write(''); if (attr.containsKey('strike')) buffer.write(''); if (attr.containsKey('link')) buffer.write(''); - buffer.write(data.replaceAll('\n', '')); - if (attr.containsKey('link')) buffer.write(''); if (attr.containsKey('strike')) buffer.write(''); if (attr.containsKey('underline')) buffer.write(''); @@ -66,14 +51,21 @@ String _convertDeltaToHtml(dynamic delta) { if (isListItem) buffer.write('
  • '); - else if (data.contains('\n')) buffer.write('
    '); + else if (data.contains('\n')) { + buffer.write('
    '); + } } - if (inList) buffer.write(''); - return buffer.toString(); } +class ContactDetailScreen extends StatefulWidget { + final ContactModel contact; + const ContactDetailScreen({super.key, required this.contact}); + @override + State createState() => _ContactDetailScreenState(); +} + class _ContactDetailScreenState extends State { late final DirectoryController directoryController; late final ProjectController projectController; @@ -85,7 +77,6 @@ class _ContactDetailScreenState extends State { directoryController = Get.find(); projectController = Get.find(); contact = widget.contact; - WidgetsBinding.instance.addPostFrameCallback((_) { directoryController.fetchCommentsForContact(contact.id); }); @@ -103,13 +94,12 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSubHeader(), + const Divider(height: 1, thickness: 0.5, color: Colors.grey), Expanded( - child: TabBarView( - children: [ - _buildDetailsTab(), - _buildCommentsTab(context), - ], - ), + child: TabBarView(children: [ + _buildDetailsTab(), + _buildCommentsTab(context), + ]), ), ], ), @@ -130,10 +120,8 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => - Get.offAllNamed('/dashboard/directory-main-page'), + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + onPressed: () => Get.offAllNamed('/dashboard/directory-main-page'), ), MySpacing.width(8), Expanded( @@ -141,30 +129,10 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - MyText.titleLarge('Contact Profile', - fontWeight: 700, color: Colors.black), + MyText.titleLarge('Contact Profile', fontWeight: 700, color: Colors.black), MySpacing.height(2), GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], - ); - }, + builder: (p) => ProjectLabel(p.selectedProject?.name), ), ], ), @@ -176,38 +144,30 @@ class _ContactDetailScreenState extends State { } Widget _buildSubHeader() { + final firstName = contact.name.split(" ").first; + final lastName = contact.name.split(" ").length > 1 ? contact.name.split(" ").last : ""; + return Padding( padding: MySpacing.xy(16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Avatar( - firstName: contact.name.split(" ").first, - lastName: contact.name.split(" ").length > 1 - ? contact.name.split(" ").last - : "", - size: 35, - backgroundColor: Colors.indigo, - ), - MySpacing.width(12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall(contact.name, - fontWeight: 600, color: Colors.black), - MySpacing.height(2), - MyText.bodySmall(contact.organization, - fontWeight: 500, color: Colors.grey[700]), - ], - ), - ], - ), + Row(children: [ + Avatar(firstName: firstName, lastName: lastName, size: 35, backgroundColor: Colors.indigo), + MySpacing.width(12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(contact.name, fontWeight: 600, color: Colors.black), + MySpacing.height(2), + MyText.bodySmall(contact.organization, fontWeight: 500, color: Colors.grey[700]), + ], + ), + ]), TabBar( labelColor: Colors.red, unselectedLabelColor: Colors.black, - indicator: MaterialIndicator( + indicator: MaterialIndicator( color: Colors.red, height: 4, topLeftRadius: 8, @@ -226,33 +186,37 @@ class _ContactDetailScreenState extends State { } Widget _buildDetailsTab() { - final email = contact.contactEmails.isNotEmpty - ? contact.contactEmails.first.emailAddress - : "-"; - - final phone = contact.contactPhones.isNotEmpty - ? contact.contactPhones.first.phoneNumber - : "-"; - final tags = contact.tags.map((e) => e.name).join(", "); - final bucketNames = contact.bucketIds .map((id) => directoryController.contactBuckets .firstWhereOrNull((b) => b.id == id) ?.name) .whereType() .join(", "); - - final projectNames = contact.projectIds - ?.map((id) => projectController.projects - .firstWhereOrNull((p) => p.id == id) - ?.name) - .whereType() - .join(", ") ?? - "-"; - + final projectNames = contact.projectIds?.map((id) => + projectController.projects.firstWhereOrNull((p) => p.id == id)?.name).whereType().join(", ") ?? "-"; final category = contact.contactCategory?.name ?? "-"; + Widget multiRows({required List items, required IconData icon, required String label, required String typeLabel, required Function(String)? onTap, required Function(String)? onLongPress}) { + return items.isNotEmpty + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _iconInfoRow(icon, label, items.first, onTap: () => onTap?.call(items.first), onLongPress: () => onLongPress?.call(items.first)), + ...items.skip(1).map( + (val) => _iconInfoRow( + null, + '', + val, + onTap: () => onTap?.call(val), + onLongPress: () => onLongPress?.call(val), + ), + ), + ], + ) + : _iconInfoRow(icon, label, "-"); + } + return Stack( children: [ SingleChildScrollView( @@ -261,28 +225,38 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ MySpacing.height(12), + // BASIC INFO CARD _infoCard("Basic Info", [ - _iconInfoRow(Icons.email, "Email", email, - onTap: () => LauncherUtils.launchEmail(email), - onLongPress: () => LauncherUtils.copyToClipboard(email, - typeLabel: "Email")), - _iconInfoRow(Icons.phone, "Phone", phone, - onTap: () => LauncherUtils.launchPhone(phone), - onLongPress: () => LauncherUtils.copyToClipboard(phone, - typeLabel: "Phone")), + multiRows( + items: contact.contactEmails.map((e) => e.emailAddress).toList(), + icon: Icons.email, + label: "Email", + typeLabel: "Email", + onTap: (email) => LauncherUtils.launchEmail(email), + onLongPress: (email) => LauncherUtils.copyToClipboard(email, typeLabel: "Email"), + ), + multiRows( + items: contact.contactPhones.map((p) => p.phoneNumber).toList(), + icon: Icons.phone, + label: "Phone", + typeLabel: "Phone", + onTap: (phone) => LauncherUtils.launchPhone(phone), + onLongPress: (phone) => LauncherUtils.copyToClipboard(phone, typeLabel: "Phone"), + ), _iconInfoRow(Icons.location_on, "Address", contact.address), ]), + // ORGANIZATION CARD _infoCard("Organization", [ - _iconInfoRow( - Icons.business, "Organization", contact.organization), + _iconInfoRow(Icons.business, "Organization", contact.organization), _iconInfoRow(Icons.category, "Category", category), ]), + // META INFO CARD _infoCard("Meta Info", [ _iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), - _iconInfoRow(Icons.folder_shared, "Contact Buckets", - bucketNames.isNotEmpty ? bucketNames : "-"), + _iconInfoRow(Icons.folder_shared, "Contact Buckets", bucketNames.isNotEmpty ? bucketNames : "-"), _iconInfoRow(Icons.work_outline, "Projects", projectNames), ]), + // DESCRIPTION CARD _infoCard("Description", [ MySpacing.height(6), Align( @@ -294,7 +268,7 @@ class _ContactDetailScreenState extends State { textAlign: TextAlign.left, ), ), - ]) + ]), ], ), ), @@ -309,25 +283,17 @@ class _ContactDetailScreenState extends State { isScrollControlled: true, backgroundColor: Colors.transparent, ); - if (result == true) { await directoryController.fetchContacts(); final updated = - directoryController.allContacts.firstWhereOrNull( - (c) => c.id == contact.id, - ); + directoryController.allContacts.firstWhereOrNull((c) => c.id == contact.id); if (updated != null) { - setState(() { - contact = updated; - }); + setState(() => contact = updated); } } }, icon: const Icon(Icons.edit, color: Colors.white), - label: const Text( - "Edit Contact", - style: TextStyle(color: Colors.white), - ), + label: const Text("Edit Contact", style: TextStyle(color: Colors.white)), ), ), ], @@ -337,24 +303,17 @@ class _ContactDetailScreenState extends State { Widget _buildCommentsTab(BuildContext context) { return Obx(() { final contactId = contact.id; - if (!directoryController.contactCommentsMap.containsKey(contactId)) { return const Center(child: CircularProgressIndicator()); } - - final comments = directoryController - .getCommentsForContact(contactId) - .reversed - .toList(); - + final comments = directoryController.getCommentsForContact(contactId).reversed.toList(); final editingId = directoryController.editingCommentId.value; return Stack( children: [ comments.isEmpty - ? Center( - child: - MyText.bodyLarge("No comments yet.", color: Colors.grey), + ? Center( + child: MyText.bodyLarge("No comments yet.", color: Colors.grey), ) : Padding( padding: MySpacing.xy(12, 12), @@ -362,137 +321,10 @@ class _ContactDetailScreenState extends State { padding: const EdgeInsets.only(bottom: 100), itemCount: comments.length, separatorBuilder: (_, __) => MySpacing.height(14), - itemBuilder: (_, index) { - final comment = comments[index]; - final isEditing = editingId == comment.id; - - final initials = comment.createdBy.firstName.isNotEmpty - ? comment.createdBy.firstName[0].toUpperCase() - : "?"; - - final decodedDelta = HtmlToDelta().convert(comment.note); - - final quillController = isEditing - ? quill.QuillController( - document: quill.Document.fromDelta(decodedDelta), - selection: TextSelection.collapsed( - offset: decodedDelta.length), - ) - : null; - - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - padding: MySpacing.xy(8, 7), - decoration: BoxDecoration( - color: isEditing ? Colors.indigo[50] : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isEditing - ? Colors.indigo - : Colors.grey.shade300, - width: 1.2, - ), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: Offset(0, 2), - ) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Row - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: initials, - lastName: '', - size: 36), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - MyText.bodyMedium( - "By: ${comment.createdBy.firstName}", - fontWeight: 600, - color: Colors.indigo[800], - ), - MySpacing.height(4), - MyText.bodySmall( - DateTimeUtils.convertUtcToLocal( - comment.createdAt.toString(), - format: 'dd MMM yyyy, hh:mm a', - ), - color: Colors.grey[600], - ), - ], - ), - ), - IconButton( - icon: Icon( - isEditing ? Icons.close : Icons.edit, - size: 20, - color: Colors.indigo, - ), - onPressed: () { - directoryController.editingCommentId.value = - isEditing ? null : comment.id; - }, - ), - ], - ), - // Comment Content - if (isEditing && quillController != null) - CommentEditorCard( - controller: quillController, - onCancel: () { - directoryController.editingCommentId.value = - null; - }, - onSave: (controller) async { - final delta = controller.document.toDelta(); - final htmlOutput = _convertDeltaToHtml(delta); - final updated = - comment.copyWith(note: htmlOutput); - - await directoryController - .updateComment(updated); - - // ✅ Re-fetch comments to get updated list - await directoryController - .fetchCommentsForContact(contactId); - - // ✅ Exit editing mode - directoryController.editingCommentId.value = - null; - }, - ) - else - html.Html( - data: comment.note, - style: { - "body": html.Style( - margin: html.Margins.zero, - padding: html.HtmlPaddings.zero, - fontSize: html.FontSize.medium, - color: Colors.black87, - ), - }, - ), - ], - ), - ); - }, + itemBuilder: (_, index) => _buildCommentItem(comments[index], editingId, contact.id), ), ), - - // Floating Action Button - if (directoryController.editingCommentId.value == null) + if (editingId == null) Positioned( bottom: 20, right: 20, @@ -503,17 +335,12 @@ class _ContactDetailScreenState extends State { AddCommentBottomSheet(contactId: contactId), isScrollControlled: true, ); - if (result == true) { - await directoryController - .fetchCommentsForContact(contactId); + await directoryController.fetchCommentsForContact(contactId); } }, icon: const Icon(Icons.add_comment, color: Colors.white), - label: const Text( - "Add Comment", - style: TextStyle(color: Colors.white), - ), + label: const Text("Add Comment", style: TextStyle(color: Colors.white)), ), ), ], @@ -521,25 +348,127 @@ class _ContactDetailScreenState extends State { }); } - Widget _iconInfoRow(IconData icon, String label, String value, - {VoidCallback? onTap, VoidCallback? onLongPress}) { + Widget _buildCommentItem(comment, editingId, contactId) { + final isEditing = editingId == comment.id; + final initials = comment.createdBy.firstName.isNotEmpty + ? comment.createdBy.firstName[0].toUpperCase() + : "?"; + final decodedDelta = HtmlToDelta().convert(comment.note); + final quillController = isEditing + ? quill.QuillController( + document: quill.Document.fromDelta(decodedDelta), + selection: TextSelection.collapsed(offset: decodedDelta.length), + ) + : null; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: MySpacing.xy(8, 7), + decoration: BoxDecoration( + color: isEditing ? Colors.indigo[50] : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isEditing ? Colors.indigo : Colors.grey.shade300, + width: 1.2, + ), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: initials, lastName: '', size: 36), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium("By: ${comment.createdBy.firstName}", + fontWeight: 600, color: Colors.indigo[800]), + MySpacing.height(4), + MyText.bodySmall( + DateTimeUtils.convertUtcToLocal( + comment.createdAt.toString(), + format: 'dd MMM yyyy, hh:mm a', + ), + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + size: 20, + color: Colors.indigo, + ), + onPressed: () { + directoryController.editingCommentId.value = isEditing ? null : comment.id; + }, + ), + ], + ), + // Comment Content + if (isEditing && quillController != null) + CommentEditorCard( + controller: quillController, + onCancel: () => directoryController.editingCommentId.value = null, + onSave: (ctrl) async { + final delta = ctrl.document.toDelta(); + final htmlOutput = _convertDeltaToHtml(delta); + final updated = comment.copyWith(note: htmlOutput); + await directoryController.updateComment(updated); + await directoryController.fetchCommentsForContact(contactId); + directoryController.editingCommentId.value = null; + }, + ) + else + html.Html( + data: comment.note, + style: { + "body": html.Style( + margin: html.Margins.zero, + padding: html.HtmlPaddings.zero, + fontSize: html.FontSize.medium, + color: Colors.black87, + ), + }, + ), + ], + ), + ); + } + + Widget _iconInfoRow( + IconData? icon, + String label, + String value, { + VoidCallback? onTap, + VoidCallback? onLongPress, + }) { return Padding( - padding: MySpacing.y(8), + padding: MySpacing.y(2), child: GestureDetector( onTap: onTap, onLongPress: onLongPress, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 22, color: Colors.indigo), - MySpacing.width(12), + if (icon != null) ...[ + Icon(icon, size: 22, color: Colors.indigo), + MySpacing.width(12), + ] else + const SizedBox(width: 34), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodySmall(label, - fontWeight: 600, color: Colors.black87), - MySpacing.height(2), + if (label.isNotEmpty) + MyText.bodySmall(label, fontWeight: 600, color: Colors.black87), + if (label.isNotEmpty) MySpacing.height(2), MyText.bodyMedium(value, color: Colors.grey[800]), ], ), @@ -560,8 +489,7 @@ class _ContactDetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.titleSmall(title, - fontWeight: 700, color: Colors.indigo[700]), + MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]), MySpacing.height(8), ...children, ], @@ -570,3 +498,26 @@ class _ContactDetailScreenState extends State { ); } } + +// Helper widget for Project label in AppBar +class ProjectLabel extends StatelessWidget { + final String? projectName; + const ProjectLabel(this.projectName, {super.key}); + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.work_outline, size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName ?? 'Select Project', + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + } +} diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 5e1bcd3..1cecd6c 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -24,8 +24,7 @@ class DirectoryView extends StatefulWidget { class _DirectoryViewState extends State { final DirectoryController controller = Get.find(); final TextEditingController searchController = TextEditingController(); - final PermissionController permissionController = - Get.put(PermissionController()); + final PermissionController permissionController = Get.put(PermissionController()); Future _refreshDirectory() async { try { @@ -213,7 +212,7 @@ class _DirectoryViewState extends State { borderRadius: BorderRadius.circular(10), ), child: IconButton( - icon: Icon(Icons.filter_alt_outlined, + icon: Icon(Icons.tune, size: 20, color: isFilterActive ? Colors.indigo @@ -267,7 +266,7 @@ class _DirectoryViewState extends State { itemBuilder: (context) { List> menuItems = []; - // Section: Actions (Always visible now) + // Section: Actions menuItems.add( const PopupMenuItem( enabled: false, @@ -282,6 +281,37 @@ class _DirectoryViewState extends State { ), ); + // Create Bucket option + menuItems.add( + PopupMenuItem( + value: 2, + child: Row( + children: const [ + Icon(Icons.add_box_outlined, + size: 20, color: Colors.black87), + SizedBox(width: 10), + Expanded(child: Text("Create Bucket")), + Icon(Icons.chevron_right, + size: 20, color: Colors.red), + ], + ), + onTap: () { + Future.delayed(Duration.zero, () async { + final created = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const CreateBucketBottomSheet(), + ); + if (created == true) { + await controller.fetchBuckets(); + } + }); + }, + ), + ); + + // Manage Buckets option menuItems.add( PopupMenuItem( value: 1, @@ -318,6 +348,7 @@ class _DirectoryViewState extends State { ), ); + // Show Inactive switch menuItems.add( PopupMenuItem( value: 0, @@ -409,62 +440,69 @@ class _DirectoryViewState extends State { color: Colors.grey[700], overflow: TextOverflow.ellipsis), MySpacing.height(8), - ...contact.contactEmails.map((e) => - GestureDetector( - onTap: () => LauncherUtils.launchEmail( - e.emailAddress), - onLongPress: () => - LauncherUtils.copyToClipboard( - e.emailAddress, - typeLabel: 'Email'), - child: Padding( - padding: - const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - const Icon(Icons.email_outlined, - size: 16, color: Colors.indigo), - MySpacing.width(4), - ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 180), - child: MyText.labelSmall( - e.emailAddress, - overflow: TextOverflow.ellipsis, - color: Colors.indigo, - decoration: - TextDecoration.underline, - ), - ), - ], - ), - ), - )), - ...contact.contactPhones.map((p) => Padding( - padding: const EdgeInsets.only( - bottom: 8, top: 4), + + // Show only the first email (if present) + if (contact.contactEmails.isNotEmpty) + GestureDetector( + onTap: () => LauncherUtils.launchEmail( + contact.contactEmails.first.emailAddress), + onLongPress: () => + LauncherUtils.copyToClipboard( + contact.contactEmails.first.emailAddress, + typeLabel: 'Email', + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 4), child: Row( children: [ - GestureDetector( - onTap: () => - LauncherUtils.launchPhone( - p.phoneNumber), + const Icon(Icons.email_outlined, + size: 16, color: Colors.indigo), + MySpacing.width(4), + Expanded( + child: MyText.labelSmall( + contact.contactEmails.first.emailAddress, + overflow: TextOverflow.ellipsis, + color: Colors.indigo, + decoration: + TextDecoration.underline, + ), + ), + ], + ), + ), + ), + + // Show only the first phone (if present) + if (contact.contactPhones.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + bottom: 8, top: 4), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => LauncherUtils + .launchPhone(contact + .contactPhones + .first + .phoneNumber), onLongPress: () => LauncherUtils.copyToClipboard( - p.phoneNumber, - typeLabel: 'Phone'), + contact.contactPhones.first + .phoneNumber, + typeLabel: 'Phone', + ), child: Row( children: [ - const Icon(Icons.phone_outlined, + const Icon( + Icons.phone_outlined, size: 16, color: Colors.indigo), MySpacing.width(4), - ConstrainedBox( - constraints: - const BoxConstraints( - maxWidth: 140), + Expanded( child: MyText.labelSmall( - p.phoneNumber, + contact.contactPhones.first + .phoneNumber, overflow: TextOverflow.ellipsis, color: Colors.indigo, @@ -475,19 +513,22 @@ class _DirectoryViewState extends State { ], ), ), - MySpacing.width(8), - GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp( - p.phoneNumber), - child: const FaIcon( - FontAwesomeIcons.whatsapp, - color: Colors.green, - size: 16), + ), + MySpacing.width(8), + GestureDetector( + onTap: () => + LauncherUtils.launchWhatsApp( + contact.contactPhones.first + .phoneNumber), + child: const FaIcon( + FontAwesomeIcons.whatsapp, + color: Colors.green, + size: 16, ), - ], - ), - )), + ), + ], + ), + ), if (tags.isNotEmpty) ...[ MySpacing.height(2), MyText.labelSmall(tags.join(', '), diff --git a/lib/view/employees/assign_employee_bottom_sheet.dart b/lib/view/employees/assign_employee_bottom_sheet.dart index a689024..61440ea 100644 --- a/lib/view/employees/assign_employee_bottom_sheet.dart +++ b/lib/view/employees/assign_employee_bottom_sheet.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/employee/assign_projects_controller.dart'; import 'package:marco/model/global_project_model.dart'; -import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AssignProjectBottomSheet extends StatefulWidget { final String employeeId; @@ -23,6 +24,7 @@ class AssignProjectBottomSheet extends StatefulWidget { class _AssignProjectBottomSheetState extends State { late final AssignProjectController assignController; + final ScrollController _scrollController = ScrollController(); @override void initState() { @@ -38,229 +40,139 @@ class _AssignProjectBottomSheetState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + return GetBuilder( + tag: '${widget.employeeId}_${widget.jobRoleId}', + builder: (_) { + return BaseBottomSheet( + title: "Assign to Project", + onCancel: () => Navigator.pop(context), + onSubmit: _handleAssign, + submitText: "Assign", + child: Obx(() { + if (assignController.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } - return SafeArea( - top: false, - child: DraggableScrollableSheet( - expand: false, - maxChildSize: 0.9, - minChildSize: 0.4, - initialChildSize: 0.7, - builder: (_, scrollController) { - return Container( - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 12, - offset: Offset(0, -2), + final projects = assignController.allProjects; + if (projects.isEmpty) { + return const Center(child: Text('No projects available.')); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + 'Select the projects to assign this employee.', + color: Colors.grey[600], + ), + MySpacing.height(8), + + // Select All + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Projects (${projects.length})', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + TextButton( + onPressed: () { + assignController.toggleSelectAll(); + }, + child: Obx(() { + return Text( + assignController.areAllSelected() + ? 'Deselect All' + : 'Select All', + style: const TextStyle( + color: Colors.blueAccent, + fontWeight: FontWeight.w600, + ), + ); + }), + ), + ], + ), + + // List of Projects + SizedBox( + height: 300, + child: ListView.builder( + controller: _scrollController, + itemCount: projects.length, + itemBuilder: (context, index) { + final GlobalProjectModel project = projects[index]; + return Obx(() { + final bool isSelected = + assignController.isProjectSelected( + project.id.toString(), + ); + return Theme( + data: Theme.of(context).copyWith( + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? Colors.blueAccent + : Colors.white, + ), + side: const BorderSide( + color: Colors.black, + width: 2, + ), + checkColor: + WidgetStateProperty.all(Colors.white), + ), + ), + child: CheckboxListTile( + dense: true, + value: isSelected, + title: Text( + project.name, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + onChanged: (checked) { + assignController.toggleProjectSelection( + project.id.toString(), + checked ?? false, + ); + }, + activeColor: Colors.blueAccent, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + ), + ); + }); + }, + ), ), ], - ), - padding: MySpacing.all(16), - child: Obx(() { - if (assignController.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } - - final projects = assignController.allProjects; - if (projects.isEmpty) { - return const Center(child: Text('No projects available.')); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Drag Handle - Center( - child: Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(10), - ), - ), - ), - MySpacing.height(12), - - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.titleMedium('Assign to Project', fontWeight: 700), - ], - ), - MySpacing.height(4), - - // Sub Info - MyText.bodySmall( - 'Select the projects to assign this employee.', - color: Colors.grey[600], - ), - MySpacing.height(8), - - // Select All Toggle - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Projects (${projects.length})', - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - ), - ), - TextButton( - onPressed: () { - assignController.toggleSelectAll(); - }, - child: Obx(() { - return Text( - assignController.areAllSelected() - ? 'Deselect All' - : 'Select All', - style: const TextStyle( - color: Colors.blueAccent, - fontWeight: FontWeight.w600, - ), - ); - }), - ), - ], - ), - - // Project List - Expanded( - child: ListView.builder( - controller: scrollController, - itemCount: projects.length, - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - final GlobalProjectModel project = projects[index]; - return Obx(() { - final bool isSelected = - assignController.isProjectSelected( - project.id.toString(), - ); - return Theme( - data: Theme.of(context).copyWith( - checkboxTheme: CheckboxThemeData( - fillColor: - WidgetStateProperty.resolveWith( - (states) { - if (states.contains(WidgetState.selected)) { - return Colors.blueAccent; - } - return Colors.white; - }, - ), - side: const BorderSide( - color: Colors.black, - width: 2, - ), - checkColor: - WidgetStateProperty.all(Colors.white), - ), - ), - child: CheckboxListTile( - dense: true, - value: isSelected, - title: Text( - project.name, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - onChanged: (checked) { - assignController.toggleProjectSelection( - project.id.toString(), - checked ?? false, - ); - }, - activeColor: Colors.blueAccent, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - ), - ); - }); - }, - ), - ), - MySpacing.height(16), - - // Cancel & Save Buttons - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", - color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 7, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - if (assignController.selectedProjects.isEmpty) { - showAppSnackbar( - title: "Error", - message: "Please select at least one project.", - type: SnackbarType.error, - ); - return; - } - await _assignProjects(); - }, - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - label: MyText.bodyMedium("Assign", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 7, - ), - ), - ), - ), - ], - ), - ], - ); - }), - ); - }, - ), + ); + }), + ); + }, ); } - Future _assignProjects() async { + Future _handleAssign() async { + if (assignController.selectedProjects.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please select at least one project.", + type: SnackbarType.error, + ); + return; + } + final success = await assignController.assignProjectsToEmployee(); if (success) { Get.back(); diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 2c2c1ad..813e3a8 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -22,8 +22,7 @@ class EmployeesScreen extends StatefulWidget { } class _EmployeesScreenState extends State with UIMixin { - final EmployeesScreenController _employeeController = - Get.put(EmployeesScreenController()); + final EmployeesScreenController _employeeController = Get.put(EmployeesScreenController()); final TextEditingController _searchController = TextEditingController(); final RxList _filteredEmployees = [].obs; @@ -32,39 +31,37 @@ class _EmployeesScreenState extends State with UIMixin { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _initEmployees(); - _searchController.addListener(() { - _filterEmployees(_searchController.text); - }); + _searchController.addListener(() => _filterEmployees(_searchController.text)); }); } Future _initEmployees() async { - final selectedProjectId = Get.find().selectedProject?.id; + final projectId = Get.find().selectedProject?.id; - if (selectedProjectId != null) { - _employeeController.selectedProjectId = selectedProjectId; - await _employeeController.fetchEmployeesByProject(selectedProjectId); - } else if (_employeeController.isAllEmployeeSelected.value) { + if (_employeeController.isAllEmployeeSelected.value) { _employeeController.selectedProjectId = null; await _employeeController.fetchAllEmployees(); + } else if (projectId != null) { + _employeeController.selectedProjectId = projectId; + await _employeeController.fetchEmployeesByProject(projectId); } else { _employeeController.clearEmployees(); } + _filterEmployees(_searchController.text); } Future _refreshEmployees() async { try { - final selectedProjectId = - Get.find().selectedProject?.id; - final isAllSelected = _employeeController.isAllEmployeeSelected.value; + final projectId = Get.find().selectedProject?.id; + final allSelected = _employeeController.isAllEmployeeSelected.value; - if (isAllSelected) { - _employeeController.selectedProjectId = null; + _employeeController.selectedProjectId = allSelected ? null : projectId; + + if (allSelected) { await _employeeController.fetchAllEmployees(); - } else if (selectedProjectId != null) { - _employeeController.selectedProjectId = selectedProjectId; - await _employeeController.fetchEmployeesByProject(selectedProjectId); + } else if (projectId != null) { + await _employeeController.fetchEmployeesByProject(projectId); } else { _employeeController.clearEmployees(); } @@ -79,17 +76,20 @@ class _EmployeesScreenState extends State with UIMixin { void _filterEmployees(String query) { final employees = _employeeController.employees; + if (query.isEmpty) { _filteredEmployees.assignAll(employees); return; } - final lowerQuery = query.toLowerCase(); + + final q = query.toLowerCase(); _filteredEmployees.assignAll( employees.where((e) => - e.name.toLowerCase().contains(lowerQuery) || - e.email.toLowerCase().contains(lowerQuery) || - e.phoneNumber.toLowerCase().contains(lowerQuery) || - e.jobRole.toLowerCase().contains(lowerQuery)), + e.name.toLowerCase().contains(q) || + e.email.toLowerCase().contains(q) || + e.phoneNumber.toLowerCase().contains(q) || + e.jobRole.toLowerCase().contains(q), + ), ); } @@ -98,7 +98,8 @@ class _EmployeesScreenState extends State with UIMixin { context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), backgroundColor: Colors.transparent, builder: (context) => AddEmployeeBottomSheet(), ); @@ -113,7 +114,8 @@ class _EmployeesScreenState extends State with UIMixin { context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24))), + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), backgroundColor: Colors.transparent, builder: (context) => AssignProjectBottomSheet( employeeId: employeeId, @@ -134,7 +136,7 @@ class _EmployeesScreenState extends State with UIMixin { child: GetBuilder( init: _employeeController, tag: 'employee_screen_controller', - builder: (controller) { + builder: (_) { _filterEmployees(_searchController.text); return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 40), @@ -168,34 +170,24 @@ class _EmployeesScreenState extends State with UIMixin { title: Padding( padding: MySpacing.xy(16, 0), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), onPressed: () => Get.offNamed('/dashboard'), ), MySpacing.width(8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - MyText.titleLarge( - 'Employees', - fontWeight: 700, - color: Colors.black, - ), + MyText.titleLarge('Employees', fontWeight: 700, color: Colors.black), MySpacing.height(2), GetBuilder( builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; + final projectName = projectController.selectedProject?.name ?? 'Select Project'; return Row( children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), + const Icon(Icons.work_outline, size: 14, color: Colors.grey), MySpacing.width(4), Expanded( child: MyText.bodySmall( @@ -228,13 +220,7 @@ class _EmployeesScreenState extends State with UIMixin { decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(28), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 6, - offset: Offset(0, 3), - ), - ], + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 3))], ), child: const Row( mainAxisSize: MainAxisSize.min, @@ -271,11 +257,9 @@ class _EmployeesScreenState extends State with UIMixin { style: const TextStyle(fontSize: 13, height: 1.2), decoration: InputDecoration( isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey), - prefixIconConstraints: - const BoxConstraints(minWidth: 32, minHeight: 32), + prefixIconConstraints: const BoxConstraints(minWidth: 32, minHeight: 32), hintText: 'Search contacts...', hintStyle: const TextStyle(fontSize: 13, color: Colors.grey), filled: true, @@ -324,46 +308,27 @@ class _EmployeesScreenState extends State with UIMixin { clipBehavior: Clip.none, children: [ const Icon(Icons.tune, color: Colors.black), - Obx(() { - return _employeeController.isAllEmployeeSelected.value - ? Positioned( - right: -1, - top: -1, - child: Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Colors.red, shape: BoxShape.circle), - ), - ) - : const SizedBox.shrink(); - }), + Obx(() => _employeeController.isAllEmployeeSelected.value + ? Positioned( + right: -1, + top: -1, + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), + ), + ) + : const SizedBox.shrink()), ], ), onSelected: (value) async { if (value == 'all_employees') { - _employeeController.isAllEmployeeSelected.value = - !_employeeController.isAllEmployeeSelected.value; - - if (_employeeController.isAllEmployeeSelected.value) { - _employeeController.selectedProjectId = null; - await _employeeController.fetchAllEmployees(); - } else { - final selectedProjectId = - Get.find().selectedProject?.id; - if (selectedProjectId != null) { - _employeeController.selectedProjectId = selectedProjectId; - await _employeeController - .fetchEmployeesByProject(selectedProjectId); - } else { - _employeeController.clearEmployees(); - } - } - _filterEmployees(_searchController.text); + _employeeController.isAllEmployeeSelected.toggle(); + await _initEmployees(); _employeeController.update(['employee_screen_controller']); } }, - itemBuilder: (context) => [ + itemBuilder: (_) => [ PopupMenuItem( value: 'all_employees', child: Obx( @@ -371,17 +336,12 @@ class _EmployeesScreenState extends State with UIMixin { children: [ Checkbox( value: _employeeController.isAllEmployeeSelected.value, - onChanged: (bool? value) => - Navigator.pop(context, 'all_employees'), + onChanged: (_) => Navigator.pop(context, 'all_employees'), checkColor: Colors.white, activeColor: Colors.red, side: const BorderSide(color: Colors.black, width: 1.5), - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { - return Colors.red; - } - return Colors.white; - }), + fillColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) ? Colors.red : Colors.white), ), const Text('All Employees'), ], @@ -394,131 +354,95 @@ class _EmployeesScreenState extends State with UIMixin { Widget _buildEmployeeList() { return Obx(() { - final isLoading = _employeeController.isLoading.value; - final employees = _filteredEmployees; - - // Show skeleton loader while data is being fetched - if (isLoading) { + if (_employeeController.isLoading.value) { return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: 8, // number of skeleton items + itemCount: 8, separatorBuilder: (_, __) => MySpacing.height(12), itemBuilder: (_, __) => SkeletonLoaders.employeeSkeletonCard(), ); } - // Show empty state when no employees are found + final employees = _filteredEmployees; + if (employees.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 60), child: Center( - child: MyText.bodySmall( - "No Employees Found", - fontWeight: 600, - color: Colors.grey[700], - ), + child: MyText.bodySmall("No Employees Found", fontWeight: 600, color: Colors.grey[700]), ), ); } - // Show the actual employee list return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: MySpacing.only(bottom: 80), itemCount: employees.length, separatorBuilder: (_, __) => MySpacing.height(12), - itemBuilder: (context, index) { - final employee = employees[index]; - final nameParts = employee.name.trim().split(' '); - final firstName = nameParts.first; - final lastName = nameParts.length > 1 ? nameParts.last : ''; + itemBuilder: (_, index) { + final e = employees[index]; + final names = e.name.trim().split(' '); + final firstName = names.first; + final lastName = names.length > 1 ? names.last : ''; return InkWell( - onTap: () => - Get.to(() => EmployeeDetailPage(employeeId: employee.id)), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar(firstName: firstName, lastName: lastName, size: 35), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall( - employee.name, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - ), - if (employee.jobRole.isNotEmpty) - MyText.bodySmall( - employee.jobRole, - color: Colors.grey[700], - overflow: TextOverflow.ellipsis, - ), - MySpacing.height(8), - if (employee.email.isNotEmpty && employee.email != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchEmail(employee.email), - onLongPress: () => LauncherUtils.copyToClipboard( - employee.email, - typeLabel: 'Email'), - child: Row( - children: [ - const Icon(Icons.email_outlined, - size: 16, color: Colors.indigo), - MySpacing.width(4), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 180), - child: MyText.labelSmall( - employee.email, - overflow: TextOverflow.ellipsis, - color: Colors.indigo, - decoration: TextDecoration.underline, - ), - ), - ], - ), - ), - if (employee.email.isNotEmpty && employee.email != '-') - MySpacing.height(6), - if (employee.phoneNumber.isNotEmpty) - GestureDetector( - onTap: () => - LauncherUtils.launchPhone(employee.phoneNumber), - onLongPress: () => LauncherUtils.copyToClipboard( - employee.phoneNumber, - typeLabel: 'Phone'), - child: Row( - children: [ - const Icon(Icons.phone_outlined, - size: 16, color: Colors.indigo), - MySpacing.width(4), - MyText.labelSmall( - employee.phoneNumber, - color: Colors.indigo, - decoration: TextDecoration.underline, - ), - ], - ), - ), - ], - ), + onTap: () => Get.to(() => EmployeeDetailPage(employeeId: e.id)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: firstName, lastName: lastName, size: 35), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(e.name, fontWeight: 600, overflow: TextOverflow.ellipsis), + if (e.jobRole.isNotEmpty) + MyText.bodySmall(e.jobRole, color: Colors.grey[700], overflow: TextOverflow.ellipsis), + MySpacing.height(8), + if (e.email.isNotEmpty && e.email != '-') + _buildLinkRow(icon: Icons.email_outlined, text: e.email, onTap: () => LauncherUtils.launchEmail(e.email), onLongPress: () => LauncherUtils.copyToClipboard(e.email, typeLabel: 'Email')), + if (e.email.isNotEmpty && e.email != '-') MySpacing.height(6), + if (e.phoneNumber.isNotEmpty) + _buildLinkRow(icon: Icons.phone_outlined, text: e.phoneNumber, onTap: () => LauncherUtils.launchPhone(e.phoneNumber), onLongPress: () => LauncherUtils.copyToClipboard(e.phoneNumber, typeLabel: 'Phone')), + ], ), - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 16), - ], - ), + ), + const Icon(Icons.arrow_forward_ios, color: Colors.grey, size: 16), + ], ), ); }, ); }); } + + Widget _buildLinkRow({ + required IconData icon, + required String text, + required VoidCallback onTap, + required VoidCallback onLongPress, + }) { + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + children: [ + Icon(icon, size: 16, color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: MyText.labelSmall( + text, + overflow: TextOverflow.ellipsis, + color: Colors.indigo, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ); + } } diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart new file mode 100644 index 0000000..8579b65 --- /dev/null +++ b/lib/view/expense/expense_detail_screen.dart @@ -0,0 +1,668 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/expense/expense_detail_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/model/expense/comment_bottom_sheet.dart'; +import 'package:marco/model/expense/expense_detail_model.dart'; +import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; +import 'package:marco/controller/expense/add_expense_controller.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:marco/helpers/widgets/expense_detail_helpers.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/model/employee_info.dart'; + +class ExpenseDetailScreen extends StatefulWidget { + final String expenseId; + const ExpenseDetailScreen({super.key, required this.expenseId}); + + @override + State createState() => _ExpenseDetailScreenState(); +} + +class _ExpenseDetailScreenState extends State { + final controller = Get.put(ExpenseDetailController()); + final projectController = Get.find(); + final permissionController = Get.find(); + + EmployeeInfo? employeeInfo; + final RxBool canSubmit = false.obs; + bool _checkedPermission = false; + + @override + void initState() { + super.initState(); + controller.init(widget.expenseId); + _loadEmployeeInfo(); + } + + void _loadEmployeeInfo() async { + final info = await LocalStorage.getEmployeeInfo(); + employeeInfo = info; + } + + void _checkPermissionToSubmit(ExpenseDetailModel expense) { + const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; + + final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id; + final nextStatusIds = expense.nextStatus.map((e) => e.id).toList(); + final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId); + + final result = isCreatedByCurrentUser && hasRequiredNextStatus; + + logSafe( + '🐛 Checking submit permission:\n' + '🐛 - Logged-in employee ID: ${employeeInfo?.id}\n' + '🐛 - Expense created by ID: ${expense.createdBy.id}\n' + '🐛 - Next Status IDs: $nextStatusIds\n' + '🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n' + '🐛 - Final Permission Result: $result', + level: LogLevel.debug, + ); + + canSubmit.value = result; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF7F7F7), + appBar: _AppBar(projectController: projectController), + body: SafeArea( + child: Obx(() { + if (controller.isLoading.value) return buildLoadingSkeleton(); + final expense = controller.expense.value; + if (controller.errorMessage.isNotEmpty || expense == null) { + return Center(child: MyText.bodyMedium("No data to display.")); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkPermissionToSubmit(expense); + }); + + final statusColor = getExpenseStatusColor(expense.status.name, + colorCode: expense.status.color); + final formattedAmount = formatExpenseAmount(expense.amount); + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 520), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, horizontal: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InvoiceHeader(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceParties(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceDetailsTable(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceDocuments(documents: expense.documents), + const Divider(height: 30, thickness: 1.2), + _InvoiceTotals( + expense: expense, + formattedAmount: formattedAmount, + statusColor: statusColor, + ), + ], + ), + ), + ), + ), + ), + ); + }), + ), + floatingActionButton: Obx(() { + if (controller.isLoading.value) return buildLoadingSkeleton(); + + final expense = controller.expense.value; + if (controller.errorMessage.isNotEmpty || expense == null) { + return Center(child: MyText.bodyMedium("No data to display.")); + } + + if (!_checkedPermission) { + _checkedPermission = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkPermissionToSubmit(expense); + }); + } + + if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) { + return const SizedBox.shrink(); + } + + return FloatingActionButton( + onPressed: () async { + final editData = { + 'id': expense.id, + 'projectName': expense.project.name, + 'amount': expense.amount, + 'supplerName': expense.supplerName, + 'description': expense.description, + 'transactionId': expense.transactionId, + 'location': expense.location, + 'transactionDate': expense.transactionDate, + 'noOfPersons': expense.noOfPersons, + 'expensesTypeId': expense.expensesType.id, + 'paymentModeId': expense.paymentMode.id, + 'paidById': expense.paidBy.id, + 'paidByFirstName': expense.paidBy.firstName, + 'paidByLastName': expense.paidBy.lastName, + 'attachments': expense.documents + .map((doc) => { + 'url': doc.preSignedUrl, + 'fileName': doc.fileName, + 'documentId': doc.documentId, + 'contentType': doc.contentType, + }) + .toList(), + }; + logSafe('editData: $editData', level: LogLevel.info); + + final addCtrl = Get.put(AddExpenseController()); + + await addCtrl.loadMasterData(); + addCtrl.populateFieldsForEdit(editData); + + await showAddExpenseBottomSheet(isEdit: true); + await controller.fetchExpenseDetails(); + }, + backgroundColor: Colors.red, + tooltip: 'Edit Expense', + child: const Icon(Icons.edit), + ); + }), + bottomNavigationBar: Obx(() { + final expense = controller.expense.value; + if (expense == null) return const SizedBox(); + + return SafeArea( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0x11000000))), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: expense.nextStatus.where((next) { + const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; + + final rawPermissions = next.permissionIds; + final parsedPermissions = + controller.parsePermissionIds(rawPermissions); + + final isSubmitStatus = next.id == submitStatusId; + final isCreatedByCurrentUser = + employeeInfo?.id == expense.createdBy.id; + + logSafe( + '🔐 Permission Logic:\n' + '🔸 Status: ${next.name}\n' + '🔸 Status ID: ${next.id}\n' + '🔸 Parsed Permissions: $parsedPermissions\n' + '🔸 Is Submit: $isSubmitStatus\n' + '🔸 Created By Current User: $isCreatedByCurrentUser', + level: LogLevel.debug, + ); + + if (isSubmitStatus) { + // Submit can be done ONLY by the creator + return isCreatedByCurrentUser; + } + + // All other statuses - check permission normally + return permissionController.hasAnyPermission(parsedPermissions); + }).map((next) { + return _statusButton(context, controller, expense, next); + }).toList(), + ), + ), + ); + }), + ); + } + + Widget _statusButton(BuildContext context, ExpenseDetailController controller, + ExpenseDetailModel expense, dynamic next) { + Color buttonColor = Colors.red; + if (next.color.isNotEmpty) { + try { + buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff'))); + } catch (_) {} + } + DateTime onlyDate(DateTime date) { + return DateTime(date.year, date.month, date.day); + } + + return ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 40), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + backgroundColor: buttonColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + onPressed: () async { + const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; + if (expense.status.id == reimbursementId) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) => ReimbursementBottomSheet( + expenseId: expense.id, + statusId: next.id, + onClose: () {}, + onSubmit: ({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + required String statusId, + }) async { + final transactionDate = DateTime.tryParse( + controller.expense.value?.transactionDate ?? ''); + final selectedReimburseDate = + DateTime.tryParse(reimburseDate); + final today = DateTime.now(); + + if (transactionDate == null || + selectedReimburseDate == null) { + showAppSnackbar( + title: 'Invalid date', + message: + 'Could not parse transaction or reimbursement date.', + type: SnackbarType.error, + ); + return false; + } + + if (onlyDate(selectedReimburseDate) + .isBefore(onlyDate(transactionDate))) { + showAppSnackbar( + title: 'Invalid Date', + message: + 'Reimbursement date cannot be before the transaction date.', + type: SnackbarType.error, + ); + return false; + } + + if (onlyDate(selectedReimburseDate) + .isAfter(onlyDate(today))) { + showAppSnackbar( + title: 'Invalid Date', + message: 'Reimbursement date cannot be in the future.', + type: SnackbarType.error, + ); + return false; + } + + final success = + await controller.updateExpenseStatusWithReimbursement( + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimburseById: reimburseById, + statusId: statusId, + ); + + if (success) { + Navigator.of(context).pop(); + showAppSnackbar( + title: 'Success', + message: 'Expense reimbursed successfully.', + type: SnackbarType.success, + ); + await controller.fetchExpenseDetails(); + return true; + } else { + showAppSnackbar( + title: 'Error', + message: 'Failed to reimburse expense.', + type: SnackbarType.error, + ); + return false; + } + }), + ); + } else { + final comment = await showCommentBottomSheet(context, next.name); + if (comment == null) return; + final success = + await controller.updateExpenseStatus(next.id, comment: comment); + if (success) { + showAppSnackbar( + title: 'Success', + message: + 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', + type: SnackbarType.success); + await controller.fetchExpenseDetails(); + } else { + showAppSnackbar( + title: 'Error', + message: 'Failed to update status.', + type: SnackbarType.error); + } + } + }, + child: MyText.labelMedium( + next.displayName.isNotEmpty ? next.displayName : next.name, + color: Colors.white, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ); + } +} + +class _AppBar extends StatelessWidget implements PreferredSizeWidget { + final ProjectController projectController; + const _AppBar({required this.projectController}); + @override + Widget build(BuildContext context) { + return AppBar( + automaticallyImplyLeading: false, + elevation: 1, + backgroundColor: Colors.white, + title: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Expense Details', + fontWeight: 700, color: Colors.black), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + +class _InvoiceHeader extends StatelessWidget { + final ExpenseDetailModel expense; + const _InvoiceHeader({required this.expense}); + @override + Widget build(BuildContext context) { + final dateString = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toString(), + format: 'dd-MM-yyyy'); + final statusColor = getExpenseStatusColor(expense.status.name, + colorCode: expense.status.color); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Row(children: [ + const Icon(Icons.calendar_month, size: 18, color: Colors.grey), + MySpacing.width(6), + MyText.bodySmall('Date:', fontWeight: 600), + MySpacing.width(6), + MyText.bodySmall(dateString, fontWeight: 600), + ]), + Container( + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Row( + children: [ + Icon(Icons.flag, size: 16, color: statusColor), + MySpacing.width(4), + MyText.labelSmall(expense.status.name, + color: statusColor, fontWeight: 600), + ], + ), + ), + ]) + ], + ); + } +} + +class _InvoiceParties extends StatelessWidget { + final ExpenseDetailModel expense; + const _InvoiceParties({required this.expense}); + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + labelValueBlock('Project', expense.project.name), + MySpacing.height(16), + labelValueBlock('Paid By:', + '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), + MySpacing.height(16), + labelValueBlock('Supplier', expense.supplerName), + MySpacing.height(16), + labelValueBlock('Created By:', + '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), + ], + ); + } +} + +class _InvoiceDetailsTable extends StatelessWidget { + final ExpenseDetailModel expense; + const _InvoiceDetailsTable({required this.expense}); + @override + Widget build(BuildContext context) { + final transactionDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toString(), + format: 'dd-MM-yyyy hh:mm a'); + final createdAt = DateTimeUtils.convertUtcToLocal( + expense.createdAt.toString(), + format: 'dd-MM-yyyy hh:mm a'); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailItem("Expense Type:", expense.expensesType.name), + _detailItem("Payment Mode:", expense.paymentMode.name), + _detailItem("Transaction Date:", transactionDate), + _detailItem("Created At:", createdAt), + _detailItem("Pre-Approved:", expense.preApproved ? 'Yes' : 'No'), + _detailItem("Description:", + expense.description.trim().isNotEmpty ? expense.description : '-', + isDescription: true), + ], + ); + } + + Widget _detailItem(String title, String value, + {bool isDescription = false}) => + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(title, fontWeight: 600), + MySpacing.height(3), + isDescription + ? ExpandableDescription(description: value) + : MyText.bodySmall(value, fontWeight: 500), + ], + ), + ); +} + +class _InvoiceDocuments extends StatelessWidget { + final List documents; + const _InvoiceDocuments({required this.documents}); + @override + Widget build(BuildContext context) { + if (documents.isEmpty) + return MyText.bodyMedium('No Supporting Documents', color: Colors.grey); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall("Supporting Documents:", fontWeight: 600), + const SizedBox(height: 12), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: documents.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final doc = documents[index]; + return GestureDetector( + onTap: () async { + final imageDocs = documents + .where((d) => d.contentType.startsWith('image/')) + .toList(); + final initialIndex = + imageDocs.indexWhere((d) => d.documentId == doc.documentId); + if (imageDocs.isNotEmpty && initialIndex != -1) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: + imageDocs.map((e) => e.preSignedUrl).toList(), + initialIndex: initialIndex, + ), + ); + } else { + final Uri url = Uri.parse(doc.preSignedUrl); + if (await canLaunchUrl(url)) { + await launchUrl(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( + children: [ + Icon( + doc.contentType.startsWith('image/') + ? Icons.image + : Icons.insert_drive_file, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 7), + Expanded( + child: MyText.labelSmall( + doc.fileName, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ), + ], + ); + } +} + +class ExpensePermissionHelper { + static bool canEditExpense( + EmployeeInfo? employee, ExpenseDetailModel expense) { + return employee?.id == expense.createdBy.id && + _isInAllowedEditStatus(expense.status.id); + } + + static bool canSubmitExpense( + EmployeeInfo? employee, ExpenseDetailModel expense) { + return employee?.id == expense.createdBy.id && + expense.nextStatus.isNotEmpty; + } + + static bool _isInAllowedEditStatus(String statusId) { + const editableStatusIds = [ + "d1ee5eec-24b6-4364-8673-a8f859c60729", + "965eda62-7907-4963-b4a1-657fb0b2724b", + "297e0d8f-f668-41b5-bfea-e03b354251c8" + ]; + return editableStatusIds.contains(statusId); + } +} + +class _InvoiceTotals extends StatelessWidget { + final ExpenseDetailModel expense; + final String formattedAmount; + final Color statusColor; + const _InvoiceTotals({ + required this.expense, + required this.formattedAmount, + required this.statusColor, + }); + @override + Widget build(BuildContext context) { + return Row( + children: [ + MyText.bodyLarge("Total:", fontWeight: 700), + const Spacer(), + MyText.bodyLarge(formattedAmount, fontWeight: 700), + ], + ); + } +} diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart new file mode 100644 index 0000000..1162ba5 --- /dev/null +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart'; + +class ExpenseFilterBottomSheet extends StatelessWidget { + final ExpenseController expenseController; + final ScrollController scrollController; + + const ExpenseFilterBottomSheet({ + super.key, + required this.expenseController, + required this.scrollController, + }); + + // FIX: create search adapter + Future> searchEmployeesForBottomSheet( + String query) async { + await expenseController + .searchEmployees(query); // async method, returns void + return expenseController.employeeSearchResults.toList(); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + return BaseBottomSheet( + title: 'Filter Expenses', + onCancel: () => Get.back(), + onSubmit: () { + expenseController.fetchExpenses(); + Get.back(); + }, + submitText: 'Submit', + submitColor: Colors.indigo, + submitIcon: Icons.check_circle_outline, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => expenseController.clearFilters(), + child: MyText( + "Reset Filter", + style: MyTextStyle.labelMedium( + color: Colors.red, + fontWeight: 600, + ), + ), + ), + ), + MySpacing.height(8), + _buildProjectFilter(context), + MySpacing.height(16), + _buildStatusFilter(context), + MySpacing.height(16), + _buildDateRangeFilter(context), + MySpacing.height(16), + _buildPaidByFilter(context), + MySpacing.height(16), + _buildCreatedByFilter(context), + ], + ), + ), + ); + }); + } + + Widget _buildField(String label, Widget child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + child, + ], + ); + } + + Widget _buildProjectFilter(BuildContext context) { + return _buildField( + "Project", + _popupSelector( + context, + currentValue: expenseController.selectedProject.value.isEmpty + ? 'Select Project' + : expenseController.selectedProject.value, + items: expenseController.globalProjects, + onSelected: (value) => expenseController.selectedProject.value = value, + ), + ); + } + + Widget _buildStatusFilter(BuildContext context) { + return _buildField( + "Expense Status", + _popupSelector( + context, + currentValue: expenseController.selectedStatus.value.isEmpty + ? 'Select Expense Status' + : expenseController.expenseStatuses + .firstWhereOrNull( + (e) => e.id == expenseController.selectedStatus.value) + ?.name ?? + 'Select Expense Status', + items: expenseController.expenseStatuses.map((e) => e.name).toList(), + onSelected: (name) { + final status = expenseController.expenseStatuses + .firstWhere((e) => e.name == name); + expenseController.selectedStatus.value = status.id; + }, + ), + ); + } + + Widget _buildDateRangeFilter(BuildContext context) { + return _buildField( + "Date Filter", + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + return SegmentedButton( + segments: expenseController.dateTypes + .map( + (type) => ButtonSegment( + value: type, + label: MyText( + type, + style: MyTextStyle.bodySmall( + fontWeight: 600, + fontSize: 13, + height: 1.2, + ), + ), + ), + ) + .toList(), + selected: {expenseController.selectedDateType.value}, + onSelectionChanged: (newSelection) { + if (newSelection.isNotEmpty) { + expenseController.selectedDateType.value = newSelection.first; + } + }, + style: ButtonStyle( + visualDensity: + const VisualDensity(horizontal: -2, vertical: -2), + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + ), + backgroundColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.indigo.shade100 + : Colors.grey.shade100, + ), + foregroundColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.indigo + : Colors.black87, + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + side: MaterialStateProperty.resolveWith( + (states) => BorderSide( + color: states.contains(MaterialState.selected) + ? Colors.indigo + : Colors.grey.shade300, + width: 1, + ), + ), + ), + ); + }), + MySpacing.height(16), + Row( + children: [ + Expanded( + child: _dateButton( + label: expenseController.startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + expenseController.startDate.value!, 'dd MMM yyyy'), + onTap: () => _selectDate( + context, + expenseController.startDate, + lastDate: expenseController.endDate.value, + ), + ), + ), + MySpacing.width(12), + Expanded( + child: _dateButton( + label: expenseController.endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + expenseController.endDate.value!, 'dd MMM yyyy'), + onTap: () => _selectDate( + context, + expenseController.endDate, + firstDate: expenseController.startDate.value, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildPaidByFilter(BuildContext context) { + return _buildField( + "Paid By", + _employeeSelector( + context: context, + selectedEmployees: expenseController.selectedPaidByEmployees, + searchEmployees: searchEmployeesForBottomSheet, // FIXED + title: 'Search Paid By', + ), + ); + } + + Widget _buildCreatedByFilter(BuildContext context) { + return _buildField( + "Created By", + _employeeSelector( + context: context, + selectedEmployees: expenseController.selectedCreatedByEmployees, + searchEmployees: searchEmployeesForBottomSheet, // FIXED + title: 'Search Created By', + ), + ); + } + + Future _selectDate( + BuildContext context, + Rx dateNotifier, { + DateTime? firstDate, + DateTime? lastDate, + }) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: dateNotifier.value ?? DateTime.now(), + firstDate: firstDate ?? DateTime(2020), + lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null && picked != dateNotifier.value) { + dateNotifier.value = picked; + } + } + + Widget _popupSelector( + BuildContext context, { + required String currentValue, + required List items, + required ValueChanged onSelected, + }) { + return PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: onSelected, + itemBuilder: (context) => items + .map((e) => PopupMenuItem( + value: e, + child: MyText(e), + )) + .toList(), + child: Container( + padding: MySpacing.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: MyText( + currentValue, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ); + } + + Widget _dateButton({required String label, required VoidCallback onTap}) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: MySpacing.xy(16, 12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const Icon(Icons.calendar_today, size: 16, color: Colors.grey), + MySpacing.width(8), + Expanded( + child: MyText( + label, + style: MyTextStyle.bodyMedium(), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } + + Future _showEmployeeSelectorBottomSheet({ + required BuildContext context, + required RxList selectedEmployees, + required Future> Function(String) searchEmployees, + String title = 'Select Employee', + }) async { + final List? result = + await showModalBottomSheet>( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => EmployeeSelectorBottomSheet( + selectedEmployees: selectedEmployees, + searchEmployees: searchEmployees, + title: title, + ), + ); + if (result != null) { + selectedEmployees.assignAll(result); + } + } + + Widget _employeeSelector({ + required BuildContext context, + required RxList selectedEmployees, + required Future> Function(String) searchEmployees, + String title = 'Search Employee', + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + if (selectedEmployees.isEmpty) { + return const SizedBox.shrink(); + } + return Wrap( + spacing: 8, + children: selectedEmployees + .map((emp) => Chip( + label: MyText(emp.name), + onDeleted: () => selectedEmployees.remove(emp), + )) + .toList(), + ); + }), + MySpacing.height(8), + GestureDetector( + onTap: () => _showEmployeeSelectorBottomSheet( + context: context, + selectedEmployees: selectedEmployees, + searchEmployees: searchEmployees, + title: title, + ), + child: Container( + padding: MySpacing.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const Icon(Icons.search, color: Colors.grey), + MySpacing.width(8), + Expanded(child: MyText(title)), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart new file mode 100644 index 0000000..0309b3c --- /dev/null +++ b/lib/view/expense/expense_screen.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/expense_main_components.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; + +class ExpenseMainScreen extends StatefulWidget { + const ExpenseMainScreen({super.key}); + + @override + State createState() => _ExpenseMainScreenState(); +} + +class _ExpenseMainScreenState extends State { + bool isHistoryView = false; + final searchController = TextEditingController(); + final expenseController = Get.put(ExpenseController()); + final projectController = Get.find(); + final permissionController = Get.find(); + + @override + void initState() { + super.initState(); + expenseController.fetchExpenses(); + } + + void _refreshExpenses() => expenseController.fetchExpenses(); + + void _openFilterBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => ExpenseFilterBottomSheet( + expenseController: expenseController, + scrollController: ScrollController(), + ), + ); + } + + List _getFilteredExpenses() { + final query = searchController.text.trim().toLowerCase(); + final now = DateTime.now(); + + final filtered = expenseController.expenses.where((e) { + return query.isEmpty || + e.expensesType.name.toLowerCase().contains(query) || + e.supplerName.toLowerCase().contains(query) || + e.paymentMode.name.toLowerCase().contains(query); + }).toList() + ..sort((a, b) => b.transactionDate.compareTo(a.transactionDate)); + + return isHistoryView + ? filtered + .where((e) => + e.transactionDate.isBefore(DateTime(now.year, now.month))) + .toList() + : filtered + .where((e) => + e.transactionDate.month == now.month && + e.transactionDate.year == now.year) + .toList(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: ExpenseAppBar(projectController: projectController), + body: SafeArea( + child: Column( + children: [ + SearchAndFilter( + controller: searchController, + onChanged: (_) => setState(() {}), + onFilterTap: _openFilterBottomSheet, + onRefreshTap: _refreshExpenses, + expenseController: expenseController, + ), + ToggleButtonsRow( + isHistoryView: isHistoryView, + onToggle: (v) => setState(() => isHistoryView = v), + ), + Expanded( + child: Obx(() { + if (expenseController.isLoading.value && + expenseController.expenses.isEmpty) { + return SkeletonLoaders.expenseListSkeletonLoader(); + } + + if (expenseController.errorMessage.isNotEmpty) { + return Center( + child: MyText.bodyMedium( + expenseController.errorMessage.value, + color: Colors.red, + ), + ); + } + + final filteredList = _getFilteredExpenses(); + + return NotificationListener( + onNotification: (ScrollNotification scrollInfo) { + if (scrollInfo.metrics.pixels == + scrollInfo.metrics.maxScrollExtent && + !expenseController.isLoading.value) { + expenseController.loadMoreExpenses(); + } + return false; + }, + child: ExpenseList( + expenseList: filteredList, + onViewDetail: () => expenseController.fetchExpenses(), + ), + ); + }), + ), + ], + ), + ), + + // ✅ FAB only if user has expenseUpload permission + floatingActionButton: + permissionController.hasPermission(Permissions.expenseUpload) + ? FloatingActionButton( + backgroundColor: Colors.red, + onPressed: showAddExpenseBottomSheet, + child: const Icon(Icons.add, color: Colors.white), + ) + : null, + ); + } +} diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 3aea78d..9fd6247 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -138,7 +138,7 @@ class _DailyProgressReportScreenState extends State MySpacing.height(flexSpacing), _buildActionBar(), Padding( - padding: MySpacing.x(flexSpacing), + padding: MySpacing.x(8), child: _buildDailyProgressReportTab(), ), ], @@ -158,9 +158,8 @@ class _DailyProgressReportScreenState extends State children: [ _buildActionItem( label: "Filter", - icon: Icons.filter_list_alt, + icon: Icons.tune, tooltip: 'Filter Project', - color: Colors.blueAccent, onTap: _openFilterSheet, ), const SizedBox(width: 8), @@ -181,7 +180,7 @@ class _DailyProgressReportScreenState extends State required IconData icon, required String tooltip, required VoidCallback onTap, - required Color color, + Color? color, }) { return Row( children: [ @@ -189,13 +188,13 @@ class _DailyProgressReportScreenState extends State Tooltip( message: tooltip, child: InkWell( - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(22), onTap: onTap, child: MouseRegion( cursor: SystemMouseCursors.click, child: Padding( padding: const EdgeInsets.all(8.0), - child: Icon(icon, color: color, size: 28), + child: Icon(icon, color: color, size: 22), ), ), ), @@ -205,29 +204,27 @@ class _DailyProgressReportScreenState extends State } Future _openFilterSheet() async { - final result = await showModalBottomSheet>( - context: context, - isScrollControlled: true, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), - builder: (context) => DailyProgressReportFilter( - controller: dailyTaskController, - permissionController: permissionController, - ), - ); + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DailyProgressReportFilter( + controller: dailyTaskController, + permissionController: permissionController, + ), + ); - if (result != null) { - final selectedProjectId = result['projectId'] as String?; - if (selectedProjectId != null && - selectedProjectId != dailyTaskController.selectedProjectId) { - dailyTaskController.selectedProjectId = selectedProjectId; - await dailyTaskController.fetchTaskData(selectedProjectId); - dailyTaskController.update(['daily_progress_report_controller']); - } + if (result != null) { + final selectedProjectId = result['projectId'] as String?; + if (selectedProjectId != null && + selectedProjectId != dailyTaskController.selectedProjectId) { + dailyTaskController.selectedProjectId = selectedProjectId; + await dailyTaskController.fetchTaskData(selectedProjectId); + dailyTaskController.update(['daily_progress_report_controller']); } } +} + Future _refreshData() async { final projectId = dailyTaskController.selectedProjectId; @@ -318,7 +315,7 @@ class _DailyProgressReportScreenState extends State ..sort((a, b) => b.compareTo(a)); return MyCard.bordered( - borderRadiusAll: 4, + borderRadiusAll: 10, border: Border.all(color: Colors.grey.withOpacity(0.2)), shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), paddingAll: 8, diff --git a/lib/view/taskPlaning/daily_task_planing.dart b/lib/view/taskPlaning/daily_task_planing.dart index d734b14..a250606 100644 --- a/lib/view/taskPlaning/daily_task_planing.dart +++ b/lib/view/taskPlaning/daily_task_planing.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/utils/my_shadow.dart'; import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; @@ -160,7 +159,7 @@ class _DailyTaskPlaningScreenState extends State ), ), Padding( - padding: MySpacing.x(flexSpacing), + padding: MySpacing.x(8), child: dailyProgressReportTab(), ), ], @@ -232,10 +231,9 @@ class _DailyTaskPlaningScreenState extends State final buildingKey = building.id.toString(); return MyCard.bordered( - borderRadiusAll: 12, + borderRadiusAll: 10, paddingAll: 0, - margin: MySpacing.bottom(12), - shadow: MyShadow(elevation: 3), + margin: MySpacing.bottom(10), child: Theme( data: Theme.of(context) .copyWith(dividerColor: Colors.transparent), diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 1ed2761..7809054 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "marco") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.marco") +set(APPLICATION_ID "com.marco.aiotstage") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index e09dfc7..20ce3ed 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -385,7 +385,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; @@ -399,7 +399,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; @@ -413,7 +413,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 1ddedb9..7f80201 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = marco // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.marco +PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. diff --git a/pubspec.yaml b/pubspec.yaml index fd193f6..b8ca6b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.0+5 environment: sdk: ^3.5.3 @@ -78,6 +78,7 @@ dependencies: flutter_quill_delta_from_html: ^1.5.2 quill_delta: ^3.0.0-nullsafety.2 connectivity_plus: ^6.1.4 + geocoding: ^4.0.0 dev_dependencies: flutter_test: sdk: flutter