Compare commits
56 Commits
main
...
Manish_Dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f0a7d97ec8 | |||
| a69246b11e | |||
| f305f7ff41 | |||
| 73bd5cdc92 | |||
| 48a4eb2ca7 | |||
| 2e1b3065df | |||
| 42c34ad26a | |||
| 6b1a64f3b1 | |||
| 74377288eb | |||
| 415f83f877 | |||
| 4d012b78f7 | |||
| 8155468cd4 | |||
| 874c2cff4d | |||
| a95e0be48b | |||
| c9f4795de6 | |||
| a6743cfd9b | |||
| b96ed51da2 | |||
| 7da7fadbc9 | |||
| fe5cae9889 | |||
| 55f36fac6d | |||
| 81de795e93 | |||
| 4e577bd7eb | |||
| 0ac8998c59 | |||
| c45cb6158e | |||
| b2e3398bb1 | |||
| 2aa6d13a71 | |||
| e2fc81ba0e | |||
| 57634d7bd2 | |||
| 03082aeea9 | |||
| f6f0cd6790 | |||
| 5f1693869d | |||
| d4c7eae981 | |||
| 9389e081c9 | |||
| 6cd9fbe57b | |||
| 0e79aa4793 | |||
| f0f1ff1a5c | |||
| 0fa5a85d79 | |||
| 5c6c6289cd | |||
| fc099cccb5 | |||
| 22b61b7024 | |||
| e8fd420d51 | |||
| 1279b0e00f | |||
| 03a3c1e06c | |||
| 1bf676f64a | |||
| fbfc54159c | |||
| 7ce0a8555a | |||
| 3603b12f9c | |||
| b907e76c12 | |||
| 406ab30dba | |||
| 18cb0068e6 | |||
| 307b3ceb96 | |||
| c96aa42e81 | |||
| b2205c18f4 | |||
| f937bd849f | |||
| 28fbc2ad29 | |||
| b1741bbb0c |
@ -39,7 +39,7 @@ android {
|
||||
// Specify your unique Application ID. This identifies your app on Google Play.
|
||||
applicationId = "com.marcoonfieldwork.aiot"
|
||||
// Set minimum and target SDK versions based on Flutter's configuration
|
||||
minSdk = 23
|
||||
minSdkVersion = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
// Set version code and name based on Flutter's configuration (from pubspec.yaml)
|
||||
versionCode = flutter.versionCode
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
android:label="On Field Work"
|
||||
android:label="OnFieldWork.com"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.6.0" apply false
|
||||
id "com.android.application" version "8.9.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.2.21" apply false
|
||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# App info
|
||||
APP_NAME="On Field Work"
|
||||
APP_NAME="OnFieldWork.com"
|
||||
BUILD_DIR="build/app/outputs"
|
||||
|
||||
echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}"
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>On Field Work</string>
|
||||
<string>OnFieldWork.com</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
||||
@ -8,5 +8,5 @@ class AppConstant {
|
||||
static int iOSAppVersion = 1;
|
||||
static String version = "1.0.0";
|
||||
|
||||
static String get appName => 'On Field Work';
|
||||
static String get appName => 'OnFieldWork.com';
|
||||
}
|
||||
|
||||
@ -12,26 +12,21 @@ import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
|
||||
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
|
||||
|
||||
class DashboardController extends GetxController {
|
||||
// Dependencies
|
||||
final ProjectController projectController = Get.put(ProjectController());
|
||||
|
||||
// =========================
|
||||
// 1. STATE VARIABLES
|
||||
// =========================
|
||||
|
||||
// Attendance
|
||||
// --------------------------
|
||||
// STATE VARIABLES
|
||||
// --------------------------
|
||||
final roleWiseData = <Map<String, dynamic>>[].obs;
|
||||
final attendanceSelectedRange = '15D'.obs;
|
||||
final attendanceIsChartView = true.obs;
|
||||
final isAttendanceLoading = false.obs;
|
||||
|
||||
// Project Progress
|
||||
final projectChartData = <ChartTaskData>[].obs;
|
||||
final projectSelectedRange = '15D'.obs;
|
||||
final projectIsChartView = true.obs;
|
||||
final isProjectLoading = false.obs;
|
||||
|
||||
// Overview Counts
|
||||
final totalProjects = 0.obs;
|
||||
final ongoingProjects = 0.obs;
|
||||
final isProjectsLoading = false.obs;
|
||||
@ -44,12 +39,12 @@ class DashboardController extends GetxController {
|
||||
final inToday = 0.obs;
|
||||
final isTeamsLoading = false.obs;
|
||||
|
||||
// Expenses & Reports
|
||||
final isPendingExpensesLoading = false.obs;
|
||||
final pendingExpensesData = Rx<PendingExpensesData?>(null);
|
||||
|
||||
final isExpenseTypeReportLoading = false.obs;
|
||||
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
|
||||
|
||||
final expenseReportStartDate =
|
||||
DateTime.now().subtract(const Duration(days: 15)).obs;
|
||||
final expenseReportEndDate = DateTime.now().obs;
|
||||
@ -63,21 +58,17 @@ class DashboardController extends GetxController {
|
||||
final expenseTypes = <ExpenseTypeModel>[].obs;
|
||||
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
||||
|
||||
// Teams/Employees
|
||||
final isLoadingEmployees = true.obs;
|
||||
final employees = <EmployeeModel>[].obs;
|
||||
final uploadingStates = <String, RxBool>{}.obs;
|
||||
|
||||
// Collection
|
||||
final isCollectionOverviewLoading = true.obs;
|
||||
final collectionOverviewData = Rx<CollectionOverviewData?>(null);
|
||||
// =========================
|
||||
// Purchase Invoice Overview
|
||||
// =========================
|
||||
|
||||
final isPurchaseInvoiceLoading = true.obs;
|
||||
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
|
||||
// Constants
|
||||
final List<String> ranges = ['7D', '15D', '30D'];
|
||||
|
||||
final List<String> ranges = const ['7D', '15D', '30D'];
|
||||
static const _rangeDaysMap = {
|
||||
'7D': 7,
|
||||
'15D': 15,
|
||||
@ -86,19 +77,22 @@ class DashboardController extends GetxController {
|
||||
'6M': 180
|
||||
};
|
||||
|
||||
// =========================
|
||||
// 2. COMPUTED PROPERTIES
|
||||
// =========================
|
||||
|
||||
int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7;
|
||||
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7;
|
||||
|
||||
// DSO Calculation Constants
|
||||
static const double _w0_30 = 15.0;
|
||||
static const double _w30_60 = 45.0;
|
||||
static const double _w60_90 = 75.0;
|
||||
static const double _w90_plus = 105.0;
|
||||
|
||||
// --------------------------
|
||||
// LATEST PROJECT ID (for race condition fix)
|
||||
// --------------------------
|
||||
String _latestProjectId = '';
|
||||
|
||||
// --------------------------
|
||||
// COMPUTED PROPERTIES
|
||||
// --------------------------
|
||||
int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7;
|
||||
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7;
|
||||
|
||||
double get calculatedDSO {
|
||||
final data = collectionOverviewData.value;
|
||||
if (data == null || data.totalDueAmount == 0) return 0.0;
|
||||
@ -111,44 +105,46 @@ class DashboardController extends GetxController {
|
||||
return weightedDue / data.totalDueAmount;
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 3. LIFECYCLE
|
||||
// =========================
|
||||
|
||||
// --------------------------
|
||||
// LIFECYCLE
|
||||
// --------------------------
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
logSafe('DashboardController initialized', level: LogLevel.info);
|
||||
|
||||
// Project Selection Listener
|
||||
// --------------------------
|
||||
// Project change listener
|
||||
// --------------------------
|
||||
ever<String>(projectController.selectedProjectId, (id) {
|
||||
if (id.isNotEmpty) {
|
||||
fetchAllDashboardData();
|
||||
fetchTodaysAttendance(id);
|
||||
_latestProjectId = id; // track latest project
|
||||
fetchAllDashboardData(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Expense Report Date Listener
|
||||
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
|
||||
if (projectController.selectedProjectId.value.isNotEmpty) {
|
||||
final id = projectController.selectedProjectId.value;
|
||||
if (id.isNotEmpty) {
|
||||
fetchExpenseTypeReport(
|
||||
startDate: expenseReportStartDate.value,
|
||||
endDate: expenseReportEndDate.value,
|
||||
projectId: id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Chart Range Listeners
|
||||
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
||||
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 4. USER ACTIONS
|
||||
// =========================
|
||||
|
||||
// --------------------------
|
||||
// USER ACTIONS
|
||||
// --------------------------
|
||||
void updateAttendanceRange(String range) =>
|
||||
attendanceSelectedRange.value = range;
|
||||
|
||||
void updateProjectRange(String range) => projectSelectedRange.value = range;
|
||||
void toggleAttendanceChartView(bool isChart) =>
|
||||
attendanceIsChartView.value = isChart;
|
||||
@ -163,7 +159,6 @@ class DashboardController extends GetxController {
|
||||
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
|
||||
selectedMonthlyExpenseDuration.value = duration;
|
||||
|
||||
// Efficient Map lookup instead of Switch
|
||||
const durationMap = {
|
||||
MonthlyExpenseDuration.oneMonth: 1,
|
||||
MonthlyExpenseDuration.threeMonths: 3,
|
||||
@ -176,7 +171,8 @@ class DashboardController extends GetxController {
|
||||
fetchMonthlyExpenses();
|
||||
}
|
||||
|
||||
Future<void> refreshDashboard() => fetchAllDashboardData();
|
||||
Future<void> refreshDashboard() =>
|
||||
fetchAllDashboardData(projectController.selectedProjectId.value);
|
||||
Future<void> refreshAttendance() => fetchRoleWiseAttendance();
|
||||
Future<void> refreshProjects() => fetchProjectProgress();
|
||||
Future<void> refreshTasks() async {
|
||||
@ -184,150 +180,78 @@ class DashboardController extends GetxController {
|
||||
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 5. DATA FETCHING (API)
|
||||
// =========================
|
||||
|
||||
/// Wrapper to reduce try-finally boilerplate for loading states
|
||||
// --------------------------
|
||||
// HELPER: Execute API call
|
||||
// --------------------------
|
||||
Future<void> _executeApiCall(
|
||||
RxBool loader, Future<void> Function() apiLogic) async {
|
||||
loader.value = true;
|
||||
RxBool loaderRx, Future<void> Function() apiLogic) async {
|
||||
loaderRx.value = true;
|
||||
try {
|
||||
await apiLogic();
|
||||
} catch (e, stack) {
|
||||
logSafe('API Call Failed: $e', level: LogLevel.error, stackTrace: stack);
|
||||
} finally {
|
||||
loader.value = false;
|
||||
loaderRx.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchAllDashboardData() async {
|
||||
final String projectId = projectController.selectedProjectId.value;
|
||||
// --------------------------
|
||||
// API FETCHES
|
||||
// --------------------------
|
||||
Future<void> fetchAllDashboardData(String projectId) async {
|
||||
if (projectId.isEmpty) return;
|
||||
_latestProjectId = projectId;
|
||||
|
||||
await Future.wait([
|
||||
fetchRoleWiseAttendance(),
|
||||
fetchProjectProgress(),
|
||||
fetchRoleWiseAttendance(projectId),
|
||||
fetchProjectProgress(projectId),
|
||||
fetchDashboardTasks(projectId: projectId),
|
||||
fetchDashboardTeams(projectId: projectId),
|
||||
fetchPendingExpenses(),
|
||||
fetchPendingExpenses(projectId),
|
||||
fetchExpenseTypeReport(
|
||||
startDate: expenseReportStartDate.value,
|
||||
endDate: expenseReportEndDate.value,
|
||||
projectId: projectId,
|
||||
),
|
||||
fetchMonthlyExpenses(),
|
||||
fetchMonthlyExpenses(projectId: projectId),
|
||||
fetchMasterData(),
|
||||
fetchCollectionOverview(),
|
||||
fetchPurchaseInvoiceOverview(),
|
||||
fetchCollectionOverview(projectId),
|
||||
fetchPurchaseInvoiceOverview(projectId),
|
||||
fetchTodaysAttendance(projectId),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> fetchCollectionOverview() async {
|
||||
final projectId = projectController.selectedProjectId.value;
|
||||
if (projectId.isEmpty) return;
|
||||
// --------------------------
|
||||
// Each fetch now ignores stale project responses
|
||||
// --------------------------
|
||||
|
||||
await _executeApiCall(isCollectionOverviewLoading, () async {
|
||||
final response =
|
||||
await ApiService.getCollectionOverview(projectId: projectId);
|
||||
collectionOverviewData.value =
|
||||
(response?.success == true) ? response!.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchTodaysAttendance(String projectId) async {
|
||||
await _executeApiCall(isLoadingEmployees, () async {
|
||||
final response = await ApiService.getAttendanceForDashboard(projectId);
|
||||
if (response != null) {
|
||||
employees.value = response;
|
||||
for (var emp in employees) {
|
||||
uploadingStates.putIfAbsent(emp.id, () => false.obs);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchMasterData() async {
|
||||
try {
|
||||
final data = await ApiService.getMasterExpenseTypes();
|
||||
if (data is List) {
|
||||
expenseTypes.value =
|
||||
data.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
|
||||
await _executeApiCall(isMonthlyExpenseLoading, () async {
|
||||
final response = await ApiService.getDashboardMonthlyExpensesApi(
|
||||
categoryId: categoryId,
|
||||
months: selectedMonthsCount.value,
|
||||
);
|
||||
monthlyExpenseList.value =
|
||||
(response?.success == true) ? response!.data : [];
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchPurchaseInvoiceOverview() async {
|
||||
final projectId = projectController.selectedProjectId.value;
|
||||
if (projectId.isEmpty) return;
|
||||
|
||||
await _executeApiCall(isPurchaseInvoiceLoading, () async {
|
||||
final response = await ApiService.getPurchaseInvoiceOverview(
|
||||
projectId: projectId,
|
||||
);
|
||||
purchaseInvoiceOverviewData.value =
|
||||
(response?.success == true) ? response!.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchPendingExpenses() async {
|
||||
final id = projectController.selectedProjectId.value;
|
||||
if (id.isEmpty) return;
|
||||
|
||||
await _executeApiCall(isPendingExpensesLoading, () async {
|
||||
final response = await ApiService.getPendingExpensesApi(projectId: id);
|
||||
pendingExpensesData.value =
|
||||
(response?.success == true) ? response!.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchRoleWiseAttendance() async {
|
||||
final id = projectController.selectedProjectId.value;
|
||||
Future<void> fetchRoleWiseAttendance([String? projectId]) async {
|
||||
final id = projectId ?? projectController.selectedProjectId.value;
|
||||
if (id.isEmpty) return;
|
||||
|
||||
final localId = id;
|
||||
await _executeApiCall(isAttendanceLoading, () async {
|
||||
final response = await ApiService.getDashboardAttendanceOverview(
|
||||
id, getAttendanceDays());
|
||||
roleWiseData.value =
|
||||
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? [];
|
||||
if (_latestProjectId != localId) return; // discard stale response
|
||||
roleWiseData.assignAll(
|
||||
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchExpenseTypeReport(
|
||||
{required DateTime startDate, required DateTime endDate}) async {
|
||||
final id = projectController.selectedProjectId.value;
|
||||
if (id.isEmpty) return;
|
||||
|
||||
await _executeApiCall(isExpenseTypeReportLoading, () async {
|
||||
final response = await ApiService.getExpenseTypeReportApi(
|
||||
projectId: id,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
expenseTypeReportData.value =
|
||||
(response?.success == true) ? response!.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchProjectProgress() async {
|
||||
final id = projectController.selectedProjectId.value;
|
||||
Future<void> fetchProjectProgress([String? projectId]) async {
|
||||
final id = projectId ?? projectController.selectedProjectId.value;
|
||||
if (id.isEmpty) return;
|
||||
|
||||
final localId = id;
|
||||
await _executeApiCall(isProjectLoading, () async {
|
||||
final response = await ApiService.getProjectProgress(
|
||||
projectId: id, days: getProjectDays());
|
||||
if (_latestProjectId != localId) return;
|
||||
if (response?.success == true) {
|
||||
projectChartData.value = response!.data
|
||||
projectChartData.assignAll(response!.data
|
||||
.map((d) => ChartTaskData.fromProjectData(d))
|
||||
.toList();
|
||||
.toList());
|
||||
} else {
|
||||
projectChartData.clear();
|
||||
}
|
||||
@ -335,27 +259,115 @@ class DashboardController extends GetxController {
|
||||
}
|
||||
|
||||
Future<void> fetchDashboardTasks({required String projectId}) async {
|
||||
final localId = projectId;
|
||||
await _executeApiCall(isTasksLoading, () async {
|
||||
final response = await ApiService.getDashboardTasks(projectId: projectId);
|
||||
if (response?.success == true) {
|
||||
totalTasks.value = response!.data?.totalTasks ?? 0;
|
||||
completedTasks.value = response.data?.completedTasks ?? 0;
|
||||
} else {
|
||||
totalTasks.value = 0;
|
||||
completedTasks.value = 0;
|
||||
}
|
||||
if (_latestProjectId != localId) return;
|
||||
totalTasks.value = response?.data?.totalTasks ?? 0;
|
||||
completedTasks.value = response?.data?.completedTasks ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchDashboardTeams({required String projectId}) async {
|
||||
final localId = projectId;
|
||||
await _executeApiCall(isTeamsLoading, () async {
|
||||
final response = await ApiService.getDashboardTeams(projectId: projectId);
|
||||
if (response?.success == true) {
|
||||
totalEmployees.value = response!.data?.totalEmployees ?? 0;
|
||||
inToday.value = response.data?.inToday ?? 0;
|
||||
} else {
|
||||
totalEmployees.value = 0;
|
||||
inToday.value = 0;
|
||||
if (_latestProjectId != localId) return;
|
||||
totalEmployees.value = response?.data?.totalEmployees ?? 0;
|
||||
inToday.value = response?.data?.inToday ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchPendingExpenses([String? projectId]) async {
|
||||
final id = projectId ?? projectController.selectedProjectId.value;
|
||||
if (id.isEmpty) return;
|
||||
|
||||
final localId = id;
|
||||
await _executeApiCall(isPendingExpensesLoading, () async {
|
||||
final response = await ApiService.getPendingExpensesApi(projectId: id);
|
||||
if (_latestProjectId != localId) return;
|
||||
pendingExpensesData.value =
|
||||
response?.success == true ? response!.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchExpenseTypeReport(
|
||||
{required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? projectId}) async {
|
||||
final id = projectId ?? projectController.selectedProjectId.value;
|
||||
if (id.isEmpty) return;
|
||||
|
||||
final localId = id;
|
||||
await _executeApiCall(isExpenseTypeReportLoading, () async {
|
||||
final response = await ApiService.getExpenseTypeReportApi(
|
||||
projectId: id, startDate: startDate, endDate: endDate);
|
||||
if (_latestProjectId != localId) return;
|
||||
expenseTypeReportData.value =
|
||||
response?.success == true ? response!.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchMonthlyExpenses(
|
||||
{String? categoryId, String? projectId}) async {
|
||||
final id = projectId ?? projectController.selectedProjectId.value;
|
||||
if (id.isEmpty) return;
|
||||
|
||||
final localId = id;
|
||||
await _executeApiCall(isMonthlyExpenseLoading, () async {
|
||||
final response = await ApiService.getDashboardMonthlyExpensesApi(
|
||||
categoryId: categoryId, months: selectedMonthsCount.value);
|
||||
if (_latestProjectId != localId) return;
|
||||
monthlyExpenseList
|
||||
.assignAll(response?.success == true ? response!.data : []);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchMasterData() async {
|
||||
await _executeApiCall(false.obs, () async {
|
||||
final data = await ApiService.getMasterExpenseTypes();
|
||||
if (data is List)
|
||||
expenseTypes
|
||||
.assignAll(data.map((e) => ExpenseTypeModel.fromJson(e)).toList());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchCollectionOverview([String? projectId]) async {
|
||||
final id = projectId ?? projectController.selectedProjectId.value;
|
||||
if (id.isEmpty) return;
|
||||
|
||||
final localId = id;
|
||||
await _executeApiCall(isCollectionOverviewLoading, () async {
|
||||
final response = await ApiService.getCollectionOverview(projectId: id);
|
||||
if (_latestProjectId != localId) return;
|
||||
collectionOverviewData.value =
|
||||
response?.success == true ? response!.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchPurchaseInvoiceOverview([String? projectId]) async {
|
||||
final id = projectId ?? projectController.selectedProjectId.value;
|
||||
if (id.isEmpty) return;
|
||||
|
||||
final localId = id;
|
||||
await _executeApiCall(isPurchaseInvoiceLoading, () async {
|
||||
final response =
|
||||
await ApiService.getPurchaseInvoiceOverview(projectId: id);
|
||||
if (_latestProjectId != localId) return;
|
||||
purchaseInvoiceOverviewData.value =
|
||||
response?.success == true ? response!.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetchTodaysAttendance(String projectId) async {
|
||||
final localId = projectId;
|
||||
await _executeApiCall(isLoadingEmployees, () async {
|
||||
final response = await ApiService.getAttendanceForDashboard(projectId);
|
||||
if (_latestProjectId != localId) return;
|
||||
|
||||
employees.assignAll(response ?? []);
|
||||
for (var emp in employees) {
|
||||
uploadingStates.putIfAbsent(emp.id, () => false.obs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,20 +7,18 @@ import 'package:on_field_work/model/employees/employee_details_model.dart';
|
||||
class EmployeesScreenController extends GetxController {
|
||||
/// ✅ Data lists
|
||||
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
||||
RxList<EmployeeModel> filteredEmployees = <EmployeeModel>[].obs;
|
||||
Rxn<EmployeeDetailsModel> selectedEmployeeDetails =
|
||||
Rxn<EmployeeDetailsModel>();
|
||||
|
||||
/// ✅ Loading states
|
||||
RxBool isLoading = false.obs;
|
||||
RxBool isLoading = true.obs;
|
||||
RxBool isLoadingEmployeeDetails = false.obs;
|
||||
|
||||
/// ✅ Selection state
|
||||
RxBool isAllEmployeeSelected = false.obs;
|
||||
RxSet<String> selectedEmployeeIds = <String>{}.obs;
|
||||
|
||||
/// ✅ Upload state tracking (if needed later)
|
||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
||||
|
||||
RxList<EmployeeModel> selectedEmployeePrimaryManagers = <EmployeeModel>[].obs;
|
||||
RxList<EmployeeModel> selectedEmployeeSecondaryManagers =
|
||||
<EmployeeModel>[].obs;
|
||||
@ -31,26 +29,51 @@ class EmployeesScreenController extends GetxController {
|
||||
fetchAllEmployees();
|
||||
}
|
||||
|
||||
/// 🔹 Fetch all employees (no project filter)
|
||||
/// 🔹 Search/Filter Logic
|
||||
void searchEmployees(String query) {
|
||||
if (query.isEmpty) {
|
||||
filteredEmployees.assignAll(employees);
|
||||
} else {
|
||||
final searchQuery = query.toLowerCase();
|
||||
final result = employees
|
||||
.where((e) =>
|
||||
e.name.toLowerCase().contains(searchQuery) ||
|
||||
e.email.toLowerCase().contains(searchQuery) ||
|
||||
e.phoneNumber.toLowerCase().contains(searchQuery) ||
|
||||
e.jobRole.toLowerCase().contains(searchQuery))
|
||||
.toList();
|
||||
|
||||
// Sort alphabetically
|
||||
result
|
||||
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
filteredEmployees.assignAll(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔹 Fetch all employees
|
||||
Future<void> fetchAllEmployees({String? organizationId}) async {
|
||||
isLoading.value = true;
|
||||
update(['employee_screen_controller']);
|
||||
|
||||
await _handleApiCall(
|
||||
() => ApiService.getAllEmployees(organizationId: organizationId),
|
||||
onSuccess: (data) {
|
||||
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
|
||||
final loadedList =
|
||||
data.map((json) => EmployeeModel.fromJson(json)).toList();
|
||||
|
||||
employees.assignAll(loadedList);
|
||||
|
||||
filteredEmployees.assignAll(loadedList);
|
||||
|
||||
logSafe(
|
||||
"All Employees fetched: ${employees.length} employees loaded.",
|
||||
level: LogLevel.info,
|
||||
);
|
||||
|
||||
// Reset selection states when new data arrives
|
||||
selectedEmployeeIds.clear();
|
||||
isAllEmployeeSelected.value = false;
|
||||
},
|
||||
onEmpty: () {
|
||||
employees.clear();
|
||||
filteredEmployees.clear();
|
||||
selectedEmployeeIds.clear();
|
||||
isAllEmployeeSelected.value = false;
|
||||
logSafe("No Employee data found or API call failed",
|
||||
@ -90,16 +113,14 @@ class EmployeesScreenController extends GetxController {
|
||||
isLoadingEmployeeDetails.value = false;
|
||||
}
|
||||
|
||||
/// Fetch reporting managers for a specific employee from /organization/hierarchy/list/:employeeId
|
||||
/// Fetch reporting managers
|
||||
Future<void> fetchReportingManagers(String? employeeId) async {
|
||||
if (employeeId == null || employeeId.isEmpty) return;
|
||||
|
||||
try {
|
||||
// ✅ Always clear before new fetch (to avoid mixing old data)
|
||||
selectedEmployeePrimaryManagers.clear();
|
||||
selectedEmployeeSecondaryManagers.clear();
|
||||
|
||||
// Fetch from existing API helper
|
||||
final data = await ApiService.getOrganizationHierarchyList(employeeId);
|
||||
|
||||
if (data == null || data.isEmpty) {
|
||||
@ -124,11 +145,8 @@ class EmployeesScreenController extends GetxController {
|
||||
selectedEmployeeSecondaryManagers.add(emp);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore malformed items
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
update(['employee_screen_controller']);
|
||||
} catch (e) {
|
||||
logSafe("Error fetching reporting managers for $employeeId",
|
||||
@ -139,13 +157,13 @@ class EmployeesScreenController extends GetxController {
|
||||
/// 🔹 Clear all employee data
|
||||
void clearEmployees() {
|
||||
employees.clear();
|
||||
filteredEmployees.clear();
|
||||
selectedEmployeeIds.clear();
|
||||
isAllEmployeeSelected.value = false;
|
||||
logSafe("Employees cleared", level: LogLevel.info);
|
||||
update(['employee_screen_controller']);
|
||||
}
|
||||
|
||||
/// 🔹 Generic handler for list API responses
|
||||
Future<void> _handleApiCall(
|
||||
Future<List<dynamic>?> Function() apiCall, {
|
||||
required Function(List<dynamic>) onSuccess,
|
||||
@ -168,7 +186,6 @@ class EmployeesScreenController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔹 Generic handler for single-object API responses
|
||||
Future<void> _handleSingleApiCall(
|
||||
Future<Map<String, dynamic>?> Function() apiCall, {
|
||||
required Function(Map<String, dynamic>) onSuccess,
|
||||
|
||||
@ -44,20 +44,25 @@ class AddExpenseController extends GetxController {
|
||||
TextEditingController get noOfPersonsController => controllers[7];
|
||||
TextEditingController get employeeSearchController => controllers[8];
|
||||
|
||||
final List<String> _transactionIdExemptIds = const [
|
||||
'24e6b0df-7929-47d2-88a3-4cf14c1f28f9',
|
||||
'48d9b462-5d87-4dec-8dec-2bc943943172',
|
||||
'f67beee6-6763-4108-922c-03bd86b9178d',
|
||||
];
|
||||
|
||||
// --- Reactive State ---
|
||||
final isLoading = false.obs;
|
||||
final isSubmitting = false.obs;
|
||||
final isFetchingLocation = false.obs;
|
||||
final isEditMode = false.obs;
|
||||
final isSearchingEmployees = false.obs;
|
||||
final isTransactionIdExempted = false.obs;
|
||||
|
||||
// --- Paid By (Single + Multi Selection Support) ---
|
||||
|
||||
// single selection
|
||||
final selectedPaidBy = Rxn<EmployeeModel>();
|
||||
|
||||
|
||||
|
||||
// helper setters
|
||||
void setSelectedPaidBy(EmployeeModel? emp) {
|
||||
selectedPaidBy.value = emp;
|
||||
@ -66,7 +71,6 @@ class AddExpenseController extends GetxController {
|
||||
// --- Dropdown Selections & Data ---
|
||||
final selectedPaymentMode = Rxn<PaymentModeModel>();
|
||||
final selectedExpenseType = Rxn<ExpenseTypeModel>();
|
||||
// final selectedPaidBy = Rxn<EmployeeModel>();
|
||||
final selectedProject = ''.obs;
|
||||
final selectedTransactionDate = Rxn<DateTime>();
|
||||
|
||||
@ -93,6 +97,7 @@ class AddExpenseController extends GetxController {
|
||||
employeeSearchController.addListener(
|
||||
() => searchEmployees(employeeSearchController.text),
|
||||
);
|
||||
ever(selectedPaymentMode, (_) => _checkTransactionIdExemption());
|
||||
}
|
||||
|
||||
@override
|
||||
@ -103,6 +108,12 @@ class AddExpenseController extends GetxController {
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
void _checkTransactionIdExemption() {
|
||||
final selectedId = selectedPaymentMode.value?.id;
|
||||
isTransactionIdExempted.value =
|
||||
selectedId != null && _transactionIdExemptIds.contains(selectedId);
|
||||
}
|
||||
|
||||
// --- Employee Search ---
|
||||
Future<void> searchEmployees(String query) async {
|
||||
if (query.trim().isEmpty) return employeeSearchResults.clear();
|
||||
@ -171,6 +182,7 @@ class AddExpenseController extends GetxController {
|
||||
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
||||
selectedPaymentMode.value =
|
||||
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
||||
_checkTransactionIdExemption();
|
||||
}
|
||||
|
||||
Future<void> _setPaidBy(Map<String, dynamic> data) async {
|
||||
@ -536,6 +548,11 @@ class AddExpenseController extends GetxController {
|
||||
if (amountController.text.trim().isEmpty) missing.add("Amount");
|
||||
if (descriptionController.text.trim().isEmpty) missing.add("Description");
|
||||
|
||||
if (!isTransactionIdExempted.value &&
|
||||
transactionIdController.text.trim().isEmpty) {
|
||||
missing.add("Transaction ID");
|
||||
}
|
||||
|
||||
if (selectedTransactionDate.value == null) {
|
||||
missing.add("Transaction Date");
|
||||
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
|
||||
|
||||
@ -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/employees/employee_model.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 {
|
||||
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
|
||||
@ -17,6 +20,22 @@ class ExpenseDetailController extends GetxController {
|
||||
final employeeSearchController = TextEditingController();
|
||||
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
|
||||
void init(String expenseId) {
|
||||
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
|
||||
Future<T?> _apiCallWrapper<T>(
|
||||
Future<T?> Function() apiCall, String operationName) async {
|
||||
@ -63,6 +112,8 @@ class ExpenseDetailController extends GetxController {
|
||||
try {
|
||||
expense.value = ExpenseDetailModel.fromJson(result);
|
||||
logSafe("Expense details loaded successfully: ${expense.value?.id}");
|
||||
// Call permission check after data is loaded
|
||||
checkPermissionToSubmit();
|
||||
} catch (e) {
|
||||
errorMessage.value = 'Failed to parse expense details: $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) {
|
||||
if (permissionData == null) return [];
|
||||
if (permissionData is List) {
|
||||
@ -131,8 +180,6 @@ class ExpenseDetailController extends GetxController {
|
||||
allEmployees.clear();
|
||||
logSafe("No employees found.", level: LogLevel.warning);
|
||||
}
|
||||
// `update()` is typically not needed for RxList directly unless you have specific GetBuilder/Obx usage that requires it
|
||||
// If you are using Obx widgets, `allEmployees.assignAll` will automatically trigger a rebuild.
|
||||
}
|
||||
|
||||
/// Update expense with reimbursement info and status
|
||||
|
||||
@ -33,13 +33,13 @@ class PaymentRequestController extends GetxController {
|
||||
try {
|
||||
final response = await ApiService.getExpensePaymentRequestFilterApi();
|
||||
|
||||
if (response != null && response.data != null) {
|
||||
projects.assignAll(response.data!.projects ?? []);
|
||||
payees.assignAll(response.data!.payees ?? []);
|
||||
categories.assignAll(response.data!.expenseCategory ?? []);
|
||||
currencies.assignAll(response.data!.currency ?? []);
|
||||
statuses.assignAll(response.data!.status ?? []);
|
||||
createdBy.assignAll(response.data!.createdBy ?? []);
|
||||
if (response != null) {
|
||||
projects.assignAll(response.data.projects);
|
||||
payees.assignAll(response.data.payees);
|
||||
categories.assignAll(response.data.expenseCategory);
|
||||
currencies.assignAll(response.data.currency);
|
||||
statuses.assignAll(response.data.status);
|
||||
createdBy.assignAll(response.data.createdBy);
|
||||
} else {
|
||||
logSafe("Payment request filter API returned null",
|
||||
level: LogLevel.warning);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:on_field_work/helpers/services/api_service.dart';
|
||||
import 'package:on_field_work/model/infra_project/infra_project_details.dart';
|
||||
import 'package:on_field_work/model/infra_project/infra_team_list_model.dart';
|
||||
|
||||
class InfraProjectDetailsController extends GetxController {
|
||||
final String projectId;
|
||||
@ -9,25 +10,39 @@ class InfraProjectDetailsController extends GetxController {
|
||||
|
||||
var isLoading = true.obs;
|
||||
var projectDetails = Rxn<ProjectData>();
|
||||
var teamList = <ProjectAllocation>[].obs;
|
||||
var teamLoading = true.obs;
|
||||
var errorMessage = ''.obs;
|
||||
|
||||
var teamErrorMessage = ''.obs;
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetchProjectDetails();
|
||||
fetchProjectTeamList();
|
||||
}
|
||||
|
||||
Map<String, List<ProjectAllocation>> get groupedTeamByRole {
|
||||
final Map<String, List<ProjectAllocation>> map = {};
|
||||
for (final member in teamList) {
|
||||
map.putIfAbsent(member.jobRoleId, () => []).add(member);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<void> fetchProjectDetails() async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final response = await ApiService.getInfraProjectDetails(projectId: projectId);
|
||||
final response =
|
||||
await ApiService.getInfraProjectDetails(projectId: projectId);
|
||||
|
||||
if (response != null && response.success == true && response.data != null) {
|
||||
if (response != null &&
|
||||
response.success == true &&
|
||||
response.data != null) {
|
||||
projectDetails.value = response.data;
|
||||
isLoading.value = false;
|
||||
|
||||
errorMessage.value = '';
|
||||
} else {
|
||||
errorMessage.value = response?.message ?? "Failed to load project details";
|
||||
errorMessage.value =
|
||||
response?.message ?? "Failed to load project details";
|
||||
}
|
||||
} catch (e) {
|
||||
errorMessage.value = "Error fetching project details: $e";
|
||||
@ -35,4 +50,28 @@ class InfraProjectDetailsController extends GetxController {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchProjectTeamList() async {
|
||||
try {
|
||||
teamLoading.value = true;
|
||||
teamErrorMessage.value = '';
|
||||
|
||||
final response = await ApiService.getInfraProjectTeamListApi(
|
||||
projectId: projectId,
|
||||
includeInactive: false,
|
||||
);
|
||||
|
||||
if (response?.success == true && response!.data.isNotEmpty) {
|
||||
teamList.assignAll(response.data);
|
||||
} else {
|
||||
teamList.clear();
|
||||
teamErrorMessage.value = response?.message ?? "No team members found.";
|
||||
}
|
||||
} catch (e) {
|
||||
teamList.clear();
|
||||
teamErrorMessage.value = "Failed to load team members";
|
||||
} finally {
|
||||
teamLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
import 'package:get/get.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/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/employees/employee_info.dart';
|
||||
import 'package:on_field_work/model/projects_model.dart';
|
||||
@ -51,7 +51,7 @@ class PermissionController extends GetxController {
|
||||
Future<void> loadData(String token) async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final userData = await PermissionService.fetchAllUserData(token);
|
||||
final userData = await AuthService.fetchAllUserData(token);
|
||||
_updateState(userData);
|
||||
await _storeData();
|
||||
logSafe("Data loaded and state updated successfully.");
|
||||
|
||||
@ -7,14 +7,19 @@ import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
class ProjectController extends GetxController {
|
||||
RxList<GlobalProjectModel> projects = <GlobalProjectModel>[].obs;
|
||||
RxString selectedProjectId = ''.obs;
|
||||
RxBool isProjectListExpanded = false.obs;
|
||||
RxBool isProjectSelectionExpanded = false.obs;
|
||||
|
||||
RxBool isProjectSelectionExpanded = false.obs;
|
||||
RxBool isProjectListExpanded = false.obs;
|
||||
RxBool isProjectDropdownExpanded = false.obs;
|
||||
|
||||
RxBool isLoading = true.obs;
|
||||
RxBool isLoadingProjects = true.obs;
|
||||
|
||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
||||
|
||||
// --------------------------
|
||||
// Current selected project
|
||||
// --------------------------
|
||||
GlobalProjectModel? get selectedProject {
|
||||
if (selectedProjectId.value.isEmpty) return null;
|
||||
return projects.firstWhereOrNull((p) => p.id == selectedProjectId.value);
|
||||
@ -26,35 +31,37 @@ class ProjectController extends GetxController {
|
||||
fetchProjects();
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Clear all projects & UI states
|
||||
// --------------------------
|
||||
void clearProjects() {
|
||||
projects.clear();
|
||||
selectedProjectId.value = '';
|
||||
|
||||
isProjectSelectionExpanded.value = false;
|
||||
isProjectListExpanded.value = false;
|
||||
isProjectDropdownExpanded.value = false;
|
||||
isLoadingProjects.value = false;
|
||||
isLoading.value = false;
|
||||
isLoadingProjects.value = false;
|
||||
uploadingStates.clear();
|
||||
|
||||
LocalStorage.saveString('selectedProjectId', '');
|
||||
|
||||
logSafe("Projects cleared and UI states reset.");
|
||||
update();
|
||||
}
|
||||
|
||||
/// Fetches projects and initializes selected project.
|
||||
// --------------------------
|
||||
// Fetch projects from API
|
||||
// --------------------------
|
||||
Future<void> fetchProjects() async {
|
||||
isLoadingProjects.value = true;
|
||||
isLoading.value = true;
|
||||
isLoadingProjects.value = true;
|
||||
|
||||
try {
|
||||
final response = await ApiService.getGlobalProjects();
|
||||
|
||||
if (response != null && response.isNotEmpty) {
|
||||
projects.assignAll(
|
||||
response.map((json) => GlobalProjectModel.fromJson(json)).toList(),
|
||||
);
|
||||
projects.assignAll(response.map((json) => GlobalProjectModel.fromJson(json)).toList());
|
||||
|
||||
// Load previously saved project
|
||||
String? savedId = LocalStorage.getString('selectedProjectId');
|
||||
if (savedId != null && projects.any((p) => p.id == savedId)) {
|
||||
selectedProjectId.value = savedId;
|
||||
@ -63,21 +70,24 @@ class ProjectController extends GetxController {
|
||||
LocalStorage.saveString('selectedProjectId', selectedProjectId.value);
|
||||
}
|
||||
|
||||
isProjectSelectionExpanded.value = false;
|
||||
logSafe("Projects fetched: ${projects.length}");
|
||||
} else {
|
||||
logSafe("No Global projects found or API call failed.", level: LogLevel.warning);
|
||||
logSafe("No projects found or API call failed.", level: LogLevel.warning);
|
||||
}
|
||||
|
||||
isLoadingProjects.value = false;
|
||||
} catch (e, stack) {
|
||||
logSafe("Error fetching projects: $e", level: LogLevel.error, stackTrace: stack);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
update(['dashboard_controller']);
|
||||
isLoadingProjects.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSelectedProject(String projectId) async {
|
||||
if (selectedProjectId.value == projectId) return;
|
||||
selectedProjectId.value = projectId;
|
||||
await LocalStorage.saveString('selectedProjectId', projectId);
|
||||
logSafe("Selected project updated to $projectId");
|
||||
update(['selected_project']);
|
||||
isProjectSelectionExpanded.value = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,8 @@ class DailyTaskPlanningController extends GetxController {
|
||||
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
||||
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
|
||||
List<EmployeeModel> allEmployeesCache = [];
|
||||
List<TaskPlanningDetailsModel> dailyTasks = [];
|
||||
RxList<TaskPlanningDetailsModel> dailyTasks =
|
||||
<TaskPlanningDetailsModel>[].obs;
|
||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
||||
|
||||
MyFormValidator basicValidator = MyFormValidator();
|
||||
@ -27,6 +28,7 @@ class DailyTaskPlanningController extends GetxController {
|
||||
RxMap<String, RxBool> buildingLoadingStates = <String, RxBool>{}.obs;
|
||||
final Set<String> buildingsWithDetails = <String>{};
|
||||
|
||||
RxMap<String, RxDouble> todaysAssignedMap = <String, RxDouble>{}.obs;
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
@ -72,6 +74,8 @@ class DailyTaskPlanningController extends GetxController {
|
||||
required int plannedTask,
|
||||
required String description,
|
||||
required List<String> taskTeam,
|
||||
required String buildingId,
|
||||
required String projectId,
|
||||
DateTime? assignmentDate,
|
||||
String? organizationId,
|
||||
String? serviceId,
|
||||
@ -93,6 +97,8 @@ class DailyTaskPlanningController extends GetxController {
|
||||
|
||||
if (response == true) {
|
||||
logSafe("Task assigned successfully", level: LogLevel.info);
|
||||
await fetchBuildingInfra(buildingId, projectId, serviceId);
|
||||
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Task assigned successfully!",
|
||||
@ -123,18 +129,17 @@ class DailyTaskPlanningController extends GetxController {
|
||||
final infraData = infraResponse?['data'] as List<dynamic>?;
|
||||
|
||||
if (infraData == null || infraData.isEmpty) {
|
||||
dailyTasks = [];
|
||||
dailyTasks.clear(); //reactive clear
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter buildings with 0 planned & completed work
|
||||
final filteredBuildings = infraData.where((b) {
|
||||
final planned = (b['plannedWork'] as num?)?.toDouble() ?? 0;
|
||||
final completed = (b['completedWork'] as num?)?.toDouble() ?? 0;
|
||||
return planned > 0 || completed > 0;
|
||||
}).toList();
|
||||
|
||||
dailyTasks = filteredBuildings.map((buildingJson) {
|
||||
final mapped = filteredBuildings.map((buildingJson) {
|
||||
final building = Building(
|
||||
id: buildingJson['id'],
|
||||
name: buildingJson['buildingName'],
|
||||
@ -157,30 +162,31 @@ class DailyTaskPlanningController extends GetxController {
|
||||
);
|
||||
}).toList();
|
||||
|
||||
dailyTasks.assignAll(mapped);
|
||||
|
||||
buildingLoadingStates.clear();
|
||||
buildingsWithDetails.clear();
|
||||
} catch (e, stack) {
|
||||
logSafe("Error fetching daily task data",
|
||||
level: LogLevel.error, error: e, stackTrace: stack);
|
||||
logSafe(
|
||||
"Error fetching daily task data",
|
||||
level: LogLevel.error,
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
} finally {
|
||||
isFetchingTasks.value = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch full infra for a single building (floors, workAreas, workItems).
|
||||
/// Called lazily when user expands a building in the UI.
|
||||
/// Fetch full infra for a single building (lazy)
|
||||
Future<void> fetchBuildingInfra(
|
||||
String buildingId, String projectId, String? serviceId) async {
|
||||
if (buildingId.isEmpty) return;
|
||||
|
||||
// mark loading
|
||||
buildingLoadingStates.putIfAbsent(buildingId, () => true.obs);
|
||||
buildingLoadingStates[buildingId]!.value = true;
|
||||
update();
|
||||
buildingLoadingStates[buildingId]!.value = true; // Rx change is enough
|
||||
|
||||
try {
|
||||
// Re-use getInfraDetails and find the building entry for the requested buildingId
|
||||
final infraResponse =
|
||||
await ApiService.getInfraDetails(projectId, serviceId: serviceId);
|
||||
final infraData = infraResponse?['data'] as List<dynamic>? ?? [];
|
||||
@ -196,7 +202,6 @@ class DailyTaskPlanningController extends GetxController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build floors & workAreas for this building
|
||||
final building = Building(
|
||||
id: buildingJson['id'],
|
||||
name: buildingJson['buildingName'],
|
||||
@ -211,7 +216,7 @@ class DailyTaskPlanningController extends GetxController {
|
||||
return WorkArea(
|
||||
id: areaJson['id'],
|
||||
areaName: areaJson['areaName'],
|
||||
workItems: [], // will populate later
|
||||
workItems: [],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
@ -220,7 +225,6 @@ class DailyTaskPlanningController extends GetxController {
|
||||
completedWork: (buildingJson['completedWork'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
|
||||
// For each workArea, fetch its work items and populate
|
||||
await Future.wait(
|
||||
building.floors.expand((f) => f.workAreas).map((area) async {
|
||||
try {
|
||||
@ -255,7 +259,6 @@ class DailyTaskPlanningController extends GetxController {
|
||||
}
|
||||
}));
|
||||
|
||||
// Merge/replace the building into dailyTasks
|
||||
bool merged = false;
|
||||
for (var t in dailyTasks) {
|
||||
final idx = t.buildings
|
||||
@ -267,7 +270,6 @@ class DailyTaskPlanningController extends GetxController {
|
||||
}
|
||||
}
|
||||
if (!merged) {
|
||||
// If not present, add a new TaskPlanningDetailsModel wrapper (fallback)
|
||||
dailyTasks.add(TaskPlanningDetailsModel(
|
||||
id: building.id,
|
||||
name: building.name,
|
||||
@ -280,7 +282,6 @@ class DailyTaskPlanningController extends GetxController {
|
||||
));
|
||||
}
|
||||
|
||||
// Mark as loaded
|
||||
buildingsWithDetails.add(buildingId.toString());
|
||||
} catch (e, stack) {
|
||||
logSafe("Error fetching infra for building $buildingId",
|
||||
@ -288,7 +289,7 @@ class DailyTaskPlanningController extends GetxController {
|
||||
} finally {
|
||||
buildingLoadingStates.putIfAbsent(buildingId, () => false.obs);
|
||||
buildingLoadingStates[buildingId]!.value = false;
|
||||
update();
|
||||
update(); // dailyTasks mutated
|
||||
}
|
||||
}
|
||||
|
||||
@ -361,7 +362,7 @@ class DailyTaskPlanningController extends GetxController {
|
||||
}
|
||||
} finally {
|
||||
isFetchingEmployees.value = false;
|
||||
update();
|
||||
// no update(): RxLists/RxBools notify observers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import 'package:get/get.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/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
import 'package:on_field_work/controller/permission_controller.dart';
|
||||
|
||||
class TenantSelectionController extends GetxController {
|
||||
final TenantService _tenantService = TenantService();
|
||||
|
||||
// Tenant list
|
||||
final tenants = <Tenant>[].obs;
|
||||
|
||||
@ -32,10 +30,11 @@ class TenantSelectionController extends GetxController {
|
||||
isLoading.value = true;
|
||||
isAutoSelecting.value = true; // show splash during auto-selection
|
||||
try {
|
||||
final data = await _tenantService.getTenants();
|
||||
final data = await AuthService.getTenants();
|
||||
if (data == null || data.isEmpty) {
|
||||
tenants.clear();
|
||||
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
|
||||
await LocalStorage.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -87,7 +86,7 @@ class TenantSelectionController extends GetxController {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
final success = await _tenantService.selectTenant(tenantId);
|
||||
final success = await AuthService.selectTenant(tenantId);
|
||||
if (!success) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
@ -99,7 +98,7 @@ class TenantSelectionController extends GetxController {
|
||||
|
||||
// Update tenant & persist
|
||||
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
|
||||
TenantService.setSelectedTenant(selectedTenant);
|
||||
AuthService.setSelectedTenant(selectedTenant);
|
||||
selectedTenantId.value = tenantId;
|
||||
await LocalStorage.setRecentTenantId(tenantId);
|
||||
|
||||
@ -131,6 +130,6 @@ class TenantSelectionController extends GetxController {
|
||||
/// Clear tenant selection
|
||||
void _clearSelection() {
|
||||
selectedTenantId.value = null;
|
||||
TenantService.currentTenant = null;
|
||||
AuthService.currentTenant = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import 'package:get/get.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/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
import 'package:on_field_work/controller/permission_controller.dart';
|
||||
|
||||
class TenantSwitchController extends GetxController {
|
||||
final TenantService _tenantService = TenantService();
|
||||
|
||||
final tenants = <Tenant>[].obs;
|
||||
final isLoading = false.obs;
|
||||
@ -23,7 +22,7 @@ class TenantSwitchController extends GetxController {
|
||||
Future<void> loadTenants() async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final data = await _tenantService.getTenants();
|
||||
final data = await AuthService.getTenants();
|
||||
if (data == null || data.isEmpty) {
|
||||
tenants.clear();
|
||||
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();
|
||||
|
||||
// Keep current tenant as selected
|
||||
selectedTenantId.value = TenantService.currentTenant?.id;
|
||||
selectedTenantId.value = AuthService.currentTenant?.id;
|
||||
} catch (e, st) {
|
||||
logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st);
|
||||
showAppSnackbar(
|
||||
@ -48,11 +47,11 @@ class TenantSwitchController extends GetxController {
|
||||
|
||||
/// Switch to a different tenant and navigate fully
|
||||
Future<void> switchTenant(String tenantId) async {
|
||||
if (TenantService.currentTenant?.id == tenantId) return;
|
||||
if (AuthService.currentTenant?.id == tenantId) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final success = await _tenantService.selectTenant(tenantId);
|
||||
final success = await AuthService.selectTenant(tenantId);
|
||||
if (!success) {
|
||||
logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning);
|
||||
showAppSnackbar(
|
||||
@ -64,7 +63,7 @@ class TenantSwitchController extends GetxController {
|
||||
}
|
||||
|
||||
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
|
||||
TenantService.setSelectedTenant(selectedTenant);
|
||||
AuthService.setSelectedTenant(selectedTenant);
|
||||
selectedTenantId.value = tenantId;
|
||||
|
||||
// Persist recent tenant
|
||||
|
||||
@ -5,7 +5,6 @@ class ApiEndpoints {
|
||||
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
|
||||
// static const String baseUrl = "https://api.onfieldwork.com/api";
|
||||
|
||||
|
||||
static const String getMasterCurrencies = "/Master/currencies/list";
|
||||
static const String getMasterExpensesCategories =
|
||||
"/Master/expenses-categories";
|
||||
@ -48,7 +47,8 @@ class ApiEndpoints {
|
||||
static const String getProjects = "/project/list";
|
||||
static const String getGlobalProjects = "/project/list/basic";
|
||||
static const String getTodaysAttendance = "/attendance/project/team";
|
||||
static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId";
|
||||
static const String getAttendanceForDashboard =
|
||||
"/dashboard/get/attendance/employee/:projectId";
|
||||
static const String getAttendanceLogs = "/attendance/project/log";
|
||||
static const String getAttendanceLogView = "/attendance/log/attendance";
|
||||
static const String getRegularizationLogs = "/attendance/regularize";
|
||||
@ -142,7 +142,6 @@ class ApiEndpoints {
|
||||
static const String manageOrganizationHierarchy =
|
||||
"/organization/hierarchy/manage";
|
||||
|
||||
|
||||
// Service Project Module API Endpoints
|
||||
static const String getServiceProjectsList = "/serviceproject/list";
|
||||
static const String getServiceProjectDetail = "/serviceproject/details";
|
||||
@ -151,10 +150,14 @@ class ApiEndpoints {
|
||||
"/serviceproject/job/details";
|
||||
static const String editServiceProjectJob = "/serviceproject/job/edit";
|
||||
static const String createServiceProjectJob = "/serviceproject/job/create";
|
||||
static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance";
|
||||
static const String serviceProjectUpateJobAttendanceLog = "/serviceproject/job/attendance/log";
|
||||
static const String getServiceProjectUpateJobAllocationList = "/serviceproject/get/allocation/list";
|
||||
static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation";
|
||||
static const String serviceProjectUpateJobAttendance =
|
||||
"/serviceproject/job/attendance";
|
||||
static const String serviceProjectUpateJobAttendanceLog =
|
||||
"/serviceproject/job/attendance/log";
|
||||
static const String getServiceProjectUpateJobAllocationList =
|
||||
"/serviceproject/get/allocation/list";
|
||||
static const String manageServiceProjectUpateJobAllocation =
|
||||
"/serviceproject/manage/allocation";
|
||||
static const String getTeamRoles = "/master/team-roles/list";
|
||||
static const String getServiceProjectBranches = "/serviceproject/branch/list";
|
||||
|
||||
@ -167,4 +170,6 @@ class ApiEndpoints {
|
||||
// Infra Project Module API Endpoints
|
||||
static const String getInfraProjectsList = "/project/list";
|
||||
static const String getInfraProjectDetail = "/project/details";
|
||||
static const String getInfraProjectTeamList = "/project/allocation";
|
||||
static const String assignInfraProjectAllocation = "/project/allocation";
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -38,11 +38,16 @@ Future<void> initializeApp() async {
|
||||
}
|
||||
}
|
||||
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 🔹 AUTH TOKEN HANDLER
|
||||
/// ---------------------------------------------------------------------------
|
||||
Future<void> _handleAuthTokens() async {
|
||||
final refreshToken = await LocalStorage.getRefreshToken();
|
||||
|
||||
if (refreshToken?.isNotEmpty ?? false) {
|
||||
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
||||
final success = await AuthService.refreshToken();
|
||||
|
||||
if (!success) {
|
||||
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
|
||||
}
|
||||
@ -51,43 +56,67 @@ Future<void> _handleAuthTokens() async {
|
||||
}
|
||||
}
|
||||
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 🔹 UI SETUP
|
||||
/// ---------------------------------------------------------------------------
|
||||
Future<void> _setupUI() async {
|
||||
setPathUrlStrategy();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
logSafe("💡 UI setup completed with default system behavior.");
|
||||
logSafe("💡 UI setup completed.");
|
||||
}
|
||||
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 🔹 FIREBASE + GEMINI SETUP
|
||||
/// ---------------------------------------------------------------------------
|
||||
Future<void> _setupFirebase() async {
|
||||
// Firebase Core
|
||||
await Firebase.initializeApp();
|
||||
logSafe("💡 Firebase initialized.");
|
||||
logSafe("🔥 Firebase initialized.");
|
||||
|
||||
}
|
||||
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 🔹 LOCAL STORAGE SETUP
|
||||
/// ---------------------------------------------------------------------------
|
||||
Future<void> _setupLocalStorage() async {
|
||||
if (!LocalStorage.isInitialized) {
|
||||
await LocalStorage.init();
|
||||
logSafe("💡 Local storage initialized.");
|
||||
logSafe("💾 Local storage initialized.");
|
||||
} else {
|
||||
logSafe("ℹ️ Local storage already initialized, skipping.");
|
||||
logSafe("ℹ️ Local storage already initialized. Skipping.");
|
||||
}
|
||||
}
|
||||
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 🔹 DEVICE INFO
|
||||
/// ---------------------------------------------------------------------------
|
||||
Future<void> _setupDeviceInfo() async {
|
||||
final deviceInfoService = DeviceInfoService();
|
||||
await deviceInfoService.init();
|
||||
logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
|
||||
|
||||
logSafe("📱 Device Info Loaded: ${deviceInfoService.deviceData}");
|
||||
}
|
||||
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 🔹 THEME SETUP
|
||||
/// ---------------------------------------------------------------------------
|
||||
Future<void> _setupTheme() async {
|
||||
await ThemeCustomizer.init();
|
||||
logSafe("💡 Theme customizer initialized.");
|
||||
logSafe("🎨 Theme customizer initialized.");
|
||||
}
|
||||
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 🔹 FIREBASE CLOUD MESSAGING (PUSH)
|
||||
/// ---------------------------------------------------------------------------
|
||||
Future<void> _setupFirebaseMessaging() async {
|
||||
await FirebaseNotificationService().initialize();
|
||||
logSafe("💡 Firebase Messaging initialized.");
|
||||
logSafe("📨 Firebase Messaging initialized.");
|
||||
}
|
||||
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 🔹 FINAL APP STYLE
|
||||
/// ---------------------------------------------------------------------------
|
||||
void _finalizeAppStyle() {
|
||||
AppStyle.init();
|
||||
logSafe("💡 AppStyle initialized.");
|
||||
logSafe("🎯 AppStyle initialized.");
|
||||
}
|
||||
|
||||
@ -1,27 +1,47 @@
|
||||
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/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 {
|
||||
static const String _baseUrl = ApiEndpoints.baseUrl;
|
||||
static const Map<String, String> _headers = {
|
||||
static const Map<String, String> _defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// AuthService properties
|
||||
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 {
|
||||
try {
|
||||
final body = {
|
||||
"refreshToken": refreshToken,
|
||||
"fcmToken": fcmToken,
|
||||
};
|
||||
|
||||
final response = await _post("/auth/logout", body);
|
||||
final body = {"refreshToken": refreshToken, "fcmToken": fcmToken};
|
||||
final response = await _networkRequest(
|
||||
path: "/auth/logout",
|
||||
method: _HttpMethod.post,
|
||||
body: body,
|
||||
);
|
||||
|
||||
if (response != null && response['statusCode'] == 200) {
|
||||
logSafe("✅ Logout API successful");
|
||||
@ -37,10 +57,7 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Public Methods */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// Registers or updates the Firebase Cloud Messaging token.
|
||||
static Future<bool> registerDeviceToken(String fcmToken) async {
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
@ -50,38 +67,36 @@ class AuthService {
|
||||
}
|
||||
|
||||
final body = {"fcmToken": fcmToken};
|
||||
final headers = {
|
||||
..._headers,
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
final endpoint = "$_baseUrl/auth/set/device-token";
|
||||
final response = await _networkRequest(
|
||||
path: "/auth/set/device-token",
|
||||
method: _HttpMethod.post,
|
||||
body: body,
|
||||
authToken: token,
|
||||
);
|
||||
|
||||
// 🔹 Log request details
|
||||
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) {
|
||||
if (response != null && response['success'] == true) {
|
||||
logSafe("✅ Device token registered successfully.");
|
||||
return true;
|
||||
}
|
||||
logSafe("⚠️ Failed to register device token: ${data?['message']}",
|
||||
logSafe("⚠️ Failed to register device token: ${response?['message']}",
|
||||
level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Handles user login with email/password.
|
||||
/// Returns error map on failure, or null on success.
|
||||
static Future<Map<String, String>?> loginUser(
|
||||
Map<String, dynamic> data) async {
|
||||
logSafe("Attempting login...");
|
||||
logSafe("Login payload (raw): $data");
|
||||
logSafe("Login payload (JSON): ${jsonEncode(data)}");
|
||||
final responseData = await _networkRequest(
|
||||
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."};
|
||||
}
|
||||
|
||||
if (responseData['data'] != null) {
|
||||
await _handleLoginSuccess(responseData['data']);
|
||||
@ -93,9 +108,10 @@ class AuthService {
|
||||
return {"error": responseData['message'] ?? "Unexpected error occurred"};
|
||||
}
|
||||
|
||||
/// Refreshes the JWT access token using the refresh token.
|
||||
static Future<bool> refreshToken() async {
|
||||
final accessToken = LocalStorage.getJwtToken();
|
||||
final refreshToken = LocalStorage.getRefreshToken();
|
||||
final accessToken = await LocalStorage.getJwtToken();
|
||||
final refreshToken = await LocalStorage.getRefreshToken();
|
||||
|
||||
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
|
||||
logSafe("Missing access or refresh token.", level: LogLevel.warning);
|
||||
@ -103,24 +119,22 @@ class AuthService {
|
||||
}
|
||||
|
||||
final body = {"token": accessToken, "refreshToken": refreshToken};
|
||||
final data = await _post("/auth/refresh-token", body);
|
||||
if (data != null && data['success'] == true) {
|
||||
final data = await _networkRequest(
|
||||
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.setRefreshToken(data['data']['refreshToken']);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
logSafe("Token refreshed successfully.");
|
||||
|
||||
// 🔹 Retry FCM token registration after token refresh
|
||||
final newFcmToken = LocalStorage.getFcmToken();
|
||||
final newFcmToken = await LocalStorage.getFcmToken();
|
||||
if (newFcmToken?.isNotEmpty ?? false) {
|
||||
final success = 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);
|
||||
await registerDeviceToken(newFcmToken!);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
logSafe("Refresh token failed: ${data?['message']}",
|
||||
@ -128,35 +142,29 @@ class AuthService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Initiates the forgot password process.
|
||||
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,
|
||||
defaultError: "Failed to send reset link.");
|
||||
|
||||
static Future<Map<String, String>?> requestDemo(
|
||||
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;
|
||||
}
|
||||
|
||||
/// Generates an MPIN for the user.
|
||||
static Future<Map<String, String>?> generateMpin({
|
||||
required String employeeId,
|
||||
required String mpin,
|
||||
}) =>
|
||||
_wrapErrorHandling(
|
||||
() async {
|
||||
final token = LocalStorage.getJwtToken();
|
||||
return _post(
|
||||
"/auth/generate-mpin",
|
||||
{"employeeId": employeeId, "mpin": mpin},
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
return _networkRequest(
|
||||
path: "/auth/generate-mpin",
|
||||
method: _HttpMethod.post,
|
||||
body: {"employeeId": employeeId, "mpin": mpin},
|
||||
authToken: token,
|
||||
);
|
||||
},
|
||||
@ -164,6 +172,7 @@ class AuthService {
|
||||
defaultError: "Failed to generate MPIN.",
|
||||
);
|
||||
|
||||
/// Verifies the MPIN for quick login.
|
||||
static Future<Map<String, String>?> verifyMpin({
|
||||
required String mpin,
|
||||
required String mpinToken,
|
||||
@ -171,12 +180,15 @@ class AuthService {
|
||||
}) =>
|
||||
_wrapErrorHandling(
|
||||
() async {
|
||||
final employeeInfo = LocalStorage.getEmployeeInfo();
|
||||
if (employeeInfo == null) return null;
|
||||
final employeeInfo = await LocalStorage.getEmployeeInfo();
|
||||
if (employeeInfo == null)
|
||||
return null; // Fails immediately if info is missing
|
||||
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,
|
||||
"mpin": mpin,
|
||||
"mpinToken": mpinToken,
|
||||
@ -184,21 +196,41 @@ class AuthService {
|
||||
},
|
||||
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,
|
||||
defaultError: "MPIN verification failed.",
|
||||
);
|
||||
|
||||
/// Generates an OTP for login/verification.
|
||||
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,
|
||||
defaultError: "Failed to generate OTP.");
|
||||
|
||||
/// Verifies the OTP and completes the login process.
|
||||
static Future<Map<String, String>?> verifyOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
}) 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) {
|
||||
await _handleLoginSuccess(data['data']);
|
||||
return null;
|
||||
@ -207,54 +239,308 @@ class AuthService {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Private Utilities */
|
||||
/* MARKET/OTHER METHODS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
static Future<Map<String, dynamic>?> _post(
|
||||
String path,
|
||||
Map<String, dynamic> body, {
|
||||
/// Submits a demo request to the market endpoint.
|
||||
static Future<Map<String, String>?> requestDemo(
|
||||
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,
|
||||
}) async {
|
||||
try {
|
||||
final uri = Uri.parse("$_baseUrl$path");
|
||||
final headers = {
|
||||
..._headers,
|
||||
if (authToken?.isNotEmpty ?? false)
|
||||
'Authorization': 'Bearer $authToken',
|
||||
..._defaultHeaders,
|
||||
if (authToken?.isNotEmpty ?? false) 'Authorization': 'Bearer $authToken',
|
||||
};
|
||||
final response = await http.post(Uri.parse("$_baseUrl$path"),
|
||||
headers: headers, body: jsonEncode(body));
|
||||
|
||||
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 {
|
||||
...jsonDecode(response.body),
|
||||
"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) {
|
||||
_handleError("$path POST 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);
|
||||
_handleError("$path ${method.name.toUpperCase()} error", e, st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility to wrap simple API calls with error-to-UI message mapping.
|
||||
static Future<Map<String, String>?> _wrapErrorHandling(
|
||||
Future<Map<String, dynamic>?> Function() request, {
|
||||
required bool Function(Map<String, dynamic> data) successCondition,
|
||||
@ -265,13 +551,13 @@ class AuthService {
|
||||
return {"error": data?['message'] ?? defaultError};
|
||||
}
|
||||
|
||||
/// Generic error logging helper.
|
||||
static void _handleError(String message, Object 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 {
|
||||
logSafe("Processing login success...");
|
||||
|
||||
await LocalStorage.setJwtToken(data['token']);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
|
||||
@ -287,6 +573,5 @@ class AuthService {
|
||||
await LocalStorage.removeMpinToken();
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,6 @@ import 'dart:convert';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:on_field_work/controller/project_controller.dart';
|
||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||
import 'package:on_field_work/helpers/services/localizations/language.dart';
|
||||
import 'package:on_field_work/helpers/theme/theme_customizer.dart';
|
||||
@ -139,6 +138,7 @@ class LocalStorage {
|
||||
print("Logout API error: $e");
|
||||
}
|
||||
|
||||
// Remove all stored values
|
||||
await removeLoggedInUser();
|
||||
await removeToken(_jwtTokenKey);
|
||||
await removeToken(_refreshTokenKey);
|
||||
@ -153,10 +153,9 @@ class LocalStorage {
|
||||
await preferences.remove(_themeCustomizerKey);
|
||||
await preferences.remove('selectedProjectId');
|
||||
|
||||
if (Get.isRegistered<ProjectController>()) {
|
||||
Get.find<ProjectController>().clearProjects();
|
||||
}
|
||||
|
||||
// ❗ Clear all GetX controllers
|
||||
Get.deleteAll(force: true);
|
||||
// Navigate to login
|
||||
Get.offAllNamed('/auth/login-option');
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,7 +25,8 @@ int get flexColumns => MyScreenMedia.flexColumns;
|
||||
class MaterialRadius {
|
||||
double xs, small, medium, large;
|
||||
|
||||
MaterialRadius({this.xs = 2, this.small = 4, this.medium = 6, this.large = 8});
|
||||
MaterialRadius(
|
||||
{this.xs = 2, this.small = 4, this.medium = 6, this.large = 8});
|
||||
}
|
||||
|
||||
class ColorGroup {
|
||||
@ -41,10 +42,12 @@ class AppTheme {
|
||||
static Color primaryColor = Color(0xff663399);
|
||||
|
||||
static ThemeData getThemeFromThemeMode() {
|
||||
return ThemeCustomizer.instance.theme == ThemeMode.light ? lightTheme : darkTheme;
|
||||
return ThemeCustomizer.instance.theme == ThemeMode.light
|
||||
? lightTheme
|
||||
: darkTheme;
|
||||
}
|
||||
|
||||
/// -------------------------- Light Theme -------------------------------------------- ///
|
||||
/// -------------------------- Light Theme -------------------------------------------- ///
|
||||
|
||||
static final ThemeData lightTheme = ThemeData(
|
||||
/// Brightness
|
||||
@ -60,14 +63,18 @@ class AppTheme {
|
||||
|
||||
/// AppBar Theme
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Color(0xffF5F5F5), iconTheme: IconThemeData(color: Color(0xff495057)), actionsIconTheme: IconThemeData(color: Color(0xff495057))),
|
||||
backgroundColor: Color(0xffF5F5F5),
|
||||
iconTheme: IconThemeData(color: Color(0xff495057)),
|
||||
actionsIconTheme: IconThemeData(color: Color(0xff495057))),
|
||||
|
||||
/// Card Theme
|
||||
cardTheme: CardTheme(color: Color(0xffffffff)),
|
||||
// FIX: Use CardThemeData
|
||||
cardTheme: CardThemeData(color: Color(0xffffffff)),
|
||||
cardColor: Color(0xffffffff),
|
||||
|
||||
/// Colorscheme
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Color(0xff663399), brightness: Brightness.light),
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Color(0xff663399), brightness: Brightness.light),
|
||||
|
||||
snackBarTheme: SnackBarThemeData(actionTextColor: Colors.white),
|
||||
|
||||
@ -86,10 +93,12 @@ class AppTheme {
|
||||
dividerColor: Color(0xffdddddd),
|
||||
|
||||
/// Bottom AppBar Theme
|
||||
bottomAppBarTheme: BottomAppBarTheme(color: Color(0xffeeeeee), elevation: 2),
|
||||
// FIX: Use BottomAppBarThemeData
|
||||
bottomAppBarTheme:
|
||||
BottomAppBarThemeData(color: Color(0xffeeeeee), elevation: 2),
|
||||
|
||||
/// Tab bar Theme
|
||||
tabBarTheme: TabBarTheme(
|
||||
tabBarTheme: TabBarThemeData(
|
||||
unselectedLabelColor: Color(0xff495057),
|
||||
labelColor: AppTheme.primaryColor,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
@ -123,8 +132,11 @@ class AppTheme {
|
||||
checkColor: WidgetStateProperty.all(Color(0xffffffff)),
|
||||
fillColor: WidgetStateProperty.all(AppTheme.primaryColor),
|
||||
),
|
||||
switchTheme:
|
||||
SwitchThemeData(thumbColor: WidgetStateProperty.resolveWith((states) => states.contains(WidgetState.selected) ? AppTheme.primaryColor : Colors.white)),
|
||||
switchTheme: SwitchThemeData(
|
||||
thumbColor: WidgetStateProperty.resolveWith((states) =>
|
||||
states.contains(WidgetState.selected)
|
||||
? AppTheme.primaryColor
|
||||
: Colors.white)),
|
||||
|
||||
/// Other Colors
|
||||
splashColor: Colors.white.withAlpha(100),
|
||||
@ -132,8 +144,9 @@ class AppTheme {
|
||||
highlightColor: Color(0xffeeeeee),
|
||||
);
|
||||
|
||||
/// -------------------------- Dark Theme -------------------------------------------- ///
|
||||
static final ThemeData darkTheme = ThemeData.dark(useMaterial3: false).copyWith(
|
||||
/// -------------------------- Dark Theme -------------------------------------------- ///
|
||||
static final ThemeData darkTheme =
|
||||
ThemeData.dark(useMaterial3: false).copyWith(
|
||||
/// Brightness
|
||||
|
||||
/// Scaffold and Background color
|
||||
@ -146,7 +159,8 @@ class AppTheme {
|
||||
appBarTheme: AppBarTheme(backgroundColor: Color(0xff262729)),
|
||||
|
||||
/// Card Theme
|
||||
cardTheme: CardTheme(color: Color(0xff1b1b1c)),
|
||||
// FIX: Use CardThemeData
|
||||
cardTheme: CardThemeData(color: Color(0xff1b1b1c)),
|
||||
cardColor: Color(0xff1b1b1c),
|
||||
|
||||
/// Colorscheme
|
||||
@ -175,10 +189,13 @@ class AppTheme {
|
||||
foregroundColor: Colors.white),
|
||||
|
||||
/// Bottom AppBar Theme
|
||||
bottomAppBarTheme: BottomAppBarTheme(color: Color(0xff464c52), elevation: 2),
|
||||
// FIX: Use BottomAppBarThemeData
|
||||
bottomAppBarTheme:
|
||||
BottomAppBarThemeData(color: Color(0xff464c52), elevation: 2),
|
||||
|
||||
/// Tab bar Theme
|
||||
tabBarTheme: TabBarTheme(
|
||||
// FIX: Use TabBarThemeData
|
||||
tabBarTheme: TabBarThemeData(
|
||||
unselectedLabelColor: Color(0xff495057),
|
||||
labelColor: AppTheme.primaryColor,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
@ -230,7 +247,8 @@ class AppStyle {
|
||||
containerRadius: AppStyle.containerRadius.medium,
|
||||
cardRadius: AppStyle.cardRadius.medium,
|
||||
buttonRadius: AppStyle.buttonRadius.medium,
|
||||
defaultBreadCrumbItem: MyBreadcrumbItem(name: 'On Field Work', route: '/client/dashboard'),
|
||||
defaultBreadCrumbItem:
|
||||
MyBreadcrumbItem(name: 'OnFieldWork.com', route: '/client/dashboard'),
|
||||
));
|
||||
bool isMobile = true;
|
||||
try {
|
||||
@ -241,12 +259,16 @@ class AppStyle {
|
||||
My.setFlexSpacing(isMobile ? 16 : 24);
|
||||
}
|
||||
|
||||
/// -------------------------- Styles -------------------------------------------- ///
|
||||
/// -------------------------- Styles -------------------------------------------- ///
|
||||
|
||||
static MaterialRadius buttonRadius = MaterialRadius(small: 2, medium: 4, large: 8);
|
||||
static MaterialRadius cardRadius = MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
||||
static MaterialRadius containerRadius = MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
||||
static MaterialRadius imageRadius = MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
||||
static MaterialRadius buttonRadius =
|
||||
MaterialRadius(small: 2, medium: 4, large: 8);
|
||||
static MaterialRadius cardRadius =
|
||||
MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
||||
static MaterialRadius containerRadius =
|
||||
MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
||||
static MaterialRadius imageRadius =
|
||||
MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
||||
}
|
||||
|
||||
class AppColors {
|
||||
@ -262,13 +284,16 @@ class AppColors {
|
||||
static ColorGroup orange = ColorGroup(Color(0xffFFCEC2), Color(0xffFF3B0A));
|
||||
static ColorGroup skyBlue = ColorGroup(Color(0xffC2F0FF), Color(0xff0099CC));
|
||||
static ColorGroup lavender = ColorGroup(Color(0xffEAE2F3), Color(0xff7748AD));
|
||||
static ColorGroup queenPink = ColorGroup(Color(0xffE8D9DC), Color(0xff804D57));
|
||||
static ColorGroup blueViolet = ColorGroup(Color(0xffC5C6E7), Color(0xff3B3E91));
|
||||
static ColorGroup queenPink =
|
||||
ColorGroup(Color(0xffE8D9DC), Color(0xff804D57));
|
||||
static ColorGroup blueViolet =
|
||||
ColorGroup(Color(0xffC5C6E7), Color(0xff3B3E91));
|
||||
static ColorGroup rosePink = ColorGroup(Color(0xffFCB1E0), Color(0xffEC0999));
|
||||
|
||||
static ColorGroup rubinRed = ColorGroup(Color(0x98f6a8bd), Color(0xffd03760));
|
||||
static ColorGroup favorite = rubinRed;
|
||||
static ColorGroup redOrange = ColorGroup(Color(0xffFFAD99), Color(0xffF53100));
|
||||
static ColorGroup redOrange =
|
||||
ColorGroup(Color(0xffFFAD99), Color(0xffF53100));
|
||||
|
||||
static Color notificationSuccessBGColor = Color(0xff117E68);
|
||||
static Color notificationSuccessTextColor = Color(0xffffffff);
|
||||
@ -278,7 +303,16 @@ class AppColors {
|
||||
static Color notificationErrorTextColor = Color(0xffFF3B0A);
|
||||
static Color notificationErrorActionColor = Color(0xff006784);
|
||||
|
||||
static List<ColorGroup> list = [redOrange, violet, blue, green, orange, skyBlue, lavender, blueViolet];
|
||||
static List<ColorGroup> list = [
|
||||
redOrange,
|
||||
violet,
|
||||
blue,
|
||||
green,
|
||||
orange,
|
||||
skyBlue,
|
||||
lavender,
|
||||
blueViolet
|
||||
];
|
||||
|
||||
static ColorGroup get random => list[Random().nextInt(list.length)];
|
||||
|
||||
@ -287,7 +321,13 @@ class AppColors {
|
||||
}
|
||||
|
||||
static Color getColorByRating(int rating) {
|
||||
var colors = {1: Color(0xfff0323c), 2: Color(0xcdf0323c), 3: star, 4: Color(0xcd3cd278), 5: Color(0xff3cd278)};
|
||||
var colors = {
|
||||
1: Color(0xfff0323c),
|
||||
2: Color(0xcdf0323c),
|
||||
3: star,
|
||||
4: Color(0xcd3cd278),
|
||||
5: Color(0xff3cd278)
|
||||
};
|
||||
|
||||
return colors[rating] ?? colors[1]!;
|
||||
}
|
||||
|
||||
@ -18,7 +18,12 @@ class ThemeOption {
|
||||
|
||||
final List<ThemeOption> themeOptions = [
|
||||
ThemeOption(
|
||||
"Theme 1", Colors.red, Colors.red, Colors.red, ColorThemeType.red),
|
||||
"Theme 1",
|
||||
const Color(0xFFC92226),
|
||||
const Color(0xFFC92226),
|
||||
const Color(0xFFC92226),
|
||||
ColorThemeType.red,
|
||||
),
|
||||
ThemeOption(
|
||||
"Theme 2",
|
||||
const Color(0xFF49BF3C),
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -5,11 +5,9 @@ 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/utils/mixins/ui_mixin.dart';
|
||||
|
||||
class CustomAppBar extends StatefulWidget
|
||||
with UIMixin
|
||||
implements PreferredSizeWidget {
|
||||
class CustomAppBar extends StatefulWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final String? projectName; // If passed, show static text
|
||||
final String? projectName;
|
||||
final VoidCallback? onBackPressed;
|
||||
final Color? backgroundColor;
|
||||
|
||||
|
||||
@ -28,9 +28,7 @@ class CollectionsHealthWidget extends StatelessWidget {
|
||||
return Container(
|
||||
decoration: _boxDecoration(),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: MyText.bodyMedium('No collection overview data available.'),
|
||||
),
|
||||
child: const _EmptyDataWidget(), // <-- Use the new empty widget here
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
// =====================================================================
|
||||
|
||||
@ -12,19 +12,39 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
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
|
||||
return Obx(() {
|
||||
final data = controller.purchaseInvoiceOverviewData.value;
|
||||
|
||||
// Show loading state while API call is in progress
|
||||
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
|
||||
if (data == null || data.totalInvoices == 0) {
|
||||
return Center(
|
||||
child: MyText.bodySmall('No purchase invoices found.'),
|
||||
return Container(
|
||||
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);
|
||||
|
||||
return _buildDashboard(metrics);
|
||||
return _buildDashboard(metrics, cardDecoration);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildDashboard(PurchaseInvoiceMetrics metrics) {
|
||||
Widget _buildDashboard(PurchaseInvoiceMetrics metrics, BoxDecoration decoration) {
|
||||
const double spacing = 16.0;
|
||||
const double smallSpacing = 8.0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(spacing),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
decoration: decoration, // Use the passed decoration
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -319,6 +329,56 @@ Color getColorForStatus(String status) {
|
||||
/// 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 {
|
||||
final String title;
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ class SkeletonLoaders {
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@ -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(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 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(5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Widget attendanceQuickCardSkeleton() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.grey.shade300.withOpacity(0.3),
|
||||
Colors.grey.shade300.withOpacity(0.6),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
// ... gradient color setup (using grey for shimmer)
|
||||
),
|
||||
child: ShimmerEffect(
|
||||
child: Column(
|
||||
@ -56,78 +186,67 @@ class SkeletonLoaders {
|
||||
// Row with avatar and texts
|
||||
Row(
|
||||
children: [
|
||||
// Avatar
|
||||
// Avatar (Size 30)
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
color: Colors.grey.shade400, shape: BoxShape.circle)),
|
||||
MySpacing.width(10),
|
||||
// Name + designation
|
||||
// Name + designation (Approximate heights for MyText.titleSmall and MyText.labelSmall)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: 100,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
MySpacing.height(6),
|
||||
height: 12, width: 100, color: Colors.grey.shade400),
|
||||
MySpacing.height(
|
||||
4), // Reduced from 6, guessing labelSmall is shorter
|
||||
Container(
|
||||
height: 10,
|
||||
width: 70,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
height: 10, width: 70, color: Colors.grey.shade400),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Status
|
||||
// Status (MyText.bodySmall, height approx 12-14)
|
||||
Container(
|
||||
height: 12,
|
||||
width: 60,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
height: 14,
|
||||
width: 80,
|
||||
color: Colors
|
||||
.grey.shade400), // Adjusted width and height slightly
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Description
|
||||
// Description (2 lines of Text, font size 13)
|
||||
Container(
|
||||
height: 10,
|
||||
height: 14,
|
||||
width: double.infinity,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
color: Colors.grey
|
||||
.shade400), // Height for one line of text size 13 + padding
|
||||
MySpacing.height(6),
|
||||
Container(
|
||||
height: 10,
|
||||
width: double.infinity,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
height: 14,
|
||||
width: double.infinity * 0.7,
|
||||
color: Colors.grey.shade400), // Shorter second line
|
||||
const SizedBox(height: 12),
|
||||
// Action buttons
|
||||
// Action buttons (Row at the end)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Check In/Out Button (Approx height 28)
|
||||
Container(
|
||||
height: 28,
|
||||
width: 80,
|
||||
height: 32,
|
||||
width: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(5))), // Larger button size
|
||||
MySpacing.width(8),
|
||||
// Log View Button (Icon Button, approx size 28-32)
|
||||
Container(
|
||||
height: 28,
|
||||
width: 28,
|
||||
height: 32,
|
||||
width: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
color: Colors.grey.shade400, shape: BoxShape.circle)),
|
||||
],
|
||||
),
|
||||
],
|
||||
@ -139,16 +258,49 @@ class SkeletonLoaders {
|
||||
static Widget dashboardCardsSkeleton({double? maxWidth}) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
double width = maxWidth ?? constraints.maxWidth;
|
||||
int crossAxisCount = (width ~/ 80).clamp(2, 4);
|
||||
double cardWidth = (width - (crossAxisCount - 1) * 6) / crossAxisCount;
|
||||
double crossAxisSpacing = 15;
|
||||
int crossAxisCount = 3;
|
||||
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: List.generate(6, (index) {
|
||||
// Calculation remains the same: screen_width - (spacing * (count - 1)) / count
|
||||
double totalHorizontalSpace =
|
||||
width - (crossAxisSpacing * (crossAxisCount - 1));
|
||||
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(
|
||||
width: cardWidth,
|
||||
height: 60,
|
||||
height: cardHeight,
|
||||
paddingAll: 4,
|
||||
borderRadiusAll: 5,
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
||||
@ -156,29 +308,98 @@ class SkeletonLoaders {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icon placeholder: Reduced size to 16
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
height: 16, // Reduced from 20
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
MySpacing.height(4),
|
||||
Container(
|
||||
width: cardWidth * 0.5,
|
||||
height: 10,
|
||||
MySpacing.height(4), // Reduced spacing from 6
|
||||
// Text placeholder 1: Reduced height to 8
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Container(
|
||||
width: cardWidth * 0.7,
|
||||
height: 8, // Reduced from 10
|
||||
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() {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||
@ -198,7 +419,7 @@ class SkeletonLoaders {
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@ -211,7 +432,7 @@ class SkeletonLoaders {
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@ -220,7 +441,7 @@ class SkeletonLoaders {
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -239,7 +460,7 @@ class SkeletonLoaders {
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
@ -248,7 +469,7 @@ class SkeletonLoaders {
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -260,7 +481,7 @@ class SkeletonLoaders {
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -281,7 +502,7 @@ class SkeletonLoaders {
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: MyCard.bordered(
|
||||
paddingAll: 16,
|
||||
borderRadiusAll: 8,
|
||||
borderRadiusAll: 5,
|
||||
shadow: MyShadow(elevation: 3),
|
||||
child: ShimmerEffect(
|
||||
child: Column(
|
||||
@ -345,7 +566,7 @@ class SkeletonLoaders {
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
)),
|
||||
@ -414,7 +635,7 @@ class SkeletonLoaders {
|
||||
children: [
|
||||
// Header skeleton (avatar + name + role)
|
||||
MyCard(
|
||||
borderRadiusAll: 8,
|
||||
borderRadiusAll: 5,
|
||||
paddingAll: 16,
|
||||
margin: MySpacing.bottom(16),
|
||||
shadow: MyShadow(elevation: 2),
|
||||
@ -465,7 +686,7 @@ class SkeletonLoaders {
|
||||
(_) => Column(
|
||||
children: [
|
||||
MyCard(
|
||||
borderRadiusAll: 8,
|
||||
borderRadiusAll: 5,
|
||||
paddingAll: 16,
|
||||
margin: MySpacing.bottom(16),
|
||||
shadow: MyShadow(elevation: 2),
|
||||
@ -552,7 +773,7 @@ class SkeletonLoaders {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: List.generate(3, (floorIndex) {
|
||||
return MyCard(
|
||||
borderRadiusAll: 8,
|
||||
borderRadiusAll: 5,
|
||||
paddingAll: 5,
|
||||
margin: MySpacing.bottom(10),
|
||||
shadow: MyShadow(elevation: 1.5),
|
||||
@ -566,7 +787,7 @@ class SkeletonLoaders {
|
||||
width: 160,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
MySpacing.height(10),
|
||||
@ -588,7 +809,7 @@ class SkeletonLoaders {
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
MySpacing.height(8),
|
||||
@ -617,7 +838,7 @@ class SkeletonLoaders {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius:
|
||||
BorderRadius.circular(4),
|
||||
BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -642,7 +863,7 @@ class SkeletonLoaders {
|
||||
static Widget chartSkeletonLoader() {
|
||||
return MyCard.bordered(
|
||||
paddingAll: 16,
|
||||
borderRadiusAll: 12,
|
||||
borderRadiusAll: 5,
|
||||
shadow: MyShadow(
|
||||
elevation: 1.5,
|
||||
position: MyShadowPosition.bottom,
|
||||
@ -657,7 +878,7 @@ class SkeletonLoaders {
|
||||
width: 180,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -686,7 +907,7 @@ class SkeletonLoaders {
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@ -704,7 +925,7 @@ class SkeletonLoaders {
|
||||
width: 90,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -735,7 +956,7 @@ class SkeletonLoaders {
|
||||
width: 160,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -767,7 +988,7 @@ class SkeletonLoaders {
|
||||
width: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@ -776,7 +997,7 @@ class SkeletonLoaders {
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -789,7 +1010,7 @@ class SkeletonLoaders {
|
||||
width: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
@ -816,7 +1037,7 @@ class SkeletonLoaders {
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@ -825,7 +1046,7 @@ class SkeletonLoaders {
|
||||
width: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -835,7 +1056,7 @@ class SkeletonLoaders {
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -847,8 +1068,10 @@ class SkeletonLoaders {
|
||||
}
|
||||
|
||||
static Widget documentSkeletonLoader() {
|
||||
return Column(
|
||||
children: List.generate(5, (index) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 0),
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -861,7 +1084,7 @@ class SkeletonLoaders {
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -873,7 +1096,7 @@ class SkeletonLoaders {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
@ -891,7 +1114,7 @@ class SkeletonLoaders {
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: const Icon(Icons.description,
|
||||
color: Colors.transparent), // invisible icon
|
||||
@ -939,7 +1162,7 @@ class SkeletonLoaders {
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -955,7 +1178,7 @@ class SkeletonLoaders {
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
@ -1012,7 +1235,7 @@ class SkeletonLoaders {
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@ -1066,7 +1289,7 @@ class SkeletonLoaders {
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@ -1112,7 +1335,7 @@ class SkeletonLoaders {
|
||||
return Column(
|
||||
children: List.generate(4, (index) {
|
||||
return MyCard.bordered(
|
||||
borderRadiusAll: 12,
|
||||
borderRadiusAll: 5,
|
||||
paddingAll: 10,
|
||||
margin: MySpacing.bottom(12),
|
||||
shadow: MyShadow(elevation: 3),
|
||||
@ -1184,7 +1407,7 @@ class SkeletonLoaders {
|
||||
|
||||
static Widget employeeListCollapsedSkeletonLoader() {
|
||||
return MyCard.bordered(
|
||||
borderRadiusAll: 4,
|
||||
borderRadiusAll: 5,
|
||||
paddingAll: 8,
|
||||
child: ShimmerEffect(
|
||||
child: Column(
|
||||
@ -1256,7 +1479,7 @@ class SkeletonLoaders {
|
||||
|
||||
static Widget dailyProgressReportSkeletonLoader() {
|
||||
return MyCard.bordered(
|
||||
borderRadiusAll: 4,
|
||||
borderRadiusAll: 5,
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
|
||||
paddingAll: 8,
|
||||
@ -1291,7 +1514,7 @@ class SkeletonLoaders {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: List.generate(3, (index) {
|
||||
return MyCard.bordered(
|
||||
borderRadiusAll: 12,
|
||||
borderRadiusAll: 5,
|
||||
paddingAll: 16,
|
||||
margin: MySpacing.bottom(12),
|
||||
shadow: MyShadow(elevation: 3),
|
||||
@ -1350,7 +1573,7 @@ class SkeletonLoaders {
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
@ -1358,7 +1581,7 @@ class SkeletonLoaders {
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1372,7 +1595,7 @@ class SkeletonLoaders {
|
||||
width: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@ -1381,7 +1604,7 @@ class SkeletonLoaders {
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1397,7 +1620,7 @@ class SkeletonLoaders {
|
||||
return MyCard.bordered(
|
||||
margin: MySpacing.only(bottom: 12),
|
||||
paddingAll: 12,
|
||||
borderRadiusAll: 12,
|
||||
borderRadiusAll: 5,
|
||||
shadow: MyShadow(
|
||||
elevation: 1.5,
|
||||
position: MyShadowPosition.bottom,
|
||||
@ -1480,9 +1703,8 @@ class SkeletonLoaders {
|
||||
return MyCard.bordered(
|
||||
margin: MySpacing.only(bottom: 12),
|
||||
paddingAll: 16,
|
||||
borderRadiusAll: 16,
|
||||
borderRadiusAll: 5,
|
||||
shadow: MyShadow(
|
||||
elevation: 1.5,
|
||||
position: MyShadowPosition.bottom,
|
||||
),
|
||||
child: ShimmerEffect(
|
||||
@ -1636,7 +1858,7 @@ class SkeletonLoaders {
|
||||
|
||||
// Aging Stacked Bar Placeholder
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
4,
|
||||
@ -1845,42 +2067,29 @@ class SkeletonLoaders {
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Legend/Details Placeholder
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Aging Legend Placeholders
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: List.generate(
|
||||
3,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
4,
|
||||
(index) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin:
|
||||
const EdgeInsets.only(right: 8),
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 80,
|
||||
width: 115, // Reduced from 120
|
||||
color: Colors.grey.shade300),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
height: 14,
|
||||
width: 50,
|
||||
color: Colors.grey.shade300),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PillTabBar extends StatelessWidget {
|
||||
class PillTabBar extends StatefulWidget {
|
||||
final TabController controller;
|
||||
final List<String> tabs;
|
||||
final List<IconData> icons;
|
||||
final Color selectedColor;
|
||||
final Color unselectedColor;
|
||||
final Color indicatorColor;
|
||||
@ -13,6 +14,7 @@ class PillTabBar extends StatelessWidget {
|
||||
Key? key,
|
||||
required this.controller,
|
||||
required this.tabs,
|
||||
required this.icons,
|
||||
this.selectedColor = Colors.blue,
|
||||
this.unselectedColor = Colors.grey,
|
||||
this.indicatorColor = Colors.blueAccent,
|
||||
@ -21,64 +23,80 @@ class PillTabBar extends StatelessWidget {
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Dynamic horizontal padding between tabs
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final tabSpacing = (screenWidth / (tabs.length * 12)).clamp(8.0, 24.0);
|
||||
State<PillTabBar> createState() => _PillTabBarState();
|
||||
}
|
||||
|
||||
class _PillTabBarState extends State<PillTabBar> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onTabChange);
|
||||
}
|
||||
|
||||
void _onTabChange() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onTabChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Container(
|
||||
height: height,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.15),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(widget.height / 2),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: controller,
|
||||
indicator: BoxDecoration(
|
||||
color: indicatorColor.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
),
|
||||
controller: widget.controller,
|
||||
isScrollable: true, // important for dynamic spacing
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicatorPadding: EdgeInsets.symmetric(
|
||||
horizontal: tabSpacing / 2,
|
||||
vertical: 4,
|
||||
indicator: BoxDecoration(
|
||||
color: widget.indicatorColor.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(widget.height / 2),
|
||||
),
|
||||
labelColor: selectedColor,
|
||||
unselectedLabelColor: unselectedColor,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
onTap: widget.onTap,
|
||||
tabs: List.generate(widget.tabs.length, (index) {
|
||||
final isSelected = widget.controller.index == index;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal:
|
||||
isSelected ? 12 : 6, // reduce padding for unselected tabs
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
widget.icons[index],
|
||||
size: isSelected ? 18 : 16,
|
||||
color: isSelected
|
||||
? widget.selectedColor
|
||||
: widget.unselectedColor,
|
||||
),
|
||||
if (isSelected) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.tabs[index],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
),
|
||||
tabs: tabs
|
||||
.map(
|
||||
(text) => Tab(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: tabSpacing),
|
||||
child: Text(
|
||||
text,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
color: widget.selectedColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
|
||||
);
|
||||
|
||||
if (success) {
|
||||
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
|
||||
widget.attendanceController.fetchTodaysAttendance(selectedProjectId);
|
||||
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
|
||||
await widget.attendanceController
|
||||
.fetchRegularizationLogs(selectedProjectId);
|
||||
|
||||
@ -24,9 +24,11 @@ class AssignTaskBottomSheet extends StatefulWidget {
|
||||
final String buildingName;
|
||||
final String floorName;
|
||||
final String workAreaName;
|
||||
final String buildingId;
|
||||
|
||||
const AssignTaskBottomSheet({
|
||||
super.key,
|
||||
required this.buildingId,
|
||||
required this.buildingName,
|
||||
required this.workLocation,
|
||||
required this.floorName,
|
||||
@ -82,10 +84,6 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
||||
serviceId: selectedService?.id,
|
||||
organizationId: selectedOrganization?.id,
|
||||
);
|
||||
await controller.fetchTaskData(
|
||||
selectedProjectId,
|
||||
serviceId: selectedService?.id,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -376,7 +374,7 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onAssignTaskPressed() {
|
||||
Future<void> _onAssignTaskPressed() async {
|
||||
final selectedTeam = controller.selectedEmployees;
|
||||
|
||||
if (selectedTeam.isEmpty) {
|
||||
@ -417,14 +415,20 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.assignDailyTask(
|
||||
final success = await controller.assignDailyTask(
|
||||
workItemId: widget.workItemId,
|
||||
plannedTask: target.toInt(),
|
||||
description: description,
|
||||
taskTeam: selectedTeam.map((e) => e.id).toList(), // pass IDs
|
||||
taskTeam: selectedTeam.map((e) => e.id).toList(),
|
||||
assignmentDate: widget.assignmentDate,
|
||||
buildingId: widget.buildingId,
|
||||
projectId: selectedProjectId!,
|
||||
organizationId: selectedOrganization?.id,
|
||||
serviceId: selectedService?.id,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,9 @@ class _UserDocumentFilterBottomSheetState
|
||||
filterData.documentCategory,
|
||||
filterData.documentType,
|
||||
filterData.documentTag,
|
||||
].any((list) => list.isNotEmpty);
|
||||
].any((list) => list.isNotEmpty) ||
|
||||
docController.startDate.value != null ||
|
||||
docController.endDate.value != null;
|
||||
|
||||
return BaseBottomSheet(
|
||||
title: 'Filter Documents',
|
||||
@ -53,8 +55,8 @@ class _UserDocumentFilterBottomSheetState
|
||||
'documentTypeIds': docController.selectedType.toList(),
|
||||
'documentTagIds': docController.selectedTag.toList(),
|
||||
'isUploadedAt': docController.isUploadedAt.value,
|
||||
'startDate': docController.startDate.value,
|
||||
'endDate': docController.endDate.value,
|
||||
'startDate': docController.startDate.value?.toIso8601String(),
|
||||
'endDate': docController.endDate.value?.toIso8601String(),
|
||||
if (docController.isVerified.value != null)
|
||||
'isVerified': docController.isVerified.value,
|
||||
};
|
||||
|
||||
@ -52,6 +52,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
||||
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
|
||||
final GlobalKey _paymentModeDropdownKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.isEdit && widget.existingExpense != null) {
|
||||
controller.populateFieldsForEdit(widget.existingExpense!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showEmployeeList() async {
|
||||
final result = await showModalBottomSheet<dynamic>(
|
||||
context: context,
|
||||
@ -217,13 +225,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
||||
),
|
||||
],
|
||||
_gap(),
|
||||
_buildTextFieldSection(
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
title: "GST No.",
|
||||
controller: controller.gstController,
|
||||
hint: "Enter GST No.",
|
||||
),
|
||||
_gap(),
|
||||
_buildDropdownField<PaymentModeModel>(
|
||||
icon: Icons.payment,
|
||||
title: "Payment Mode",
|
||||
@ -239,6 +240,29 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
||||
dropdownKey: _paymentModeDropdownKey,
|
||||
),
|
||||
_gap(),
|
||||
Obx(() {
|
||||
if (controller.isTransactionIdExempted.value) {
|
||||
return const SizedBox.shrink(); // hide field
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildTextFieldSection(
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
title: "Transaction ID",
|
||||
hint: "Enter Transaction ID",
|
||||
controller: controller.transactionIdController,
|
||||
isRequiredOverride: true,
|
||||
validator: (v) {
|
||||
return (v != null && v.isNotEmpty)
|
||||
? Validators.transactionIdValidator(v)
|
||||
: Validators.requiredField(v);
|
||||
},
|
||||
),
|
||||
_gap(),
|
||||
],
|
||||
);
|
||||
}),
|
||||
_buildPaidBySection(),
|
||||
_gap(),
|
||||
_buildTextFieldSection(
|
||||
@ -262,12 +286,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
||||
_gap(),
|
||||
_buildTextFieldSection(
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
title: "Transaction ID",
|
||||
controller: controller.transactionIdController,
|
||||
hint: "Enter Transaction ID",
|
||||
validator: (v) => (v != null && v.isNotEmpty)
|
||||
? Validators.transactionIdValidator(v)
|
||||
: null,
|
||||
title: "GST No.",
|
||||
controller: controller.gstController,
|
||||
hint: "Enter GST No.",
|
||||
),
|
||||
_gap(),
|
||||
_buildTransactionDateField(),
|
||||
@ -321,12 +342,18 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
||||
TextInputType? keyboardType,
|
||||
FormFieldValidator<String>? validator,
|
||||
int maxLines = 1,
|
||||
bool? isRequiredOverride,
|
||||
}) {
|
||||
final bool isRequired = isRequiredOverride ?? (validator != null);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitle(
|
||||
icon: icon, title: title, requiredField: validator != null),
|
||||
icon: icon,
|
||||
title: title,
|
||||
requiredField: isRequired
|
||||
),
|
||||
MySpacing.height(6),
|
||||
CustomTextField(
|
||||
controller: controller,
|
||||
|
||||
@ -41,7 +41,7 @@ class ReimbursementBottomSheet extends StatefulWidget {
|
||||
|
||||
class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
||||
final ExpenseDetailController controller =
|
||||
Get.find<ExpenseDetailController>();
|
||||
Get.put(ExpenseDetailController());
|
||||
|
||||
final TextEditingController commentCtrl = TextEditingController();
|
||||
final TextEditingController txnCtrl = TextEditingController();
|
||||
@ -197,7 +197,7 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expenseTransactionDate != null && selectedDate != null) {
|
||||
if (expenseTransactionDate != null) {
|
||||
final normalizedSelected = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
class AssignProjectAllocationRequest {
|
||||
final String employeeId;
|
||||
final String projectId;
|
||||
final String jobRoleId;
|
||||
final String serviceId;
|
||||
final bool status;
|
||||
|
||||
AssignProjectAllocationRequest({
|
||||
required this.employeeId,
|
||||
required this.projectId,
|
||||
required this.jobRoleId,
|
||||
required this.serviceId,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"employeeId": employeeId,
|
||||
"projectId": projectId,
|
||||
"jobRoleId": jobRoleId,
|
||||
"serviceId": serviceId,
|
||||
"status": status,
|
||||
};
|
||||
}
|
||||
}
|
||||
119
lib/model/infra_project/infra_team_list_model.dart
Normal file
119
lib/model/infra_project/infra_team_list_model.dart
Normal file
@ -0,0 +1,119 @@
|
||||
|
||||
class ProjectAllocationResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<ProjectAllocation> data;
|
||||
final int statusCode;
|
||||
final String timestamp;
|
||||
|
||||
ProjectAllocationResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory ProjectAllocationResponse.fromJson(Map<String, dynamic> json) {
|
||||
final List<dynamic>? rawData = json['data'] as List<dynamic>?;
|
||||
|
||||
final List<ProjectAllocation> allocations = rawData
|
||||
?.map((item) => ProjectAllocation.fromJson(item as Map<String, dynamic>))
|
||||
.toList()
|
||||
?? [];
|
||||
|
||||
return ProjectAllocationResponse(
|
||||
success: json['success'] as bool? ?? false,
|
||||
message: json['message'] as String? ?? 'An unknown API error occurred.',
|
||||
data: allocations,
|
||||
statusCode: json['statusCode'] as int? ?? 0,
|
||||
timestamp: json['timestamp'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts the [ProjectAllocationResponse] object back to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': data.map((e) => e.toJson()).toList(),
|
||||
'statusCode': statusCode,
|
||||
'timestamp': timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- Allocation Detail Class ---
|
||||
|
||||
class ProjectAllocation {
|
||||
final String id;
|
||||
final String employeeId;
|
||||
final String projectId;
|
||||
final String allocationDate;
|
||||
final bool isActive;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String middleName;
|
||||
final String organizationId;
|
||||
final String organizationName;
|
||||
final String serviceId;
|
||||
final String serviceName;
|
||||
final String jobRoleId;
|
||||
final String jobRoleName;
|
||||
|
||||
ProjectAllocation({
|
||||
required this.id,
|
||||
required this.employeeId,
|
||||
required this.projectId,
|
||||
required this.allocationDate,
|
||||
required this.isActive,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.middleName,
|
||||
required this.organizationId,
|
||||
required this.organizationName,
|
||||
required this.serviceId,
|
||||
required this.serviceName,
|
||||
required this.jobRoleId,
|
||||
required this.jobRoleName
|
||||
});
|
||||
|
||||
|
||||
factory ProjectAllocation.fromJson(Map<String, dynamic> json) {
|
||||
return ProjectAllocation(
|
||||
id: json['id'] as String? ?? '',
|
||||
employeeId: json['employeeId'] as String? ?? '',
|
||||
projectId: json['projectId'] as String? ?? '',
|
||||
allocationDate: json['allocationDate'] as String? ?? '',
|
||||
isActive: json['isActive'] as bool? ?? false,
|
||||
firstName: json['firstName'] as String? ?? '',
|
||||
lastName: json['lastName'] as String? ?? '',
|
||||
middleName: json['middleName'] as String? ?? '',
|
||||
organizationId: json['organizationId'] as String? ?? '',
|
||||
organizationName: json['organizationName'] as String? ?? '',
|
||||
serviceId: json['serviceId'] as String? ?? '',
|
||||
serviceName: json['serviceName'] as String? ?? '',
|
||||
jobRoleId: json['jobRoleId'] as String? ?? '',
|
||||
jobRoleName: json['jobRoleName'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'employeeId': employeeId,
|
||||
'projectId': projectId,
|
||||
'allocationDate': allocationDate,
|
||||
'isActive': isActive,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'middleName': middleName,
|
||||
'organizationId': organizationId,
|
||||
'organizationName': organizationName,
|
||||
'serviceId': serviceId,
|
||||
'serviceName': serviceName,
|
||||
'jobRoleId': jobRoleId,
|
||||
'jobRoleName': jobRoleName
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.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/login_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') {
|
||||
return const RouteSettings(name: '/auth/login-option');
|
||||
}
|
||||
} else if (!TenantService.isTenantSelected) {
|
||||
} else if (!AuthService.isTenantSelected) {
|
||||
if (route != '/select-tenant') {
|
||||
return const RouteSettings(name: '/select-tenant');
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ class _AttendanceScreenState extends State<AttendanceScreen>
|
||||
final projectController = Get.put(ProjectController());
|
||||
|
||||
late TabController _tabController;
|
||||
late List<Map<String, String>> _tabs;
|
||||
late List<Map<String, dynamic>> _tabs;
|
||||
bool _tabsInitialized = false;
|
||||
|
||||
@override
|
||||
@ -62,9 +62,13 @@ class _AttendanceScreenState extends State<AttendanceScreen>
|
||||
|
||||
void _initializeTabs() async {
|
||||
final allTabs = [
|
||||
{'label': "Today's", 'value': 'todaysAttendance'},
|
||||
{'label': "Logs", 'value': 'attendanceLogs'},
|
||||
{'label': "Regularization", 'value': 'regularizationRequests'},
|
||||
{'label': "Today's", 'value': 'todaysAttendance', 'icon': Icons.today},
|
||||
{'label': "Logs", 'value': 'attendanceLogs', 'icon': Icons.list_alt},
|
||||
{
|
||||
'label': "Regularization",
|
||||
'value': 'regularizationRequests',
|
||||
'icon': Icons.edit
|
||||
},
|
||||
];
|
||||
|
||||
final hasRegularizationPermission =
|
||||
@ -306,7 +310,11 @@ class _AttendanceScreenState extends State<AttendanceScreen>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: PillTabBar(
|
||||
controller: _tabController,
|
||||
tabs: _tabs.map((e) => e['label']!).toList(),
|
||||
tabs:
|
||||
_tabs.map((e) => e['label'] as String).toList(),
|
||||
icons: _tabs
|
||||
.map((e) => e['icon'] as IconData)
|
||||
.toList(),
|
||||
selectedColor: contentTheme.primary,
|
||||
unselectedColor: Colors.grey.shade600,
|
||||
indicatorColor: contentTheme.primary,
|
||||
|
||||
@ -123,14 +123,33 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
||||
Widget _buildWelcomeText() {
|
||||
return Column(
|
||||
children: [
|
||||
MyText(
|
||||
"Welcome to On Field Work",
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: Colors.black87,
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
children: const [
|
||||
TextSpan(
|
||||
text: "Welcome to ",
|
||||
style: TextStyle(color: Colors.black87),
|
||||
),
|
||||
TextSpan(
|
||||
text: "OnField",
|
||||
style: TextStyle(color: Color(0xFF007BFF)), // Blue
|
||||
),
|
||||
TextSpan(
|
||||
text: "Work",
|
||||
style: TextStyle(color: Color(0xFF71DD37)), // Green
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
MyText(
|
||||
"Streamline Project Management\nBoost Productivity with Automation.",
|
||||
fontSize: 14,
|
||||
@ -254,7 +273,11 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
||||
Widget _buildBackButton() {
|
||||
return TextButton.icon(
|
||||
onPressed: () async => await LocalStorage.logout(),
|
||||
icon: Icon(Icons.arrow_back, size: 18, color: contentTheme.primary,),
|
||||
icon: Icon(
|
||||
Icons.arrow_back,
|
||||
size: 18,
|
||||
color: contentTheme.primary,
|
||||
),
|
||||
label: MyText.bodyMedium(
|
||||
'Back to Login',
|
||||
color: contentTheme.primary,
|
||||
|
||||
@ -196,14 +196,33 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
||||
Widget _buildWelcomeText() {
|
||||
return Column(
|
||||
children: [
|
||||
MyText(
|
||||
"Welcome to On Field Work",
|
||||
fontSize: 26,
|
||||
fontWeight: 800,
|
||||
color: Colors.black87,
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.black87,
|
||||
),
|
||||
children: const [
|
||||
TextSpan(
|
||||
text: "Welcome to ",
|
||||
style: TextStyle(color: Colors.black87),
|
||||
),
|
||||
TextSpan(
|
||||
text: "OnField",
|
||||
style: TextStyle(color: Color(0xFF007BFF)), // Blue
|
||||
),
|
||||
TextSpan(
|
||||
text: "Work",
|
||||
style: TextStyle(color: Color(0xFF71DD37)), // Green
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
MyText(
|
||||
"Streamline Project Management\nBoost Productivity with Automation.",
|
||||
fontSize: 14,
|
||||
|
||||
@ -10,7 +10,6 @@ import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:on_field_work/helpers/widgets/wave_background.dart';
|
||||
|
||||
|
||||
class MPINAuthScreen extends StatefulWidget {
|
||||
const MPINAuthScreen({super.key});
|
||||
|
||||
@ -91,12 +90,31 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
MyText(
|
||||
"Welcome to On Field Work",
|
||||
fontSize: 24,
|
||||
fontWeight: 800,
|
||||
color: Colors.black87,
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: const TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.black87,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "Welcome to ",
|
||||
style: TextStyle(color: Colors.black87),
|
||||
),
|
||||
TextSpan(
|
||||
text: "OnField",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF007BFF)), // Blue
|
||||
),
|
||||
TextSpan(
|
||||
text: "Work",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF71DD37)), // Green
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
MyText(
|
||||
@ -317,8 +335,8 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
||||
if (isNewUser || isChangeMpin)
|
||||
TextButton.icon(
|
||||
onPressed: () => Get.toNamed('/dashboard'),
|
||||
icon: Icon(Icons.arrow_back,
|
||||
size: 18, color: contentTheme.primary),
|
||||
icon:
|
||||
Icon(Icons.arrow_back, size: 18, color: contentTheme.primary),
|
||||
label: MyText.bodyMedium(
|
||||
'Back to Home Page',
|
||||
color: contentTheme.primary,
|
||||
|
||||
@ -11,8 +11,8 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
|
||||
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
||||
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
|
||||
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
|
||||
import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart';
|
||||
import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart';
|
||||
// import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart'; // Unused
|
||||
// import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart'; // Unused
|
||||
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
||||
@ -34,7 +34,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
final AttendanceController attendanceController =
|
||||
Get.put(AttendanceController());
|
||||
final DynamicMenuController menuController = Get.put(DynamicMenuController());
|
||||
final ProjectController projectController = Get.find<ProjectController>();
|
||||
final ProjectController projectController = Get.put(ProjectController());
|
||||
|
||||
bool hasMpin = true;
|
||||
|
||||
@ -56,35 +56,36 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _cardWrapper({required Widget child}) {
|
||||
const BorderRadius cardRadius = BorderRadius.all(Radius.circular(5));
|
||||
const List<BoxShadow> cardShadow = [
|
||||
BoxShadow(
|
||||
color: Color.fromRGBO(0, 0, 0, 0.05),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
];
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
borderRadius: cardRadius,
|
||||
border: Border.all(color: Colors.black12.withOpacity(.04)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12.withOpacity(.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
boxShadow: cardShadow,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _sectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text(
|
||||
child: MyText.titleMedium(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -120,9 +121,33 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'No attendance data available',
|
||||
style: TextStyle(color: Colors.white),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline,
|
||||
size: 30, color: Colors.white),
|
||||
MySpacing.width(10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
"No attendance data available yet.",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(12),
|
||||
const Text(
|
||||
"You are not added to this project or attendance data is not available.",
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -137,6 +162,12 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
? 'Checked In'
|
||||
: 'Checked Out';
|
||||
|
||||
final String infoText = !isCheckedIn
|
||||
? 'You are not checked-in yet. Please check-in to start your work.'
|
||||
: !isCheckedOut
|
||||
? 'You are currently checked-in. Don\'t forget to check-out after your work.'
|
||||
: 'You have checked-out for today.';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@ -185,19 +216,15 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MySpacing.height(12),
|
||||
Text(
|
||||
!isCheckedIn
|
||||
? 'You are not checked-in yet. Please check-in to start your work.'
|
||||
: !isCheckedOut
|
||||
? 'You are currently checked-in. Don\'t forget to check-out after your work.'
|
||||
: 'You have checked-out for today.',
|
||||
infoText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MySpacing.height(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
@ -236,8 +263,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
|
||||
final bool projectSelected = projectController.selectedProject != null;
|
||||
|
||||
// these are String constants from permission_constants.dart
|
||||
final List<String> cardOrder = [
|
||||
const List<String> cardOrder = [
|
||||
MenuItems.attendance,
|
||||
MenuItems.employees,
|
||||
MenuItems.directory,
|
||||
@ -280,14 +306,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Modules',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
_sectionTitle('Modules'),
|
||||
if (!projectSelected)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@ -312,7 +331,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
physics: const NeverScrollableScrollPhysics(), // Important!
|
||||
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
@ -326,8 +345,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
final item = allowed[id]!;
|
||||
final _DashboardCardMeta cardMeta = meta[id]!;
|
||||
|
||||
// Attendance is the only module not requiring a project
|
||||
final bool isEnabled =
|
||||
item.name == 'Attendance' ? true : projectSelected;
|
||||
item.id == MenuItems.attendance ? true : projectSelected;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
@ -371,7 +391,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
color:
|
||||
isEnabled ? cardMeta.color : Colors.grey.shade300,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
MySpacing.height(6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text(
|
||||
@ -413,10 +433,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
final String? selectedId = projectController.selectedProjectId.value;
|
||||
|
||||
if (isLoading) {
|
||||
return SkeletonLoaders.dashboardCardsSkeleton(
|
||||
maxWidth: MediaQuery.of(context).size.width,
|
||||
);
|
||||
return SkeletonLoaders.projectSelectorSkeleton();
|
||||
}
|
||||
final String selectedProjectName = projects
|
||||
.firstWhereOrNull(
|
||||
(p) => p.id == selectedId,
|
||||
)
|
||||
?.name ??
|
||||
'Select Project';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -445,15 +469,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
color: Colors.blue,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
projects
|
||||
.firstWhereOrNull(
|
||||
(p) => p.id == selectedId,
|
||||
)
|
||||
?.name ??
|
||||
'Select Project',
|
||||
selectedProjectName,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
@ -498,17 +517,17 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
const TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search project...',
|
||||
isDense: true,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
MySpacing.height(10),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: projects.length,
|
||||
@ -534,8 +553,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// Build (MODIFIED FOR FIXED HEADER)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
@ -543,22 +563,27 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xfff5f6fa),
|
||||
body: Layout(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Main content
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_projectSelector(),
|
||||
MySpacing.height(20),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_quickActions(),
|
||||
MySpacing.height(20),
|
||||
_dashboardModules(),
|
||||
MySpacing.height(20),
|
||||
_sectionTitle('Reports & Analytics'),
|
||||
CompactPurchaseInvoiceDashboard(),
|
||||
MySpacing.height(20),
|
||||
CollectionsHealthWidget(),
|
||||
MySpacing.height(20),
|
||||
_cardWrapper(
|
||||
child: ExpenseTypeReportChart(),
|
||||
),
|
||||
@ -570,15 +595,20 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
_cardWrapper(
|
||||
child: MonthlyExpenseDashboardChart(),
|
||||
),
|
||||
MySpacing.height(20),
|
||||
MySpacing.height(80), // give space under content
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardCardMeta {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
@ -70,6 +70,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
|
||||
appBar: CustomAppBar(
|
||||
title: 'Contact Profile',
|
||||
backgroundColor: appBarColor,
|
||||
projectName: " All Projects",
|
||||
onBackPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
|
||||
),
|
||||
|
||||
|
||||
@ -46,6 +46,7 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
|
||||
appBar: CustomAppBar(
|
||||
title: "Directory",
|
||||
onBackPressed: () => Get.offNamed('/dashboard'),
|
||||
projectName: " All Projects",
|
||||
backgroundColor: appBarColor,
|
||||
),
|
||||
body: Stack(
|
||||
@ -73,6 +74,10 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
|
||||
PillTabBar(
|
||||
controller: _tabController,
|
||||
tabs: const ["Directory", "Notes"],
|
||||
icons: const [
|
||||
Icons.people,
|
||||
Icons.notes_outlined,
|
||||
],
|
||||
selectedColor: contentTheme.primary,
|
||||
unselectedColor: Colors.grey.shade600,
|
||||
indicatorColor: contentTheme.primary,
|
||||
|
||||
@ -424,6 +424,8 @@ class _DirectoryViewState extends State<DirectoryView> with UIMixin {
|
||||
child: controller.isLoading.value
|
||||
? ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: MySpacing.only(
|
||||
left: 10, right: 10, top: 4, bottom: 80),
|
||||
itemCount: 10,
|
||||
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||
itemBuilder: (_, __) =>
|
||||
|
||||
@ -526,6 +526,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
|
||||
label: MyText(
|
||||
'Assign to Project',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontWeight: 500,
|
||||
),
|
||||
);
|
||||
@ -538,7 +539,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
|
||||
if (managers.isEmpty) return '—';
|
||||
return managers
|
||||
.map((m) =>
|
||||
'${(m.firstName ?? '').trim()} ${(m.lastName ?? '').trim()}'.trim())
|
||||
'${(m.firstName ).trim()} ${(m.lastName ).trim()}'.trim())
|
||||
.where((name) => name.isNotEmpty)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:on_field_work/view/employees/employee_detail_screen.dart';
|
||||
import 'package:on_field_work/view/document/user_document_screen.dart';
|
||||
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart'; // <-- import PillTabBar
|
||||
|
||||
class EmployeeProfilePage extends StatefulWidget {
|
||||
final String employeeId;
|
||||
@ -16,14 +17,11 @@ class EmployeeProfilePage extends StatefulWidget {
|
||||
|
||||
class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
||||
with SingleTickerProviderStateMixin, UIMixin {
|
||||
// We no longer need to listen to the TabController for setState,
|
||||
// as the TabBar handles its own state updates via the controller.
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize TabController with 2 tabs
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@ -33,11 +31,8 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// --- No need for _buildSegmentedButton function anymore ---
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Accessing theme colors for consistency
|
||||
final Color appBarColor = contentTheme.primary;
|
||||
final Color primaryColor = contentTheme.primary;
|
||||
|
||||
@ -45,13 +40,13 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
||||
backgroundColor: const Color(0xFFF1F1F1),
|
||||
appBar: CustomAppBar(
|
||||
title: "Employee Profile",
|
||||
projectName: " All Projects",
|
||||
onBackPressed: () => Get.back(),
|
||||
backgroundColor: appBarColor,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// === Gradient at the top behind AppBar + Toggle ===
|
||||
// This container ensures the background color transitions nicely
|
||||
// Gradient at the top behind AppBar + Toggle
|
||||
Container(
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
@ -65,63 +60,20 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
||||
),
|
||||
),
|
||||
),
|
||||
// === Main Content Area ===
|
||||
SafeArea(
|
||||
top: false,
|
||||
bottom: true,
|
||||
child: Column(
|
||||
children: [
|
||||
// 🛑 NEW: The Modern TabBar Implementation 🛑
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Container(
|
||||
height: 48, // Define a specific height for the TabBar container
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24.0), // Rounded corners for a chip-like look
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.15),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TabBar(
|
||||
PillTabBar(
|
||||
controller: _tabController,
|
||||
// Style the indicator as a subtle pill/chip
|
||||
indicator: BoxDecoration(
|
||||
color: primaryColor.withOpacity(0.1), // Light background color for the selection
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
tabs: const ["Details", "Documents"],
|
||||
icons: const [Icons.person, Icons.folder],
|
||||
selectedColor: primaryColor,
|
||||
unselectedColor: Colors.grey.shade600,
|
||||
indicatorColor: primaryColor,
|
||||
height: 48,
|
||||
),
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
// The padding is used to slightly shrink the indicator area
|
||||
indicatorPadding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
|
||||
|
||||
// Text styling
|
||||
labelColor: primaryColor, // Selected text color is primary
|
||||
unselectedLabelColor: Colors.grey.shade600, // Unselected text color is darker grey
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 15,
|
||||
),
|
||||
|
||||
// Tabs (No custom widget needed, just use the built-in Tab)
|
||||
tabs: const [
|
||||
Tab(text: "Details"),
|
||||
Tab(text: "Documents"),
|
||||
],
|
||||
// Setting this to zero removes the default underline
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 🛑 TabBarView (The Content) 🛑
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
|
||||
@ -7,9 +7,7 @@ import 'package:on_field_work/helpers/widgets/my_text.dart';
|
||||
import 'package:on_field_work/model/employees/add_employee_bottom_sheet.dart';
|
||||
import 'package:on_field_work/controller/employee/employees_screen_controller.dart';
|
||||
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
||||
import 'package:on_field_work/controller/project_controller.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||
import 'package:on_field_work/model/employees/employee_model.dart';
|
||||
import 'package:on_field_work/helpers/utils/launcher_utils.dart';
|
||||
import 'package:on_field_work/view/employees/assign_employee_bottom_sheet.dart';
|
||||
import 'package:on_field_work/controller/permission_controller.dart';
|
||||
@ -30,56 +28,27 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
late final EmployeesScreenController _employeeController;
|
||||
late final PermissionController _permissionController;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_employeeController = Get.put(EmployeesScreenController());
|
||||
_permissionController = Get.put(PermissionController());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await _initEmployees();
|
||||
_searchController.addListener(() {
|
||||
_filterEmployees(_searchController.text);
|
||||
_employeeController.searchEmployees(_searchController.text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initEmployees() async {
|
||||
await _employeeController.fetchAllEmployees();
|
||||
_filterEmployees(_searchController.text);
|
||||
}
|
||||
|
||||
Future<void> _refreshEmployees() async {
|
||||
try {
|
||||
await _employeeController.fetchAllEmployees();
|
||||
_filterEmployees(_searchController.text);
|
||||
_employeeController.update(['employee_screen_controller']);
|
||||
_employeeController.searchEmployees(_searchController.text);
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('Error refreshing employee data: $e');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void _filterEmployees(String query) {
|
||||
final employees = _employeeController.employees;
|
||||
final searchQuery = query.toLowerCase();
|
||||
final filtered = query.isEmpty
|
||||
? List<EmployeeModel>.from(employees)
|
||||
: employees
|
||||
.where(
|
||||
(e) =>
|
||||
e.name.toLowerCase().contains(searchQuery) ||
|
||||
e.email.toLowerCase().contains(searchQuery) ||
|
||||
e.phoneNumber.toLowerCase().contains(searchQuery) ||
|
||||
e.jobRole.toLowerCase().contains(searchQuery),
|
||||
)
|
||||
.toList();
|
||||
filtered
|
||||
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
_filteredEmployees.assignAll(filtered);
|
||||
}
|
||||
|
||||
Future<void> _onAddNewEmployee() async {
|
||||
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
||||
context: context,
|
||||
@ -121,8 +90,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
appBar: CustomAppBar(
|
||||
title: "Employees",
|
||||
backgroundColor: appBarColor,
|
||||
projectName: Get.find<ProjectController>().selectedProject?.name ??
|
||||
'Select Project',
|
||||
projectName: " All Projects",
|
||||
onBackPressed: () => Get.offNamed('/dashboard'),
|
||||
),
|
||||
body: Stack(
|
||||
@ -144,11 +112,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
|
||||
// Main content
|
||||
SafeArea(
|
||||
child: GetBuilder<EmployeesScreenController>(
|
||||
init: _employeeController,
|
||||
tag: 'employee_screen_controller',
|
||||
builder: (_) {
|
||||
_filterEmployees(_searchController.text);
|
||||
child: Obx(() {
|
||||
return MyRefreshIndicator(
|
||||
onRefresh: _refreshEmployees,
|
||||
child: SingleChildScrollView(
|
||||
@ -158,10 +122,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MySpacing.height(flexSpacing),
|
||||
Padding(
|
||||
padding: MySpacing.x(15),
|
||||
child: _buildSearchField(),
|
||||
),
|
||||
_buildSearchField(),
|
||||
MySpacing.height(flexSpacing),
|
||||
Padding(
|
||||
padding: MySpacing.x(flexSpacing),
|
||||
@ -171,8 +132,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -238,7 +198,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
size: 20, color: Colors.grey),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_filterEmployees('');
|
||||
},
|
||||
);
|
||||
},
|
||||
@ -255,13 +214,11 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
onChanged: (_) => _filterEmployees(_searchController.text),
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.width(10),
|
||||
|
||||
// Three dots menu (Manage Reporting)
|
||||
Container(
|
||||
height: 35,
|
||||
width: 35,
|
||||
@ -277,10 +234,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5)),
|
||||
itemBuilder: (context) {
|
||||
List<PopupMenuEntry<int>> menuItems = [];
|
||||
|
||||
// Section: Actions
|
||||
menuItems.add(
|
||||
return [
|
||||
const PopupMenuItem<int>(
|
||||
enabled: false,
|
||||
height: 30,
|
||||
@ -290,10 +244,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
fontWeight: FontWeight.bold, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Manage Reporting option
|
||||
menuItems.add(
|
||||
PopupMenuItem<int>(
|
||||
value: 1,
|
||||
child: Row(
|
||||
@ -317,9 +267,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return menuItems;
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
@ -329,7 +277,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
}
|
||||
|
||||
Widget _buildEmployeeList() {
|
||||
return Obx(() {
|
||||
if (_employeeController.isLoading.value) {
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
@ -340,7 +287,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
);
|
||||
}
|
||||
|
||||
final employees = _filteredEmployees;
|
||||
final employees = _employeeController.filteredEmployees;
|
||||
|
||||
if (employees.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
@ -395,22 +343,19 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
_buildLinkRow(
|
||||
icon: Icons.phone_outlined,
|
||||
text: e.phoneNumber,
|
||||
onTap: () =>
|
||||
LauncherUtils.launchPhone(e.phoneNumber),
|
||||
onTap: () => LauncherUtils.launchPhone(e.phoneNumber),
|
||||
onLongPress: () => LauncherUtils.copyToClipboard(
|
||||
e.phoneNumber,
|
||||
typeLabel: 'Phone')),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_forward_ios,
|
||||
color: Colors.grey, size: 16),
|
||||
const Icon(Icons.arrow_forward_ios, color: Colors.grey, size: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildLinkRow({
|
||||
|
||||
@ -36,6 +36,10 @@ class _ManageReportingBottomSheetState
|
||||
final TextEditingController _primaryController = TextEditingController();
|
||||
final TextEditingController _secondaryController = TextEditingController();
|
||||
|
||||
final FocusNode _mainEmployeeFocus = FocusNode();
|
||||
final FocusNode _primaryFocus = FocusNode();
|
||||
final FocusNode _secondaryFocus = FocusNode();
|
||||
|
||||
final RxList<EmployeeModel> _filteredPrimary = <EmployeeModel>[].obs;
|
||||
final RxList<EmployeeModel> _filteredSecondary = <EmployeeModel>[].obs;
|
||||
final RxList<EmployeeModel> _selectedPrimary = <EmployeeModel>[].obs;
|
||||
@ -69,6 +73,10 @@ class _ManageReportingBottomSheetState
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mainEmployeeFocus.dispose();
|
||||
_primaryFocus.dispose();
|
||||
_secondaryFocus.dispose();
|
||||
|
||||
_primaryController.dispose();
|
||||
_secondaryController.dispose();
|
||||
_selectEmployeeController.dispose();
|
||||
@ -368,6 +376,7 @@ class _ManageReportingBottomSheetState
|
||||
_buildSearchSection(
|
||||
label: "Primary Reporting Manager*",
|
||||
controller: _primaryController,
|
||||
focusNode: _primaryFocus,
|
||||
filteredList: _filteredPrimary,
|
||||
selectedList: _selectedPrimary,
|
||||
isPrimary: true,
|
||||
@ -379,6 +388,7 @@ class _ManageReportingBottomSheetState
|
||||
_buildSearchSection(
|
||||
label: "Secondary Reporting Manager",
|
||||
controller: _secondaryController,
|
||||
focusNode: _secondaryFocus,
|
||||
filteredList: _filteredSecondary,
|
||||
selectedList: _selectedSecondary,
|
||||
isPrimary: false,
|
||||
@ -386,12 +396,13 @@ class _ManageReportingBottomSheetState
|
||||
],
|
||||
);
|
||||
|
||||
// 🔥 WRAP EVERYTHING IN SAFEAREA + SCROLL + BOTTOM PADDING
|
||||
final safeWrappedContent = SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewPadding.bottom + 20,
|
||||
left: 16, right: 16, top: 8,
|
||||
bottom: 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 8,
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
@ -449,6 +460,7 @@ class _ManageReportingBottomSheetState
|
||||
Widget _buildSearchSection({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required FocusNode focusNode,
|
||||
required RxList<EmployeeModel> filteredList,
|
||||
required RxList<EmployeeModel> selectedList,
|
||||
required bool isPrimary,
|
||||
@ -459,20 +471,10 @@ class _ManageReportingBottomSheetState
|
||||
MyText.bodyMedium(label, fontWeight: 600),
|
||||
MySpacing.height(8),
|
||||
|
||||
// Search field
|
||||
TextField(
|
||||
_searchBar(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Type to search employees...",
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.grey[50],
|
||||
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
focusNode: focusNode,
|
||||
hint: "Type to search employees...",
|
||||
),
|
||||
|
||||
// Dropdown suggestions
|
||||
@ -567,19 +569,10 @@ class _ManageReportingBottomSheetState
|
||||
children: [
|
||||
MyText.bodyMedium("Select Employee *", fontWeight: 600),
|
||||
MySpacing.height(8),
|
||||
TextField(
|
||||
_searchBar(
|
||||
controller: _selectEmployeeController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Type to search employee...",
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.grey[50],
|
||||
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
focusNode: _mainEmployeeFocus,
|
||||
hint: "Type to search employee...",
|
||||
),
|
||||
Obx(() {
|
||||
if (_filteredEmployees.isEmpty) return const SizedBox.shrink();
|
||||
@ -641,4 +634,55 @@ class _ManageReportingBottomSheetState
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _searchBar({
|
||||
required TextEditingController controller,
|
||||
required FocusNode focusNode,
|
||||
required String hint,
|
||||
}) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
prefixIcon: const Padding(
|
||||
padding: EdgeInsets.only(left: 12, right: 8),
|
||||
child: Icon(Icons.search, size: 18, color: Colors.grey),
|
||||
),
|
||||
prefixIconConstraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.blueAccent,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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/reimbursement_bottom_sheet.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: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/expense/expense_detail_helpers.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/services/storage/local_storage.dart';
|
||||
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||
import 'package:timeline_tile/timeline_tile.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 permissionController = Get.put(PermissionController());
|
||||
|
||||
EmployeeInfo? employeeInfo;
|
||||
final RxBool canSubmit = false.obs;
|
||||
bool _checkedPermission = false;
|
||||
// Removed local employeeInfo, canSubmit, and _checkedPermission
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = Get.put(ExpenseDetailController(), tag: widget.expenseId);
|
||||
// EmployeeInfo loading and permission checking is now handled inside controller.init()
|
||||
controller.init(widget.expenseId);
|
||||
_loadEmployeeInfo();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -54,32 +51,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadEmployeeInfo() async {
|
||||
final info = await LocalStorage.getEmployeeInfo();
|
||||
employeeInfo = info;
|
||||
}
|
||||
|
||||
void _checkPermissionToSubmit(ExpenseDetailModel expense) {
|
||||
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||
|
||||
final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id;
|
||||
final nextStatusIds = expense.nextStatus.map((e) => e.id).toList();
|
||||
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
|
||||
|
||||
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
|
||||
|
||||
logSafe(
|
||||
'🐛 Checking submit permission:\n'
|
||||
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
|
||||
'🐛 - Expense created by ID: ${expense.createdBy.id}\n'
|
||||
'🐛 - Next Status IDs: $nextStatusIds\n'
|
||||
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
|
||||
'🐛 - Final Permission Result: $result',
|
||||
level: LogLevel.debug,
|
||||
);
|
||||
|
||||
canSubmit.value = result;
|
||||
}
|
||||
// Removed _loadEmployeeInfo and _checkPermissionToSubmit
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -89,6 +61,7 @@ Widget build(BuildContext context) {
|
||||
backgroundColor: const Color(0xFFF7F7F7),
|
||||
appBar: CustomAppBar(
|
||||
title: "Expense Details",
|
||||
projectName: " All Projects",
|
||||
backgroundColor: appBarColor,
|
||||
onBackPressed: () => Get.toNamed('/dashboard/expense-main-page'),
|
||||
),
|
||||
@ -119,9 +92,7 @@ Widget build(BuildContext context) {
|
||||
return Center(child: MyText.bodyMedium("No data to display."));
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkPermissionToSubmit(expense);
|
||||
});
|
||||
// Permission logic moved to controller (no need for postFrameCallback here)
|
||||
|
||||
final statusColor = getExpenseStatusColor(
|
||||
expense.status.name,
|
||||
@ -135,8 +106,7 @@ Widget build(BuildContext context) {
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom
|
||||
),
|
||||
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
@ -162,9 +132,11 @@ Widget build(BuildContext context) {
|
||||
Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodyMedium('Amount', fontWeight: 600),
|
||||
MyText.bodyMedium('Amount',
|
||||
fontWeight: 600),
|
||||
const SizedBox(height: 4),
|
||||
MyText.bodyLarge(
|
||||
formattedAmount,
|
||||
@ -223,21 +195,16 @@ Widget build(BuildContext context) {
|
||||
],
|
||||
),
|
||||
floatingActionButton: Obx(() {
|
||||
if (controller.isLoading.value) return buildLoadingSkeleton();
|
||||
|
||||
final expense = controller.expense.value;
|
||||
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!_checkedPermission) {
|
||||
_checkedPermission = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkPermissionToSubmit(expense);
|
||||
});
|
||||
}
|
||||
// Removed _checkedPermission and its logic
|
||||
|
||||
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
|
||||
if (!ExpensePermissionHelper.canEditExpense(
|
||||
controller.employeeInfo, // Use controller's employeeInfo
|
||||
expense)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@ -304,9 +271,8 @@ Widget build(BuildContext context) {
|
||||
controller.parsePermissionIds(rawPermissions);
|
||||
|
||||
final isSubmitStatus = next.id == submitStatusId;
|
||||
final isCreatedByCurrentUser =
|
||||
employeeInfo?.id == expense.createdBy.id;
|
||||
|
||||
final isCreatedByCurrentUser = controller.employeeInfo?.id ==
|
||||
expense.createdBy.id;
|
||||
if (isSubmitStatus) return isCreatedByCurrentUser;
|
||||
return permissionController.hasAnyPermission(parsedPermissions);
|
||||
}).map((next) {
|
||||
@ -319,7 +285,6 @@ Widget build(BuildContext context) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
|
||||
ExpenseDetailModel expense, dynamic next) {
|
||||
Color primary = Colors.red;
|
||||
|
||||
@ -95,6 +95,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
|
||||
backgroundColor: Colors.white,
|
||||
appBar: CustomAppBar(
|
||||
title: "Expense & Reimbursement",
|
||||
projectName: " All Projects",
|
||||
backgroundColor: appBarColor,
|
||||
onBackPressed: () => Get.toNamed('/dashboard/finance'),
|
||||
),
|
||||
@ -133,6 +134,10 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
|
||||
PillTabBar(
|
||||
controller: _tabController,
|
||||
tabs: const ["Current Month", "History"],
|
||||
icons: const [
|
||||
Icons.calendar_today,
|
||||
Icons.history,
|
||||
],
|
||||
selectedColor: contentTheme.primary,
|
||||
unselectedColor: Colors.grey.shade600,
|
||||
indicatorColor: contentTheme.primary,
|
||||
|
||||
@ -54,6 +54,7 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: CustomAppBar(
|
||||
title: "Advance Payments",
|
||||
projectName: " All Projects",
|
||||
onBackPressed: () => Get.offNamed('/dashboard/finance'),
|
||||
backgroundColor: appBarColor,
|
||||
),
|
||||
|
||||
@ -14,7 +14,6 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
|
||||
import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart';
|
||||
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
||||
|
||||
|
||||
class FinanceScreen extends StatefulWidget {
|
||||
const FinanceScreen({super.key});
|
||||
|
||||
@ -59,6 +58,7 @@ class _FinanceScreenState extends State<FinanceScreen>
|
||||
backgroundColor: const Color(0xFFF8F9FA),
|
||||
appBar: CustomAppBar(
|
||||
title: "Finance",
|
||||
projectName: " All Projects",
|
||||
onBackPressed: () => Get.offAllNamed('/dashboard'),
|
||||
backgroundColor: appBarColor,
|
||||
),
|
||||
|
||||
@ -114,6 +114,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
||||
backgroundColor: Colors.white,
|
||||
appBar: CustomAppBar(
|
||||
title: "Payment Request Details",
|
||||
projectName: " All Projects",
|
||||
backgroundColor: appBarColor,
|
||||
),
|
||||
body: Stack(
|
||||
@ -217,7 +218,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!_checkedPermission && request != null && employeeInfo != null) {
|
||||
if (!_checkedPermission && employeeInfo != null) {
|
||||
_checkedPermission = true;
|
||||
_checkPermissionToSubmit(request);
|
||||
}
|
||||
|
||||
@ -104,6 +104,7 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
||||
backgroundColor: Colors.white,
|
||||
appBar: CustomAppBar(
|
||||
title: "Payment Requests",
|
||||
projectName: " All Projects",
|
||||
onBackPressed: () => Get.offNamed('/dashboard/finance'),
|
||||
backgroundColor: appBarColor,
|
||||
),
|
||||
@ -142,6 +143,10 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
||||
PillTabBar(
|
||||
controller: _tabController,
|
||||
tabs: const ["Current Month", "History"],
|
||||
icons: const [
|
||||
Icons.calendar_today,
|
||||
Icons.history,
|
||||
],
|
||||
selectedColor: contentTheme.primary,
|
||||
unselectedColor: Colors.grey.shade600,
|
||||
indicatorColor: contentTheme.primary,
|
||||
@ -183,7 +188,13 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
||||
return canCreate
|
||||
? FloatingActionButton.extended(
|
||||
backgroundColor: contentTheme.primary,
|
||||
onPressed: showPaymentRequestBottomSheet,
|
||||
onPressed: () {
|
||||
showPaymentRequestBottomSheet(
|
||||
onUpdated: () async {
|
||||
await paymentController.fetchPaymentRequests();
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: const Text(
|
||||
"Create Payment Request",
|
||||
|
||||
306
lib/view/infraProject/assign_employee_infra_bottom_sheet.dart
Normal file
306
lib/view/infraProject/assign_employee_infra_bottom_sheet.dart
Normal file
@ -0,0 +1,306 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:on_field_work/controller/tenant/organization_selection_controller.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/tenant/organization_selector.dart';
|
||||
import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
|
||||
import 'package:on_field_work/model/employees/employee_model.dart';
|
||||
import 'package:on_field_work/model/employees/multiple_select_bottomsheet.dart';
|
||||
import 'package:on_field_work/controller/tenant/service_controller.dart';
|
||||
import 'package:on_field_work/helpers/widgets/tenant/service_selector.dart';
|
||||
import 'package:on_field_work/model/tenant/tenant_services_model.dart';
|
||||
import 'package:on_field_work/helpers/services/api_service.dart';
|
||||
import 'package:on_field_work/helpers/utils/base_bottom_sheet.dart';
|
||||
import 'package:on_field_work/model/infra_project/assign_project_allocation_request.dart';
|
||||
|
||||
class JobRole {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
JobRole({required this.id, required this.name});
|
||||
|
||||
factory JobRole.fromJson(Map<String, dynamic> json) {
|
||||
return JobRole(
|
||||
id: json['id'].toString(),
|
||||
name: json['name'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssignEmployeeBottomSheet extends StatefulWidget {
|
||||
final String projectId;
|
||||
|
||||
const AssignEmployeeBottomSheet({
|
||||
super.key,
|
||||
required this.projectId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AssignEmployeeBottomSheet> createState() =>
|
||||
_AssignEmployeeBottomSheetState();
|
||||
}
|
||||
|
||||
class _AssignEmployeeBottomSheetState extends State<AssignEmployeeBottomSheet> {
|
||||
late final OrganizationController _organizationController;
|
||||
late final ServiceController _serviceController;
|
||||
|
||||
final RxList<EmployeeModel> _selectedEmployees = <EmployeeModel>[].obs;
|
||||
|
||||
Organization? _selectedOrganization;
|
||||
JobRole? _selectedRole;
|
||||
|
||||
final RxBool _isLoadingRoles = false.obs;
|
||||
final RxList<JobRole> _roles = <JobRole>[].obs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_organizationController = Get.put(
|
||||
OrganizationController(),
|
||||
tag: 'assign_employee_org',
|
||||
);
|
||||
|
||||
_serviceController = Get.put(
|
||||
ServiceController(),
|
||||
tag: 'assign_employee_service',
|
||||
);
|
||||
|
||||
_organizationController.fetchOrganizations(widget.projectId);
|
||||
_serviceController.fetchServices(widget.projectId);
|
||||
|
||||
_fetchRoles();
|
||||
}
|
||||
|
||||
Future<void> _fetchRoles() async {
|
||||
try {
|
||||
_isLoadingRoles.value = true;
|
||||
final res = await ApiService.getRoles();
|
||||
if (res != null) {
|
||||
_roles.assignAll(
|
||||
res.map((e) => JobRole.fromJson(e)).toList(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_isLoadingRoles.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
Get.delete<OrganizationController>(tag: 'assign_employee_org');
|
||||
Get.delete<ServiceController>(tag: 'assign_employee_service');
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openEmployeeSelector() async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => EmployeeSelectionBottomSheet(
|
||||
title: 'Select Employee(s)',
|
||||
multipleSelection: true,
|
||||
initiallySelected: _selectedEmployees.toList(),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && result is List<EmployeeModel>) {
|
||||
_selectedEmployees.assignAll(result);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleAssign() async {
|
||||
if (_selectedEmployees.isEmpty ||
|
||||
_selectedRole == null ||
|
||||
_serviceController.selectedService == null) {
|
||||
Get.snackbar('Error', 'Please complete all selections');
|
||||
return;
|
||||
}
|
||||
|
||||
final allocations = _selectedEmployees
|
||||
.map(
|
||||
(e) => AssignProjectAllocationRequest(
|
||||
employeeId: e.id,
|
||||
projectId: widget.projectId,
|
||||
jobRoleId: _selectedRole!.id,
|
||||
serviceId: _serviceController.selectedService!.id,
|
||||
status: true,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final res = await ApiService.assignEmployeesToProject(
|
||||
allocations: allocations,
|
||||
);
|
||||
|
||||
if (res?.success == true) {
|
||||
Navigator.of(context).pop(true);
|
||||
} else {
|
||||
Get.snackbar('Error', res?.message ?? 'Assignment failed');
|
||||
}
|
||||
}
|
||||
|
||||
BoxDecoration _boxDecoration() => BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
title: 'Assign Employee',
|
||||
submitText: 'Assign',
|
||||
isSubmitting: false,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: _handleAssign,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
/// ORGANIZATION
|
||||
MyText.labelMedium(
|
||||
'Organization',
|
||||
fontWeight: 600,
|
||||
),
|
||||
MySpacing.height(8),
|
||||
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: _boxDecoration(),
|
||||
child: OrganizationSelector(
|
||||
controller: _organizationController,
|
||||
height: 48,
|
||||
onSelectionChanged: (Organization? org) async {
|
||||
_selectedOrganization = org;
|
||||
_selectedEmployees.clear();
|
||||
_selectedRole = null;
|
||||
_serviceController.clearSelection();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
MySpacing.height(20),
|
||||
|
||||
/// EMPLOYEES
|
||||
MyText.labelMedium(
|
||||
'Employees',
|
||||
fontWeight: 600,
|
||||
),
|
||||
MySpacing.height(8),
|
||||
|
||||
Obx(
|
||||
() => InkWell(
|
||||
onTap: _openEmployeeSelector,
|
||||
child: _dropdownBox(
|
||||
_selectedEmployees.isEmpty
|
||||
? 'Select employee(s)'
|
||||
: '${_selectedEmployees.length} employee(s) selected',
|
||||
icon: Icons.search,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
MySpacing.height(20),
|
||||
|
||||
/// SERVICE
|
||||
MyText.labelMedium(
|
||||
'Service',
|
||||
fontWeight: 600,
|
||||
),
|
||||
MySpacing.height(8),
|
||||
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: _boxDecoration(),
|
||||
child: ServiceSelector(
|
||||
controller: _serviceController,
|
||||
height: 48,
|
||||
onSelectionChanged: (Service? service) async {
|
||||
_selectedRole = null;
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
MySpacing.height(20),
|
||||
|
||||
/// JOB ROLE
|
||||
MyText.labelMedium(
|
||||
'Job Role',
|
||||
fontWeight: 600,
|
||||
),
|
||||
MySpacing.height(8),
|
||||
|
||||
Obx(() {
|
||||
if (_isLoadingRoles.value) {
|
||||
return _skeleton();
|
||||
}
|
||||
|
||||
return PopupMenuButton<JobRole>(
|
||||
offset: const Offset(0, 48),
|
||||
onSelected: (role) {
|
||||
_selectedRole = role;
|
||||
setState(() {});
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
if (_roles.isEmpty) {
|
||||
return const [
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
child: Text('No roles found'),
|
||||
),
|
||||
];
|
||||
}
|
||||
return _roles
|
||||
.map(
|
||||
(r) => PopupMenuItem<JobRole>(
|
||||
value: r,
|
||||
child: Text(r.name),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
child: _dropdownBox(
|
||||
_selectedRole?.name ?? 'Select role',
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dropdownBox(String text, {IconData icon = Icons.arrow_drop_down}) {
|
||||
return Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: _boxDecoration(),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
Icon(icon, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _skeleton() {
|
||||
return Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -10,11 +10,16 @@ import 'package:on_field_work/helpers/utils/launcher_utils.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_refresh_indicator.dart';
|
||||
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
||||
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
|
||||
|
||||
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
|
||||
import 'package:on_field_work/controller/infra_project/infra_project_screen_details_controller.dart';
|
||||
import 'package:on_field_work/view/taskPlanning/daily_progress_report.dart';
|
||||
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
|
||||
import 'package:on_field_work/model/infra_project/infra_team_list_model.dart';
|
||||
import 'package:on_field_work/view/infraProject/assign_employee_infra_bottom_sheet.dart';
|
||||
|
||||
|
||||
class InfraProjectDetailsScreen extends StatefulWidget {
|
||||
final String projectId;
|
||||
@ -36,51 +41,213 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
||||
late final TabController _tabController;
|
||||
final DynamicMenuController menuController =
|
||||
Get.find<DynamicMenuController>();
|
||||
late final InfraProjectDetailsController controller;
|
||||
final List<_InfraTab> _tabs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller =
|
||||
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
|
||||
_prepareTabs();
|
||||
}
|
||||
|
||||
void _prepareTabs() {
|
||||
// Profile tab is always added
|
||||
_tabs.add(_InfraTab(name: "Profile", view: _buildProfileTab()));
|
||||
_tabs.add(_InfraTab(
|
||||
name: "Profile",
|
||||
icon: Icons.person,
|
||||
view: _buildProfileTab(),
|
||||
));
|
||||
_tabs.add(_InfraTab(
|
||||
name: "Team",
|
||||
icon: Icons.group,
|
||||
view: _buildTeamTab(),
|
||||
));
|
||||
|
||||
final allowedMenu = menuController.menuItems.where((m) => m.available);
|
||||
|
||||
if (allowedMenu.any((m) => m.id == MenuItems.dailyTaskPlanning)) {
|
||||
_tabs.add(
|
||||
_InfraTab(
|
||||
_tabs.add(_InfraTab(
|
||||
name: "Task Planning",
|
||||
icon: Icons.task,
|
||||
view: DailyTaskPlanningScreen(projectId: widget.projectId),
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
if (allowedMenu.any((m) => m.id == MenuItems.dailyProgressReport)) {
|
||||
_tabs.add(
|
||||
_InfraTab(
|
||||
_tabs.add(_InfraTab(
|
||||
name: "Task Progress",
|
||||
icon: Icons.trending_up,
|
||||
view: DailyProgressReportScreen(projectId: widget.projectId),
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
_tabController = TabController(length: _tabs.length, vsync: this);
|
||||
}
|
||||
|
||||
void _openAssignEmployeeBottomSheet() async {
|
||||
final result = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => AssignEmployeeBottomSheet(
|
||||
projectId: widget.projectId,
|
||||
),
|
||||
);
|
||||
if (result == true) {
|
||||
controller.fetchProjectTeamList();
|
||||
Get.snackbar('Success', 'Employee assigned successfully');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildProfileTab() {
|
||||
final controller =
|
||||
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
|
||||
Widget _buildTeamTab() {
|
||||
return Obx(() {
|
||||
if (controller.teamLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (controller.teamErrorMessage.isNotEmpty) {
|
||||
return Center(
|
||||
child: MyText.bodyMedium(controller.teamErrorMessage.value),
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.teamList.isEmpty) {
|
||||
return const Center(
|
||||
child: Text("No team members allocated to this project."),
|
||||
);
|
||||
}
|
||||
|
||||
final roleGroups = controller.groupedTeamByRole;
|
||||
|
||||
final sortedRoleEntries = roleGroups.entries.toList()
|
||||
..sort((a, b) {
|
||||
final aName = (a.value.isNotEmpty ? a.value.first.jobRoleName : '')
|
||||
.toLowerCase();
|
||||
final bName = (b.value.isNotEmpty ? b.value.first.jobRoleName : '')
|
||||
.toLowerCase();
|
||||
return aName.compareTo(bName);
|
||||
});
|
||||
|
||||
return MyRefreshIndicator(
|
||||
onRefresh: controller.fetchProjectTeamList,
|
||||
backgroundColor: Colors.indigo,
|
||||
color: Colors.white,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: sortedRoleEntries.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final teamMembers = sortedRoleEntries[index].value;
|
||||
return _buildRoleCard(teamMembers);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildRoleCard(List<ProjectAllocation> teamMembers) {
|
||||
teamMembers.sort((a, b) {
|
||||
final aName = ("${a.firstName} ${a.lastName}").trim().toLowerCase();
|
||||
final bName = ("${b.firstName} ${b.lastName}").trim().toLowerCase();
|
||||
return aName.compareTo(bName);
|
||||
});
|
||||
|
||||
final String roleName =
|
||||
(teamMembers.isNotEmpty ? (teamMembers.first.jobRoleName) : '').trim();
|
||||
|
||||
return Card(
|
||||
elevation: 3,
|
||||
shadowColor: Colors.black26,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// TOP: Job Role name
|
||||
if (roleName.isNotEmpty) ...[
|
||||
MyText.bodyLarge(
|
||||
roleName,
|
||||
fontWeight: 700,
|
||||
),
|
||||
const Divider(height: 20),
|
||||
] else
|
||||
const Divider(height: 20),
|
||||
|
||||
// Team members list
|
||||
...teamMembers.map((allocation) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Get.to(
|
||||
() => EmployeeProfilePage(
|
||||
employeeId: allocation.employeeId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Avatar(
|
||||
firstName: allocation.firstName,
|
||||
lastName: allocation.lastName,
|
||||
size: 32,
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleMedium(
|
||||
"${allocation.firstName} ${allocation.lastName}",
|
||||
fontWeight: 600,
|
||||
),
|
||||
MyText.bodySmall(
|
||||
allocation.serviceName.isNotEmpty
|
||||
? "Service: ${allocation.serviceName}"
|
||||
: "No Service Assigned",
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
MyText.bodySmall(
|
||||
"Allocated",
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
MyText.bodySmall(
|
||||
DateFormat('d MMM yyyy').format(
|
||||
DateTime.parse(allocation.allocationDate),
|
||||
),
|
||||
fontWeight: 600,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileTab() {
|
||||
return Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@ -155,23 +322,27 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
||||
_buildDetailRow(
|
||||
icon: Icons.location_on_outlined,
|
||||
label: 'Address',
|
||||
value: data.projectAddress ?? "-"),
|
||||
value: data.projectAddress ?? "-",
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
label: 'Start Date',
|
||||
value: data.startDate != null
|
||||
? DateFormat('d/M/yyyy').format(data.startDate!)
|
||||
: "-"),
|
||||
: "-",
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
label: 'End Date',
|
||||
value: data.endDate != null
|
||||
? DateFormat('d/M/yyyy').format(data.endDate!)
|
||||
: "-"),
|
||||
: "-",
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.flag_outlined,
|
||||
label: 'Status',
|
||||
value: data.projectStatus?.status ?? "-"),
|
||||
value: data.projectStatus?.status ?? "-",
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.person_outline,
|
||||
label: 'Contact Person',
|
||||
@ -181,7 +352,8 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
||||
if (data.contactPerson != null) {
|
||||
LauncherUtils.launchPhone(data.contactPerson!);
|
||||
}
|
||||
}),
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -194,20 +366,22 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
||||
_buildDetailRow(
|
||||
icon: Icons.person_outline,
|
||||
label: 'Name',
|
||||
value: promoter.name ?? "-"),
|
||||
value: promoter.name ?? "-",
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.phone_outlined,
|
||||
label: 'Contact',
|
||||
value: promoter.contactNumber ?? "-",
|
||||
isActionable: true,
|
||||
onTap: () =>
|
||||
LauncherUtils.launchPhone(promoter.contactNumber ?? "")),
|
||||
onTap: () => LauncherUtils.launchPhone(promoter.contactNumber ?? ""),
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.email_outlined,
|
||||
label: 'Email',
|
||||
value: promoter.email ?? "-",
|
||||
isActionable: true,
|
||||
onTap: () => LauncherUtils.launchEmail(promoter.email ?? "")),
|
||||
onTap: () => LauncherUtils.launchEmail(promoter.email ?? ""),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -218,19 +392,24 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
||||
titleIcon: Icons.engineering_outlined,
|
||||
children: [
|
||||
_buildDetailRow(
|
||||
icon: Icons.person_outline, label: 'Name', value: pmc.name ?? "-"),
|
||||
icon: Icons.person_outline,
|
||||
label: 'Name',
|
||||
value: pmc.name ?? "-",
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.phone_outlined,
|
||||
label: 'Contact',
|
||||
value: pmc.contactNumber ?? "-",
|
||||
isActionable: true,
|
||||
onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? "")),
|
||||
onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? ""),
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.email_outlined,
|
||||
label: 'Email',
|
||||
value: pmc.email ?? "-",
|
||||
isActionable: true,
|
||||
onTap: () => LauncherUtils.launchEmail(pmc.email ?? "")),
|
||||
onTap: () => LauncherUtils.launchEmail(pmc.email ?? ""),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -251,7 +430,9 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8), child: Icon(icon, size: 20)),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(icon, size: 20),
|
||||
),
|
||||
MySpacing.width(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@ -329,6 +510,19 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
||||
projectName: widget.projectName,
|
||||
backgroundColor: appBarColor,
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
_openAssignEmployeeBottomSheet();
|
||||
},
|
||||
backgroundColor: contentTheme.primary,
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: MyText(
|
||||
'Assign Employee',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontWeight: 500,
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Container(
|
||||
@ -349,6 +543,7 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
||||
PillTabBar(
|
||||
controller: _tabController,
|
||||
tabs: _tabs.map((e) => e.name).toList(),
|
||||
icons: _tabs.map((e) => e.icon).toList(),
|
||||
selectedColor: contentTheme.primary,
|
||||
unselectedColor: Colors.grey.shade600,
|
||||
indicatorColor: contentTheme.primary,
|
||||
@ -371,7 +566,12 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
||||
/// INTERNAL MODEL
|
||||
class _InfraTab {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final Widget view;
|
||||
|
||||
_InfraTab({required this.name, required this.view});
|
||||
_InfraTab({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.view,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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/view/infraProject/infra_project_details_screen.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||
|
||||
class InfraProjectScreen extends StatefulWidget {
|
||||
const InfraProjectScreen({super.key});
|
||||
@ -245,7 +246,7 @@ class _InfraProjectScreenState extends State<InfraProjectScreen> with UIMixin {
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return Center(child: SkeletonLoaders.serviceProjectListSkeletonLoader());
|
||||
}
|
||||
|
||||
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/images.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';
|
||||
|
||||
class Layout extends StatefulWidget {
|
||||
@ -24,7 +24,6 @@ class Layout extends StatefulWidget {
|
||||
class _LayoutState extends State<Layout> with UIMixin {
|
||||
final LayoutController controller = LayoutController();
|
||||
final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo();
|
||||
final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
|
||||
|
||||
bool hasMpin = true;
|
||||
|
||||
@ -46,18 +45,16 @@ class _LayoutState extends State<Layout> with UIMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MyResponsive(builder: (context, _, screenMT) {
|
||||
return GetBuilder(
|
||||
return GetBuilder<LayoutController>(
|
||||
init: controller,
|
||||
builder: (_) {
|
||||
return (screenMT.isMobile || screenMT.isTablet)
|
||||
? _buildScaffold(context, isMobile: true)
|
||||
: _buildScaffold(context);
|
||||
return _buildScaffold(context);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildScaffold(BuildContext context, {bool isMobile = false}) {
|
||||
Widget _buildScaffold(BuildContext context) {
|
||||
final primaryColor = contentTheme.primary;
|
||||
|
||||
return Scaffold(
|
||||
@ -66,11 +63,10 @@ class _LayoutState extends State<Layout> with UIMixin {
|
||||
floatingActionButton: widget.floatingActionButton,
|
||||
body: Column(
|
||||
children: [
|
||||
// Solid primary background area
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: primaryColor,
|
||||
child: _buildHeaderContent(isMobile),
|
||||
child: _buildHeaderContent(),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
@ -91,27 +87,27 @@ class _LayoutState extends State<Layout> with UIMixin {
|
||||
top: false,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {},
|
||||
child: SingleChildScrollView(
|
||||
key: controller.scrollKey,
|
||||
padding: EdgeInsets.zero,
|
||||
child: widget.child,
|
||||
),
|
||||
onTap: () =>
|
||||
FocusScope.of(context).unfocus(),
|
||||
child: widget.child ??
|
||||
const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderContent(bool isMobile) {
|
||||
final selectedTenant = TenantService.currentTenant;
|
||||
Widget _buildHeaderContent() {
|
||||
final selectedTenant = AuthService.currentTenant;
|
||||
final bool isBeta = ApiEndpoints.baseUrl.contains("stage");
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 18),
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@ -139,7 +135,7 @@ class _LayoutState extends State<Layout> with UIMixin {
|
||||
),
|
||||
|
||||
// Beta badge
|
||||
if (ApiEndpoints.baseUrl.contains("stage"))
|
||||
if (isBeta)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 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/controller/auth/mpin_controller.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/controller/tenant/tenant_switch_controller.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);
|
||||
if (selectedTenant != null) {
|
||||
|
||||
291
lib/view/mandatory_update_screen.dart
Normal file
291
lib/view/mandatory_update_screen.dart
Normal file
@ -0,0 +1,291 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_in_store_app_version_checker/flutter_in_store_app_version_checker.dart';
|
||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:on_field_work/images.dart';
|
||||
import 'package:on_field_work/helpers/widgets/wave_background.dart';
|
||||
|
||||
class MandatoryUpdateScreen extends StatefulWidget {
|
||||
final String newVersion;
|
||||
final InStoreAppVersionCheckerResult? updateResult;
|
||||
|
||||
const MandatoryUpdateScreen({
|
||||
super.key,
|
||||
required this.newVersion,
|
||||
this.updateResult,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MandatoryUpdateScreen> createState() => _MandatoryUpdateScreenState();
|
||||
}
|
||||
|
||||
class _MandatoryUpdateScreenState extends State<MandatoryUpdateScreen>
|
||||
with SingleTickerProviderStateMixin, UIMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _logoAnimation;
|
||||
|
||||
static const double _kMaxContentWidth = 480.0;
|
||||
|
||||
Color get _primaryColor => contentTheme.primary;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
_logoAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _launchStoreUrl() async {
|
||||
final url = widget.updateResult?.appURL;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
final uri = Uri.parse(url);
|
||||
try {
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
logSafe("Could not launch store URL: $url");
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe(
|
||||
"Error launching store URL: $url",
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
level: LogLevel.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
RedWaveBackground(brandRed: _primaryColor),
|
||||
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints:
|
||||
const BoxConstraints(maxWidth: _kMaxContentWidth),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
|
||||
ScaleTransition(
|
||||
scale: _logoAnimation,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Image.asset(Images.logoDark),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
"Update Required",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Subtitle/Message
|
||||
Text(
|
||||
"A mandatory update (version ${widget.newVersion}) is available to continue using the application. Please update now for uninterrupted access.",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
color: Colors.black54,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Prominent Action Button
|
||||
ElevatedButton.icon(
|
||||
onPressed: _launchStoreUrl,
|
||||
icon: const Icon(
|
||||
Icons.system_update_alt,
|
||||
color: Colors.white,
|
||||
),
|
||||
label: Text(
|
||||
"UPDATE NOW",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 18),
|
||||
backgroundColor: _primaryColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
elevation: 5,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Why Update Section
|
||||
Text(
|
||||
"Why updating is important:",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BulletPoint(
|
||||
text:
|
||||
"Access new features and improvements"),
|
||||
BulletPoint(
|
||||
text:
|
||||
"Fix critical bugs and security issues"),
|
||||
BulletPoint(
|
||||
text:
|
||||
"Ensure smooth app performance and stability"),
|
||||
BulletPoint(
|
||||
text:
|
||||
"Stay compatible with latest operating system and services"),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Text(
|
||||
"Thank you for keeping your app up to date!",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Colors.black45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BulletPoint extends StatelessWidget {
|
||||
final String text;
|
||||
final Color bulletColor;
|
||||
|
||||
const BulletPoint({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.bulletColor = const Color(0xFF555555),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8, top: 4),
|
||||
child: Icon(
|
||||
Icons.circle,
|
||||
size: 6,
|
||||
color: bulletColor,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.black87,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,9 @@ import 'package:get/get.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:flutter_in_store_app_version_checker/flutter_in_store_app_version_checker.dart';
|
||||
|
||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||
import 'package:on_field_work/helpers/extensions/app_localization_delegate.dart';
|
||||
import 'package:on_field_work/helpers/services/localizations/language.dart';
|
||||
import 'package:on_field_work/helpers/services/navigation_services.dart';
|
||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
@ -13,84 +14,120 @@ import 'package:on_field_work/helpers/theme/theme_customizer.dart';
|
||||
import 'package:on_field_work/helpers/theme/app_notifier.dart';
|
||||
import 'package:on_field_work/routes.dart';
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
final bool isOffline;
|
||||
import 'package:on_field_work/view/mandatory_update_screen.dart';
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
final bool isOffline;
|
||||
const MyApp({super.key, required this.isOffline});
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
bool _needsUpdate = false;
|
||||
String? _newVersion;
|
||||
InStoreAppVersionCheckerResult? _updateResult;
|
||||
|
||||
final InStoreAppVersionChecker _checker = InStoreAppVersionChecker(
|
||||
androidStore: AndroidStore.googlePlayStore,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkVersion();
|
||||
}
|
||||
|
||||
/// -------------------------
|
||||
/// Version Check
|
||||
/// -------------------------
|
||||
Future<void> _checkVersion() async {
|
||||
try {
|
||||
final result = await _checker.checkUpdate();
|
||||
_updateResult = result;
|
||||
logSafe("Version Check initiated...");
|
||||
logSafe("Current App Version: ${_checker.currentVersion}");
|
||||
logSafe("Result canUpdate: ${result.canUpdate}");
|
||||
logSafe("Result newVersion: ${result.newVersion}");
|
||||
logSafe("Result appURL: ${result.appURL}");
|
||||
if (result.canUpdate) {
|
||||
setState(() {
|
||||
_needsUpdate = true;
|
||||
_newVersion = result.newVersion ?? "";
|
||||
});
|
||||
|
||||
logSafe("New version available → $_newVersion");
|
||||
}
|
||||
|
||||
if (result.errorMessage != null) {
|
||||
logSafe("VersionChecker Error: ${result.errorMessage}");
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe(
|
||||
"Version check exception",
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
level: LogLevel.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// -------------------------
|
||||
/// Initial Route Logic
|
||||
/// -------------------------
|
||||
Future<String> _getInitialRoute() async {
|
||||
try {
|
||||
final token = LocalStorage.getJwtToken();
|
||||
|
||||
if (token == null || token.isEmpty) {
|
||||
logSafe("User not logged in. Routing to /auth/login-option");
|
||||
return "/auth/login-option";
|
||||
}
|
||||
|
||||
final bool hasMpin = LocalStorage.getIsMpin();
|
||||
if (hasMpin) {
|
||||
if (LocalStorage.getIsMpin()) {
|
||||
await LocalStorage.setBool("mpin_verified", false);
|
||||
logSafe("Routing to /auth/mpin-auth");
|
||||
return "/auth/mpin-auth";
|
||||
}
|
||||
|
||||
logSafe("No MPIN. Routing to /dashboard");
|
||||
return "/dashboard";
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Error determining initial route",
|
||||
level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
} catch (e, stack) {
|
||||
logSafe(
|
||||
"Initial route ERROR",
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
level: LogLevel.error,
|
||||
);
|
||||
return "/auth/login-option";
|
||||
}
|
||||
}
|
||||
|
||||
// ✨ REVISED: Helper Widget to show a full-screen, well-designed offline status
|
||||
Widget _buildConnectivityOverlay(BuildContext context) {
|
||||
// If not offline, return an empty widget.
|
||||
if (!isOffline) return const SizedBox.shrink();
|
||||
|
||||
// Otherwise, return a full-screen overlay.
|
||||
/// -------------------------
|
||||
/// Offline Overlay (Blocking)
|
||||
/// -------------------------
|
||||
Widget _buildOfflineOverlay() {
|
||||
return Directionality(
|
||||
textDirection: AppTheme.textDirection,
|
||||
child: Scaffold(
|
||||
backgroundColor:
|
||||
Colors.grey.shade100, // Light background for the offline state
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_off,
|
||||
color: Colors.red.shade700, // Prominent color
|
||||
size: 100,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Icon(Icons.cloud_off, size: 100, color: Colors.red.shade600),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
"You Are Offline",
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 10),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 40.0),
|
||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
"Please check your internet connection and try again.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.black54,
|
||||
style: TextStyle(fontSize: 16, color: Colors.black54),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Optional: Add a button for the user to potentially refresh/retry
|
||||
// ElevatedButton(
|
||||
// onPressed: () {
|
||||
// // Add logic to re-check connectivity or navigate (if possible)
|
||||
// },
|
||||
// child: const Text("RETRY"),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -98,21 +135,28 @@ class MyApp extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// -------------------------
|
||||
/// Build
|
||||
/// -------------------------
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_needsUpdate && !widget.isOffline) {
|
||||
// 4. USE THE NEW WIDGET HERE
|
||||
return MandatoryUpdateScreen(
|
||||
newVersion: _newVersion ?? "",
|
||||
updateResult: _updateResult,
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.isOffline) {
|
||||
return _buildOfflineOverlay();
|
||||
}
|
||||
|
||||
return Consumer<AppNotifier>(
|
||||
builder: (_, notifier, __) {
|
||||
return FutureBuilder<String>(
|
||||
future: _getInitialRoute(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
logSafe("FutureBuilder snapshot error",
|
||||
level: LogLevel.error, error: snapshot.error);
|
||||
return const MaterialApp(
|
||||
home: Center(child: Text("Error determining route")),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.hasData) {
|
||||
return const MaterialApp(
|
||||
home: Center(child: CircularProgressIndicator()),
|
||||
@ -127,29 +171,19 @@ class MyApp extends StatelessWidget {
|
||||
navigatorKey: NavigationService.navigatorKey,
|
||||
initialRoute: snapshot.data!,
|
||||
getPages: getPageRoute(),
|
||||
builder: (context, child) {
|
||||
NavigationService.registerContext(context);
|
||||
|
||||
// 💡 REVISED: Use a Stack to place the offline overlay ON TOP of the app content.
|
||||
// This allows the full-screen view to cover everything, including the main app content.
|
||||
return Stack(
|
||||
children: [
|
||||
Directionality(
|
||||
textDirection: AppTheme.textDirection,
|
||||
child: child ?? const SizedBox(),
|
||||
),
|
||||
// 2. The full-screen connectivity overlay, only visible when offline
|
||||
_buildConnectivityOverlay(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
localizationsDelegates: [
|
||||
AppLocalizationsDelegate(context),
|
||||
supportedLocales: Language.getLocales(),
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: Language.getLocales(),
|
||||
builder: (context, child) {
|
||||
NavigationService.registerContext(context);
|
||||
return Directionality(
|
||||
textDirection: AppTheme.textDirection,
|
||||
child: child ?? const SizedBox(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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/helpers/widgets/my_refresh_indicator.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 {
|
||||
final String projectId;
|
||||
@ -332,7 +334,8 @@ class _ServiceProjectDetailsScreenState
|
||||
Widget _buildTeamsTab() {
|
||||
return Obx(() {
|
||||
if (controller.isTeamLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return Center(
|
||||
child: SkeletonLoaders.serviceProjectListSkeletonLoader());
|
||||
}
|
||||
|
||||
if (controller.teamErrorMessage.value.isNotEmpty &&
|
||||
@ -385,7 +388,14 @@ class _ServiceProjectDetailsScreenState
|
||||
const Divider(height: 20, thickness: 1),
|
||||
// List of team members inside this role card
|
||||
...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),
|
||||
child: Row(
|
||||
children: [
|
||||
@ -416,6 +426,7 @@ class _ServiceProjectDetailsScreenState
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
@ -464,6 +475,11 @@ class _ServiceProjectDetailsScreenState
|
||||
PillTabBar(
|
||||
controller: _tabController,
|
||||
tabs: const ["Profile", "Jobs", "Teams"],
|
||||
icons: const [
|
||||
Icons.person,
|
||||
Icons.work,
|
||||
Icons.group,
|
||||
],
|
||||
selectedColor: contentTheme.primary,
|
||||
unselectedColor: Colors.grey.shade600,
|
||||
indicatorColor: contentTheme.primary.withOpacity(0.1),
|
||||
@ -475,7 +491,9 @@ class _ServiceProjectDetailsScreenState
|
||||
child: Obx(() {
|
||||
if (controller.isLoading.value &&
|
||||
controller.projectDetail.value == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return Center(
|
||||
child: SkeletonLoaders
|
||||
.serviceProjectListSkeletonLoader());
|
||||
}
|
||||
if (controller.errorMessage.value.isNotEmpty &&
|
||||
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/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/my_custom_skeleton.dart';
|
||||
|
||||
class ServiceProjectScreen extends StatefulWidget {
|
||||
const ServiceProjectScreen({super.key});
|
||||
@ -264,7 +265,9 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return Center(
|
||||
child: SkeletonLoaders
|
||||
.serviceProjectListSkeletonLoader());
|
||||
}
|
||||
final projects = controller.filteredProjects;
|
||||
|
||||
|
||||
@ -29,6 +29,9 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
// Animation for logo and text fade-in
|
||||
late Animation<double> _opacityAnimation;
|
||||
|
||||
// Animation for the gradient shimmer effect (moves from -1.0 to 2.0)
|
||||
late Animation<double> _shimmerAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -39,7 +42,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
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(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
@ -56,7 +59,15 @@ 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(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
@ -66,10 +77,10 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
|
||||
// Start the complex animation sequence
|
||||
_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) {
|
||||
_controller.repeat(
|
||||
min: 0.4, // Start repeat from the float interval
|
||||
min: 0.4, // Keep repeat range for float animation
|
||||
max: 1.0,
|
||||
reverse: true,
|
||||
);
|
||||
@ -83,6 +94,70 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
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),
|
||||
children: <TextSpan>[
|
||||
// 'OnField' - Blue (#007bff)
|
||||
const TextSpan(
|
||||
text: 'OnField',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF007BFF),
|
||||
),
|
||||
),
|
||||
|
||||
// 'Work' - Green (#71dd37)
|
||||
const TextSpan(
|
||||
text: 'Work',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF71DD37),
|
||||
),
|
||||
),
|
||||
|
||||
// '.com' - Shimmer gradient
|
||||
TextSpan(
|
||||
text: '.com',
|
||||
style: textStyle.copyWith(
|
||||
foreground: Paint()
|
||||
..shader = shimmerGradient.createShader(
|
||||
const Rect.fromLTWH(0.0, 0.0, 150.0, 50.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// A simple, modern custom progress indicator
|
||||
Widget _buildProgressIndicator() {
|
||||
return SizedBox(
|
||||
@ -98,7 +173,6 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: widget.backgroundColor,
|
||||
// Full screen display, no SafeArea needed for a full bleed splash
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@ -127,6 +201,15 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
|
||||
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)
|
||||
if (widget.message != null)
|
||||
FadeTransition(
|
||||
|
||||
@ -84,7 +84,24 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
MySpacing.height(flexSpacing),
|
||||
Padding(
|
||||
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,
|
||||
height: 40,
|
||||
onSelectionChanged: (service) async {
|
||||
@ -97,7 +114,8 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
MySpacing.height(flexSpacing),
|
||||
Padding(
|
||||
@ -126,12 +144,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly();
|
||||
}
|
||||
|
||||
// Check 1: If no daily tasks are fetched at all
|
||||
if (dailyTasks.isEmpty) {
|
||||
return Center(
|
||||
child: MyText.bodySmall(
|
||||
"No Progress Report Found",
|
||||
fontWeight: 600,
|
||||
),
|
||||
return const _EmptyDataCard(
|
||||
title: "No Daily Tasks Found",
|
||||
subtitle: "No progress reports are planned for the selected filter.",
|
||||
);
|
||||
}
|
||||
|
||||
@ -164,11 +181,10 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
.toList();
|
||||
|
||||
if (buildings.isEmpty) {
|
||||
return Center(
|
||||
child: MyText.bodySmall(
|
||||
"No Progress Report Found",
|
||||
fontWeight: 600,
|
||||
),
|
||||
return const _EmptyDataCard(
|
||||
title: "No Progress Report Found",
|
||||
subtitle:
|
||||
"No work is planned or completed for the selected service/project.",
|
||||
);
|
||||
}
|
||||
|
||||
@ -247,11 +263,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
.dailyProgressPlanningInfraSkeleton(),
|
||||
)
|
||||
else if (!buildingLoaded || building.floors.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: MyText.bodySmall(
|
||||
"No Progress Report Found for this Project",
|
||||
fontWeight: 600,
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: _EmptyDataMessage(
|
||||
message:
|
||||
"No floors or work data found for this building.",
|
||||
),
|
||||
)
|
||||
else
|
||||
@ -431,33 +447,40 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
.assignReportTask))
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.person_add_alt_1_rounded,
|
||||
Icons
|
||||
.person_add_alt_1_rounded,
|
||||
color: Color.fromARGB(
|
||||
255, 46, 161, 233),
|
||||
),
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
final pendingTask =
|
||||
(planned - completed)
|
||||
.clamp(0, planned)
|
||||
.toInt();
|
||||
|
||||
showModalBottomSheet(
|
||||
// Wait until user closes bottom sheet
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape:
|
||||
const RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.vertical(
|
||||
top:
|
||||
Radius.circular(
|
||||
top: Radius
|
||||
.circular(
|
||||
16)),
|
||||
),
|
||||
builder: (context) =>
|
||||
AssignTaskBottomSheet(
|
||||
buildingName: building.name,
|
||||
floorName: floor.floorName,
|
||||
workAreaName: area.areaName,
|
||||
workLocation: area.areaName,
|
||||
buildingId: building.id,
|
||||
buildingName:
|
||||
building.name,
|
||||
floorName:
|
||||
floor.floorName,
|
||||
workAreaName:
|
||||
area.areaName,
|
||||
workLocation:
|
||||
area.areaName,
|
||||
activityName: item
|
||||
.activityMaster
|
||||
?.name ??
|
||||
@ -469,11 +492,12 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
}),
|
||||
],
|
||||
),
|
||||
MySpacing.height(6),
|
||||
MySpacing.height(4),
|
||||
Row(
|
||||
children: [
|
||||
MyText.bodySmall(
|
||||
@ -538,3 +562,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,13 +50,12 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
|
||||
}
|
||||
|
||||
Future<void> _onTenantSelected(String tenantId) async {
|
||||
await _controller.onTenantSelected(tenantId);
|
||||
return _controller.onTenantSelected(tenantId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
// Splash screen for auto-selection
|
||||
if (_controller.isAutoSelecting.value) {
|
||||
return const SplashScreen();
|
||||
}
|
||||
@ -91,6 +90,7 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
|
||||
controller: _controller,
|
||||
isLoading: _controller.isLoading.value,
|
||||
onTenantSelected: _onTenantSelected,
|
||||
primaryColor: contentTheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -109,7 +109,6 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated Logo Widget
|
||||
class _AnimatedLogo extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
const _AnimatedLogo({required this.animation});
|
||||
@ -139,7 +138,6 @@ class _AnimatedLogo extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Welcome Texts
|
||||
class _WelcomeTexts extends StatelessWidget {
|
||||
const _WelcomeTexts();
|
||||
|
||||
@ -166,7 +164,6 @@ class _WelcomeTexts extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Beta Badge
|
||||
class _BetaBadge extends StatelessWidget {
|
||||
const _BetaBadge();
|
||||
|
||||
@ -188,16 +185,18 @@ class _BetaBadge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tenant Card List
|
||||
class TenantCardList extends StatelessWidget with UIMixin {
|
||||
class TenantCardList extends StatelessWidget {
|
||||
final TenantSelectionController controller;
|
||||
final bool isLoading;
|
||||
final Function(String tenantId) onTenantSelected;
|
||||
final Color primaryColor;
|
||||
|
||||
TenantCardList({
|
||||
const TenantCardList({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.isLoading,
|
||||
required this.onTenantSelected,
|
||||
required this.primaryColor,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -226,18 +225,16 @@ class TenantCardList extends StatelessWidget with UIMixin {
|
||||
(tenant) => _TenantCard(
|
||||
tenant: tenant,
|
||||
onTap: () => onTenantSelected(tenant.id),
|
||||
primaryColor: primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await LocalStorage.logout();
|
||||
},
|
||||
icon:
|
||||
Icon(Icons.arrow_back, size: 20, color: contentTheme.primary,),
|
||||
onPressed: LocalStorage.logout,
|
||||
icon: Icon(Icons.arrow_back, size: 20, color: primaryColor),
|
||||
label: MyText(
|
||||
'Back to Login',
|
||||
color: contentTheme.primary,
|
||||
color: primaryColor,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
),
|
||||
@ -248,11 +245,16 @@ class TenantCardList extends StatelessWidget with UIMixin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Single Tenant Card
|
||||
class _TenantCard extends StatelessWidget with UIMixin {
|
||||
class _TenantCard extends StatelessWidget {
|
||||
final dynamic tenant;
|
||||
final VoidCallback onTap;
|
||||
_TenantCard({required this.tenant, required this.onTap});
|
||||
final Color primaryColor;
|
||||
|
||||
const _TenantCard({
|
||||
required this.tenant,
|
||||
required this.onTap,
|
||||
required this.primaryColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -297,7 +299,7 @@ class _TenantCard extends StatelessWidget with UIMixin {
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.arrow_forward_ios, size: 24, color: contentTheme.primary,),
|
||||
Icon(Icons.arrow_forward_ios, size: 24, color: primaryColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -306,7 +308,6 @@ class _TenantCard extends StatelessWidget with UIMixin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tenant Logo (supports base64 and URL)
|
||||
class TenantLogo extends StatelessWidget {
|
||||
final String? logoImage;
|
||||
const TenantLogo({required this.logoImage});
|
||||
@ -324,14 +325,13 @@ class TenantLogo extends StatelessWidget {
|
||||
} catch (_) {
|
||||
return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
return Image.network(
|
||||
logoImage!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Center(
|
||||
child: Icon(Icons.business, color: Colors.grey.shade600),
|
||||
),
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Center(child: Icon(Icons.business, color: Colors.grey.shade600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
pubspec.yaml
29
pubspec.yaml
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+18
|
||||
version: 1.0.1+20
|
||||
environment:
|
||||
sdk: ^3.5.3
|
||||
|
||||
@ -46,17 +46,17 @@ dependencies:
|
||||
carousel_slider: ^5.0.0
|
||||
reorderable_grid: ^1.0.10
|
||||
loading_animation_widget: ^1.3.0
|
||||
intl: ^0.19.0
|
||||
syncfusion_flutter_core: ^29.1.40
|
||||
syncfusion_flutter_sliders: ^29.1.40
|
||||
intl: ^0.20.2
|
||||
syncfusion_flutter_core: ^31.2.18
|
||||
syncfusion_flutter_sliders: ^31.2.18
|
||||
file_picker: ^10.3.2
|
||||
timelines_plus: ^1.0.4
|
||||
syncfusion_flutter_charts: ^29.1.40
|
||||
syncfusion_flutter_charts: ^31.2.18
|
||||
appflowy_board: ^0.1.2
|
||||
syncfusion_flutter_calendar: ^29.1.40
|
||||
syncfusion_flutter_maps: ^29.1.40
|
||||
syncfusion_flutter_calendar: ^31.2.18
|
||||
syncfusion_flutter_maps: ^31.2.18
|
||||
http: ^1.6.0
|
||||
geolocator: ^14.0.2
|
||||
geolocator: ^14.0.1
|
||||
permission_handler: ^12.0.1
|
||||
image: ^4.0.17
|
||||
image_picker: ^1.0.7
|
||||
@ -71,13 +71,13 @@ dependencies:
|
||||
font_awesome_flutter: ^10.8.0
|
||||
flutter_html: ^3.0.0
|
||||
tab_indicator_styler: ^2.0.0
|
||||
connectivity_plus: ^6.1.4
|
||||
connectivity_plus: ^7.0.0
|
||||
geocoding: ^4.0.0
|
||||
firebase_core: ^4.0.0
|
||||
firebase_messaging: ^16.0.0
|
||||
googleapis_auth: ^2.0.0
|
||||
device_info_plus: ^11.3.0
|
||||
flutter_local_notifications: 19.4.0
|
||||
device_info_plus: ^12.3.0
|
||||
flutter_local_notifications: ^19.5.0
|
||||
equatable: ^2.0.7
|
||||
mime: ^2.0.0
|
||||
timeago: ^3.7.1
|
||||
@ -86,6 +86,8 @@ dependencies:
|
||||
gallery_saver_plus: ^3.2.9
|
||||
share_plus: ^12.0.1
|
||||
timeline_tile: ^2.0.0
|
||||
encrypt: ^5.0.3
|
||||
flutter_in_store_app_version_checker: ^1.10.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@ -96,7 +98,7 @@ dev_dependencies:
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
@ -149,6 +151,3 @@ flutter:
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
|
||||
dependency_overrides:
|
||||
http: ^1.6.0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user