From 45bc492683d19089323e3bd388089b14cae1b2a6 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 8 Oct 2025 11:06:39 +0530 Subject: [PATCH 1/5] fixed tenant issue --- lib/controller/auth/login_controller.dart | 10 - lib/controller/auth/mpin_controller.dart | 24 +- .../dashboard/dashboard_controller.dart | 5 +- .../task_planning/daily_task_controller.dart | 3 +- .../tenant/tenant_selection_controller.dart | 133 +++--- .../tenant/tenant_switch_controller.dart | 106 +++++ lib/helpers/services/api_service.dart | 100 +++-- lib/helpers/services/app_initializer.dart | 59 +-- lib/helpers/services/auth_service.dart | 12 +- .../dashbaord/attendance_overview_chart.dart | 87 ++-- .../dashbaord/project_progress_chart.dart | 80 ++-- .../widgets/tenant/organization_selector.dart | 28 +- .../widgets/tenant/service_selector.dart | 31 +- .../attendance/attendence_action_button.dart | 41 +- .../attendance/attendence_filter_sheet.dart | 30 +- .../attendance/regualrize_action_button.dart | 117 ++--- .../assign_task_bottom_sheet .dart | 36 +- .../dailyTaskPlanning/daily_task_model.dart | 72 ++-- .../report_action_bottom_sheet.dart | 94 ++-- .../employees/add_employee_bottom_sheet.dart | 27 +- lib/view/dashboard/dashboard_screen.dart | 30 +- lib/view/directory/contact_detail_screen.dart | 48 +-- lib/view/document/document_details_page.dart | 4 +- lib/view/document/user_document_screen.dart | 405 ++++++++---------- .../employees/employee_detail_screen.dart | 7 +- lib/view/employees/employees_screen.dart | 8 +- lib/view/expense/expense_detail_screen.dart | 2 +- lib/view/expense/expense_screen.dart | 32 +- lib/view/layouts/user_profile_right_bar.dart | 168 ++++---- lib/view/my_app.dart | 14 +- lib/view/taskPlanning/daily_progress.dart | 2 +- lib/view/tenant/tenant_selection_screen.dart | 6 +- 32 files changed, 1022 insertions(+), 799 deletions(-) create mode 100644 lib/controller/tenant/tenant_switch_controller.dart diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index f52b28b..e7abe3a 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -79,16 +79,6 @@ class LoginController extends MyController { enableRemoteLogging(); logSafe("✅ Remote logging enabled after login."); - final fcmToken = await LocalStorage.getFcmToken(); - if (fcmToken?.isNotEmpty ?? false) { - final success = await AuthService.registerDeviceToken(fcmToken!); - logSafe( - success - ? "✅ FCM token registered after login." - : "⚠️ Failed to register FCM token after login.", - level: LogLevel.warning); - } - logSafe("Login successful for user: ${loginData['username']}"); Get.toNamed('/select_tenant'); diff --git a/lib/controller/auth/mpin_controller.dart b/lib/controller/auth/mpin_controller.dart index baeeeac..4a92691 100644 --- a/lib/controller/auth/mpin_controller.dart +++ b/lib/controller/auth/mpin_controller.dart @@ -7,6 +7,8 @@ import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/view/dashboard/dashboard_screen.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/controller/project_controller.dart'; class MPINController extends GetxController { final MyFormValidator basicValidator = MyFormValidator(); @@ -239,15 +241,12 @@ class MPINController extends GetxController { logSafe("verifyMPIN triggered"); final enteredMPIN = digitControllers.map((c) => c.text).join(); - logSafe("Entered MPIN: $enteredMPIN"); - if (enteredMPIN.length < 4) { _showError("Please enter all 4 digits."); return; } final mpinToken = await LocalStorage.getMpinToken(); - if (mpinToken == null || mpinToken.isEmpty) { _showError("Missing MPIN token. Please log in again."); return; @@ -270,6 +269,19 @@ class MPINController extends GetxController { logSafe("MPIN verified successfully"); await LocalStorage.setBool('mpin_verified', true); + // 🔹 Ensure controllers are injected and loaded + final token = await LocalStorage.getJwtToken(); + if (token != null && token.isNotEmpty) { + if (!Get.isRegistered()) { + Get.put(PermissionController()); + await Get.find().loadData(token); + } + if (!Get.isRegistered()) { + Get.put(ProjectController(), permanent: true); + await Get.find().fetchProjects(); + } + } + showAppSnackbar( title: "Success", message: "MPIN Verified Successfully", @@ -291,11 +303,7 @@ class MPINController extends GetxController { } catch (e) { isLoading.value = false; logSafe("Exception in verifyMPIN", level: LogLevel.error, error: e); - showAppSnackbar( - title: "Error", - message: "Something went wrong. Please try again.", - type: SnackbarType.error, - ); + _showError("Something went wrong. Please try again."); } } diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 72f2331..50a761c 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -46,8 +46,9 @@ class DashboardController extends GetxController { // Common ranges final List ranges = ['7D', '15D', '30D']; - // Inject ProjectController - final ProjectController projectController = Get.find(); +// Inside your DashboardController + final ProjectController projectController = + Get.put(ProjectController(), permanent: true); @override void onInit() { diff --git a/lib/controller/task_planning/daily_task_controller.dart b/lib/controller/task_planning/daily_task_controller.dart index 644bc9f..93486b4 100644 --- a/lib/controller/task_planning/daily_task_controller.dart +++ b/lib/controller/task_planning/daily_task_controller.dart @@ -78,8 +78,7 @@ class DailyTaskController extends GetxController { ); if (response != null && response.isNotEmpty) { - for (var taskJson in response) { - final task = TaskModel.fromJson(taskJson); + for (var task in response) { final assignmentDateKey = task.assignmentDate.toIso8601String().split('T')[0]; groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task); diff --git a/lib/controller/tenant/tenant_selection_controller.dart b/lib/controller/tenant/tenant_selection_controller.dart index 7789de9..b4cbee3 100644 --- a/lib/controller/tenant/tenant_selection_controller.dart +++ b/lib/controller/tenant/tenant_selection_controller.dart @@ -4,12 +4,16 @@ import 'package:marco/helpers/services/tenant_service.dart'; import 'package:marco/model/tenant/tenant_list_model.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/controller/permission_controller.dart'; class TenantSelectionController extends GetxController { final TenantService _tenantService = TenantService(); - var tenants = [].obs; - var isLoading = false.obs; + TenantSelectionController(); + + final tenants = [].obs; + final isLoading = false.obs; + final selectedTenantId = RxnString(); @override void onInit() { @@ -17,83 +21,106 @@ class TenantSelectionController extends GetxController { loadTenants(); } - /// Load tenants from API - Future loadTenants({bool fromTenantSelectionScreen = false}) async { + /// Load tenants and perform smart auto-selection + Future loadTenants() async { + isLoading.value = true; try { - isLoading.value = true; final data = await _tenantService.getTenants(); - if (data != null) { - tenants.value = data.map((e) => Tenant.fromJson(e)).toList(); - - final recentTenantId = LocalStorage.getRecentTenantId(); - - // ✅ If user came from TenantSelectionScreen & recent tenant exists, auto-select - if (fromTenantSelectionScreen && recentTenantId != null) { - final tenantExists = tenants.any((t) => t.id == recentTenantId); - if (tenantExists) { - await onTenantSelected(recentTenantId); - return; - } else { - // if tenant is no longer valid, clear recentTenant - await LocalStorage.removeRecentTenantId(); - } - } - - // ✅ Auto-select if only one tenant - if (tenants.length == 1) { - await onTenantSelected(tenants.first.id); - } - } else { + if (data == null || data.isEmpty) { tenants.clear(); logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning); + return; + } + + tenants.value = data.map((e) => Tenant.fromJson(e)).toList(); + + // Smart auto-selection + final recentTenantId = await LocalStorage.getRecentTenantId(); + + if (tenants.length == 1) { + await _selectTenant(tenants.first.id); + } else if (recentTenantId != null) { + final recentTenant = + tenants.where((t) => t.id == recentTenantId).isNotEmpty + ? tenants.firstWhere((t) => t.id == recentTenantId) + : null; + + if (recentTenant != null) { + await _selectTenant(recentTenant.id); + } else { + selectedTenantId.value = null; + TenantService.currentTenant = null; + } + } else { + selectedTenantId.value = null; + TenantService.currentTenant = null; } } catch (e, st) { logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st); + showAppSnackbar( + title: "Error", + message: "Failed to load organizations. Please try again.", + type: SnackbarType.error, + ); } finally { isLoading.value = false; } } - /// Select tenant + /// Manually select tenant (user triggered) Future onTenantSelected(String tenantId) async { + await _selectTenant(tenantId); + } + + /// Internal tenant selection logic + Future _selectTenant(String tenantId) async { try { isLoading.value = true; final success = await _tenantService.selectTenant(tenantId); - if (success) { - logSafe("✅ Tenant selection successful: $tenantId"); - - // Store selected tenant in memory - final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); - TenantService.setSelectedTenant(selectedTenant); - - // 🔥 Save in LocalStorage - await LocalStorage.setRecentTenantId(tenantId); - - // Navigate to dashboard - Get.offAllNamed('/dashboard'); - - showAppSnackbar( - title: "Success", - message: "Organization selected successfully.", - type: SnackbarType.success, - ); - } else { + if (!success) { logSafe("❌ Tenant selection failed for: $tenantId", level: LogLevel.warning); - - // Show error snackbar showAppSnackbar( title: "Error", message: "Unable to select organization. Please try again.", type: SnackbarType.error, ); + return; } - } catch (e, st) { - logSafe("❌ Exception in onTenantSelected", - level: LogLevel.error, error: e, stackTrace: st); - // Show error snackbar for exception + final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); + TenantService.setSelectedTenant(selectedTenant); + selectedTenantId.value = tenantId; + + // Persist recent tenant + await LocalStorage.setRecentTenantId(tenantId); + + logSafe("✅ Tenant selection successful: $tenantId"); + + // 🔹 Load permissions after tenant selection (null-safe) + final token = await LocalStorage.getJwtToken(); + if (token != null && token.isNotEmpty) { + if (!Get.isRegistered()) { + Get.put(PermissionController()); + logSafe("✅ PermissionController injected after tenant selection."); + } + await Get.find().loadData(token); + } else { + logSafe("⚠️ JWT token is null. Cannot load permissions.", level: LogLevel.warning); + } + + // Navigate to dashboard + Get.offAllNamed('/dashboard'); + + showAppSnackbar( + title: "Success", + message: "Organization selected successfully.", + type: SnackbarType.success, + ); + } catch (e, st) { + logSafe("❌ Exception in _selectTenant", + level: LogLevel.error, error: e, stackTrace: st); showAppSnackbar( title: "Error", message: "An unexpected error occurred while selecting organization.", diff --git a/lib/controller/tenant/tenant_switch_controller.dart b/lib/controller/tenant/tenant_switch_controller.dart new file mode 100644 index 0000000..5d73dc7 --- /dev/null +++ b/lib/controller/tenant/tenant_switch_controller.dart @@ -0,0 +1,106 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/tenant_service.dart'; +import 'package:marco/model/tenant/tenant_list_model.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/controller/permission_controller.dart'; + +class TenantSwitchController extends GetxController { + final TenantService _tenantService = TenantService(); + + final tenants = [].obs; + final isLoading = false.obs; + final selectedTenantId = RxnString(); + + @override + void onInit() { + super.onInit(); + loadTenants(); + } + + /// Load all tenants for switching (does not auto-select) + Future loadTenants() async { + isLoading.value = true; + try { + final data = await _tenantService.getTenants(); + if (data == null || data.isEmpty) { + tenants.clear(); + logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning); + return; + } + + tenants.value = data.map((e) => Tenant.fromJson(e)).toList(); + + // Keep current tenant as selected + selectedTenantId.value = TenantService.currentTenant?.id; + } catch (e, st) { + logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st); + showAppSnackbar( + title: "Error", + message: "Failed to load organizations for switching.", + type: SnackbarType.error, + ); + } finally { + isLoading.value = false; + } + } + + /// Switch to a different tenant and navigate fully + Future switchTenant(String tenantId) async { + if (TenantService.currentTenant?.id == tenantId) return; + + isLoading.value = true; + try { + final success = await _tenantService.selectTenant(tenantId); + if (!success) { + logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning); + showAppSnackbar( + title: "Error", + message: "Unable to switch organization. Try again.", + type: SnackbarType.error, + ); + return; + } + + final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); + TenantService.setSelectedTenant(selectedTenant); + selectedTenantId.value = tenantId; + + // Persist recent tenant + await LocalStorage.setRecentTenantId(tenantId); + + logSafe("✅ Tenant switched successfully: $tenantId"); + + // 🔹 Load permissions after tenant switch (null-safe) + final token = await LocalStorage.getJwtToken(); + if (token != null && token.isNotEmpty) { + if (!Get.isRegistered()) { + Get.put(PermissionController()); + logSafe("✅ PermissionController injected after tenant switch."); + } + await Get.find().loadData(token); + } else { + logSafe("⚠️ JWT token is null. Cannot load permissions.", level: LogLevel.warning); + } + + // FULL NAVIGATION: reload app/dashboard + Get.offAllNamed('/dashboard'); + + showAppSnackbar( + title: "Success", + message: "Switched to organization: ${selectedTenant.name}", + type: SnackbarType.success, + ); + } catch (e, st) { + logSafe("❌ Exception in switchTenant", level: LogLevel.error, error: e, stackTrace: st); + showAppSnackbar( + title: "Error", + message: "An unexpected error occurred while switching organization.", + type: SnackbarType.error, + ); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 1f15ef4..583e3c1 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -20,6 +20,7 @@ import 'package:marco/model/document/document_details_model.dart'; import 'package:marco/model/document/document_version_model.dart'; import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:marco/model/tenant/tenant_services_model.dart'; +import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart'; class ApiService { static const bool enableLogs = true; @@ -354,14 +355,24 @@ class ApiService { logSafe("Posting logs... count=${logs.length}"); try { - final response = - await _postRequest(endpoint, logs, customTimeout: extendedTimeout); - - if (response == null) { - logSafe("Post logs failed: null response", level: LogLevel.error); + // Get token directly without triggering logout or refresh + final token = await LocalStorage.getJwtToken(); + if (token == null) { + logSafe("No token available. Skipping logs post.", + level: LogLevel.warning); return false; } + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); + final headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }; + + final response = await http + .post(uri, headers: headers, body: jsonEncode(logs)) + .timeout(ApiService.extendedTimeout); + logSafe("Post logs response status: ${response.statusCode}"); logSafe("Post logs response body: ${response.body}"); @@ -1761,19 +1772,18 @@ class ApiService { return false; } -static Future?> getDirectoryComments( - String contactId, { - bool active = true, -}) async { - final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active"; - final response = await _getRequest(url); - final data = response != null - ? _parseResponse(response, label: 'Directory Comments') - : null; - - return data is List ? data : null; -} + static Future?> getDirectoryComments( + String contactId, { + bool active = true, + }) async { + final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active"; + final response = await _getRequest(url); + final data = response != null + ? _parseResponse(response, label: 'Directory Comments') + : null; + return data is List ? data : null; + } static Future updateContact( String contactId, Map payload) async { @@ -2116,38 +2126,42 @@ static Future?> getDirectoryComments( // === Daily Task APIs === - static Future?> getDailyTasks( - String projectId, { - DateTime? dateFrom, - DateTime? dateTo, - List? serviceIds, - int pageNumber = 1, - int pageSize = 20, - }) async { - final filterBody = { - "serviceIds": serviceIds ?? [], - }; + static Future?> getDailyTasks( + String projectId, { + DateTime? dateFrom, + DateTime? dateTo, + List? serviceIds, + int pageNumber = 1, + int pageSize = 20, +}) async { + final filterBody = { + "serviceIds": serviceIds ?? [], + }; - final query = { - "projectId": projectId, - "pageNumber": pageNumber.toString(), - "pageSize": pageSize.toString(), - if (dateFrom != null) - "dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom), - if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo), - "filter": jsonEncode(filterBody), - }; + final query = { + "projectId": projectId, + "pageNumber": pageNumber.toString(), + "pageSize": pageSize.toString(), + if (dateFrom != null) + "dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom), + if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo), + "filter": jsonEncode(filterBody), + }; - final uri = - Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query); + final uri = + Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query); - final response = await _getRequest(uri.toString()); + final response = await _getRequest(uri.toString()); + final parsed = response != null ? _parseResponse(response, label: 'Daily Tasks') : null; - return response != null - ? _parseResponse(response, label: 'Daily Tasks') - : null; + if (parsed != null && parsed['data'] != null) { + return (parsed['data'] as List).map((e) => TaskModel.fromJson(e)).toList(); } + return null; +} + + static Future reportTask({ required String id, required int completedTask, diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index 54db545..45cd5c2 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -1,10 +1,6 @@ import 'package:flutter/services.dart'; -import 'package:get/get.dart'; import 'package:url_strategy/url_strategy.dart'; import 'package:firebase_core/firebase_core.dart'; - -import 'package:marco/controller/permission_controller.dart'; -import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/auth_service.dart'; @@ -24,9 +20,8 @@ Future initializeApp() async { ]); await _setupDeviceInfo(); - await _handleAuthTokens(); + await _handleAuthTokens(); // refresh token only, no controller injection await _setupTheme(); - await _setupControllers(); await _setupFirebaseMessaging(); _finalizeAppStyle(); @@ -43,6 +38,19 @@ Future initializeApp() async { } } +Future _handleAuthTokens() async { + final refreshToken = await LocalStorage.getRefreshToken(); + if (refreshToken?.isNotEmpty ?? false) { + logSafe("🔁 Refresh token found. Attempting to refresh JWT..."); + final success = await AuthService.refreshToken(); + if (!success) { + logSafe("⚠️ Refresh token invalid or expired. User must login again."); + } + } else { + logSafe("❌ No refresh token found. Skipping refresh."); + } +} + Future _setupUI() async { setPathUrlStrategy(); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -69,50 +77,11 @@ Future _setupDeviceInfo() async { logSafe("📱 Device Info: ${deviceInfoService.deviceData}"); } -Future _handleAuthTokens() async { - final refreshToken = await LocalStorage.getRefreshToken(); - if (refreshToken?.isNotEmpty ?? false) { - logSafe("🔁 Refresh token found. Attempting to refresh JWT..."); - final success = await AuthService.refreshToken(); - if (!success) { - logSafe( - "⚠️ Refresh token invalid or expired. Skipping controller injection."); - } - } else { - logSafe("❌ No refresh token found. Skipping refresh."); - } -} - Future _setupTheme() async { await ThemeCustomizer.init(); logSafe("💡 Theme customizer initialized."); } -Future _setupControllers() async { - final token = LocalStorage.getString('jwt_token'); - if (token?.isEmpty ?? true) { - logSafe("⚠️ No valid JWT token found. Skipping controller initialization."); - return; - } - - if (!Get.isRegistered()) { - Get.put(PermissionController()); - logSafe("💡 PermissionController injected."); - } - - if (!Get.isRegistered()) { - Get.put(ProjectController(), permanent: true); - logSafe("💡 ProjectController injected as permanent."); - } - - await Future.wait([ - Get.find().loadData(token!), - Get.find().fetchProjects(), - ]); -} - -// ❌ Commented out Firebase Messaging setup - Future _setupFirebaseMessaging() async { await FirebaseNotificationService().initialize(); logSafe("💡 Firebase Messaging initialized."); diff --git a/lib/helpers/services/auth_service.dart b/lib/helpers/services/auth_service.dart index 3c9b02b..0ccb708 100644 --- a/lib/helpers/services/auth_service.dart +++ b/lib/helpers/services/auth_service.dart @@ -291,19 +291,25 @@ class AuthService { await LocalStorage.removeMpinToken(); } + // 🔹 Inject controllers only here, after login success if (!Get.isRegistered()) { Get.put(PermissionController()); logSafe("✅ PermissionController injected after login."); } + if (!Get.isRegistered()) { Get.put(ProjectController(), permanent: true); logSafe("✅ ProjectController injected after login."); } - await Get.find().loadData(data['token']); - await Get.find().fetchProjects(); + // 🔹 Load data + final token = data['token']; + await Future.wait([ + Get.find().loadData(token), + Get.find().fetchProjects(), + ]); - // 🔹 Always try to register FCM token after login + // 🔹 Register FCM token after login final fcmToken = await LocalStorage.getFcmToken(); if (fcmToken?.isNotEmpty ?? false) { final success = await registerDeviceToken(fcmToken!); diff --git a/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart b/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart index 4ec74b0..a777767 100644 --- a/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart +++ b/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart @@ -60,7 +60,6 @@ class AttendanceDashboardChart extends StatelessWidget { final filteredData = _getFilteredData(); - return Container( decoration: _containerDecoration, padding: EdgeInsets.symmetric( @@ -254,7 +253,7 @@ class _AttendanceChart extends StatelessWidget { @override Widget build(BuildContext context) { - final dateFormat = DateFormat('d MMMM'); + final dateFormat = DateFormat('d MMM'); final uniqueDates = data .map((e) => DateTime.parse(e['date'] as String)) .toSet() @@ -273,10 +272,6 @@ class _AttendanceChart extends StatelessWidget { if (allZero) { return Container( height: 600, - decoration: BoxDecoration( - color: Colors.blueGrey.shade50, - borderRadius: BorderRadius.circular(5), - ), child: const Center( child: Text( 'No attendance data for the selected range.', @@ -302,7 +297,6 @@ class _AttendanceChart extends StatelessWidget { height: 600, padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: Colors.blueGrey.shade50, borderRadius: BorderRadius.circular(5), ), child: SfCartesianChart( @@ -317,7 +311,7 @@ class _AttendanceChart extends StatelessWidget { return {'date': date, 'present': formattedMap[key] ?? 0}; }) .where((d) => (d['present'] ?? 0) > 0) - .toList(); // ✅ remove 0 bars + .toList(); return StackedColumnSeries, String>( dataSource: seriesData, @@ -358,7 +352,7 @@ class _AttendanceTable extends StatelessWidget { @override Widget build(BuildContext context) { - final dateFormat = DateFormat('d MMMM'); + final dateFormat = DateFormat('d MMM'); final uniqueDates = data .map((e) => DateTime.parse(e['date'] as String)) .toSet() @@ -377,10 +371,6 @@ class _AttendanceTable extends StatelessWidget { if (allZero) { return Container( height: 300, - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(5), - ), child: const Center( child: Text( 'No attendance data for the selected range.', @@ -402,38 +392,49 @@ class _AttendanceTable extends StatelessWidget { decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(5), - color: Colors.grey.shade50, ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - columnSpacing: screenWidth < 600 ? 20 : 36, - headingRowHeight: 44, - headingRowColor: - MaterialStateProperty.all(Colors.blueAccent.withOpacity(0.08)), - headingTextStyle: const TextStyle( - fontWeight: FontWeight.bold, color: Colors.black87), - columns: [ - const DataColumn(label: Text('Role')), - ...filteredDates.map((d) => DataColumn(label: Text(d))), - ], - rows: filteredRoles.map((role) { - return DataRow( - cells: [ - DataCell(_RolePill(role: role, color: getRoleColor(role))), - ...filteredDates.map((date) { - final key = '${role}_$date'; - return DataCell( - Text( - NumberFormat.decimalPattern() - .format(formattedMap[key] ?? 0), - style: const TextStyle(fontSize: 13), - ), + child: Scrollbar( + thumbVisibility: true, + trackVisibility: true, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: + BoxConstraints(minWidth: MediaQuery.of(context).size.width), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columnSpacing: 20, + headingRowHeight: 44, + headingRowColor: MaterialStateProperty.all( + Colors.blueAccent.withOpacity(0.08)), + headingTextStyle: const TextStyle( + fontWeight: FontWeight.bold, color: Colors.black87), + columns: [ + const DataColumn(label: Text('Role')), + ...filteredDates.map((d) => DataColumn(label: Text(d))), + ], + rows: filteredRoles.map((role) { + return DataRow( + cells: [ + DataCell( + _RolePill(role: role, color: getRoleColor(role))), + ...filteredDates.map((date) { + final key = '${role}_$date'; + return DataCell( + Text( + NumberFormat.decimalPattern() + .format(formattedMap[key] ?? 0), + style: const TextStyle(fontSize: 13), + ), + ); + }), + ], ); - }), - ], - ); - }).toList(), + }).toList(), + ), + ), + ), ), ), ); diff --git a/lib/helpers/widgets/dashbaord/project_progress_chart.dart b/lib/helpers/widgets/dashbaord/project_progress_chart.dart index 648fc75..0bce9e7 100644 --- a/lib/helpers/widgets/dashbaord/project_progress_chart.dart +++ b/lib/helpers/widgets/dashbaord/project_progress_chart.dart @@ -197,13 +197,13 @@ class ProjectProgressChart extends StatelessWidget { height: height > 280 ? 280 : height, padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: Colors.blueGrey.shade50, + // Remove background + color: Colors.transparent, borderRadius: BorderRadius.circular(5), ), child: SfCartesianChart( tooltipBehavior: TooltipBehavior(enable: true), legend: Legend(isVisible: true, position: LegendPosition.bottom), - // ✅ Use CategoryAxis so only nonZeroData dates show up primaryXAxis: CategoryAxis( majorGridLines: const MajorGridLines(width: 0), axisLine: const AxisLine(width: 0), @@ -273,48 +273,44 @@ class ProjectProgressChart extends StatelessWidget { decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(5), - color: Colors.grey.shade50, + color: Colors.transparent, ), - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: DataTable( - columnSpacing: screenWidth < 600 ? 16 : 36, - headingRowHeight: 44, - headingRowColor: MaterialStateProperty.all( - Colors.blueAccent.withOpacity(0.08)), - headingTextStyle: const TextStyle( - fontWeight: FontWeight.bold, color: Colors.black87), - columns: const [ - DataColumn(label: Text('Date')), - DataColumn(label: Text('Planned')), - DataColumn(label: Text('Completed')), - ], - rows: nonZeroData.map((task) { - return DataRow( - cells: [ - DataCell(Text(DateFormat('d MMM').format(task.date))), - DataCell(Text( - '${task.planned}', - style: TextStyle(color: _getTaskColor('Planned')), - )), - DataCell(Text( - '${task.completed}', - style: TextStyle(color: _getTaskColor('Completed')), - )), - ], - ); - }).toList(), - ), + child: Scrollbar( + thumbVisibility: true, + trackVisibility: true, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: screenWidth), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columnSpacing: screenWidth < 600 ? 16 : 36, + headingRowHeight: 44, + headingRowColor: MaterialStateProperty.all( + Colors.blueAccent.withOpacity(0.08)), + headingTextStyle: const TextStyle( + fontWeight: FontWeight.bold, color: Colors.black87), + columns: const [ + DataColumn(label: Text('Date')), + DataColumn(label: Text('Planned')), + DataColumn(label: Text('Completed')), + ], + rows: nonZeroData.map((task) { + return DataRow( + cells: [ + DataCell(Text(DateFormat('d MMM').format(task.date))), + DataCell(Text('${task.planned}', + style: TextStyle(color: _getTaskColor('Planned')))), + DataCell(Text('${task.completed}', + style: TextStyle(color: _getTaskColor('Completed')))), + ], + ); + }).toList(), ), ), - ); - }, + ), + ), ), ); } @@ -323,7 +319,7 @@ class ProjectProgressChart extends StatelessWidget { return Container( height: height > 280 ? 280 : height, decoration: BoxDecoration( - color: Colors.blueGrey.shade50, + color: Colors.transparent, borderRadius: BorderRadius.circular(5), ), child: const Center( diff --git a/lib/helpers/widgets/tenant/organization_selector.dart b/lib/helpers/widgets/tenant/organization_selector.dart index 8295f97..e902b24 100644 --- a/lib/helpers/widgets/tenant/organization_selector.dart +++ b/lib/helpers/widgets/tenant/organization_selector.dart @@ -77,7 +77,32 @@ class OrganizationSelector extends StatelessWidget { Widget build(BuildContext context) { return Obx(() { if (controller.isLoadingOrganizations.value) { - return const Center(child: CircularProgressIndicator()); + return Container( + height: height ?? 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 100, + height: 14, + color: Colors.grey.shade400, + ), + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.grey.shade400, + shape: BoxShape.circle, + ), + ), + ], + ), + ); } else if (controller.organizations.isEmpty) { return Center( child: Padding( @@ -96,7 +121,6 @@ class OrganizationSelector extends StatelessWidget { ...controller.organizations.map((e) => e.name) ]; - // Listen to selectedOrganization.value return _popupSelector( currentValue: controller.currentSelection, items: orgNames, diff --git a/lib/helpers/widgets/tenant/service_selector.dart b/lib/helpers/widgets/tenant/service_selector.dart index 61b4ad4..d9c65a9 100644 --- a/lib/helpers/widgets/tenant/service_selector.dart +++ b/lib/helpers/widgets/tenant/service_selector.dart @@ -88,11 +88,40 @@ class ServiceSelector extends StatelessWidget { ); } + Widget _skeletonSelector() { + return Container( + height: height ?? 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 100, + height: 14, + color: Colors.grey.shade400, + ), + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.grey.shade400, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Obx(() { if (controller.isLoadingServices.value) { - return const Center(child: CircularProgressIndicator()); + return _skeletonSelector(); } final serviceNames = controller.services.isEmpty diff --git a/lib/model/attendance/attendence_action_button.dart b/lib/model/attendance/attendence_action_button.dart index 7fc49b1..84f698f 100644 --- a/lib/model/attendance/attendence_action_button.dart +++ b/lib/model/attendance/attendence_action_button.dart @@ -108,8 +108,10 @@ class _AttendanceActionButtonState extends State { break; case 1: - final isOldCheckIn = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2); - final isOldCheckOut = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2); + final isOldCheckIn = + AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2); + final isOldCheckOut = + AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2); if (widget.employee.checkOut == null && isOldCheckIn) { action = 2; @@ -167,7 +169,9 @@ class _AttendanceActionButtonState extends State { String? markTime; if (actionText == ButtonActions.requestRegularize) { selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!); - markTime = selectedTime != null ? DateFormat("hh:mm a").format(selectedTime) : null; + markTime = selectedTime != null + ? DateFormat("hh:mm a").format(selectedTime) + : null; } else if (selectedTime != null) { markTime = DateFormat("hh:mm a").format(selectedTime); } @@ -205,13 +209,17 @@ class _AttendanceActionButtonState extends State { Widget build(BuildContext context) { return Obx(() { final controller = widget.attendanceController; - final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false; + 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 isYesterday = + AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut); + final isTodayApproved = + AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn); final isApprovedButNotToday = - AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved); + AttendanceButtonHelper.isApprovedButNotToday( + emp.activity, isTodayApproved); final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( isUploading: isUploading, @@ -272,12 +280,12 @@ class AttendanceActionButtonUI extends StatelessWidget { textStyle: const TextStyle(fontSize: 12), ), child: isUploading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + ? Container( + width: 60, + height: 14, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(4), ), ) : Row( @@ -288,7 +296,8 @@ class AttendanceActionButtonUI extends StatelessWidget { 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), + const Icon(Icons.hourglass_top, + size: 16, color: Colors.orange), if (['approved', 'rejected', 'requested'] .contains(buttonText.toLowerCase())) const SizedBox(width: 4), @@ -342,7 +351,8 @@ Future _showCommentBottomSheet( } return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom), child: BaseBottomSheet( title: sheetTitle, // 👈 now showing full sentence as title onCancel: () => Navigator.of(context).pop(), @@ -375,6 +385,5 @@ Future _showCommentBottomSheet( ); } - 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 f9b2467..cc5517e 100644 --- a/lib/model/attendance/attendence_filter_sheet.dart +++ b/lib/model/attendance/attendence_filter_sheet.dart @@ -39,8 +39,7 @@ class _AttendanceFilterBottomSheetState final endDate = widget.controller.endDateAttendance; if (startDate != null && endDate != null) { - final start = - DateTimeUtils.formatDate(startDate, 'dd MMM yyyy'); + final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy'); final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy'); return "$start - $end"; } @@ -161,7 +160,32 @@ class _AttendanceFilterBottomSheetState ), Obx(() { if (widget.controller.isLoadingOrganizations.value) { - return const Center(child: CircularProgressIndicator()); + return Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 100, + height: 14, + color: Colors.grey.shade400, + ), + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.grey.shade400, + shape: BoxShape.circle, + ), + ), + ], + ), + ); } else if (widget.controller.organizations.isEmpty) { return Center( child: Padding( diff --git a/lib/model/attendance/regualrize_action_button.dart b/lib/model/attendance/regualrize_action_button.dart index bf6eac5..450d4f8 100644 --- a/lib/model/attendance/regualrize_action_button.dart +++ b/lib/model/attendance/regualrize_action_button.dart @@ -3,12 +3,12 @@ import 'package:marco/helpers/utils/attendance_actions.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:get/get.dart'; + enum ButtonActions { approve, reject } class RegularizeActionButton extends StatefulWidget { - final dynamic - attendanceController; - final dynamic log; + final dynamic attendanceController; + final dynamic log; final String uniqueLogKey; final ButtonActions action; @@ -53,57 +53,60 @@ class _RegularizeActionButtonState extends State { Colors.grey; } - Future _handlePress() async { - final projectController = Get.find(); - final selectedProjectId = projectController.selectedProject?.id; + Future _handlePress() async { + final projectController = Get.find(); + final selectedProjectId = projectController.selectedProject?.id; - if (selectedProjectId == null) { - showAppSnackbar( - title: 'Warning', - message: 'Please select a project first', - type: SnackbarType.warning, + if (selectedProjectId == null) { + showAppSnackbar( + title: 'Warning', + message: 'Please select a project first', + type: SnackbarType.warning, + ); + return; + } + + setState(() { + isUploading = true; + }); + + widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = + true; + + final success = + await widget.attendanceController.captureAndUploadAttendance( + widget.log.id, + widget.log.employeeId, + selectedProjectId, + comment: _buttonComments[widget.action]!, + action: _buttonActionCodes[widget.action]!, + imageCapture: false, ); - return; + + showAppSnackbar( + title: success ? 'Success' : 'Error', + message: success + ? '${capitalizeFirstLetter(_buttonTexts[widget.action]!)} marked successfully!' + : 'Failed to mark ${capitalizeFirstLetter(_buttonTexts[widget.action]!)}.', + type: success ? SnackbarType.success : SnackbarType.error, + ); + + if (success) { + widget.attendanceController.fetchEmployeesByProject(selectedProjectId); + widget.attendanceController.fetchAttendanceLogs(selectedProjectId); + await widget.attendanceController + .fetchRegularizationLogs(selectedProjectId); + await widget.attendanceController.fetchProjectData(selectedProjectId); + } + + widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = + false; + + setState(() { + isUploading = false; + }); } - setState(() { - isUploading = true; - }); - - widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = true; - - final success = await widget.attendanceController.captureAndUploadAttendance( - widget.log.id, - widget.log.employeeId, - selectedProjectId, - comment: _buttonComments[widget.action]!, - action: _buttonActionCodes[widget.action]!, - imageCapture: false, - ); - - showAppSnackbar( - title: success ? 'Success' : 'Error', - message: success - ? '${capitalizeFirstLetter(_buttonTexts[widget.action]!)} marked successfully!' - : 'Failed to mark ${capitalizeFirstLetter(_buttonTexts[widget.action]!)}.', - type: success ? SnackbarType.success : SnackbarType.error, - ); - - if (success) { - widget.attendanceController.fetchEmployeesByProject(selectedProjectId); - widget.attendanceController.fetchAttendanceLogs(selectedProjectId); - await widget.attendanceController.fetchRegularizationLogs(selectedProjectId); - await widget.attendanceController.fetchProjectData(selectedProjectId); - } - - widget.attendanceController.uploadingStates[widget.uniqueLogKey]?.value = false; - - setState(() { - isUploading = false; - }); -} - - @override Widget build(BuildContext context) { final buttonText = _buttonTexts[widget.action]!; @@ -116,17 +119,19 @@ class _RegularizeActionButtonState extends State { onPressed: isUploading ? null : _handlePress, style: ElevatedButton.styleFrom( backgroundColor: backgroundColor, - foregroundColor: - Colors.white, // Ensures visibility on all backgrounds + foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6), minimumSize: const Size(60, 20), textStyle: const TextStyle(fontSize: 12), ), child: isUploading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + ? Container( + width: 60, + height: 14, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.5), + borderRadius: BorderRadius.circular(4), + ), ) : FittedBox( fit: BoxFit.scaleDown, diff --git a/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart index b183f8d..eca72d2 100644 --- a/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart @@ -153,7 +153,36 @@ class _AssignTaskBottomSheetState extends State { Widget _buildEmployeeList() { return Obx(() { if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); + // Skeleton loader instead of CircularProgressIndicator + return ListView.separated( + shrinkWrap: true, + itemCount: 5, // show 5 skeleton rows + separatorBuilder: (_, __) => const SizedBox(height: 4), + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + height: 14, + color: Colors.grey.shade300, + ), + ), + ], + ), + ); + }, + ); } final selectedRoleId = controller.selectedRoleId.value; @@ -276,8 +305,9 @@ class _AssignTaskBottomSheetState extends State { hintText: '', border: OutlineInputBorder(), ), - validator: (value) => - this.controller.formFieldValidator(value, fieldType: validatorType), + validator: (value) => this + .controller + .formFieldValidator(value, fieldType: validatorType), ), ], ); diff --git a/lib/model/dailyTaskPlanning/daily_task_model.dart b/lib/model/dailyTaskPlanning/daily_task_model.dart index 4c93b88..bbae91f 100644 --- a/lib/model/dailyTaskPlanning/daily_task_model.dart +++ b/lib/model/dailyTaskPlanning/daily_task_model.dart @@ -16,38 +16,36 @@ class TaskModel { required this.assignmentDate, this.reportedDate, required this.id, - required this.workItem, + this.workItem, required this.workItemId, required this.plannedTask, required this.completedTask, required this.assignedBy, this.approvedBy, - required this.teamMembers, - required this.comments, - required this.reportedPreSignedUrls, + this.teamMembers = const [], + this.comments = const [], + this.reportedPreSignedUrls = const [], }); factory TaskModel.fromJson(Map json) { return TaskModel( - assignmentDate: DateTime.parse(json['assignmentDate']), - reportedDate: json['reportedDate'] != null - ? DateTime.tryParse(json['reportedDate']) - : null, - id: json['id'], - workItem: - json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null, - workItemId: json['workItemId'], - plannedTask: (json['plannedTask'] as num).toDouble(), - completedTask: (json['completedTask'] as num).toDouble(), - assignedBy: AssignedBy.fromJson(json['assignedBy']), - approvedBy: json['approvedBy'] != null - ? AssignedBy.fromJson(json['approvedBy']) - : null, - teamMembers: (json['teamMembers'] as List) - .map((e) => TeamMember.fromJson(e)) - .toList(), - comments: - (json['comments'] as List).map((e) => Comment.fromJson(e)).toList(), + assignmentDate: DateTime.parse(json['assignmentDate'] ?? DateTime.now().toIso8601String()), + reportedDate: json['reportedDate'] != null ? DateTime.tryParse(json['reportedDate']) : null, + id: json['id']?.toString() ?? '', + workItem: json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null, + workItemId: json['workItemId']?.toString() ?? '', + plannedTask: (json['plannedTask'] as num?)?.toDouble() ?? 0, + completedTask: (json['completedTask'] as num?)?.toDouble() ?? 0, + assignedBy: AssignedBy.fromJson(json['assignedBy'] ?? {}), + approvedBy: json['approvedBy'] != null ? AssignedBy.fromJson(json['approvedBy']) : null, + teamMembers: (json['teamMembers'] as List?) + ?.map((e) => TeamMember.fromJson(e)) + .toList() ?? + [], + comments: (json['comments'] as List?) + ?.map((e) => Comment.fromJson(e)) + .toList() ?? + [], reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List?) ?.map((e) => e.toString()) .toList() ?? @@ -79,8 +77,7 @@ class WorkItem { activityMaster: json['activityMaster'] != null ? ActivityMaster.fromJson(json['activityMaster']) : null, - workArea: - json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null, + workArea: json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null, plannedWork: (json['plannedWork'] as num?)?.toDouble(), completedWork: (json['completedWork'] as num?)?.toDouble(), preSignedUrls: (json['preSignedUrls'] as List?) @@ -92,7 +89,7 @@ class WorkItem { } class ActivityMaster { - final String? id; // ✅ Added + final String? id; final String activityName; ActivityMaster({ @@ -103,13 +100,13 @@ class ActivityMaster { factory ActivityMaster.fromJson(Map json) { return ActivityMaster( id: json['id']?.toString(), - activityName: json['activityName'] ?? '', + activityName: json['activityName']?.toString() ?? '', ); } } class WorkArea { - final String? id; // ✅ Added + final String? id; final String areaName; final Floor? floor; @@ -122,7 +119,7 @@ class WorkArea { factory WorkArea.fromJson(Map json) { return WorkArea( id: json['id']?.toString(), - areaName: json['areaName'] ?? '', + areaName: json['areaName']?.toString() ?? '', floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null, ); } @@ -136,9 +133,8 @@ class Floor { factory Floor.fromJson(Map json) { return Floor( - floorName: json['floorName'] ?? '', - building: - json['building'] != null ? Building.fromJson(json['building']) : null, + floorName: json['floorName']?.toString() ?? '', + building: json['building'] != null ? Building.fromJson(json['building']) : null, ); } } @@ -149,7 +145,7 @@ class Building { Building({required this.name}); factory Building.fromJson(Map json) { - return Building(name: json['name'] ?? ''); + return Building(name: json['name']?.toString() ?? ''); } } @@ -167,8 +163,8 @@ class AssignedBy { factory AssignedBy.fromJson(Map json) { return AssignedBy( id: json['id']?.toString() ?? '', - firstName: json['firstName'] ?? '', - lastName: json['lastName'], + firstName: json['firstName']?.toString() ?? '', + lastName: json['lastName']?.toString(), ); } } @@ -203,7 +199,7 @@ class Comment { required this.comment, required this.commentedBy, required this.timestamp, - required this.preSignedUrls, + this.preSignedUrls = const [], }); factory Comment.fromJson(Map json) { @@ -212,7 +208,9 @@ class Comment { commentedBy: json['employee'] != null ? TeamMember.fromJson(json['employee']) : TeamMember(id: '', firstName: '', lastName: null), - timestamp: DateTime.parse(json['commentDate'] ?? ''), + timestamp: json['commentDate'] != null + ? DateTime.parse(json['commentDate']) + : DateTime.now(), preSignedUrls: (json['preSignedUrls'] as List?) ?.map((e) => e.toString()) .toList() ?? diff --git a/lib/model/dailyTaskPlanning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlanning/report_action_bottom_sheet.dart index 54b333b..3c25f8b 100644 --- a/lib/model/dailyTaskPlanning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlanning/report_action_bottom_sheet.dart @@ -147,8 +147,9 @@ class _ReportActionBottomSheetState extends State floatingLabelBehavior: FloatingLabelBehavior.never, ), ), - MySpacing.height(10), + + // Reported Images Section if ((widget.taskData['reportedPreSignedUrls'] as List?) ?.isNotEmpty == true) @@ -157,39 +158,37 @@ class _ReportActionBottomSheetState extends State widget.taskData['reportedPreSignedUrls'] ?? []), context: context, ), - MySpacing.height(10), + + // Report Actions Dropdown MyText.titleSmall("Report Actions", fontWeight: 600), MySpacing.height(10), - Obx(() { if (controller.isLoadingWorkStatus.value) return const CircularProgressIndicator(); return PopupMenuButton( - onSelected: (String value) { + onSelected: (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(); - }, + itemBuilder: (context) => 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( @@ -211,9 +210,9 @@ class _ReportActionBottomSheetState extends State ), ); }), - MySpacing.height(10), + // Add New Task Checkbox Obx(() { if (!controller.showAddTaskCheckbox.value) return const SizedBox.shrink(); @@ -221,19 +220,15 @@ class _ReportActionBottomSheetState extends State data: Theme.of(context).copyWith( checkboxTheme: CheckboxThemeData( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - side: const BorderSide( - color: Colors.black, width: 2), + 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.white; + if (states.contains(MaterialState.selected)) + return Colors.blueAccent; + return Colors.white; }), - checkColor: - MaterialStateProperty.all(Colors.white), - ), + checkColor: MaterialStateProperty.all(Colors.white), + ), ), child: CheckboxListTile( title: MyText.titleSmall("Add new task", fontWeight: 600), @@ -245,10 +240,9 @@ class _ReportActionBottomSheetState extends State ), ); }), - MySpacing.height(24), - // ✏️ Comment Field + // 💬 Comment Field Row( children: [ Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]), @@ -258,8 +252,8 @@ class _ReportActionBottomSheetState extends State ), MySpacing.height(8), TextFormField( - validator: controller.basicValidator.getValidation('comment'), controller: controller.basicValidator.getController('comment'), + validator: controller.basicValidator.getValidation('comment'), keyboardType: TextInputType.text, decoration: InputDecoration( hintText: "eg: Work done successfully", @@ -269,10 +263,9 @@ class _ReportActionBottomSheetState extends State floatingLabelBehavior: FloatingLabelBehavior.never, ), ), - MySpacing.height(16), - // 📸 Image Attachments + // 📸 Attach Photos Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -297,21 +290,18 @@ class _ReportActionBottomSheetState extends State 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, - ), - ); - }, + onPreviewImage: (index) => showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: images, + initialIndex: index, + ), + ), ); }), - MySpacing.height(12), - // ✅ Submit/Cancel Buttons moved here + // ✅ Submit/Cancel Buttons Row( children: [ Expanded( @@ -347,7 +337,6 @@ class _ReportActionBottomSheetState extends State ?.text .trim() ?? ''; - final shouldShowAddTaskSheet = controller.isAddTaskChecked.value; @@ -408,10 +397,9 @@ class _ReportActionBottomSheetState extends State ), ], ), - MySpacing.height(12), - // 💬 Previous Comments List (only below submit) + // 💬 Previous Comments if ((widget.taskData['taskComments'] as List?)?.isNotEmpty == true) ...[ Row( diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index 97a37b2..656e45d 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -36,7 +36,7 @@ class _AddEmployeeBottomSheetState extends State late final TextEditingController _genderController; late final TextEditingController _roleController; - @override + @override void initState() { super.initState(); _orgFieldController = TextEditingController(); @@ -50,7 +50,6 @@ class _AddEmployeeBottomSheetState extends State _controller.editingEmployeeData = widget.employeeData; _controller.prefillFields(); - // Prepopulate hasApplicationAccess and email _hasApplicationAccess = widget.employeeData?['hasApplicationAccess'] ?? false; @@ -60,22 +59,32 @@ class _AddEmployeeBottomSheetState extends State email.toString(); } - // Trigger UI rebuild to reflect email & checkbox - setState(() {}); + final orgId = widget.employeeData?['organization_id']; + if (orgId != null) { + final org = _organizationController.organizations + .firstWhereOrNull((o) => o.id == orgId); + if (org != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _organizationController.selectOrganization(org); + _controller.selectedOrganizationId = org.id; + _orgFieldController.text = org.name; + }); + } + } - // Joining date + // ✅ Prefill Joining date if (_controller.joiningDate != null) { _joiningDateController.text = DateFormat('dd MMM yyyy').format(_controller.joiningDate!); } - // Gender + // ✅ Prefill Gender if (_controller.selectedGender != null) { _genderController.text = _controller.selectedGender!.name.capitalizeFirst ?? ''; } - // Role + // ✅ Prefill Role _controller.fetchRoles().then((_) { if (_controller.selectedRoleId != null) { final roleName = _controller.roles.firstWhereOrNull( @@ -91,6 +100,7 @@ class _AddEmployeeBottomSheetState extends State _controller.fetchRoles(); } } + @override void dispose() { _orgFieldController.dispose(); @@ -337,8 +347,7 @@ class _AddEmployeeBottomSheetState extends State return null; }, keyboardType: TextInputType.emailAddress, - decoration: _inputDecoration('e.g., john.doe@example.com').copyWith( - ), + decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(), ), ], ); diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 5f89aec..66718b4 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -84,8 +84,6 @@ class _DashboardScreenState extends State with UIMixin { /// Project Progress Chart Section Widget _buildProjectProgressChartSection() { return Obx(() { - - if (dashboardController.projectChartData.isEmpty) { return const Padding( padding: EdgeInsets.all(16), @@ -110,7 +108,6 @@ class _DashboardScreenState extends State with UIMixin { /// Attendance Chart Section Widget _buildAttendanceChartSection() { return Obx(() { - final isAttendanceAllowed = menuController.isMenuAllowed("Attendance"); if (!isAttendanceAllowed) { @@ -212,7 +209,7 @@ class _DashboardScreenState extends State with UIMixin { return _buildLoadingSkeleton(context); } - if (menuController.hasError.value && menuController.menuItems.isEmpty) { + if (menuController.hasError.value || menuController.menuItems.isEmpty) { return Padding( padding: const EdgeInsets.all(16), child: Center( @@ -224,6 +221,10 @@ class _DashboardScreenState extends State with UIMixin { ); } + final projectController = Get.find(); + final isProjectSelected = projectController.selectedProject != null; + + // Keep previous stat items (icons, title, routes) final stats = [ _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, DashboardScreen.attendanceRoute), @@ -241,8 +242,16 @@ class _DashboardScreenState extends State with UIMixin { DashboardScreen.documentMainPageRoute), ]; - final projectController = Get.find(); - final isProjectSelected = projectController.selectedProject != null; + // Safe menu check function to avoid exceptions + bool _isMenuAllowed(String menuTitle) { + try { + return menuController.menuItems.isNotEmpty + ? menuController.isMenuAllowed(menuTitle) + : false; + } catch (e) { + return false; + } + } return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -250,7 +259,6 @@ class _DashboardScreenState extends State with UIMixin { if (!isProjectSelected) _buildNoProjectMessage(), LayoutBuilder( builder: (context, constraints) { - // ✅ smaller width cards → fit more in a row int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8); double cardWidth = (constraints.maxWidth - (crossAxisCount - 1) * 6) / @@ -261,14 +269,10 @@ class _DashboardScreenState extends State with UIMixin { runSpacing: 6, alignment: WrapAlignment.start, children: stats - .where((stat) { - if (stat.title == "Documents") return true; - return menuController.isMenuAllowed(stat.title); - }) + .where((stat) => _isMenuAllowed(stat.title)) .map((stat) => _buildStatCard(stat, isProjectSelected, cardWidth)) - .toList() - .cast(), + .toList(), ); }, ), diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index e77c747..b5394d8 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -360,33 +360,31 @@ class _ContactDetailScreenState extends State { [...activeComments, ...inactiveComments].reversed.toList(); final editingId = directoryController.editingCommentId.value; - if (comments.isEmpty) { - return Center( - child: MyText.bodyLarge("No notes yet.", color: Colors.grey), - ); - } - return Stack( children: [ - MyRefreshIndicator( - onRefresh: () async { - await directoryController.fetchCommentsForContact(contactId, - active: true); - await directoryController.fetchCommentsForContact(contactId, - active: false); - }, - child: Padding( - padding: MySpacing.xy(12, 12), - child: ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 100), - itemCount: comments.length, - separatorBuilder: (_, __) => MySpacing.height(14), - itemBuilder: (_, index) => - _buildCommentItem(comments[index], editingId, contactId), - ), - ), - ), + comments.isEmpty + ? Center( + child: MyText.bodyLarge("No notes yet.", color: Colors.grey), + ) + : MyRefreshIndicator( + onRefresh: () async { + await directoryController.fetchCommentsForContact(contactId, + active: true); + await directoryController.fetchCommentsForContact(contactId, + active: false); + }, + child: Padding( + padding: MySpacing.xy(12, 12), + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 100), + itemCount: comments.length, + separatorBuilder: (_, __) => MySpacing.height(14), + itemBuilder: (_, index) => _buildCommentItem( + comments[index], editingId, contactId), + ), + ), + ), if (editingId == null) Positioned( bottom: 20, diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart index e20953b..1c07d7b 100644 --- a/lib/view/document/document_details_page.dart +++ b/lib/view/document/document_details_page.dart @@ -27,8 +27,8 @@ class _DocumentDetailsPageState extends State { final DocumentDetailsController controller = Get.find(); - final PermissionController permissionController = - Get.find(); + final permissionController = Get.put(PermissionController()); + @override void initState() { super.initState(); diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index a60c73e..e7228eb 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -1,24 +1,24 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:marco/controller/document/user_document_controller.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_refresh_indicator.dart'; -import 'package:marco/helpers/utils/permission_constants.dart'; -import 'package:marco/model/document/user_document_filter_bottom_sheet.dart'; -import 'package:marco/model/document/documents_list_model.dart'; import 'package:intl/intl.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; -import 'package:marco/model/document/document_upload_bottom_sheet.dart'; +import 'package:marco/controller/document/document_details_controller.dart'; import 'package:marco/controller/document/document_upload_controller.dart'; -import 'package:marco/view/document/document_details_page.dart'; +import 'package:marco/controller/document/user_document_controller.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.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/permission_controller.dart'; -import 'package:marco/controller/document/document_details_controller.dart'; -import 'dart:convert'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/document/document_upload_bottom_sheet.dart'; +import 'package:marco/model/document/documents_list_model.dart'; +import 'package:marco/model/document/user_document_filter_bottom_sheet.dart'; +import 'package:marco/view/document/document_details_page.dart'; class UserDocumentsPage extends StatefulWidget { final String? entityId; @@ -36,10 +36,9 @@ class UserDocumentsPage extends StatefulWidget { class _UserDocumentsPageState extends State { final DocumentController docController = Get.put(DocumentController()); - final PermissionController permissionController = - Get.find(); - final DocumentDetailsController controller = - Get.put(DocumentDetailsController()); + final PermissionController permissionController = Get.put(PermissionController()); + final DocumentDetailsController controller = Get.put(DocumentDetailsController()); + String get entityTypeId => widget.isEmployee ? Permissions.employeeEntity : Permissions.projectEntity; @@ -68,12 +67,9 @@ class _UserDocumentsPageState extends State { } Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) { - final uploadDate = - DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal()); - + final uploadDate = DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal()); final uploader = doc.uploadedBy.firstName.isNotEmpty - ? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}" - .trim() + ? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim() : "Added by you"; return Column( @@ -91,7 +87,6 @@ class _UserDocumentsPageState extends State { ), InkWell( onTap: () { - // 👉 Navigate to details page Get.to(() => DocumentDetailsPage(documentId: doc.id)); }, child: Container( @@ -146,92 +141,90 @@ class _UserDocumentsPageState extends State { ], ), ), - PopupMenuButton( - icon: const Icon(Icons.more_vert, color: Colors.black54), - onSelected: (value) async { - if (value == "delete") { - // existing delete flow (unchanged) - final result = await showDialog( - context: context, - builder: (_) => ConfirmDialog( - title: "Delete Document", - message: - "Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.", - confirmText: "Delete", - cancelText: "Cancel", - icon: Icons.delete_forever, - confirmColor: Colors.redAccent, - onConfirm: () async { - final success = - await docController.toggleDocumentActive( - doc.id, - isActive: false, - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - ); + Obx(() { + // React to permission changes + return PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.black54), + onSelected: (value) async { + if (value == "delete") { + final result = await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: "Delete Document", + message: + "Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + icon: Icons.delete_forever, + confirmColor: Colors.redAccent, + onConfirm: () async { + final success = + await docController.toggleDocumentActive( + doc.id, + isActive: false, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); - if (success) { - showAppSnackbar( - title: "Deleted", - message: "Document deleted successfully", - type: SnackbarType.success, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to delete document", - type: SnackbarType.error, - ); - throw Exception( - "Failed to delete"); // keep dialog open - } - }, + if (success) { + showAppSnackbar( + title: "Deleted", + message: "Document deleted successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to delete document", + type: SnackbarType.error, + ); + throw Exception("Failed to delete"); + } + }, + ), + ); + if (result == true) { + debugPrint("✅ Document deleted and removed from list"); + } + } else if (value == "restore") { + final success = await docController.toggleDocumentActive( + doc.id, + isActive: true, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + + if (success) { + showAppSnackbar( + title: "Restored", + message: "Document restored successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to restore document", + type: SnackbarType.error, + ); + } + } + }, + itemBuilder: (context) => [ + if (doc.isActive && + permissionController.hasPermission(Permissions.deleteDocument)) + const PopupMenuItem( + value: "delete", + child: Text("Delete"), + ) + else if (!doc.isActive && + permissionController.hasPermission(Permissions.modifyDocument)) + const PopupMenuItem( + value: "restore", + child: Text("Restore"), ), - ); - if (result == true) { - debugPrint("✅ Document deleted and removed from list"); - } - } else if (value == "restore") { - // existing activate flow (unchanged) - final success = await docController.toggleDocumentActive( - doc.id, - isActive: true, - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - ); - - if (success) { - showAppSnackbar( - title: "Restored", - message: "Document reastored successfully", - type: SnackbarType.success, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to restore document", - type: SnackbarType.error, - ); - } - } - }, - itemBuilder: (context) => [ - if (doc.isActive && - permissionController - .hasPermission(Permissions.deleteDocument)) - const PopupMenuItem( - value: "delete", - child: Text("Delete"), - ) - else if (!doc.isActive && - permissionController - .hasPermission(Permissions.modifyDocument)) - const PopupMenuItem( - value: "restore", - child: Text("Restore"), - ), - ], - ), + ], + ); + }), ], ), ), @@ -267,7 +260,6 @@ class _UserDocumentsPageState extends State { padding: MySpacing.xy(8, 8), child: Row( children: [ - // 🔍 Search Bar Expanded( child: SizedBox( height: 35, @@ -283,15 +275,13 @@ class _UserDocumentsPageState extends State { }, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: - const Icon(Icons.search, size: 20, color: Colors.grey), + prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), suffixIcon: ValueListenableBuilder( valueListenable: docController.searchController, builder: (context, value, _) { if (value.text.isEmpty) return const SizedBox.shrink(); return IconButton( - icon: const Icon(Icons.clear, - size: 20, color: Colors.grey), + icon: const Icon(Icons.clear, size: 20, color: Colors.grey), onPressed: () { docController.searchController.clear(); docController.searchQuery.value = ''; @@ -320,8 +310,6 @@ class _UserDocumentsPageState extends State { ), ), MySpacing.width(8), - - // 🛠️ Filter Icon with indicator Obx(() { final isFilterActive = docController.hasActiveFilters(); return Stack( @@ -337,18 +325,13 @@ class _UserDocumentsPageState extends State { child: IconButton( padding: EdgeInsets.zero, constraints: BoxConstraints(), - icon: Icon( - Icons.tune, - size: 20, - color: Colors.black87, - ), + icon: Icon(Icons.tune, size: 20, color: Colors.black87), onPressed: () { showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(5)), + borderRadius: BorderRadius.vertical(top: Radius.circular(5)), ), builder: (_) => UserDocumentFilterBottomSheet( entityId: resolvedEntityId, @@ -375,8 +358,6 @@ class _UserDocumentsPageState extends State { ); }), MySpacing.width(10), - - // ⋮ Menu (Show Inactive toggle) Container( height: 35, width: 35, @@ -387,8 +368,7 @@ class _UserDocumentsPageState extends State { ), child: PopupMenuButton( padding: EdgeInsets.zero, - icon: - const Icon(Icons.more_vert, size: 20, color: Colors.black87), + icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5), ), @@ -439,8 +419,7 @@ class _UserDocumentsPageState extends State { Widget _buildStatusHeader() { return Obx(() { - final isInactive = docController.showInactive.value; - if (!isInactive) return const SizedBox.shrink(); // hide when active + if (!docController.showInactive.value) return const SizedBox.shrink(); return Container( width: double.infinity, @@ -448,18 +427,11 @@ class _UserDocumentsPageState extends State { color: Colors.red.shade50, child: Row( children: [ - Icon( - Icons.visibility_off, - color: Colors.red, - size: 18, - ), + Icon(Icons.visibility_off, color: Colors.red, size: 18), const SizedBox(width: 8), Text( "Showing Deleted Documents", - style: TextStyle( - color: Colors.red, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600), ), ], ), @@ -468,30 +440,33 @@ class _UserDocumentsPageState extends State { } Widget _buildBody(BuildContext context) { - // 🔒 Check for viewDocument permission - if (!permissionController.hasPermission(Permissions.viewDocument)) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.lock_outline, size: 60, color: Colors.grey), - MySpacing.height(18), - MyText.titleMedium( - 'Access Denied', - fontWeight: 600, - color: Colors.grey, - ), - MySpacing.height(10), - MyText.bodySmall( - 'You do not have permission to view documents.', - color: Colors.grey, - ), - ], - ), - ); - } - return Obx(() { + if (permissionController.permissions.isEmpty) { + return Center(child: CircularProgressIndicator()); + } + + if (!permissionController.hasPermission(Permissions.viewDocument)) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.lock_outline, size: 60, color: Colors.grey), + MySpacing.height(18), + MyText.titleMedium( + 'Access Denied', + fontWeight: 600, + color: Colors.grey, + ), + MySpacing.height(10), + MyText.bodySmall( + 'You do not have permission to view documents.', + color: Colors.grey, + ), + ], + ), + ); + } + if (docController.isLoading.value && docController.documents.isEmpty) { return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), @@ -510,8 +485,7 @@ class _UserDocumentsPageState extends State { onRefresh: () async { final combinedFilter = { 'uploadedByIds': docController.selectedUploadedBy.toList(), - 'documentCategoryIds': - docController.selectedCategory.toList(), + 'documentCategoryIds': docController.selectedCategory.toList(), 'documentTypeIds': docController.selectedType.toList(), 'documentTagIds': docController.selectedTag.toList(), }; @@ -525,9 +499,7 @@ class _UserDocumentsPageState extends State { }, child: ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: docs.isEmpty - ? null - : const EdgeInsets.fromLTRB(0, 0, 0, 80), + padding: docs.isEmpty ? null : const EdgeInsets.fromLTRB(0, 0, 0, 80), children: docs.isEmpty ? [ SizedBox( @@ -543,8 +515,8 @@ class _UserDocumentsPageState extends State { final currentDate = DateFormat("dd MMM yyyy") .format(doc.uploadedAt.toLocal()); final prevDate = index > 0 - ? DateFormat("dd MMM yyyy").format( - docs[index - 1].uploadedAt.toLocal()) + ? DateFormat("dd MMM yyyy") + .format(docs[index - 1].uploadedAt.toLocal()) : null; final showDateHeader = currentDate != prevDate; @@ -591,58 +563,61 @@ class _UserDocumentsPageState extends State { ) : null, body: _buildBody(context), - floatingActionButton: permissionController - .hasPermission(Permissions.uploadDocument) - ? FloatingActionButton.extended( - onPressed: () { - final uploadController = Get.put(DocumentUploadController()); + floatingActionButton: Obx(() { + if (permissionController.permissions.isEmpty) return SizedBox.shrink(); - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => DocumentUploadBottomSheet( - isEmployee: widget.isEmployee, - onSubmit: (data) async { - final success = await uploadController.uploadDocument( - name: data["name"], - description: data["description"], - documentId: data["documentId"], - entityId: resolvedEntityId, - documentTypeId: data["documentTypeId"], - fileName: data["attachment"]["fileName"], - base64Data: data["attachment"]["base64Data"], - contentType: data["attachment"]["contentType"], - fileSize: data["attachment"]["fileSize"], - ); + return permissionController.hasPermission(Permissions.uploadDocument) + ? FloatingActionButton.extended( + onPressed: () { + final uploadController = Get.put(DocumentUploadController()); - if (success) { - Navigator.pop(context); - docController.fetchDocuments( - entityTypeId: entityTypeId, + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => DocumentUploadBottomSheet( + isEmployee: widget.isEmployee, + onSubmit: (data) async { + final success = await uploadController.uploadDocument( + name: data["name"], + description: data["description"], + documentId: data["documentId"], entityId: resolvedEntityId, - reset: true, + documentTypeId: data["documentTypeId"], + fileName: data["attachment"]["fileName"], + base64Data: data["attachment"]["base64Data"], + contentType: data["attachment"]["contentType"], + fileSize: data["attachment"]["fileSize"], ); - } else { - showAppSnackbar( - title: "Error", - message: "Upload failed, please try again", - type: SnackbarType.error, - ); - } - }, - ), - ); - }, - icon: const Icon(Icons.add, color: Colors.white), - label: MyText.bodyMedium( - "Add Document", - color: Colors.white, - fontWeight: 600, - ), - backgroundColor: Colors.red, - ) - : null, + + if (success) { + Navigator.pop(context); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Upload failed, please try again", + type: SnackbarType.error, + ); + } + }, + ), + ); + }, + icon: const Icon(Icons.add, color: Colors.white), + label: MyText.bodyMedium( + "Add Document", + color: Colors.white, + fontWeight: 600, + ), + backgroundColor: Colors.red, + ) + : SizedBox.shrink(); + }), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); } diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 752dc8d..6d0c418 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -30,8 +30,8 @@ class EmployeeDetailPage extends StatefulWidget { class _EmployeeDetailPageState extends State { final EmployeesScreenController controller = Get.put(EmployeesScreenController()); - final PermissionController _permissionController = - Get.find(); + final PermissionController permissionController = + Get.put(PermissionController()); @override void initState() { @@ -272,6 +272,7 @@ class _EmployeeDetailPageState extends State { 'job_role_id': employee.jobRoleId, 'joining_date': employee.joiningDate?.toIso8601String(), + 'organization_id': employee.organizationId, }, ), ); @@ -292,7 +293,7 @@ class _EmployeeDetailPageState extends State { ); }), floatingActionButton: Obx(() { - if (!_permissionController.hasPermission(Permissions.assignToProject)) { + if (!permissionController.hasPermission(Permissions.assignToProject)) { return const SizedBox.shrink(); } if (controller.isLoadingEmployeeDetails.value || diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 19c6e46..79146de 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -29,8 +29,8 @@ class EmployeesScreen extends StatefulWidget { class _EmployeesScreenState extends State with UIMixin { final EmployeesScreenController _employeeController = Get.put(EmployeesScreenController()); - final PermissionController _permissionController = - Get.find(); + final PermissionController permissionController = + Get.put(PermissionController()); final TextEditingController _searchController = TextEditingController(); final RxList _filteredEmployees = [].obs; final OrganizationController _organizationController = @@ -248,7 +248,7 @@ class _EmployeesScreenState extends State with UIMixin { } Widget _buildFloatingActionButton() { - if (!_permissionController.hasPermission(Permissions.manageEmployees)) { + if (!permissionController.hasPermission(Permissions.manageEmployees)) { return const SizedBox.shrink(); } @@ -371,7 +371,7 @@ class _EmployeesScreenState extends State with UIMixin { } Widget _buildPopupMenu() { - if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) { + if (!permissionController.hasPermission(Permissions.viewAllEmployees)) { return const SizedBox.shrink(); } diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index ecb5b6e..1975d18 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -33,7 +33,7 @@ class ExpenseDetailScreen extends StatefulWidget { class _ExpenseDetailScreenState extends State { final controller = Get.put(ExpenseDetailController()); final projectController = Get.find(); - final permissionController = Get.find(); +final permissionController = Get.put(PermissionController()); EmployeeInfo? employeeInfo; final RxBool canSubmit = false.obs; diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index cf120f6..ed356f2 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -26,7 +26,7 @@ class _ExpenseMainScreenState extends State final searchController = TextEditingController(); final expenseController = Get.put(ExpenseController()); final projectController = Get.find(); - final permissionController = Get.find(); +final permissionController = Get.put(PermissionController()); @override void initState() { @@ -81,7 +81,7 @@ class _ExpenseMainScreenState extends State .toList(); } - @override +@override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, @@ -106,7 +106,7 @@ Widget build(BuildContext context) { // ---------------- Gray background for rest ---------------- Expanded( child: Container( - color: Colors.grey[100], // Light gray background + color: Colors.grey[100], child: Column( children: [ // ---------------- Search ---------------- @@ -137,14 +137,24 @@ Widget build(BuildContext context) { ], ), - floatingActionButton: - permissionController.hasPermission(Permissions.expenseUpload) - ? FloatingActionButton( - backgroundColor: Colors.red, - onPressed: showAddExpenseBottomSheet, - child: const Icon(Icons.add, color: Colors.white), - ) - : null, + // ✅ FAB reacts only to upload permission + floatingActionButton: Obx(() { + // Show loader or hide FAB while permissions are loading + if (permissionController.permissions.isEmpty) { + return const SizedBox.shrink(); + } + + final canUpload = + permissionController.hasPermission(Permissions.expenseUpload); + + return canUpload + ? FloatingActionButton( + backgroundColor: Colors.red, + onPressed: showAddExpenseBottomSheet, + child: const Icon(Icons.add, color: Colors.white), + ) + : const SizedBox.shrink(); + }), ); } diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index a15d2d6..c7e36c2 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -9,10 +9,12 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/controller/auth/mpin_controller.dart'; -import 'package:marco/controller/tenant/tenant_selection_controller.dart'; import 'package:marco/view/employees/employee_profile_screen.dart'; import 'package:marco/helpers/services/tenant_service.dart'; import 'package:marco/view/tenant/tenant_selection_screen.dart'; +import 'package:marco/controller/tenant/tenant_switch_controller.dart'; + + class UserProfileBar extends StatefulWidget { final bool isCondensed; @@ -27,21 +29,13 @@ class _UserProfileBarState extends State late EmployeeInfo employeeInfo; bool _isLoading = true; bool hasMpin = true; - late final TenantSelectionController _tenantController; @override void initState() { super.initState(); - _tenantController = Get.put(TenantSelectionController()); _initData(); } - @override - void dispose() { - Get.delete(); - super.dispose(); - } - Future _initData() async { employeeInfo = LocalStorage.getEmployeeInfo()!; hasMpin = await LocalStorage.getIsMpin(); @@ -122,93 +116,101 @@ class _UserProfileBarState extends State } /// Row widget to switch tenant with popup menu (button only) - Widget _switchTenantRow() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Obx(() { - if (_tenantController.isLoading.value) return _loadingTenantContainer(); + /// Row widget to switch tenant with popup menu (button only) +Widget _switchTenantRow() { + // Use the dedicated switch controller + final TenantSwitchController tenantSwitchController = + Get.put(TenantSwitchController()); - final tenants = _tenantController.tenants; - if (tenants.isEmpty) return _noTenantContainer(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Obx(() { + if (tenantSwitchController.isLoading.value) { + return _loadingTenantContainer(); + } - final selectedTenant = TenantService.currentTenant; + final tenants = tenantSwitchController.tenants; + if (tenants.isEmpty) return _noTenantContainer(); - // Sort tenants: selected tenant first - final sortedTenants = List.of(tenants); - if (selectedTenant != null) { - sortedTenants.sort((a, b) { - if (a.id == selectedTenant.id) return -1; - if (b.id == selectedTenant.id) return 1; - return 0; - }); - } + final selectedTenant = TenantService.currentTenant; - return PopupMenuButton( - onSelected: (tenantId) => - _tenantController.onTenantSelected(tenantId), - itemBuilder: (_) => sortedTenants.map((tenant) { - return PopupMenuItem( - value: tenant.id, - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - width: 20, - height: 20, - color: Colors.grey.shade200, - child: TenantLogo(logoImage: tenant.logoImage), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - tenant.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: tenant.id == selectedTenant?.id - ? FontWeight.bold - : FontWeight.w600, - color: tenant.id == selectedTenant?.id - ? Colors.blueAccent - : Colors.black87, - ), - ), - ), - if (tenant.id == selectedTenant?.id) - const Icon(Icons.check_circle, - color: Colors.blueAccent, size: 18), - ], - ), - ); - }).toList(), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + // Sort tenants: selected tenant first + final sortedTenants = List.of(tenants); + if (selectedTenant != null) { + sortedTenants.sort((a, b) { + if (a.id == selectedTenant.id) return -1; + if (b.id == selectedTenant.id) return 1; + return 0; + }); + } + + return PopupMenuButton( + onSelected: (tenantId) => + tenantSwitchController.switchTenant(tenantId), + itemBuilder: (_) => sortedTenants.map((tenant) { + return PopupMenuItem( + value: tenant.id, child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon(Icons.swap_horiz, color: Colors.blue.shade600), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 20, + height: 20, + color: Colors.grey.shade200, + child: TenantLogo(logoImage: tenant.logoImage), + ), + ), + const SizedBox(width: 10), Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - "Switch Organization", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.blue, fontWeight: FontWeight.bold), + child: Text( + tenant.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: tenant.id == selectedTenant?.id + ? FontWeight.bold + : FontWeight.w600, + color: tenant.id == selectedTenant?.id + ? Colors.blueAccent + : Colors.black87, ), ), ), - Icon(Icons.arrow_drop_down, color: Colors.blue.shade600), + if (tenant.id == selectedTenant?.id) + const Icon(Icons.check_circle, + color: Colors.blueAccent, size: 18), ], ), + ); + }).toList(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon(Icons.swap_horiz, color: Colors.blue.shade600), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + "Switch Organization", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.blue, fontWeight: FontWeight.bold), + ), + ), + ), + Icon(Icons.arrow_drop_down, color: Colors.blue.shade600), + ], ), - ); - }), - ); - } + ), + ); + }), + ); +} + Widget _loadingTenantContainer() => Container( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), diff --git a/lib/view/my_app.dart b/lib/view/my_app.dart index a27ae57..5b4a66a 100644 --- a/lib/view/my_app.dart +++ b/lib/view/my_app.dart @@ -5,7 +5,6 @@ import 'package:provider/provider.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/extensions/app_localization_delegate.dart'; -import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/localizations/language.dart'; import 'package:marco/helpers/services/navigation_services.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; @@ -19,22 +18,21 @@ class MyApp extends StatelessWidget { Future _getInitialRoute() async { try { - if (!AuthService.isLoggedIn) { + final token = LocalStorage.getJwtToken(); + if (token == null || token.isEmpty) { logSafe("User not logged in. Routing to /auth/login-option"); return "/auth/login-option"; } final bool hasMpin = LocalStorage.getIsMpin(); - logSafe("MPIN enabled: $hasMpin", ); - if (hasMpin) { await LocalStorage.setBool("mpin_verified", false); - logSafe("Routing to /auth/mpin-auth and setting mpin_verified to false"); + logSafe("Routing to /auth/mpin-auth"); return "/auth/mpin-auth"; - } else { - logSafe("MPIN not enabled. Routing to /dashboard"); - return "/dashboard"; } + + logSafe("No MPIN. Routing to /dashboard"); + return "/dashboard"; } catch (e, stacktrace) { logSafe("Error determining initial route", level: LogLevel.error, error: e, stackTrace: stacktrace); diff --git a/lib/view/taskPlanning/daily_progress.dart b/lib/view/taskPlanning/daily_progress.dart index c102803..103ffde 100644 --- a/lib/view/taskPlanning/daily_progress.dart +++ b/lib/view/taskPlanning/daily_progress.dart @@ -41,7 +41,7 @@ class _DailyProgressReportScreenState extends State final DailyTaskController dailyTaskController = Get.put(DailyTaskController()); final PermissionController permissionController = - Get.find(); + Get.put(PermissionController()); final ProjectController projectController = Get.find(); final ServiceController serviceController = Get.put(ServiceController()); final ScrollController _scrollController = ScrollController(); diff --git a/lib/view/tenant/tenant_selection_screen.dart b/lib/view/tenant/tenant_selection_screen.dart index d63a8b8..2bf7acb 100644 --- a/lib/view/tenant/tenant_selection_screen.dart +++ b/lib/view/tenant/tenant_selection_screen.dart @@ -25,7 +25,8 @@ class _TenantSelectionScreenState extends State @override void initState() { super.initState(); - _controller = Get.put(TenantSelectionController()); + _controller = + Get.put(TenantSelectionController()); _logoAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), @@ -37,7 +38,7 @@ class _TenantSelectionScreenState extends State _logoAnimController.forward(); // 🔥 Tell controller this is tenant selection screen - _controller.loadTenants(fromTenantSelectionScreen: true); + _controller.loadTenants(); } @override @@ -211,6 +212,7 @@ class TenantCardList extends StatelessWidget { if (controller.tenants.length == 1) { return const SizedBox.shrink(); } + // Show tenant even if only 1 tenant return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24), child: Column( From 7e75431feb1dde8271405f4ba66986752e13ca55 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 8 Oct 2025 11:20:50 +0530 Subject: [PATCH 2/5] fixed permissions loading issue on employee screen --- lib/controller/permission_controller.dart | 28 ++-- lib/view/employees/employees_screen.dart | 166 ++++++++++++---------- 2 files changed, 111 insertions(+), 83 deletions(-) diff --git a/lib/controller/permission_controller.dart b/lib/controller/permission_controller.dart index e78a0ba..815c455 100644 --- a/lib/controller/permission_controller.dart +++ b/lib/controller/permission_controller.dart @@ -13,6 +13,7 @@ class PermissionController extends GetxController { var employeeInfo = Rxn(); var projectsInfo = [].obs; Timer? _refreshTimer; + var isLoading = true.obs; @override void onInit() { @@ -26,7 +27,8 @@ class PermissionController extends GetxController { await loadData(token!); _startAutoRefresh(); } else { - logSafe("Token is null or empty. Skipping API load and auto-refresh.", level: LogLevel.warning); + logSafe("Token is null or empty. Skipping API load and auto-refresh.", + level: LogLevel.warning); } } @@ -37,19 +39,24 @@ class PermissionController extends GetxController { logSafe("Auth token retrieved: $token", level: LogLevel.debug); return token; } catch (e, stacktrace) { - logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace); + logSafe("Error retrieving auth token", + level: LogLevel.error, error: e, stackTrace: stacktrace); return null; } } Future loadData(String token) async { try { + isLoading.value = true; final userData = await PermissionService.fetchAllUserData(token); _updateState(userData); await _storeData(); logSafe("Data loaded and state updated successfully."); } catch (e, stacktrace) { - logSafe("Error loading data from API", level: LogLevel.error, error: e, stackTrace: stacktrace); + logSafe("Error loading data from API", + level: LogLevel.error, error: e, stackTrace: stacktrace); + } finally { + isLoading.value = false; } } @@ -60,7 +67,8 @@ class PermissionController extends GetxController { projectsInfo.assignAll(userData['projects']); logSafe("State updated with user data."); } catch (e, stacktrace) { - logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace); + logSafe("Error updating state", + level: LogLevel.error, error: e, stackTrace: stacktrace); } } @@ -89,7 +97,8 @@ class PermissionController extends GetxController { logSafe("User data successfully stored in SharedPreferences."); } catch (e, stacktrace) { - logSafe("Error storing data", level: LogLevel.error, error: e, stackTrace: stacktrace); + logSafe("Error storing data", + level: LogLevel.error, error: e, stackTrace: stacktrace); } } @@ -100,20 +109,23 @@ class PermissionController extends GetxController { if (token?.isNotEmpty ?? false) { await loadData(token!); } else { - logSafe("Token missing during auto-refresh. Skipping.", level: LogLevel.warning); + logSafe("Token missing during auto-refresh. Skipping.", + level: LogLevel.warning); } }); } bool hasPermission(String permissionId) { final hasPerm = permissions.any((p) => p.id == permissionId); - logSafe("Checking permission $permissionId: $hasPerm", level: LogLevel.debug); + logSafe("Checking permission $permissionId: $hasPerm", + level: LogLevel.debug); return hasPerm; } bool isUserAssignedToProject(String projectId) { final assigned = projectsInfo.any((project) => project.id == projectId); - logSafe("Checking project assignment for $projectId: $assigned", level: LogLevel.debug); + logSafe("Checking project assignment for $projectId: $assigned", + level: LogLevel.debug); return assigned; } diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 79146de..d96d53f 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -248,33 +248,46 @@ class _EmployeesScreenState extends State with UIMixin { } Widget _buildFloatingActionButton() { - if (!permissionController.hasPermission(Permissions.manageEmployees)) { - return const SizedBox.shrink(); - } + return Obx(() { + // Show nothing while permissions are loading + if (permissionController.isLoading.value) { + return const SizedBox.shrink(); + } - return InkWell( - onTap: _onAddNewEmployee, - borderRadius: BorderRadius.circular(28), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(28), - boxShadow: const [ - BoxShadow( - color: Colors.black26, blurRadius: 6, offset: Offset(0, 3)) - ], + // Show FAB only if user has Manage Employees permission + final hasPermission = + permissionController.hasPermission(Permissions.manageEmployees); + if (!hasPermission) { + return const SizedBox.shrink(); + } + + return InkWell( + onTap: _onAddNewEmployee, + borderRadius: BorderRadius.circular(28), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(28), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 6, + offset: Offset(0, 3), + ) + ], + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, color: Colors.white), + SizedBox(width: 8), + Text('Add New Employee', style: TextStyle(color: Colors.white)), + ], + ), ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add, color: Colors.white), - SizedBox(width: 8), - Text('Add New Employee', style: TextStyle(color: Colors.white)), - ], - ), - ), - ); + ); + }); } Widget _buildSearchAndActionRow() { @@ -371,60 +384,63 @@ class _EmployeesScreenState extends State with UIMixin { } Widget _buildPopupMenu() { - if (!permissionController.hasPermission(Permissions.viewAllEmployees)) { - return const SizedBox.shrink(); - } + return Obx(() { + if (permissionController.isLoading.value || + !permissionController.hasPermission(Permissions.viewAllEmployees)) { + return const SizedBox.shrink(); + } - return PopupMenuButton( - icon: Stack( - clipBehavior: Clip.none, - children: [ - const Icon(Icons.tune, color: Colors.black), - Obx(() => _employeeController.isAllEmployeeSelected.value - ? Positioned( - right: -1, - top: -1, - child: Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Colors.red, shape: BoxShape.circle), + return PopupMenuButton( + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.tune, color: Colors.black), + 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.toggle(); + await _initEmployees(); + _employeeController.update(['employee_screen_controller']); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'all_employees', + child: Obx( + () => Row( + children: [ + Checkbox( + value: _employeeController.isAllEmployeeSelected.value, + onChanged: (_) => Navigator.pop(context, 'all_employees'), + checkColor: Colors.white, + activeColor: Colors.blueAccent, + side: const BorderSide(color: Colors.black, width: 1.5), + fillColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.blueAccent + : Colors.white), ), - ) - : const SizedBox.shrink()), - ], - ), - onSelected: (value) async { - if (value == 'all_employees') { - _employeeController.isAllEmployeeSelected.toggle(); - await _initEmployees(); - _employeeController.update(['employee_screen_controller']); - } - }, - itemBuilder: (_) => [ - PopupMenuItem( - value: 'all_employees', - child: Obx( - () => Row( - children: [ - Checkbox( - value: _employeeController.isAllEmployeeSelected.value, - onChanged: (_) => Navigator.pop(context, 'all_employees'), - checkColor: Colors.white, - activeColor: Colors.blueAccent, - side: const BorderSide(color: Colors.black, width: 1.5), - fillColor: MaterialStateProperty.resolveWith( - (states) => states.contains(MaterialState.selected) - ? Colors.blueAccent - : Colors.white), - ), - const Text('All Employees'), - ], + const Text('All Employees'), + ], + ), ), ), - ), - ], - ); + ], + ); + }); } Widget _buildEmployeeList() { From d1d48b1a74cf1c7b4f16f2c755f749529b91457b Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 8 Oct 2025 11:41:38 +0530 Subject: [PATCH 3/5] fixed auto tenant selection --- .../tenant/tenant_selection_controller.dart | 16 ++++++---------- lib/helpers/services/app_initializer.dart | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/controller/tenant/tenant_selection_controller.dart b/lib/controller/tenant/tenant_selection_controller.dart index b4cbee3..eb6f6a6 100644 --- a/lib/controller/tenant/tenant_selection_controller.dart +++ b/lib/controller/tenant/tenant_selection_controller.dart @@ -9,8 +9,6 @@ import 'package:marco/controller/permission_controller.dart'; class TenantSelectionController extends GetxController { final TenantService _tenantService = TenantService(); - TenantSelectionController(); - final tenants = [].obs; final isLoading = false.obs; final selectedTenantId = RxnString(); @@ -34,16 +32,13 @@ class TenantSelectionController extends GetxController { tenants.value = data.map((e) => Tenant.fromJson(e)).toList(); - // Smart auto-selection - final recentTenantId = await LocalStorage.getRecentTenantId(); + final recentTenantId = LocalStorage.getRecentTenantId(); if (tenants.length == 1) { await _selectTenant(tenants.first.id); } else if (recentTenantId != null) { - final recentTenant = - tenants.where((t) => t.id == recentTenantId).isNotEmpty - ? tenants.firstWhere((t) => t.id == recentTenantId) - : null; + final recentTenant = tenants + .firstWhereOrNull((t) => t.id == recentTenantId); if (recentTenant != null) { await _selectTenant(recentTenant.id); @@ -99,7 +94,7 @@ class TenantSelectionController extends GetxController { logSafe("✅ Tenant selection successful: $tenantId"); // 🔹 Load permissions after tenant selection (null-safe) - final token = await LocalStorage.getJwtToken(); + final token = LocalStorage.getJwtToken(); if (token != null && token.isNotEmpty) { if (!Get.isRegistered()) { Get.put(PermissionController()); @@ -107,7 +102,8 @@ class TenantSelectionController extends GetxController { } await Get.find().loadData(token); } else { - logSafe("⚠️ JWT token is null. Cannot load permissions.", level: LogLevel.warning); + logSafe("⚠️ JWT token is null. Cannot load permissions.", + level: LogLevel.warning); } // Navigate to dashboard diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index 45cd5c2..acd9b88 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -20,7 +20,7 @@ Future initializeApp() async { ]); await _setupDeviceInfo(); - await _handleAuthTokens(); // refresh token only, no controller injection + await _handleAuthTokens(); await _setupTheme(); await _setupFirebaseMessaging(); From 041b62ca2f070795d5cdb6404b3071717d2abbe7 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 8 Oct 2025 16:07:26 +0530 Subject: [PATCH 4/5] fixed the tennt selection process --- lib/controller/auth/login_controller.dart | 2 +- lib/controller/auth/mpin_controller.dart | 10 ++--- lib/controller/auth/otp_controller.dart | 28 ++++++------- .../auth/reset_password_controller.dart | 4 +- lib/helpers/services/auth_service.dart | 42 ++----------------- .../firebase/firebase_messaging_service.dart | 9 ++-- lib/helpers/services/tenant_service.dart | 11 +++++ lib/helpers/theme/app_theme.dart | 2 +- lib/routes.dart | 2 +- lib/view/layouts/left_bar.dart | 2 +- lib/view/tenant/tenant_selection_screen.dart | 3 -- 11 files changed, 43 insertions(+), 72 deletions(-) diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index e7abe3a..6cc1bc1 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -81,7 +81,7 @@ class LoginController extends MyController { logSafe("Login successful for user: ${loginData['username']}"); - Get.toNamed('/select_tenant'); + Get.toNamed('/select-tenant'); } } catch (e, stacktrace) { logSafe("Exception during login", diff --git a/lib/controller/auth/mpin_controller.dart b/lib/controller/auth/mpin_controller.dart index 4a92691..a03b1f2 100644 --- a/lib/controller/auth/mpin_controller.dart +++ b/lib/controller/auth/mpin_controller.dart @@ -4,7 +4,6 @@ import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; -import 'package:marco/view/dashboard/dashboard_screen.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; import 'package:marco/controller/permission_controller.dart'; @@ -140,16 +139,17 @@ class MPINController extends GetxController { } /// Navigate to dashboard - void _navigateToDashboard({String? message}) { + /// Navigate to tenant selection after MPIN verification + void _navigateToTenantSelection({String? message}) { if (message != null) { - logSafe("Navigating to Dashboard with message: $message"); + logSafe("Navigating to Tenant Selection with message: $message"); showAppSnackbar( title: "Success", message: message, type: SnackbarType.success, ); } - Get.offAll(() => const DashboardScreen()); + Get.offAllNamed('/select-tenant'); } /// Clear the primary MPIN fields @@ -287,7 +287,7 @@ class MPINController extends GetxController { message: "MPIN Verified Successfully", type: SnackbarType.success, ); - _navigateToDashboard(); + _navigateToTenantSelection(); } else { final errorMessage = response["error"] ?? "Invalid MPIN"; logSafe("MPIN verification failed: $errorMessage", diff --git a/lib/controller/auth/otp_controller.dart b/lib/controller/auth/otp_controller.dart index 43c76ac..53cfeb5 100644 --- a/lib/controller/auth/otp_controller.dart +++ b/lib/controller/auth/otp_controller.dart @@ -109,7 +109,8 @@ class OTPController extends GetxController { } void onOTPChanged(String value, int index) { - logSafe("[OTPController] OTP field changed: index=$index", level: LogLevel.debug); + logSafe("[OTPController] OTP field changed: index=$index", + level: LogLevel.debug); if (value.isNotEmpty) { if (index < otpControllers.length - 1) { focusNodes[index + 1].requestFocus(); @@ -125,30 +126,24 @@ class OTPController extends GetxController { Future verifyOTP() async { final enteredOTP = otpControllers.map((c) => c.text).join(); - logSafe("[OTPController] Verifying OTP"); - final result = await AuthService.verifyOtp( email: email.value, otp: enteredOTP, ); if (result == null) { - logSafe("[OTPController] OTP verified successfully"); - showAppSnackbar( - title: "Success", - message: "OTP verified successfully", - type: SnackbarType.success, - ); - final bool isMpinEnabled = LocalStorage.getIsMpin(); - logSafe("[OTPController] MPIN Enabled: $isMpinEnabled"); + // ✅ Handle remember-me like in LoginController + final remember = LocalStorage.getBool('remember_me') ?? false; + if (remember) await LocalStorage.setToken('otp_email', email.value); - Get.offAllNamed('/home'); + // ✅ Enable remote logging + enableRemoteLogging(); + + Get.offAllNamed('/select-tenant'); } else { - final error = result['error'] ?? "Failed to verify OTP"; - logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error); showAppSnackbar( title: "Error", - message: error, + message: result['error']!, type: SnackbarType.error, ); } @@ -215,7 +210,8 @@ class OTPController extends GetxController { final savedEmail = LocalStorage.getToken('otp_email') ?? ''; emailController.text = savedEmail; email.value = savedEmail; - logSafe("[OTPController] Loaded saved email from local storage: $savedEmail"); + logSafe( + "[OTPController] Loaded saved email from local storage: $savedEmail"); } } } diff --git a/lib/controller/auth/reset_password_controller.dart b/lib/controller/auth/reset_password_controller.dart index efb1cce..842ca40 100644 --- a/lib/controller/auth/reset_password_controller.dart +++ b/lib/controller/auth/reset_password_controller.dart @@ -49,8 +49,8 @@ class ResetPasswordController extends MyController { basicValidator.clearErrors(); } - logSafe("[ResetPasswordController] Navigating to /home"); - Get.toNamed('/home'); + logSafe("[ResetPasswordController] Navigating to /dashboard"); + Get.toNamed('/dashboard'); update(); } else { logSafe("[ResetPasswordController] Form validation failed", level: LogLevel.warning); diff --git a/lib/helpers/services/auth_service.dart b/lib/helpers/services/auth_service.dart index 0ccb708..8bbe2da 100644 --- a/lib/helpers/services/auth_service.dart +++ b/lib/helpers/services/auth_service.dart @@ -1,9 +1,5 @@ import 'dart:convert'; -import 'package:get/get.dart'; import 'package:http/http.dart' as http; - -import 'package:marco/controller/permission_controller.dart'; -import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/app_logger.dart'; @@ -98,8 +94,8 @@ class AuthService { } static Future refreshToken() async { - final accessToken = await LocalStorage.getJwtToken(); - final refreshToken = await LocalStorage.getRefreshToken(); + final accessToken = LocalStorage.getJwtToken(); + final refreshToken = LocalStorage.getRefreshToken(); if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) { logSafe("Missing access or refresh token.", level: LogLevel.warning); @@ -115,7 +111,7 @@ class AuthService { logSafe("Token refreshed successfully."); // 🔹 Retry FCM token registration after token refresh - final newFcmToken = await LocalStorage.getFcmToken(); + final newFcmToken = LocalStorage.getFcmToken(); if (newFcmToken?.isNotEmpty ?? false) { final success = await registerDeviceToken(newFcmToken!); logSafe( @@ -157,7 +153,7 @@ class AuthService { }) => _wrapErrorHandling( () async { - final token = await LocalStorage.getJwtToken(); + final token = LocalStorage.getJwtToken(); return _post( "/auth/generate-mpin", {"employeeId": employeeId, "mpin": mpin}, @@ -290,36 +286,6 @@ class AuthService { await LocalStorage.setIsMpin(false); await LocalStorage.removeMpinToken(); } - - // 🔹 Inject controllers only here, after login success - if (!Get.isRegistered()) { - Get.put(PermissionController()); - logSafe("✅ PermissionController injected after login."); - } - - if (!Get.isRegistered()) { - Get.put(ProjectController(), permanent: true); - logSafe("✅ ProjectController injected after login."); - } - - // 🔹 Load data - final token = data['token']; - await Future.wait([ - Get.find().loadData(token), - Get.find().fetchProjects(), - ]); - - // 🔹 Register FCM token after login - final fcmToken = await LocalStorage.getFcmToken(); - if (fcmToken?.isNotEmpty ?? false) { - final success = await registerDeviceToken(fcmToken!); - logSafe( - success - ? "✅ FCM token registered after login." - : "⚠️ Failed to register FCM token after login.", - level: success ? LogLevel.info : LogLevel.warning); - } - isLoggedIn = true; logSafe("✅ Login flow completed and controllers initialized."); } diff --git a/lib/helpers/services/firebase/firebase_messaging_service.dart b/lib/helpers/services/firebase/firebase_messaging_service.dart index 5cb98c5..1ac8c60 100644 --- a/lib/helpers/services/firebase/firebase_messaging_service.dart +++ b/lib/helpers/services/firebase/firebase_messaging_service.dart @@ -19,7 +19,7 @@ class FirebaseNotificationService { _registerMessageListeners(); _registerTokenRefreshListener(); - // Fetch token on app start (but only register with server if JWT available) + // Fetch token on app start (and register with server if JWT available) await getFcmToken(registerOnServer: true); } @@ -49,6 +49,7 @@ class FirebaseNotificationService { FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap); + // Background messages FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); } @@ -111,8 +112,6 @@ class FirebaseNotificationService { } } - - /// Handle tap on notification void _handleNotificationTap(RemoteMessage message) { _logger.i('📌 Notification tapped: ${message.data}'); @@ -129,7 +128,9 @@ class FirebaseNotificationService { } } -/// Background handler (required by Firebase) +/// 🔹 Background handler (required by Firebase) +/// Must be a top-level function and annotated for AOT +@pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { final logger = Logger(); logger diff --git a/lib/helpers/services/tenant_service.dart b/lib/helpers/services/tenant_service.dart index 83acfc6..3d9be30 100644 --- a/lib/helpers/services/tenant_service.dart +++ b/lib/helpers/services/tenant_service.dart @@ -129,6 +129,17 @@ class TenantService implements ITenantService { logSafe("⚠️ ProjectController not found while refreshing projects"); } + // 🔹 Register FCM token after tenant selection + final fcmToken = LocalStorage.getFcmToken(); + if (fcmToken?.isNotEmpty ?? false) { + final success = await AuthService.registerDeviceToken(fcmToken!); + logSafe( + success + ? "✅ FCM token registered after tenant selection." + : "⚠️ Failed to register FCM token after tenant selection.", + level: success ? LogLevel.info : LogLevel.warning); + } + return true; } diff --git a/lib/helpers/theme/app_theme.dart b/lib/helpers/theme/app_theme.dart index 8aa92e3..1d3e8bb 100644 --- a/lib/helpers/theme/app_theme.dart +++ b/lib/helpers/theme/app_theme.dart @@ -230,7 +230,7 @@ class AppStyle { containerRadius: AppStyle.containerRadius.medium, cardRadius: AppStyle.cardRadius.medium, buttonRadius: AppStyle.buttonRadius.medium, - defaultBreadCrumbItem: MyBreadcrumbItem(name: 'Marco', route: '/client/home'), + defaultBreadCrumbItem: MyBreadcrumbItem(name: 'Marco', route: '/client/dashboard'), )); bool isMobile = true; try { diff --git a/lib/routes.dart b/lib/routes.dart index a2f9362..4984fe1 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -45,7 +45,7 @@ getPageRoute() { page: () => DashboardScreen(), middlewares: [AuthMiddleware()]), GetPage( - name: '/home', + name: '/dashboard', page: () => DashboardScreen(), // or your actual home screen middlewares: [AuthMiddleware()], ), diff --git a/lib/view/layouts/left_bar.dart b/lib/view/layouts/left_bar.dart index 3c620d3..8b332cf 100644 --- a/lib/view/layouts/left_bar.dart +++ b/lib/view/layouts/left_bar.dart @@ -82,7 +82,7 @@ class _LeftBarState extends State child: Padding( padding: EdgeInsets.only(top: 50), child: InkWell( - onTap: () => Get.toNamed('/home'), + onTap: () => Get.toNamed('/dashboard'), child: Image.asset( (ThemeCustomizer.instance.theme == ThemeMode.light ? (widget.isCondensed diff --git a/lib/view/tenant/tenant_selection_screen.dart b/lib/view/tenant/tenant_selection_screen.dart index 2bf7acb..d3269b1 100644 --- a/lib/view/tenant/tenant_selection_screen.dart +++ b/lib/view/tenant/tenant_selection_screen.dart @@ -209,9 +209,6 @@ class TenantCardList extends StatelessWidget { ), ); } - if (controller.tenants.length == 1) { - return const SizedBox.shrink(); - } // Show tenant even if only 1 tenant return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24), From d5a8d08e632699f9e003c8d0eef45fb5ea6baa2c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 8 Oct 2025 17:33:20 +0530 Subject: [PATCH 5/5] added splash screen --- lib/controller/auth/login_controller.dart | 36 +--- .../tenant/tenant_selection_controller.dart | 71 ++++---- lib/view/auth/email_login_form.dart | 30 +-- lib/view/auth/login_screen.dart | 2 +- lib/view/splash_screen.dart | 120 ++++++++++++ lib/view/tenant/tenant_selection_screen.dart | 171 +++++++++--------- 6 files changed, 270 insertions(+), 160 deletions(-) create mode 100644 lib/view/splash_screen.dart diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index 6cc1bc1..40cbd25 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -14,6 +14,7 @@ class LoginController extends MyController { final RxBool isLoading = false.obs; final RxBool showPassword = false.obs; final RxBool isChecked = false.obs; + final RxBool showSplash = false.obs; @override void onInit() { @@ -40,18 +41,14 @@ class LoginController extends MyController { ); } - void onChangeCheckBox(bool? value) { - isChecked.value = value ?? false; - } + void onChangeCheckBox(bool? value) => isChecked.value = value ?? false; - void onChangeShowPassword() { - showPassword.toggle(); - } + void onChangeShowPassword() => showPassword.toggle(); Future onLogin() async { if (!basicValidator.validateForm()) return; - isLoading.value = true; + showSplash.value = true; try { final loginData = basicValidator.getData(); @@ -60,39 +57,30 @@ class LoginController extends MyController { final errors = await AuthService.loginUser(loginData); if (errors != null) { - logSafe( - "Login failed for user: ${loginData['username']} with errors: $errors", - level: LogLevel.warning); - showAppSnackbar( title: "Login Failed", message: "Username or password is incorrect", type: SnackbarType.error, ); - basicValidator.addErrors(errors); basicValidator.validateForm(); basicValidator.clearErrors(); } else { await _handleRememberMe(); - // ✅ Enable remote logging after successful login enableRemoteLogging(); - logSafe("✅ Remote logging enabled after login."); - logSafe("Login successful for user: ${loginData['username']}"); - - Get.toNamed('/select-tenant'); + Get.offNamed('/select-tenant'); } } catch (e, stacktrace) { - logSafe("Exception during login", - level: LogLevel.error, error: e, stackTrace: stacktrace); showAppSnackbar( title: "Login Error", message: "An unexpected error occurred", type: SnackbarType.error, ); + logSafe("Exception during login", + level: LogLevel.error, error: e, stackTrace: stacktrace); } finally { - isLoading.value = false; + showSplash.value = false; } } @@ -124,11 +112,7 @@ class LoginController extends MyController { } } - void goToForgotPassword() { - Get.toNamed('/auth/forgot_password'); - } + void goToForgotPassword() => Get.toNamed('/auth/forgot_password'); - void gotoRegister() { - Get.offAndToNamed('/auth/register_account'); - } + void gotoRegister() => Get.offAndToNamed('/auth/register_account'); } diff --git a/lib/controller/tenant/tenant_selection_controller.dart b/lib/controller/tenant/tenant_selection_controller.dart index eb6f6a6..7952e91 100644 --- a/lib/controller/tenant/tenant_selection_controller.dart +++ b/lib/controller/tenant/tenant_selection_controller.dart @@ -9,19 +9,28 @@ import 'package:marco/controller/permission_controller.dart'; class TenantSelectionController extends GetxController { final TenantService _tenantService = TenantService(); + // Tenant list final tenants = [].obs; + + // Loading state final isLoading = false.obs; + + // Selected tenant ID final selectedTenantId = RxnString(); + // Flag to indicate auto-selection (for splash screen) + final isAutoSelecting = false.obs; + @override void onInit() { super.onInit(); loadTenants(); } - /// Load tenants and perform smart auto-selection + /// Load tenants and handle auto-selection Future loadTenants() async { isLoading.value = true; + isAutoSelecting.value = true; // show splash during auto-selection try { final data = await _tenantService.getTenants(); if (data == null || data.isEmpty) { @@ -34,21 +43,23 @@ class TenantSelectionController extends GetxController { final recentTenantId = LocalStorage.getRecentTenantId(); + // Auto-select if only one tenant if (tenants.length == 1) { await _selectTenant(tenants.first.id); - } else if (recentTenantId != null) { - final recentTenant = tenants - .firstWhereOrNull((t) => t.id == recentTenantId); - + } + // Auto-select recent tenant if available + else if (recentTenantId != null) { + final recentTenant = + tenants.firstWhereOrNull((t) => t.id == recentTenantId); if (recentTenant != null) { await _selectTenant(recentTenant.id); } else { - selectedTenantId.value = null; - TenantService.currentTenant = null; + _clearSelection(); } - } else { - selectedTenantId.value = null; - TenantService.currentTenant = null; + } + // No auto-selection + else { + _clearSelection(); } } catch (e, st) { logSafe("❌ Exception in loadTenants", @@ -60,22 +71,24 @@ class TenantSelectionController extends GetxController { ); } finally { isLoading.value = false; + isAutoSelecting.value = false; // hide splash } } - /// Manually select tenant (user triggered) + /// User manually selects a tenant Future onTenantSelected(String tenantId) async { + isAutoSelecting.value = true; await _selectTenant(tenantId); + isAutoSelecting.value = false; } /// Internal tenant selection logic Future _selectTenant(String tenantId) async { try { isLoading.value = true; + final success = await _tenantService.selectTenant(tenantId); if (!success) { - logSafe("❌ Tenant selection failed for: $tenantId", - level: LogLevel.warning); showAppSnackbar( title: "Error", message: "Unable to select organization. Please try again.", @@ -84,39 +97,27 @@ class TenantSelectionController extends GetxController { return; } + // Update tenant & persist final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); TenantService.setSelectedTenant(selectedTenant); selectedTenantId.value = tenantId; - - // Persist recent tenant await LocalStorage.setRecentTenantId(tenantId); - logSafe("✅ Tenant selection successful: $tenantId"); - - // 🔹 Load permissions after tenant selection (null-safe) + // Load permissions if token exists final token = LocalStorage.getJwtToken(); if (token != null && token.isNotEmpty) { if (!Get.isRegistered()) { Get.put(PermissionController()); - logSafe("✅ PermissionController injected after tenant selection."); } await Get.find().loadData(token); - } else { - logSafe("⚠️ JWT token is null. Cannot load permissions.", - level: LogLevel.warning); } - // Navigate to dashboard - Get.offAllNamed('/dashboard'); + // Navigate **before changing isAutoSelecting** + await Get.offAllNamed('/dashboard'); - showAppSnackbar( - title: "Success", - message: "Organization selected successfully.", - type: SnackbarType.success, - ); - } catch (e, st) { - logSafe("❌ Exception in _selectTenant", - level: LogLevel.error, error: e, stackTrace: st); + // Then hide splash + isAutoSelecting.value = false; + } catch (e) { showAppSnackbar( title: "Error", message: "An unexpected error occurred while selecting organization.", @@ -126,4 +127,10 @@ class TenantSelectionController extends GetxController { isLoading.value = false; } } + + /// Clear tenant selection + void _clearSelection() { + selectedTenantId.value = null; + TenantService.currentTenant = null; + } } diff --git a/lib/view/auth/email_login_form.dart b/lib/view/auth/email_login_form.dart index eff0222..c2ef521 100644 --- a/lib/view/auth/email_login_form.dart +++ b/lib/view/auth/email_login_form.dart @@ -123,20 +123,24 @@ class _EmailLoginFormState extends State with UIMixin { ), MySpacing.height(28), Center( - child: MyButton.rounded( - onPressed: controller.onLogin, - elevation: 2, - padding: MySpacing.xy(80, 16), - borderRadiusAll: 10, - backgroundColor: contentTheme.brandRed, - child: MyText.labelLarge( - 'Login', - fontWeight: 700, - color: contentTheme.onPrimary, - ), - ), + child: Obx(() { + final isLoading = controller.isLoading.value; + return MyButton.rounded( + onPressed: isLoading + ? null + : controller.onLogin, + elevation: 2, + padding: MySpacing.xy(80, 16), + borderRadiusAll: 10, + backgroundColor: contentTheme.brandRed, + child: MyText.labelLarge( + isLoading ? 'Logging in...' : 'Login', + fontWeight: 700, + color: contentTheme.onPrimary, + ), + ); + }), ), - ], ), ); diff --git a/lib/view/auth/login_screen.dart b/lib/view/auth/login_screen.dart index 433e91a..56d0733 100644 --- a/lib/view/auth/login_screen.dart +++ b/lib/view/auth/login_screen.dart @@ -37,7 +37,7 @@ class _LoginScreenState extends State with UIMixin { builder: (_) { return Obx(() { if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: LinearProgressIndicator()); } return Form( diff --git a/lib/view/splash_screen.dart b/lib/view/splash_screen.dart new file mode 100644 index 0000000..67552b6 --- /dev/null +++ b/lib/view/splash_screen.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:marco/images.dart'; + +class SplashScreen extends StatefulWidget { + final String? message; + final double? logoSize; + final Color? backgroundColor; + + const SplashScreen({ + super.key, + this.message, + this.logoSize = 120, + this.backgroundColor = Colors.white, + }); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + )..repeat(reverse: true); + + _animation = Tween(begin: 0.0, end: 8.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _buildAnimatedDots() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(3, (index) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + double opacity; + if (index == 0) { + opacity = (0.3 + _animation.value / 8).clamp(0.0, 1.0); + } else if (index == 1) { + opacity = (0.3 + (_animation.value / 8)).clamp(0.0, 1.0); + } else { + opacity = (0.3 + (1 - _animation.value / 8)).clamp(0.0, 1.0); + } + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.blueAccent.withOpacity(opacity), + shape: BoxShape.circle, + ), + ); + }, + ); + }), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: widget.backgroundColor, + body: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo with slight bounce animation + ScaleTransition( + scale: Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ), + child: SizedBox( + width: widget.logoSize, + height: widget.logoSize, + child: Image.asset(Images.logoDark), + ), + ), + + const SizedBox(height: 20), + // Text message + if (widget.message != null) + Text( + widget.message!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 30), + // Animated loading dots + _buildAnimatedDots(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/view/tenant/tenant_selection_screen.dart b/lib/view/tenant/tenant_selection_screen.dart index d3269b1..0565adf 100644 --- a/lib/view/tenant/tenant_selection_screen.dart +++ b/lib/view/tenant/tenant_selection_screen.dart @@ -6,6 +6,7 @@ import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/images.dart'; import 'package:marco/controller/tenant/tenant_selection_controller.dart'; +import 'package:marco/view/splash_screen.dart'; class TenantSelectionScreen extends StatefulWidget { const TenantSelectionScreen({super.key}); @@ -20,25 +21,23 @@ class _TenantSelectionScreenState extends State late final AnimationController _logoAnimController; late final Animation _logoAnimation; final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage"); - bool _isLoading = false; @override void initState() { super.initState(); - _controller = - Get.put(TenantSelectionController()); + _controller = Get.put(TenantSelectionController()); + _logoAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); + _logoAnimation = CurvedAnimation( parent: _logoAnimController, curve: Curves.easeOutBack, ); - _logoAnimController.forward(); - // 🔥 Tell controller this is tenant selection screen - _controller.loadTenants(); + _logoAnimController.forward(); } @override @@ -49,61 +48,66 @@ class _TenantSelectionScreenState extends State } Future _onTenantSelected(String tenantId) async { - setState(() => _isLoading = true); await _controller.onTenantSelected(tenantId); - setState(() => _isLoading = false); } @override Widget build(BuildContext context) { - return Scaffold( - body: Stack( - children: [ - _RedWaveBackground(brandRed: contentTheme.brandRed), - SafeArea( - child: Center( - child: Column( - children: [ - const SizedBox(height: 24), - _AnimatedLogo(animation: _logoAnimation), - const SizedBox(height: 8), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - children: [ - const SizedBox(height: 12), - const _WelcomeTexts(), - if (_isBetaEnvironment) ...[ + return Obx(() { + // Splash screen for auto-selection + if (_controller.isAutoSelecting.value) { + return const SplashScreen(); + } + + return Scaffold( + body: Stack( + children: [ + _RedWaveBackground(brandRed: contentTheme.brandRed), + SafeArea( + child: Center( + child: Column( + children: [ + const SizedBox(height: 24), + _AnimatedLogo(animation: _logoAnimation), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + children: [ const SizedBox(height: 12), - const _BetaBadge(), + const _WelcomeTexts(), + if (_isBetaEnvironment) ...[ + const SizedBox(height: 12), + const _BetaBadge(), + ], + const SizedBox(height: 36), + TenantCardList( + controller: _controller, + isLoading: _controller.isLoading.value, + onTenantSelected: _onTenantSelected, + ), ], - const SizedBox(height: 36), - // Tenant list directly reacts to controller - TenantCardList( - controller: _controller, - isLoading: _isLoading, - onTenantSelected: _onTenantSelected, - ), - ], + ), ), ), ), ), - ), - ], + ], + ), ), ), - ), - ], - ), - ); + ], + ), + ); + }); } } +/// Animated Logo Widget class _AnimatedLogo extends StatelessWidget { final Animation animation; const _AnimatedLogo({required this.animation}); @@ -133,6 +137,7 @@ class _AnimatedLogo extends StatelessWidget { } } +/// Welcome Texts class _WelcomeTexts extends StatelessWidget { const _WelcomeTexts(); @@ -149,7 +154,7 @@ class _WelcomeTexts extends StatelessWidget { ), const SizedBox(height: 10), MyText( - "Please select which dashboard you want to explore!.", + "Please select which dashboard you want to explore!", fontSize: 14, color: Colors.black54, textAlign: TextAlign.center, @@ -159,6 +164,7 @@ class _WelcomeTexts extends StatelessWidget { } } +/// Beta Badge class _BetaBadge extends StatelessWidget { const _BetaBadge(); @@ -180,6 +186,7 @@ class _BetaBadge extends StatelessWidget { } } +/// Tenant Card List class TenantCardList extends StatelessWidget { final TenantSelectionController controller; final bool isLoading; @@ -195,10 +202,9 @@ class TenantCardList extends StatelessWidget { Widget build(BuildContext context) { return Obx(() { if (controller.isLoading.value || isLoading) { - return const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ); + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); } + if (controller.tenants.isEmpty) { return Center( child: MyText( @@ -209,37 +215,35 @@ class TenantCardList extends StatelessWidget { ), ); } - // Show tenant even if only 1 tenant - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ...controller.tenants.map( - (tenant) => _TenantCard( - tenant: tenant, - onTap: () => onTenantSelected(tenant.id), - ), + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ...controller.tenants.map( + (tenant) => _TenantCard( + tenant: tenant, + onTap: () => onTenantSelected(tenant.id), ), - const SizedBox(height: 16), - TextButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.arrow_back, - size: 20, color: Colors.redAccent), - label: MyText( - 'Back to Login', - color: Colors.red, - fontWeight: 600, - fontSize: 14, - ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => Get.back(), + icon: + const Icon(Icons.arrow_back, size: 20, color: Colors.redAccent), + label: MyText( + 'Back to Login', + color: Colors.red, + fontWeight: 600, + fontSize: 14, ), - ], - ), + ), + ], ); }); } } +/// Single Tenant Card class _TenantCard extends StatelessWidget { final dynamic tenant; final VoidCallback onTap; @@ -252,9 +256,7 @@ class _TenantCard extends StatelessWidget { borderRadius: BorderRadius.circular(5), child: Card( elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), margin: const EdgeInsets.only(bottom: 20), child: Padding( padding: const EdgeInsets.all(16), @@ -290,11 +292,7 @@ class _TenantCard extends StatelessWidget { ], ), ), - Icon( - Icons.arrow_forward_ios, - size: 24, - color: Colors.red, - ), + const Icon(Icons.arrow_forward_ios, size: 24, color: Colors.red), ], ), ), @@ -303,6 +301,7 @@ class _TenantCard extends StatelessWidget { } } +/// Tenant Logo (supports base64 and URL) class TenantLogo extends StatelessWidget { final String? logoImage; const TenantLogo({required this.logoImage}); @@ -310,9 +309,7 @@ class TenantLogo extends StatelessWidget { @override Widget build(BuildContext context) { if (logoImage == null || logoImage!.isEmpty) { - return Center( - child: Icon(Icons.business, color: Colors.grey.shade600), - ); + return Center(child: Icon(Icons.business, color: Colors.grey.shade600)); } if (logoImage!.startsWith("data:image")) { try { @@ -320,9 +317,7 @@ class TenantLogo extends StatelessWidget { final bytes = base64Decode(base64Str); return Image.memory(bytes, fit: BoxFit.cover); } catch (_) { - return Center( - child: Icon(Icons.business, color: Colors.grey.shade600), - ); + return Center(child: Icon(Icons.business, color: Colors.grey.shade600)); } } else { return Image.network( @@ -336,6 +331,7 @@ class TenantLogo extends StatelessWidget { } } +/// Red Wave Background class _RedWaveBackground extends StatelessWidget { final Color brandRed; const _RedWaveBackground({required this.brandRed}); @@ -351,7 +347,6 @@ class _RedWaveBackground extends StatelessWidget { class _WavePainter extends CustomPainter { final Color brandRed; - _WavePainter(this.brandRed); @override