Compare commits

..

12 Commits

26 changed files with 7824 additions and 4274 deletions

View File

@ -4,6 +4,9 @@ import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/model/expense/expense_detail_model.dart'; import 'package:on_field_work/model/expense/expense_detail_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
class ExpenseDetailController extends GetxController { class ExpenseDetailController extends GetxController {
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null); final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
@ -17,6 +20,22 @@ class ExpenseDetailController extends GetxController {
final employeeSearchController = TextEditingController(); final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs; final isSearchingEmployees = false.obs;
// NEW: Holds the logged-in user info for permission checks
EmployeeInfo? employeeInfo;
final RxBool canSubmit = false.obs;
@override
void onInit() {
super.onInit();
_loadEmployeeInfo(); // Load employee info on init
}
void _loadEmployeeInfo() async {
final info = await LocalStorage.getEmployeeInfo();
employeeInfo = info;
}
/// Call this once from the screen (NOT inside build) to initialize /// Call this once from the screen (NOT inside build) to initialize
void init(String expenseId) { void init(String expenseId) {
if (_isInitialized) return; if (_isInitialized) return;
@ -31,6 +50,36 @@ class ExpenseDetailController extends GetxController {
]); ]);
} }
/// NEW: Logic to check if the current user can submit the expense
void checkPermissionToSubmit() {
final expenseData = expense.value;
if (employeeInfo == null || expenseData == null) {
canSubmit.value = false;
return;
}
// Status ID for 'Submit' (Hardcoded ID from the original screen logic)
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final isCreatedByCurrentUser = employeeInfo?.id == expenseData.createdBy.id;
final nextStatusIds = expenseData.nextStatus.map((e) => e.id).toList();
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
logSafe(
'🐛 Checking submit permission:\n'
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
'🐛 - Expense created by ID: ${expenseData.createdBy.id}\n'
'🐛 - Next Status IDs: $nextStatusIds\n'
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
'🐛 - Final Permission Result: $result',
level: LogLevel.debug,
);
canSubmit.value = result;
}
/// Generic method to handle API calls with loading and error states /// Generic method to handle API calls with loading and error states
Future<T?> _apiCallWrapper<T>( Future<T?> _apiCallWrapper<T>(
Future<T?> Function() apiCall, String operationName) async { Future<T?> Function() apiCall, String operationName) async {
@ -63,6 +112,8 @@ class ExpenseDetailController extends GetxController {
try { try {
expense.value = ExpenseDetailModel.fromJson(result); expense.value = ExpenseDetailModel.fromJson(result);
logSafe("Expense details loaded successfully: ${expense.value?.id}"); logSafe("Expense details loaded successfully: ${expense.value?.id}");
// Call permission check after data is loaded
checkPermissionToSubmit();
} catch (e) { } catch (e) {
errorMessage.value = 'Failed to parse expense details: $e'; errorMessage.value = 'Failed to parse expense details: $e';
logSafe("Parse error in fetchExpenseDetails: $e", logSafe("Parse error in fetchExpenseDetails: $e",
@ -75,8 +126,6 @@ class ExpenseDetailController extends GetxController {
} }
} }
// This method seems like a utility and might be better placed in a helper or utility class
// if it's used across multiple controllers. Keeping it here for now as per original code.
List<String> parsePermissionIds(dynamic permissionData) { List<String> parsePermissionIds(dynamic permissionData) {
if (permissionData == null) return []; if (permissionData == null) return [];
if (permissionData is List) { if (permissionData is List) {
@ -131,8 +180,6 @@ class ExpenseDetailController extends GetxController {
allEmployees.clear(); allEmployees.clear();
logSafe("No employees found.", level: LogLevel.warning); logSafe("No employees found.", level: LogLevel.warning);
} }
// `update()` is typically not needed for RxList directly unless you have specific GetBuilder/Obx usage that requires it
// If you are using Obx widgets, `allEmployees.assignAll` will automatically trigger a rebuild.
} }
/// Update expense with reimbursement info and status /// Update expense with reimbursement info and status

View File

@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/permission_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/user_permission.dart'; import 'package:on_field_work/model/user_permission.dart';
import 'package:on_field_work/model/employees/employee_info.dart'; import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/model/projects_model.dart'; import 'package:on_field_work/model/projects_model.dart';
@ -51,7 +51,7 @@ class PermissionController extends GetxController {
Future<void> loadData(String token) async { Future<void> loadData(String token) async {
try { try {
isLoading.value = true; isLoading.value = true;
final userData = await PermissionService.fetchAllUserData(token); final userData = await AuthService.fetchAllUserData(token);
_updateState(userData); _updateState(userData);
await _storeData(); await _storeData();
logSafe("Data loaded and state updated successfully."); logSafe("Data loaded and state updated successfully.");

View File

@ -1,13 +1,12 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart'; import 'package:on_field_work/model/tenant/tenant_list_model.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
class TenantSelectionController extends GetxController { class TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService();
// Tenant list // Tenant list
final tenants = <Tenant>[].obs; final tenants = <Tenant>[].obs;
@ -32,7 +31,7 @@ class TenantSelectionController extends GetxController {
isLoading.value = true; isLoading.value = true;
isAutoSelecting.value = true; // show splash during auto-selection isAutoSelecting.value = true; // show splash during auto-selection
try { try {
final data = await _tenantService.getTenants(); final data = await AuthService.getTenants();
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
tenants.clear(); tenants.clear();
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning); logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
@ -87,7 +86,7 @@ class TenantSelectionController extends GetxController {
try { try {
isLoading.value = true; isLoading.value = true;
final success = await _tenantService.selectTenant(tenantId); final success = await AuthService.selectTenant(tenantId);
if (!success) { if (!success) {
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
@ -99,7 +98,7 @@ class TenantSelectionController extends GetxController {
// Update tenant & persist // Update tenant & persist
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant); AuthService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId; selectedTenantId.value = tenantId;
await LocalStorage.setRecentTenantId(tenantId); await LocalStorage.setRecentTenantId(tenantId);
@ -131,6 +130,6 @@ class TenantSelectionController extends GetxController {
/// Clear tenant selection /// Clear tenant selection
void _clearSelection() { void _clearSelection() {
selectedTenantId.value = null; selectedTenantId.value = null;
TenantService.currentTenant = null; AuthService.currentTenant = null;
} }
} }

View File

@ -1,13 +1,12 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart'; import 'package:on_field_work/model/tenant/tenant_list_model.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
class TenantSwitchController extends GetxController { class TenantSwitchController extends GetxController {
final TenantService _tenantService = TenantService();
final tenants = <Tenant>[].obs; final tenants = <Tenant>[].obs;
final isLoading = false.obs; final isLoading = false.obs;
@ -23,7 +22,7 @@ class TenantSwitchController extends GetxController {
Future<void> loadTenants() async { Future<void> loadTenants() async {
isLoading.value = true; isLoading.value = true;
try { try {
final data = await _tenantService.getTenants(); final data = await AuthService.getTenants();
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
tenants.clear(); tenants.clear();
logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning); logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning);
@ -33,7 +32,7 @@ class TenantSwitchController extends GetxController {
tenants.value = data.map((e) => Tenant.fromJson(e)).toList(); tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
// Keep current tenant as selected // Keep current tenant as selected
selectedTenantId.value = TenantService.currentTenant?.id; selectedTenantId.value = AuthService.currentTenant?.id;
} catch (e, st) { } catch (e, st) {
logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st); logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar( showAppSnackbar(
@ -48,11 +47,11 @@ class TenantSwitchController extends GetxController {
/// Switch to a different tenant and navigate fully /// Switch to a different tenant and navigate fully
Future<void> switchTenant(String tenantId) async { Future<void> switchTenant(String tenantId) async {
if (TenantService.currentTenant?.id == tenantId) return; if (AuthService.currentTenant?.id == tenantId) return;
isLoading.value = true; isLoading.value = true;
try { try {
final success = await _tenantService.selectTenant(tenantId); final success = await AuthService.selectTenant(tenantId);
if (!success) { if (!success) {
logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning); logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
@ -64,7 +63,7 @@ class TenantSwitchController extends GetxController {
} }
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant); AuthService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId; selectedTenantId.value = tenantId;
// Persist recent tenant // Persist recent tenant

View File

@ -1,8 +1,8 @@
class ApiEndpoints { class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
// static const String baseUrl = "https://mapi.marcoaiot.com/api"; static const String baseUrl = "https://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api"; // static const String baseUrl = "https://api.onfieldwork.com/api";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,47 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:get/get.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart'; import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/utils/encryption_helper.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
import 'package:on_field_work/model/user_permission.dart';
import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/model/projects_model.dart';
// Enum for standardizing HTTP methods within the service
enum _HttpMethod { get, post }
class AuthService { class AuthService {
static const String _baseUrl = ApiEndpoints.baseUrl; static const String _baseUrl = ApiEndpoints.baseUrl;
static const Map<String, String> _headers = { static const Map<String, String> _defaultHeaders = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
// AuthService properties
static bool isLoggedIn = false; static bool isLoggedIn = false;
// TenantService properties
static Tenant? currentTenant;
// PermissionService properties
static final Map<String, Map<String, dynamic>> _userDataCache = {};
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Logout API */ /* AUTH METHODS                                */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/// Logs the user out by calling the logout API.
static Future<bool> logoutApi(String refreshToken, String fcmToken) async { static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
try { try {
final body = { final body = {"refreshToken": refreshToken, "fcmToken": fcmToken};
"refreshToken": refreshToken, final response = await _networkRequest(
"fcmToken": fcmToken, path: "/auth/logout",
}; method: _HttpMethod.post,
body: body,
final response = await _post("/auth/logout", body); );
if (response != null && response['statusCode'] == 200) { if (response != null && response['statusCode'] == 200) {
logSafe("✅ Logout API successful"); logSafe("✅ Logout API successful");
@ -37,10 +57,7 @@ class AuthService {
} }
} }
/* -------------------------------------------------------------------------- */ /// Registers or updates the Firebase Cloud Messaging token.
/* Public Methods */
/* -------------------------------------------------------------------------- */
static Future<bool> registerDeviceToken(String fcmToken) async { static Future<bool> registerDeviceToken(String fcmToken) async {
final token = await LocalStorage.getJwtToken(); final token = await LocalStorage.getJwtToken();
if (token == null || token.isEmpty) { if (token == null || token.isEmpty) {
@ -50,38 +67,36 @@ class AuthService {
} }
final body = {"fcmToken": fcmToken}; final body = {"fcmToken": fcmToken};
final headers = { final response = await _networkRequest(
..._headers, path: "/auth/set/device-token",
'Authorization': 'Bearer $token', method: _HttpMethod.post,
}; body: body,
final endpoint = "$_baseUrl/auth/set/device-token"; authToken: token,
);
// 🔹 Log request details if (response != null && response['success'] == true) {
logSafe("📡 Device Token API Request");
logSafe("➡️ Endpoint: $endpoint");
logSafe("➡️ Headers: ${jsonEncode(headers)}");
logSafe("➡️ Payload: ${jsonEncode(body)}");
final data = await _post("/auth/set/device-token", body, authToken: token);
if (data != null && data['success'] == true) {
logSafe("✅ Device token registered successfully."); logSafe("✅ Device token registered successfully.");
return true; return true;
} }
logSafe("⚠️ Failed to register device token: ${data?['message']}", logSafe("⚠️ Failed to register device token: ${response?['message']}",
level: LogLevel.warning); level: LogLevel.warning);
return false; return false;
} }
/// Handles user login with email/password.
/// Returns error map on failure, or null on success.
static Future<Map<String, String>?> loginUser( static Future<Map<String, String>?> loginUser(
Map<String, dynamic> data) async { Map<String, dynamic> data) async {
logSafe("Attempting login..."); logSafe("Attempting login...");
logSafe("Login payload (raw): $data"); final responseData = await _networkRequest(
logSafe("Login payload (JSON): ${jsonEncode(data)}"); path: "/auth/app/login",
method: _HttpMethod.post,
body: data,
);
final responseData = await _post("/auth/app/login", data); if (responseData == null) {
if (responseData == null)
return {"error": "Network error. Please check your connection."}; return {"error": "Network error. Please check your connection."};
}
if (responseData['data'] != null) { if (responseData['data'] != null) {
await _handleLoginSuccess(responseData['data']); await _handleLoginSuccess(responseData['data']);
@ -93,9 +108,10 @@ class AuthService {
return {"error": responseData['message'] ?? "Unexpected error occurred"}; return {"error": responseData['message'] ?? "Unexpected error occurred"};
} }
/// Refreshes the JWT access token using the refresh token.
static Future<bool> refreshToken() async { static Future<bool> refreshToken() async {
final accessToken = LocalStorage.getJwtToken(); final accessToken = await LocalStorage.getJwtToken();
final refreshToken = LocalStorage.getRefreshToken(); final refreshToken = await LocalStorage.getRefreshToken();
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) { if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
logSafe("Missing access or refresh token.", level: LogLevel.warning); logSafe("Missing access or refresh token.", level: LogLevel.warning);
@ -103,24 +119,22 @@ class AuthService {
} }
final body = {"token": accessToken, "refreshToken": refreshToken}; final body = {"token": accessToken, "refreshToken": refreshToken};
final data = await _post("/auth/refresh-token", body); final data = await _networkRequest(
if (data != null && data['success'] == true) { path: "/auth/refresh-token",
method: _HttpMethod.post,
body: body,
);
if (data != null && data['success'] == true && data['data'] != null) {
await LocalStorage.setJwtToken(data['data']['token']); await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']); await LocalStorage.setRefreshToken(data['data']['refreshToken']);
await LocalStorage.setLoggedInUser(true); await LocalStorage.setLoggedInUser(true);
logSafe("Token refreshed successfully."); logSafe("Token refreshed successfully.");
// 🔹 Retry FCM token registration after token refresh final newFcmToken = await LocalStorage.getFcmToken();
final newFcmToken = LocalStorage.getFcmToken();
if (newFcmToken?.isNotEmpty ?? false) { if (newFcmToken?.isNotEmpty ?? false) {
final success = await registerDeviceToken(newFcmToken!); await registerDeviceToken(newFcmToken!);
logSafe(
success
? "✅ FCM token re-registered after JWT refresh."
: "⚠️ Failed to register FCM token after JWT refresh.",
level: success ? LogLevel.info : LogLevel.warning);
} }
return true; return true;
} }
logSafe("Refresh token failed: ${data?['message']}", logSafe("Refresh token failed: ${data?['message']}",
@ -128,35 +142,29 @@ class AuthService {
return false; return false;
} }
/// Initiates the forgot password process.
static Future<Map<String, String>?> forgotPassword(String email) => static Future<Map<String, String>?> forgotPassword(String email) =>
_wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}), _wrapErrorHandling(
() => _networkRequest(
path: "/auth/forgot-password",
method: _HttpMethod.post,
body: {"email": email},
),
successCondition: (data) => data['success'] == true, successCondition: (data) => data['success'] == true,
defaultError: "Failed to send reset link."); defaultError: "Failed to send reset link.");
static Future<Map<String, String>?> requestDemo( /// Generates an MPIN for the user.
Map<String, dynamic> demoData) =>
_wrapErrorHandling(() => _post("/market/inquiry", demoData),
successCondition: (data) => data['success'] == true,
defaultError: "Failed to submit demo request.");
static Future<List<Map<String, dynamic>>?> getIndustries() async {
final data = await _get("/market/industries");
if (data != null && data['success'] == true) {
return List<Map<String, dynamic>>.from(data['data']);
}
return null;
}
static Future<Map<String, String>?> generateMpin({ static Future<Map<String, String>?> generateMpin({
required String employeeId, required String employeeId,
required String mpin, required String mpin,
}) => }) =>
_wrapErrorHandling( _wrapErrorHandling(
() async { () async {
final token = LocalStorage.getJwtToken(); final token = await LocalStorage.getJwtToken();
return _post( return _networkRequest(
"/auth/generate-mpin", path: "/auth/generate-mpin",
{"employeeId": employeeId, "mpin": mpin}, method: _HttpMethod.post,
body: {"employeeId": employeeId, "mpin": mpin},
authToken: token, authToken: token,
); );
}, },
@ -164,6 +172,7 @@ class AuthService {
defaultError: "Failed to generate MPIN.", defaultError: "Failed to generate MPIN.",
); );
/// Verifies the MPIN for quick login.
static Future<Map<String, String>?> verifyMpin({ static Future<Map<String, String>?> verifyMpin({
required String mpin, required String mpin,
required String mpinToken, required String mpinToken,
@ -171,12 +180,14 @@ class AuthService {
}) => }) =>
_wrapErrorHandling( _wrapErrorHandling(
() async { () async {
final employeeInfo = LocalStorage.getEmployeeInfo(); final employeeInfo = await LocalStorage.getEmployeeInfo();
if (employeeInfo == null) return null; if (employeeInfo == null) return null; // Fails immediately if info is missing
final token = await LocalStorage.getJwtToken(); final token = await LocalStorage.getJwtToken();
return _post(
"/auth/login-mpin", final responseData = await _networkRequest(
{ path: "/auth/login-mpin",
method: _HttpMethod.post,
body: {
"employeeId": employeeInfo.id, "employeeId": employeeInfo.id,
"mpin": mpin, "mpin": mpin,
"mpinToken": mpinToken, "mpinToken": mpinToken,
@ -184,21 +195,41 @@ class AuthService {
}, },
authToken: token, authToken: token,
); );
// Handle token updates from MPIN login success if necessary,
// though typically refresh or a separate login handles this.
if (responseData?['data'] != null) {
await _handleLoginSuccess(responseData!['data']);
}
return responseData;
}, },
successCondition: (data) => data['success'] == true, successCondition: (data) => data['success'] == true,
defaultError: "MPIN verification failed.", defaultError: "MPIN verification failed.",
); );
/// Generates an OTP for login/verification.
static Future<Map<String, String>?> generateOtp(String email) => static Future<Map<String, String>?> generateOtp(String email) =>
_wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}), _wrapErrorHandling(
() => _networkRequest(
path: "/auth/send-otp",
method: _HttpMethod.post,
body: {"email": email},
),
successCondition: (data) => data['success'] == true, successCondition: (data) => data['success'] == true,
defaultError: "Failed to generate OTP."); defaultError: "Failed to generate OTP.");
/// Verifies the OTP and completes the login process.
static Future<Map<String, String>?> verifyOtp({ static Future<Map<String, String>?> verifyOtp({
required String email, required String email,
required String otp, required String otp,
}) async { }) async {
final data = await _post("/auth/login-otp", {"email": email, "otp": otp}); final data = await _networkRequest(
path: "/auth/login-otp",
method: _HttpMethod.post,
body: {"email": email, "otp": otp},
);
if (data != null && data['data'] != null) { if (data != null && data['data'] != null) {
await _handleLoginSuccess(data['data']); await _handleLoginSuccess(data['data']);
return null; return null;
@ -207,54 +238,296 @@ class AuthService {
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Private Utilities */ /* MARKET/OTHER METHODS                          */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
static Future<Map<String, dynamic>?> _post( /// Submits a demo request to the market endpoint.
String path, static Future<Map<String, String>?> requestDemo(
Map<String, dynamic> body, { Map<String, dynamic> demoData) =>
_wrapErrorHandling(
() => _networkRequest(
path: "/market/inquiry",
method: _HttpMethod.post,
body: demoData,
),
successCondition: (data) => data['success'] == true,
defaultError: "Failed to submit demo request.");
/// Fetches the list of available industries.
static Future<List<Map<String, dynamic>>?> getIndustries() async {
final data = await _networkRequest(
path: "/market/industries",
method: _HttpMethod.get,
);
if (data != null && data['success'] == true && data['data'] is List) {
return List<Map<String, dynamic>>.from(data['data']);
}
return null;
}
/* -------------------------------------------------------------------------- */
/* TENANT METHODS                                */
/* -------------------------------------------------------------------------- */
static void setSelectedTenant(Tenant tenant) {
currentTenant = tenant;
}
static bool get isTenantSelected => currentTenant != null;
/// Fetches the list of tenants the user belongs to.
static Future<List<Map<String, dynamic>>?> getTenants(
{bool hasRetried = false}) async {
final token = await LocalStorage.getJwtToken();
if (token == null) {
await _handleUnauthorized();
return null;
}
final data = await _networkRequest(
path: "/auth/get/user/tenants",
method: _HttpMethod.get,
authToken: token,
);
if (data != null && data['success'] == true && data['data'] is List) {
return List<Map<String, dynamic>>.from(data['data']);
}
// Handle 401 Unauthorized via refreshToken/retry logic
if (data?['statusCode'] == 401 && !hasRetried) {
final refreshed = await refreshToken();
if (refreshed) return getTenants(hasRetried: true);
}
// Fallback on all other failures
if (data != null && data['statusCode'] != 401) {
_handleApiError(
data['statusCode'], data, "Fetching tenants");
} else if (data?['statusCode'] == 401 && hasRetried) {
await _handleUnauthorized();
}
return null;
}
/// Selects a specific tenant, updating the JWT and refresh tokens.
static Future<bool> selectTenant(String tenantId,
{bool hasRetried = false}) async {
final token = await LocalStorage.getJwtToken();
if (token == null) {
await _handleUnauthorized();
return false;
}
final data = await _networkRequest(
path: "/auth/select-tenant/$tenantId",
method: _HttpMethod.post,
authToken: token,
);
if (data != null && data['success'] == true && data['data'] != null) {
await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
logSafe("✅ Tenant selected successfully. Tokens updated.");
// Refresh project controller data
try {
final projectController = Get.find<ProjectController>();
projectController.clearProjects();
projectController.fetchProjects();
} catch (_) {
logSafe("⚠️ ProjectController not found while refreshing projects");
}
// Re-register FCM token with new tenant context
final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
await registerDeviceToken(fcmToken!);
}
return true;
}
// Handle 401 Unauthorized via refreshToken/retry logic
if (data?['statusCode'] == 401 && !hasRetried) {
final refreshed = await refreshToken();
if (refreshed) return selectTenant(tenantId, hasRetried: true);
await _handleUnauthorized();
}
// Fallback on all other failures
if (data != null) {
_handleApiError(data['statusCode'], data, "Selecting tenant");
}
return false;
}
/* -------------------------------------------------------------------------- */
/* PERMISSION/USER METHODS                        */
/* -------------------------------------------------------------------------- */
/// Fetches all user-related data (permissions, employee info, projects).
static Future<Map<String, dynamic>> fetchAllUserData(
String token, {
bool hasRetried = false,
}) async {
logSafe("Fetching user data...");
final cached = _userDataCache[token];
if (cached != null) {
logSafe("User data cache hit.");
return cached;
}
final data = await _networkRequest(
path: "/user/profile",
method: _HttpMethod.get,
authToken: token,
);
if (data != null && data['success'] == true && data['data'] is Map<String, dynamic>) {
final responseData = data['data'] as Map<String, dynamic>;
final result = {
'permissions': _parsePermissions(responseData['featurePermissions']),
'employeeInfo': await _parseEmployeeInfo(responseData['employeeInfo']),
'projects': _parseProjectsInfo(responseData['projects']),
};
_userDataCache[token] = result;
logSafe("User data fetched and decrypted successfully.");
return result;
}
// Handle 401 Unauthorized via refreshToken/retry logic
if (data?['statusCode'] == 401 && !hasRetried) {
final refreshed = await refreshToken();
final newToken = await LocalStorage.getJwtToken();
if (refreshed && newToken != null) {
return fetchAllUserData(newToken, hasRetried: true);
}
}
// Handle failure and unauthorized
if (data?['statusCode'] == 401 || data?['statusCode'] == 403 || data == null) {
await _handleUnauthorized();
throw Exception('Unauthorized or Network Error. Token refresh failed.');
}
final errorMsg = data['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $errorMsg');
}
/* -------------------------------------------------------------------------- */
/* Private Utilities                              */
/* -------------------------------------------------------------------------- */
/// Global handler for unauthorized access, clears tokens and redirects.
static Future<void> _handleUnauthorized() async {
logSafe(
"Clearing tokens and redirecting to login due to unauthorized access.",
level: LogLevel.warning);
await LocalStorage.removeToken('jwt_token');
await LocalStorage.removeToken('refresh_token');
await LocalStorage.setLoggedInUser(false);
isLoggedIn = false;
Get.offAllNamed('/auth/login-option');
}
/// Parses raw permission list into a list of UserPermission models.
static List<UserPermission> _parsePermissions(List<dynamic>? permissions) {
logSafe("Parsing user permissions...");
if (permissions == null) return [];
return permissions
.map((perm) => UserPermission.fromJson({'id': perm}))
.toList();
}
/// Parses raw employee info, stores it locally, and returns the model.
static Future<EmployeeInfo> _parseEmployeeInfo(
Map<String, dynamic>? data) async {
logSafe("Parsing employee info...");
if (data == null) throw Exception("Employee data missing");
final employeeInfo = EmployeeInfo.fromJson(data);
await LocalStorage.setEmployeeInfo(employeeInfo);
return employeeInfo;
}
/// Parses raw projects list into a list of ProjectInfo models.
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
logSafe("Parsing projects info...");
if (projects == null) return [];
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
}
/// Internal utility to report API errors.
static void _handleApiError(
int statusCode, Map<String, dynamic> data, String context) {
final message = data['message'] ?? 'Unknown error';
final level = statusCode >= 500 ? LogLevel.error : LogLevel.warning;
logSafe("$context failed: $message [Status: $statusCode]", level: level);
}
/// General network request handler for both GET and POST.
static Future<Map<String, dynamic>?> _networkRequest({
required String path,
required _HttpMethod method,
Map<String, dynamic>? body,
String? authToken, String? authToken,
}) async { }) async {
try { final uri = Uri.parse("$_baseUrl$path");
final headers = { final headers = {
..._headers, ..._defaultHeaders,
if (authToken?.isNotEmpty ?? false) if (authToken?.isNotEmpty ?? false) 'Authorization': 'Bearer $authToken',
'Authorization': 'Bearer $authToken',
};
final response = await http.post(Uri.parse("$_baseUrl$path"),
headers: headers, body: jsonEncode(body));
return {
...jsonDecode(response.body),
"statusCode": response.statusCode,
}; };
http.Response? response;
try {
logSafe(
"➡️ ${method.name.toUpperCase()} $_baseUrl$path${body != null ? '\nBody: ${jsonEncode(body)}' : ''}",
level: LogLevel.info);
if (method == _HttpMethod.post) {
response = await http.post(uri, headers: headers, body: jsonEncode(body));
} else { // GET
response = await http.get(uri, headers: headers);
}
if (response.body.isEmpty || response.body.trim().isEmpty) {
logSafe("❌ Empty response for $path", level: LogLevel.error);
// Special case for unauthorized response with no body (e.g., gateway issue)
if (response.statusCode == 401) {
await _handleUnauthorized();
}
return {"statusCode": response.statusCode, "success": false, "message": "Empty response body"};
}
final decrypted = decryptResponse(response.body);
if (decrypted == null) {
logSafe("❌ Response decryption failed for $path", level: LogLevel.error);
return {"statusCode": response.statusCode, "success": false, "message": "Failed to decrypt response"};
}
final Map<String, dynamic> result = decrypted is Map<String, dynamic>
? decrypted
: {"data": decrypted}; // Wrap non-map responses
logSafe(
"⬅️ Response: ${jsonEncode(result)} [Status: ${response.statusCode}]",
level: LogLevel.info);
return {"statusCode": response.statusCode, ...result};
} catch (e, st) { } catch (e, st) {
_handleError("$path POST error", e, st); _handleError("$path ${method.name.toUpperCase()} error", e, st);
return null;
}
}
static Future<Map<String, dynamic>?> _get(
String path, {
String? authToken,
}) async {
try {
final headers = {
..._headers,
if (authToken?.isNotEmpty ?? false)
'Authorization': 'Bearer $authToken',
};
final response =
await http.get(Uri.parse("$_baseUrl$path"), headers: headers);
return {
...jsonDecode(response.body),
"statusCode": response.statusCode,
};
} catch (e, st) {
_handleError("$path GET error", e, st);
return null; return null;
} }
} }
/// Utility to wrap simple API calls with error-to-UI message mapping.
static Future<Map<String, String>?> _wrapErrorHandling( static Future<Map<String, String>?> _wrapErrorHandling(
Future<Map<String, dynamic>?> Function() request, { Future<Map<String, dynamic>?> Function() request, {
required bool Function(Map<String, dynamic> data) successCondition, required bool Function(Map<String, dynamic> data) successCondition,
@ -265,13 +538,13 @@ class AuthService {
return {"error": data?['message'] ?? defaultError}; return {"error": data?['message'] ?? defaultError};
} }
/// Generic error logging helper.
static void _handleError(String message, Object error, StackTrace st) { static void _handleError(String message, Object error, StackTrace st) {
logSafe(message, level: LogLevel.error, error: error, stackTrace: st); logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
} }
/// Common logic for storing tokens and login state upon successful authentication.
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async { static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
logSafe("Processing login success...");
await LocalStorage.setJwtToken(data['token']); await LocalStorage.setJwtToken(data['token']);
await LocalStorage.setLoggedInUser(true); await LocalStorage.setLoggedInUser(true);
@ -287,6 +560,5 @@ class AuthService {
await LocalStorage.removeMpinToken(); await LocalStorage.removeMpinToken();
} }
isLoggedIn = true; isLoggedIn = true;
logSafe("✅ Login flow completed and controllers initialized.");
} }
} }

View File

@ -0,0 +1,255 @@
// lib/helpers/services/http_client.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/utils/encryption_helper.dart';
/// Centralized HTTP client with automatic token management, encryption,
/// and retry logic for OnFieldWork.com API communication.
class HttpClient {
static const Duration _timeout = Duration(seconds: 60);
static const Duration _tokenRefreshThreshold = Duration(minutes: 2);
final http.Client _client = http.Client();
bool _isRefreshing = false;
/// Private constructor - use singleton instance
HttpClient._();
static final HttpClient instance = HttpClient._();
/// Clean headers with JWT token
Map<String, String> _defaultHeaders(String token) => {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
};
/// Ensures valid token with proactive refresh
Future<String?> _getValidToken() async {
String? token = await LocalStorage.getJwtToken();
if (token == null) {
logSafe("No JWT token available", level: LogLevel.error);
await LocalStorage.logout();
return null;
}
try {
if (JwtDecoder.isExpired(token) ||
JwtDecoder.getExpirationDate(token).difference(DateTime.now()) <
_tokenRefreshThreshold) {
logSafe("Token expired/expiring soon. Refreshing...",
level: LogLevel.info);
if (!await _refreshTokenIfPossible()) {
logSafe("Token refresh failed. Logging out.", level: LogLevel.error);
await LocalStorage.logout();
return null;
}
token = await LocalStorage.getJwtToken();
}
} catch (e) {
logSafe("Token validation failed: $e. Logging out.",
level: LogLevel.error);
await LocalStorage.logout();
return null;
}
return token;
}
/// Attempts token refresh with concurrency protection
Future<bool> _refreshTokenIfPossible() async {
if (_isRefreshing) return false;
_isRefreshing = true;
try {
return await AuthService.refreshToken();
} finally {
_isRefreshing = false;
}
}
/// Unified response parser with decryption and validation
dynamic _parseResponse(
http.Response response, {
required String endpoint,
bool fullResponse = false,
}) {
final body = response.body.trim();
if (body.isEmpty &&
response.statusCode >= 200 &&
response.statusCode < 300) {
logSafe("Empty response for $endpoint - returning default structure",
level: LogLevel.info);
return fullResponse ? {'success': true, 'data': []} : [];
}
final decryptedData = decryptResponse(body);
if (decryptedData == null) {
logSafe("❌ Decryption failed for $endpoint", level: LogLevel.error);
return null;
}
final jsonData = decryptedData;
if (response.statusCode >= 200 && response.statusCode < 300) {
if (jsonData is Map && jsonData['success'] == true) {
logSafe("$endpoint: Success (${response.statusCode})",
level: LogLevel.info);
return fullResponse ? jsonData : jsonData['data'];
} else if (jsonData is Map) {
logSafe(
"⚠️ $endpoint: API error - ${jsonData['message'] ?? 'Unknown error'}",
level: LogLevel.warning);
}
}
logSafe("$endpoint: HTTP ${response.statusCode} - $jsonData",
level: LogLevel.error);
return null;
}
/// Generic request executor with 401 retry logic
Future<http.Response?> _execute(
String method,
String endpoint, {
Map<String, String>? queryParams,
Object? body,
Map<String, String>? extraHeaders,
bool hasRetried = false,
}) async {
final token = await _getValidToken();
if (token == null) return null;
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint").replace(
queryParameters:
(method == 'GET' || method == 'DELETE') ? queryParams : null);
final headers = {
..._defaultHeaders(token),
if (extraHeaders != null) ...extraHeaders,
};
final requestBody = body != null ? jsonEncode(body) : null;
logSafe(
"📡 $method $uri${requestBody != null ? ' | Body: ${requestBody.length > 100 ? '${requestBody.substring(0, 100)}...' : requestBody}' : ''}",
level: LogLevel.debug);
try {
final response = switch (method) {
'GET' => await _client.get(uri, headers: headers).timeout(_timeout),
'POST' => await _client
.post(uri, headers: headers, body: requestBody)
.timeout(_timeout),
'PUT' => await _client
.put(uri, headers: headers, body: requestBody)
.timeout(_timeout),
'PATCH' => await _client
.patch(uri, headers: headers, body: requestBody)
.timeout(_timeout),
'DELETE' =>
await _client.delete(uri, headers: headers).timeout(_timeout),
_ => throw HttpException('Unsupported method: $method'),
};
// Handle 401 with single retry
if (response.statusCode == 401 && !hasRetried) {
logSafe("🔄 401 detected for $endpoint - retrying with fresh token",
level: LogLevel.warning);
if (await _refreshTokenIfPossible()) {
return await _execute(method, endpoint,
queryParams: queryParams,
body: body,
extraHeaders: extraHeaders,
hasRetried: true);
}
await LocalStorage.logout();
return null;
}
return response;
} on SocketException catch (e) {
logSafe("🌐 Network error for $endpoint: $e", level: LogLevel.error);
return null;
} catch (e, stackTrace) {
logSafe("💥 HTTP $method error for $endpoint: $e\n$stackTrace",
level: LogLevel.error);
return null;
}
}
// Public API - Clean and consistent
Future<T?> get<T>(
String endpoint, {
Map<String, String>? queryParams,
bool fullResponse = false,
}) async {
final response = await _execute('GET', endpoint, queryParams: queryParams);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
Future<T?> post<T>(
String endpoint,
Object? body, {
bool fullResponse = false,
}) async {
final response = await _execute('POST', endpoint, body: body);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
Future<T?> put<T>(
String endpoint,
Object? body, {
Map<String, String>? extraHeaders,
bool fullResponse = false,
}) async {
final response =
await _execute('PUT', endpoint, body: body, extraHeaders: extraHeaders);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
Future<T?> patch<T>(
String endpoint,
Object? body, {
bool fullResponse = false,
}) async {
final response = await _execute('PATCH', endpoint, body: body);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
Future<T?> delete<T>(
String endpoint, {
Map<String, String>? queryParams,
bool fullResponse = false,
}) async {
final response =
await _execute('DELETE', endpoint, queryParams: queryParams);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
/// Proper cleanup for long-lived instances
void dispose() {
_client.close();
}
}

View File

@ -1,111 +0,0 @@
import 'dart:convert';
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/model/user_permission.dart';
import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/model/projects_model.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart';
class PermissionService {
// In-memory cache keyed by user token
static final Map<String, Map<String, dynamic>> _userDataCache = {};
static const String _baseUrl = ApiEndpoints.baseUrl;
/// Fetches all user-related data (permissions, employee info, projects).
/// Uses in-memory cache for repeated token queries during session.
static Future<Map<String, dynamic>> fetchAllUserData(
String token, {
bool hasRetried = false,
}) async {
logSafe("Fetching user data...");
// Check for cached data before network request
final cached = _userDataCache[token];
if (cached != null) {
logSafe("User data cache hit.");
return cached;
}
final uri = Uri.parse("$_baseUrl/user/profile");
final headers = {'Authorization': 'Bearer $token'};
try {
final response = await http.get(uri, headers: headers);
final statusCode = response.statusCode;
if (statusCode == 200) {
final raw = json.decode(response.body);
final data = raw['data'] as Map<String, dynamic>;
final result = {
'permissions': _parsePermissions(data['featurePermissions']),
'employeeInfo': _parseEmployeeInfo(data['employeeInfo']),
'projects': _parseProjectsInfo(data['projects']),
};
_userDataCache[token] = result; // Cache it for future use
logSafe("User data fetched successfully.");
return result;
}
// Token expired, try refresh once then redirect on failure
if (statusCode == 401 && !hasRetried) {
logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning);
final refreshed = await AuthService.refreshToken();
if (refreshed) {
final newToken = await LocalStorage.getJwtToken();
if (newToken != null && newToken.isNotEmpty) {
return fetchAllUserData(newToken, hasRetried: true);
}
}
await _handleUnauthorized();
logSafe("Token refresh failed. Redirecting to login.", level: LogLevel.warning);
throw Exception('Unauthorized. Token refresh failed.');
}
final errorMsg = json.decode(response.body)['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $errorMsg');
} catch (e, stacktrace) {
logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace);
rethrow; // Let the caller handle or report
}
}
/// Handles unauthorized/user sign out flow
static Future<void> _handleUnauthorized() async {
logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning);
await LocalStorage.removeToken('jwt_token');
await LocalStorage.removeToken('refresh_token');
await LocalStorage.setLoggedInUser(false);
Get.offAllNamed('/auth/login-option');
}
/// Robust model parsing for permissions
static List<UserPermission> _parsePermissions(List<dynamic> permissions) {
logSafe("Parsing user permissions...");
return permissions
.map((perm) => UserPermission.fromJson({'id': perm}))
.toList();
}
/// Robust model parsing for employee info
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic>? data) {
logSafe("Parsing employee info...");
if (data == null) throw Exception("Employee data missing");
return EmployeeInfo.fromJson(data);
}
/// Robust model parsing for projects list
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
logSafe("Parsing projects info...");
if (projects == null) return [];
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
}
}

View File

@ -1,173 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:get/get.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
/// Abstract interface for tenant service functionality
abstract class ITenantService {
Future<List<Map<String, dynamic>>?> getTenants({bool hasRetried = false});
Future<bool> selectTenant(String tenantId, {bool hasRetried = false});
}
/// Tenant API service
class TenantService implements ITenantService {
static const String _baseUrl = ApiEndpoints.baseUrl;
static const Map<String, String> _headers = {
'Content-Type': 'application/json',
};
/// Currently selected tenant
static Tenant? currentTenant;
/// Set the selected tenant
static void setSelectedTenant(Tenant tenant) {
currentTenant = tenant;
}
/// Check if tenant is selected
static bool get isTenantSelected => currentTenant != null;
/// Build authorized headers
static Future<Map<String, String>> _authorizedHeaders() async {
final token = await LocalStorage.getJwtToken();
if (token == null || token.isEmpty) {
throw Exception('Missing JWT token');
}
return {..._headers, 'Authorization': 'Bearer $token'};
}
/// Handle API errors
static void _handleApiError(
http.Response response, dynamic data, String context) {
final message = data['message'] ?? 'Unknown error';
final level =
response.statusCode >= 500 ? LogLevel.error : LogLevel.warning;
logSafe("$context failed: $message [Status: ${response.statusCode}]",
level: level);
}
/// Log exceptions
static void _logException(dynamic e, dynamic st, String context) {
logSafe("$context exception",
level: LogLevel.error, error: e, stackTrace: st);
}
@override
Future<List<Map<String, dynamic>>?> getTenants(
{bool hasRetried = false}) async {
try {
final headers = await _authorizedHeaders();
final response = await http.get(
Uri.parse("$_baseUrl/auth/get/user/tenants"),
headers: headers,
);
// Handle empty response BEFORE decoding
if (response.body.isEmpty || response.body.trim().isEmpty) {
logSafe("❌ Empty tenant response — auto logout");
await LocalStorage.logout();
return null;
}
Map<String, dynamic> data;
try {
data = jsonDecode(response.body);
} catch (e) {
logSafe("❌ Invalid JSON in tenant response — auto logout");
await LocalStorage.logout();
return null;
}
// SUCCESS CASE
if (response.statusCode == 200 && data['success'] == true) {
final list = data['data'];
if (list is! List) return null;
return List<Map<String, dynamic>>.from(list);
}
// TOKEN EXPIRED
if (response.statusCode == 401 && !hasRetried) {
final refreshed = await AuthService.refreshToken();
if (refreshed) return getTenants(hasRetried: true);
return null;
}
_handleApiError(response, data, "Fetching tenants");
return null;
} catch (e, st) {
_logException(e, st, "Get Tenants API");
return null;
}
}
@override
Future<bool> selectTenant(String tenantId, {bool hasRetried = false}) async {
try {
final headers = await _authorizedHeaders();
logSafe(
"➡️ POST $_baseUrl/auth/select-tenant/$tenantId\nHeaders: $headers",
level: LogLevel.info);
final response = await http.post(
Uri.parse("$_baseUrl/auth/select-tenant/$tenantId"),
headers: headers,
);
final data = jsonDecode(response.body);
logSafe(
"⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]",
level: LogLevel.info);
if (response.statusCode == 200 && data['success'] == true) {
await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
logSafe("✅ Tenant selected successfully. Tokens updated.");
// 🔥 Refresh projects when tenant changes
try {
final projectController = Get.find<ProjectController>();
projectController.clearProjects();
projectController.fetchProjects();
} catch (_) {
logSafe("⚠️ ProjectController not found while refreshing projects");
}
// 🔹 Register FCM token after tenant selection
final fcmToken = LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(fcmToken!);
logSafe(
success
? "✅ FCM token registered after tenant selection."
: "⚠️ Failed to register FCM token after tenant selection.",
level: success ? LogLevel.info : LogLevel.warning);
}
return true;
}
if (response.statusCode == 401 && !hasRetried) {
logSafe("⚠️ Unauthorized while selecting tenant. Refreshing token...",
level: LogLevel.warning);
final refreshed = await AuthService.refreshToken();
if (refreshed) return selectTenant(tenantId, hasRetried: true);
logSafe("❌ Token refresh failed while selecting tenant.",
level: LogLevel.error);
return false;
}
_handleApiError(response, data, "Selecting tenant");
return false;
} catch (e, st) {
_logException(e, st, "Select Tenant API");
return false;
}
}
}

View File

@ -0,0 +1,75 @@
import 'dart:convert';
import 'package:encrypt/encrypt.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; // <-- for logging
// 🔑 CONSTANTS
// Base64-encoded 32-byte key (256 bits for AES-256)
const String _keyBase64 = "u4J7p9Qx2hF5vYtLz8Kq3mN1sG0bRwXyZcD6eH8jFQw=";
// IV must be 16 bytes for AES-CBC mode
const int _ivLength = 16;
/// Decrypts a Base64-encoded string that contains the IV prepended to the ciphertext.
/// Returns the decoded JSON object, the plain decrypted string, or null on failure.
dynamic decryptResponse(String encryptedBase64Str) {
try {
// 1 Initialize Key
final rawKeyBytes = base64.decode(_keyBase64);
if (rawKeyBytes.length != 32) {
logSafe("ERROR: Decoded key length is ${rawKeyBytes.length}. Expected 32 bytes for AES-256.", level: LogLevel.error);
throw Exception("Invalid key length.");
}
final key = Key(rawKeyBytes);
// 2 Decode incoming encrypted payload (IV + Ciphertext)
final fullBytes = base64.decode(encryptedBase64Str);
if (fullBytes.length < _ivLength + 16) {
// Minimum length check (16 bytes IV + 1 block of ciphertext, which is 16 bytes)
throw Exception("Encrypted string too short or corrupted.");
}
// 3 Extract IV & Ciphertext
// Assumes the first 16 bytes are the IV
final iv = IV(fullBytes.sublist(0, _ivLength));
final cipherTextBytes = fullBytes.sublist(_ivLength);
// 4 Configure Encrypter with specific parameters
// AES-256 with CBC mode and standard PKCS7 padding
final encrypter = Encrypter(
AES(
key,
mode: AESMode.cbc,
padding: 'PKCS7'
)
);
final encrypted = Encrypted(cipherTextBytes);
// 5 Decrypt - This is where the "Invalid or corrupted pad block" error occurs
final decryptedBytes = encrypter.decryptBytes(encrypted, iv: iv);
final decryptedString = utf8.decode(decryptedBytes);
if (decryptedString.isEmpty) {
throw Exception("Decryption produced empty string (check if padding was correct).");
}
// 🔹 Log decrypted snippet for verification
final snippetLength = decryptedString.length > 50 ? 50 : decryptedString.length;
logSafe(
"Decryption successful. Snippet: ${decryptedString.substring(0, snippetLength)}...",
level: LogLevel.info,
);
// 6 Try parsing JSON
try {
return jsonDecode(decryptedString);
} catch (_) {
// return plain string if it's not JSON
logSafe("Decrypted data is not JSON. Returning plain string.", level: LogLevel.warning);
return decryptedString;
}
} catch (e, st) {
// Catch the specific decryption error (e.g., 'Invalid or corrupted pad block')
logSafe("FATAL Decryption failed: $e", level: LogLevel.error, stackTrace: st);
return null;
}
}

View File

@ -28,9 +28,7 @@ class CollectionsHealthWidget extends StatelessWidget {
return Container( return Container(
decoration: _boxDecoration(), decoration: _boxDecoration(),
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Center( child: const _EmptyDataWidget(), // <-- Use the new empty widget here
child: MyText.bodyMedium('No collection overview data available.'),
),
); );
} }
@ -287,6 +285,71 @@ class CollectionsHealthWidget extends StatelessWidget {
} }
} }
// =====================================================================
// NEW EMPTY DATA WIDGET FOR CollectionsHealthWidget
// =====================================================================
class _EmptyDataWidget extends StatelessWidget {
const _EmptyDataWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// This height is set to resemble the expected height of the chart/metrics content
const double containerHeight = 220;
const double iconSize = 48;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Section
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Collections Health Overview',
fontWeight: 700),
const SizedBox(height: 2),
MyText.bodySmall('View your collection health data.',
color: Colors.grey),
],
),
),
],
),
// Empty Content Area
SizedBox(
height: containerHeight,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
color: Colors.grey.shade400,
size: iconSize,
),
const SizedBox(height: 10),
MyText.bodyMedium(
'No collection overview data available.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
MyText.bodySmall(
'Please check your data source or filters.',
textAlign: TextAlign.center,
color: Colors.grey.shade400,
),
],
),
),
),
],
);
}
}
// ===================================================================== // =====================================================================
// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars) // CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars)
// ===================================================================== // =====================================================================

View File

@ -12,19 +12,39 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final DashboardController controller = Get.find(); final DashboardController controller = Get.find();
// Define the common box decoration for the main card structure
final BoxDecoration cardDecoration = BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
);
// Use Obx to reactively listen to data changes // Use Obx to reactively listen to data changes
return Obx(() { return Obx(() {
final data = controller.purchaseInvoiceOverviewData.value; final data = controller.purchaseInvoiceOverviewData.value;
// Show loading state while API call is in progress // Show loading state while API call is in progress
if (controller.isPurchaseInvoiceLoading.value) { if (controller.isPurchaseInvoiceLoading.value) {
return SkeletonLoaders.purchaseInvoiceDashboardSkeleton(); return Container(
decoration: cardDecoration, // Apply decoration to loading state
padding: const EdgeInsets.all(16.0),
child: SkeletonLoaders.purchaseInvoiceDashboardSkeleton(),
);
} }
// Show empty state if no data // Show empty state if no data
if (data == null || data.totalInvoices == 0) { if (data == null || data.totalInvoices == 0) {
return Center( return Container(
child: MyText.bodySmall('No purchase invoices found.'), decoration: cardDecoration, // Apply decoration to empty state
padding: const EdgeInsets.all(16.0),
child: const _EmptyDataWidget(), // <-- Use the new empty widget
); );
} }
@ -42,27 +62,17 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices); final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
return _buildDashboard(metrics); return _buildDashboard(metrics, cardDecoration);
}); });
} }
Widget _buildDashboard(PurchaseInvoiceMetrics metrics) { Widget _buildDashboard(PurchaseInvoiceMetrics metrics, BoxDecoration decoration) {
const double spacing = 16.0; const double spacing = 16.0;
const double smallSpacing = 8.0; const double smallSpacing = 8.0;
return Container( return Container(
padding: const EdgeInsets.all(spacing), padding: const EdgeInsets.all(spacing),
decoration: BoxDecoration( decoration: decoration, // Use the passed decoration
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -319,6 +329,56 @@ Color getColorForStatus(String status) {
/// REDESIGNED INTERNAL UI WIDGETS /// REDESIGNED INTERNAL UI WIDGETS
/// ======================= /// =======================
// NEW WIDGET: Empty Data Card
class _EmptyDataWidget extends StatelessWidget {
const _EmptyDataWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const double containerHeight = 220;
const double iconSize = 48;
const double spacing = 16.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Section
const _DashboardHeader(),
const SizedBox(height: spacing),
// Empty Content Area
SizedBox(
height: containerHeight,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
color: Colors.grey.shade400,
size: iconSize,
),
const SizedBox(height: 10),
MyText.bodyMedium(
'No purchase invoice data available.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
MyText.bodySmall(
'Please check your data source or filters.',
textAlign: TextAlign.center,
color: Colors.grey.shade400,
),
],
),
),
),
],
);
}
}
class _SectionTitle extends StatelessWidget { class _SectionTitle extends StatelessWidget {
final String title; final String title;

View File

@ -35,19 +35,149 @@ class SkeletonLoaders {
); );
} }
static Widget serviceProjectListSkeletonLoader() {
// --- Start: Configuration to match live UI ---
// Live UI uses ListView.separated with:
// - padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 120)
// - separatorBuilder: MySpacing.height(12)
// - _buildProjectCard uses Card(margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 4))
// To combine:
// Horizontal padding: 8 (ListView) + 6 (Card margin) = 14 on each side.
// Top/Bottom separation: 4 (ListView padding) + 4 (Card margin) = 8
// Separator space: 4 (Card margin) + 12 (Separator) + 4 (Card margin) = 20 total space between cards.
// New ListView.separated padding to compensate for inner Card margins
const EdgeInsets listPadding =
const EdgeInsets.fromLTRB(14, 8, 14, 120 + 4); // 8(L/R) + 6(Card L/R Margin) = 14
// New separator to match the 12 + 4 * 2 = 20 gap.
const Widget cardSeparator = const SizedBox(height: 12);
const EdgeInsets cardMargin = EdgeInsets.zero; // Margin is now controlled by the ListView.separated padding
// Internal Card padding matches the live card
const EdgeInsets cardInnerPadding =
const EdgeInsets.symmetric(horizontal: 18, vertical: 14);
// --- End: Configuration to match live UI ---
return ListView.separated(
padding: listPadding, // Use calculated padding
physics:
const NeverScrollableScrollPhysics(),
itemCount: 4,
separatorBuilder: (_, __) => cardSeparator, // Use calculated separator
itemBuilder: (context, index) {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
margin: cardMargin, // Set margin to zero, handled by ListView padding
shadowColor: Colors.indigo.withOpacity(0.10),
color: Colors.white,
child: ShimmerEffect(
child: Padding(
padding: cardInnerPadding, // Use live card's inner padding
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Title and Status Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Project Name Placeholder
Container(
height: 18, // Matches MyText.titleMedium height approx
width: 150,
color: Colors.grey.shade300,
),
// Status Chip Placeholder
Container(
height: 18, // Matches status chip height approx
width: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
],
),
// MySpacing.height(10) in live UI is the key spacing here
// Note: The live UI has MySpacing.height(4) after the title
// and then MySpacing.height(10) before the first detail row,
// so the total space is 4 + 10 = 14.
MySpacing.height(14),
// 2. Detail Rows (Date, Client, Contact)
// Assigned Date Row
_buildDetailRowSkeleton(
width: 200, iconColor: Colors.teal.shade300),
MySpacing.height(8),
// Client Row
_buildDetailRowSkeleton(
width: 240, iconColor: Colors.indigo.shade300),
MySpacing.height(8),
// Contact Row
_buildDetailRowSkeleton(
width: 220, iconColor: Colors.green.shade300),
MySpacing.height(12), // MySpacing.height(12) before Wrap
// 3. Service Chips Wrap
Wrap(
spacing: 6,
runSpacing: 4,
children: List.generate(
3,
(chipIndex) => Container(
height: 20,
width:
70 + (chipIndex * 10).toDouble(), // Varied widths
decoration: BoxDecoration(
color: Colors
.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
),
),
],
),
),
),
);
},
);
}
/// Helper to build a skeleton row for details
static Widget _buildDetailRowSkeleton({
required double width,
required Color iconColor,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Icon Placeholder (size 18 matches live UI)
Icon(Icons.circle, size: 18, color: iconColor),
MySpacing.width(8),
// Text Placeholder (height 13 approx for font size 13)
Container(
height: 14,
width: width,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
],
);
}
static Widget attendanceQuickCardSkeleton() { static Widget attendanceQuickCardSkeleton() {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
gradient: LinearGradient( // ... gradient color setup (using grey for shimmer)
colors: [
Colors.grey.shade300.withOpacity(0.3),
Colors.grey.shade300.withOpacity(0.6),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
), ),
child: ShimmerEffect( child: ShimmerEffect(
child: Column( child: Column(
@ -56,78 +186,67 @@ class SkeletonLoaders {
// Row with avatar and texts // Row with avatar and texts
Row( Row(
children: [ children: [
// Avatar // Avatar (Size 30)
Container( Container(
width: 30, width: 30,
height: 30, height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade400, color: Colors.grey.shade400, shape: BoxShape.circle)),
shape: BoxShape.circle,
),
),
MySpacing.width(10), MySpacing.width(10),
// Name + designation // Name + designation (Approximate heights for MyText.titleSmall and MyText.labelSmall)
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
height: 12, height: 12, width: 100, color: Colors.grey.shade400),
width: 100, MySpacing.height(
color: Colors.grey.shade400, 4), // Reduced from 6, guessing labelSmall is shorter
),
MySpacing.height(6),
Container( Container(
height: 10, height: 10, width: 70, color: Colors.grey.shade400),
width: 70,
color: Colors.grey.shade400,
),
], ],
), ),
), ),
// Status // Status (MyText.bodySmall, height approx 12-14)
Container( Container(
height: 12, height: 14,
width: 60, width: 80,
color: Colors.grey.shade400, color: Colors
), .grey.shade400), // Adjusted width and height slightly
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Description // Description (2 lines of Text, font size 13)
Container( Container(
height: 10, height: 14,
width: double.infinity, width: double.infinity,
color: Colors.grey.shade400, color: Colors.grey
), .shade400), // Height for one line of text size 13 + padding
MySpacing.height(6), MySpacing.height(6),
Container( Container(
height: 10, height: 14,
width: double.infinity, width: double.infinity * 0.7,
color: Colors.grey.shade400, color: Colors.grey.shade400), // Shorter second line
),
const SizedBox(height: 12), const SizedBox(height: 12),
// Action buttons // Action buttons (Row at the end)
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
// Check In/Out Button (Approx height 28)
Container( Container(
height: 28, height: 32,
width: 80, width: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade400, color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(4), borderRadius:
), BorderRadius.circular(6))), // Larger button size
),
MySpacing.width(8), MySpacing.width(8),
// Log View Button (Icon Button, approx size 28-32)
Container( Container(
height: 28, height: 32,
width: 28, width: 32,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade400, color: Colors.grey.shade400, shape: BoxShape.circle)),
shape: BoxShape.circle,
),
),
], ],
), ),
], ],
@ -139,46 +258,148 @@ class SkeletonLoaders {
static Widget dashboardCardsSkeleton({double? maxWidth}) { static Widget dashboardCardsSkeleton({double? maxWidth}) {
return LayoutBuilder(builder: (context, constraints) { return LayoutBuilder(builder: (context, constraints) {
double width = maxWidth ?? constraints.maxWidth; double width = maxWidth ?? constraints.maxWidth;
int crossAxisCount = (width ~/ 80).clamp(2, 4); double crossAxisSpacing = 15;
double cardWidth = (width - (crossAxisCount - 1) * 6) / crossAxisCount; int crossAxisCount = 3;
return Wrap( // Calculation remains the same: screen_width - (spacing * (count - 1)) / count
spacing: 6, double totalHorizontalSpace =
runSpacing: 6, width - (crossAxisSpacing * (crossAxisCount - 1));
children: List.generate(6, (index) { double cardWidth = totalHorizontalSpace / crossAxisCount;
// Dynamic height calculation: width / 1.8 (e.g., 92.0 / 1.8 = 51.11, not 46.7)
// Rerunning the calculation based on the constraint h=46.7 given in the error:
// If cardWidth = 92.0, the aspect ratio must be different, or the parent widget
// is forcing a smaller height. To fix the overflow, we must assume the target
// height is fixed by the aspect ratio and reduce the inner content size.
double cardHeight = cardWidth / 1.8;
// Inner available vertical space (cardHeight - 2 * paddingAll):
// If cardHeight is 51.11, inner space is 51.11 - 8 = 43.11.
// If cardHeight is 46.7 (as per error constraint), inner space is 46.7 - 8 = 38.7.
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Skeleton for the "Modules" title (fontSize 16, fontWeight 700)
Container(
margin: const EdgeInsets.only(left: 4, bottom: 8),
height: 18,
width: 80,
color: Colors.grey.shade300),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: crossAxisSpacing,
mainAxisSpacing: 8,
childAspectRatio: 1.8,
),
itemCount: 6,
itemBuilder: (context, index) {
return MyCard.bordered( return MyCard.bordered(
width: cardWidth, width: cardWidth,
height: 60, height: cardHeight,
paddingAll: 4, paddingAll: 4,
borderRadiusAll: 5, borderRadiusAll: 10,
border: Border.all(color: Colors.grey.withOpacity(0.15)), border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: ShimmerEffect( child: ShimmerEffect(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Icon placeholder: Reduced size to 16
Container( Container(
width: 16, width: 16,
height: 16, height: 16, // Reduced from 20
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
MySpacing.height(4), MySpacing.height(4), // Reduced spacing from 6
Container( // Text placeholder 1: Reduced height to 8
width: cardWidth * 0.5, Padding(
height: 10, padding: const EdgeInsets.symmetric(horizontal: 2),
child: Container(
width: cardWidth * 0.7,
height: 8, // Reduced from 10
color: Colors.grey.shade300, color: Colors.grey.shade300,
), ),
),
MySpacing.height(2), // Reduced spacing from 4
// Text placeholder 2: Reduced height to 8
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Container(
width: cardWidth * 0.5,
height: 8, // Reduced from 10
color: Colors.grey.shade300,
),
),
// Total inner height is now 16 + 4 + 8 + 2 + 8 = 38 pixels.
// This will fit safely within the calculated or constrained height.
], ],
), ),
), ),
); );
}), },
),
],
); );
}); });
} }
static Widget projectSelectorSkeleton() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Title Skeleton
Container(
margin: const EdgeInsets.only(left: 4, bottom: 8),
height: 18, // For _sectionTitle
width: 80,
color: Colors.grey.shade300,
),
// Selector Card Skeleton
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border:
Border.all(color: Colors.grey.shade300), // Placeholder border
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(.04),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ShimmerEffect(
child: Row(
children: [
// Icon placeholder
Container(width: 20, height: 20, color: Colors.grey.shade300),
const SizedBox(width: 12),
// Text placeholder
Expanded(
child: Container(
height: 16,
width: double.infinity,
color: Colors.grey.shade300),
),
// Arrow icon placeholder
Container(width: 26, height: 26, color: Colors.grey.shade300),
],
),
),
),
],
);
}
static Widget paymentRequestListSkeletonLoader() { static Widget paymentRequestListSkeletonLoader() {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
@ -1845,42 +2066,29 @@ class SkeletonLoaders {
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
// Legend/Details Placeholder // Legend/Details Placeholder
Expanded( // Aging Legend Placeholders
child: Column( Wrap(
crossAxisAlignment: CrossAxisAlignment.start, spacing: 12,
runSpacing: 8,
children: List.generate( children: List.generate(
3, 4,
(index) => Padding( (index) => Row(
padding: const EdgeInsets.only(bottom: 8.0), mainAxisSize: MainAxisSize.min,
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [ children: [
Container( Container(
width: 8, width: 10,
height: 8, height: 10,
margin:
const EdgeInsets.only(right: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
shape: BoxShape.circle)), shape: BoxShape.circle)),
const SizedBox(width: 6),
Container( Container(
height: 12, height: 12,
width: 80, width: 115, // Reduced from 120
color: Colors.grey.shade300), color: Colors.grey.shade300),
], ],
),
Container(
height: 14,
width: 50,
color: Colors.grey.shade300),
],
),
)), )),
), ),
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart';
import 'package:on_field_work/view/auth/forgot_password_screen.dart'; import 'package:on_field_work/view/auth/forgot_password_screen.dart';
import 'package:on_field_work/view/auth/login_screen.dart'; import 'package:on_field_work/view/auth/login_screen.dart';
import 'package:on_field_work/view/auth/register_account_screen.dart'; import 'package:on_field_work/view/auth/register_account_screen.dart';
@ -32,7 +31,7 @@ class AuthMiddleware extends GetMiddleware {
if (route != '/auth/login-option') { if (route != '/auth/login-option') {
return const RouteSettings(name: '/auth/login-option'); return const RouteSettings(name: '/auth/login-option');
} }
} else if (!TenantService.isTenantSelected) { } else if (!AuthService.isTenantSelected) {
if (route != '/select-tenant') { if (route != '/select-tenant') {
return const RouteSettings(name: '/select-tenant'); return const RouteSettings(name: '/select-tenant');
} }

View File

@ -413,9 +413,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final String? selectedId = projectController.selectedProjectId.value; final String? selectedId = projectController.selectedProjectId.value;
if (isLoading) { if (isLoading) {
return SkeletonLoaders.dashboardCardsSkeleton( // Use the new specialized skeleton
maxWidth: MediaQuery.of(context).size.width, return SkeletonLoaders.projectSelectorSkeleton();
);
} }
return Column( return Column(

View File

@ -11,14 +11,12 @@ import 'package:on_field_work/model/expense/comment_bottom_sheet.dart';
import 'package:on_field_work/model/expense/expense_detail_model.dart'; import 'package:on_field_work/model/expense/expense_detail_model.dart';
import 'package:on_field_work/model/expense/reimbursement_bottom_sheet.dart'; import 'package:on_field_work/model/expense/reimbursement_bottom_sheet.dart';
import 'package:on_field_work/controller/expense/add_expense_controller.dart'; import 'package:on_field_work/controller/expense/add_expense_controller.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart'; import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/model/employees/employee_info.dart'; import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:timeline_tile/timeline_tile.dart'; import 'package:timeline_tile/timeline_tile.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
@ -37,15 +35,14 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final permissionController = Get.put(PermissionController()); final permissionController = Get.put(PermissionController());
EmployeeInfo? employeeInfo; // Removed local employeeInfo, canSubmit, and _checkedPermission
final RxBool canSubmit = false.obs;
bool _checkedPermission = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = Get.put(ExpenseDetailController(), tag: widget.expenseId); controller = Get.put(ExpenseDetailController(), tag: widget.expenseId);
// EmployeeInfo loading and permission checking is now handled inside controller.init()
controller.init(widget.expenseId); controller.init(widget.expenseId);
_loadEmployeeInfo();
} }
@override @override
@ -54,32 +51,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
super.dispose(); super.dispose();
} }
void _loadEmployeeInfo() async { // Removed _loadEmployeeInfo and _checkPermissionToSubmit
final info = await LocalStorage.getEmployeeInfo();
employeeInfo = info;
}
void _checkPermissionToSubmit(ExpenseDetailModel expense) {
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id;
final nextStatusIds = expense.nextStatus.map((e) => e.id).toList();
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
logSafe(
'🐛 Checking submit permission:\n'
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
'🐛 - Expense created by ID: ${expense.createdBy.id}\n'
'🐛 - Next Status IDs: $nextStatusIds\n'
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
'🐛 - Final Permission Result: $result',
level: LogLevel.debug,
);
canSubmit.value = result;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -119,9 +91,7 @@ Widget build(BuildContext context) {
return Center(child: MyText.bodyMedium("No data to display.")); return Center(child: MyText.bodyMedium("No data to display."));
} }
WidgetsBinding.instance.addPostFrameCallback((_) { // Permission logic moved to controller (no need for postFrameCallback here)
_checkPermissionToSubmit(expense);
});
final statusColor = getExpenseStatusColor( final statusColor = getExpenseStatusColor(
expense.status.name, expense.status.name,
@ -135,8 +105,7 @@ Widget build(BuildContext context) {
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom 12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
),
child: Center( child: Center(
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
@ -162,9 +131,11 @@ Widget build(BuildContext context) {
Row( Row(
children: [ children: [
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium('Amount', fontWeight: 600), MyText.bodyMedium('Amount',
fontWeight: 600),
const SizedBox(height: 4), const SizedBox(height: 4),
MyText.bodyLarge( MyText.bodyLarge(
formattedAmount, formattedAmount,
@ -223,21 +194,16 @@ Widget build(BuildContext context) {
], ],
), ),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value; final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) { if (controller.errorMessage.isNotEmpty || expense == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (!_checkedPermission) { // Removed _checkedPermission and its logic
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
}
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) { if (!ExpensePermissionHelper.canEditExpense(
controller.employeeInfo, // Use controller's employeeInfo
expense)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@ -305,7 +271,7 @@ Widget build(BuildContext context) {
final isSubmitStatus = next.id == submitStatusId; final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser = final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id; controller.employeeInfo?.id == expense.createdBy.id; // Use controller's employeeInfo
if (isSubmitStatus) return isCreatedByCurrentUser; if (isSubmitStatus) return isCreatedByCurrentUser;
return permissionController.hasAnyPermission(parsedPermissions); return permissionController.hasAnyPermission(parsedPermissions);
@ -319,7 +285,6 @@ Widget build(BuildContext context) {
); );
} }
Widget _statusButton(BuildContext context, ExpenseDetailController controller, Widget _statusButton(BuildContext context, ExpenseDetailController controller,
ExpenseDetailModel expense, dynamic next) { ExpenseDetailModel expense, dynamic next) {
Color primary = Colors.red; Color primary = Colors.red;
@ -346,7 +311,8 @@ Widget build(BuildContext context) {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(5))), borderRadius:
BorderRadius.vertical(top: Radius.circular(5))),
builder: (context) => ReimbursementBottomSheet( builder: (context) => ReimbursementBottomSheet(
expenseId: expense.id, expenseId: expense.id,
statusId: next.id, statusId: next.id,

View File

@ -11,6 +11,7 @@ import 'package:on_field_work/model/infra_project/infra_project_list.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/view/infraProject/infra_project_details_screen.dart'; import 'package:on_field_work/view/infraProject/infra_project_details_screen.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class InfraProjectScreen extends StatefulWidget { class InfraProjectScreen extends StatefulWidget {
const InfraProjectScreen({super.key}); const InfraProjectScreen({super.key});
@ -245,7 +246,7 @@ class _InfraProjectScreenState extends State<InfraProjectScreen> with UIMixin {
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return Center(child: SkeletonLoaders.serviceProjectListSkeletonLoader());
} }
final projects = controller.filteredProjects; final projects = controller.filteredProjects;

View File

@ -8,7 +8,7 @@ import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart'; import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/images.dart'; import 'package:on_field_work/images.dart';
import 'package:on_field_work/view/layouts/user_profile_right_bar.dart'; import 'package:on_field_work/view/layouts/user_profile_right_bar.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class Layout extends StatefulWidget { class Layout extends StatefulWidget {
@ -106,7 +106,7 @@ class _LayoutState extends State<Layout> with UIMixin {
} }
Widget _buildHeaderContent(bool isMobile) { Widget _buildHeaderContent(bool isMobile) {
final selectedTenant = TenantService.currentTenant; final selectedTenant = AuthService.currentTenant;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0), padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),

View File

@ -11,7 +11,7 @@ import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/model/employees/employee_info.dart'; import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/controller/auth/mpin_controller.dart'; import 'package:on_field_work/controller/auth/mpin_controller.dart';
import 'package:on_field_work/view/employees/employee_profile_screen.dart'; import 'package:on_field_work/view/employees/employee_profile_screen.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/view/tenant/tenant_selection_screen.dart'; import 'package:on_field_work/view/tenant/tenant_selection_screen.dart';
import 'package:on_field_work/controller/tenant/tenant_switch_controller.dart'; import 'package:on_field_work/controller/tenant/tenant_switch_controller.dart';
import 'package:on_field_work/helpers/theme/theme_editor_widget.dart'; import 'package:on_field_work/helpers/theme/theme_editor_widget.dart';
@ -285,7 +285,7 @@ class _UserProfileBarState extends State<UserProfileBar>
); );
} }
final selectedTenant = TenantService.currentTenant; final selectedTenant = AuthService.currentTenant;
final sortedTenants = List.of(tenants); final sortedTenants = List.of(tenants);
if (selectedTenant != null) { if (selectedTenant != null) {

View File

@ -14,6 +14,8 @@ import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/view/service_project/jobs_tab.dart'; import 'package:on_field_work/view/service_project/jobs_tab.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart'; import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class ServiceProjectDetailsScreen extends StatefulWidget { class ServiceProjectDetailsScreen extends StatefulWidget {
final String projectId; final String projectId;
@ -332,7 +334,8 @@ class _ServiceProjectDetailsScreenState
Widget _buildTeamsTab() { Widget _buildTeamsTab() {
return Obx(() { return Obx(() {
if (controller.isTeamLoading.value) { if (controller.isTeamLoading.value) {
return const Center(child: CircularProgressIndicator()); return Center(
child: SkeletonLoaders.serviceProjectListSkeletonLoader());
} }
if (controller.teamErrorMessage.value.isNotEmpty && if (controller.teamErrorMessage.value.isNotEmpty &&
@ -385,7 +388,14 @@ class _ServiceProjectDetailsScreenState
const Divider(height: 20, thickness: 1), const Divider(height: 20, thickness: 1),
// List of team members inside this role card // List of team members inside this role card
...teamMembers.map((team) { ...teamMembers.map((team) {
return Padding( return InkWell(
onTap: () {
// NAVIGATION TO EMPLOYEE DETAILS SCREEN
Get.to(() => EmployeeProfilePage(
employeeId: team.employee.id,
));
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Row(
children: [ children: [
@ -416,6 +426,7 @@ class _ServiceProjectDetailsScreenState
), ),
], ],
), ),
),
); );
}).toList(), }).toList(),
], ],
@ -475,7 +486,9 @@ class _ServiceProjectDetailsScreenState
child: Obx(() { child: Obx(() {
if (controller.isLoading.value && if (controller.isLoading.value &&
controller.projectDetail.value == null) { controller.projectDetail.value == null) {
return const Center(child: CircularProgressIndicator()); return Center(
child: SkeletonLoaders
.serviceProjectListSkeletonLoader());
} }
if (controller.errorMessage.value.isNotEmpty && if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) { controller.projectDetail.value == null) {

View File

@ -9,6 +9,7 @@ import 'package:on_field_work/model/service_project/service_projects_list_model.
import 'package:on_field_work/helpers/utils/date_time_utils.dart'; import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/view/service_project/service_project_details_screen.dart'; import 'package:on_field_work/view/service_project/service_project_details_screen.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class ServiceProjectScreen extends StatefulWidget { class ServiceProjectScreen extends StatefulWidget {
const ServiceProjectScreen({super.key}); const ServiceProjectScreen({super.key});
@ -264,7 +265,9 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return Center(
child: SkeletonLoaders
.serviceProjectListSkeletonLoader());
} }
final projects = controller.filteredProjects; final projects = controller.filteredProjects;

View File

@ -29,6 +29,9 @@ class _SplashScreenState extends State<SplashScreen>
// Animation for logo and text fade-in // Animation for logo and text fade-in
late Animation<double> _opacityAnimation; late Animation<double> _opacityAnimation;
// Animation for the gradient shimmer effect (moves from -1.0 to 2.0)
late Animation<double> _shimmerAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -39,7 +42,7 @@ class _SplashScreenState extends State<SplashScreen>
vsync: this, vsync: this,
); );
// Initial scale-in: from 0.0 to 1.0 (happens in the first 40% of the duration) // Initial scale-in: from 0.5 to 1.0 (happens in the first 40% of the duration)
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate( _scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation( CurvedAnimation(
parent: _controller, parent: _controller,
@ -56,7 +59,16 @@ class _SplashScreenState extends State<SplashScreen>
), ),
); );
// Floating effect: from 0.0 to 1.0 (loops repeatedly after initial animations) // Shimmer/Gradient Animation: Moves the gradient horizontally from left to right
_shimmerAnimation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 1.0, curve: Curves.linear),
),
);
// Floating effect: from -8.0 to 8.0 (loops repeatedly after initial animations)
_floatAnimation = Tween<double>(begin: -8.0, end: 8.0).animate( _floatAnimation = Tween<double>(begin: -8.0, end: 8.0).animate(
CurvedAnimation( CurvedAnimation(
parent: _controller, parent: _controller,
@ -66,10 +78,10 @@ class _SplashScreenState extends State<SplashScreen>
// Start the complex animation sequence // Start the complex animation sequence
_controller.forward().then((_) { _controller.forward().then((_) {
// After the initial scale/fade, switch to repeating the float animation // After the initial scale/fade, switch to repeating the float and shimmer animation
if (mounted) { if (mounted) {
_controller.repeat( _controller.repeat(
min: 0.4, // Start repeat from the float interval min: 0.4, // Keep repeat range for float animation
max: 1.0, max: 1.0,
reverse: true, reverse: true,
); );
@ -83,6 +95,64 @@ class _SplashScreenState extends State<SplashScreen>
super.dispose(); super.dispose();
} }
// Widget for the multi-colored text with shimmering effect only on '.com'
Widget _buildAnimatedDomainText() {
const textStyle = TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: 'Roboto', // Use a clear, modern font
);
// The Shimmer Effect: AnimatedBuilder rebuilds the widget as the shimmerAnimation updates
return AnimatedBuilder(
animation: _shimmerAnimation,
builder: (context, child) {
// Define the silver gradient
final shimmerGradient = LinearGradient(
colors: const [
Colors.grey, // Starting dull color
Colors.white, // Brightest 'shimmer' highlight
Colors.grey, // Ending dull color
],
stops: const [0.3, 0.5, 0.7], // Position of colors
// The begin/end points move based on the animation value
begin: Alignment(_shimmerAnimation.value - 1.0, 0.0), // Start from left
end: Alignment(_shimmerAnimation.value, 0.0), // End to right
);
// The Text Content: RichText allows for different styles within one text block
return RichText(
text: TextSpan(
style: textStyle.copyWith(color: Colors.black), // Base style
children: <TextSpan>[
// 'On' - Blue color
TextSpan(
text: 'On',
style: TextStyle(color: Colors.blueAccent.shade700),
),
// 'FieldWork' - Green color
TextSpan(
text: 'FieldWork',
style: TextStyle(color: Colors.green.shade700),
),
// '.com' - The part that uses the animated gradient
TextSpan(
text: '.com',
style: textStyle.copyWith(
// Use a Paint()..shader to apply the gradient to the text color
// The Rect size (150.0 x 50.0) must be large enough to cover the '.com' text
foreground: Paint()..shader = shimmerGradient.createShader(
const Rect.fromLTWH(0.0, 0.0, 150.0, 50.0),
),
),
),
],
),
);
},
);
}
// A simple, modern custom progress indicator // A simple, modern custom progress indicator
Widget _buildProgressIndicator() { Widget _buildProgressIndicator() {
return SizedBox( return SizedBox(
@ -98,7 +168,6 @@ class _SplashScreenState extends State<SplashScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: widget.backgroundColor, backgroundColor: widget.backgroundColor,
// Full screen display, no SafeArea needed for a full bleed splash
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -127,6 +196,14 @@ class _SplashScreenState extends State<SplashScreen>
const SizedBox(height: 30), const SizedBox(height: 30),
// **Corrected: Animated Domain Text with specific colors and only '.com' shimmering**
FadeTransition(
opacity: _opacityAnimation,
child: _buildAnimatedDomainText(),
),
const SizedBox(height: 10), // Small space between new text and message
// Text Message (Fades in slightly after logo) // Text Message (Fades in slightly after logo)
if (widget.message != null) if (widget.message != null)
FadeTransition( FadeTransition(

View File

@ -84,7 +84,24 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
Padding( Padding(
padding: MySpacing.x(10), padding: MySpacing.x(10),
child: ServiceSelector( child: Obx(() {
// 1. Check if services are loading or empty
if (serviceController.isLoadingServices.value) {
return ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
// Empty handler when loading
},
);
}
if (serviceController.services.isEmpty) {
return const _EmptyServiceWidget();
}
// 2. Display ServiceSelector if services are available
return ServiceSelector(
controller: serviceController, controller: serviceController,
height: 40, height: 40,
onSelectionChanged: (service) async { onSelectionChanged: (service) async {
@ -97,7 +114,8 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
); );
} }
}, },
), );
}),
), ),
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
Padding( Padding(
@ -126,12 +144,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly(); return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly();
} }
// Check 1: If no daily tasks are fetched at all
if (dailyTasks.isEmpty) { if (dailyTasks.isEmpty) {
return Center( return const _EmptyDataCard(
child: MyText.bodySmall( title: "No Daily Tasks Found",
"No Progress Report Found", subtitle: "No progress reports are planned for the selected filter.",
fontWeight: 600,
),
); );
} }
@ -164,11 +181,10 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
.toList(); .toList();
if (buildings.isEmpty) { if (buildings.isEmpty) {
return Center( return const _EmptyDataCard(
child: MyText.bodySmall( title: "No Progress Report Found",
"No Progress Report Found", subtitle:
fontWeight: 600, "No work is planned or completed for the selected service/project.",
),
); );
} }
@ -247,11 +263,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
.dailyProgressPlanningInfraSkeleton(), .dailyProgressPlanningInfraSkeleton(),
) )
else if (!buildingLoaded || building.floors.isEmpty) else if (!buildingLoaded || building.floors.isEmpty)
Padding( const Padding(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: MyText.bodySmall( child: _EmptyDataMessage(
"No Progress Report Found for this Project", message:
fontWeight: 600, "No floors or work data found for this building.",
), ),
) )
else else
@ -473,7 +489,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
), ),
], ],
), ),
MySpacing.height(6), MySpacing.height(4),
Row( Row(
children: [ children: [
MyText.bodySmall( MyText.bodySmall(
@ -538,3 +554,80 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
}); });
} }
} }
// =====================================================================
// NEW EMPTY DATA WIDGETS
// =====================================================================
class _EmptyDataMessage extends StatelessWidget {
final String message;
const _EmptyDataMessage({required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: MyText.bodySmall(
message,
fontWeight: 600,
color: Colors.grey.shade500,
textAlign: TextAlign.center,
),
);
}
}
class _EmptyServiceWidget extends StatelessWidget {
const _EmptyServiceWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Center(
child: MyText.bodyMedium(
'No services found for this project.',
fontWeight: 700,
color: Colors.grey.shade600,
),
),
);
}
}
class _EmptyDataCard extends StatelessWidget {
final String title;
final String subtitle;
const _EmptyDataCard({required this.title, required this.subtitle});
@override
Widget build(BuildContext context) {
return MyCard.bordered(
paddingAll: 16,
borderRadiusAll: 10,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.task_alt_outlined,
size: 48,
color: Colors.grey.shade400,
),
MySpacing.height(12),
MyText.titleMedium(
title,
fontWeight: 700,
color: Colors.grey.shade700,
textAlign: TextAlign.center,
),
MySpacing.height(4),
MyText.bodySmall(
subtitle,
color: Colors.grey.shade500,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@ -86,6 +86,7 @@ dependencies:
gallery_saver_plus: ^3.2.9 gallery_saver_plus: ^3.2.9
share_plus: ^12.0.1 share_plus: ^12.0.1
timeline_tile: ^2.0.0 timeline_tile: ^2.0.0
encrypt: ^5.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: