fixed tenant issue

This commit is contained in:
Vaibhav Surve 2025-10-08 11:06:39 +05:30
parent 26675388dd
commit 45bc492683
32 changed files with 1022 additions and 799 deletions

View File

@ -79,16 +79,6 @@ class LoginController extends MyController {
enableRemoteLogging(); enableRemoteLogging();
logSafe("✅ Remote logging enabled after login."); 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']}"); logSafe("Login successful for user: ${loginData['username']}");
Get.toNamed('/select_tenant'); Get.toNamed('/select_tenant');

View File

@ -7,6 +7,8 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart'; import 'package:marco/view/dashboard/dashboard_screen.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/firebase/firebase_messaging_service.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 { class MPINController extends GetxController {
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
@ -239,15 +241,12 @@ class MPINController extends GetxController {
logSafe("verifyMPIN triggered"); logSafe("verifyMPIN triggered");
final enteredMPIN = digitControllers.map((c) => c.text).join(); final enteredMPIN = digitControllers.map((c) => c.text).join();
logSafe("Entered MPIN: $enteredMPIN");
if (enteredMPIN.length < 4) { if (enteredMPIN.length < 4) {
_showError("Please enter all 4 digits."); _showError("Please enter all 4 digits.");
return; return;
} }
final mpinToken = await LocalStorage.getMpinToken(); final mpinToken = await LocalStorage.getMpinToken();
if (mpinToken == null || mpinToken.isEmpty) { if (mpinToken == null || mpinToken.isEmpty) {
_showError("Missing MPIN token. Please log in again."); _showError("Missing MPIN token. Please log in again.");
return; return;
@ -270,6 +269,19 @@ class MPINController extends GetxController {
logSafe("MPIN verified successfully"); logSafe("MPIN verified successfully");
await LocalStorage.setBool('mpin_verified', true); 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<PermissionController>()) {
Get.put(PermissionController());
await Get.find<PermissionController>().loadData(token);
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
await Get.find<ProjectController>().fetchProjects();
}
}
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: "MPIN Verified Successfully", message: "MPIN Verified Successfully",
@ -291,11 +303,7 @@ class MPINController extends GetxController {
} catch (e) { } catch (e) {
isLoading.value = false; isLoading.value = false;
logSafe("Exception in verifyMPIN", level: LogLevel.error, error: e); logSafe("Exception in verifyMPIN", level: LogLevel.error, error: e);
showAppSnackbar( _showError("Something went wrong. Please try again.");
title: "Error",
message: "Something went wrong. Please try again.",
type: SnackbarType.error,
);
} }
} }

View File

@ -46,8 +46,9 @@ class DashboardController extends GetxController {
// Common ranges // Common ranges
final List<String> ranges = ['7D', '15D', '30D']; final List<String> ranges = ['7D', '15D', '30D'];
// Inject ProjectController // Inside your DashboardController
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController =
Get.put(ProjectController(), permanent: true);
@override @override
void onInit() { void onInit() {

View File

@ -78,8 +78,7 @@ class DailyTaskController extends GetxController {
); );
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
for (var taskJson in response) { for (var task in response) {
final task = TaskModel.fromJson(taskJson);
final assignmentDateKey = final assignmentDateKey =
task.assignmentDate.toIso8601String().split('T')[0]; task.assignmentDate.toIso8601String().split('T')[0];
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task); groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);

View File

@ -4,12 +4,16 @@ import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/model/tenant/tenant_list_model.dart'; import 'package:marco/model/tenant/tenant_list_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/controller/permission_controller.dart';
class TenantSelectionController extends GetxController { class TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService(); final TenantService _tenantService = TenantService();
var tenants = <Tenant>[].obs; TenantSelectionController();
var isLoading = false.obs;
final tenants = <Tenant>[].obs;
final isLoading = false.obs;
final selectedTenantId = RxnString();
@override @override
void onInit() { void onInit() {
@ -17,83 +21,106 @@ class TenantSelectionController extends GetxController {
loadTenants(); loadTenants();
} }
/// Load tenants from API /// Load tenants and perform smart auto-selection
Future<void> loadTenants({bool fromTenantSelectionScreen = false}) async { Future<void> loadTenants() async {
isLoading.value = true;
try { try {
isLoading.value = true;
final data = await _tenantService.getTenants(); final data = await _tenantService.getTenants();
if (data != null) { if (data == null || data.isEmpty) {
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 {
tenants.clear(); tenants.clear();
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning); 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) { } catch (e, st) {
logSafe("❌ Exception in loadTenants", logSafe("❌ Exception in loadTenants",
level: LogLevel.error, error: e, stackTrace: st); level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to load organizations. Please try again.",
type: SnackbarType.error,
);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
/// Select tenant /// Manually select tenant (user triggered)
Future<void> onTenantSelected(String tenantId) async { Future<void> onTenantSelected(String tenantId) async {
await _selectTenant(tenantId);
}
/// Internal tenant selection logic
Future<void> _selectTenant(String tenantId) async {
try { try {
isLoading.value = true; isLoading.value = true;
final success = await _tenantService.selectTenant(tenantId); final success = await _tenantService.selectTenant(tenantId);
if (success) { 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 {
logSafe("❌ Tenant selection failed for: $tenantId", logSafe("❌ Tenant selection failed for: $tenantId",
level: LogLevel.warning); level: LogLevel.warning);
// Show error snackbar
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Unable to select organization. Please try again.", message: "Unable to select organization. Please try again.",
type: SnackbarType.error, 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<PermissionController>()) {
Get.put(PermissionController());
logSafe("✅ PermissionController injected after tenant selection.");
}
await Get.find<PermissionController>().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( showAppSnackbar(
title: "Error", title: "Error",
message: "An unexpected error occurred while selecting organization.", message: "An unexpected error occurred while selecting organization.",

View File

@ -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 = <Tenant>[].obs;
final isLoading = false.obs;
final selectedTenantId = RxnString();
@override
void onInit() {
super.onInit();
loadTenants();
}
/// Load all tenants for switching (does not auto-select)
Future<void> 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<void> 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<PermissionController>()) {
Get.put(PermissionController());
logSafe("✅ PermissionController injected after tenant switch.");
}
await Get.find<PermissionController>().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;
}
}
}

View File

@ -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/document/document_version_model.dart';
import 'package:marco/model/attendance/organization_per_project_list_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/tenant/tenant_services_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
@ -354,14 +355,24 @@ class ApiService {
logSafe("Posting logs... count=${logs.length}"); logSafe("Posting logs... count=${logs.length}");
try { try {
final response = // Get token directly without triggering logout or refresh
await _postRequest(endpoint, logs, customTimeout: extendedTimeout); final token = await LocalStorage.getJwtToken();
if (token == null) {
if (response == null) { logSafe("No token available. Skipping logs post.",
logSafe("Post logs failed: null response", level: LogLevel.error); level: LogLevel.warning);
return false; 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 status: ${response.statusCode}");
logSafe("Post logs response body: ${response.body}"); logSafe("Post logs response body: ${response.body}");
@ -1761,19 +1772,18 @@ class ApiService {
return false; return false;
} }
static Future<List<dynamic>?> getDirectoryComments( static Future<List<dynamic>?> getDirectoryComments(
String contactId, { String contactId, {
bool active = true, bool active = true,
}) async { }) async {
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active"; final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active";
final response = await _getRequest(url); final response = await _getRequest(url);
final data = response != null final data = response != null
? _parseResponse(response, label: 'Directory Comments') ? _parseResponse(response, label: 'Directory Comments')
: null; : null;
return data is List ? data : null;
}
return data is List ? data : null;
}
static Future<bool> updateContact( static Future<bool> updateContact(
String contactId, Map<String, dynamic> payload) async { String contactId, Map<String, dynamic> payload) async {
@ -2116,38 +2126,42 @@ static Future<List<dynamic>?> getDirectoryComments(
// === Daily Task APIs === // === Daily Task APIs ===
static Future<List<dynamic>?> getDailyTasks( static Future<List<TaskModel>?> getDailyTasks(
String projectId, { String projectId, {
DateTime? dateFrom, DateTime? dateFrom,
DateTime? dateTo, DateTime? dateTo,
List<String>? serviceIds, List<String>? serviceIds,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 20, int pageSize = 20,
}) async { }) async {
final filterBody = { final filterBody = {
"serviceIds": serviceIds ?? [], "serviceIds": serviceIds ?? [],
}; };
final query = { final query = {
"projectId": projectId, "projectId": projectId,
"pageNumber": pageNumber.toString(), "pageNumber": pageNumber.toString(),
"pageSize": pageSize.toString(), "pageSize": pageSize.toString(),
if (dateFrom != null) if (dateFrom != null)
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom), "dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo), if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
"filter": jsonEncode(filterBody), "filter": jsonEncode(filterBody),
}; };
final uri = final uri =
Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query); 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 if (parsed != null && parsed['data'] != null) {
? _parseResponse(response, label: 'Daily Tasks') return (parsed['data'] as List).map((e) => TaskModel.fromJson(e)).toList();
: null;
} }
return null;
}
static Future<bool> reportTask({ static Future<bool> reportTask({
required String id, required String id,
required int completedTask, required int completedTask,

View File

@ -1,10 +1,6 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:url_strategy/url_strategy.dart'; import 'package:url_strategy/url_strategy.dart';
import 'package:firebase_core/firebase_core.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/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/auth_service.dart';
@ -24,9 +20,8 @@ Future<void> initializeApp() async {
]); ]);
await _setupDeviceInfo(); await _setupDeviceInfo();
await _handleAuthTokens(); await _handleAuthTokens(); // refresh token only, no controller injection
await _setupTheme(); await _setupTheme();
await _setupControllers();
await _setupFirebaseMessaging(); await _setupFirebaseMessaging();
_finalizeAppStyle(); _finalizeAppStyle();
@ -43,6 +38,19 @@ Future<void> initializeApp() async {
} }
} }
Future<void> _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<void> _setupUI() async { Future<void> _setupUI() async {
setPathUrlStrategy(); setPathUrlStrategy();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@ -69,50 +77,11 @@ Future<void> _setupDeviceInfo() async {
logSafe("📱 Device Info: ${deviceInfoService.deviceData}"); logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
} }
Future<void> _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<void> _setupTheme() async { Future<void> _setupTheme() async {
await ThemeCustomizer.init(); await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized."); logSafe("💡 Theme customizer initialized.");
} }
Future<void> _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<PermissionController>()) {
Get.put(PermissionController());
logSafe("💡 PermissionController injected.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("💡 ProjectController injected as permanent.");
}
await Future.wait([
Get.find<PermissionController>().loadData(token!),
Get.find<ProjectController>().fetchProjects(),
]);
}
// Commented out Firebase Messaging setup
Future<void> _setupFirebaseMessaging() async { Future<void> _setupFirebaseMessaging() async {
await FirebaseNotificationService().initialize(); await FirebaseNotificationService().initialize();
logSafe("💡 Firebase Messaging initialized."); logSafe("💡 Firebase Messaging initialized.");

View File

@ -291,19 +291,25 @@ class AuthService {
await LocalStorage.removeMpinToken(); await LocalStorage.removeMpinToken();
} }
// 🔹 Inject controllers only here, after login success
if (!Get.isRegistered<PermissionController>()) { if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController()); Get.put(PermissionController());
logSafe("✅ PermissionController injected after login."); logSafe("✅ PermissionController injected after login.");
} }
if (!Get.isRegistered<ProjectController>()) { if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true); Get.put(ProjectController(), permanent: true);
logSafe("✅ ProjectController injected after login."); logSafe("✅ ProjectController injected after login.");
} }
await Get.find<PermissionController>().loadData(data['token']); // 🔹 Load data
await Get.find<ProjectController>().fetchProjects(); final token = data['token'];
await Future.wait([
Get.find<PermissionController>().loadData(token),
Get.find<ProjectController>().fetchProjects(),
]);
// 🔹 Always try to register FCM token after login // 🔹 Register FCM token after login
final fcmToken = await LocalStorage.getFcmToken(); final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) { if (fcmToken?.isNotEmpty ?? false) {
final success = await registerDeviceToken(fcmToken!); final success = await registerDeviceToken(fcmToken!);

View File

@ -60,7 +60,6 @@ class AttendanceDashboardChart extends StatelessWidget {
final filteredData = _getFilteredData(); final filteredData = _getFilteredData();
return Container( return Container(
decoration: _containerDecoration, decoration: _containerDecoration,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
@ -254,7 +253,7 @@ class _AttendanceChart extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM'); final dateFormat = DateFormat('d MMM');
final uniqueDates = data final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String)) .map((e) => DateTime.parse(e['date'] as String))
.toSet() .toSet()
@ -273,10 +272,6 @@ class _AttendanceChart extends StatelessWidget {
if (allZero) { if (allZero) {
return Container( return Container(
height: 600, height: 600,
decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(5),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -302,7 +297,6 @@ class _AttendanceChart extends StatelessWidget {
height: 600, height: 600,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: SfCartesianChart( child: SfCartesianChart(
@ -317,7 +311,7 @@ class _AttendanceChart extends StatelessWidget {
return {'date': date, 'present': formattedMap[key] ?? 0}; return {'date': date, 'present': formattedMap[key] ?? 0};
}) })
.where((d) => (d['present'] ?? 0) > 0) .where((d) => (d['present'] ?? 0) > 0)
.toList(); // remove 0 bars .toList();
return StackedColumnSeries<Map<String, dynamic>, String>( return StackedColumnSeries<Map<String, dynamic>, String>(
dataSource: seriesData, dataSource: seriesData,
@ -358,7 +352,7 @@ class _AttendanceTable extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM'); final dateFormat = DateFormat('d MMM');
final uniqueDates = data final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String)) .map((e) => DateTime.parse(e['date'] as String))
.toSet() .toSet()
@ -377,10 +371,6 @@ class _AttendanceTable extends StatelessWidget {
if (allZero) { if (allZero) {
return Container( return Container(
height: 300, height: 300,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(5),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -402,38 +392,49 @@ class _AttendanceTable extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade50,
), ),
child: SingleChildScrollView( child: Scrollbar(
scrollDirection: Axis.horizontal, thumbVisibility: true,
child: DataTable( trackVisibility: true,
columnSpacing: screenWidth < 600 ? 20 : 36, child: SingleChildScrollView(
headingRowHeight: 44, scrollDirection: Axis.horizontal,
headingRowColor: child: ConstrainedBox(
MaterialStateProperty.all(Colors.blueAccent.withOpacity(0.08)), constraints:
headingTextStyle: const TextStyle( BoxConstraints(minWidth: MediaQuery.of(context).size.width),
fontWeight: FontWeight.bold, color: Colors.black87), child: SingleChildScrollView(
columns: [ scrollDirection: Axis.vertical,
const DataColumn(label: Text('Role')), child: DataTable(
...filteredDates.map((d) => DataColumn(label: Text(d))), columnSpacing: 20,
], headingRowHeight: 44,
rows: filteredRoles.map((role) { headingRowColor: MaterialStateProperty.all(
return DataRow( Colors.blueAccent.withOpacity(0.08)),
cells: [ headingTextStyle: const TextStyle(
DataCell(_RolePill(role: role, color: getRoleColor(role))), fontWeight: FontWeight.bold, color: Colors.black87),
...filteredDates.map((date) { columns: [
final key = '${role}_$date'; const DataColumn(label: Text('Role')),
return DataCell( ...filteredDates.map((d) => DataColumn(label: Text(d))),
Text( ],
NumberFormat.decimalPattern() rows: filteredRoles.map((role) {
.format(formattedMap[key] ?? 0), return DataRow(
style: const TextStyle(fontSize: 13), 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(), ),
), ),
), ),
); );

View File

@ -197,13 +197,13 @@ class ProjectProgressChart extends StatelessWidget {
height: height > 280 ? 280 : height, height: height > 280 ? 280 : height,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50, // Remove background
color: Colors.transparent,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: SfCartesianChart( child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true), tooltipBehavior: TooltipBehavior(enable: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom), legend: Legend(isVisible: true, position: LegendPosition.bottom),
// Use CategoryAxis so only nonZeroData dates show up
primaryXAxis: CategoryAxis( primaryXAxis: CategoryAxis(
majorGridLines: const MajorGridLines(width: 0), majorGridLines: const MajorGridLines(width: 0),
axisLine: const AxisLine(width: 0), axisLine: const AxisLine(width: 0),
@ -273,48 +273,44 @@ class ProjectProgressChart extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade50, color: Colors.transparent,
), ),
child: LayoutBuilder( child: Scrollbar(
builder: (context, constraints) { thumbVisibility: true,
return SingleChildScrollView( trackVisibility: true,
scrollDirection: Axis.horizontal, child: SingleChildScrollView(
child: ConstrainedBox( scrollDirection: Axis.horizontal,
constraints: BoxConstraints(minWidth: constraints.maxWidth), child: ConstrainedBox(
child: SingleChildScrollView( constraints: BoxConstraints(minWidth: screenWidth),
scrollDirection: Axis.vertical, child: SingleChildScrollView(
child: DataTable( scrollDirection: Axis.vertical,
columnSpacing: screenWidth < 600 ? 16 : 36, child: DataTable(
headingRowHeight: 44, columnSpacing: screenWidth < 600 ? 16 : 36,
headingRowColor: MaterialStateProperty.all( headingRowHeight: 44,
Colors.blueAccent.withOpacity(0.08)), headingRowColor: MaterialStateProperty.all(
headingTextStyle: const TextStyle( Colors.blueAccent.withOpacity(0.08)),
fontWeight: FontWeight.bold, color: Colors.black87), headingTextStyle: const TextStyle(
columns: const [ fontWeight: FontWeight.bold, color: Colors.black87),
DataColumn(label: Text('Date')), columns: const [
DataColumn(label: Text('Planned')), DataColumn(label: Text('Date')),
DataColumn(label: Text('Completed')), DataColumn(label: Text('Planned')),
], DataColumn(label: Text('Completed')),
rows: nonZeroData.map((task) { ],
return DataRow( rows: nonZeroData.map((task) {
cells: [ return DataRow(
DataCell(Text(DateFormat('d MMM').format(task.date))), cells: [
DataCell(Text( DataCell(Text(DateFormat('d MMM').format(task.date))),
'${task.planned}', DataCell(Text('${task.planned}',
style: TextStyle(color: _getTaskColor('Planned')), style: TextStyle(color: _getTaskColor('Planned')))),
)), DataCell(Text('${task.completed}',
DataCell(Text( style: TextStyle(color: _getTaskColor('Completed')))),
'${task.completed}', ],
style: TextStyle(color: _getTaskColor('Completed')), );
)), }).toList(),
],
);
}).toList(),
),
), ),
), ),
); ),
}, ),
), ),
); );
} }
@ -323,7 +319,7 @@ class ProjectProgressChart extends StatelessWidget {
return Container( return Container(
height: height > 280 ? 280 : height, height: height > 280 ? 280 : height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50, color: Colors.transparent,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: const Center( child: const Center(

View File

@ -77,7 +77,32 @@ class OrganizationSelector extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
if (controller.isLoadingOrganizations.value) { 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) { } else if (controller.organizations.isEmpty) {
return Center( return Center(
child: Padding( child: Padding(
@ -96,7 +121,6 @@ class OrganizationSelector extends StatelessWidget {
...controller.organizations.map((e) => e.name) ...controller.organizations.map((e) => e.name)
]; ];
// Listen to selectedOrganization.value
return _popupSelector( return _popupSelector(
currentValue: controller.currentSelection, currentValue: controller.currentSelection,
items: orgNames, items: orgNames,

View File

@ -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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
if (controller.isLoadingServices.value) { if (controller.isLoadingServices.value) {
return const Center(child: CircularProgressIndicator()); return _skeletonSelector();
} }
final serviceNames = controller.services.isEmpty final serviceNames = controller.services.isEmpty

View File

@ -108,8 +108,10 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
break; break;
case 1: case 1:
final isOldCheckIn = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2); final isOldCheckIn =
final isOldCheckOut = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2); AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
if (widget.employee.checkOut == null && isOldCheckIn) { if (widget.employee.checkOut == null && isOldCheckIn) {
action = 2; action = 2;
@ -167,7 +169,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
String? markTime; String? markTime;
if (actionText == ButtonActions.requestRegularize) { if (actionText == ButtonActions.requestRegularize) {
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!); 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) { } else if (selectedTime != null) {
markTime = DateFormat("hh:mm a").format(selectedTime); markTime = DateFormat("hh:mm a").format(selectedTime);
} }
@ -205,13 +209,17 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
final controller = widget.attendanceController; final controller = widget.attendanceController;
final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false; final isUploading =
controller.uploadingStates[uniqueLogKey]?.value ?? false;
final emp = widget.employee; final emp = widget.employee;
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut); final isYesterday =
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn); AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isTodayApproved =
AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isApprovedButNotToday = final isApprovedButNotToday =
AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved); AttendanceButtonHelper.isApprovedButNotToday(
emp.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading, isUploading: isUploading,
@ -272,12 +280,12 @@ class AttendanceActionButtonUI extends StatelessWidget {
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
), ),
child: isUploading child: isUploading
? const SizedBox( ? Container(
width: 16, width: 60,
height: 16, height: 14,
child: CircularProgressIndicator( decoration: BoxDecoration(
strokeWidth: 2, color: Colors.white.withOpacity(0.5),
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), borderRadius: BorderRadius.circular(4),
), ),
) )
: Row( : Row(
@ -288,7 +296,8 @@ class AttendanceActionButtonUI extends StatelessWidget {
if (buttonText.toLowerCase() == 'rejected') if (buttonText.toLowerCase() == 'rejected')
const Icon(Icons.close, size: 16, color: Colors.red), const Icon(Icons.close, size: 16, color: Colors.red),
if (buttonText.toLowerCase() == 'requested') 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'] if (['approved', 'rejected', 'requested']
.contains(buttonText.toLowerCase())) .contains(buttonText.toLowerCase()))
const SizedBox(width: 4), const SizedBox(width: 4),
@ -342,7 +351,8 @@ Future<String?> _showCommentBottomSheet(
} }
return Padding( return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom),
child: BaseBottomSheet( child: BaseBottomSheet(
title: sheetTitle, // 👈 now showing full sentence as title title: sheetTitle, // 👈 now showing full sentence as title
onCancel: () => Navigator.of(context).pop(), onCancel: () => Navigator.of(context).pop(),
@ -375,6 +385,5 @@ Future<String?> _showCommentBottomSheet(
); );
} }
String capitalizeFirstLetter(String text) => String capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1); text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);

View File

@ -39,8 +39,7 @@ class _AttendanceFilterBottomSheetState
final endDate = widget.controller.endDateAttendance; final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) { if (startDate != null && endDate != null) {
final start = final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy'); final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end"; return "$start - $end";
} }
@ -161,7 +160,32 @@ class _AttendanceFilterBottomSheetState
), ),
Obx(() { Obx(() {
if (widget.controller.isLoadingOrganizations.value) { 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) { } else if (widget.controller.organizations.isEmpty) {
return Center( return Center(
child: Padding( child: Padding(

View File

@ -3,12 +3,12 @@ import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
enum ButtonActions { approve, reject } enum ButtonActions { approve, reject }
class RegularizeActionButton extends StatefulWidget { class RegularizeActionButton extends StatefulWidget {
final dynamic final dynamic attendanceController;
attendanceController; final dynamic log;
final dynamic log;
final String uniqueLogKey; final String uniqueLogKey;
final ButtonActions action; final ButtonActions action;
@ -53,57 +53,60 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
Colors.grey; Colors.grey;
} }
Future<void> _handlePress() async { Future<void> _handlePress() async {
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id; final selectedProjectId = projectController.selectedProject?.id;
if (selectedProjectId == null) { if (selectedProjectId == null) {
showAppSnackbar( showAppSnackbar(
title: 'Warning', title: 'Warning',
message: 'Please select a project first', message: 'Please select a project first',
type: SnackbarType.warning, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final buttonText = _buttonTexts[widget.action]!; final buttonText = _buttonTexts[widget.action]!;
@ -116,17 +119,19 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
onPressed: isUploading ? null : _handlePress, onPressed: isUploading ? null : _handlePress,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
foregroundColor: foregroundColor: Colors.white,
Colors.white, // Ensures visibility on all backgrounds
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6),
minimumSize: const Size(60, 20), minimumSize: const Size(60, 20),
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
), ),
child: isUploading child: isUploading
? const SizedBox( ? Container(
width: 16, width: 60,
height: 16, height: 14,
child: CircularProgressIndicator(strokeWidth: 2), decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
) )
: FittedBox( : FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,

View File

@ -153,7 +153,36 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
Widget _buildEmployeeList() { Widget _buildEmployeeList() {
return Obx(() { return Obx(() {
if (controller.isLoading.value) { 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; final selectedRoleId = controller.selectedRoleId.value;
@ -276,8 +305,9 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
hintText: '', hintText: '',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: (value) => validator: (value) => this
this.controller.formFieldValidator(value, fieldType: validatorType), .controller
.formFieldValidator(value, fieldType: validatorType),
), ),
], ],
); );

View File

@ -16,38 +16,36 @@ class TaskModel {
required this.assignmentDate, required this.assignmentDate,
this.reportedDate, this.reportedDate,
required this.id, required this.id,
required this.workItem, this.workItem,
required this.workItemId, required this.workItemId,
required this.plannedTask, required this.plannedTask,
required this.completedTask, required this.completedTask,
required this.assignedBy, required this.assignedBy,
this.approvedBy, this.approvedBy,
required this.teamMembers, this.teamMembers = const [],
required this.comments, this.comments = const [],
required this.reportedPreSignedUrls, this.reportedPreSignedUrls = const [],
}); });
factory TaskModel.fromJson(Map<String, dynamic> json) { factory TaskModel.fromJson(Map<String, dynamic> json) {
return TaskModel( return TaskModel(
assignmentDate: DateTime.parse(json['assignmentDate']), assignmentDate: DateTime.parse(json['assignmentDate'] ?? DateTime.now().toIso8601String()),
reportedDate: json['reportedDate'] != null reportedDate: json['reportedDate'] != null ? DateTime.tryParse(json['reportedDate']) : null,
? DateTime.tryParse(json['reportedDate']) id: json['id']?.toString() ?? '',
: null, workItem: json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null,
id: json['id'], workItemId: json['workItemId']?.toString() ?? '',
workItem: plannedTask: (json['plannedTask'] as num?)?.toDouble() ?? 0,
json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null, completedTask: (json['completedTask'] as num?)?.toDouble() ?? 0,
workItemId: json['workItemId'], assignedBy: AssignedBy.fromJson(json['assignedBy'] ?? {}),
plannedTask: (json['plannedTask'] as num).toDouble(), approvedBy: json['approvedBy'] != null ? AssignedBy.fromJson(json['approvedBy']) : null,
completedTask: (json['completedTask'] as num).toDouble(), teamMembers: (json['teamMembers'] as List<dynamic>?)
assignedBy: AssignedBy.fromJson(json['assignedBy']), ?.map((e) => TeamMember.fromJson(e))
approvedBy: json['approvedBy'] != null .toList() ??
? AssignedBy.fromJson(json['approvedBy']) [],
: null, comments: (json['comments'] as List<dynamic>?)
teamMembers: (json['teamMembers'] as List) ?.map((e) => Comment.fromJson(e))
.map((e) => TeamMember.fromJson(e)) .toList() ??
.toList(), [],
comments:
(json['comments'] as List).map((e) => Comment.fromJson(e)).toList(),
reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List<dynamic>?) reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? .toList() ??
@ -79,8 +77,7 @@ class WorkItem {
activityMaster: json['activityMaster'] != null activityMaster: json['activityMaster'] != null
? ActivityMaster.fromJson(json['activityMaster']) ? ActivityMaster.fromJson(json['activityMaster'])
: null, : null,
workArea: workArea: json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
plannedWork: (json['plannedWork'] as num?)?.toDouble(), plannedWork: (json['plannedWork'] as num?)?.toDouble(),
completedWork: (json['completedWork'] as num?)?.toDouble(), completedWork: (json['completedWork'] as num?)?.toDouble(),
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?) preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
@ -92,7 +89,7 @@ class WorkItem {
} }
class ActivityMaster { class ActivityMaster {
final String? id; // Added final String? id;
final String activityName; final String activityName;
ActivityMaster({ ActivityMaster({
@ -103,13 +100,13 @@ class ActivityMaster {
factory ActivityMaster.fromJson(Map<String, dynamic> json) { factory ActivityMaster.fromJson(Map<String, dynamic> json) {
return ActivityMaster( return ActivityMaster(
id: json['id']?.toString(), id: json['id']?.toString(),
activityName: json['activityName'] ?? '', activityName: json['activityName']?.toString() ?? '',
); );
} }
} }
class WorkArea { class WorkArea {
final String? id; // Added final String? id;
final String areaName; final String areaName;
final Floor? floor; final Floor? floor;
@ -122,7 +119,7 @@ class WorkArea {
factory WorkArea.fromJson(Map<String, dynamic> json) { factory WorkArea.fromJson(Map<String, dynamic> json) {
return WorkArea( return WorkArea(
id: json['id']?.toString(), id: json['id']?.toString(),
areaName: json['areaName'] ?? '', areaName: json['areaName']?.toString() ?? '',
floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null, floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null,
); );
} }
@ -136,9 +133,8 @@ class Floor {
factory Floor.fromJson(Map<String, dynamic> json) { factory Floor.fromJson(Map<String, dynamic> json) {
return Floor( return Floor(
floorName: json['floorName'] ?? '', floorName: json['floorName']?.toString() ?? '',
building: building: json['building'] != null ? Building.fromJson(json['building']) : null,
json['building'] != null ? Building.fromJson(json['building']) : null,
); );
} }
} }
@ -149,7 +145,7 @@ class Building {
Building({required this.name}); Building({required this.name});
factory Building.fromJson(Map<String, dynamic> json) { factory Building.fromJson(Map<String, dynamic> json) {
return Building(name: json['name'] ?? ''); return Building(name: json['name']?.toString() ?? '');
} }
} }
@ -167,8 +163,8 @@ class AssignedBy {
factory AssignedBy.fromJson(Map<String, dynamic> json) { factory AssignedBy.fromJson(Map<String, dynamic> json) {
return AssignedBy( return AssignedBy(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
firstName: json['firstName'] ?? '', firstName: json['firstName']?.toString() ?? '',
lastName: json['lastName'], lastName: json['lastName']?.toString(),
); );
} }
} }
@ -203,7 +199,7 @@ class Comment {
required this.comment, required this.comment,
required this.commentedBy, required this.commentedBy,
required this.timestamp, required this.timestamp,
required this.preSignedUrls, this.preSignedUrls = const [],
}); });
factory Comment.fromJson(Map<String, dynamic> json) { factory Comment.fromJson(Map<String, dynamic> json) {
@ -212,7 +208,9 @@ class Comment {
commentedBy: json['employee'] != null commentedBy: json['employee'] != null
? TeamMember.fromJson(json['employee']) ? TeamMember.fromJson(json['employee'])
: TeamMember(id: '', firstName: '', lastName: null), : 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<dynamic>?) preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? .toList() ??

View File

@ -147,8 +147,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
floatingLabelBehavior: FloatingLabelBehavior.never, floatingLabelBehavior: FloatingLabelBehavior.never,
), ),
), ),
MySpacing.height(10), MySpacing.height(10),
// Reported Images Section
if ((widget.taskData['reportedPreSignedUrls'] as List<dynamic>?) if ((widget.taskData['reportedPreSignedUrls'] as List<dynamic>?)
?.isNotEmpty == ?.isNotEmpty ==
true) true)
@ -157,39 +158,37 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
widget.taskData['reportedPreSignedUrls'] ?? []), widget.taskData['reportedPreSignedUrls'] ?? []),
context: context, context: context,
), ),
MySpacing.height(10), MySpacing.height(10),
// Report Actions Dropdown
MyText.titleSmall("Report Actions", fontWeight: 600), MyText.titleSmall("Report Actions", fontWeight: 600),
MySpacing.height(10), MySpacing.height(10),
Obx(() { Obx(() {
if (controller.isLoadingWorkStatus.value) if (controller.isLoadingWorkStatus.value)
return const CircularProgressIndicator(); return const CircularProgressIndicator();
return PopupMenuButton<String>( return PopupMenuButton<String>(
onSelected: (String value) { onSelected: (value) {
controller.selectedWorkStatusName.value = value; controller.selectedWorkStatusName.value = value;
controller.showAddTaskCheckbox.value = true; controller.showAddTaskCheckbox.value = true;
}, },
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)), borderRadius: BorderRadius.circular(12)),
itemBuilder: (BuildContext context) { itemBuilder: (context) => controller.workStatus.map((status) {
return controller.workStatus.map((status) { return PopupMenuItem<String>(
return PopupMenuItem<String>( value: status.name,
value: status.name, child: Row(
child: Row( children: [
children: [ Radio<String>(
Radio<String>( value: status.name,
value: status.name, groupValue: controller.selectedWorkStatusName.value,
groupValue: controller.selectedWorkStatusName.value, onChanged: (_) => Navigator.pop(context, status.name),
onChanged: (_) => Navigator.pop(context, status.name), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), MyText.bodySmall(status.name),
MyText.bodySmall(status.name), ],
], ),
), );
); }).toList(),
}).toList();
},
child: Container( child: Container(
padding: MySpacing.xy(16, 12), padding: MySpacing.xy(16, 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -211,9 +210,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
); );
}), }),
MySpacing.height(10), MySpacing.height(10),
// Add New Task Checkbox
Obx(() { Obx(() {
if (!controller.showAddTaskCheckbox.value) if (!controller.showAddTaskCheckbox.value)
return const SizedBox.shrink(); return const SizedBox.shrink();
@ -221,19 +220,15 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
checkboxTheme: CheckboxThemeData( checkboxTheme: CheckboxThemeData(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4)),
), side: const BorderSide(color: Colors.black, width: 2),
side: const BorderSide(
color: Colors.black, width: 2),
fillColor: MaterialStateProperty.resolveWith<Color>((states) { fillColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(MaterialState.selected))
return Colors.blueAccent; return Colors.blueAccent;
} return Colors.white;
return Colors.white;
}), }),
checkColor: checkColor: MaterialStateProperty.all(Colors.white),
MaterialStateProperty.all(Colors.white), ),
),
), ),
child: CheckboxListTile( child: CheckboxListTile(
title: MyText.titleSmall("Add new task", fontWeight: 600), title: MyText.titleSmall("Add new task", fontWeight: 600),
@ -245,10 +240,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
); );
}), }),
MySpacing.height(24), MySpacing.height(24),
// Comment Field // 💬 Comment Field
Row( Row(
children: [ children: [
Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]), Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]),
@ -258,8 +252,8 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
MySpacing.height(8), MySpacing.height(8),
TextFormField( TextFormField(
validator: controller.basicValidator.getValidation('comment'),
controller: controller.basicValidator.getController('comment'), controller: controller.basicValidator.getController('comment'),
validator: controller.basicValidator.getValidation('comment'),
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "eg: Work done successfully", hintText: "eg: Work done successfully",
@ -269,10 +263,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
floatingLabelBehavior: FloatingLabelBehavior.never, floatingLabelBehavior: FloatingLabelBehavior.never,
), ),
), ),
MySpacing.height(16), MySpacing.height(16),
// 📸 Image Attachments // 📸 Attach Photos
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -297,21 +290,18 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
onCameraTap: () => controller.pickImages(fromCamera: true), onCameraTap: () => controller.pickImages(fromCamera: true),
onUploadTap: () => controller.pickImages(fromCamera: false), onUploadTap: () => controller.pickImages(fromCamera: false),
onRemoveImage: (index) => controller.removeImageAt(index), onRemoveImage: (index) => controller.removeImageAt(index),
onPreviewImage: (index) { onPreviewImage: (index) => showDialog(
showDialog( context: context,
context: context, builder: (_) => ImageViewerDialog(
builder: (_) => ImageViewerDialog( imageSources: images,
imageSources: images, initialIndex: index,
initialIndex: index, ),
), ),
);
},
); );
}), }),
MySpacing.height(12), MySpacing.height(12),
// Submit/Cancel Buttons moved here // Submit/Cancel Buttons
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -347,7 +337,6 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
?.text ?.text
.trim() ?? .trim() ??
''; '';
final shouldShowAddTaskSheet = final shouldShowAddTaskSheet =
controller.isAddTaskChecked.value; controller.isAddTaskChecked.value;
@ -408,10 +397,9 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
), ),
], ],
), ),
MySpacing.height(12), MySpacing.height(12),
// 💬 Previous Comments List (only below submit) // 💬 Previous Comments
if ((widget.taskData['taskComments'] as List<dynamic>?)?.isNotEmpty == if ((widget.taskData['taskComments'] as List<dynamic>?)?.isNotEmpty ==
true) ...[ true) ...[
Row( Row(

View File

@ -36,7 +36,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
late final TextEditingController _genderController; late final TextEditingController _genderController;
late final TextEditingController _roleController; late final TextEditingController _roleController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_orgFieldController = TextEditingController(); _orgFieldController = TextEditingController();
@ -50,7 +50,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
_controller.editingEmployeeData = widget.employeeData; _controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields(); _controller.prefillFields();
// Prepopulate hasApplicationAccess and email
_hasApplicationAccess = _hasApplicationAccess =
widget.employeeData?['hasApplicationAccess'] ?? false; widget.employeeData?['hasApplicationAccess'] ?? false;
@ -60,22 +59,32 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
email.toString(); email.toString();
} }
// Trigger UI rebuild to reflect email & checkbox final orgId = widget.employeeData?['organization_id'];
setState(() {}); 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) { if (_controller.joiningDate != null) {
_joiningDateController.text = _joiningDateController.text =
DateFormat('dd MMM yyyy').format(_controller.joiningDate!); DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
} }
// Gender // Prefill Gender
if (_controller.selectedGender != null) { if (_controller.selectedGender != null) {
_genderController.text = _genderController.text =
_controller.selectedGender!.name.capitalizeFirst ?? ''; _controller.selectedGender!.name.capitalizeFirst ?? '';
} }
// Role // Prefill Role
_controller.fetchRoles().then((_) { _controller.fetchRoles().then((_) {
if (_controller.selectedRoleId != null) { if (_controller.selectedRoleId != null) {
final roleName = _controller.roles.firstWhereOrNull( final roleName = _controller.roles.firstWhereOrNull(
@ -91,6 +100,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
_controller.fetchRoles(); _controller.fetchRoles();
} }
} }
@override @override
void dispose() { void dispose() {
_orgFieldController.dispose(); _orgFieldController.dispose();
@ -337,8 +347,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return null; return null;
}, },
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
decoration: _inputDecoration('e.g., john.doe@example.com').copyWith( decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(),
),
), ),
], ],
); );

View File

@ -84,8 +84,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
/// Project Progress Chart Section /// Project Progress Chart Section
Widget _buildProjectProgressChartSection() { Widget _buildProjectProgressChartSection() {
return Obx(() { return Obx(() {
if (dashboardController.projectChartData.isEmpty) { if (dashboardController.projectChartData.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
@ -110,7 +108,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
/// Attendance Chart Section /// Attendance Chart Section
Widget _buildAttendanceChartSection() { Widget _buildAttendanceChartSection() {
return Obx(() { return Obx(() {
final isAttendanceAllowed = menuController.isMenuAllowed("Attendance"); final isAttendanceAllowed = menuController.isMenuAllowed("Attendance");
if (!isAttendanceAllowed) { if (!isAttendanceAllowed) {
@ -212,7 +209,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
return _buildLoadingSkeleton(context); return _buildLoadingSkeleton(context);
} }
if (menuController.hasError.value && menuController.menuItems.isEmpty) { if (menuController.hasError.value || menuController.menuItems.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Center( child: Center(
@ -224,6 +221,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
final projectController = Get.find<ProjectController>();
final isProjectSelected = projectController.selectedProject != null;
// Keep previous stat items (icons, title, routes)
final stats = [ final stats = [
_StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success,
DashboardScreen.attendanceRoute), DashboardScreen.attendanceRoute),
@ -241,8 +242,16 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
DashboardScreen.documentMainPageRoute), DashboardScreen.documentMainPageRoute),
]; ];
final projectController = Get.find<ProjectController>(); // Safe menu check function to avoid exceptions
final isProjectSelected = projectController.selectedProject != null; bool _isMenuAllowed(String menuTitle) {
try {
return menuController.menuItems.isNotEmpty
? menuController.isMenuAllowed(menuTitle)
: false;
} catch (e) {
return false;
}
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -250,7 +259,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
if (!isProjectSelected) _buildNoProjectMessage(), if (!isProjectSelected) _buildNoProjectMessage(),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// smaller width cards fit more in a row
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8); int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8);
double cardWidth = double cardWidth =
(constraints.maxWidth - (crossAxisCount - 1) * 6) / (constraints.maxWidth - (crossAxisCount - 1) * 6) /
@ -261,14 +269,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
runSpacing: 6, runSpacing: 6,
alignment: WrapAlignment.start, alignment: WrapAlignment.start,
children: stats children: stats
.where((stat) { .where((stat) => _isMenuAllowed(stat.title))
if (stat.title == "Documents") return true;
return menuController.isMenuAllowed(stat.title);
})
.map((stat) => .map((stat) =>
_buildStatCard(stat, isProjectSelected, cardWidth)) _buildStatCard(stat, isProjectSelected, cardWidth))
.toList() .toList(),
.cast<Widget>(),
); );
}, },
), ),

View File

@ -360,33 +360,31 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
[...activeComments, ...inactiveComments].reversed.toList(); [...activeComments, ...inactiveComments].reversed.toList();
final editingId = directoryController.editingCommentId.value; final editingId = directoryController.editingCommentId.value;
if (comments.isEmpty) {
return Center(
child: MyText.bodyLarge("No notes yet.", color: Colors.grey),
);
}
return Stack( return Stack(
children: [ children: [
MyRefreshIndicator( comments.isEmpty
onRefresh: () async { ? Center(
await directoryController.fetchCommentsForContact(contactId, child: MyText.bodyLarge("No notes yet.", color: Colors.grey),
active: true); )
await directoryController.fetchCommentsForContact(contactId, : MyRefreshIndicator(
active: false); onRefresh: () async {
}, await directoryController.fetchCommentsForContact(contactId,
child: Padding( active: true);
padding: MySpacing.xy(12, 12), await directoryController.fetchCommentsForContact(contactId,
child: ListView.separated( active: false);
physics: const AlwaysScrollableScrollPhysics(), },
padding: const EdgeInsets.only(bottom: 100), child: Padding(
itemCount: comments.length, padding: MySpacing.xy(12, 12),
separatorBuilder: (_, __) => MySpacing.height(14), child: ListView.separated(
itemBuilder: (_, index) => physics: const AlwaysScrollableScrollPhysics(),
_buildCommentItem(comments[index], editingId, contactId), padding: const EdgeInsets.only(bottom: 100),
), itemCount: comments.length,
), separatorBuilder: (_, __) => MySpacing.height(14),
), itemBuilder: (_, index) => _buildCommentItem(
comments[index], editingId, contactId),
),
),
),
if (editingId == null) if (editingId == null)
Positioned( Positioned(
bottom: 20, bottom: 20,

View File

@ -27,8 +27,8 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
final DocumentDetailsController controller = final DocumentDetailsController controller =
Get.find<DocumentDetailsController>(); Get.find<DocumentDetailsController>();
final PermissionController permissionController = final permissionController = Get.put(PermissionController());
Get.find<PermissionController>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();

View File

@ -1,24 +1,24 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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:intl/intl.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/controller/document/document_details_controller.dart';
import 'package:marco/model/document/document_upload_bottom_sheet.dart';
import 'package:marco/controller/document/document_upload_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/custom_app_bar.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.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/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/controller/document/document_details_controller.dart'; import 'package:marco/model/document/document_upload_bottom_sheet.dart';
import 'dart:convert'; 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 { class UserDocumentsPage extends StatefulWidget {
final String? entityId; final String? entityId;
@ -36,10 +36,9 @@ class UserDocumentsPage extends StatefulWidget {
class _UserDocumentsPageState extends State<UserDocumentsPage> { class _UserDocumentsPageState extends State<UserDocumentsPage> {
final DocumentController docController = Get.put(DocumentController()); final DocumentController docController = Get.put(DocumentController());
final PermissionController permissionController = final PermissionController permissionController = Get.put(PermissionController());
Get.find<PermissionController>(); final DocumentDetailsController controller = Get.put(DocumentDetailsController());
final DocumentDetailsController controller =
Get.put(DocumentDetailsController());
String get entityTypeId => widget.isEmployee String get entityTypeId => widget.isEmployee
? Permissions.employeeEntity ? Permissions.employeeEntity
: Permissions.projectEntity; : Permissions.projectEntity;
@ -68,12 +67,9 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
} }
Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) { Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) {
final uploadDate = final uploadDate = DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
final uploader = doc.uploadedBy.firstName.isNotEmpty final uploader = doc.uploadedBy.firstName.isNotEmpty
? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}" ? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim()
.trim()
: "Added by you"; : "Added by you";
return Column( return Column(
@ -91,7 +87,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
), ),
InkWell( InkWell(
onTap: () { onTap: () {
// 👉 Navigate to details page
Get.to(() => DocumentDetailsPage(documentId: doc.id)); Get.to(() => DocumentDetailsPage(documentId: doc.id));
}, },
child: Container( child: Container(
@ -146,92 +141,90 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
], ],
), ),
), ),
PopupMenuButton<String>( Obx(() {
icon: const Icon(Icons.more_vert, color: Colors.black54), // React to permission changes
onSelected: (value) async { return PopupMenuButton<String>(
if (value == "delete") { icon: const Icon(Icons.more_vert, color: Colors.black54),
// existing delete flow (unchanged) onSelected: (value) async {
final result = await showDialog<bool>( if (value == "delete") {
context: context, final result = await showDialog<bool>(
builder: (_) => ConfirmDialog( context: context,
title: "Delete Document", builder: (_) => ConfirmDialog(
message: title: "Delete Document",
"Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.", message:
confirmText: "Delete", "Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.",
cancelText: "Cancel", confirmText: "Delete",
icon: Icons.delete_forever, cancelText: "Cancel",
confirmColor: Colors.redAccent, icon: Icons.delete_forever,
onConfirm: () async { confirmColor: Colors.redAccent,
final success = onConfirm: () async {
await docController.toggleDocumentActive( final success =
doc.id, await docController.toggleDocumentActive(
isActive: false, doc.id,
entityTypeId: entityTypeId, isActive: false,
entityId: resolvedEntityId, entityTypeId: entityTypeId,
); entityId: resolvedEntityId,
);
if (success) { if (success) {
showAppSnackbar( showAppSnackbar(
title: "Deleted", title: "Deleted",
message: "Document deleted successfully", message: "Document deleted successfully",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to delete document", message: "Failed to delete document",
type: SnackbarType.error, type: SnackbarType.error,
); );
throw Exception( throw Exception("Failed to delete");
"Failed to delete"); // keep dialog open }
} },
}, ),
);
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<UserDocumentsPage> {
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
child: Row( child: Row(
children: [ children: [
// 🔍 Search Bar
Expanded( Expanded(
child: SizedBox( child: SizedBox(
height: 35, height: 35,
@ -283,15 +275,13 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
}, },
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey),
const Icon(Icons.search, size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>( suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: docController.searchController, valueListenable: docController.searchController,
builder: (context, value, _) { builder: (context, value, _) {
if (value.text.isEmpty) return const SizedBox.shrink(); if (value.text.isEmpty) return const SizedBox.shrink();
return IconButton( return IconButton(
icon: const Icon(Icons.clear, icon: const Icon(Icons.clear, size: 20, color: Colors.grey),
size: 20, color: Colors.grey),
onPressed: () { onPressed: () {
docController.searchController.clear(); docController.searchController.clear();
docController.searchQuery.value = ''; docController.searchQuery.value = '';
@ -320,8 +310,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
), ),
), ),
MySpacing.width(8), MySpacing.width(8),
// 🛠 Filter Icon with indicator
Obx(() { Obx(() {
final isFilterActive = docController.hasActiveFilters(); final isFilterActive = docController.hasActiveFilters();
return Stack( return Stack(
@ -337,18 +325,13 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
child: IconButton( child: IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: BoxConstraints(), constraints: BoxConstraints(),
icon: Icon( icon: Icon(Icons.tune, size: 20, color: Colors.black87),
Icons.tune,
size: 20,
color: Colors.black87,
),
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: borderRadius: BorderRadius.vertical(top: Radius.circular(5)),
BorderRadius.vertical(top: Radius.circular(5)),
), ),
builder: (_) => UserDocumentFilterBottomSheet( builder: (_) => UserDocumentFilterBottomSheet(
entityId: resolvedEntityId, entityId: resolvedEntityId,
@ -375,8 +358,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
); );
}), }),
MySpacing.width(10), MySpacing.width(10),
// Menu (Show Inactive toggle)
Container( Container(
height: 35, height: 35,
width: 35, width: 35,
@ -387,8 +368,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
), ),
child: PopupMenuButton<int>( child: PopupMenuButton<int>(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87),
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
@ -439,8 +419,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
Widget _buildStatusHeader() { Widget _buildStatusHeader() {
return Obx(() { return Obx(() {
final isInactive = docController.showInactive.value; if (!docController.showInactive.value) return const SizedBox.shrink();
if (!isInactive) return const SizedBox.shrink(); // hide when active
return Container( return Container(
width: double.infinity, width: double.infinity,
@ -448,18 +427,11 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
color: Colors.red.shade50, color: Colors.red.shade50,
child: Row( child: Row(
children: [ children: [
Icon( Icon(Icons.visibility_off, color: Colors.red, size: 18),
Icons.visibility_off,
color: Colors.red,
size: 18,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
"Showing Deleted Documents", "Showing Deleted Documents",
style: TextStyle( style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600),
color: Colors.red,
fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
@ -468,30 +440,33 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
} }
Widget _buildBody(BuildContext context) { 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(() { 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) { if (docController.isLoading.value && docController.documents.isEmpty) {
return SingleChildScrollView( return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@ -510,8 +485,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
onRefresh: () async { onRefresh: () async {
final combinedFilter = { final combinedFilter = {
'uploadedByIds': docController.selectedUploadedBy.toList(), 'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds': 'documentCategoryIds': docController.selectedCategory.toList(),
docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(), 'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(), 'documentTagIds': docController.selectedTag.toList(),
}; };
@ -525,9 +499,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
}, },
child: ListView( child: ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: docs.isEmpty padding: docs.isEmpty ? null : const EdgeInsets.fromLTRB(0, 0, 0, 80),
? null
: const EdgeInsets.fromLTRB(0, 0, 0, 80),
children: docs.isEmpty children: docs.isEmpty
? [ ? [
SizedBox( SizedBox(
@ -543,8 +515,8 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
final currentDate = DateFormat("dd MMM yyyy") final currentDate = DateFormat("dd MMM yyyy")
.format(doc.uploadedAt.toLocal()); .format(doc.uploadedAt.toLocal());
final prevDate = index > 0 final prevDate = index > 0
? DateFormat("dd MMM yyyy").format( ? DateFormat("dd MMM yyyy")
docs[index - 1].uploadedAt.toLocal()) .format(docs[index - 1].uploadedAt.toLocal())
: null; : null;
final showDateHeader = currentDate != prevDate; final showDateHeader = currentDate != prevDate;
@ -591,58 +563,61 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
) )
: null, : null,
body: _buildBody(context), body: _buildBody(context),
floatingActionButton: permissionController floatingActionButton: Obx(() {
.hasPermission(Permissions.uploadDocument) if (permissionController.permissions.isEmpty) return SizedBox.shrink();
? FloatingActionButton.extended(
onPressed: () {
final uploadController = Get.put(DocumentUploadController());
showModalBottomSheet( return permissionController.hasPermission(Permissions.uploadDocument)
context: context, ? FloatingActionButton.extended(
isScrollControlled: true, onPressed: () {
backgroundColor: Colors.transparent, final uploadController = Get.put(DocumentUploadController());
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"],
);
if (success) { showModalBottomSheet(
Navigator.pop(context); context: context,
docController.fetchDocuments( isScrollControlled: true,
entityTypeId: entityTypeId, 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, 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( if (success) {
title: "Error", Navigator.pop(context);
message: "Upload failed, please try again", docController.fetchDocuments(
type: SnackbarType.error, entityTypeId: entityTypeId,
); entityId: resolvedEntityId,
} reset: true,
}, );
), } else {
); showAppSnackbar(
}, title: "Error",
icon: const Icon(Icons.add, color: Colors.white), message: "Upload failed, please try again",
label: MyText.bodyMedium( type: SnackbarType.error,
"Add Document", );
color: Colors.white, }
fontWeight: 600, },
), ),
backgroundColor: Colors.red, );
) },
: null, 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, floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
); );
} }

View File

@ -30,8 +30,8 @@ class EmployeeDetailPage extends StatefulWidget {
class _EmployeeDetailPageState extends State<EmployeeDetailPage> { class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
final EmployeesScreenController controller = final EmployeesScreenController controller =
Get.put(EmployeesScreenController()); Get.put(EmployeesScreenController());
final PermissionController _permissionController = final PermissionController permissionController =
Get.find<PermissionController>(); Get.put(PermissionController());
@override @override
void initState() { void initState() {
@ -272,6 +272,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
'job_role_id': employee.jobRoleId, 'job_role_id': employee.jobRoleId,
'joining_date': 'joining_date':
employee.joiningDate?.toIso8601String(), employee.joiningDate?.toIso8601String(),
'organization_id': employee.organizationId,
}, },
), ),
); );
@ -292,7 +293,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
); );
}), }),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
if (!_permissionController.hasPermission(Permissions.assignToProject)) { if (!permissionController.hasPermission(Permissions.assignToProject)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (controller.isLoadingEmployeeDetails.value || if (controller.isLoadingEmployeeDetails.value ||

View File

@ -29,8 +29,8 @@ class EmployeesScreen extends StatefulWidget {
class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin { class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
final EmployeesScreenController _employeeController = final EmployeesScreenController _employeeController =
Get.put(EmployeesScreenController()); Get.put(EmployeesScreenController());
final PermissionController _permissionController = final PermissionController permissionController =
Get.find<PermissionController>(); Get.put(PermissionController());
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs; final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
final OrganizationController _organizationController = final OrganizationController _organizationController =
@ -248,7 +248,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Widget _buildFloatingActionButton() { Widget _buildFloatingActionButton() {
if (!_permissionController.hasPermission(Permissions.manageEmployees)) { if (!permissionController.hasPermission(Permissions.manageEmployees)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@ -371,7 +371,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Widget _buildPopupMenu() { Widget _buildPopupMenu() {
if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) { if (!permissionController.hasPermission(Permissions.viewAllEmployees)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }

View File

@ -33,7 +33,7 @@ class ExpenseDetailScreen extends StatefulWidget {
class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> { class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
final controller = Get.put(ExpenseDetailController()); final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>(); final permissionController = Get.put(PermissionController());
EmployeeInfo? employeeInfo; EmployeeInfo? employeeInfo;
final RxBool canSubmit = false.obs; final RxBool canSubmit = false.obs;

View File

@ -26,7 +26,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
final searchController = TextEditingController(); final searchController = TextEditingController();
final expenseController = Get.put(ExpenseController()); final expenseController = Get.put(ExpenseController());
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>(); final permissionController = Get.put(PermissionController());
@override @override
void initState() { void initState() {
@ -81,7 +81,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
.toList(); .toList();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
@ -106,7 +106,7 @@ Widget build(BuildContext context) {
// ---------------- Gray background for rest ---------------- // ---------------- Gray background for rest ----------------
Expanded( Expanded(
child: Container( child: Container(
color: Colors.grey[100], // Light gray background color: Colors.grey[100],
child: Column( child: Column(
children: [ children: [
// ---------------- Search ---------------- // ---------------- Search ----------------
@ -137,14 +137,24 @@ Widget build(BuildContext context) {
], ],
), ),
floatingActionButton: // FAB reacts only to upload permission
permissionController.hasPermission(Permissions.expenseUpload) floatingActionButton: Obx(() {
? FloatingActionButton( // Show loader or hide FAB while permissions are loading
backgroundColor: Colors.red, if (permissionController.permissions.isEmpty) {
onPressed: showAddExpenseBottomSheet, return const SizedBox.shrink();
child: const Icon(Icons.add, color: Colors.white), }
)
: null, 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();
}),
); );
} }

View File

@ -9,10 +9,12 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/controller/auth/mpin_controller.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/view/employees/employee_profile_screen.dart';
import 'package:marco/helpers/services/tenant_service.dart'; import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart'; import 'package:marco/view/tenant/tenant_selection_screen.dart';
import 'package:marco/controller/tenant/tenant_switch_controller.dart';
class UserProfileBar extends StatefulWidget { class UserProfileBar extends StatefulWidget {
final bool isCondensed; final bool isCondensed;
@ -27,21 +29,13 @@ class _UserProfileBarState extends State<UserProfileBar>
late EmployeeInfo employeeInfo; late EmployeeInfo employeeInfo;
bool _isLoading = true; bool _isLoading = true;
bool hasMpin = true; bool hasMpin = true;
late final TenantSelectionController _tenantController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tenantController = Get.put(TenantSelectionController());
_initData(); _initData();
} }
@override
void dispose() {
Get.delete<TenantSelectionController>();
super.dispose();
}
Future<void> _initData() async { Future<void> _initData() async {
employeeInfo = LocalStorage.getEmployeeInfo()!; employeeInfo = LocalStorage.getEmployeeInfo()!;
hasMpin = await LocalStorage.getIsMpin(); hasMpin = await LocalStorage.getIsMpin();
@ -122,93 +116,101 @@ class _UserProfileBarState extends State<UserProfileBar>
} }
/// Row widget to switch tenant with popup menu (button only) /// Row widget to switch tenant with popup menu (button only)
Widget _switchTenantRow() { /// Row widget to switch tenant with popup menu (button only)
return Padding( Widget _switchTenantRow() {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), // Use the dedicated switch controller
child: Obx(() { final TenantSwitchController tenantSwitchController =
if (_tenantController.isLoading.value) return _loadingTenantContainer(); Get.put(TenantSwitchController());
final tenants = _tenantController.tenants; return Padding(
if (tenants.isEmpty) return _noTenantContainer(); 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 selectedTenant = TenantService.currentTenant;
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<String>( // Sort tenants: selected tenant first
onSelected: (tenantId) => final sortedTenants = List.of(tenants);
_tenantController.onTenantSelected(tenantId), if (selectedTenant != null) {
itemBuilder: (_) => sortedTenants.map((tenant) { sortedTenants.sort((a, b) {
return PopupMenuItem( if (a.id == selectedTenant.id) return -1;
value: tenant.id, if (b.id == selectedTenant.id) return 1;
child: Row( return 0;
children: [ });
ClipRRect( }
borderRadius: BorderRadius.circular(8),
child: Container( return PopupMenuButton<String>(
width: 20, onSelected: (tenantId) =>
height: 20, tenantSwitchController.switchTenant(tenantId),
color: Colors.grey.shade200, itemBuilder: (_) => sortedTenants.map((tenant) {
child: TenantLogo(logoImage: tenant.logoImage), return PopupMenuItem(
), value: tenant.id,
),
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),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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( Expanded(
child: Padding( child: Text(
padding: const EdgeInsets.symmetric(horizontal: 6), tenant.name,
child: Text( maxLines: 1,
"Switch Organization", overflow: TextOverflow.ellipsis,
maxLines: 1, style: TextStyle(
overflow: TextOverflow.ellipsis, fontWeight: tenant.id == selectedTenant?.id
style: TextStyle( ? FontWeight.bold
color: Colors.blue, fontWeight: 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( Widget _loadingTenantContainer() => Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),

View File

@ -5,7 +5,6 @@ import 'package:provider/provider.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/extensions/app_localization_delegate.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/localizations/language.dart';
import 'package:marco/helpers/services/navigation_services.dart'; import 'package:marco/helpers/services/navigation_services.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
@ -19,22 +18,21 @@ class MyApp extends StatelessWidget {
Future<String> _getInitialRoute() async { Future<String> _getInitialRoute() async {
try { try {
if (!AuthService.isLoggedIn) { final token = LocalStorage.getJwtToken();
if (token == null || token.isEmpty) {
logSafe("User not logged in. Routing to /auth/login-option"); logSafe("User not logged in. Routing to /auth/login-option");
return "/auth/login-option"; return "/auth/login-option";
} }
final bool hasMpin = LocalStorage.getIsMpin(); final bool hasMpin = LocalStorage.getIsMpin();
logSafe("MPIN enabled: $hasMpin", );
if (hasMpin) { if (hasMpin) {
await LocalStorage.setBool("mpin_verified", false); 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"; 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) { } catch (e, stacktrace) {
logSafe("Error determining initial route", logSafe("Error determining initial route",
level: LogLevel.error, error: e, stackTrace: stacktrace); level: LogLevel.error, error: e, stackTrace: stacktrace);

View File

@ -41,7 +41,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final DailyTaskController dailyTaskController = final DailyTaskController dailyTaskController =
Get.put(DailyTaskController()); Get.put(DailyTaskController());
final PermissionController permissionController = final PermissionController permissionController =
Get.find<PermissionController>(); Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController = Get.find<ProjectController>();
final ServiceController serviceController = Get.put(ServiceController()); final ServiceController serviceController = Get.put(ServiceController());
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();

View File

@ -25,7 +25,8 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = Get.put(TenantSelectionController()); _controller =
Get.put(TenantSelectionController());
_logoAnimController = AnimationController( _logoAnimController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 800), duration: const Duration(milliseconds: 800),
@ -37,7 +38,7 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
_logoAnimController.forward(); _logoAnimController.forward();
// 🔥 Tell controller this is tenant selection screen // 🔥 Tell controller this is tenant selection screen
_controller.loadTenants(fromTenantSelectionScreen: true); _controller.loadTenants();
} }
@override @override
@ -211,6 +212,7 @@ class TenantCardList extends StatelessWidget {
if (controller.tenants.length == 1) { if (controller.tenants.length == 1) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
// Show tenant even if only 1 tenant
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24), padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24),
child: Column( child: Column(