Compare commits
12 Commits
main
...
Decription
| Author | SHA1 | Date | |
|---|---|---|---|
| fbfc54159c | |||
| 7ce0a8555a | |||
| 3603b12f9c | |||
| b907e76c12 | |||
| 406ab30dba | |||
| 18cb0068e6 | |||
| 307b3ceb96 | |||
| c96aa42e81 | |||
| b2205c18f4 | |||
| f937bd849f | |||
| 28fbc2ad29 | |||
| b1741bbb0c |
@ -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
|
||||||
|
|||||||
@ -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.");
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
4051
lib/helpers/services/api_service copy.dart
Normal file
4051
lib/helpers/services/api_service copy.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
255
lib/helpers/services/http_client.dart
Normal file
255
lib/helpers/services/http_client.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
75
lib/helpers/utils/encryption_helper.dart
Normal file
75
lib/helpers/utils/encryption_helper.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user