Merge pull request 'Vaibhav_Feature-#768' (#60) from Vaibhav_Feature-#768 into main

Reviewed-on: #60
This commit is contained in:
vaibhav.surve 2025-08-07 05:18:31 +00:00
commit 092fe21252
72 changed files with 10836 additions and 6918 deletions

View File

@ -5,40 +5,73 @@ plugins {
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
} }
// Load keystore properties from key.properties file
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "com.example.marco" // Define the namespace for your Android application
namespace = "com.marco.aiotstage"
// Set the compile SDK version based on Flutter's configuration
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
// Set the NDK version based on Flutter's configuration
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
// Configure Java compatibility options
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
} }
// Configure Kotlin options for JVM target
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8 jvmTarget = JavaVersion.VERSION_1_8
} }
// Default configuration for your application
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // Specify your unique Application ID. This identifies your app on Google Play.
applicationId = "com.example.marcostage" applicationId = "com.marco.aiotstage"
// You can update the following values to match your application needs. // Set minimum and target SDK versions based on Flutter's configuration
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
// Set version code and name based on Flutter's configuration (from pubspec.yaml)
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
// Define signing configurations for different build types
signingConfigs {
release {
// Reference the key alias from key.properties
keyAlias keystoreProperties['keyAlias']
// Reference the key password from key.properties
keyPassword keystoreProperties['keyPassword']
// Reference the keystore file path from key.properties
storeFile file(keystoreProperties['storeFile'])
// Reference the keystore password from key.properties
storePassword keystoreProperties['storePassword']
}
}
// Define different build types (e.g., debug, release)
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // Apply the 'release' signing configuration defined above to the release build
// Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.release
signingConfig = signingConfigs.debug // Enable code minification to reduce app size
minifyEnabled true
// Enable resource shrinking to remove unused resources
shrinkResources true
// Other release specific configurations can be added here, e.g., ProGuard rules
} }
} }
} }
// Configure Flutter specific settings, pointing to the root of your Flutter project
flutter { flutter {
source = "../.." source = "../.."
} }

View File

@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/> <uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>

View File

@ -1,4 +1,4 @@
package com.example.marco package com.marco.aiotstage
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -368,7 +368,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -384,7 +384,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -401,7 +401,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -416,7 +416,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -547,7 +547,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -569,7 +569,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View File

@ -1,22 +1,25 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart'; import 'package:marco/helpers/widgets/my_image_compressor.dart';
import 'package:marco/model/attendance_model.dart'; import 'package:marco/model/attendance_model.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/model/project_model.dart';
import 'package:marco/model/employee_model.dart'; import 'package:marco/model/employee_model.dart';
import 'package:marco/model/attendance_log_model.dart'; import 'package:marco/model/attendance_log_model.dart';
import 'package:marco/model/regularization_log_model.dart'; import 'package:marco/model/regularization_log_model.dart';
import 'package:marco/model/attendance_log_view_model.dart'; import 'package:marco/model/attendance_log_view_model.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
class AttendanceController extends GetxController { class AttendanceController extends GetxController {
// Data models
List<AttendanceModel> attendances = []; List<AttendanceModel> attendances = [];
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
List<EmployeeModel> employees = []; List<EmployeeModel> employees = [];
@ -24,19 +27,18 @@ class AttendanceController extends GetxController {
List<RegularizationLogModel> regularizationLogs = []; List<RegularizationLogModel> regularizationLogs = [];
List<AttendanceLogViewModel> attendenceLogsView = []; List<AttendanceLogViewModel> attendenceLogsView = [];
// States
String selectedTab = 'Employee List'; String selectedTab = 'Employee List';
DateTime? startDateAttendance; DateTime? startDateAttendance;
DateTime? endDateAttendance; DateTime? endDateAttendance;
RxBool isLoading = true.obs; final isLoading = true.obs;
RxBool isLoadingProjects = true.obs; final isLoadingProjects = true.obs;
RxBool isLoadingEmployees = true.obs; final isLoadingEmployees = true.obs;
RxBool isLoadingAttendanceLogs = true.obs; final isLoadingAttendanceLogs = true.obs;
RxBool isLoadingRegularizationLogs = true.obs; final isLoadingRegularizationLogs = true.obs;
RxBool isLoadingLogView = true.obs; final isLoadingLogView = true.obs;
final uploadingStates = <String, RxBool>{}.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
@override @override
void onInit() { void onInit() {
@ -56,76 +58,46 @@ class AttendanceController extends GetxController {
logSafe("Default date range set: $startDateAttendance to $endDateAttendance"); logSafe("Default date range set: $startDateAttendance to $endDateAttendance");
} }
Future<bool> _handleLocationPermission() async { // ------------------ Project & Employee ------------------
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
logSafe('Location permissions are denied', level: LogLevel.warning);
return false;
}
}
if (permission == LocationPermission.deniedForever) {
logSafe('Location permissions are permanently denied', level: LogLevel.error);
return false;
}
return true;
}
Future<void> fetchProjects() async { Future<void> fetchProjects() async {
isLoadingProjects.value = true; isLoadingProjects.value = true;
isLoading.value = true;
final response = await ApiService.getProjects(); final response = await ApiService.getProjects();
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
projects = response.map((json) => ProjectModel.fromJson(json)).toList(); projects = response.map((e) => ProjectModel.fromJson(e)).toList();
logSafe("Projects fetched: ${projects.length}"); logSafe("Projects fetched: ${projects.length}");
} else { } else {
logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error);
projects = []; projects = [];
logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error);
} }
isLoadingProjects.value = false; isLoadingProjects.value = false;
isLoading.value = false;
update(['attendance_dashboard_controller']); update(['attendance_dashboard_controller']);
} }
Future<void> loadAttendanceData(String projectId) async {
await fetchEmployeesByProject(projectId);
await fetchAttendanceLogs(projectId);
await fetchRegularizationLogs(projectId);
await fetchProjectData(projectId);
}
Future<void> fetchProjectData(String? projectId) async {
if (projectId == null) return;
isLoading.value = true;
await Future.wait([
fetchEmployeesByProject(projectId),
fetchAttendanceLogs(projectId, dateFrom: startDateAttendance, dateTo: endDateAttendance),
fetchRegularizationLogs(projectId),
]);
isLoading.value = false;
logSafe("Project data fetched for project ID: $projectId");
}
Future<void> fetchEmployeesByProject(String? projectId) async { Future<void> fetchEmployeesByProject(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
isLoadingEmployees.value = true; isLoadingEmployees.value = true;
final response = await ApiService.getEmployeesByProject(projectId); final response = await ApiService.getEmployeesByProject(projectId);
if (response != null) { if (response != null) {
employees = response.map((json) => EmployeeModel.fromJson(json)).toList(); employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
for (var emp in employees) { for (var emp in employees) {
uploadingStates[emp.id] = false.obs; uploadingStates[emp.id] = false.obs;
} }
logSafe("Employees fetched: ${employees.length} for project $projectId"); logSafe("Employees fetched: ${employees.length} for project $projectId");
update();
} else { } else {
logSafe("Failed to fetch employees for project $projectId", level: LogLevel.error); logSafe("Failed to fetch employees for project $projectId", level: LogLevel.error);
} }
isLoadingEmployees.value = false; isLoadingEmployees.value = false;
update();
} }
// ------------------ Attendance Capture ------------------
Future<bool> captureAndUploadAttendance( Future<bool> captureAndUploadAttendance(
String id, String id,
String employeeId, String employeeId,
@ -137,6 +109,7 @@ class AttendanceController extends GetxController {
}) async { }) async {
try { try {
uploadingStates[employeeId]?.value = true; uploadingStates[employeeId]?.value = true;
XFile? image; XFile? image;
if (imageCapture) { if (imageCapture) {
image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 80); image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 80);
@ -144,24 +117,39 @@ class AttendanceController extends GetxController {
logSafe("Image capture cancelled.", level: LogLevel.warning); logSafe("Image capture cancelled.", level: LogLevel.warning);
return false; return false;
} }
final compressedBytes = await compressImageToUnder100KB(File(image.path)); final compressedBytes = await compressImageToUnder100KB(File(image.path));
if (compressedBytes == null) { if (compressedBytes == null) {
logSafe("Image compression failed.", level: LogLevel.error); logSafe("Image compression failed.", level: LogLevel.error);
return false; return false;
} }
final compressedFile = await saveCompressedImageToFile(compressedBytes); final compressedFile = await saveCompressedImageToFile(compressedBytes);
image = XFile(compressedFile.path); image = XFile(compressedFile.path);
} }
final hasLocationPermission = await _handleLocationPermission();
if (!hasLocationPermission) return false; if (!await _handleLocationPermission()) return false;
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); final position = await Geolocator.getCurrentPosition(
final imageName = imageCapture ? ApiService.generateImageName(employeeId, employees.length + 1) : ""; desiredAccuracy: LocationAccuracy.high);
final imageName = imageCapture
? ApiService.generateImageName(employeeId, employees.length + 1)
: "";
final result = await ApiService.uploadAttendanceImage( final result = await ApiService.uploadAttendanceImage(
id, employeeId, image, position.latitude, position.longitude, id,
imageName: imageName, projectId: projectId, comment: comment, employeeId,
action: action, imageCapture: imageCapture, markTime: markTime, image,
position.latitude,
position.longitude,
imageName: imageName,
projectId: projectId,
comment: comment,
action: action,
imageCapture: imageCapture,
markTime: markTime,
); );
logSafe("Attendance uploaded for $employeeId, action: $action"); logSafe("Attendance uploaded for $employeeId, action: $action");
return result; return result;
} catch (e, stacktrace) { } catch (e, stacktrace) {
@ -172,8 +160,133 @@ class AttendanceController extends GetxController {
} }
} }
Future<void> selectDateRangeForAttendance(BuildContext context, AttendanceController controller) async { Future<bool> _handleLocationPermission() async {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
logSafe('Location permissions are denied', level: LogLevel.warning);
return false;
}
}
if (permission == LocationPermission.deniedForever) {
logSafe('Location permissions are permanently denied', level: LogLevel.error);
return false;
}
return true;
}
// ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return;
isLoadingAttendanceLogs.value = true;
final response = await ApiService.getAttendanceLogs(projectId, dateFrom: dateFrom, dateTo: dateTo);
if (response != null) {
attendanceLogs = response.map((e) => AttendanceLogModel.fromJson(e)).toList();
logSafe("Attendance logs fetched: ${attendanceLogs.length}");
} else {
logSafe("Failed to fetch attendance logs for project $projectId", level: LogLevel.error);
}
isLoadingAttendanceLogs.value = false;
update();
}
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
final groupedLogs = <String, List<AttendanceLogModel>>{};
for (var logItem in attendanceLogs) {
final checkInDate = logItem.checkIn != null
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
: 'Unknown';
groupedLogs.putIfAbsent(checkInDate, () => []).add(logItem);
}
final sortedEntries = groupedLogs.entries.toList()
..sort((a, b) {
if (a.key == 'Unknown') return 1;
if (b.key == 'Unknown') return -1;
final dateA = DateFormat('dd MMM yyyy').parse(a.key);
final dateB = DateFormat('dd MMM yyyy').parse(b.key);
return dateB.compareTo(dateA);
});
return Map<String, List<AttendanceLogModel>>.fromEntries(sortedEntries);
}
// ------------------ Regularization Logs ------------------
Future<void> fetchRegularizationLogs(String? projectId) async {
if (projectId == null) return;
isLoadingRegularizationLogs.value = true;
final response = await ApiService.getRegularizationLogs(projectId);
if (response != null) {
regularizationLogs = response.map((e) => RegularizationLogModel.fromJson(e)).toList();
logSafe("Regularization logs fetched: ${regularizationLogs.length}");
} else {
logSafe("Failed to fetch regularization logs for project $projectId", level: LogLevel.error);
}
isLoadingRegularizationLogs.value = false;
update();
}
// ------------------ Attendance Log View ------------------
Future<void> fetchLogsView(String? id) async {
if (id == null) return;
isLoadingLogView.value = true;
final response = await ApiService.getAttendanceLogView(id);
if (response != null) {
attendenceLogsView = response.map((e) => AttendanceLogViewModel.fromJson(e)).toList();
attendenceLogsView.sort((a, b) =>
(b.activityTime ?? DateTime(2000)).compareTo(a.activityTime ?? DateTime(2000)));
logSafe("Attendance log view fetched for ID: $id");
} else {
logSafe("Failed to fetch attendance log view for ID $id", level: LogLevel.error);
}
isLoadingLogView.value = false;
update();
}
// ------------------ Combined Load ------------------
Future<void> loadAttendanceData(String projectId) async {
isLoading.value = true;
await fetchProjectData(projectId);
isLoading.value = false;
}
Future<void> fetchProjectData(String? projectId) async {
if (projectId == null) return;
await Future.wait([
fetchEmployeesByProject(projectId),
fetchAttendanceLogs(projectId,
dateFrom: startDateAttendance, dateTo: endDateAttendance),
fetchRegularizationLogs(projectId),
]);
logSafe("Project data fetched for project ID: $projectId");
}
// ------------------ UI Interaction ------------------
Future<void> selectDateRangeForAttendance(
BuildContext context, AttendanceController controller) async {
final today = DateTime.now(); final today = DateTime.now();
final picked = await showDateRangePicker( final picked = await showDateRangePicker(
context: context, context: context,
firstDate: DateTime(2022), firstDate: DateTime(2022),
@ -190,14 +303,13 @@ class AttendanceController extends GetxController {
child: Theme( child: Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light( colorScheme: ColorScheme.light(
primary: const Color.fromARGB(255, 95, 132, 255), primary: const Color(0xFF5F84FF),
onPrimary: Colors.white, onPrimary: Colors.white,
onSurface: Colors.teal.shade800, onSurface: Colors.teal.shade800,
), ),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: Colors.teal), style: TextButton.styleFrom(foregroundColor: Colors.teal),
), ),
dialogTheme: DialogThemeData(backgroundColor: Colors.white),
), ),
child: child!, child: child!,
), ),
@ -210,6 +322,7 @@ class AttendanceController extends GetxController {
startDateAttendance = picked.start; startDateAttendance = picked.start;
endDateAttendance = picked.end; endDateAttendance = picked.end;
logSafe("Date range selected: $startDateAttendance to $endDateAttendance"); logSafe("Date range selected: $startDateAttendance to $endDateAttendance");
await controller.fetchAttendanceLogs( await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id, Get.find<ProjectController>().selectedProject?.id,
dateFrom: picked.start, dateFrom: picked.start,
@ -217,78 +330,4 @@ class AttendanceController extends GetxController {
); );
} }
} }
Future<void> fetchAttendanceLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return;
isLoadingAttendanceLogs.value = true;
isLoading.value = true;
final response = await ApiService.getAttendanceLogs(projectId, dateFrom: dateFrom, dateTo: dateTo);
if (response != null) {
attendanceLogs = response.map((json) => AttendanceLogModel.fromJson(json)).toList();
logSafe("Attendance logs fetched: ${attendanceLogs.length}");
update();
} else {
logSafe("Failed to fetch attendance logs for project $projectId", level: LogLevel.error);
}
isLoadingAttendanceLogs.value = false;
isLoading.value = false;
}
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
final groupedLogs = <String, List<AttendanceLogModel>>{};
for (var logItem in attendanceLogs) {
final checkInDate = logItem.checkIn != null
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
: 'Unknown';
groupedLogs.putIfAbsent(checkInDate, () => []);
groupedLogs[checkInDate]!.add(logItem);
}
final sortedEntries = groupedLogs.entries.toList()
..sort((a, b) {
if (a.key == 'Unknown') return 1;
if (b.key == 'Unknown') return -1;
final dateA = DateFormat('dd MMM yyyy').parse(a.key);
final dateB = DateFormat('dd MMM yyyy').parse(b.key);
return dateB.compareTo(dateA);
});
final sortedMap = Map<String, List<AttendanceLogModel>>.fromEntries(sortedEntries);
logSafe("Logs grouped and sorted by check-in date.");
return sortedMap;
}
Future<void> fetchRegularizationLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return;
isLoadingRegularizationLogs.value = true;
isLoading.value = true;
final response = await ApiService.getRegularizationLogs(projectId);
if (response != null) {
regularizationLogs = response.map((json) => RegularizationLogModel.fromJson(json)).toList();
logSafe("Regularization logs fetched: ${regularizationLogs.length}");
update();
} else {
logSafe("Failed to fetch regularization logs for project $projectId", level: LogLevel.error);
}
isLoadingRegularizationLogs.value = false;
isLoading.value = false;
}
Future<void> fetchLogsView(String? id) async {
if (id == null) return;
isLoadingLogView.value = true;
isLoading.value = true;
final response = await ApiService.getAttendanceLogView(id);
if (response != null) {
attendenceLogsView = response.map((json) => AttendanceLogViewModel.fromJson(json)).toList();
attendenceLogsView.sort((a, b) {
if (a.activityTime == null || b.activityTime == null) return 0;
return b.activityTime!.compareTo(a.activityTime!);
});
logSafe("Attendance log view fetched for ID: $id");
update();
} else {
logSafe("Failed to fetch attendance log view for ID $id", level: LogLevel.error);
}
isLoadingLogView.value = false;
isLoading.value = false;
}
} }

View File

@ -24,6 +24,7 @@ class AddContactController extends GetxController {
final RxMap<String, String> tagsMap = <String, String>{}.obs; final RxMap<String, String> tagsMap = <String, String>{}.obs;
final RxBool isInitialized = false.obs; final RxBool isInitialized = false.obs;
final RxList<String> selectedProjects = <String>[].obs; final RxList<String> selectedProjects = <String>[].obs;
final RxBool isSubmitting = false.obs;
@override @override
void onInit() { void onInit() {
@ -94,6 +95,9 @@ class AddContactController extends GetxController {
required String address, required String address,
required String description, required String description,
}) async { }) async {
if (isSubmitting.value) return;
isSubmitting.value = true;
final categoryId = categoriesMap[selectedCategory.value]; final categoryId = categoriesMap[selectedCategory.value];
final bucketId = bucketsMap[selectedBucket.value]; final bucketId = bucketsMap[selectedBucket.value];
final projectIds = selectedProjects final projectIds = selectedProjects
@ -101,13 +105,13 @@ class AddContactController extends GetxController {
.whereType<String>() .whereType<String>()
.toList(); .toList();
// === Required validations only for name, organization, and bucket ===
if (name.trim().isEmpty) { if (name.trim().isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "Missing Name", title: "Missing Name",
message: "Please enter the contact name.", message: "Please enter the contact name.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false;
return; return;
} }
@ -117,6 +121,7 @@ class AddContactController extends GetxController {
message: "Please enter the organization name.", message: "Please enter the organization name.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false;
return; return;
} }
@ -126,10 +131,10 @@ class AddContactController extends GetxController {
message: "Please select a bucket.", message: "Please select a bucket.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false;
return; return;
} }
// === Build body (include optional fields if available) ===
try { try {
final tagObjects = enteredTags.map((tagName) { final tagObjects = enteredTags.map((tagName) {
final tagId = tagsMap[tagName]; final tagId = tagsMap[tagName];
@ -182,6 +187,8 @@ class AddContactController extends GetxController {
message: "Something went wrong", message: "Something went wrong",
type: SnackbarType.error, type: SnackbarType.error,
); );
} finally {
isSubmitting.value = false;
} }
} }

View File

@ -0,0 +1,434 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:mime/mime.dart';
class AddExpenseController extends GetxController {
// --- Text Controllers ---
final amountController = TextEditingController();
final descriptionController = TextEditingController();
final supplierController = TextEditingController();
final transactionIdController = TextEditingController();
final gstController = TextEditingController();
final locationController = TextEditingController();
final transactionDateController = TextEditingController();
final noOfPersonsController = TextEditingController();
final employeeSearchController = TextEditingController();
// --- Reactive State ---
final isLoading = false.obs;
final isSubmitting = false.obs;
final isFetchingLocation = false.obs;
final isEditMode = false.obs;
final isSearchingEmployees = false.obs;
// --- Dropdown Selections & Data ---
final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rxn<ExpenseTypeModel>();
final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs;
final selectedTransactionDate = Rxn<DateTime>();
final attachments = <File>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final globalProjects = <String>[].obs;
final projectsMap = <String, String>{}.obs;
final expenseTypes = <ExpenseTypeModel>[].obs;
final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs;
String? editingExpenseId;
final expenseController = Get.find<ExpenseController>();
@override
void onInit() {
super.onInit();
fetchMasterData();
fetchGlobalProjects();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
}
@override
void onClose() {
for (var c in [
amountController,
descriptionController,
supplierController,
transactionIdController,
gstController,
locationController,
transactionDateController,
noOfPersonsController,
employeeSearchController,
]) {
c.dispose();
}
super.onClose();
}
// --- Employee Search ---
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) return employeeSearchResults.clear();
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
);
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
// --- Form Population: Edit Mode ---
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true;
editingExpenseId = '${data['id']}';
selectedProject.value = data['projectName'] ?? '';
amountController.text = data['amount']?.toString() ?? '';
supplierController.text = data['supplerName'] ?? '';
descriptionController.text = data['description'] ?? '';
transactionIdController.text = data['transactionId'] ?? '';
locationController.text = data['location'] ?? '';
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
// Transaction Date
if (data['transactionDate'] != null) {
try {
final parsed = DateTime.parse(data['transactionDate']);
selectedTransactionDate.value = parsed;
transactionDateController.text =
DateFormat('dd-MM-yyyy').format(parsed);
} catch (_) {
selectedTransactionDate.value = null;
transactionDateController.clear();
}
}
// Dropdown
selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
// Paid By
final paidById = '${data['paidById']}';
selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
await searchEmployees(
'${data['paidByFirstName']} ${data['paidByLastName']}');
selectedPaidBy.value = employeeSearchResults
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
}
// Attachments
existingAttachments.clear();
if (data['attachments'] is List) {
existingAttachments.addAll(
List<Map<String, dynamic>>.from(data['attachments'])
.map((e) => {...e, 'isActive': true}),
);
}
_logPrefilledData();
}
void _logPrefilledData() {
logSafe('--- Prefilled Expense Data ---', level: LogLevel.info);
[
'ID: $editingExpenseId',
'Project: ${selectedProject.value}',
'Amount: ${amountController.text}',
'Supplier: ${supplierController.text}',
'Description: ${descriptionController.text}',
'Transaction ID: ${transactionIdController.text}',
'Location: ${locationController.text}',
'Transaction Date: ${transactionDateController.text}',
'No. of Persons: ${noOfPersonsController.text}',
'Expense Type: ${selectedExpenseType.value?.name}',
'Payment Mode: ${selectedPaymentMode.value?.name}',
'Paid By: ${selectedPaidBy.value?.name}',
'Attachments: ${attachments.length}',
'Existing Attachments: ${existingAttachments.length}',
].forEach((str) => logSafe(str, level: LogLevel.info));
}
// --- Pickers ---
Future<void> pickTransactionDate(BuildContext context) async {
final picked = await showDatePicker(
context: context,
initialDate: selectedTransactionDate.value ?? DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime.now(),
);
if (picked != null) {
selectedTransactionDate.value = picked;
transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked);
}
}
Future<void> pickAttachments() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
allowMultiple: true,
);
if (result != null) {
attachments
.addAll(result.paths.whereType<String>().map((path) => File(path)));
}
} catch (e) {
_errorSnackbar("Attachment error: $e");
}
}
void removeAttachment(File file) => attachments.remove(file);
// --- Location ---
Future<void> fetchCurrentLocation() async {
isFetchingLocation.value = true;
try {
final permission = await _ensureLocationPermission();
if (!permission) return;
final position = await Geolocator.getCurrentPosition();
final placemarks =
await placemarkFromCoordinates(position.latitude, position.longitude);
locationController.text = placemarks.isNotEmpty
? [
placemarks.first.name,
placemarks.first.street,
placemarks.first.locality,
placemarks.first.administrativeArea,
placemarks.first.country
].where((e) => e?.isNotEmpty == true).join(", ")
: "${position.latitude}, ${position.longitude}";
} catch (e) {
_errorSnackbar("Location error: $e");
} finally {
isFetchingLocation.value = false;
}
}
Future<bool> _ensureLocationPermission() async {
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
_errorSnackbar("Location permission denied.");
return false;
}
}
if (!await Geolocator.isLocationServiceEnabled()) {
_errorSnackbar("Location service disabled.");
return false;
}
return true;
}
// --- Data Fetching ---
Future<void> loadMasterData() async =>
await Future.wait([fetchMasterData(), fetchGlobalProjects()]);
Future<void> fetchMasterData() async {
try {
final types = await ApiService.getMasterExpenseTypes();
if (types is List)
expenseTypes.value =
types.map((e) => ExpenseTypeModel.fromJson(e)).toList();
final modes = await ApiService.getMasterPaymentModes();
if (modes is List)
paymentModes.value =
modes.map((e) => PaymentModeModel.fromJson(e)).toList();
} catch (_) {
_errorSnackbar("Failed to fetch master data");
}
}
Future<void> fetchGlobalProjects() async {
try {
final response = await ApiService.getGlobalProjects();
if (response != null) {
final names = <String>[];
for (var item in response) {
final name = item['name']?.toString().trim(),
id = item['id']?.toString().trim();
if (name != null && id != null) {
projectsMap[name] = id;
names.add(name);
}
}
globalProjects.assignAll(names);
}
} catch (e) {
logSafe("Error fetching projects: $e", level: LogLevel.error);
}
}
// --- Submission ---
Future<void> submitOrUpdateExpense() async {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
final validationMsg = validateForm();
if (validationMsg.isNotEmpty) {
_errorSnackbar(validationMsg, "Missing Fields");
return;
}
final payload = await _buildExpensePayload();
final success = isEditMode.value && editingExpenseId != null
? await ApiService.editExpenseApi(
expenseId: editingExpenseId!, payload: payload)
: await ApiService.createExpenseApi(
projectId: payload['projectId'],
expensesTypeId: payload['expensesTypeId'],
paymentModeId: payload['paymentModeId'],
paidById: payload['paidById'],
transactionDate: DateTime.parse(payload['transactionDate']),
transactionId: payload['transactionId'],
description: payload['description'],
location: payload['location'],
supplerName: payload['supplerName'],
amount: payload['amount'],
noOfPersons: payload['noOfPersons'],
billAttachments: payload['billAttachments'],
);
if (success) {
await expenseController.fetchExpenses();
Get.back();
showAppSnackbar(
title: "Success",
message:
"Expense ${isEditMode.value ? 'updated' : 'created'} successfully!",
type: SnackbarType.success,
);
} else {
_errorSnackbar("Operation failed. Try again.");
}
} catch (e) {
_errorSnackbar("Unexpected error: $e");
} finally {
isSubmitting.value = false;
}
}
Future<Map<String, dynamic>> _buildExpensePayload() async {
final now = DateTime.now();
final existingAttachmentPayloads = existingAttachments
.map((e) => {
"documentId": e['documentId'],
"fileName": e['fileName'],
"contentType": e['contentType'],
"fileSize": 0,
"description": "",
"url": e['url'],
"isActive": e['isActive'] ?? true,
"base64Data": e['isActive'] == false ? null : e['base64Data'],
})
.toList();
final newAttachmentPayloads =
await Future.wait(attachments.map((file) async {
final bytes = await file.readAsBytes();
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(bytes),
"contentType": lookupMimeType(file.path) ?? 'application/octet-stream',
"fileSize": await file.length(),
"description": "",
};
}));
final type = selectedExpenseType.value!;
return {
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
"projectId": projectsMap[selectedProject.value]!,
"expensesTypeId": type.id,
"paymentModeId": selectedPaymentMode.value!.id,
"paidById": selectedPaidBy.value!.id,
"transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc())
.toIso8601String(),
"transactionId": transactionIdController.text,
"description": descriptionController.text,
"location": locationController.text,
"supplerName": supplierController.text,
"amount": double.parse(amountController.text.trim()),
"noOfPersons": type.noOfPersonsRequired == true
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
: 0,
"billAttachments": [
...existingAttachmentPayloads,
...newAttachmentPayloads
],
};
}
String validateForm() {
final missing = <String>[];
if (selectedProject.value.isEmpty) missing.add("Project");
if (selectedExpenseType.value == null) missing.add("Expense Type");
if (selectedPaymentMode.value == null) missing.add("Payment Mode");
if (selectedPaidBy.value == null) missing.add("Paid By");
if (amountController.text.trim().isEmpty) missing.add("Amount");
if (descriptionController.text.trim().isEmpty) missing.add("Description");
// Date Required
if (selectedTransactionDate.value == null) missing.add("Transaction Date");
if (selectedTransactionDate.value != null &&
selectedTransactionDate.value!.isAfter(DateTime.now())) {
missing.add("Valid Transaction Date");
}
final amount = double.tryParse(amountController.text.trim());
if (amount == null) missing.add("Valid Amount");
// Attachment: at least one required at all times
bool hasActiveExisting =
existingAttachments.any((e) => e['isActive'] != false);
if (attachments.isEmpty && !hasActiveExisting) missing.add("Attachment");
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
}
// --- Snackbar Helper ---
void _errorSnackbar(String msg, [String title = "Error"]) => showAppSnackbar(
title: title,
message: msg,
type: SnackbarType.error,
);
}

View File

@ -0,0 +1,187 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/model/expense/expense_detail_model.dart';
import 'package:marco/model/employee_model.dart';
import 'package:flutter/material.dart';
class ExpenseDetailController extends GetxController {
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
late String _expenseId;
bool _isInitialized = false;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
/// Call this once from the screen (NOT inside build) to initialize
void init(String expenseId) {
if (_isInitialized) return;
_isInitialized = true;
_expenseId = expenseId;
// Use Future.wait to fetch details and employees concurrently
Future.wait([
fetchExpenseDetails(),
fetchAllEmployees(),
]);
}
/// Generic method to handle API calls with loading and error states
Future<T?> _apiCallWrapper<T>(
Future<T?> Function() apiCall, String operationName) async {
isLoading.value = true;
errorMessage.value = ''; // Clear previous errors
try {
logSafe("Initiating $operationName...");
final result = await apiCall();
logSafe("$operationName completed successfully.");
return result;
} catch (e, stack) {
errorMessage.value =
'An unexpected error occurred during $operationName.';
logSafe("Exception in $operationName: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return null;
} finally {
isLoading.value = false;
}
}
/// Fetch expense details by stored ID
Future<void> fetchExpenseDetails() async {
final result = await _apiCallWrapper(
() => ApiService.getExpenseDetailsApi(expenseId: _expenseId),
"fetch expense details");
if (result != null) {
try {
expense.value = ExpenseDetailModel.fromJson(result);
logSafe("Expense details loaded successfully: ${expense.value?.id}");
} catch (e) {
errorMessage.value = 'Failed to parse expense details: $e';
logSafe("Parse error in fetchExpenseDetails: $e",
level: LogLevel.error);
}
} else {
errorMessage.value = 'Failed to fetch expense details from server.';
logSafe("fetchExpenseDetails failed: null response",
level: LogLevel.error);
}
}
// 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) {
return permissionData
.map((e) => e.toString().trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (permissionData is String) {
final clean = permissionData.replaceAll(RegExp(r'[\[\]]'), '');
return clean
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
return [];
}
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) return employeeSearchResults.clear();
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
);
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// Fetch all employees
Future<void> fetchAllEmployees() async {
final response = await _apiCallWrapper(
() => ApiService.getAllEmployees(), "fetch all employees");
if (response != null && response.isNotEmpty) {
try {
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
logSafe("All Employees fetched: ${allEmployees.length}",
level: LogLevel.info);
} catch (e) {
errorMessage.value = 'Failed to parse employee data: $e';
logSafe("Parse error in fetchAllEmployees: $e", level: LogLevel.error);
}
} else {
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
Future<bool> updateExpenseStatusWithReimbursement({
required String comment,
required String reimburseTransactionId,
required String reimburseDate,
required String reimburseById,
required String statusId,
}) async {
final success = await _apiCallWrapper(
() => ApiService.updateExpenseStatusApi(
expenseId: _expenseId,
statusId: statusId,
comment: comment,
reimburseTransactionId: reimburseTransactionId,
reimburseDate: reimburseDate,
reimbursedById: reimburseById,
),
"submit reimbursement",
);
if (success == true) {
// Explicitly check for true as _apiCallWrapper returns T?
await fetchExpenseDetails(); // Refresh details after successful update
return true;
} else {
errorMessage.value = "Failed to submit reimbursement.";
return false;
}
}
/// Update status for this specific expense
Future<bool> updateExpenseStatus(String statusId, {String? comment}) async {
final success = await _apiCallWrapper(
() => ApiService.updateExpenseStatusApi(
expenseId: _expenseId,
statusId: statusId,
comment: comment,
),
"update expense status",
);
if (success == true) {
await fetchExpenseDetails();
return true;
} else {
errorMessage.value = "Failed to update expense status.";
return false;
}
}
}

View File

@ -0,0 +1,349 @@
import 'dart:convert';
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/expense_status_model.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:flutter/material.dart';
class ExpenseController extends GetxController {
final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs;
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
// Master data
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs;
final RxList<String> globalProjects = <String>[].obs;
final RxMap<String, String> projectsMap = <String, String>{}.obs;
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
// Persistent Filter States
final RxString selectedProject = ''.obs;
final RxString selectedStatus = ''.obs;
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
final Rx<DateTime?> endDate = Rx<DateTime?>(null);
final RxList<EmployeeModel> selectedPaidByEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedCreatedByEmployees =
<EmployeeModel>[].obs;
final RxString selectedDateType = 'Transaction Date'.obs;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
final employeeSearchResults = <EmployeeModel>[].obs;
final List<String> dateTypes = [
'Transaction Date',
'Created At',
];
int _pageSize = 20;
int _pageNumber = 1;
@override
void onInit() {
super.onInit();
loadInitialMasterData();
fetchAllEmployees();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
}
bool get isFilterApplied {
return selectedProject.value.isNotEmpty ||
selectedStatus.value.isNotEmpty ||
startDate.value != null ||
endDate.value != null ||
selectedPaidByEmployees.isNotEmpty ||
selectedCreatedByEmployees.isNotEmpty;
}
/// Load master data
Future<void> loadInitialMasterData() async {
await fetchGlobalProjects();
await fetchMasterData();
}
Future<void> deleteExpense(String expenseId) async {
try {
logSafe("Attempting to delete expense: $expenseId");
final success = await ApiService.deleteExpense(expenseId);
if (success) {
expenses.removeWhere((e) => e.id == expenseId);
logSafe("Expense deleted successfully.");
showAppSnackbar(
title: "Deleted",
message: "Expense has been deleted successfully.",
type: SnackbarType.success,
);
} else {
logSafe("Failed to delete expense: $expenseId", level: LogLevel.error);
showAppSnackbar(
title: "Failed",
message: "Failed to delete expense.",
type: SnackbarType.error,
);
}
} catch (e, stack) {
logSafe("Exception in deleteExpense: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while deleting.",
type: SnackbarType.error,
);
}
}
Future<void> searchEmployees(String searchQuery) async {
if (searchQuery.trim().isEmpty) {
employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true;
try {
final results = await ApiService.searchEmployeesBasic(
searchString: searchQuery.trim(),
);
if (results != null) {
employeeSearchResults.assignAll(
results.map((e) => EmployeeModel.fromJson(e)),
);
} else {
employeeSearchResults.clear();
}
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// Fetch expenses using filters
Future<void> fetchExpenses({
List<String>? projectIds,
List<String>? statusIds,
List<String>? createdByIds,
List<String>? paidByIds,
DateTime? startDate,
DateTime? endDate,
int pageSize = 20,
int pageNumber = 1,
}) async {
isLoading.value = true;
errorMessage.value = '';
expenses.clear();
_pageSize = pageSize;
_pageNumber = pageNumber;
final Map<String, dynamic> filterMap = {
"projectIds": projectIds ??
(selectedProject.value.isEmpty
? []
: [projectsMap[selectedProject.value] ?? '']),
"statusIds": statusIds ??
(selectedStatus.value.isEmpty ? [] : [selectedStatus.value]),
"createdByIds":
createdByIds ?? selectedCreatedByEmployees.map((e) => e.id).toList(),
"paidByIds":
paidByIds ?? selectedPaidByEmployees.map((e) => e.id).toList(),
"startDate": (startDate ?? this.startDate.value)?.toIso8601String(),
"endDate": (endDate ?? this.endDate.value)?.toIso8601String(),
"isTransactionDate": selectedDateType.value == 'Transaction Date',
};
try {
logSafe("Fetching expenses with filter: ${jsonEncode(filterMap)}");
final result = await ApiService.getExpenseListApi(
filter: jsonEncode(filterMap),
pageSize: _pageSize,
pageNumber: _pageNumber,
);
if (result != null) {
try {
final expenseResponse = ExpenseResponse.fromJson(result);
expenses.assignAll(expenseResponse.data.data);
logSafe("Expenses loaded: ${expenses.length}");
logSafe(
"Pagination Info: Page ${expenseResponse.data.currentPage} of ${expenseResponse.data.totalPages} | Total: ${expenseResponse.data.totalEntites}");
} catch (e) {
errorMessage.value = 'Failed to parse expenses: $e';
logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error);
}
} else {
errorMessage.value = 'Failed to fetch expenses from server.';
logSafe("fetchExpenses failed: null response", level: LogLevel.error);
}
} catch (e, stack) {
errorMessage.value = 'An unexpected error occurred.';
logSafe("Exception in fetchExpenses: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
} finally {
isLoading.value = false;
}
}
/// Clear all filters
void clearFilters() {
selectedProject.value = '';
selectedStatus.value = '';
startDate.value = null;
endDate.value = null;
selectedPaidByEmployees.clear();
selectedCreatedByEmployees.clear();
}
/// Fetch master data: expense types, payment modes, and expense status
Future<void> fetchMasterData() async {
try {
final expenseTypesData = await ApiService.getMasterExpenseTypes();
if (expenseTypesData is List) {
expenseTypes.value =
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
final paymentModesData = await ApiService.getMasterPaymentModes();
if (paymentModesData is List) {
paymentModes.value =
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
}
final expenseStatusData = await ApiService.getMasterExpenseStatus();
if (expenseStatusData is List) {
expenseStatuses.value = expenseStatusData
.map((e) => ExpenseStatusModel.fromJson(e))
.toList();
}
} catch (e) {
showAppSnackbar(
title: "Error",
message: "Failed to fetch master data: $e",
type: SnackbarType.error,
);
}
}
/// Fetch global projects
Future<void> fetchGlobalProjects() async {
try {
final response = await ApiService.getGlobalProjects();
if (response != null) {
final names = <String>[];
for (var item in response) {
final name = item['name']?.toString().trim();
final id = item['id']?.toString().trim();
if (name != null && id != null && name.isNotEmpty) {
projectsMap[name] = id;
names.add(name);
}
}
globalProjects.assignAll(names);
logSafe("Fetched ${names.length} global projects");
}
} catch (e) {
logSafe("Failed to fetch global projects: $e", level: LogLevel.error);
}
}
/// Fetch all employees
Future<void> fetchAllEmployees() async {
isLoading.value = true;
try {
final response = await ApiService.getAllEmployees();
if (response != null && response.isNotEmpty) {
allEmployees
.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
logSafe(
"All Employees fetched for Manage Bucket: ${allEmployees.length}",
level: LogLevel.info,
);
} else {
allEmployees.clear();
logSafe("No employees found for Manage Bucket.",
level: LogLevel.warning);
}
} catch (e) {
allEmployees.clear();
logSafe("Error fetching employees in Manage Bucket",
level: LogLevel.error, error: e);
}
isLoading.value = false;
update();
}
Future<void> loadMoreExpenses() async {
if (isLoading.value) return;
_pageNumber += 1;
isLoading.value = true;
final Map<String, dynamic> filterMap = {
"projectIds": selectedProject.value.isEmpty
? []
: [projectsMap[selectedProject.value] ?? ''],
"statusIds": selectedStatus.value.isEmpty ? [] : [selectedStatus.value],
"createdByIds": selectedCreatedByEmployees.map((e) => e.id).toList(),
"paidByIds": selectedPaidByEmployees.map((e) => e.id).toList(),
"startDate": startDate.value?.toIso8601String(),
"endDate": endDate.value?.toIso8601String(),
"isTransactionDate": selectedDateType.value == 'Transaction Date',
};
try {
final result = await ApiService.getExpenseListApi(
filter: jsonEncode(filterMap),
pageSize: _pageSize,
pageNumber: _pageNumber,
);
if (result != null) {
final expenseResponse = ExpenseResponse.fromJson(result);
expenses.addAll(expenseResponse.data.data);
}
} catch (e) {
logSafe("Error in loadMoreExpenses: $e", level: LogLevel.error);
} finally {
isLoading.value = false;
}
}
/// Update expense status
Future<bool> updateExpenseStatus(String expenseId, String statusId) async {
isLoading.value = true;
errorMessage.value = '';
try {
logSafe("Updating status for expense: $expenseId -> $statusId");
final success = await ApiService.updateExpenseStatusApi(
expenseId: expenseId,
statusId: statusId,
);
if (success) {
logSafe("Expense status updated successfully.");
await fetchExpenses();
return true;
} else {
errorMessage.value = "Failed to update expense status.";
return false;
}
} catch (e, stack) {
errorMessage.value = 'An unexpected error occurred.';
logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
} finally {
isLoading.value = false;
}
}
}

View File

@ -117,6 +117,23 @@ class PermissionController extends GetxController {
return assigned; return assigned;
} }
List<String> get allowedPermissionIds {
final ids = permissions.map((p) => p.id).toList();
logSafe("[PermissionController] Allowed Permission IDs: $ids",
level: LogLevel.debug);
return ids;
}
bool hasAnyPermission(List<String> ids) {
logSafe("[PermissionController] Checking if any of these are allowed: $ids",
level: LogLevel.debug);
final allowed = allowedPermissionIds;
final result = ids.any((id) => allowed.contains(id));
logSafe("[PermissionController] Permission match result: $result",
level: LogLevel.debug);
return result;
}
@override @override
void onClose() { void onClose() {
_refreshTimer?.cancel(); _refreshTimer?.cancel();

View File

@ -17,6 +17,7 @@ class DailyTaskPlaningController extends GetxController {
MyFormValidator basicValidator = MyFormValidator(); MyFormValidator basicValidator = MyFormValidator();
List<Map<String, dynamic>> roles = []; List<Map<String, dynamic>> roles = [];
RxBool isAssigningTask = false.obs;
RxnString selectedRoleId = RxnString(); RxnString selectedRoleId = RxnString();
RxBool isLoading = false.obs; RxBool isLoading = false.obs;
@ -46,16 +47,21 @@ class DailyTaskPlaningController extends GetxController {
} }
void updateSelectedEmployees() { void updateSelectedEmployees() {
final selected = employees final selected =
.where((e) => uploadingStates[e.id]?.value == true) employees.where((e) => uploadingStates[e.id]?.value == true).toList();
.toList();
selectedEmployees.value = selected; selectedEmployees.value = selected;
logSafe("Updated selected employees", level: LogLevel.debug, ); logSafe(
"Updated selected employees",
level: LogLevel.debug,
);
} }
void onRoleSelected(String? roleId) { void onRoleSelected(String? roleId) {
selectedRoleId.value = roleId; selectedRoleId.value = roleId;
logSafe("Role selected", level: LogLevel.info, ); logSafe(
"Role selected",
level: LogLevel.info,
);
} }
Future<void> fetchRoles() async { Future<void> fetchRoles() async {
@ -77,6 +83,7 @@ class DailyTaskPlaningController extends GetxController {
required List<String> taskTeam, required List<String> taskTeam,
DateTime? assignmentDate, DateTime? assignmentDate,
}) async { }) async {
isAssigningTask.value = true;
logSafe("Starting assign task...", level: LogLevel.info); logSafe("Starting assign task...", level: LogLevel.info);
final response = await ApiService.assignDailyTask( final response = await ApiService.assignDailyTask(
@ -87,6 +94,8 @@ class DailyTaskPlaningController extends GetxController {
assignmentDate: assignmentDate, assignmentDate: assignmentDate,
); );
isAssigningTask.value = false;
if (response == true) { if (response == true) {
logSafe("Task assigned successfully", level: LogLevel.info); logSafe("Task assigned successfully", level: LogLevel.info);
showAppSnackbar( showAppSnackbar(
@ -111,15 +120,18 @@ class DailyTaskPlaningController extends GetxController {
try { try {
final response = await ApiService.getProjects(); final response = await ApiService.getProjects();
if (response?.isEmpty ?? true) { if (response?.isEmpty ?? true) {
logSafe("No project data found or API call failed", level: LogLevel.warning); logSafe("No project data found or API call failed",
level: LogLevel.warning);
return; return;
} }
projects = response!.map((json) => ProjectModel.fromJson(json)).toList(); projects = response!.map((json) => ProjectModel.fromJson(json)).toList();
logSafe("Projects fetched: ${projects.length} projects loaded", level: LogLevel.info); logSafe("Projects fetched: ${projects.length} projects loaded",
level: LogLevel.info);
update(); update();
} catch (e, stack) { } catch (e, stack) {
logSafe("Error fetching projects", level: LogLevel.error, error: e, stackTrace: stack); logSafe("Error fetching projects",
level: LogLevel.error, error: e, stackTrace: stack);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -137,12 +149,16 @@ class DailyTaskPlaningController extends GetxController {
final data = response?['data']; final data = response?['data'];
if (data != null) { if (data != null) {
dailyTasks = [TaskPlanningDetailsModel.fromJson(data)]; dailyTasks = [TaskPlanningDetailsModel.fromJson(data)];
logSafe("Daily task Planning Details fetched", level: LogLevel.info, ); logSafe(
"Daily task Planning Details fetched",
level: LogLevel.info,
);
} else { } else {
logSafe("Data field is null", level: LogLevel.warning); logSafe("Data field is null", level: LogLevel.warning);
} }
} catch (e, stack) { } 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 { } finally {
isLoading.value = false; isLoading.value = false;
update(); update();
@ -151,7 +167,8 @@ class DailyTaskPlaningController extends GetxController {
Future<void> fetchEmployeesByProject(String? projectId) async { Future<void> fetchEmployeesByProject(String? projectId) async {
if (projectId == null || projectId.isEmpty) { if (projectId == null || projectId.isEmpty) {
logSafe("Project ID is required but was null or empty", level: LogLevel.error); logSafe("Project ID is required but was null or empty",
level: LogLevel.error);
return; return;
} }
@ -159,19 +176,29 @@ class DailyTaskPlaningController extends GetxController {
try { try {
final response = await ApiService.getAllEmployeesByProject(projectId); final response = await ApiService.getAllEmployeesByProject(projectId);
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
employees = response.map((json) => EmployeeModel.fromJson(json)).toList(); employees =
response.map((json) => EmployeeModel.fromJson(json)).toList();
for (var emp in employees) { for (var emp in employees) {
uploadingStates[emp.id] = false.obs; uploadingStates[emp.id] = false.obs;
} }
logSafe("Employees fetched: ${employees.length} for project $projectId", logSafe(
level: LogLevel.info, ); "Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info,
);
} else { } else {
employees = []; employees = [];
logSafe("No employees found for project $projectId", level: LogLevel.warning, ); logSafe(
"No employees found for project $projectId",
level: LogLevel.warning,
);
} }
} catch (e, stack) { } catch (e, stack) {
logSafe("Error fetching employees for project $projectId", logSafe(
level: LogLevel.error, error: e, stackTrace: stack, ); "Error fetching employees for project $projectId",
level: LogLevel.error,
error: e,
stackTrace: stack,
);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
update(); update();

View File

@ -1,11 +1,11 @@
class ApiEndpoints { class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; static const String baseUrl = "https://api.marcoaiot.com/api";
// Dashboard Screen API Endpoints // Dashboard Module API Endpoints
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
// Attendance Screen API Endpoints // Attendance Module API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
static const String getEmployeesByProject = "/attendance/project/team"; static const String getEmployeesByProject = "/attendance/project/team";
@ -17,6 +17,7 @@ class ApiEndpoints {
// Employee Screen API Endpoints // Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployees = "/employee/list"; static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole"; static const String getRoles = "/roles/jobrole";
static const String createEmployee = "/employee/manage-mobile"; static const String createEmployee = "/employee/manage-mobile";
static const String getEmployeeInfo = "/employee/profile/get"; static const String getEmployeeInfo = "/employee/profile/get";
@ -24,7 +25,7 @@ class ApiEndpoints {
static const String getAssignedProjects = "/project/assigned-projects"; static const String getAssignedProjects = "/project/assigned-projects";
static const String assignProjects = "/project/assign-projects"; static const String assignProjects = "/project/assign-projects";
// Daily Task Screen API Endpoints // Daily Task Module API Endpoints
static const String getDailyTask = "/task/list"; static const String getDailyTask = "/task/list";
static const String reportTask = "/task/report"; static const String reportTask = "/task/report";
static const String commentTask = "/task/comment"; static const String commentTask = "/task/comment";
@ -35,7 +36,7 @@ class ApiEndpoints {
static const String assignTask = "/project/task"; static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories"; static const String getmasterWorkCategories = "/Master/work-categories";
////// Directory Screen API Endpoints ////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory"; static const String getDirectoryContacts = "/directory";
static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryBucketList = "/directory/buckets";
static const String getDirectoryContactDetail = "/directory/notes"; static const String getDirectoryContactDetail = "/directory/notes";
@ -49,4 +50,16 @@ class ApiEndpoints {
static const String createBucket = "/directory/bucket"; static const String createBucket = "/directory/bucket";
static const String updateBucket = "/directory/bucket"; static const String updateBucket = "/directory/bucket";
static const String assignBucket = "/directory/assign-bucket"; static const String assignBucket = "/directory/assign-bucket";
////// Expense Module API Endpoints
static const String getExpenseCategories = "/expense/categories";
static const String getExpenseList = "/expense/list";
static const String getExpenseDetails = "/expense/details";
static const String createExpense = "/expense/create";
static const String editExpense = "/Expense/edit";
static const String getMasterPaymentModes = "/master/payment-modes";
static const String getMasterExpenseStatus = "/master/expenses-status";
static const String getMasterExpenseTypes = "/master/expenses-types";
static const String updateExpenseStatus = "/expense/action";
static const String deleteExpense = "/expense/delete";
} }

View File

@ -239,6 +239,335 @@ class ApiService {
} }
} }
// === Expense APIs === //
/// Edit Expense API
static Future<bool> editExpenseApi({
required String expenseId,
required Map<String, dynamic> payload,
}) async {
final endpoint = "${ApiEndpoints.editExpense}/$expenseId";
logSafe("Editing expense $expenseId with payload: $payload");
try {
final response = await _putRequest(
endpoint,
payload,
customTimeout: extendedTimeout,
);
if (response == null) {
logSafe("Edit expense failed: null response", level: LogLevel.error);
return false;
}
logSafe("Edit expense response status: ${response.statusCode}");
logSafe("Edit expense response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Expense updated successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to update expense: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception during editExpenseApi: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return false;
}
static Future<bool> deleteExpense(String expenseId) async {
final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId";
try {
final token = await _getToken();
if (token == null) {
logSafe("Token is null. Cannot proceed with DELETE request.",
level: LogLevel.error);
return false;
}
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
final response =
await http.delete(uri, headers: _headers(token)).timeout(timeout);
logSafe("DELETE expense response status: ${response.statusCode}");
logSafe("DELETE expense response body: ${response.body}");
final json = jsonDecode(response.body);
if (response.statusCode == 200 && json['success'] == true) {
logSafe("Expense deleted successfully.");
return true;
} else {
logSafe(
"Failed to delete expense: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning);
}
} catch (e, stack) {
logSafe("Exception during deleteExpenseApi: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return false;
}
/// Get Expense Details API
static Future<Map<String, dynamic>?> getExpenseDetailsApi({
required String expenseId,
}) async {
final endpoint = "${ApiEndpoints.getExpenseDetails}/$expenseId";
logSafe("Fetching expense details for ID: $expenseId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Expense details request failed: null response",
level: LogLevel.error);
return null;
}
final body = response.body.trim();
if (body.isEmpty) {
logSafe("Expense details response body is empty",
level: LogLevel.error);
return null;
}
final jsonResponse = jsonDecode(body);
if (jsonResponse is Map<String, dynamic>) {
if (jsonResponse['success'] == true) {
logSafe("Expense details fetched successfully");
return jsonResponse['data']; // Return the expense details object
} else {
logSafe(
"Failed to fetch expense details: ${jsonResponse['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
}
} else {
logSafe("Unexpected response structure: $jsonResponse",
level: LogLevel.error);
}
} catch (e, stack) {
logSafe("Exception during getExpenseDetailsApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Update Expense Status API
static Future<bool> updateExpenseStatusApi({
required String expenseId,
required String statusId,
String? comment,
String? reimburseTransactionId,
String? reimburseDate,
String? reimbursedById,
}) async {
final Map<String, dynamic> payload = {
"expenseId": expenseId,
"statusId": statusId,
};
if (comment != null) {
payload["comment"] = comment;
}
if (reimburseTransactionId != null) {
payload["reimburseTransactionId"] = reimburseTransactionId;
}
if (reimburseDate != null) {
payload["reimburseDate"] = reimburseDate;
}
if (reimbursedById != null) {
payload["reimburseById"] = reimbursedById;
}
const endpoint = ApiEndpoints.updateExpenseStatus;
logSafe("Updating expense status with payload: $payload");
try {
final response = await _postRequest(
endpoint,
payload,
customTimeout: extendedTimeout,
);
if (response == null) {
logSafe("Update expense status failed: null response",
level: LogLevel.error);
return false;
}
logSafe("Update expense status response status: ${response.statusCode}");
logSafe("Update expense status response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Expense status updated successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to update expense status: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception during updateExpenseStatus API: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return false;
}
static Future<Map<String, dynamic>?> getExpenseListApi({
String? filter,
int pageSize = 20,
int pageNumber = 1,
}) async {
// Build the endpoint with query parameters
String endpoint = ApiEndpoints.getExpenseList;
final queryParams = <String, String>{
'pageSize': pageSize.toString(),
'pageNumber': pageNumber.toString(),
};
if (filter != null && filter.isNotEmpty) {
queryParams['filter'] = filter;
}
// Build the full URI
final uri = Uri.parse(endpoint).replace(queryParameters: queryParams);
logSafe("Fetching expense list with URI: $uri");
try {
final response = await _getRequest(uri.toString());
if (response == null) {
logSafe("Expense list request failed: null response",
level: LogLevel.error);
return null;
}
// Directly parse and return the entire JSON response
final body = response.body.trim();
if (body.isEmpty) {
logSafe("Expense list response body is empty", level: LogLevel.error);
return null;
}
final jsonResponse = jsonDecode(body);
if (jsonResponse is Map<String, dynamic>) {
logSafe("Expense list response parsed successfully");
return jsonResponse; // Return the entire API response
} else {
logSafe("Unexpected response structure: $jsonResponse",
level: LogLevel.error);
return null;
}
} catch (e, stack) {
logSafe("Exception during getExpenseListApi: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return null;
}
}
/// Fetch Master Payment Modes
static Future<List<dynamic>?> getMasterPaymentModes() async {
const endpoint = ApiEndpoints.getMasterPaymentModes;
return _getRequest(endpoint).then((res) => res != null
? _parseResponse(res, label: 'Master Payment Modes')
: null);
}
/// Fetch Master Expense Status
static Future<List<dynamic>?> getMasterExpenseStatus() async {
const endpoint = ApiEndpoints.getMasterExpenseStatus;
return _getRequest(endpoint).then((res) => res != null
? _parseResponse(res, label: 'Master Expense Status')
: null);
}
/// Fetch Master Expense Types
static Future<List<dynamic>?> getMasterExpenseTypes() async {
const endpoint = ApiEndpoints.getMasterExpenseTypes;
return _getRequest(endpoint).then((res) => res != null
? _parseResponse(res, label: 'Master Expense Types')
: null);
}
/// Create Expense API
static Future<bool> createExpenseApi({
required String projectId,
required String expensesTypeId,
required String paymentModeId,
required String paidById,
required DateTime transactionDate,
required String transactionId,
required String description,
required String location,
required String supplerName,
required double amount,
required int noOfPersons,
required List<Map<String, dynamic>> billAttachments,
}) async {
final payload = {
"projectId": projectId,
"expensesTypeId": expensesTypeId,
"paymentModeId": paymentModeId,
"paidById": paidById,
"transactionDate": transactionDate.toIso8601String(),
"transactionId": transactionId,
"description": description,
"location": location,
"supplerName": supplerName,
"amount": amount,
"noOfPersons": noOfPersons,
"billAttachments": billAttachments,
};
const endpoint = ApiEndpoints.createExpense;
logSafe("Creating expense with payload: $payload");
try {
final response =
await _postRequest(endpoint, payload, customTimeout: extendedTimeout);
if (response == null) {
logSafe("Create expense failed: null response", level: LogLevel.error);
return false;
}
logSafe("Create expense response status: ${response.statusCode}");
logSafe("Create expense response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Expense created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create expense: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning);
}
} catch (e, stack) {
logSafe("Exception during createExpense API: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return false;
}
// === Dashboard Endpoints === // === Dashboard Endpoints ===
static Future<List<dynamic>?> getDashboardAttendanceOverview( static Future<List<dynamic>?> getDashboardAttendanceOverview(
@ -822,14 +1151,42 @@ class ApiService {
} }
// === Employee APIs === // === Employee APIs ===
/// Search employees by first name and last name only (not middle name)
/// Returns a list of up to 10 employee records matching the search string.
static Future<List<dynamic>?> searchEmployeesBasic({
String? searchString,
}) async {
// Remove ArgumentError check because searchString is optional now
final queryParams = <String, String>{};
// Add searchString to query parameters only if it's not null or empty
if (searchString != null && searchString.isNotEmpty) {
queryParams['searchString'] = searchString;
}
final response = await _getRequest(
ApiEndpoints.getEmployeesWithoutPermission,
queryParams: queryParams,
);
if (response != null) {
return _parseResponse(response, label: 'Search Employees Basic');
}
return null;
}
static Future<List<dynamic>?> getAllEmployeesByProject( static Future<List<dynamic>?> getAllEmployeesByProject(
String projectId) async { String projectId) async {
if (projectId.isEmpty) throw ArgumentError('projectId must not be empty'); if (projectId.isEmpty) throw ArgumentError('projectId must not be empty');
final endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId"; final endpoint =
return _getRequest(endpoint).then((res) => res != null "${ApiEndpoints.getAllEmployeesByProject}?projectId=$projectId";
? _parseResponse(res, label: 'Employees by Project') return _getRequest(endpoint).then(
: null); (res) => res != null
? _parseResponse(res, label: 'Employees by Project')
: null,
);
} }
static Future<List<dynamic>?> getAllEmployees() async => static Future<List<dynamic>?> getAllEmployees() async =>

View File

@ -8,41 +8,53 @@ import 'package:marco/helpers/theme/app_theme.dart';
import 'package:url_strategy/url_strategy.dart'; import 'package:url_strategy/url_strategy.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/auth_service.dart';
import 'package:flutter/material.dart';
Future<void> initializeApp() async { Future<void> initializeApp() async {
try { try {
logSafe("💡 Starting app initialization..."); logSafe("💡 Starting app initialization...");
// UI Setup
setPathUrlStrategy(); setPathUrlStrategy();
logSafe("💡 URL strategy set."); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( const SystemUiOverlayStyle(
statusBarColor: Color.fromARGB(255, 255, 0, 0), statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light, systemNavigationBarColor: Colors.transparent,
)); statusBarIconBrightness: Brightness.light,
logSafe("💡 System UI overlay style set."); systemNavigationBarIconBrightness: Brightness.dark,
),
);
logSafe("💡 UI setup completed.");
// Local storage
await LocalStorage.init(); await LocalStorage.init();
logSafe("💡 Local storage initialized."); logSafe("💡 Local storage initialized.");
// If a refresh token is found, try to refresh the JWT token // Token handling
final refreshToken = await LocalStorage.getRefreshToken(); final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken != null && refreshToken.isNotEmpty) { final hasRefreshToken = refreshToken?.isNotEmpty ?? false;
if (hasRefreshToken) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT..."); logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken(); final success = await AuthService.refreshToken();
if (!success) { if (!success) {
logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection."); logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection.");
// Optionally, clear tokens and force logout here if needed // Optionally clear tokens or handle logout here
} }
} else { } else {
logSafe("❌ No refresh token found. Skipping refresh."); logSafe("❌ No refresh token found. Skipping refresh.");
} }
// Theme setup
await ThemeCustomizer.init(); await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized."); logSafe("💡 Theme customizer initialized.");
// Controller setup
final token = LocalStorage.getString('jwt_token'); final token = LocalStorage.getString('jwt_token');
if (token != null && token.isNotEmpty) { final hasJwt = token?.isNotEmpty ?? false;
if (hasJwt) {
if (!Get.isRegistered<PermissionController>()) { if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController()); Get.put(PermissionController());
logSafe("💡 PermissionController injected."); logSafe("💡 PermissionController injected.");
@ -53,13 +65,13 @@ Future<void> initializeApp() async {
logSafe("💡 ProjectController injected as permanent."); logSafe("💡 ProjectController injected as permanent.");
} }
// Load data into controllers if required await Get.find<PermissionController>().loadData(token!);
await Get.find<PermissionController>().loadData(token);
await Get.find<ProjectController>().fetchProjects(); await Get.find<ProjectController>().fetchProjects();
} else { } else {
logSafe("⚠️ No valid JWT token found. Skipping controller initialization."); logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
} }
// Final style setup
AppStyle.init(); AppStyle.init();
logSafe("💡 AppStyle initialized."); logSafe("💡 AppStyle initialized.");

View File

@ -1,18 +1,14 @@
import 'dart:io'; import 'dart:io';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:path_provider/path_provider.dart';
/// Global logger instance /// Global logger instance
late final Logger appLogger; late final Logger appLogger;
/// Log file output handler
late final FileLogOutput fileLogOutput; late final FileLogOutput fileLogOutput;
/// Initialize logging (call once in `main()`) /// Initialize logging
Future<void> initLogging() async { Future<void> initLogging() async {
await requestStoragePermission();
fileLogOutput = FileLogOutput(); fileLogOutput = FileLogOutput();
appLogger = Logger( appLogger = Logger(
@ -23,21 +19,13 @@ Future<void> initLogging() async {
printEmojis: true, printEmojis: true,
), ),
output: MultiOutput([ output: MultiOutput([
ConsoleOutput(), // Console will use the top-level PrettyPrinter ConsoleOutput(),
fileLogOutput, // File will still use the SimpleFileLogPrinter fileLogOutput,
]), ]),
level: Level.debug, level: Level.debug,
); );
} }
/// Request storage permission (for Android 11+)
Future<void> requestStoragePermission() async {
final status = await Permission.manageExternalStorage.status;
if (!status.isGranted) {
await Permission.manageExternalStorage.request();
}
}
/// Safe logger wrapper /// Safe logger wrapper
void logSafe( void logSafe(
String message, { String message, {
@ -46,7 +34,7 @@ void logSafe(
StackTrace? stackTrace, StackTrace? stackTrace,
bool sensitive = false, bool sensitive = false,
}) { }) {
if (sensitive) return; if (sensitive) return;
switch (level) { switch (level) {
case LogLevel.debug: case LogLevel.debug:
@ -66,15 +54,15 @@ void logSafe(
} }
} }
/// Custom log output that writes to a local `.txt` file /// Log output to file (safe path, no permission required)
class FileLogOutput extends LogOutput { class FileLogOutput extends LogOutput {
File? _logFile; File? _logFile;
/// Initialize log file in Downloads/marco_logs/log_YYYY-MM-DD.txt
Future<void> _init() async { Future<void> _init() async {
if (_logFile != null) return; if (_logFile != null) return;
final directory = Directory('/storage/emulated/0/Download/marco_logs'); final baseDir = await getExternalStorageDirectory();
final directory = Directory('${baseDir!.path}/marco_logs');
if (!await directory.exists()) { if (!await directory.exists()) {
await directory.create(recursive: true); await directory.create(recursive: true);
} }
@ -119,7 +107,6 @@ class FileLogOutput extends LogOutput {
return _logFile!.readAsString(); return _logFile!.readAsString();
} }
/// Delete logs older than 3 days
Future<void> _cleanOldLogs(Directory directory) async { Future<void> _cleanOldLogs(Directory directory) async {
final files = directory.listSync(); final files = directory.listSync();
final now = DateTime.now(); final now = DateTime.now();
@ -135,7 +122,7 @@ class FileLogOutput extends LogOutput {
} }
} }
/// A simple, readable log printer for file output /// Simple log printer for file output
class SimpleFileLogPrinter extends LogPrinter { class SimpleFileLogPrinter extends LogPrinter {
@override @override
List<String> log(LogEvent event) { List<String> log(LogEvent event) {
@ -152,5 +139,5 @@ class SimpleFileLogPrinter extends LogPrinter {
} }
} }
/// Optional log level enum for better type safety /// Optional enum for log levels
enum LogLevel { debug, info, warning, error, verbose } enum LogLevel { debug, info, warning, error, verbose }

View File

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class BaseBottomSheet extends StatelessWidget {
final String title;
final Widget child;
final VoidCallback onCancel;
final VoidCallback onSubmit;
final bool isSubmitting;
final String submitText;
final Color submitColor;
final IconData submitIcon;
final bool showButtons;
final Widget? bottomContent;
const BaseBottomSheet({
super.key,
required this.title,
required this.child,
required this.onCancel,
required this.onSubmit,
this.isSubmitting = false,
this.submitText = 'Submit',
this.submitColor = Colors.indigo,
this.submitIcon = Icons.check_circle_outline,
this.showButtons = true,
this.bottomContent,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
return SingleChildScrollView(
padding: mediaQuery.viewInsets,
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, -2),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MySpacing.height(5),
Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
MySpacing.height(12),
MyText.titleLarge(title, fontWeight: 700),
MySpacing.height(12),
child,
MySpacing.height(12),
// 👇 Buttons (if enabled)
if (showButtons) ...[
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: isSubmitting ? null : onSubmit,
icon: Icon(submitIcon, color: Colors.white),
label: MyText.bodyMedium(
isSubmitting ? "Submitting..." : submitText,
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: submitColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
],
),
// 👇 Optional Bottom Content
if (bottomContent != null) ...[
MySpacing.height(12),
bottomContent!,
],
],
],
),
),
),
),
);
}
}

View File

@ -1,11 +1,10 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
class DateTimeUtils { class DateTimeUtils {
/// Converts a UTC datetime string to local time and formats it.
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) {
try { try {
logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"');
final parsed = DateTime.parse(utcTimeString); final parsed = DateTime.parse(utcTimeString);
final utcDateTime = DateTime.utc( final utcDateTime = DateTime.utc(
parsed.year, parsed.year,
@ -17,13 +16,10 @@ class DateTimeUtils {
parsed.millisecond, parsed.millisecond,
parsed.microsecond, parsed.microsecond,
); );
logSafe('Parsed (assumed UTC): $utcDateTime');
final localDateTime = utcDateTime.toLocal(); final localDateTime = utcDateTime.toLocal();
logSafe('Converted to Local: $localDateTime');
final formatted = _formatDateTime(localDateTime, format: format); final formatted = _formatDateTime(localDateTime, format: format);
logSafe('Formatted Local Time: $formatted');
return formatted; return formatted;
} catch (e, stackTrace) { } catch (e, stackTrace) {
@ -32,6 +28,17 @@ class DateTimeUtils {
} }
} }
/// Public utility for formatting any DateTime.
static String formatDate(DateTime date, String format) {
try {
return DateFormat(format).format(date);
} catch (e, stackTrace) {
logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace);
return 'Invalid Date';
}
}
/// Internal formatter with default format.
static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) { static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) {
return DateFormat(format).format(dateTime); return DateFormat(format).format(dateTime);
} }

View File

@ -3,9 +3,9 @@ class Permissions {
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614"; static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc"; static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566"; static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566";
static const String manageProjectInfra ="f2aee20a-b754-4537-8166-f9507b44585b"; static const String manageProjectInfra = "f2aee20a-b754-4537-8166-f9507b44585b";
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8"; static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6"; static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3"; static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c"; static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5"; static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
@ -13,4 +13,13 @@ class Permissions {
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2"; static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda"; static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5"; static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
// Expense Permissions
static const String expenseViewSelf = "385be49f-8fde-440e-bdbc-3dffeb8dd116";
static const String expenseViewAll = "01e06444-9ca7-4df4-b900-8c3fa051b92f";
static const String expenseUpload = "0f57885d-bcb2-4711-ac95-d841ace6d5a7";
static const String expenseReview = "1f4bda08-1873-449a-bb66-3e8222bd871b";
static const String expenseApprove = "eaafdd76-8aac-45f9-a530-315589c6deca";
static const String expenseProcess = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
static const String expenseManage = "bdee29a2-b73b-402d-8dd1-c4b1f81ccbc3";
} }

View File

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
/// Returns a formatted color for the expense status.
Color getExpenseStatusColor(String? status, {String? colorCode}) {
if (colorCode != null && colorCode.isNotEmpty) {
try {
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
} catch (_) {}
}
switch (status) {
case 'Approval Pending':
return Colors.orange;
case 'Process Pending':
return Colors.blue;
case 'Rejected':
return Colors.red;
case 'Paid':
return Colors.green;
default:
return Colors.black;
}
}
/// Formats amount to currency string.
String formatExpenseAmount(double amount) {
return NumberFormat.currency(
locale: 'en_IN', symbol: '', decimalDigits: 2)
.format(amount);
}
/// Label/Value block as reusable widget.
Widget labelValueBlock(String label, String value) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(label, fontWeight: 600),
MySpacing.height(4),
MyText.bodySmall(value,
fontWeight: 500, softWrap: true, maxLines: null),
],
);
/// Skeleton loader for lists.
Widget buildLoadingSkeleton() => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 5,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.only(bottom: 16),
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300], borderRadius: BorderRadius.circular(10)),
),
);
/// Expandable description widget.
class ExpandableDescription extends StatefulWidget {
final String description;
const ExpandableDescription({Key? key, required this.description})
: super(key: key);
@override
State<ExpandableDescription> createState() => _ExpandableDescriptionState();
}
class _ExpandableDescriptionState extends State<ExpandableDescription> {
bool isExpanded = false;
@override
Widget build(BuildContext context) {
final isLong = widget.description.length > 100;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
widget.description,
maxLines: isExpanded ? null : 2,
overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
fontWeight: 500,
),
if (isLong || !isExpanded)
InkWell(
onTap: () => setState(() => isExpanded = !isExpanded),
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: MyText.labelSmall(
isExpanded ? 'Show less' : 'Show more',
fontWeight: 600,
color: Colors.blue,
),
),
),
],
);
}
}

View File

@ -0,0 +1,437 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/view/expense/expense_detail_screen.dart';
class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController;
const ExpenseAppBar({required this.projectController, super.key});
@override
Size get preferredSize => const Size.fromHeight(72);
@override
Widget build(BuildContext context) {
return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Expenses', fontWeight: 700),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
fontWeight: 600,
),
),
],
);
},
),
],
),
),
],
),
),
);
}
}
class SearchAndFilter extends StatelessWidget {
final TextEditingController controller;
final ValueChanged<String> onChanged;
final VoidCallback onFilterTap;
final VoidCallback onRefreshTap;
final ExpenseController expenseController;
const SearchAndFilter({
required this.controller,
required this.onChanged,
required this.onFilterTap,
required this.onRefreshTap,
required this.expenseController,
super.key,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: MySpacing.fromLTRB(12, 10, 12, 0),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 35,
child: TextField(
controller: controller,
onChanged: onChanged,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
prefixIcon:
const Icon(Icons.search, size: 20, color: Colors.grey),
hintText: 'Search expenses...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
),
),
MySpacing.width(8),
Tooltip(
message: 'Refresh Data',
child: IconButton(
icon: const Icon(Icons.refresh, color: Colors.green, size: 24),
onPressed: onRefreshTap,
),
),
MySpacing.width(4),
Obx(() {
return IconButton(
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.tune, color: Colors.black),
if (expenseController.isFilterApplied)
Positioned(
top: -1,
right: -1,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
),
),
],
),
onPressed: onFilterTap,
);
}),
],
),
);
}
}
class ToggleButtonsRow extends StatelessWidget {
final bool isHistoryView;
final ValueChanged<bool> onToggle;
const ToggleButtonsRow({
required this.isHistoryView,
required this.onToggle,
super.key,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: MySpacing.fromLTRB(8, 12, 8, 5),
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
_ToggleButton(
label: 'Expenses',
icon: Icons.receipt_long,
selected: !isHistoryView,
onTap: () => onToggle(false),
),
_ToggleButton(
label: 'History',
icon: Icons.history,
selected: isHistoryView,
onTap: () => onToggle(true),
),
],
),
),
);
}
}
class _ToggleButton extends StatelessWidget {
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
const _ToggleButton({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
decoration: BoxDecoration(
color: selected ? Colors.red : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 16, color: selected ? Colors.white : Colors.grey),
const SizedBox(width: 6),
MyText.bodyMedium(label,
color: selected ? Colors.white : Colors.grey,
fontWeight: 600),
],
),
),
),
);
}
}
class ExpenseList extends StatelessWidget {
final List<ExpenseModel> expenseList;
final Future<void> Function()? onViewDetail;
const ExpenseList({
required this.expenseList,
this.onViewDetail,
super.key,
});
void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) {
final ExpenseController controller = Get.find<ExpenseController>();
final RxBool isDeleting = false.obs;
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Obx(() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: isDeleting.value
? const SizedBox(
height: 100,
child: Center(child: CircularProgressIndicator()),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete,
size: 48, color: Colors.redAccent),
const SizedBox(height: 16),
MyText.titleLarge("Delete Expense",
fontWeight: 600,
color:
Theme.of(context).colorScheme.onBackground),
const SizedBox(height: 12),
MyText.bodySmall(
"Are you sure you want to delete this draft expense?",
textAlign: TextAlign.center,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => Navigator.pop(context),
icon:
const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding:
const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
isDeleting.value = true;
await controller.deleteExpense(expense.id);
isDeleting.value = false;
Navigator.pop(context);
showAppSnackbar(
title: 'Deleted',
message: 'Expense has been deleted.',
type: SnackbarType.success,
);
},
icon: const Icon(Icons.delete_forever,
color: Colors.white),
label: MyText.bodyMedium(
"Delete",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding:
const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
),
);
}),
),
);
}
@override
Widget build(BuildContext context) {
if (expenseList.isEmpty && !Get.find<ExpenseController>().isLoading.value) {
return Center(child: MyText.bodyMedium('No expenses found.'));
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: expenseList.length,
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
itemBuilder: (context, index) {
final expense = expenseList[index];
final formattedDate = DateTimeUtils.convertUtcToLocal(
expense.transactionDate.toIso8601String(),
format: 'dd MMM yyyy, hh:mm a',
);
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () async {
final result = await Get.to(
() => ExpenseDetailScreen(expenseId: expense.id),
arguments: {'expense': expense},
);
if (result == true && onViewDetail != null) {
await onViewDetail!();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(expense.expensesType.name,
fontWeight: 600),
Row(
children: [
MyText.bodyMedium(
'${expense.amount.toStringAsFixed(2)}',
fontWeight: 600),
if (expense.status.name.toLowerCase() == 'draft') ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () =>
_showDeleteConfirmation(context, expense),
child: const Icon(Icons.delete,
color: Colors.red, size: 20),
),
],
],
),
],
),
const SizedBox(height: 6),
Row(
children: [
MyText.bodySmall(formattedDate, fontWeight: 500),
const Spacer(),
MyText.bodySmall(expense.status.name, fontWeight: 500),
],
),
],
),
),
),
);
},
);
}
}

View File

@ -4,36 +4,34 @@ import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/my_shadow.dart'; import 'package:marco/helpers/utils/my_shadow.dart';
class SkeletonLoaders { class SkeletonLoaders {
static Widget buildLoadingSkeleton() {
static Widget buildLoadingSkeleton() { return SizedBox(
return SizedBox( height: 360,
height: 360, child: Column(
child: Column( children: List.generate(5, (index) {
children: List.generate(5, (index) { return Padding(
return Padding( padding: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(vertical: 6), child: SingleChildScrollView(
child: SingleChildScrollView( scrollDirection: Axis.horizontal,
scrollDirection: Axis.horizontal, child: Row(
child: Row( children: List.generate(6, (i) {
children: List.generate(6, (i) { return Container(
return Container( margin: const EdgeInsets.symmetric(horizontal: 4),
margin: const EdgeInsets.symmetric(horizontal: 4), width: 48,
width: 48, height: 16,
height: 16, decoration: BoxDecoration(
decoration: BoxDecoration( color: Colors.grey.shade300,
color: Colors.grey.shade300, borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(6), ),
), );
); }),
}), ),
), ),
), );
); }),
}), ),
), );
); }
}
// Employee List - Card Style // Employee List - Card Style
static Widget employeeListSkeletonLoader() { static Widget employeeListSkeletonLoader() {
@ -63,25 +61,37 @@ static Widget buildLoadingSkeleton() {
children: [ children: [
Row( Row(
children: [ children: [
Container(height: 14, width: 100, color: Colors.grey.shade300), Container(
height: 14,
width: 100,
color: Colors.grey.shade300),
MySpacing.width(8), MySpacing.width(8),
Container(height: 12, width: 60, color: Colors.grey.shade300), Container(
height: 12, width: 60, color: Colors.grey.shade300),
], ],
), ),
MySpacing.height(8), MySpacing.height(8),
Row( Row(
children: [ children: [
Icon(Icons.email, size: 16, color: Colors.grey.shade300), Icon(Icons.email,
size: 16, color: Colors.grey.shade300),
MySpacing.width(4), MySpacing.width(4),
Container(height: 10, width: 140, color: Colors.grey.shade300), Container(
height: 10,
width: 140,
color: Colors.grey.shade300),
], ],
), ),
MySpacing.height(8), MySpacing.height(8),
Row( Row(
children: [ children: [
Icon(Icons.phone, size: 16, color: Colors.grey.shade300), Icon(Icons.phone,
size: 16, color: Colors.grey.shade300),
MySpacing.width(4), MySpacing.width(4),
Container(height: 10, width: 100, color: Colors.grey.shade300), Container(
height: 10,
width: 100,
color: Colors.grey.shade300),
], ],
), ),
], ],
@ -122,16 +132,28 @@ static Widget buildLoadingSkeleton() {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container(height: 12, width: 100, color: Colors.grey.shade300), Container(
height: 12,
width: 100,
color: Colors.grey.shade300),
MySpacing.height(8), MySpacing.height(8),
Container(height: 10, width: 80, color: Colors.grey.shade300), Container(
height: 10,
width: 80,
color: Colors.grey.shade300),
MySpacing.height(12), MySpacing.height(12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Container(height: 28, width: 60, color: Colors.grey.shade300), Container(
height: 28,
width: 60,
color: Colors.grey.shade300),
MySpacing.width(8), MySpacing.width(8),
Container(height: 28, width: 60, color: Colors.grey.shade300), Container(
height: 28,
width: 60,
color: Colors.grey.shade300),
], ],
), ),
], ],
@ -167,7 +189,8 @@ static Widget buildLoadingSkeleton() {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Container(height: 14, width: 120, color: Colors.grey.shade300), Container(
height: 14, width: 120, color: Colors.grey.shade300),
Icon(Icons.add_circle, color: Colors.grey.shade300), Icon(Icons.add_circle, color: Colors.grey.shade300),
], ],
), ),
@ -226,133 +249,198 @@ static Widget buildLoadingSkeleton() {
}), }),
); );
} }
static Widget employeeSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 12,
borderRadiusAll: 12,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
// Name, org, email, phone static Widget expenseListSkeletonLoader() {
Expanded( return ListView.separated(
child: Column( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
crossAxisAlignment: CrossAxisAlignment.start, itemCount: 6, // Show 6 skeleton items
children: [ separatorBuilder: (_, __) =>
Container(height: 12, width: 120, color: Colors.grey.shade300), Divider(color: Colors.grey.shade300, height: 20),
MySpacing.height(6), itemBuilder: (context, index) {
Container(height: 10, width: 80, color: Colors.grey.shade300), return Column(
MySpacing.height(8), crossAxisAlignment: CrossAxisAlignment.start,
// Email placeholder
Row(
children: [
Icon(Icons.email_outlined, size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(height: 10, width: 140, color: Colors.grey.shade300),
],
),
MySpacing.height(8),
// Phone placeholder
Row(
children: [
Icon(Icons.phone_outlined, size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.width(8),
Container(
height: 16,
width: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
],
),
MySpacing.height(8),
// Tags placeholder
Container(height: 8, width: 80, color: Colors.grey.shade300),
],
),
),
// Arrow
Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300),
],
),
);
}
static Widget contactSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 16,
borderRadiusAll: 16,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Container( // Title and Amount
height: 40, Row(
width: 40, mainAxisAlignment: MainAxisAlignment.spaceBetween,
decoration: BoxDecoration( children: [
color: Colors.grey.shade300, Container(
shape: BoxShape.circle, height: 14,
), width: 120,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
Container(
height: 14,
width: 80,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
],
), ),
MySpacing.width(12), const SizedBox(height: 6),
Expanded( // Date and Status
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Container(
Container( height: 12,
height: 12, width: 100,
width: 100, decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
), ),
MySpacing.height(6), ),
Container( const Spacer(),
height: 10, Container(
width: 60, height: 12,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
), ),
], ),
), ],
), ),
], ],
), );
MySpacing.height(16), },
Container(height: 10, width: 150, color: Colors.grey.shade300), );
MySpacing.height(8), }
Container(height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 120, color: Colors.grey.shade300),
],
),
);
}
static Widget employeeSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 12,
borderRadiusAll: 12,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
// Name, org, email, phone
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 12, width: 120, color: Colors.grey.shade300),
MySpacing.height(6),
Container(height: 10, width: 80, color: Colors.grey.shade300),
MySpacing.height(8),
// Email placeholder
Row(
children: [
Icon(Icons.email_outlined,
size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(
height: 10, width: 140, color: Colors.grey.shade300),
],
),
MySpacing.height(8),
// Phone placeholder
Row(
children: [
Icon(Icons.phone_outlined,
size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(
height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.width(8),
Container(
height: 16,
width: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
],
),
MySpacing.height(8),
// Tags placeholder
Container(height: 8, width: 80, color: Colors.grey.shade300),
],
),
),
// Arrow
Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300),
],
),
);
}
static Widget contactSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 16,
borderRadiusAll: 16,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
height: 40,
width: 40,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 12,
width: 100,
color: Colors.grey.shade300,
),
MySpacing.height(6),
Container(
height: 10,
width: 60,
color: Colors.grey.shade300,
),
],
),
),
],
),
MySpacing.height(16),
Container(height: 10, width: 150, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 120, color: Colors.grey.shade300),
],
),
);
}
} }

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class TeamBottomSheet { class TeamBottomSheet {
static void show({ static void show({
@ -9,46 +11,61 @@ class TeamBottomSheet {
}) { }) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
shape: const RoundedRectangleBorder( isScrollControlled: true,
borderRadius: BorderRadius.vertical(top: Radius.circular(12)), backgroundColor: Colors.transparent,
), builder: (_) {
backgroundColor: Colors.white, return BaseBottomSheet(
builder: (_) => Padding( title: 'Team Members',
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), onCancel: () => Navigator.pop(context),
child: Column( onSubmit: () {},
mainAxisSize: MainAxisSize.min, showButtons: false,
crossAxisAlignment: CrossAxisAlignment.start, child: _TeamMemberList(teamMembers: teamMembers),
children: [ );
// Title and Close Icon },
Row( );
mainAxisAlignment: MainAxisAlignment.spaceBetween, }
children: [ }
MyText.bodyLarge("Team Members", fontWeight: 600),
IconButton( class _TeamMemberList extends StatelessWidget {
icon: const Icon(Icons.close, size: 20, color: Colors.black54), final List<dynamic> teamMembers;
onPressed: () => Navigator.pop(context),
), const _TeamMemberList({required this.teamMembers});
],
), @override
const Divider(thickness: 1.2), Widget build(BuildContext context) {
// Team Member Rows if (teamMembers.isEmpty) {
...teamMembers.map((member) => _buildTeamMemberRow(member)), return Center(
], child: Padding(
), padding: const EdgeInsets.symmetric(vertical: 20),
), child: MyText.bodySmall(
); "No team members found.",
} fontWeight: 600,
color: Colors.grey,
static Widget _buildTeamMemberRow(dynamic member) { ),
return Padding( ),
padding: const EdgeInsets.symmetric(vertical: 8), );
child: Row( }
children: [
Avatar(firstName: member.firstName, lastName: '', size: 36), return ListView.separated(
const SizedBox(width: 10), shrinkWrap: true,
MyText.bodyMedium(member.firstName, fontWeight: 500), physics: const NeverScrollableScrollPhysics(),
], itemCount: teamMembers.length,
), separatorBuilder: (_, __) => const Divider(thickness: 0.8, height: 12),
itemBuilder: (_, index) {
final member = teamMembers[index];
final String name = member.firstName ?? 'Unnamed';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Avatar(firstName: member.firstName, lastName: '', size: 36),
MySpacing.width(10),
MyText.bodyMedium(name, fontWeight: 500),
],
),
);
},
); );
} }
} }

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/model/directory/contact_bucket_list_model.dart'; import 'package:marco/model/directory/contact_bucket_list_model.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class TeamMembersBottomSheet { class TeamMembersBottomSheet {
static void show( static void show(
@ -11,8 +13,9 @@ class TeamMembersBottomSheet {
bool canEdit = false, bool canEdit = false,
VoidCallback? onEdit, VoidCallback? onEdit,
}) { }) {
// Ensure the owner is at the top of the list
final ownerId = bucket.createdBy.id; final ownerId = bucket.createdBy.id;
// Ensure owner is listed first
members.sort((a, b) { members.sort((a, b) {
if (a.id == ownerId) return -1; if (a.id == ownerId) return -1;
if (b.id == ownerId) return 1; if (b.id == ownerId) return 1;
@ -23,201 +26,185 @@ class TeamMembersBottomSheet {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
isDismissible: true, builder: (_) {
enableDrag: true, return BaseBottomSheet(
builder: (context) { title: 'Bucket Details',
return SafeArea( onCancel: () => Navigator.pop(context),
child: Container( onSubmit: () {}, // Not used, but required
decoration: const BoxDecoration( showButtons: false,
color: Colors.white, child: _TeamContent(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), bucket: bucket,
), members: members,
child: DraggableScrollableSheet( canEdit: canEdit,
expand: false, onEdit: onEdit,
initialChildSize: 0.7, ownerId: ownerId,
minChildSize: 0.5, ),
maxChildSize: 0.95, );
builder: (context, scrollController) { },
return Column( );
children: [ }
const SizedBox(height: 6), }
Container(
width: 36, class _TeamContent extends StatelessWidget {
height: 4, final ContactBucket bucket;
decoration: BoxDecoration( final List<dynamic> members;
color: Colors.grey.shade300, final bool canEdit;
borderRadius: BorderRadius.circular(2), final VoidCallback? onEdit;
), final String ownerId;
),
const SizedBox(height: 10), const _TeamContent({
required this.bucket,
MyText.titleMedium( required this.members,
'Bucket Details', required this.canEdit,
fontWeight: 700, this.onEdit,
), required this.ownerId,
});
const SizedBox(height: 12),
@override
// Header with title and edit Widget build(BuildContext context) {
Padding( return Column(
padding: const EdgeInsets.symmetric(horizontal: 16), children: [
child: Row( _buildHeader(),
children: [ _buildInfo(),
Expanded( _buildMembersTitle(),
child: MyText.titleMedium( MySpacing.height(8),
bucket.name, SizedBox(
fontWeight: 700, height: 300,
), child: _buildMemberList(),
), ),
if (canEdit) ],
IconButton( );
onPressed: onEdit, }
icon: const Icon(Icons.edit, color: Colors.red),
tooltip: 'Edit Bucket', Widget _buildHeader() {
), return Padding(
], padding: const EdgeInsets.symmetric(horizontal: 4),
), child: Row(
), children: [
Expanded(
// Info child: MyText.titleMedium(bucket.name, fontWeight: 700),
Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 16), if (canEdit)
child: Column( IconButton(
crossAxisAlignment: CrossAxisAlignment.start, onPressed: onEdit,
children: [ icon: const Icon(Icons.edit, color: Colors.red),
if (bucket.description.isNotEmpty) tooltip: 'Edit Bucket',
Padding( ),
padding: const EdgeInsets.only(bottom: 6), ],
child: MyText.bodySmall( ),
bucket.description, );
color: Colors.grey[700], }
),
), Widget _buildInfo() {
Row( return Padding(
children: [ padding: const EdgeInsets.only(bottom: 12),
const Icon(Icons.contacts_outlined, child: Column(
size: 14, color: Colors.grey), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 4), children: [
MyText.labelSmall( if (bucket.description.isNotEmpty)
'${bucket.numberOfContacts} contact(s)', Padding(
fontWeight: 600, padding: const EdgeInsets.only(bottom: 6),
color: Colors.red, child: MyText.bodySmall(
), bucket.description,
const SizedBox(width: 12), color: Colors.grey[700],
const Icon(Icons.ios_share_outlined, ),
size: 14, color: Colors.grey), ),
const SizedBox(width: 4), Row(
MyText.labelSmall( children: [
'Shared with (${members.length})', const Icon(Icons.contacts_outlined, size: 14, color: Colors.grey),
fontWeight: 600, const SizedBox(width: 4),
color: Colors.indigo, MyText.labelSmall(
), '${bucket.numberOfContacts} contact(s)',
], fontWeight: 600,
), color: Colors.red,
Padding( ),
padding: const EdgeInsets.only(top: 8), const SizedBox(width: 12),
child: Row( const Icon(Icons.ios_share_outlined, size: 14, color: Colors.grey),
children: [ const SizedBox(width: 4),
const Icon(Icons.edit_outlined, MyText.labelSmall(
size: 14, color: Colors.grey), 'Shared with (${members.length})',
const SizedBox(width: 4), fontWeight: 600,
MyText.labelSmall( color: Colors.indigo,
canEdit ),
? 'Can be edited by you' ],
: 'You dont have edit access', ),
fontWeight: 600, MySpacing.height(8),
color: canEdit ? Colors.green : Colors.grey, Row(
), children: [
], const Icon(Icons.edit_outlined, size: 14, color: Colors.grey),
), const SizedBox(width: 4),
), MyText.labelSmall(
const SizedBox(height: 8), canEdit ? 'Can be edited by you' : 'You dont have edit access',
const Divider(thickness: 1), fontWeight: 600,
const SizedBox(height: 6), color: canEdit ? Colors.green : Colors.grey,
MyText.labelLarge( ),
'Shared with', ],
fontWeight: 700, ),
color: Colors.black, MySpacing.height(12),
), const Divider(thickness: 1),
], ],
), ),
), );
}
const SizedBox(height: 4),
Widget _buildMembersTitle() {
Expanded( return Align(
child: Padding( alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16), child: MyText.labelLarge('Shared with', fontWeight: 700, color: Colors.black),
child: members.isEmpty );
? Center( }
child: MyText.bodySmall(
"No team members found.", Widget _buildMemberList() {
fontWeight: 600, if (members.isEmpty) {
color: Colors.grey, return Center(
), child: MyText.bodySmall(
) "No team members found.",
: ListView.separated( fontWeight: 600,
controller: scrollController, color: Colors.grey,
itemCount: members.length, ),
separatorBuilder: (_, __) => );
const SizedBox(height: 4), }
itemBuilder: (context, index) {
final member = members[index]; return ListView.separated(
final firstName = member.firstName ?? ''; itemCount: members.length,
final lastName = member.lastName ?? ''; separatorBuilder: (_, __) => const SizedBox(height: 6),
final isOwner = itemBuilder: (context, index) {
member.id == bucket.createdBy.id; final member = members[index];
final firstName = member.firstName ?? '';
return ListTile( final lastName = member.lastName ?? '';
dense: true, final isOwner = member.id == ownerId;
contentPadding: EdgeInsets.zero,
leading: Avatar( return ListTile(
firstName: firstName, dense: true,
lastName: lastName, contentPadding: EdgeInsets.zero,
size: 32, leading: Avatar(firstName: firstName, lastName: lastName, size: 32),
), title: Row(
title: Row( children: [
children: [ Expanded(
Expanded( child: MyText.bodyMedium(
child: MyText.bodyMedium( '${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}',
'${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}', fontWeight: 600,
fontWeight: 600, ),
), ),
), if (isOwner)
if (isOwner) Container(
Container( margin: const EdgeInsets.only(left: 6),
margin: padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
const EdgeInsets.only(left: 6), decoration: BoxDecoration(
padding: const EdgeInsets.symmetric( color: Colors.red.shade50,
horizontal: 6, vertical: 2), borderRadius: BorderRadius.circular(4),
decoration: BoxDecoration( ),
color: Colors.red.shade50, child: MyText.labelSmall(
borderRadius: "Owner",
BorderRadius.circular(4), fontWeight: 600,
), color: Colors.red,
child: MyText.labelSmall( ),
"Owner", ),
fontWeight: 600, ],
color: Colors.red, ),
), subtitle: MyText.bodySmall(
), member.jobRole ?? '',
], color: Colors.grey.shade600,
),
subtitle: MyText.bodySmall(
member.jobRole ?? '',
color: Colors.grey.shade600,
),
);
},
),
),
),
const SizedBox(height: 8),
],
);
},
),
), ),
); );
}, },

View File

@ -66,7 +66,8 @@ class _MainWrapperState extends State<MainWrapper> {
super.initState(); super.initState();
_initializeConnectivity(); _initializeConnectivity();
// Listen for changes, the callback now provides a List<ConnectivityResult> // Listen for changes, the callback now provides a List<ConnectivityResult>
_connectivity.onConnectivityChanged.listen((List<ConnectivityResult> results) { _connectivity.onConnectivityChanged
.listen((List<ConnectivityResult> results) {
setState(() { setState(() {
_connectivityStatus = results; _connectivityStatus = results;
}); });
@ -84,7 +85,8 @@ class _MainWrapperState extends State<MainWrapper> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Check if any of the connectivity results indicate no internet // Check if any of the connectivity results indicate no internet
final bool isOffline = _connectivityStatus.contains(ConnectivityResult.none); final bool isOffline =
_connectivityStatus.contains(ConnectivityResult.none);
// Show OfflineScreen if no internet // Show OfflineScreen if no internet
if (isOffline) { if (isOffline) {
@ -97,4 +99,4 @@ class _MainWrapperState extends State<MainWrapper> {
// Show main app if online // Show main app if online
return const MyApp(); return const MyApp();
} }
} }

View File

@ -1,117 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
import 'package:marco/helpers/utils/attendance_actions.dart'; import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AttendanceActionButton extends StatefulWidget { class AttendanceActionButton extends StatefulWidget {
final dynamic employee; final dynamic employee;
final AttendanceController attendanceController; final AttendanceController attendanceController;
const AttendanceActionButton({ const AttendanceActionButton({
Key? key, super.key,
required this.employee, required this.employee,
required this.attendanceController, required this.attendanceController,
}) : super(key: key); });
@override @override
State<AttendanceActionButton> createState() => _AttendanceActionButtonState(); State<AttendanceActionButton> createState() => _AttendanceActionButtonState();
} }
Future<String?> _showCommentBottomSheet(
BuildContext context, String actionText) async {
final TextEditingController commentController = TextEditingController();
String? errorText;
Get.find<ProjectController>().selectedProject?.id;
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 24,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Add Comment for ${capitalizeFirstLetter(actionText)}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 16),
TextField(
controller: commentController,
maxLines: 4,
decoration: InputDecoration(
hintText: 'Type your comment here...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Colors.grey.shade100,
errorText: errorText,
),
onChanged: (_) {
if (errorText != null) {
setModalState(() => errorText = null);
}
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
final comment = commentController.text.trim();
if (comment.isEmpty) {
setModalState(() {
errorText = 'Comment cannot be empty.';
});
return;
}
Navigator.of(context).pop(comment);
},
child: const Text('Submit'),
),
),
],
),
],
),
);
},
);
},
);
}
String capitalizeFirstLetter(String text) {
if (text.isEmpty) return text;
return text[0].toUpperCase() + text.substring(1);
}
class _AttendanceActionButtonState extends State<AttendanceActionButton> { class _AttendanceActionButtonState extends State<AttendanceActionButton> {
late final String uniqueLogKey; late final String uniqueLogKey;
@ -119,60 +29,57 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
void initState() { void initState() {
super.initState(); super.initState();
uniqueLogKey = AttendanceButtonHelper.getUniqueKey( uniqueLogKey = AttendanceButtonHelper.getUniqueKey(
widget.employee.employeeId, widget.employee.id); widget.employee.employeeId,
widget.employee.id,
);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!widget.attendanceController.uploadingStates widget.attendanceController.uploadingStates.putIfAbsent(
.containsKey(uniqueLogKey)) { uniqueLogKey,
widget.attendanceController.uploadingStates[uniqueLogKey] = false.obs; () => false.obs,
} );
}); });
} }
Future<DateTime?> showTimePickerForRegularization({ Future<DateTime?> _pickRegularizationTime(DateTime checkInTime) async {
required BuildContext context,
required DateTime checkInTime,
}) async {
final pickedTime = await showTimePicker( final pickedTime = await showTimePicker(
context: context, context: context,
initialTime: TimeOfDay.fromDateTime(DateTime.now()), initialTime: TimeOfDay.fromDateTime(DateTime.now()),
); );
if (pickedTime != null) { if (pickedTime == null) return null;
final selectedDateTime = DateTime(
checkInTime.year, final selected = DateTime(
checkInTime.month, checkInTime.year,
checkInTime.day, checkInTime.month,
pickedTime.hour, checkInTime.day,
pickedTime.minute, pickedTime.hour,
pickedTime.minute,
);
final now = DateTime.now();
if (selected.isBefore(checkInTime)) {
showAppSnackbar(
title: "Invalid Time",
message: "Time must be after check-in.",
type: SnackbarType.warning,
); );
return null;
final now = DateTime.now(); } else if (selected.isAfter(now)) {
showAppSnackbar(
if (selectedDateTime.isBefore(checkInTime)) { title: "Invalid Time",
showAppSnackbar( message: "Future time is not allowed.",
title: "Invalid Time", type: SnackbarType.warning,
message: "Time must be after check-in.", );
type: SnackbarType.warning, return null;
);
return null;
} else if (selectedDateTime.isAfter(now)) {
showAppSnackbar(
title: "Invalid Time",
message: "Future time is not allowed.",
type: SnackbarType.warning,
);
return null;
}
return selectedDateTime;
} }
return null;
return selected;
} }
void _handleButtonPressed(BuildContext context) async { Future<void> _handleButtonPressed() async {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = true; final controller = widget.attendanceController;
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id; final selectedProjectId = projectController.selectedProject?.id;
@ -182,53 +89,49 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
message: "Please select a project first", message: "Please select a project first",
type: SnackbarType.error, type: SnackbarType.error,
); );
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false;
return; return;
} }
int updatedAction; controller.uploadingStates[uniqueLogKey]?.value = true;
int action;
String actionText; String actionText;
bool imageCapture = true; bool imageCapture = true;
switch (widget.employee.activity) { switch (widget.employee.activity) {
case 0: case 0:
updatedAction = 0; case 4:
action = 0;
actionText = ButtonActions.checkIn; actionText = ButtonActions.checkIn;
break; break;
case 1: case 1:
if (widget.employee.checkOut == null && final isOld = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
AttendanceButtonHelper.isOlderThanDays( final isOldCheckout = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
widget.employee.checkIn, 2)) {
updatedAction = 2; if (widget.employee.checkOut == null && isOld) {
action = 2;
actionText = ButtonActions.requestRegularize; actionText = ButtonActions.requestRegularize;
imageCapture = false; imageCapture = false;
} else if (widget.employee.checkOut != null && } else if (widget.employee.checkOut != null && isOldCheckout) {
AttendanceButtonHelper.isOlderThanDays( action = 2;
widget.employee.checkOut, 2)) {
updatedAction = 2;
actionText = ButtonActions.requestRegularize; actionText = ButtonActions.requestRegularize;
} else { } else {
updatedAction = 1; action = 1;
actionText = ButtonActions.checkOut; actionText = ButtonActions.checkOut;
} }
break; break;
case 2: case 2:
updatedAction = 2; action = 2;
actionText = ButtonActions.requestRegularize; actionText = ButtonActions.requestRegularize;
break; break;
case 4:
updatedAction = 0;
actionText = ButtonActions.checkIn;
break;
default: default:
updatedAction = 0; action = 0;
actionText = "Unknown Action"; actionText = "Unknown Action";
break; break;
} }
DateTime? selectedTime; DateTime? selectedTime;
// New condition: Yesterday Check-In + CheckOut action
final isYesterdayCheckIn = widget.employee.checkIn != null && final isYesterdayCheckIn = widget.employee.checkIn != null &&
DateUtils.isSameDay( DateUtils.isSameDay(
widget.employee.checkIn, widget.employee.checkIn,
@ -238,67 +141,41 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
if (isYesterdayCheckIn && if (isYesterdayCheckIn &&
widget.employee.checkOut == null && widget.employee.checkOut == null &&
actionText == ButtonActions.checkOut) { actionText == ButtonActions.checkOut) {
selectedTime = await showTimePickerForRegularization( selectedTime = await _pickRegularizationTime(widget.employee.checkIn!);
context: context,
checkInTime: widget.employee.checkIn!,
);
if (selectedTime == null) { if (selectedTime == null) {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = controller.uploadingStates[uniqueLogKey]?.value = false;
false;
return; return;
} }
} }
final userComment = await _showCommentBottomSheet(context, actionText); final comment = await _showCommentBottomSheet(context, actionText);
if (userComment == null || userComment.isEmpty) { if (comment == null || comment.isEmpty) {
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; controller.uploadingStates[uniqueLogKey]?.value = false;
return; return;
} }
bool success = false; bool success = false;
String? markTime;
if (actionText == ButtonActions.requestRegularize) { if (actionText == ButtonActions.requestRegularize) {
final regularizeTime = selectedTime ?? selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!);
await showTimePickerForRegularization( if (selectedTime != null) {
context: context, markTime = DateFormat("hh:mm a").format(selectedTime);
checkInTime: widget.employee.checkIn!,
);
if (regularizeTime != null) {
final formattedSelectedTime =
DateFormat("hh:mm a").format(regularizeTime);
success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: userComment,
action: updatedAction,
imageCapture: imageCapture,
markTime: formattedSelectedTime,
);
} }
} else if (selectedTime != null) { } else if (selectedTime != null) {
// If selectedTime was picked in the new condition markTime = DateFormat("hh:mm a").format(selectedTime);
final formattedSelectedTime = DateFormat("hh:mm a").format(selectedTime);
success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: userComment,
action: updatedAction,
imageCapture: imageCapture,
markTime: formattedSelectedTime,
);
} else {
success = await widget.attendanceController.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: userComment,
action: updatedAction,
imageCapture: imageCapture,
);
} }
success = await controller.captureAndUploadAttendance(
widget.employee.id,
widget.employee.employeeId,
selectedProjectId,
comment: comment,
action: action,
imageCapture: imageCapture,
markTime: markTime,
);
showAppSnackbar( showAppSnackbar(
title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error', title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error',
message: success message: success
@ -307,51 +184,47 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
type: success ? SnackbarType.success : SnackbarType.error, type: success ? SnackbarType.success : SnackbarType.error,
); );
widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; controller.uploadingStates[uniqueLogKey]?.value = false;
if (success) { if (success) {
widget.attendanceController.fetchEmployeesByProject(selectedProjectId); controller.fetchEmployeesByProject(selectedProjectId);
widget.attendanceController.fetchAttendanceLogs(selectedProjectId); controller.fetchAttendanceLogs(selectedProjectId);
await widget.attendanceController await controller.fetchRegularizationLogs(selectedProjectId);
.fetchRegularizationLogs(selectedProjectId); await controller.fetchProjectData(selectedProjectId);
await widget.attendanceController.fetchProjectData(selectedProjectId); controller.update();
widget.attendanceController.update();
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
final isUploading = final controller = widget.attendanceController;
widget.attendanceController.uploadingStates[uniqueLogKey]?.value ??
false;
final isYesterday = AttendanceButtonHelper.isLogFromYesterday( final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false;
widget.employee.checkIn, widget.employee.checkOut); final emp = widget.employee;
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(
widget.employee.activity, widget.employee.checkIn); final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isApprovedButNotToday = final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
AttendanceButtonHelper.isApprovedButNotToday( final isApprovedButNotToday = AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved);
widget.employee.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading, isUploading: isUploading,
isYesterday: isYesterday, isYesterday: isYesterday,
activity: widget.employee.activity, activity: emp.activity,
isApprovedButNotToday: isApprovedButNotToday, isApprovedButNotToday: isApprovedButNotToday,
); );
final buttonText = AttendanceButtonHelper.getButtonText( final buttonText = AttendanceButtonHelper.getButtonText(
activity: widget.employee.activity, activity: emp.activity,
checkIn: widget.employee.checkIn, checkIn: emp.checkIn,
checkOut: widget.employee.checkOut, checkOut: emp.checkOut,
isTodayApproved: isTodayApproved, isTodayApproved: isTodayApproved,
); );
final buttonColor = AttendanceButtonHelper.getButtonColor( final buttonColor = AttendanceButtonHelper.getButtonColor(
isYesterday: isYesterday, isYesterday: isYesterday,
isTodayApproved: isTodayApproved, isTodayApproved: isTodayApproved,
activity: widget.employee.activity, activity: emp.activity,
); );
return AttendanceActionButtonUI( return AttendanceActionButtonUI(
@ -359,8 +232,7 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
isButtonDisabled: isButtonDisabled, isButtonDisabled: isButtonDisabled,
buttonText: buttonText, buttonText: buttonText,
buttonColor: buttonColor, buttonColor: buttonColor,
onPressed: onPressed: isButtonDisabled ? null : _handleButtonPressed,
isButtonDisabled ? null : () => _handleButtonPressed(context),
); );
}); });
} }
@ -374,20 +246,20 @@ class AttendanceActionButtonUI extends StatelessWidget {
final VoidCallback? onPressed; final VoidCallback? onPressed;
const AttendanceActionButtonUI({ const AttendanceActionButtonUI({
Key? key, super.key,
required this.isUploading, required this.isUploading,
required this.isButtonDisabled, required this.isButtonDisabled,
required this.buttonText, required this.buttonText,
required this.buttonColor, required this.buttonColor,
required this.onPressed, required this.onPressed,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 30, height: 30,
child: ElevatedButton( child: ElevatedButton(
onPressed: isButtonDisabled ? null : onPressed, onPressed: onPressed,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: buttonColor, backgroundColor: buttonColor,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
@ -405,17 +277,14 @@ class AttendanceActionButtonUI extends StatelessWidget {
: Row( : Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (buttonText.toLowerCase() == 'approved') ...[ if (buttonText.toLowerCase() == 'approved')
const Icon(Icons.check, size: 16, color: Colors.green), const Icon(Icons.check, size: 16, color: Colors.green),
const SizedBox(width: 4), if (buttonText.toLowerCase() == 'rejected')
] else if (buttonText.toLowerCase() == 'rejected') ...[
const Icon(Icons.close, size: 16, color: Colors.red), const Icon(Icons.close, size: 16, color: Colors.red),
if (buttonText.toLowerCase() == 'requested')
const Icon(Icons.hourglass_top, size: 16, color: Colors.orange),
if (['approved', 'rejected', 'requested'].contains(buttonText.toLowerCase()))
const SizedBox(width: 4), const SizedBox(width: 4),
] else if (buttonText.toLowerCase() == 'requested') ...[
const Icon(Icons.hourglass_top,
size: 16, color: Colors.orange),
const SizedBox(width: 4),
],
Flexible( Flexible(
child: Text( child: Text(
buttonText, buttonText,
@ -429,3 +298,68 @@ class AttendanceActionButtonUI extends StatelessWidget {
); );
} }
} }
Future<String?> _showCommentBottomSheet(BuildContext context, String actionText) async {
final commentController = TextEditingController();
String? errorText;
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
void submit() {
final comment = commentController.text.trim();
if (comment.isEmpty) {
setModalState(() => errorText = 'Comment cannot be empty.');
return;
}
Navigator.of(context).pop(comment);
}
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: BaseBottomSheet(
title: 'Add Comment for ${capitalizeFirstLetter(actionText)}',
onCancel: () => Navigator.of(context).pop(),
onSubmit: submit,
isSubmitting: false,
submitText: 'Submit',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: commentController,
maxLines: 4,
decoration: InputDecoration(
hintText: 'Type your comment here...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Colors.grey.shade100,
errorText: errorText,
),
onChanged: (_) {
if (errorText != null) {
setModalState(() => errorText = null);
}
},
),
],
),
),
);
},
);
},
);
}
String capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AttendanceFilterBottomSheet extends StatefulWidget { class AttendanceFilterBottomSheet extends StatefulWidget {
final AttendanceController controller; final AttendanceController controller;
@ -18,7 +19,7 @@ class AttendanceFilterBottomSheet extends StatefulWidget {
}); });
@override @override
_AttendanceFilterBottomSheetState createState() => State<AttendanceFilterBottomSheet> createState() =>
_AttendanceFilterBottomSheetState(); _AttendanceFilterBottomSheetState();
} }
@ -53,83 +54,70 @@ class _AttendanceFilterBottomSheetState
{'label': 'Regularization Requests', 'value': 'regularizationRequests'}, {'label': 'Regularization Requests', 'value': 'regularizationRequests'},
]; ];
final filteredViewOptions = viewOptions.where((item) { final filteredOptions = viewOptions.where((item) {
if (item['value'] == 'regularizationRequests') { return item['value'] != 'regularizationRequests' ||
return hasRegularizationPermission; hasRegularizationPermission;
}
return true;
}).toList(); }).toList();
List<Widget> widgets = [ final List<Widget> widgets = [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), padding: EdgeInsets.only(bottom: 4),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: MyText.titleSmall( child: MyText.titleSmall("View", fontWeight: 600),
"View",
fontWeight: 600,
),
), ),
), ),
...filteredViewOptions.map((item) { ...filteredOptions.map((item) {
return RadioListTile<String>( return RadioListTile<String>(
dense: true, dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: EdgeInsets.zero,
title: Text(item['label']!), title: MyText.bodyMedium(
item['label']!,
fontWeight: 500,
),
value: item['value']!, value: item['value']!,
groupValue: tempSelectedTab, groupValue: tempSelectedTab,
onChanged: (value) => setState(() => tempSelectedTab = value!), onChanged: (value) => setState(() => tempSelectedTab = value!),
); );
}).toList(), }),
]; ];
if (tempSelectedTab == 'attendanceLogs') { if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([ widgets.addAll([
const Divider(), const Divider(),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), padding: EdgeInsets.only(top: 12, bottom: 4),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: MyText.titleSmall( child: MyText.titleSmall("Date Range", fontWeight: 600),
"Date Range",
fontWeight: 600,
),
), ),
), ),
Padding( InkWell(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), borderRadius: BorderRadius.circular(10),
child: InkWell( onTap: () => widget.controller.selectDateRangeForAttendance(
borderRadius: BorderRadius.circular(10), context,
onTap: () => widget.controller.selectDateRangeForAttendance( widget.controller,
context, ),
widget.controller, child: Ink(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
), ),
child: Ink( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration( child: Row(
color: Colors.white, children: [
border: Border.all(color: Colors.grey.shade400), const Icon(Icons.date_range, color: Colors.black87),
borderRadius: BorderRadius.circular(10), const SizedBox(width: 12),
), Expanded(
padding: child: MyText.bodyMedium(
const EdgeInsets.symmetric(horizontal: 16, vertical: 14), getLabelText(),
child: Row( fontWeight: 500,
children: [ color: Colors.black87,
Icon(Icons.date_range, color: Colors.black87),
const SizedBox(width: 12),
Expanded(
child: Text(
getLabelText(),
style: const TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
), ),
const Icon(Icons.arrow_drop_down, color: Colors.black87), ),
], const Icon(Icons.arrow_drop_down, color: Colors.black87),
), ],
), ),
), ),
), ),
@ -141,49 +129,17 @@ class _AttendanceFilterBottomSheetState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return ClipRRect(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: SingleChildScrollView( child: BaseBottomSheet(
title: "Attendance Filter",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab,
}),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: buildMainFilters(),
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(4),
),
),
),
),
...buildMainFilters(),
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color.fromARGB(255, 95, 132, 255),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Apply Filter'),
onPressed: () {
Navigator.pop(context, {
'selectedTab': tempSelectedTab,
});
},
),
),
),
],
), ),
), ),
); );

View File

@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/attendance_actions.dart'; import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AttendanceLogViewButton extends StatelessWidget { class AttendanceLogViewButton extends StatelessWidget {
final dynamic employee; final dynamic employee;
final dynamic attendanceController; // Use correct types as needed final dynamic attendanceController;
const AttendanceLogViewButton({ const AttendanceLogViewButton({
Key? key, Key? key,
required this.employee, required this.employee,
@ -50,191 +50,164 @@ class AttendanceLogViewButton extends StatelessWidget {
void _showLogsBottomSheet(BuildContext context) async { void _showLogsBottomSheet(BuildContext context) async {
await attendanceController.fetchLogsView(employee.id.toString()); await attendanceController.fetchLogsView(employee.id.toString());
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
backgroundColor: Theme.of(context).cardColor, backgroundColor: Colors.transparent,
builder: (context) => Padding( builder: (context) => BaseBottomSheet(
padding: EdgeInsets.only( title: "Attendance Log",
left: 16, onCancel: () => Navigator.pop(context),
right: 16, onSubmit: () => Navigator.pop(context),
top: 16, showButtons: false,
bottom: MediaQuery.of(context).viewInsets.bottom + 16, child: attendanceController.attendenceLogsView.isEmpty
), ? Padding(
child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, children: const [
children: [ Icon(Icons.info_outline, size: 40, color: Colors.grey),
// Header SizedBox(height: 8),
Row( Text("No attendance logs available."),
mainAxisAlignment: MainAxisAlignment.spaceBetween, ],
children: [ ),
MyText.titleMedium( )
"Attendance Log", : ListView.separated(
fontWeight: 700, shrinkWrap: true,
), physics: const NeverScrollableScrollPhysics(),
IconButton( itemCount: attendanceController.attendenceLogsView.length,
icon: const Icon(Icons.close), separatorBuilder: (_, __) => const SizedBox(height: 16),
onPressed: () => Navigator.pop(context), itemBuilder: (_, index) {
), final log = attendanceController.attendenceLogsView[index];
], return Container(
), decoration: BoxDecoration(
const SizedBox(height: 12), color: Theme.of(context).colorScheme.surfaceVariant,
if (attendanceController.attendenceLogsView.isEmpty) borderRadius: BorderRadius.circular(12),
Padding( boxShadow: [
padding: const EdgeInsets.symmetric(vertical: 24.0), BoxShadow(
child: Column( color: Colors.black.withOpacity(0.05),
children: const [ blurRadius: 6,
Icon(Icons.info_outline, size: 40, color: Colors.grey), offset: const Offset(0, 2),
SizedBox(height: 8), )
Text("No attendance logs available."), ],
], ),
), padding: const EdgeInsets.all(8),
) child: Column(
else crossAxisAlignment: CrossAxisAlignment.start,
ListView.separated( children: [
shrinkWrap: true, Row(
physics: const NeverScrollableScrollPhysics(), crossAxisAlignment: CrossAxisAlignment.center,
itemCount: attendanceController.attendenceLogsView.length, children: [
separatorBuilder: (_, __) => const SizedBox(height: 16), Expanded(
itemBuilder: (_, index) { flex: 3,
final log = attendanceController.attendenceLogsView[index]; child: Column(
return Container( crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration( children: [
color: Theme.of(context).colorScheme.surfaceVariant, Row(
borderRadius: BorderRadius.circular(12), children: [
boxShadow: [ _getLogIcon(log),
BoxShadow( const SizedBox(width: 10),
color: Colors.black.withOpacity(0.05), Column(
blurRadius: 6, crossAxisAlignment:
offset: const Offset(0, 2), CrossAxisAlignment.start,
) children: [
], MyText.bodyLarge(
), log.formattedDate ?? '-',
padding: const EdgeInsets.all(8), fontWeight: 600,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_getLogIcon(log),
const SizedBox(width: 10),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
log.formattedDate ?? '-',
fontWeight: 600,
),
MyText.bodySmall(
"Time: ${log.formattedTime ?? '-'}",
color: Colors.grey[700],
),
],
),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
if (log.latitude != null &&
log.longitude != null)
GestureDetector(
onTap: () {
final lat = double.tryParse(log
.latitude
.toString()) ??
0.0;
final lon = double.tryParse(log
.longitude
.toString()) ??
0.0;
if (lat >= -90 &&
lat <= 90 &&
lon >= -180 &&
lon <= 180) {
_openGoogleMaps(
context, lat, lon);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Invalid location coordinates')),
);
}
},
child: const Padding(
padding:
EdgeInsets.only(right: 8.0),
child: Icon(Icons.location_on,
size: 18, color: Colors.blue),
),
), ),
Expanded( MyText.bodySmall(
child: MyText.bodyMedium( "Time: ${log.formattedTime ?? '-'}",
log.comment?.isNotEmpty == true color: Colors.grey[700],
? log.comment
: "No description provided",
fontWeight: 500,
), ),
), ],
], ),
), ],
],
),
),
const SizedBox(width: 16),
if (log.thumbPreSignedUrl != null)
GestureDetector(
onTap: () {
if (log.preSignedUrl != null) {
_showImageDialog(
context, log.preSignedUrl!);
}
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
log.thumbPreSignedUrl!,
height: 60,
width: 60,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) {
return const Icon(Icons.broken_image,
size: 20, color: Colors.grey);
},
),
), ),
) const SizedBox(height: 12),
else Row(
const Icon(Icons.broken_image, crossAxisAlignment:
size: 20, color: Colors.grey), CrossAxisAlignment.start,
], children: [
), if (log.latitude != null &&
], log.longitude != null)
), GestureDetector(
); onTap: () {
}, final lat = double.tryParse(
) log.latitude.toString()) ??
], 0.0;
), final lon = double.tryParse(
), log.longitude.toString()) ??
0.0;
if (lat >= -90 &&
lat <= 90 &&
lon >= -180 &&
lon <= 180) {
_openGoogleMaps(
context, lat, lon);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Invalid location coordinates')),
);
}
},
child: const Padding(
padding:
EdgeInsets.only(right: 8.0),
child: Icon(Icons.location_on,
size: 18, color: Colors.blue),
),
),
Expanded(
child: MyText.bodyMedium(
log.comment?.isNotEmpty == true
? log.comment
: "No description provided",
fontWeight: 500,
),
),
],
),
],
),
),
const SizedBox(width: 16),
if (log.thumbPreSignedUrl != null)
GestureDetector(
onTap: () {
if (log.preSignedUrl != null) {
_showImageDialog(
context, log.preSignedUrl!);
}
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
log.thumbPreSignedUrl!,
height: 60,
width: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.broken_image,
size: 20, color: Colors.grey);
},
),
),
)
else
const Icon(Icons.broken_image,
size: 20, color: Colors.grey),
],
),
],
),
);
},
),
), ),
); );
} }

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planing/daily_task_planing_controller.dart'; import 'package:marco/controller/task_planing/daily_task_planing_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AssignTaskBottomSheet extends StatefulWidget { class AssignTaskBottomSheet extends StatefulWidget {
final String workLocation; final String workLocation;
@ -37,17 +38,9 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final ProjectController projectController = Get.find(); final ProjectController projectController = Get.find();
final TextEditingController targetController = TextEditingController(); final TextEditingController targetController = TextEditingController();
final TextEditingController descriptionController = TextEditingController(); final TextEditingController descriptionController = TextEditingController();
String? selectedProjectId;
final ScrollController _employeeListScrollController = ScrollController(); final ScrollController _employeeListScrollController = ScrollController();
@override String? selectedProjectId;
void dispose() {
_employeeListScrollController.dispose();
targetController.dispose();
descriptionController.dispose();
super.dispose();
}
@override @override
void initState() { void initState() {
@ -61,180 +54,102 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
}); });
} }
@override
void dispose() {
_employeeListScrollController.dispose();
targetController.dispose();
descriptionController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return Obx(() => BaseBottomSheet(
child: Container( title: "Assign Task",
padding: MediaQuery.of(context).viewInsets.add(MySpacing.all(16)), child: _buildAssignTaskForm(),
decoration: const BoxDecoration( onCancel: () => Get.back(),
color: Colors.white, onSubmit: _onAssignTaskPressed,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), isSubmitting: controller.isAssigningTask.value,
), ));
child: SingleChildScrollView( }
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, Widget _buildAssignTaskForm() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoRow(Icons.location_on, "Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"),
Divider(),
_infoRow(Icons.pending_actions, "Pending Task of Activity",
"${widget.pendingTask}"),
Divider(),
GestureDetector(
onTap: _onRoleMenuPressed,
child: Row(
children: [ children: [
Row( MyText.titleMedium("Select Team :", fontWeight: 600),
mainAxisAlignment: MainAxisAlignment.spaceBetween, const SizedBox(width: 4),
children: [ const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)),
Row(
children: [
Icon(Icons.assignment, color: Colors.black54),
SizedBox(width: 8),
MyText.titleMedium("Assign Task",
fontSize: 18, fontWeight: 600),
],
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Get.back(),
),
],
),
Divider(),
_infoRow(Icons.location_on, "Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"),
Divider(),
_infoRow(Icons.pending_actions, "Pending Task of Activity",
"${widget.pendingTask}"),
Divider(),
GestureDetector(
onTap: () {
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
final Size screenSize = overlay.size;
showMenu(
context: context,
position: RelativeRect.fromLTRB(
screenSize.width / 2 - 100,
screenSize.height / 2 - 20,
screenSize.width / 2 - 100,
screenSize.height / 2 - 20,
),
items: [
const PopupMenuItem(
value: 'all',
child: Text("All Roles"),
),
...controller.roles.map((role) {
return PopupMenuItem(
value: role['id'].toString(),
child: Text(role['name'] ?? 'Unknown Role'),
);
}),
],
).then((value) {
if (value != null) {
controller.onRoleSelected(value == 'all' ? null : value);
}
});
},
child: Row(
children: [
MyText.titleMedium("Select Team :", fontWeight: 600),
const SizedBox(width: 4),
Icon(Icons.filter_alt,
color: const Color.fromARGB(255, 95, 132, 255)),
],
),
),
MySpacing.height(8),
Container(
constraints: BoxConstraints(maxHeight: 150),
child: _buildEmployeeList(),
),
MySpacing.height(8),
Obx(() {
if (controller.selectedEmployees.isEmpty) return Container();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: controller.selectedEmployees.map((e) {
return Obx(() {
final isSelected =
controller.uploadingStates[e.id]?.value ?? false;
if (!isSelected) return Container();
return Chip(
label: Text(e.name,
style: const TextStyle(color: Colors.white)),
backgroundColor:
const Color.fromARGB(255, 95, 132, 255),
deleteIcon:
const Icon(Icons.close, color: Colors.white),
onDeleted: () {
controller.uploadingStates[e.id]?.value = false;
controller.updateSelectedEmployees();
},
);
});
}).toList(),
),
);
}),
_buildTextField(
icon: Icons.track_changes,
label: "Target for Today :",
controller: targetController,
hintText: "Enter target",
keyboardType: TextInputType.number,
validatorType: "target",
),
MySpacing.height(24),
_buildTextField(
icon: Icons.description,
label: "Description :",
controller: descriptionController,
hintText: "Enter task description",
maxLines: 3,
validatorType: "description",
),
MySpacing.height(24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
OutlinedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.close, color: Colors.red),
label: MyText.bodyMedium("Cancel", color: Colors.red),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 14),
),
),
ElevatedButton.icon(
onPressed: _onAssignTaskPressed,
icon: const Icon(Icons.check_circle_outline,
color: Colors.white),
label:
MyText.bodyMedium("Assign Task", color: Colors.white),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 28, vertical: 14),
),
),
],
),
], ],
), ),
), ),
), MySpacing.height(8),
Container(
constraints: const BoxConstraints(maxHeight: 150),
child: _buildEmployeeList(),
),
MySpacing.height(8),
_buildSelectedEmployees(),
_buildTextField(
icon: Icons.track_changes,
label: "Target for Today :",
controller: targetController,
hintText: "Enter target",
keyboardType: TextInputType.number,
validatorType: "target",
),
MySpacing.height(24),
_buildTextField(
icon: Icons.description,
label: "Description :",
controller: descriptionController,
hintText: "Enter task description",
maxLines: 3,
validatorType: "description",
),
],
); );
} }
void _onRoleMenuPressed() {
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final Size screenSize = overlay.size;
showMenu(
context: context,
position: RelativeRect.fromLTRB(
screenSize.width / 2 - 100,
screenSize.height / 2 - 20,
screenSize.width / 2 - 100,
screenSize.height / 2 - 20,
),
items: [
const PopupMenuItem(value: 'all', child: Text("All Roles")),
...controller.roles.map((role) {
return PopupMenuItem(
value: role['id'].toString(),
child: Text(role['name'] ?? 'Unknown Role'),
);
}),
],
).then((value) {
if (value != null) {
controller.onRoleSelected(value == 'all' ? null : value);
}
});
}
Widget _buildEmployeeList() { Widget _buildEmployeeList() {
return Obx(() { return Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
@ -255,49 +170,43 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
return Scrollbar( return Scrollbar(
controller: _employeeListScrollController, controller: _employeeListScrollController,
thumbVisibility: true, thumbVisibility: true,
interactive: true,
child: ListView.builder( child: ListView.builder(
controller: _employeeListScrollController, controller: _employeeListScrollController,
shrinkWrap: true, shrinkWrap: true,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filteredEmployees.length, itemCount: filteredEmployees.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final employee = filteredEmployees[index]; final employee = filteredEmployees[index];
final rxBool = controller.uploadingStates[employee.id]; final rxBool = controller.uploadingStates[employee.id];
return Obx(() => Padding( return Obx(() => Padding(
padding: const EdgeInsets.symmetric(vertical: 0), padding: const EdgeInsets.symmetric(vertical: 2),
child: Row( child: Row(
children: [ children: [
Theme( Checkbox(
data: Theme.of(context) shape: RoundedRectangleBorder(
.copyWith(unselectedWidgetColor: Colors.black), borderRadius: BorderRadius.circular(4),
child: Checkbox(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: const BorderSide(color: Colors.black),
),
value: rxBool?.value ?? false,
onChanged: (bool? selected) {
if (rxBool != null) {
rxBool.value = selected ?? false;
controller.updateSelectedEmployees();
}
},
fillColor:
WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return const Color.fromARGB(255, 95, 132, 255);
}
return Colors.transparent;
}),
checkColor: Colors.white,
side: const BorderSide(color: Colors.black),
), ),
value: rxBool?.value ?? false,
onChanged: (bool? selected) {
if (rxBool != null) {
rxBool.value = selected ?? false;
controller.updateSelectedEmployees();
}
},
fillColor:
WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return const Color.fromARGB(255, 95, 132, 255);
}
return Colors.transparent;
}),
checkColor: Colors.white,
side: const BorderSide(color: Colors.black),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text(employee.name, child: Text(employee.name,
style: TextStyle(fontSize: 14))), style: const TextStyle(fontSize: 14))),
], ],
), ),
)); ));
@ -307,6 +216,38 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
}); });
} }
Widget _buildSelectedEmployees() {
return Obx(() {
if (controller.selectedEmployees.isEmpty) return Container();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: controller.selectedEmployees.map((e) {
return Obx(() {
final isSelected =
controller.uploadingStates[e.id]?.value ?? false;
if (!isSelected) return Container();
return Chip(
label:
Text(e.name, style: const TextStyle(color: Colors.white)),
backgroundColor: const Color.fromARGB(255, 95, 132, 255),
deleteIcon: const Icon(Icons.close, color: Colors.white),
onDeleted: () {
controller.uploadingStates[e.id]?.value = false;
controller.updateSelectedEmployees();
},
);
});
}).toList(),
),
);
});
}
Widget _buildTextField({ Widget _buildTextField({
required IconData icon, required IconData icon,
required String label, required String label,
@ -331,13 +272,12 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
controller: controller, controller: controller,
keyboardType: keyboardType, keyboardType: keyboardType,
maxLines: maxLines, maxLines: maxLines,
decoration: InputDecoration( decoration: const InputDecoration(
hintText: hintText, hintText: '',
border: const OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: (value) => this validator: (value) =>
.controller this.controller.formFieldValidator(value, fieldType: validatorType),
.formFieldValidator(value, fieldType: validatorType),
), ),
], ],
); );

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,7 @@ import 'package:marco/controller/task_planing/add_task_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
void showCreateTaskBottomSheet({ void showCreateTaskBottomSheet({
required String workArea, required String workArea,
@ -27,197 +26,120 @@ void showCreateTaskBottomSheet({
Get.bottomSheet( Get.bottomSheet(
StatefulBuilder( StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
return LayoutBuilder( return BaseBottomSheet(
builder: (context, constraints) { title: "Create Task",
final isLarge = constraints.maxWidth > 600; onCancel: () => Get.back(),
final horizontalPadding = onSubmit: () async {
isLarge ? constraints.maxWidth * 0.2 : 16.0; final plannedValue =
int.tryParse(plannedTaskController.text.trim()) ?? 0;
final comment = descriptionController.text.trim();
final selectedCategoryId = controller.selectedCategoryId.value;
return // Inside showManageTaskBottomSheet... if (selectedCategoryId == null) {
showAppSnackbar(
title: "error",
message: "Please select a work category!",
type: SnackbarType.error,
);
return;
}
SafeArea( final success = await controller.createTask(
child: Material( parentTaskId: parentTaskId,
color: Colors.white, plannedTask: plannedValue,
borderRadius: comment: comment,
const BorderRadius.vertical(top: Radius.circular(20)), workAreaId: workAreaId,
child: Container( activityId: activityId,
constraints: const BoxConstraints(maxHeight: 760), categoryId: selectedCategoryId,
padding: EdgeInsets.fromLTRB( );
horizontalPadding, 12, horizontalPadding, 24),
child: SingleChildScrollView( if (success) {
child: Column( Get.back();
crossAxisAlignment: CrossAxisAlignment.start, Future.delayed(const Duration(milliseconds: 300), () {
onSubmit();
showAppSnackbar(
title: "Success",
message: "Task created successfully!",
type: SnackbarType.success,
);
});
}
},
submitText: "Submit",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoCardSection([
_infoRowWithIcon(
Icons.workspaces, "Selected Work Area", workArea),
_infoRowWithIcon(Icons.list_alt, "Selected Activity", activity),
_infoRowWithIcon(Icons.check_circle_outline, "Completed Work",
completedWork),
]),
const SizedBox(height: 16),
_sectionTitle(Icons.edit_calendar, "Planned Work"),
const SizedBox(height: 6),
_customTextField(
controller: plannedTaskController,
hint: "Enter planned work",
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
_sectionTitle(Icons.description_outlined, "Comment"),
const SizedBox(height: 6),
_customTextField(
controller: descriptionController,
hint: "Enter task description",
maxLines: 3,
),
const SizedBox(height: 16),
_sectionTitle(Icons.category_outlined, "Selected Work Category"),
const SizedBox(height: 6),
Obx(() {
final categoryMap = controller.categoryIdNameMap;
final String selectedName =
controller.selectedCategoryId.value != null
? (categoryMap[controller.selectedCategoryId.value!] ??
'Select Category')
: 'Select Category';
return Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
onSelected: (val) {
controller.selectCategory(val);
onCategoryChanged(val);
},
itemBuilder: (context) => categoryMap.entries
.map((entry) => PopupMenuItem<String>(
value: entry.key,
child: Text(entry.value),
))
.toList(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Center( Text(
child: Container( selectedName,
width: 40, style: const TextStyle(
height: 4, fontSize: 14, color: Colors.black87),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
),
Center(
child: MyText.titleLarge(
"Create Task",
fontWeight: 700,
),
),
const SizedBox(height: 20),
_infoCardSection([
_infoRowWithIcon(
Icons.workspaces, "Selected Work Area", workArea),
_infoRowWithIcon(
Icons.list_alt, "Selected Activity", activity),
_infoRowWithIcon(Icons.check_circle_outline,
"Completed Work", completedWork),
]),
const SizedBox(height: 16),
_sectionTitle(Icons.edit_calendar, "Planned Work"),
const SizedBox(height: 6),
_customTextField(
controller: plannedTaskController,
hint: "Enter planned work",
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
_sectionTitle(Icons.description_outlined, "Comment"),
const SizedBox(height: 6),
_customTextField(
controller: descriptionController,
hint: "Enter task description",
maxLines: 3,
),
const SizedBox(height: 16),
_sectionTitle(
Icons.category_outlined, "Selected Work Category"),
const SizedBox(height: 6),
Obx(() {
final categoryMap = controller.categoryIdNameMap;
final String selectedName =
controller.selectedCategoryId.value != null
? (categoryMap[controller
.selectedCategoryId.value!] ??
'Select Category')
: 'Select Category';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
onSelected: (val) {
controller.selectCategory(val);
onCategoryChanged(val);
},
itemBuilder: (context) => categoryMap.entries
.map(
(entry) => PopupMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
)
.toList(),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
selectedName,
style: const TextStyle(
fontSize: 14, color: Colors.black87),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.close, size: 18),
label: MyText.bodyMedium("Cancel",
fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final plannedValue = int.tryParse(
plannedTaskController.text.trim()) ??
0;
final comment =
descriptionController.text.trim();
final selectedCategoryId =
controller.selectedCategoryId.value;
if (selectedCategoryId == null) {
showAppSnackbar(
title: "error",
message: "Please select a work category!",
type: SnackbarType.error,
);
return;
}
final success = await controller.createTask(
parentTaskId: parentTaskId,
plannedTask: plannedValue,
comment: comment,
workAreaId: workAreaId,
activityId: activityId,
categoryId: selectedCategoryId,
);
if (success) {
Get.back();
Future.delayed(
const Duration(milliseconds: 300), () {
onSubmit();
showAppSnackbar(
title: "Success",
message: "Task created successfully!",
type: SnackbarType.success,
);
});
}
},
icon: const Icon(Icons.check, size: 18),
label: MyText.bodyMedium("Submit",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
),
],
), ),
const Icon(Icons.arrow_drop_down),
], ],
), ),
), ),
), );
), }),
); ],
}, ),
); );
}, },
), ),

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/dashboard/daily_task_controller.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/controller/dashboard/daily_task_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
class DailyProgressReportFilter extends StatefulWidget { class DailyProgressReportFilter extends StatelessWidget {
final DailyTaskController controller; final DailyTaskController controller;
final PermissionController permissionController; final PermissionController permissionController;
@ -14,20 +15,9 @@ class DailyProgressReportFilter extends StatefulWidget {
required this.permissionController, required this.permissionController,
}); });
@override
State<DailyProgressReportFilter> createState() =>
_DailyProgressReportFilterState();
}
class _DailyProgressReportFilterState extends State<DailyProgressReportFilter> {
@override
void initState() {
super.initState();
}
String getLabelText() { String getLabelText() {
final startDate = widget.controller.startDateTask; final startDate = controller.startDateTask;
final endDate = widget.controller.endDateTask; final endDate = controller.endDateTask;
if (startDate != null && endDate != null) { if (startDate != null && endDate != null) {
final start = DateFormat('dd MM yyyy').format(startDate); final start = DateFormat('dd MM yyyy').format(startDate);
final end = DateFormat('dd MM yyyy').format(endDate); final end = DateFormat('dd MM yyyy').format(endDate);
@ -38,105 +28,55 @@ class _DailyProgressReportFilterState extends State<DailyProgressReportFilter> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return BaseBottomSheet(
child: Padding( title: "Filter Tasks",
padding: EdgeInsets.only( onCancel: () => Navigator.pop(context),
bottom: MediaQuery.of(context).viewInsets.bottom,
), onSubmit: () {
child: SingleChildScrollView( Navigator.pop(context, {
child: Column( 'startDate': controller.startDateTask,
mainAxisSize: MainAxisSize.min, 'endDate': controller.endDateTask,
children: [ });
Padding( },
padding: const EdgeInsets.only(top: 12, bottom: 8), child: Column(
child: Center( crossAxisAlignment: CrossAxisAlignment.start,
child: Container( children: [
width: 40, MyText.titleSmall("Select Date Range", fontWeight: 600),
height: 4, const SizedBox(height: 8),
decoration: BoxDecoration( InkWell(
color: Colors.grey[400], borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(4), onTap: () => controller.selectDateRangeForTaskData(
), context,
), controller,
), ),
child: Ink(
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
), ),
const Divider(), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
Padding( child: Row(
padding: EdgeInsets.fromLTRB(16, 12, 16, 4), children: [
child: Align( Icon(Icons.date_range, color: Colors.blue.shade600),
alignment: Alignment.centerLeft, const SizedBox(width: 12),
child: MyText.titleSmall( Expanded(
"Select Date Range", child: Text(
fontWeight: 600, getLabelText(),
), style: const TextStyle(
), fontSize: 16,
), color: Colors.black87,
Padding( fontWeight: FontWeight.w500,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => widget.controller.selectDateRangeForTaskData(
context,
widget.controller,
),
child: Ink(
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(Icons.date_range, color: Colors.blue.shade600),
const SizedBox(width: 12),
Expanded(
child: Text(
getLabelText(),
style: const TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
),
),
const Divider(),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
), ),
overflow: TextOverflow.ellipsis,
), ),
child: const Text('Apply Filter'),
onPressed: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.pop(context, {
'startDate': widget.controller.startDateTask,
'endDate': widget.controller.endDateTask,
});
});
},
), ),
), const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
), ),
], ),
), ),
), ],
), ),
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,392 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:get/get.dart';
/// Show labeled row with optional icon
Widget buildRow(String label, String? value, {IconData? icon}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (icon != null)
Padding(
padding: const EdgeInsets.only(right: 8.0, top: 2),
child: Icon(icon, size: 18, color: Colors.grey[700]),
),
MyText.titleSmall("$label:", fontWeight: 600),
MySpacing.width(12),
Expanded(
child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"),
),
],
),
);
}
/// Show uploaded network images
Widget buildReportedImagesSection({
required List<String> imageUrls,
required BuildContext context,
String title = "Reported Images",
}) {
if (imageUrls.isEmpty) return const SizedBox();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(8),
Row(
children: [
Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(title, fontWeight: 600),
],
),
MySpacing.height(8),
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final url = imageUrls[index];
return GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: imageUrls,
initialIndex: index,
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
url,
width: 70,
height: 70,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
width: 70,
height: 70,
color: Colors.grey.shade200,
child: Icon(Icons.broken_image, color: Colors.grey[600]),
),
),
),
);
},
),
),
MySpacing.height(16),
],
);
}
/// Local image picker preview (with file images)
Widget buildImagePickerSection({
required List<File> images,
required VoidCallback onCameraTap,
required VoidCallback onUploadTap,
required void Function(int index) onRemoveImage,
required void Function(int initialIndex) onPreviewImage,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (images.isEmpty)
Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined,
size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () => onPreviewImage(index),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
file,
height: 70,
width: 70,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => onRemoveImage(index),
child: Container(
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Icons.close,
size: 20, color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: onCameraTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.camera_alt,
size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture', color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: onUploadTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.upload_file,
size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload', color: Colors.blueAccent),
],
),
),
),
],
),
],
);
}
/// Comment list widget
Widget buildCommentList(
List<Map<String, dynamic>> comments, BuildContext context, String Function(String) timeAgo) {
comments.sort((a, b) {
final aDate = DateTime.tryParse(a['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
final bDate = DateTime.tryParse(b['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
return bDate.compareTo(aDate); // newest first
});
return SizedBox(
height: 300,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: comments.length,
itemBuilder: (context, index) {
final comment = comments[index];
final commentText = comment['text'] ?? '-';
final commentedBy = comment['commentedBy'] ?? 'Unknown';
final relativeTime = timeAgo(comment['date'] ?? '');
final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Avatar(
firstName: commentedBy.split(' ').first,
lastName: commentedBy.split(' ').length > 1
? commentedBy.split(' ').last
: '',
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(commentedBy,
fontWeight: 700, color: Colors.black87),
MyText.bodySmall(
relativeTime,
fontSize: 12,
color: Colors.black54,
),
],
),
),
],
),
const SizedBox(height: 12),
MyText.bodyMedium(commentText,
fontWeight: 500, color: Colors.black87),
const SizedBox(height: 12),
if (imageUrls.isNotEmpty) ...[
Row(
children: [
Icon(Icons.attach_file_outlined,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.bodyMedium('Attachments',
fontWeight: 600, color: Colors.black87),
],
),
const SizedBox(height: 8),
SizedBox(
height: 60,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
itemBuilder: (context, imageIndex) {
final imageUrl = imageUrls[imageIndex];
return GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: imageUrls,
initialIndex: imageIndex,
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
imageUrl,
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
);
},
separatorBuilder: (_, __) => const SizedBox(width: 12),
),
),
]
],
),
);
},
),
);
}
/// Cancel + Submit buttons
Widget buildCommentActionButtons({
required VoidCallback onCancel,
required Future<void> Function() onSubmit,
required RxBool isLoading,
}) {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, color: Colors.red, size: 18),
label:
MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Obx(() {
return ElevatedButton.icon(
onPressed: isLoading.value ? null : () => onSubmit(),
icon: isLoading.value
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.send, color: Colors.white, size: 18),
label: isLoading.value
? const SizedBox()
: MyText.bodyMedium("Submit",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
);
}),
),
],
);
}
/// Converts a UTC timestamp to a relative time string
String timeAgo(String dateString) {
try {
DateTime date = DateTime.parse(dateString + "Z").toLocal();
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays > 8) {
return "${date.day.toString().padLeft(2, '0')}-${date.month.toString().padLeft(2, '0')}-${date.year}";
} else if (difference.inDays >= 1) {
return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago';
} else if (difference.inHours >= 1) {
return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago';
} else if (difference.inMinutes >= 1) {
return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago';
} else {
return 'just now';
}
} catch (e) {
return '';
}
}

View File

@ -6,10 +6,12 @@ import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class ReportTaskBottomSheet extends StatefulWidget { class ReportTaskBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData; final Map<String, dynamic> taskData;
final VoidCallback? onReportSuccess; final VoidCallback? onReportSuccess;
const ReportTaskBottomSheet({ const ReportTaskBottomSheet({
super.key, super.key,
required this.taskData, required this.taskData,
@ -27,464 +29,282 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize the controller with a unique tag (optional) controller = Get.put(
controller = Get.put(ReportTaskController(), ReportTaskController(),
tag: widget.taskData['taskId'] ?? UniqueKey().toString()); tag: widget.taskData['taskId'] ?? UniqueKey().toString(),
);
_preFillFormFields();
}
final taskData = widget.taskData; void _preFillFormFields() {
controller.basicValidator.getController('assigned_date')?.text = final data = widget.taskData;
taskData['assignedOn'] ?? ''; final v = controller.basicValidator;
controller.basicValidator.getController('assigned_by')?.text =
taskData['assignedBy'] ?? ''; v.getController('assigned_date')?.text = data['assignedOn'] ?? '';
controller.basicValidator.getController('work_area')?.text = v.getController('assigned_by')?.text = data['assignedBy'] ?? '';
taskData['location'] ?? ''; v.getController('work_area')?.text = data['location'] ?? '';
controller.basicValidator.getController('activity')?.text = v.getController('activity')?.text = data['activity'] ?? '';
taskData['activity'] ?? ''; v.getController('team_size')?.text = data['teamSize']?.toString() ?? '';
controller.basicValidator.getController('team_size')?.text = v.getController('assigned')?.text = data['assigned'] ?? '';
taskData['teamSize']?.toString() ?? ''; v.getController('task_id')?.text = data['taskId'] ?? '';
controller.basicValidator.getController('assigned')?.text = v.getController('completed_work')?.clear();
taskData['assigned'] ?? ''; v.getController('comment')?.clear();
controller.basicValidator.getController('task_id')?.text =
taskData['taskId'] ?? '';
controller.basicValidator.getController('completed_work')?.clear();
controller.basicValidator.getController('comment')?.clear();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Obx(() {
decoration: BoxDecoration( return BaseBottomSheet(
color: Colors.white, title: "Report Task",
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), isSubmitting: controller.reportStatus.value == ApiStatus.loading,
), onCancel: () => Navigator.of(context).pop(),
child: SingleChildScrollView( onSubmit: _handleSubmit,
padding: EdgeInsets.only( child: Form(
bottom: MediaQuery.of(context).viewInsets.bottom + 24, key: controller.basicValidator.formKey,
left: 24, child: Column(
right: 24, crossAxisAlignment: CrossAxisAlignment.start,
top: 12, children: [
), _buildRow("Assigned Date", controller.basicValidator.getController('assigned_date')?.text),
child: Column( _buildRow("Assigned By", controller.basicValidator.getController('assigned_by')?.text),
mainAxisSize: MainAxisSize.min, _buildRow("Work Area", controller.basicValidator.getController('work_area')?.text),
children: [ _buildRow("Activity", controller.basicValidator.getController('activity')?.text),
// Drag handle _buildRow("Team Size", controller.basicValidator.getController('team_size')?.text),
Container( _buildRow(
width: 40, "Assigned",
height: 4, "${controller.basicValidator.getController('assigned')?.text ?? '-'} "
margin: const EdgeInsets.only(bottom: 12), "of ${widget.taskData['pendingWork'] ?? '-'} Pending",
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
), ),
), _buildCompletedWorkField(),
GetBuilder<ReportTaskController>( _buildCommentField(),
tag: widget.taskData['taskId'] ?? '', Obx(() => _buildImageSection()),
init: controller, ],
builder: (_) {
return Form(
key: controller.basicValidator.formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: MyText.titleMedium(
"Report Task",
fontWeight: 600,
),
),
MySpacing.height(16),
buildRow(
"Assigned Date",
controller.basicValidator
.getController('assigned_date')
?.text
.trim()),
buildRow(
"Assigned By",
controller.basicValidator
.getController('assigned_by')
?.text
.trim()),
buildRow(
"Work Area",
controller.basicValidator
.getController('work_area')
?.text
.trim()),
buildRow(
"Activity",
controller.basicValidator
.getController('activity')
?.text
.trim()),
buildRow(
"Team Size",
controller.basicValidator
.getController('team_size')
?.text
.trim()),
buildRow(
"Assigned",
"${controller.basicValidator.getController('assigned')?.text.trim()} "
"of ${widget.taskData['pendingWork'] ?? '-'} Pending"),
Row(
children: [
Icon(Icons.work_outline,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(
"Completed Work:",
fontWeight: 600,
),
],
),
MySpacing.height(8),
TextFormField(
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter completed work';
}
final completed = int.tryParse(value.trim());
final pending = widget.taskData['pendingWork'] ?? 0;
if (completed == null) {
return 'Enter a valid number';
}
if (completed > pending) {
return 'Completed work cannot exceed pending work $pending';
}
return null;
},
controller: controller.basicValidator
.getController('completed_work'),
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: "eg: 10",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder,
enabledBorder: outlineInputBorder,
focusedBorder: focusedInputBorder,
contentPadding: MySpacing.all(16),
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(24),
Row(
children: [
Icon(Icons.comment_outlined,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(
"Comment:",
fontWeight: 600,
),
],
),
MySpacing.height(8),
TextFormField(
validator: controller.basicValidator
.getValidation('comment'),
controller: controller.basicValidator
.getController('comment'),
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: "eg: Work done successfully",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder,
enabledBorder: outlineInputBorder,
focusedBorder: focusedInputBorder,
contentPadding: MySpacing.all(16),
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(24),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.camera_alt_outlined,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Attach Photos:",
fontWeight: 600),
MySpacing.height(12),
],
),
),
],
),
Obx(() {
final images = controller.selectedImages;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (images.isEmpty)
Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined,
size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (_, __) =>
MySpacing.width(12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => Dialog(
child: InteractiveViewer(
child: Image.file(file),
),
),
);
},
child: ClipRRect(
borderRadius:
BorderRadius.circular(12),
child: Image.file(
file,
height: 70,
width: 70,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => controller
.removeImageAt(index),
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: Icon(Icons.close,
size: 20,
color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(
fromCamera: true),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt,
size: 16,
color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture',
color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(
fromCamera: false),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.upload_file,
size: 16,
color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload',
color: Colors.blueAccent),
],
),
),
),
],
),
],
);
}),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.red, size: 18),
label: MyText.bodyMedium(
"Cancel",
color: Colors.red,
fontWeight: 600,
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
), ),
), );
), });
const SizedBox(width: 16),
Expanded(
child: Obx(() {
final isLoading =
controller.reportStatus.value == ApiStatus.loading;
return ElevatedButton.icon(
onPressed: isLoading
? null
: () async {
if (controller.basicValidator.validateForm()) {
final success = await controller.reportTask(
projectId: controller.basicValidator
.getController('task_id')
?.text ??
'',
comment: controller.basicValidator
.getController('comment')
?.text ??
'',
completedTask: int.tryParse(
controller.basicValidator
.getController('completed_work')
?.text ??
'') ??
0,
checklist: [],
reportedDate: DateTime.now(),
images: controller.selectedImages,
);
if (success && widget.onReportSuccess != null) {
widget.onReportSuccess!();
}
}
},
icon: isLoading
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.check_circle_outline,
color: Colors.white, size: 18),
label: isLoading
? const SizedBox.shrink()
: MyText.bodyMedium(
"Report",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
);
}),
),
],
),
],
),
),
);
},
),
],
),
),
);
} }
Widget buildRow(String label, String? value) { Future<void> _handleSubmit() async {
IconData icon; final v = controller.basicValidator;
switch (label) {
case "Assigned Date": if (v.validateForm()) {
icon = Icons.calendar_today_outlined; final success = await controller.reportTask(
break; projectId: v.getController('task_id')?.text ?? '',
case "Assigned By": comment: v.getController('comment')?.text ?? '',
icon = Icons.person_outline; completedTask: int.tryParse(v.getController('completed_work')?.text ?? '') ?? 0,
break; checklist: [],
case "Work Area": reportedDate: DateTime.now(),
icon = Icons.place_outlined; images: controller.selectedImages,
break; );
case "Activity":
icon = Icons.run_circle_outlined; if (success) {
break; widget.onReportSuccess?.call();
case "Team Size": }
icon = Icons.group_outlined;
break;
case "Assigned":
icon = Icons.assignment_turned_in_outlined;
break;
default:
icon = Icons.info_outline;
} }
}
Widget _buildRow(String label, String? value) {
final icons = {
"Assigned Date": Icons.calendar_today_outlined,
"Assigned By": Icons.person_outline,
"Work Area": Icons.place_outlined,
"Activity": Icons.run_circle_outlined,
"Team Size": Icons.group_outlined,
"Assigned": Icons.assignment_turned_in_outlined,
};
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, size: 18, color: Colors.grey[700]), Icon(icons[label] ?? Icons.info_outline, size: 18, color: Colors.grey[700]),
MySpacing.width(8), MySpacing.width(8),
MyText.titleSmall( MyText.titleSmall("$label:", fontWeight: 600),
"$label:",
fontWeight: 600,
),
MySpacing.width(12), MySpacing.width(12),
Expanded( Expanded(
child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), child: MyText.bodyMedium(value?.trim().isNotEmpty == true ? value!.trim() : "-"),
), ),
], ],
), ),
); );
} }
}
Widget _buildCompletedWorkField() {
final pending = widget.taskData['pendingWork'] ?? 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.work_outline, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("Completed Work:", fontWeight: 600),
],
),
MySpacing.height(8),
TextFormField(
controller: controller.basicValidator.getController('completed_work'),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.trim().isEmpty) return 'Please enter completed work';
final completed = int.tryParse(value.trim());
if (completed == null) return 'Enter a valid number';
if (completed > pending) return 'Completed work cannot exceed pending work $pending';
return null;
},
decoration: InputDecoration(
hintText: "eg: 10",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder,
enabledBorder: outlineInputBorder,
focusedBorder: focusedInputBorder,
contentPadding: MySpacing.all(16),
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(24),
],
);
}
Widget _buildCommentField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("Comment:", fontWeight: 600),
],
),
MySpacing.height(8),
TextFormField(
controller: controller.basicValidator.getController('comment'),
validator: controller.basicValidator.getValidation('comment'),
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: "eg: Work done successfully",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder,
enabledBorder: outlineInputBorder,
focusedBorder: focusedInputBorder,
contentPadding: MySpacing.all(16),
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(24),
],
);
}
Widget _buildImageSection() {
final images = controller.selectedImages;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.camera_alt_outlined, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("Attach Photos:", fontWeight: 600),
],
),
MySpacing.height(12),
if (images.isEmpty)
Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined, size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (_, __) => MySpacing.width(12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => Dialog(
child: InteractiveViewer(child: Image.file(file)),
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(file, height: 70, width: 70, fit: BoxFit.cover),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => controller.removeImageAt(index),
child: Container(
decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle),
child: const Icon(Icons.close, size: 20, color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(fromCamera: true),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.camera_alt, size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture', color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(fromCamera: false),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.upload_file, size: 16, color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload', color: Colors.blueAccent),
],
),
),
),
],
),
],
);
}
}

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:marco/controller/directory/add_contact_controller.dart'; import 'package:marco/controller/directory/add_contact_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
@ -8,6 +8,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/directory/contact_model.dart'; import 'package:marco/model/directory/contact_model.dart';
import 'package:marco/helpers/utils/contact_picker_helper.dart'; import 'package:marco/helpers/utils/contact_picker_helper.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AddContactBottomSheet extends StatefulWidget { class AddContactBottomSheet extends StatefulWidget {
final ContactModel? existingContact; final ContactModel? existingContact;
@ -21,106 +22,98 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
final controller = Get.put(AddContactController()); final controller = Get.put(AddContactController());
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
final nameController = TextEditingController(); final nameCtrl = TextEditingController();
final orgController = TextEditingController(); final orgCtrl = TextEditingController();
final addressController = TextEditingController(); final addrCtrl = TextEditingController();
final descriptionController = TextEditingController(); final descCtrl = TextEditingController();
final tagTextController = TextEditingController(); final tagCtrl = TextEditingController();
final RxBool showAdvanced = false.obs;
final RxList<TextEditingController> emailControllers =
<TextEditingController>[].obs;
final RxList<RxString> emailLabels = <RxString>[].obs;
final RxList<TextEditingController> phoneControllers = final showAdvanced = false.obs;
<TextEditingController>[].obs; final bucketError = ''.obs;
final RxList<RxString> phoneLabels = <RxString>[].obs;
final emailCtrls = <TextEditingController>[].obs;
final emailLabels = <RxString>[].obs;
final phoneCtrls = <TextEditingController>[].obs;
final phoneLabels = <RxString>[].obs;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller.resetForm(); controller.resetForm();
_initFields();
}
nameController.text = widget.existingContact?.name ?? ''; void _initFields() {
orgController.text = widget.existingContact?.organization ?? ''; final c = widget.existingContact;
addressController.text = widget.existingContact?.address ?? ''; if (c != null) {
descriptionController.text = widget.existingContact?.description ?? ''; nameCtrl.text = c.name;
tagTextController.clear(); orgCtrl.text = c.organization;
addrCtrl.text = c.address;
descCtrl.text = c.description;
if (widget.existingContact != null) { emailCtrls.assignAll(c.contactEmails.isEmpty
emailControllers.clear(); ? [TextEditingController()]
emailLabels.clear(); : c.contactEmails
for (var email in widget.existingContact!.contactEmails) { .map((e) => TextEditingController(text: e.emailAddress)));
emailControllers.add(TextEditingController(text: email.emailAddress)); emailLabels.assignAll(c.contactEmails.isEmpty
emailLabels.add((email.label).obs); ? ['Office'.obs]
} : c.contactEmails.map((e) => e.label.obs));
if (emailControllers.isEmpty) {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
}
phoneControllers.clear(); phoneCtrls.assignAll(c.contactPhones.isEmpty
phoneLabels.clear(); ? [TextEditingController()]
for (var phone in widget.existingContact!.contactPhones) { : c.contactPhones
phoneControllers.add(TextEditingController(text: phone.phoneNumber)); .map((p) => TextEditingController(text: p.phoneNumber)));
phoneLabels.add((phone.label).obs); phoneLabels.assignAll(c.contactPhones.isEmpty
} ? ['Work'.obs]
if (phoneControllers.isEmpty) { : c.contactPhones.map((p) => p.label.obs));
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
}
controller.enteredTags.assignAll( controller.enteredTags.assignAll(c.tags.map((e) => e.name));
widget.existingContact!.tags.map((tag) => tag.name).toList(),
);
ever(controller.isInitialized, (bool ready) { ever(controller.isInitialized, (bool ready) {
if (ready) { if (ready) {
final projectIds = widget.existingContact!.projectIds; final projectIds = c.projectIds;
final bucketId = widget.existingContact!.bucketIds.firstOrNull; final bucketId = c.bucketIds.firstOrNull;
final categoryName = widget.existingContact!.contactCategory?.name; final category = c.contactCategory?.name;
if (categoryName != null) { if (category != null) controller.selectedCategory.value = category;
controller.selectedCategory.value = categoryName;
}
if (projectIds != null) { if (projectIds != null) {
final names = projectIds controller.selectedProjects.assignAll(
.map((id) { projectIds
return controller.projectsMap.entries .map((id) => controller.projectsMap.entries
.firstWhereOrNull((e) => e.value == id) .firstWhereOrNull((e) => e.value == id)
?.key; ?.key)
}) .whereType<String>()
.whereType<String>() .toList(),
.toList(); );
controller.selectedProjects.assignAll(names);
} }
if (bucketId != null) { if (bucketId != null) {
final name = controller.bucketsMap.entries final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId) .firstWhereOrNull((e) => e.value == bucketId)
?.key; ?.key;
if (name != null) { if (name != null) controller.selectedBucket.value = name;
controller.selectedBucket.value = name;
}
} }
} }
}); });
} else { } else {
emailControllers.add(TextEditingController()); emailCtrls.add(TextEditingController());
emailLabels.add('Office'.obs); emailLabels.add('Office'.obs);
phoneControllers.add(TextEditingController()); phoneCtrls.add(TextEditingController());
phoneLabels.add('Work'.obs); phoneLabels.add('Work'.obs);
} }
} }
@override @override
void dispose() { void dispose() {
nameController.dispose(); nameCtrl.dispose();
orgController.dispose(); orgCtrl.dispose();
tagTextController.dispose(); addrCtrl.dispose();
addressController.dispose(); descCtrl.dispose();
descriptionController.dispose(); tagCtrl.dispose();
emailControllers.forEach((e) => e.dispose()); emailCtrls.forEach((c) => c.dispose());
phoneControllers.forEach((p) => p.dispose()); phoneCtrls.forEach((c) => c.dispose());
Get.delete<AddContactController>(); Get.delete<AddContactController>();
super.dispose(); super.dispose();
} }
@ -147,158 +140,38 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
isDense: true, isDense: true,
); );
Widget _buildLabeledRow( Widget _textField(String label, TextEditingController ctrl,
String label, {bool required = false, int maxLines = 1}) {
RxString selectedLabel, return Column(
List<String> options,
String inputLabel,
TextEditingController controller,
TextInputType inputType,
{VoidCallback? onRemove}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( MyText.labelMedium(label),
child: Column( MySpacing.height(8),
crossAxisAlignment: CrossAxisAlignment.start, TextFormField(
children: [ controller: ctrl,
MyText.labelMedium(label), maxLines: maxLines,
MySpacing.height(8), decoration: _inputDecoration("Enter $label"),
_popupSelector( validator: required
hint: "Label", ? (v) =>
selectedValue: selectedLabel, (v == null || v.trim().isEmpty) ? "$label is required" : null
options: options), : null,
],
),
), ),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(inputLabel),
MySpacing.height(8),
TextFormField(
controller: controller,
keyboardType: inputType,
maxLength: inputType == TextInputType.phone ? 10 : null,
inputFormatters: inputType == TextInputType.phone
? [FilteringTextInputFormatter.digitsOnly]
: [],
decoration: _inputDecoration("Enter $inputLabel").copyWith(
counterText: "",
suffixIcon: inputType == TextInputType.phone
? IconButton(
icon: const Icon(Icons.contact_phone,
color: Colors.blue),
onPressed: () async {
final selectedPhone =
await ContactPickerHelper.pickIndianPhoneNumber(
context);
if (selectedPhone != null) {
controller.text = selectedPhone;
}
},
)
: null,
),
validator: (value) {
if (value == null || value.trim().isEmpty)
return "$inputLabel is required";
final trimmed = value.trim();
if (inputType == TextInputType.phone) {
if (!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
return "Enter valid phone number";
}
}
if (inputType == TextInputType.emailAddress &&
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(trimmed)) {
return "Enter valid email";
}
return null;
},
),
],
),
),
if (onRemove != null)
Padding(
padding: const EdgeInsets.only(top: 24),
child: IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
onPressed: onRemove,
),
),
], ],
); );
} }
Widget _buildEmailList() => Column( Widget _popupSelector(RxString selected, List<String> options, String hint) =>
children: List.generate(emailControllers.length, (index) { Obx(() {
return Padding( return GestureDetector(
padding: const EdgeInsets.only(bottom: 12),
child: _buildLabeledRow(
"Email Label",
emailLabels[index],
["Office", "Personal", "Other"],
"Email",
emailControllers[index],
TextInputType.emailAddress,
onRemove: emailControllers.length > 1
? () {
emailControllers.removeAt(index);
emailLabels.removeAt(index);
}
: null,
),
);
}),
);
Widget _buildPhoneList() => Column(
children: List.generate(phoneControllers.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildLabeledRow(
"Phone Label",
phoneLabels[index],
["Work", "Mobile", "Other"],
"Phone",
phoneControllers[index],
TextInputType.phone,
onRemove: phoneControllers.length > 1
? () {
phoneControllers.removeAt(index);
phoneLabels.removeAt(index);
}
: null,
),
);
}),
);
Widget _popupSelector({
required String hint,
required RxString selectedValue,
required List<String> options,
}) {
return Obx(() => GestureDetector(
onTap: () async { onTap: () async {
final selected = await showMenu<String>( final selectedItem = await showMenu<String>(
context: context, context: context,
position: RelativeRect.fromLTRB(100, 300, 100, 0), position: RelativeRect.fromLTRB(100, 300, 100, 0),
items: options.map((option) { items: options
return PopupMenuItem<String>( .map((e) => PopupMenuItem<String>(value: e, child: Text(e)))
value: option, .toList(),
child: Text(option),
);
}).toList(),
); );
if (selectedItem != null) selected.value = selectedItem;
if (selected != null) {
selectedValue.value = selected;
}
}, },
child: Container( child: Container(
height: 48, height: 48,
@ -312,45 +185,126 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(selected.value.isNotEmpty ? selected.value : hint,
selectedValue.value.isNotEmpty ? selectedValue.value : hint, style: const TextStyle(fontSize: 14)),
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.expand_more, size: 20), const Icon(Icons.expand_more, size: 20),
], ],
), ),
), ),
)); );
});
Widget _dynamicList(
RxList<TextEditingController> ctrls,
RxList<RxString> labels,
String labelType,
List<String> labelOptions,
TextInputType type) {
return Obx(() {
return Column(
children: List.generate(ctrls.length, (i) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("$labelType Label"),
MySpacing.height(8),
_popupSelector(labels[i], labelOptions, "Label"),
],
),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(labelType),
MySpacing.height(8),
TextFormField(
controller: ctrls[i],
keyboardType: type,
maxLength: type == TextInputType.phone ? 10 : null,
inputFormatters: type == TextInputType.phone
? [FilteringTextInputFormatter.digitsOnly]
: [],
decoration:
_inputDecoration("Enter $labelType").copyWith(
counterText: "",
suffixIcon: type == TextInputType.phone
? IconButton(
icon: const Icon(Icons.contact_phone,
color: Colors.blue),
onPressed: () async {
final phone = await ContactPickerHelper
.pickIndianPhoneNumber(context);
if (phone != null) ctrls[i].text = phone;
},
)
: null,
),
validator: (value) {
if (value == null || value.trim().isEmpty)
return null;
final trimmed = value.trim();
if (type == TextInputType.phone &&
!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
return "Enter valid phone number";
}
if (type == TextInputType.emailAddress &&
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(trimmed)) {
return "Enter valid email";
}
return null;
},
),
],
),
),
if (ctrls.length > 1)
Padding(
padding: const EdgeInsets.only(top: 24),
child: IconButton(
icon: const Icon(Icons.remove_circle_outline,
color: Colors.red),
onPressed: () {
ctrls.removeAt(i);
labels.removeAt(i);
},
),
),
],
),
);
}),
);
});
} }
Widget _sectionLabel(String title) => Column( Widget _tagInput() {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelLarge(title, fontWeight: 600),
MySpacing.height(4),
Divider(thickness: 1, color: Colors.grey.shade200),
],
);
Widget _tagInputSection() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( SizedBox(
height: 48, height: 48,
child: TextField( child: TextField(
controller: tagTextController, controller: tagCtrl,
onChanged: controller.filterSuggestions, onChanged: controller.filterSuggestions,
onSubmitted: (value) { onSubmitted: (v) {
controller.addEnteredTag(value); controller.addEnteredTag(v);
tagTextController.clear(); tagCtrl.clear();
controller.clearSuggestions(); controller.clearSuggestions();
}, },
decoration: _inputDecoration("Start typing to add tags"), decoration: _inputDecoration("Start typing to add tags"),
), ),
), ),
Obx(() => controller.filteredSuggestions.isEmpty Obx(() => controller.filteredSuggestions.isEmpty
? const SizedBox() ? const SizedBox.shrink()
: Container( : Container(
margin: const EdgeInsets.only(top: 4), margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -364,14 +318,14 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
child: ListView.builder( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
itemCount: controller.filteredSuggestions.length, itemCount: controller.filteredSuggestions.length,
itemBuilder: (context, index) { itemBuilder: (_, i) {
final suggestion = controller.filteredSuggestions[index]; final suggestion = controller.filteredSuggestions[i];
return ListTile( return ListTile(
dense: true, dense: true,
title: Text(suggestion), title: Text(suggestion),
onTap: () { onTap: () {
controller.addEnteredTag(suggestion); controller.addEnteredTag(suggestion);
tagTextController.clear(); tagCtrl.clear();
controller.clearSuggestions(); controller.clearSuggestions();
}, },
); );
@ -392,125 +346,46 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
); );
} }
Widget _buildTextField(String label, TextEditingController controller, void _handleSubmit() {
{int maxLines = 1}) { bool valid = formKey.currentState?.validate() ?? false;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
TextFormField(
controller: controller,
maxLines: maxLines,
decoration: _inputDecoration("Enter $label"),
validator: (value) => value == null || value.trim().isEmpty
? "$label is required"
: null,
),
],
);
}
Widget _buildOrganizationField() { if (controller.selectedBucket.value.isEmpty) {
return Column( bucketError.value = "Bucket is required";
crossAxisAlignment: CrossAxisAlignment.start, valid = false;
children: [ } else {
MyText.labelMedium("Organization"), bucketError.value = "";
MySpacing.height(8), }
TextField(
controller: orgController,
onChanged: controller.filterOrganizationSuggestions,
decoration: _inputDecoration("Enter organization"),
),
Obx(() => controller.filteredOrgSuggestions.isEmpty
? const SizedBox()
: ListView.builder(
shrinkWrap: true,
itemCount: controller.filteredOrgSuggestions.length,
itemBuilder: (context, index) {
final suggestion = controller.filteredOrgSuggestions[index];
return ListTile(
dense: true,
title: Text(suggestion),
onTap: () {
orgController.text = suggestion;
controller.filteredOrgSuggestions.clear();
},
);
},
)),
],
);
}
Widget _buildActionButtons() { if (!valid) return;
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Get.back();
Get.delete<AddContactController>();
},
icon: const Icon(Icons.close, color: Colors.red),
label:
MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
),
),
),
MySpacing.width(12),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
if (formKey.currentState!.validate()) {
final emails = emailControllers
.asMap()
.entries
.where((entry) => entry.value.text.trim().isNotEmpty)
.map((entry) => {
"label": emailLabels[entry.key].value,
"emailAddress": entry.value.text.trim(),
})
.toList();
final phones = phoneControllers final emails = emailCtrls
.asMap() .asMap()
.entries .entries
.where((entry) => entry.value.text.trim().isNotEmpty) .where((e) => e.value.text.trim().isNotEmpty)
.map((entry) => { .map((e) => {
"label": phoneLabels[entry.key].value, "label": emailLabels[e.key].value,
"phoneNumber": entry.value.text.trim(), "emailAddress": e.value.text.trim()
}) })
.toList(); .toList();
controller.submitContact( final phones = phoneCtrls
id: widget.existingContact?.id, .asMap()
name: nameController.text.trim(), .entries
organization: orgController.text.trim(), .where((e) => e.value.text.trim().isNotEmpty)
emails: emails, .map((e) => {
phones: phones, "label": phoneLabels[e.key].value,
address: addressController.text.trim(), "phoneNumber": e.value.text.trim()
description: descriptionController.text.trim(), })
); .toList();
}
}, controller.submitContact(
icon: const Icon(Icons.check_circle_outline, color: Colors.white), id: widget.existingContact?.id,
label: name: nameCtrl.text.trim(),
MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), organization: orgCtrl.text.trim(),
style: ElevatedButton.styleFrom( emails: emails,
backgroundColor: Colors.indigo, phones: phones,
shape: RoundedRectangleBorder( address: addrCtrl.text.trim(),
borderRadius: BorderRadius.circular(10)), description: descCtrl.text.trim(),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
),
),
),
],
); );
} }
@ -521,213 +396,107 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return SafeArea( return BaseBottomSheet(
child: SingleChildScrollView( title: widget.existingContact != null
padding: EdgeInsets.only( ? "Edit Contact"
top: 32, : "Create New Contact",
).add(MediaQuery.of(context).viewInsets), onCancel: () => Get.back(),
child: Container( onSubmit: _handleSubmit,
decoration: BoxDecoration( isSubmitting: controller.isSubmitting.value,
color: Theme.of(context).cardColor, child: Form(
borderRadius: key: formKey,
const BorderRadius.vertical(top: Radius.circular(24)), child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
child: Padding( children: [
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), _textField("Name", nameCtrl, required: true),
child: Form( MySpacing.height(16),
key: formKey, _textField("Organization", orgCtrl, required: true),
child: Column( MySpacing.height(16),
crossAxisAlignment: CrossAxisAlignment.start, MyText.labelMedium("Select Bucket"),
children: [ MySpacing.height(8),
Center( Stack(
child: MyText.titleMedium( children: [
widget.existingContact != null _popupSelector(controller.selectedBucket, controller.buckets,
? "Edit Contact" "Select Bucket"),
: "Create New Contact", Positioned(
fontWeight: 700, left: 0,
), right: 0,
), top: 56,
MySpacing.height(24), child: Obx(() => bucketError.value.isEmpty
_sectionLabel("Required Fields"), ? const SizedBox.shrink()
MySpacing.height(12), : Padding(
_buildTextField("Name", nameController), padding:
MySpacing.height(16), const EdgeInsets.symmetric(horizontal: 8.0),
_buildOrganizationField(), child: Text(bucketError.value,
MySpacing.height(16), style: const TextStyle(
MyText.labelMedium("Select Bucket"), color: Colors.red, fontSize: 12)),
MySpacing.height(8), )),
_popupSelector( ),
hint: "Select Bucket", ],
selectedValue: controller.selectedBucket,
options: controller.buckets,
),
MySpacing.height(24),
Obx(() => GestureDetector(
onTap: () => showAdvanced.toggle(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.labelLarge("Advanced Details (Optional)",
fontWeight: 600),
Icon(showAdvanced.value
? Icons.expand_less
: Icons.expand_more),
],
),
)),
Obx(() => showAdvanced.value
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(24),
_sectionLabel("Contact Info"),
MySpacing.height(16),
_buildEmailList(),
TextButton.icon(
onPressed: () {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
_buildPhoneList(),
TextButton.icon(
onPressed: () {
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Phone"),
),
MySpacing.height(24),
_sectionLabel("Other Details"),
MySpacing.height(16),
MyText.labelMedium("Category"),
MySpacing.height(8),
_popupSelector(
hint: "Select Category",
selectedValue: controller.selectedCategory,
options: controller.categories,
),
MySpacing.height(16),
MyText.labelMedium("Select Projects"),
MySpacing.height(8),
_projectSelectorUI(),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInputSection(),
MySpacing.height(16),
_buildTextField("Address", addressController,
maxLines: 2),
MySpacing.height(16),
_buildTextField(
"Description", descriptionController,
maxLines: 2),
],
)
: const SizedBox()),
MySpacing.height(24),
_buildActionButtons(),
],
),
), ),
), MySpacing.height(24),
Obx(() => GestureDetector(
onTap: () => showAdvanced.toggle(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.labelLarge("Advanced Details (Optional)",
fontWeight: 600),
Icon(showAdvanced.value
? Icons.expand_less
: Icons.expand_more),
],
),
)),
Obx(() => showAdvanced.value
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(24),
_dynamicList(
emailCtrls,
emailLabels,
"Email",
["Office", "Personal", "Other"],
TextInputType.emailAddress),
TextButton.icon(
onPressed: () {
emailCtrls.add(TextEditingController());
emailLabels.add("Office".obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
_dynamicList(phoneCtrls, phoneLabels, "Phone",
["Work", "Mobile", "Other"], TextInputType.phone),
TextButton.icon(
onPressed: () {
phoneCtrls.add(TextEditingController());
phoneLabels.add("Work".obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Phone"),
),
MySpacing.height(16),
MyText.labelMedium("Category"),
MySpacing.height(8),
_popupSelector(controller.selectedCategory,
controller.categories, "Select Category"),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInput(),
MySpacing.height(16),
_textField("Address", addrCtrl),
MySpacing.height(16),
_textField("Description", descCtrl),
],
)
: const SizedBox.shrink()),
],
), ),
), ),
); );
}); });
} }
Widget _projectSelectorUI() {
return GestureDetector(
onTap: () async {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Select Projects'),
content: Obx(() {
return SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
children: controller.globalProjects.map((project) {
final isSelected =
controller.selectedProjects.contains(project);
return Theme(
data: Theme.of(context).copyWith(
unselectedWidgetColor: Colors.black,
checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) {
if (states.contains(MaterialState.selected)) {
return Colors.white;
}
return Colors.transparent;
}),
checkColor: MaterialStateProperty.all(Colors.black),
side:
const BorderSide(color: Colors.black, width: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
),
child: CheckboxListTile(
dense: true,
title: Text(project),
value: isSelected,
onChanged: (bool? selected) {
if (selected == true) {
controller.selectedProjects.add(project);
} else {
controller.selectedProjects.remove(project);
}
},
),
);
}).toList(),
),
);
}),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Done'),
),
],
);
},
);
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
alignment: Alignment.centerLeft,
child: Obx(() {
final selected = controller.selectedProjects;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
selected.isEmpty ? "Select Projects" : selected.join(', '),
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
),
const Icon(Icons.expand_more, size: 20),
],
);
}),
),
);
}
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/directory/create_bucket_controller.dart'; import 'package:marco/controller/directory/create_bucket_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
@ -38,125 +39,55 @@ class _CreateBucketBottomSheetState extends State<CreateBucketBottomSheet> {
); );
} }
Widget _formContent() {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Bucket Name"),
MySpacing.height(8),
TextFormField(
initialValue: _controller.name.value,
onChanged: _controller.updateName,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Bucket name is required";
}
return null;
},
decoration: _inputDecoration("e.g., Project Docs"),
),
MySpacing.height(16),
MyText.labelMedium("Description"),
MySpacing.height(8),
TextFormField(
initialValue: _controller.description.value,
onChanged: _controller.updateDescription,
maxLines: 3,
decoration: _inputDecoration("Optional bucket description"),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return GetBuilder<BucketController>( return GetBuilder<BucketController>(
builder: (_) { builder: (_) {
return SafeArea( return SafeArea(
top: false, top: false,
child: SingleChildScrollView( child: BaseBottomSheet(
padding: MediaQuery.of(context).viewInsets, title: "Create New Bucket",
child: Container( child: _formContent(),
decoration: BoxDecoration( onCancel: () => Navigator.pop(context, false),
color: theme.cardColor, onSubmit: () async {
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), if (_formKey.currentState!.validate()) {
boxShadow: const [ await _controller.createBucket();
BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2)), }
], },
), isSubmitting: _controller.isCreating.value,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
MySpacing.height(12),
Text("Create New Bucket", style: MyTextStyle.titleLarge(fontWeight: 700)),
MySpacing.height(24),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Bucket Name"),
MySpacing.height(8),
TextFormField(
initialValue: _controller.name.value,
onChanged: _controller.updateName,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Bucket name is required";
}
return null;
},
decoration: _inputDecoration("e.g., Project Docs"),
),
MySpacing.height(16),
MyText.labelMedium("Description"),
MySpacing.height(8),
TextFormField(
initialValue: _controller.description.value,
onChanged: _controller.updateDescription,
maxLines: 3,
decoration: _inputDecoration("Optional bucket description"),
),
MySpacing.height(24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Navigator.pop(context, false),
icon: const Icon(Icons.close, color: Colors.red),
label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
),
MySpacing.width(12),
Expanded(
child: Obx(() {
return ElevatedButton.icon(
onPressed: _controller.isCreating.value
? null
: () async {
if (_formKey.currentState!.validate()) {
await _controller.createBucket();
}
},
icon: _controller.isCreating.value
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium(
_controller.isCreating.value ? "Creating..." : "Create",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
),
);
}),
),
],
),
],
),
),
],
),
),
),
), ),
); );
}, },

View File

@ -1,170 +1,275 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/directory/directory_controller.dart'; import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
class DirectoryFilterBottomSheet extends StatelessWidget { class DirectoryFilterBottomSheet extends StatefulWidget {
const DirectoryFilterBottomSheet({super.key}); const DirectoryFilterBottomSheet({super.key});
@override @override
Widget build(BuildContext context) { State<DirectoryFilterBottomSheet> createState() =>
final controller = Get.find<DirectoryController>(); _DirectoryFilterBottomSheetState();
}
return Container( class _DirectoryFilterBottomSheetState
decoration: const BoxDecoration( extends State<DirectoryFilterBottomSheet> {
color: Colors.white, final DirectoryController controller = Get.find<DirectoryController>();
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), final _categorySearchQuery = ''.obs;
), final _bucketSearchQuery = ''.obs;
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 20, final _categoryExpanded = false.obs;
top: 12, final _bucketExpanded = false.obs;
left: 16,
right: 16, late final RxList<String> _tempSelectedCategories;
), late final RxList<String> _tempSelectedBuckets;
child: Obx(() {
return SingleChildScrollView( @override
void initState() {
super.initState();
_tempSelectedCategories = controller.selectedCategories.toList().obs;
_tempSelectedBuckets = controller.selectedBuckets.toList().obs;
}
void _toggleCategory(String id) {
_tempSelectedCategories.contains(id)
? _tempSelectedCategories.remove(id)
: _tempSelectedCategories.add(id);
}
void _toggleBucket(String id) {
_tempSelectedBuckets.contains(id)
? _tempSelectedBuckets.remove(id)
: _tempSelectedBuckets.add(id);
}
void _resetFilters() {
_tempSelectedCategories.clear();
_tempSelectedBuckets.clear();
}
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: "Filter Contacts",
onSubmit: () {
controller.selectedCategories.value = _tempSelectedCategories;
controller.selectedBuckets.value = _tempSelectedBuckets;
controller.applyFilters();
Get.back();
},
onCancel: Get.back,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
/// Drag handle Obx(() {
Center( final hasSelections = _tempSelectedCategories.isNotEmpty ||
child: Container( _tempSelectedBuckets.isNotEmpty;
height: 5, if (!hasSelections) return const SizedBox.shrink();
width: 50, return Column(
margin: const EdgeInsets.only(bottom: 12), crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2.5),
),
),
),
/// Title
Center(
child: MyText.titleMedium(
"Filter Contacts",
fontWeight: 700,
),
),
const SizedBox(height: 24),
/// Categories
if (controller.contactCategories.isNotEmpty) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
MyText.bodyMedium("Categories", fontWeight: 600), MyText("Selected Filters:", fontWeight: 600),
const SizedBox(height: 4),
_buildChips(_tempSelectedCategories,
controller.contactCategories, _toggleCategory),
_buildChips(_tempSelectedBuckets, controller.contactBuckets,
_toggleBucket),
], ],
), );
const SizedBox(height: 10), }),
Wrap(
spacing: 2,
runSpacing: 0,
children: controller.contactCategories.map((category) {
final selected =
controller.selectedCategories.contains(category.id);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: FilterChip(
label: MyText.bodySmall(
category.name,
color: selected ? Colors.white : Colors.black87,
),
selected: selected,
onSelected: (_) =>
controller.toggleCategory(category.id),
selectedColor: Colors.indigo,
backgroundColor: Colors.grey.shade200,
checkmarkColor: Colors.white,
),
);
}).toList(),
),
const SizedBox(height: 12),
],
/// Buckets
if (controller.contactBuckets.isNotEmpty) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium("Buckets", fontWeight: 600),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 2,
runSpacing: 0,
children: controller.contactBuckets.map((bucket) {
final selected =
controller.selectedBuckets.contains(bucket.id);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: FilterChip(
label: MyText.bodySmall(
bucket.name,
color: selected ? Colors.white : Colors.black87,
),
selected: selected,
onSelected: (_) => controller.toggleBucket(bucket.id),
selectedColor: Colors.teal,
backgroundColor: Colors.grey.shade200,
checkmarkColor: Colors.white,
),
);
}).toList(),
),
],
const SizedBox(height: 12),
/// Action Buttons
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
OutlinedButton.icon( TextButton.icon(
onPressed: () { onPressed: _resetFilters,
controller.selectedCategories.clear(); icon: const Icon(Icons.restart_alt, size: 18),
controller.selectedBuckets.clear(); label: MyText("Reset All", color: Colors.red),
controller.searchQuery.value = ''; style: TextButton.styleFrom(
controller.applyFilters(); foregroundColor: Colors.red.shade400,
Get.back();
},
icon: const Icon(Icons.refresh, color: Colors.red),
label: MyText.bodyMedium("Clear", color: Colors.red),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 7),
),
),
ElevatedButton.icon(
onPressed: () {
controller.applyFilters();
Get.back();
},
icon: const Icon(Icons.check_circle_outline),
label: MyText.bodyMedium("Apply", color: Colors.white),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 7),
), ),
), ),
], ],
), ),
const SizedBox(height: 10), if (controller.contactCategories.isNotEmpty)
Obx(() => _buildExpandableFilterSection(
title: "Categories",
expanded: _categoryExpanded,
searchQuery: _categorySearchQuery,
allItems: controller.contactCategories,
selectedItems: _tempSelectedCategories,
onToggle: _toggleCategory,
)),
if (controller.contactBuckets.isNotEmpty)
Obx(() => _buildExpandableFilterSection(
title: "Buckets",
expanded: _bucketExpanded,
searchQuery: _bucketSearchQuery,
allItems: controller.contactBuckets,
selectedItems: _tempSelectedBuckets,
onToggle: _toggleBucket,
)),
], ],
), ),
); ),
}), ),
);
}
Widget _buildChips(RxList<String> selectedIds, List<dynamic> allItems,
Function(String) onRemoved) {
final idToName = {for (var item in allItems) item.id: item.name};
return Wrap(
spacing: 4,
runSpacing: 4,
children: selectedIds
.map((id) => Chip(
label: MyText(idToName[id] ?? "", color: Colors.black87),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () => onRemoved(id),
backgroundColor: Colors.blue.shade50,
))
.toList(),
);
}
Widget _buildExpandableFilterSection({
required String title,
required RxBool expanded,
required RxString searchQuery,
required List<dynamic> allItems,
required RxList<String> selectedItems,
required Function(String) onToggle,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
children: [
GestureDetector(
onTap: () => expanded.toggle(),
child: Row(
children: [
Icon(
expanded.value
? Icons.keyboard_arrow_down
: Icons.keyboard_arrow_right,
size: 20,
),
const SizedBox(width: 4),
MyText(
"$title",
fontWeight: 600,
fontSize: 16,
),
],
),
),
if (expanded.value)
_buildFilterSection(
searchQuery: searchQuery,
allItems: allItems,
selectedItems: selectedItems,
onToggle: onToggle,
title: title,
),
],
),
);
}
Widget _buildFilterSection({
required String title,
required RxString searchQuery,
required List<dynamic> allItems,
required RxList<String> selectedItems,
required Function(String) onToggle,
}) {
final filteredList = allItems.where((item) {
if (searchQuery.isEmpty) return true;
return item.name.toLowerCase().contains(searchQuery.value.toLowerCase());
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 6),
TextField(
onChanged: (value) => searchQuery.value = value,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
hintText: "Search $title...",
hintStyle: const TextStyle(fontSize: 13),
prefixIcon: const Icon(Icons.search, size: 18),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
filled: true,
fillColor: Colors.grey.shade100,
),
),
const SizedBox(height: 8),
if (filteredList.isEmpty)
Row(
children: [
const Icon(Icons.sentiment_dissatisfied, color: Colors.grey),
const SizedBox(width: 10),
MyText("No results found.",
color: Colors.grey.shade600, fontSize: 14),
],
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 230),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: filteredList.length,
itemBuilder: (context, index) {
final item = filteredList[index];
final isSelected = selectedItems.contains(item.id);
return GestureDetector(
onTap: () => onToggle(item.id),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color:
isSelected ? Colors.blueAccent : Colors.white,
border: Border.all(
color: Colors.black,
width: 1.2,
),
borderRadius: BorderRadius.circular(4),
),
child: isSelected
? const Icon(Icons.check,
size: 14, color: Colors.white)
: null,
),
const SizedBox(width: 8),
MyText(item.name, fontSize: 14),
],
),
),
);
},
),
)
],
); );
} }
} }

View File

@ -1,18 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:collection/collection.dart';
import 'package:marco/controller/directory/manage_bucket_controller.dart'; import 'package:marco/controller/directory/manage_bucket_controller.dart';
import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/model/directory/contact_bucket_list_model.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/controller/directory/directory_controller.dart';
import 'package:collection/collection.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/model/directory/contact_bucket_list_model.dart';
class EditBucketBottomSheet { class EditBucketBottomSheet {
static void show(BuildContext context, ContactBucket bucket, static void show(
List<EmployeeModel> allEmployees, BuildContext context,
{required String ownerId}) { ContactBucket bucket,
List<EmployeeModel> allEmployees, {
required String ownerId,
}) {
final ManageBucketController controller = Get.find(); final ManageBucketController controller = Get.find();
final nameController = TextEditingController(text: bucket.name); final nameController = TextEditingController(text: bucket.name);
@ -25,7 +29,6 @@ class EditBucketBottomSheet {
InputDecoration _inputDecoration(String label) { InputDecoration _inputDecoration(String label) {
return InputDecoration( return InputDecoration(
labelText: label, labelText: label,
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
filled: true, filled: true,
fillColor: Colors.grey.shade100, fillColor: Colors.grey.shade100,
border: OutlineInputBorder( border: OutlineInputBorder(
@ -36,9 +39,9 @@ class EditBucketBottomSheet {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
), ),
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14), const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
@ -46,256 +49,183 @@ class EditBucketBottomSheet {
); );
} }
Future<void> _handleSubmit() async {
final newName = nameController.text.trim();
final newDesc = descController.text.trim();
final newEmployeeIds = selectedIds.toList()..sort();
final originalEmployeeIds = [...bucket.employeeIds]..sort();
final nameChanged = newName != bucket.name;
final descChanged = newDesc != bucket.description;
final employeeChanged =
!(const ListEquality().equals(newEmployeeIds, originalEmployeeIds));
if (!nameChanged && !descChanged && !employeeChanged) {
showAppSnackbar(
title: "No Changes",
message: "No changes were made to update the bucket.",
type: SnackbarType.warning,
);
return;
}
final success = await controller.updateBucket(
id: bucket.id,
name: newName,
description: newDesc,
employeeIds: newEmployeeIds,
originalEmployeeIds: originalEmployeeIds,
);
if (success) {
final directoryController = Get.find<DirectoryController>();
await directoryController.fetchBuckets();
Navigator.of(context).pop();
}
}
Widget _formContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameController,
decoration: _inputDecoration('Bucket Name'),
),
MySpacing.height(16),
TextField(
controller: descController,
maxLines: 2,
decoration: _inputDecoration('Description'),
),
MySpacing.height(20),
MyText.labelLarge('Shared With', fontWeight: 600),
MySpacing.height(8),
Obx(() => TextField(
controller: searchController,
onChanged: (value) => searchText.value = value.toLowerCase(),
decoration: InputDecoration(
hintText: 'Search employee...',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: searchText.value.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
searchController.clear();
searchText.value = '';
},
)
: null,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
)),
MySpacing.height(8),
Obx(() {
final filtered = allEmployees.where((emp) {
final fullName = '${emp.firstName} ${emp.lastName}'.toLowerCase();
return fullName.contains(searchText.value);
}).toList();
return SizedBox(
height: 180,
child: ListView.separated(
itemCount: filtered.length,
separatorBuilder: (_, __) => const SizedBox(height: 2),
itemBuilder: (context, index) {
final emp = filtered[index];
final fullName = '${emp.firstName} ${emp.lastName}'.trim();
return Obx(() => Theme(
data: Theme.of(context).copyWith(
unselectedWidgetColor: Colors.grey.shade500,
checkboxTheme: CheckboxThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4)),
side: const BorderSide(color: Colors.grey),
fillColor:
MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.selected)) {
return Colors.blueAccent;
}
return Colors.white;
}),
checkColor: MaterialStateProperty.all(Colors.white),
),
),
child: CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
visualDensity: const VisualDensity(vertical: -4),
controlAffinity: ListTileControlAffinity.leading,
value: selectedIds.contains(emp.id),
onChanged: emp.id == ownerId
? null
: (val) {
if (val == true) {
selectedIds.add(emp.id);
} else {
selectedIds.remove(emp.id);
}
},
title: Row(
children: [
Expanded(
child: MyText.bodyMedium(
fullName.isNotEmpty ? fullName : 'Unnamed',
fontWeight: 600,
),
),
if (emp.id == ownerId)
Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(4),
),
child: MyText.labelSmall(
"Owner",
fontWeight: 600,
color: Colors.red,
),
),
],
),
subtitle: emp.jobRole.isNotEmpty
? MyText.bodySmall(
emp.jobRole,
color: Colors.grey.shade600,
)
: null,
),
));
},
),
);
}),
],
);
}
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) { builder: (context) {
return SingleChildScrollView( return BaseBottomSheet(
padding: MediaQuery.of(context).viewInsets, title: "Edit Bucket",
child: Container( onCancel: () => Navigator.pop(context),
decoration: BoxDecoration( onSubmit: _handleSubmit,
color: Theme.of(context).cardColor, child: _formContent(),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, -2),
),
],
),
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
),
MySpacing.height(12),
Center(
child: MyText.titleMedium('Edit Bucket', fontWeight: 700),
),
MySpacing.height(24),
// Bucket Name
TextField(
controller: nameController,
decoration: _inputDecoration('Bucket Name'),
),
MySpacing.height(16),
// Description
TextField(
controller: descController,
maxLines: 2,
decoration: _inputDecoration('Description'),
),
MySpacing.height(20),
// Shared With
Align(
alignment: Alignment.centerLeft,
child: MyText.labelLarge('Shared With', fontWeight: 600),
),
MySpacing.height(8),
// Search
Obx(() => TextField(
controller: searchController,
onChanged: (value) =>
searchText.value = value.toLowerCase(),
decoration: InputDecoration(
hintText: 'Search employee...',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: searchText.value.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
searchController.clear();
searchText.value = '';
},
)
: null,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
borderSide:
BorderSide(color: Colors.blueAccent, width: 1.5),
),
),
)),
MySpacing.height(8),
// Employee list
Obx(() {
final filtered = allEmployees.where((emp) {
final fullName =
'${emp.firstName} ${emp.lastName}'.toLowerCase();
return fullName.contains(searchText.value);
}).toList();
return SizedBox(
height: 180,
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final emp = filtered[index];
final fullName =
'${emp.firstName} ${emp.lastName}'.trim();
return Obx(() => Theme(
data: Theme.of(context).copyWith(
checkboxTheme: CheckboxThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: const BorderSide(
color: Colors.black, width: 2),
fillColor:
MaterialStateProperty.resolveWith<Color>(
(states) {
if (states
.contains(MaterialState.selected)) {
return Colors.blueAccent;
}
return Colors.transparent;
}),
checkColor:
MaterialStateProperty.all(Colors.white),
),
),
child: CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
visualDensity:
const VisualDensity(vertical: -4),
controlAffinity:
ListTileControlAffinity.leading,
value: selectedIds.contains(emp.id),
onChanged: emp.id == ownerId
? null
: (val) {
if (val == true) {
selectedIds.add(emp.id);
} else {
selectedIds.remove(emp.id);
}
},
title: Text(
fullName.isNotEmpty ? fullName : 'Unnamed',
style: const TextStyle(fontSize: 13),
),
),
));
},
),
);
}),
MySpacing.height(24),
// Action Buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.close, color: Colors.red),
label: MyText.bodyMedium("Cancel",
color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 12),
),
),
),
MySpacing.width(12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final newName = nameController.text.trim();
final newDesc = descController.text.trim();
final newEmployeeIds = selectedIds.toList()..sort();
final originalEmployeeIds = [...bucket.employeeIds]
..sort();
final nameChanged = newName != bucket.name;
final descChanged = newDesc != bucket.description;
final employeeChanged = !(ListEquality()
.equals(newEmployeeIds, originalEmployeeIds));
if (!nameChanged &&
!descChanged &&
!employeeChanged) {
showAppSnackbar(
title: "No Changes",
message:
"No changes were made to update the bucket.",
type: SnackbarType.warning,
);
return;
}
final success = await controller.updateBucket(
id: bucket.id,
name: newName,
description: newDesc,
employeeIds: newEmployeeIds,
originalEmployeeIds: originalEmployeeIds,
);
if (success) {
final directoryController =
Get.find<DirectoryController>();
await directoryController.fetchBuckets();
Navigator.of(context).pop();
}
},
icon: const Icon(Icons.check_circle_outline,
color: Colors.white),
label: MyText.bodyMedium("Save",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 12),
),
),
),
],
),
],
),
),
); );
}, },
); );

View File

@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:marco/controller/dashboard/add_employee_controller.dart'; import 'package:marco/controller/dashboard/add_employee_controller.dart';
import 'package:marco/controller/dashboard/employees_screen_controller.dart'; import 'package:marco/controller/dashboard/employees_screen_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AddEmployeeBottomSheet extends StatefulWidget { class AddEmployeeBottomSheet extends StatefulWidget {
@override @override
@ -18,69 +18,110 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin { with UIMixin {
final AddEmployeeController _controller = Get.put(AddEmployeeController()); final AddEmployeeController _controller = Get.put(AddEmployeeController());
late TextEditingController genderController;
late TextEditingController roleController;
@override @override
void initState() { Widget build(BuildContext context) {
super.initState(); return GetBuilder<AddEmployeeController>(
genderController = TextEditingController(); init: _controller,
roleController = TextEditingController(); builder: (_) {
} return BaseBottomSheet(
title: "Add Employee",
RelativeRect _popupMenuPosition(BuildContext context) { onCancel: () => Navigator.pop(context),
final RenderBox overlay = onSubmit: _handleSubmit,
Overlay.of(context).context.findRenderObject() as RenderBox; child: Form(
return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0); key: _controller.basicValidator.formKey,
} child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
void _showGenderPopup(BuildContext context) async { children: [
final selected = await showMenu<Gender>( _sectionLabel("Personal Info"),
context: context, MySpacing.height(16),
position: _popupMenuPosition(context), _inputWithIcon(
items: Gender.values.map((gender) { label: "First Name",
return PopupMenuItem<Gender>( hint: "e.g., John",
value: gender, icon: Icons.person,
child: Text(gender.name.capitalizeFirst!), controller:
_controller.basicValidator.getController('first_name')!,
validator:
_controller.basicValidator.getValidation('first_name'),
),
MySpacing.height(16),
_inputWithIcon(
label: "Last Name",
hint: "e.g., Doe",
icon: Icons.person_outline,
controller:
_controller.basicValidator.getController('last_name')!,
validator:
_controller.basicValidator.getValidation('last_name'),
),
MySpacing.height(16),
_sectionLabel("Contact Details"),
MySpacing.height(16),
_buildPhoneInput(context),
MySpacing.height(24),
_sectionLabel("Other Details"),
MySpacing.height(16),
_buildDropdownField(
label: "Gender",
value: _controller.selectedGender?.name.capitalizeFirst ?? '',
hint: "Select Gender",
onTap: () => _showGenderPopup(context),
),
MySpacing.height(16),
_buildDropdownField(
label: "Role",
value: _controller.roles.firstWhereOrNull((role) =>
role['id'] == _controller.selectedRoleId)?['name'] ??
"",
hint: "Select Role",
onTap: () => _showRolePopup(context),
),
],
),
),
); );
}).toList(), },
); );
}
if (selected != null) { // Submit logic
_controller.onGenderSelected(selected); Future<void> _handleSubmit() async {
final result = await _controller.createEmployees();
if (result != null && result['success'] == true) {
final employeeData = result['data']; // Safe now
final employeeController = Get.find<EmployeesScreenController>();
final projectId = employeeController.selectedProjectId;
if (projectId == null) {
await employeeController.fetchAllEmployees();
} else {
await employeeController.fetchEmployeesByProject(projectId);
}
employeeController.update(['employee_screen_controller']);
_controller.basicValidator.getController("first_name")?.clear();
_controller.basicValidator.getController("last_name")?.clear();
_controller.basicValidator.getController("phone_number")?.clear();
_controller.selectedGender = null;
_controller.selectedRoleId = null;
_controller.update(); _controller.update();
Navigator.pop(context, employeeData);
} }
} }
void _showRolePopup(BuildContext context) async { // Section label widget
final selected = await showMenu<String>( Widget _sectionLabel(String title) => Column(
context: context, crossAxisAlignment: CrossAxisAlignment.start,
position: _popupMenuPosition(context), children: [
items: _controller.roles.map((role) { MyText.labelLarge(title, fontWeight: 600),
return PopupMenuItem<String>( MySpacing.height(4),
value: role['id'], Divider(thickness: 1, color: Colors.grey.shade200),
child: Text(role['name']), ],
); );
}).toList(),
);
if (selected != null) {
_controller.onRoleSelected(selected);
_controller.update();
}
}
Widget _sectionLabel(String title) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelLarge(title, fontWeight: 600),
MySpacing.height(4),
Divider(thickness: 1, color: Colors.grey.shade200),
],
);
}
// Input field with icon
Widget _inputWithIcon({ Widget _inputWithIcon({
required String label, required String label,
required String hint, required String hint,
@ -104,6 +145,124 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
); );
} }
// Phone input with country code selector
Widget _buildPhoneInput(BuildContext context) {
return Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
),
child: PopupMenuButton<Map<String, String>>(
onSelected: (country) {
_controller.selectedCountryCode = country['code']!;
_controller.update();
},
itemBuilder: (context) => [
PopupMenuItem(
enabled: false,
padding: EdgeInsets.zero,
child: SizedBox(
height: 200,
width: 100,
child: ListView(
children: _controller.countries.map((country) {
return ListTile(
dense: true,
title: Text("${country['name']} (${country['code']})"),
onTap: () => Navigator.pop(context, country),
);
}).toList(),
),
),
),
],
child: Row(
children: [
Text(_controller.selectedCountryCode),
const Icon(Icons.arrow_drop_down),
],
),
),
),
MySpacing.width(12),
Expanded(
child: TextFormField(
controller:
_controller.basicValidator.getController('phone_number'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Phone number is required";
}
final digitsOnly = value.trim();
final minLength = _controller
.minDigitsPerCountry[_controller.selectedCountryCode] ??
7;
final maxLength = _controller
.maxDigitsPerCountry[_controller.selectedCountryCode] ??
15;
if (!RegExp(r'^[0-9]+$').hasMatch(digitsOnly)) {
return "Only digits allowed";
}
if (digitsOnly.length < minLength ||
digitsOnly.length > maxLength) {
return "Between $minLength$maxLength digits";
}
return null;
},
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(15),
],
decoration: _inputDecoration("e.g., 9876543210").copyWith(
suffixIcon: IconButton(
icon: const Icon(Icons.contacts),
onPressed: () => _controller.pickContact(context),
),
),
),
),
],
);
}
// Gender/Role field (read-only dropdown)
Widget _buildDropdownField({
required String label,
required String value,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(text: value),
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
],
);
}
// Common input decoration
InputDecoration _inputDecoration(String hint) { InputDecoration _inputDecoration(String hint) {
return InputDecoration( return InputDecoration(
hintText: hint, hintText: hint,
@ -120,311 +279,53 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
), ),
contentPadding: MySpacing.all(16), contentPadding: MySpacing.all(16),
); );
} }
@override // Gender popup menu
Widget build(BuildContext context) { void _showGenderPopup(BuildContext context) async {
final theme = Theme.of(context); final selected = await showMenu<Gender>(
context: context,
return GetBuilder<AddEmployeeController>( position: _popupMenuPosition(context),
init: _controller, items: Gender.values.map((gender) {
builder: (_) { return PopupMenuItem<Gender>(
return SingleChildScrollView( value: gender,
padding: MediaQuery.of(context).viewInsets, child: Text(gender.name.capitalizeFirst!),
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, -2))
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag Handle
Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
MySpacing.height(12),
Text("Add Employee",
style: MyTextStyle.titleLarge(fontWeight: 700)),
MySpacing.height(24),
Form(
key: _controller.basicValidator.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel("Personal Info"),
MySpacing.height(16),
_inputWithIcon(
label: "First Name",
hint: "e.g., John",
icon: Icons.person,
controller: _controller.basicValidator
.getController('first_name')!,
validator: _controller.basicValidator
.getValidation('first_name'),
),
MySpacing.height(16),
_inputWithIcon(
label: "Last Name",
hint: "e.g., Doe",
icon: Icons.person_outline,
controller: _controller.basicValidator
.getController('last_name')!,
validator: _controller.basicValidator
.getValidation('last_name'),
),
MySpacing.height(16),
_sectionLabel("Contact Details"),
MySpacing.height(16),
MyText.labelMedium("Phone Number"),
MySpacing.height(8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
),
child: PopupMenuButton<Map<String, String>>(
onSelected: (country) {
_controller.selectedCountryCode =
country['code']!;
_controller.update();
},
itemBuilder: (context) => [
PopupMenuItem(
enabled: false,
padding: EdgeInsets.zero,
child: SizedBox(
height: 200,
width: 100,
child: ListView(
children: _controller.countries
.map((country) {
return ListTile(
dense: true,
title: Text(
"${country['name']} (${country['code']})"),
onTap: () =>
Navigator.pop(context, country),
);
}).toList(),
),
),
),
],
child: Row(
children: [
Text(_controller.selectedCountryCode),
const Icon(Icons.arrow_drop_down),
],
),
),
),
MySpacing.width(12),
Expanded(
child: TextFormField(
controller: _controller.basicValidator
.getController('phone_number'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Phone number is required";
}
final digitsOnly = value.trim();
final minLength = _controller
.minDigitsPerCountry[
_controller.selectedCountryCode] ??
7;
final maxLength = _controller
.maxDigitsPerCountry[
_controller.selectedCountryCode] ??
15;
if (!RegExp(r'^[0-9]+$')
.hasMatch(digitsOnly)) {
return "Only digits allowed";
}
if (digitsOnly.length < minLength ||
digitsOnly.length > maxLength) {
return "Between $minLength$maxLength digits";
}
return null;
},
keyboardType: TextInputType.phone,
inputFormatters: [
// Allow only digits
FilteringTextInputFormatter.digitsOnly,
// Limit to 10 digits
LengthLimitingTextInputFormatter(10),
],
decoration: _inputDecoration("e.g., 9876543210")
.copyWith(
suffixIcon: IconButton(
icon: const Icon(Icons.contacts),
onPressed: () =>
_controller.pickContact(context),
),
),
),
),
],
),
MySpacing.height(24),
_sectionLabel("Other Details"),
MySpacing.height(16),
MyText.labelMedium("Gender"),
MySpacing.height(8),
GestureDetector(
onTap: () => _showGenderPopup(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(
text: _controller
.selectedGender?.name.capitalizeFirst,
),
decoration:
_inputDecoration("Select Gender").copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
MySpacing.height(16),
MyText.labelMedium("Role"),
MySpacing.height(8),
GestureDetector(
onTap: () => _showRolePopup(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(
text: _controller.roles.firstWhereOrNull(
(role) =>
role['id'] ==
_controller.selectedRoleId,
)?['name'] ??
"",
),
decoration:
_inputDecoration("Select Role").copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Navigator.pop(context),
icon:
const Icon(Icons.close, color: Colors.red),
label: MyText.bodyMedium("Cancel",
color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 14),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
if (_controller.basicValidator
.validateForm()) {
final result =
await _controller.createEmployees();
if (result != null &&
result['success'] == true) {
final employeeData = result['data'];
final employeeController =
Get.find<EmployeesScreenController>();
final projectId =
employeeController.selectedProjectId;
if (projectId == null) {
await employeeController
.fetchAllEmployees();
} else {
await employeeController
.fetchEmployeesByProject(projectId);
}
employeeController.update(
['employee_screen_controller']);
_controller.basicValidator
.getController("first_name")
?.clear();
_controller.basicValidator
.getController("last_name")
?.clear();
_controller.basicValidator
.getController("phone_number")
?.clear();
_controller.selectedGender = null;
_controller.selectedRoleId = null;
_controller.update();
Navigator.pop(context, employeeData);
}
}
},
icon: const Icon(Icons.check_circle_outline,
color: Colors.white),
label: MyText.bodyMedium("Save",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(
horizontal: 28, vertical: 14),
),
),
),
],
),
],
),
),
],
),
),
),
); );
}, }).toList(),
); );
if (selected != null) {
_controller.onGenderSelected(selected);
_controller.update();
}
}
// Role popup menu
void _showRolePopup(BuildContext context) async {
final selected = await showMenu<String>(
context: context,
position: _popupMenuPosition(context),
items: _controller.roles.map((role) {
return PopupMenuItem<String>(
value: role['id'],
child: Text(role['name']),
);
}).toList(),
);
if (selected != null) {
_controller.onRoleSelected(selected);
_controller.update();
}
}
RelativeRect _popupMenuPosition(BuildContext context) {
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0);
} }
} }

View File

@ -0,0 +1,30 @@
class EmployeeModelWithIdName {
final String id;
final String firstName;
final String lastName;
final String name;
EmployeeModelWithIdName({
required this.id,
required this.firstName,
required this.lastName,
required this.name,
});
factory EmployeeModelWithIdName.fromJson(Map<String, dynamic> json) {
return EmployeeModelWithIdName(
id: json['id']?.toString() ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
name: '${json['firstName'] ?? ''} ${json['lastName'] ?? ''}'.trim(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'firstName': name.split(' ').first,
'lastName': name.split(' ').length > 1 ? name.split(' ').last : '',
};
}
}

View File

@ -0,0 +1,719 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
Future<T?> showAddExpenseBottomSheet<T>({
bool isEdit = false,
Map<String, dynamic>? existingExpense,
}) {
return Get.bottomSheet<T>(
_AddExpenseBottomSheet(
isEdit: isEdit,
existingExpense: existingExpense,
),
isScrollControlled: true,
);
}
class _AddExpenseBottomSheet extends StatefulWidget {
final bool isEdit;
final Map<String, dynamic>? existingExpense;
const _AddExpenseBottomSheet({
this.isEdit = false,
this.existingExpense,
});
@override
State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState();
}
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final AddExpenseController controller = Get.put(AddExpenseController());
final GlobalKey _projectDropdownKey = GlobalKey();
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
final GlobalKey _paymentModeDropdownKey = GlobalKey();
void _showEmployeeList() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Colors.transparent,
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedPaidBy.value = emp,
),
);
// Optional cleanup
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
Future<void> _showOptionList<T>(
List<T> options,
String Function(T) getLabel,
ValueChanged<T> onSelected,
GlobalKey triggerKey, // add this param
) async {
final RenderBox button =
triggerKey.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
final selected = await showMenu<T>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map(
(option) => PopupMenuItem<T>(
value: option,
child: Text(getLabel(option)),
),
)
.toList(),
);
if (selected != null) onSelected(selected);
}
@override
Widget build(BuildContext context) {
return Obx(() {
return BaseBottomSheet(
title: widget.isEdit ? "Edit Expense" : "Add Expense",
isSubmitting: controller.isSubmitting.value,
onCancel: Get.back,
onSubmit: () {
if (!controller.isSubmitting.value) {
controller.submitOrUpdateExpense();
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDropdown<String>(
icon: Icons.work_outline,
title: "Project",
requiredField: true,
value: controller.selectedProject.value.isEmpty
? "Select Project"
: controller.selectedProject.value,
onTap: () => _showOptionList<String>(
controller.globalProjects.toList(),
(p) => p,
(val) => controller.selectedProject.value = val,
_projectDropdownKey, // pass the relevant GlobalKey here
),
dropdownKey: _projectDropdownKey, // pass key also here
),
MySpacing.height(16),
_buildDropdown<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Type",
requiredField: true,
value: controller.selectedExpenseType.value?.name ??
"Select Expense Type",
onTap: () => _showOptionList<ExpenseTypeModel>(
controller.expenseTypes.toList(),
(e) => e.name,
(val) => controller.selectedExpenseType.value = val,
_expenseTypeDropdownKey,
),
dropdownKey: _expenseTypeDropdownKey,
),
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
true)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
icon: Icons.people_outline,
title: "No. of Persons",
requiredField: true,
),
MySpacing.height(6),
_CustomTextField(
controller: controller.noOfPersonsController,
hint: "Enter No. of Persons",
keyboardType: TextInputType.number,
),
],
),
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.confirmation_number_outlined, title: "GST No."),
MySpacing.height(6),
_CustomTextField(
controller: controller.gstController, hint: "Enter GST No."),
MySpacing.height(16),
_buildDropdown<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
controller.paymentModes.toList(),
(p) => p.name,
(val) => controller.selectedPaymentMode.value = val,
_paymentModeDropdownKey,
),
dropdownKey: _paymentModeDropdownKey,
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.person_outline,
title: "Paid By",
requiredField: true),
MySpacing.height(6),
GestureDetector(
onTap: _showEmployeeList,
child: _TileContainer(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.selectedPaidBy.value == null
? "Select Paid By"
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.currency_rupee,
title: "Amount",
requiredField: true),
MySpacing.height(6),
_CustomTextField(
controller: controller.amountController,
hint: "Enter Amount",
keyboardType: TextInputType.number,
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name",
requiredField: true,
),
MySpacing.height(6),
_CustomTextField(
controller: controller.supplierController,
hint: "Enter Supplier Name"),
MySpacing.height(16),
_SectionTitle(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID"),
MySpacing.height(6),
_CustomTextField(
controller: controller.transactionIdController,
hint: "Enter Transaction ID"),
MySpacing.height(16),
_SectionTitle(
icon: Icons.calendar_today,
title: "Transaction Date",
requiredField: true,
),
MySpacing.height(6),
GestureDetector(
onTap: () => controller.pickTransactionDate(context),
child: AbsorbPointer(
child: _CustomTextField(
controller: controller.transactionDateController,
hint: "Select Transaction Date",
),
),
),
MySpacing.height(16),
_SectionTitle(icon: Icons.location_on_outlined, title: "Location"),
MySpacing.height(6),
TextField(
controller: controller.locationController,
decoration: InputDecoration(
hintText: "Enter Location",
filled: true,
fillColor: Colors.grey.shade100,
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
suffixIcon: controller.isFetchingLocation.value
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
icon: const Icon(Icons.my_location),
tooltip: "Use Current Location",
onPressed: controller.fetchCurrentLocation,
),
),
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.attach_file,
title: "Attachments",
requiredField: true),
MySpacing.height(6),
_AttachmentsSection(
attachments: controller.attachments,
existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment,
onRemoveExisting: (item) {
final index = controller.existingAttachments.indexOf(item);
if (index != -1) {
controller.existingAttachments[index]['isActive'] = false;
controller.existingAttachments.refresh();
}
},
onAdd: controller.pickAttachments,
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.description_outlined,
title: "Description",
requiredField: true),
MySpacing.height(6),
_CustomTextField(
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
),
],
),
);
});
}
Widget _buildDropdown<T>({
required IconData icon,
required String title,
required bool requiredField,
required String value,
required VoidCallback onTap,
required GlobalKey dropdownKey, // new param
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(icon: icon, title: title, requiredField: requiredField),
MySpacing.height(6),
_DropdownTile(
key: dropdownKey, // Pass the key here
title: value,
onTap: onTap,
),
],
);
}
}
class _SectionTitle extends StatelessWidget {
final IconData icon;
final String title;
final bool requiredField;
const _SectionTitle({
required this.icon,
required this.title,
this.requiredField = false,
});
@override
Widget build(BuildContext context) {
final color = Colors.grey[700];
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 8),
RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
children: [
TextSpan(text: title),
if (requiredField)
const TextSpan(
text: ' *',
style: TextStyle(color: Colors.red),
),
],
),
),
],
);
}
}
class _CustomTextField extends StatelessWidget {
final TextEditingController controller;
final String hint;
final int maxLines;
final TextInputType keyboardType;
const _CustomTextField({
required this.controller,
required this.hint,
this.maxLines = 1,
this.keyboardType = TextInputType.text,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
),
),
);
}
}
class _DropdownTile extends StatelessWidget {
final String title;
final VoidCallback onTap;
const _DropdownTile({
required this.title,
required this.onTap,
Key? key, // Add optional key parameter
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 14, color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}
}
class _TileContainer extends StatelessWidget {
final Widget child;
const _TileContainer({required this.child});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade400),
),
child: child,
);
}
}
class _AttachmentsSection extends StatelessWidget {
final RxList<File> attachments;
final RxList<Map<String, dynamic>> existingAttachments;
final ValueChanged<File> onRemoveNew;
final ValueChanged<Map<String, dynamic>>? onRemoveExisting;
final VoidCallback onAdd;
const _AttachmentsSection({
required this.attachments,
required this.existingAttachments,
required this.onRemoveNew,
this.onRemoveExisting,
required this.onAdd,
});
@override
Widget build(BuildContext context) {
return Obx(() {
final activeExistingAttachments =
existingAttachments.where((doc) => doc['isActive'] != false).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (activeExistingAttachments.isNotEmpty) ...[
Text(
"Existing Attachments",
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: activeExistingAttachments.map((doc) {
final isImage =
doc['contentType']?.toString().startsWith('image/') ??
false;
final url = doc['url'];
final fileName = doc['fileName'] ?? 'Unnamed';
return Stack(
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () async {
if (isImage) {
final imageDocs = activeExistingAttachments
.where((d) => (d['contentType']
?.toString()
.startsWith('image/') ??
false))
.toList();
final initialIndex =
imageDocs.indexWhere((d) => d == doc);
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources:
imageDocs.map((e) => e['url']).toList(),
initialIndex: initialIndex,
),
);
} else {
if (url != null && await canLaunchUrlString(url)) {
await launchUrlString(
url,
mode: LaunchMode.externalApplication,
);
} else {
showAppSnackbar(
title: 'Error',
message: 'Could not open the document.',
type: SnackbarType.error,
);
}
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
color: Colors.grey.shade100,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isImage ? Icons.image : Icons.insert_drive_file,
size: 20,
color: Colors.grey[600],
),
const SizedBox(width: 7),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
fileName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
),
],
),
),
),
if (onRemoveExisting != null)
Positioned(
top: -6,
right: -6,
child: IconButton(
icon: const Icon(Icons.close,
color: Colors.red, size: 18),
onPressed: () {
onRemoveExisting?.call(doc);
},
),
),
],
);
}).toList(),
),
const SizedBox(height: 16),
],
// New attachments section
Wrap(
spacing: 8,
runSpacing: 8,
children: [
...attachments.map((file) => _AttachmentTile(
file: file,
onRemove: () => onRemoveNew(file),
)),
GestureDetector(
onTap: onAdd,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade100,
),
child: const Icon(Icons.add, size: 30, color: Colors.grey),
),
),
],
),
],
);
});
}
}
class _AttachmentTile extends StatelessWidget {
final File file;
final VoidCallback onRemove;
const _AttachmentTile({required this.file, required this.onRemove});
@override
Widget build(BuildContext context) {
final fileName = file.path.split('/').last;
final extension = fileName.split('.').last.toLowerCase();
final isImage = ['jpg', 'jpeg', 'png'].contains(extension);
IconData fileIcon = Icons.insert_drive_file;
Color iconColor = Colors.blueGrey;
switch (extension) {
case 'pdf':
fileIcon = Icons.picture_as_pdf;
iconColor = Colors.redAccent;
break;
case 'doc':
case 'docx':
fileIcon = Icons.description;
iconColor = Colors.blueAccent;
break;
case 'xls':
case 'xlsx':
fileIcon = Icons.table_chart;
iconColor = Colors.green;
break;
case 'txt':
fileIcon = Icons.article;
iconColor = Colors.grey;
break;
}
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
color: Colors.grey.shade100,
),
child: isImage
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, fit: BoxFit.cover),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(fileIcon, color: iconColor, size: 30),
const SizedBox(height: 4),
Text(
extension.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: iconColor),
overflow: TextOverflow.ellipsis,
),
],
),
),
Positioned(
top: -6,
right: -6,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.red, size: 18),
onPressed: onRemove,
),
),
],
);
}
}

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
Future<String?> showCommentBottomSheet(BuildContext context, String actionText) async {
final commentController = TextEditingController();
String? errorText;
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
void submit() {
final comment = commentController.text.trim();
if (comment.isEmpty) {
setModalState(() => errorText = 'Comment cannot be empty.');
return;
}
Navigator.of(context).pop(comment);
}
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: BaseBottomSheet(
title: 'Add Comment for ${_capitalizeFirstLetter(actionText)}',
onCancel: () => Navigator.of(context).pop(),
onSubmit: submit,
isSubmitting: false,
submitText: 'Submit',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: commentController,
maxLines: 4,
decoration: InputDecoration(
hintText: 'Type your comment here...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Colors.grey.shade100,
errorText: errorText,
),
onChanged: (_) {
if (errorText != null) {
setModalState(() => errorText = null);
}
},
),
],
),
),
);
},
);
},
);
}
String _capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);

View File

@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/model/employee_model.dart';
class ReusableEmployeeSelectorBottomSheet extends StatelessWidget {
final TextEditingController searchController;
final RxList<EmployeeModel> searchResults;
final RxBool isSearching;
final void Function(String) onSearch;
final void Function(EmployeeModel) onSelect;
const ReusableEmployeeSelectorBottomSheet({
super.key,
required this.searchController,
required this.searchResults,
required this.isSearching,
required this.onSearch,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: "Search Employee",
onCancel: () => Get.back(),
onSubmit: () {},
showButtons: false,
child: Obx(() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: searchController,
decoration: InputDecoration(
hintText: "Search by name, email...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
onChanged: onSearch,
),
MySpacing.height(12),
SizedBox(
height: 400,
child: isSearching.value
? const Center(child: CircularProgressIndicator())
: searchResults.isEmpty
? Center(
child: MyText.bodyMedium(
"No employees found.",
fontWeight: 500,
),
)
: ListView.builder(
itemCount: searchResults.length,
itemBuilder: (_, index) {
final emp = searchResults[index];
final fullName =
'${emp.firstName} ${emp.lastName}'.trim();
return ListTile(
title: MyText.bodyLarge(
fullName.isNotEmpty ? fullName : "Unnamed",
fontWeight: 600,
),
onTap: () {
onSelect(emp);
Get.back();
},
);
},
),
),
],
);
}),
);
}
}

View File

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class EmployeeSelectorBottomSheet extends StatefulWidget {
final RxList<EmployeeModel> selectedEmployees;
final Future<List<EmployeeModel>> Function(String) searchEmployees;
final String title;
const EmployeeSelectorBottomSheet({
super.key,
required this.selectedEmployees,
required this.searchEmployees,
this.title = "Select Employees",
});
@override
State<EmployeeSelectorBottomSheet> createState() =>
_EmployeeSelectorBottomSheetState();
}
class _EmployeeSelectorBottomSheetState
extends State<EmployeeSelectorBottomSheet> {
final TextEditingController _searchController = TextEditingController();
final RxBool isSearching = false.obs;
final RxList<EmployeeModel> searchResults = <EmployeeModel>[].obs;
@override
void initState() {
super.initState();
// Initial fetch (empty text gets all/none as you wish)
_searchEmployees('');
}
void _searchEmployees(String query) async {
isSearching.value = true;
List<EmployeeModel> results = await widget.searchEmployees(query);
searchResults.assignAll(results);
isSearching.value = false;
}
void _submitSelection() =>
Get.back(result: widget.selectedEmployees.toList());
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: widget.title,
onCancel: () => Get.back(),
onSubmit: _submitSelection,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Chips
Obx(() => widget.selectedEmployees.isEmpty
? const SizedBox.shrink()
: Wrap(
spacing: 8,
children: widget.selectedEmployees
.map(
(emp) => Chip(
label: MyText(emp.name),
onDeleted: () =>
widget.selectedEmployees.remove(emp),
),
)
.toList(),
)),
MySpacing.height(8),
// Search box
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Search Employees...",
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
prefixIcon: Icon(Icons.search),
),
onChanged: _searchEmployees,
),
MySpacing.height(12),
SizedBox(
height: 320, // CHANGE AS PER DESIGN!
child: Obx(() {
if (isSearching.value) {
return Center(child: CircularProgressIndicator());
}
if (searchResults.isEmpty) {
return Padding(
padding: EdgeInsets.all(20),
child:
MyText('No results', style: MyTextStyle.bodyMedium()),
);
}
return ListView.separated(
itemCount: searchResults.length,
separatorBuilder: (_, __) => Divider(height: 1),
itemBuilder: (context, index) {
final emp = searchResults[index];
final isSelected = widget.selectedEmployees.contains(emp);
return ListTile(
title: MyText(emp.name),
trailing: isSelected
? Icon(Icons.check_circle, color: Colors.indigo)
: Icon(Icons.radio_button_unchecked,
color: Colors.grey),
onTap: () {
if (isSelected) {
widget.selectedEmployees.remove(emp);
} else {
widget.selectedEmployees.add(emp);
}
});
},
);
}),
),
],
));
}
}

View File

@ -0,0 +1,278 @@
class ExpenseDetailModel {
final String id;
final Project project;
final ExpensesType expensesType;
final PaymentMode paymentMode;
final Person paidBy;
final Person createdBy;
final String transactionDate;
final String createdAt;
final String supplerName;
final double amount;
final ExpenseStatus status;
final List<ExpenseStatus> nextStatus;
final bool preApproved;
final String transactionId;
final String description;
final String location;
final List<ExpenseDocument> documents;
final String? gstNumber;
final int noOfPersons;
final bool isActive;
ExpenseDetailModel({
required this.id,
required this.project,
required this.expensesType,
required this.paymentMode,
required this.paidBy,
required this.createdBy,
required this.transactionDate,
required this.createdAt,
required this.supplerName,
required this.amount,
required this.status,
required this.nextStatus,
required this.preApproved,
required this.transactionId,
required this.description,
required this.location,
required this.documents,
this.gstNumber,
required this.noOfPersons,
required this.isActive,
});
factory ExpenseDetailModel.fromJson(Map<String, dynamic> json) {
return ExpenseDetailModel(
id: json['id'] ?? '',
project: json['project'] != null ? Project.fromJson(json['project']) : Project.empty(),
expensesType: json['expensesType'] != null ? ExpensesType.fromJson(json['expensesType']) : ExpensesType.empty(),
paymentMode: json['paymentMode'] != null ? PaymentMode.fromJson(json['paymentMode']) : PaymentMode.empty(),
paidBy: json['paidBy'] != null ? Person.fromJson(json['paidBy']) : Person.empty(),
createdBy: json['createdBy'] != null ? Person.fromJson(json['createdBy']) : Person.empty(),
transactionDate: json['transactionDate'] ?? '',
createdAt: json['createdAt'] ?? '',
supplerName: json['supplerName'] ?? '',
amount: (json['amount'] as num?)?.toDouble() ?? 0.0,
status: json['status'] != null ? ExpenseStatus.fromJson(json['status']) : ExpenseStatus.empty(),
nextStatus: (json['nextStatus'] as List?)?.map((e) => ExpenseStatus.fromJson(e)).toList() ?? [],
preApproved: json['preApproved'] ?? false,
transactionId: json['transactionId'] ?? '',
description: json['description'] ?? '',
location: json['location'] ?? '',
documents: (json['documents'] as List?)?.map((e) => ExpenseDocument.fromJson(e)).toList() ?? [],
gstNumber: json['gstNumber']?.toString(),
noOfPersons: json['noOfPersons'] ?? 0,
isActive: json['isActive'] ?? true,
);
}
}
class Project {
final String id;
final String name;
final String shortName;
final String projectAddress;
final String contactPerson;
final String startDate;
final String endDate;
final String projectStatusId;
Project({
required this.id,
required this.name,
required this.shortName,
required this.projectAddress,
required this.contactPerson,
required this.startDate,
required this.endDate,
required this.projectStatusId,
});
factory Project.fromJson(Map<String, dynamic> json) {
return Project(
id: json['id'] ?? '',
name: json['name'] ?? '',
shortName: json['shortName'] ?? '',
projectAddress: json['projectAddress'] ?? '',
contactPerson: json['contactPerson'] ?? '',
startDate: json['startDate'] ?? '',
endDate: json['endDate'] ?? '',
projectStatusId: json['projectStatusId'] ?? '',
);
}
factory Project.empty() => Project(
id: '',
name: '',
shortName: '',
projectAddress: '',
contactPerson: '',
startDate: '',
endDate: '',
projectStatusId: '',
);
}
class ExpensesType {
final String id;
final String name;
final bool noOfPersonsRequired;
final String description;
ExpensesType({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.description,
});
factory ExpensesType.fromJson(Map<String, dynamic> json) {
return ExpensesType(
id: json['id'] ?? '',
name: json['name'] ?? '',
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
description: json['description'] ?? '',
);
}
factory ExpensesType.empty() => ExpensesType(
id: '',
name: '',
noOfPersonsRequired: false,
description: '',
);
}
class PaymentMode {
final String id;
final String name;
final String description;
PaymentMode({
required this.id,
required this.name,
required this.description,
});
factory PaymentMode.fromJson(Map<String, dynamic> json) {
return PaymentMode(
id: json['id'] ?? '',
name: json['name'] ?? '',
description: json['description'] ?? '',
);
}
factory PaymentMode.empty() => PaymentMode(
id: '',
name: '',
description: '',
);
}
class Person {
final String id;
final String firstName;
final String lastName;
final String photo;
final String jobRoleId;
final String jobRoleName;
Person({
required this.id,
required this.firstName,
required this.lastName,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory Person.fromJson(Map<String, dynamic> json) {
return Person(
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
photo: json['photo'] is String ? json['photo'] : '',
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
factory Person.empty() => Person(
id: '',
firstName: '',
lastName: '',
photo: '',
jobRoleId: '',
jobRoleName: '',
);
}
class ExpenseStatus {
final String id;
final String name;
final String displayName;
final String description;
final String? permissionIds;
final String color;
final bool isSystem;
ExpenseStatus({
required this.id,
required this.name,
required this.displayName,
required this.description,
required this.permissionIds,
required this.color,
required this.isSystem,
});
factory ExpenseStatus.fromJson(Map<String, dynamic> json) {
return ExpenseStatus(
id: json['id'] ?? '',
name: json['name'] ?? '',
displayName: json['displayName'] ?? '',
description: json['description'] ?? '',
permissionIds: json['permissionIds']?.toString(),
color: json['color'] ?? '',
isSystem: json['isSystem'] ?? false,
);
}
factory ExpenseStatus.empty() => ExpenseStatus(
id: '',
name: '',
displayName: '',
description: '',
permissionIds: null,
color: '',
isSystem: false,
);
}
class ExpenseDocument {
final String documentId;
final String fileName;
final String contentType;
final String preSignedUrl;
final String thumbPreSignedUrl;
ExpenseDocument({
required this.documentId,
required this.fileName,
required this.contentType,
required this.preSignedUrl,
required this.thumbPreSignedUrl,
});
factory ExpenseDocument.fromJson(Map<String, dynamic> json) {
return ExpenseDocument(
documentId: json['documentId'] ?? '',
fileName: json['fileName'] ?? '',
contentType: json['contentType'] ?? '',
preSignedUrl: json['preSignedUrl'] ?? '',
thumbPreSignedUrl: json['thumbPreSignedUrl'] ?? '',
);
}
}

View File

@ -0,0 +1,405 @@
import 'dart:convert';
/// Parse the entire response
ExpenseResponse expenseResponseFromJson(String str) =>
ExpenseResponse.fromJson(json.decode(str));
String expenseResponseToJson(ExpenseResponse data) =>
json.encode(data.toJson());
class ExpenseResponse {
final bool success;
final String message;
final ExpenseData data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
ExpenseResponse({
required this.success,
required this.message,
required this.data,
required this.errors,
required this.statusCode,
required this.timestamp,
});
factory ExpenseResponse.fromJson(Map<String, dynamic> json) {
final dataField = json["data"];
return ExpenseResponse(
success: json["success"] ?? false,
message: json["message"] ?? '',
data: (dataField is Map<String, dynamic>)
? ExpenseData.fromJson(dataField)
: ExpenseData.empty(),
errors: json["errors"],
statusCode: json["statusCode"] ?? 0,
timestamp: DateTime.tryParse(json["timestamp"] ?? '') ?? DateTime.now(),
);
}
Map<String, dynamic> toJson() => {
"success": success,
"message": message,
"data": data.toJson(),
"errors": errors,
"statusCode": statusCode,
"timestamp": timestamp.toIso8601String(),
};
}
class ExpenseData {
final Filter? filter;
final int currentPage;
final int totalPages;
final int totalEntites;
final List<ExpenseModel> data;
ExpenseData({
required this.filter,
required this.currentPage,
required this.totalPages,
required this.totalEntites,
required this.data,
});
factory ExpenseData.fromJson(Map<String, dynamic> json) => ExpenseData(
filter: json["filter"] != null ? Filter.fromJson(json["filter"]) : null,
currentPage: json["currentPage"] ?? 0,
totalPages: json["totalPages"] ?? 0,
totalEntites: json["totalEntites"] ?? 0,
data: (json["data"] as List<dynamic>? ?? [])
.map((x) => ExpenseModel.fromJson(x))
.toList(),
);
factory ExpenseData.empty() => ExpenseData(
filter: null,
currentPage: 0,
totalPages: 0,
totalEntites: 0,
data: [],
);
Map<String, dynamic> toJson() => {
"filter": filter?.toJson(),
"currentPage": currentPage,
"totalPages": totalPages,
"totalEntites": totalEntites,
"data": List<dynamic>.from(data.map((x) => x.toJson())),
};
}
class Filter {
final List<String> projectIds;
final List<String> statusIds;
final List<String> createdByIds;
final List<String> paidById;
final DateTime? startDate;
final DateTime? endDate;
Filter({
required this.projectIds,
required this.statusIds,
required this.createdByIds,
required this.paidById,
required this.startDate,
required this.endDate,
});
factory Filter.fromJson(Map<String, dynamic> json) => Filter(
projectIds: List<String>.from(json["projectIds"] ?? []),
statusIds: List<String>.from(json["statusIds"] ?? []),
createdByIds: List<String>.from(json["createdByIds"] ?? []),
paidById: List<String>.from(json["paidById"] ?? []),
startDate:
json["startDate"] != null ? DateTime.tryParse(json["startDate"]) : null,
endDate:
json["endDate"] != null ? DateTime.tryParse(json["endDate"]) : null,
);
Map<String, dynamic> toJson() => {
"projectIds": projectIds,
"statusIds": statusIds,
"createdByIds": createdByIds,
"paidById": paidById,
"startDate": startDate?.toIso8601String(),
"endDate": endDate?.toIso8601String(),
};
}
// --- ExpenseModel and other classes remain same as you wrote ---
// I will include them here for completeness.
class ExpenseModel {
final String id;
final Project project;
final ExpenseType expensesType;
final PaymentMode paymentMode;
final PaidBy paidBy;
final CreatedBy createdBy;
final DateTime transactionDate;
final DateTime createdAt;
final String supplerName;
final double amount;
final Status status;
final List<Status> nextStatus;
final bool preApproved;
ExpenseModel({
required this.id,
required this.project,
required this.expensesType,
required this.paymentMode,
required this.paidBy,
required this.createdBy,
required this.transactionDate,
required this.createdAt,
required this.supplerName,
required this.amount,
required this.status,
required this.nextStatus,
required this.preApproved,
});
factory ExpenseModel.fromJson(Map<String, dynamic> json) => ExpenseModel(
id: json["id"] ?? '',
project: Project.fromJson(json["project"] ?? {}),
expensesType: ExpenseType.fromJson(json["expensesType"] ?? {}),
paymentMode: PaymentMode.fromJson(json["paymentMode"] ?? {}),
paidBy: PaidBy.fromJson(json["paidBy"] ?? {}),
createdBy: CreatedBy.fromJson(json["createdBy"] ?? {}),
transactionDate:
DateTime.tryParse(json["transactionDate"] ?? '') ?? DateTime.now(),
createdAt:
DateTime.tryParse(json["createdAt"] ?? '') ?? DateTime.now(),
supplerName: json["supplerName"] ?? '',
amount: (json["amount"] ?? 0).toDouble(),
status: Status.fromJson(json["status"] ?? {}),
nextStatus: (json["nextStatus"] as List<dynamic>? ?? [])
.map((x) => Status.fromJson(x))
.toList(),
preApproved: json["preApproved"] ?? false,
);
Map<String, dynamic> toJson() => {
"id": id,
"project": project.toJson(),
"expensesType": expensesType.toJson(),
"paymentMode": paymentMode.toJson(),
"paidBy": paidBy.toJson(),
"createdBy": createdBy.toJson(),
"transactionDate": transactionDate.toIso8601String(),
"createdAt": createdAt.toIso8601String(),
"supplerName": supplerName,
"amount": amount,
"status": status.toJson(),
"nextStatus": List<dynamic>.from(nextStatus.map((x) => x.toJson())),
"preApproved": preApproved,
};
}
class Project {
final String id;
final String name;
final String shortName;
final String projectAddress;
final String contactPerson;
final DateTime startDate;
final DateTime endDate;
final String projectStatusId;
Project({
required this.id,
required this.name,
required this.shortName,
required this.projectAddress,
required this.contactPerson,
required this.startDate,
required this.endDate,
required this.projectStatusId,
});
factory Project.fromJson(Map<String, dynamic> json) => Project(
id: json["id"] ?? '',
name: json["name"] ?? '',
shortName: json["shortName"] ?? '',
projectAddress: json["projectAddress"] ?? '',
contactPerson: json["contactPerson"] ?? '',
startDate:
DateTime.tryParse(json["startDate"] ?? '') ?? DateTime.now(),
endDate: DateTime.tryParse(json["endDate"] ?? '') ?? DateTime.now(),
projectStatusId: json["projectStatusId"] ?? '',
);
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"shortName": shortName,
"projectAddress": projectAddress,
"contactPerson": contactPerson,
"startDate": startDate.toIso8601String(),
"endDate": endDate.toIso8601String(),
"projectStatusId": projectStatusId,
};
}
class ExpenseType {
final String id;
final String name;
final bool noOfPersonsRequired;
final String description;
ExpenseType({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.description,
});
factory ExpenseType.fromJson(Map<String, dynamic> json) => ExpenseType(
id: json["id"] ?? '',
name: json["name"] ?? '',
noOfPersonsRequired: json["noOfPersonsRequired"] ?? false,
description: json["description"] ?? '',
);
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"noOfPersonsRequired": noOfPersonsRequired,
"description": description,
};
}
class PaymentMode {
final String id;
final String name;
final String description;
PaymentMode({
required this.id,
required this.name,
required this.description,
});
factory PaymentMode.fromJson(Map<String, dynamic> json) => PaymentMode(
id: json["id"] ?? '',
name: json["name"] ?? '',
description: json["description"] ?? '',
);
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"description": description,
};
}
class PaidBy {
final String id;
final String firstName;
final String lastName;
final String photo;
final String jobRoleId;
final String? jobRoleName;
PaidBy({
required this.id,
required this.firstName,
required this.lastName,
required this.photo,
required this.jobRoleId,
this.jobRoleName,
});
factory PaidBy.fromJson(Map<String, dynamic> json) => PaidBy(
id: json["id"] ?? '',
firstName: json["firstName"] ?? '',
lastName: json["lastName"] ?? '',
photo: json["photo"] ?? '',
jobRoleId: json["jobRoleId"] ?? '',
jobRoleName: json["jobRoleName"],
);
Map<String, dynamic> toJson() => {
"id": id,
"firstName": firstName,
"lastName": lastName,
"photo": photo,
"jobRoleId": jobRoleId,
"jobRoleName": jobRoleName,
};
}
class CreatedBy {
final String id;
final String firstName;
final String lastName;
final String photo;
final String jobRoleId;
final String? jobRoleName;
CreatedBy({
required this.id,
required this.firstName,
required this.lastName,
required this.photo,
required this.jobRoleId,
this.jobRoleName,
});
factory CreatedBy.fromJson(Map<String, dynamic> json) => CreatedBy(
id: json["id"] ?? '',
firstName: json["firstName"] ?? '',
lastName: json["lastName"] ?? '',
photo: json["photo"] ?? '',
jobRoleId: json["jobRoleId"] ?? '',
jobRoleName: json["jobRoleName"],
);
Map<String, dynamic> toJson() => {
"id": id,
"firstName": firstName,
"lastName": lastName,
"photo": photo,
"jobRoleId": jobRoleId,
"jobRoleName": jobRoleName,
};
}
class Status {
final String id;
final String name;
final String displayName;
final String description;
final String color;
final bool isSystem;
Status({
required this.id,
required this.name,
required this.displayName,
required this.description,
required this.color,
required this.isSystem,
});
factory Status.fromJson(Map<String, dynamic> json) => Status(
id: json["id"] ?? '',
name: json["name"] ?? '',
displayName: json["displayName"] ?? '',
description: json["description"] ?? '',
color: (json["color"] ?? '').replaceAll("'", ''),
isSystem: json["isSystem"] ?? false,
);
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"displayName": displayName,
"description": description,
"color": color,
"isSystem": isSystem,
};
}

View File

@ -0,0 +1,25 @@
class ExpenseStatusModel {
final String id;
final String name;
final String description;
final bool isSystem;
final bool isActive;
ExpenseStatusModel({
required this.id,
required this.name,
required this.description,
required this.isSystem,
required this.isActive,
});
factory ExpenseStatusModel.fromJson(Map<String, dynamic> json) {
return ExpenseStatusModel(
id: json['id'],
name: json['name'],
description: json['description'] ?? '',
isSystem: json['isSystem'] ?? false,
isActive: json['isActive'] ?? false,
);
}
}

View File

@ -0,0 +1,25 @@
class ExpenseTypeModel {
final String id;
final String name;
final bool noOfPersonsRequired;
final String description;
final bool isActive;
ExpenseTypeModel({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.description,
required this.isActive,
});
factory ExpenseTypeModel.fromJson(Map<String, dynamic> json) {
return ExpenseTypeModel(
id: json['id'],
name: json['name'],
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
description: json['description'] ?? '',
isActive: json['isActive'] ?? false,
);
}
}

View File

@ -0,0 +1,22 @@
class PaymentModeModel {
final String id;
final String name;
final String description;
final bool isActive;
PaymentModeModel({
required this.id,
required this.name,
required this.description,
required this.isActive,
});
factory PaymentModeModel.fromJson(Map<String, dynamic> json) {
return PaymentModeModel(
id: json['id'],
name: json['name'],
description: json['description'] ?? '',
isActive: json['isActive'] ?? false,
);
}
}

View File

@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/expense/expense_detail_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
class ReimbursementBottomSheet extends StatefulWidget {
final String expenseId;
final String statusId;
final void Function() onClose;
final Future<bool> Function({
required String comment,
required String reimburseTransactionId,
required String reimburseDate,
required String reimburseById,
required String statusId,
}) onSubmit;
const ReimbursementBottomSheet({
super.key,
required this.expenseId,
required this.onClose,
required this.onSubmit,
required this.statusId,
});
@override
State<ReimbursementBottomSheet> createState() =>
_ReimbursementBottomSheetState();
}
class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
final ExpenseDetailController controller =
Get.find<ExpenseDetailController>();
final TextEditingController commentCtrl = TextEditingController();
final TextEditingController txnCtrl = TextEditingController();
final RxString dateStr = ''.obs;
@override
void dispose() {
commentCtrl.dispose();
txnCtrl.dispose();
super.dispose();
}
void _showEmployeeList() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Colors.transparent,
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedReimbursedBy.value = emp,
),
);
// Optional cleanup
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
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: MySpacing.all(16),
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
return BaseBottomSheet(
title: "Reimbursement Info",
isSubmitting: controller.isLoading.value,
onCancel: () {
widget.onClose();
Navigator.pop(context);
},
onSubmit: () async {
if (commentCtrl.text.trim().isEmpty ||
txnCtrl.text.trim().isEmpty ||
dateStr.value.isEmpty ||
controller.selectedReimbursedBy.value == null) {
showAppSnackbar(
title: "Incomplete",
message: "Please fill all fields",
type: SnackbarType.warning,
);
return;
}
final success = await widget.onSubmit(
comment: commentCtrl.text.trim(),
reimburseTransactionId: txnCtrl.text.trim(),
reimburseDate: dateStr.value,
reimburseById: controller.selectedReimbursedBy.value!.id,
statusId: widget.statusId,
);
if (success) {
Get.back();
showAppSnackbar(
title: "Success",
message: "Reimbursement submitted",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: controller.errorMessage.value,
type: SnackbarType.error,
);
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Comment"),
MySpacing.height(8),
TextField(
controller: commentCtrl,
decoration: _inputDecoration("Enter comment"),
),
MySpacing.height(16),
MyText.labelMedium("Transaction ID"),
MySpacing.height(8),
TextField(
controller: txnCtrl,
decoration: _inputDecoration("Enter transaction ID"),
),
MySpacing.height(16),
MyText.labelMedium("Reimbursement Date"),
MySpacing.height(8),
GestureDetector(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: dateStr.value.isEmpty
? DateTime.now()
: DateFormat('yyyy-MM-dd').parse(dateStr.value),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
dateStr.value = DateFormat('yyyy-MM-dd').format(picked);
}
},
child: AbsorbPointer(
child: TextField(
controller: TextEditingController(text: dateStr.value),
decoration: _inputDecoration("Select Date").copyWith(
suffixIcon: const Icon(Icons.calendar_today),
),
),
),
),
MySpacing.height(16),
MyText.labelMedium("Reimbursed By"),
MySpacing.height(8),
GestureDetector(
onTap: _showEmployeeList,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.selectedReimbursedBy.value == null
? "Select Paid By"
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
],
),
);
});
}
}

View File

@ -17,6 +17,7 @@ import 'package:marco/view/auth/login_option_screen.dart';
import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart';
import 'package:marco/view/auth/mpin_auth_screen.dart'; import 'package:marco/view/auth/mpin_auth_screen.dart';
import 'package:marco/view/directory/directory_main_screen.dart'; import 'package:marco/view/directory/directory_main_screen.dart';
import 'package:marco/view/expense/expense_screen.dart';
class AuthMiddleware extends GetMiddleware { class AuthMiddleware extends GetMiddleware {
@override @override
@ -65,6 +66,11 @@ getPageRoute() {
name: '/dashboard/directory-main-page', name: '/dashboard/directory-main-page',
page: () => DirectoryMainScreen(), page: () => DirectoryMainScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Expense
GetPage(
name: '/dashboard/expense-main-page',
page: () => ExpenseMainScreen(),
middlewares: [AuthMiddleware()]),
// Authentication // Authentication
GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login', page: () => LoginScreen()),
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),

View File

@ -1,13 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/auth/forgot_password_controller.dart'; import 'package:marco/controller/auth/forgot_password_controller.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/images.dart'; import 'package:marco/images.dart';
class ForgotPasswordScreen extends StatefulWidget { class ForgotPasswordScreen extends StatefulWidget {
@ -22,10 +21,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
final ForgotPasswordController controller = final ForgotPasswordController controller =
Get.put(ForgotPasswordController()); Get.put(ForgotPasswordController());
late AnimationController _controller; late final AnimationController _controller;
late Animation<double> _logoAnimation; late final Animation<double> _logoAnimation;
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
bool _isLoading = false; bool _isLoading = false;
@override @override
@ -64,29 +63,9 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
SafeArea( SafeArea(
child: Center( child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 24), const SizedBox(height: 24),
ScaleTransition( _buildAnimatedLogo(),
scale: _logoAnimation,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
padding: const EdgeInsets.all(20),
child: Image.asset(Images.logoDark),
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
@ -96,36 +75,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 12),
MyText( _buildWelcomeText(),
"Welcome to Marco",
fontSize: 24,
fontWeight: 800,
color: Colors.black87,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
MyText(
"Streamline Project Management\nBoost Productivity with Automation.",
fontSize: 14,
color: Colors.black54,
textAlign: TextAlign.center,
),
if (_isBetaEnvironment) ...[ if (_isBetaEnvironment) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Container( _buildBetaBadge(),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(6),
),
child: MyText(
'BETA',
color: Colors.white,
fontWeight: 600,
fontSize: 12,
),
),
], ],
const SizedBox(height: 36), const SizedBox(height: 36),
_buildForgotCard(), _buildForgotCard(),
@ -143,6 +96,66 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
); );
} }
Widget _buildAnimatedLogo() {
return ScaleTransition(
scale: _logoAnimation,
child: Container(
width: 100,
height: 100,
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Image.asset(Images.logoDark),
),
);
}
Widget _buildWelcomeText() {
return Column(
children: [
MyText(
"Welcome to Marco",
fontSize: 24,
fontWeight: 600,
color: Colors.black87,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
MyText(
"Streamline Project Management\nBoost Productivity with Automation.",
fontSize: 14,
color: Colors.black54,
textAlign: TextAlign.center,
),
],
);
}
Widget _buildBetaBadge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(6),
),
child: MyText(
'BETA',
color: Colors.white,
fontWeight: 600,
fontSize: 12,
),
);
}
Widget _buildForgotCard() { Widget _buildForgotCard() {
return Container( return Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@ -165,7 +178,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
MyText( MyText(
'Forgot Password', 'Forgot Password',
fontSize: 20, fontSize: 20,
fontWeight: 700, fontWeight: 600,
color: Colors.black87, color: Colors.black87,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@ -177,70 +190,80 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
TextFormField( _buildEmailInput(),
validator: controller.basicValidator.getValidation('email'),
controller: controller.basicValidator.getController('email'),
keyboardType: TextInputType.emailAddress,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
labelText: "Email Address",
labelStyle: const TextStyle(color: Colors.black54),
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(LucideIcons.mail, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
),
const SizedBox(height: 32), const SizedBox(height: 32),
MyButton.rounded( _buildResetButton(),
onPressed: _isLoading ? null : _handleForgotPassword,
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
borderRadiusAll: 10,
backgroundColor: _isLoading
? contentTheme.brandRed.withOpacity(0.6)
: contentTheme.brandRed,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: MyText.bodyMedium(
'Send Reset Link',
color: Colors.white,
fontWeight: 700,
fontSize: 16,
),
),
const SizedBox(height: 20), const SizedBox(height: 20),
TextButton.icon( _buildBackButton(),
onPressed: () async => await LocalStorage.logout(),
icon: const Icon(Icons.arrow_back,
size: 18, color: Colors.redAccent),
label: MyText.bodyMedium(
'Back to Login',
color: contentTheme.brandRed,
fontWeight: 600,
fontSize: 14,
),
),
], ],
), ),
), ),
); );
} }
Widget _buildEmailInput() {
return TextFormField(
validator: controller.basicValidator.getValidation('email'),
controller: controller.basicValidator.getController('email'),
keyboardType: TextInputType.emailAddress,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
labelText: "Email Address",
labelStyle: const TextStyle(color: Colors.black54),
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(LucideIcons.mail, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
);
}
Widget _buildResetButton() {
return MyButton.rounded(
onPressed: _isLoading ? null : _handleForgotPassword,
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
borderRadiusAll: 10,
backgroundColor: _isLoading
? contentTheme.brandRed.withOpacity(0.6)
: contentTheme.brandRed,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: MyText.bodyMedium(
'Send Reset Link',
color: Colors.white,
fontWeight: 600,
fontSize: 16,
),
);
}
Widget _buildBackButton() {
return TextButton.icon(
onPressed: () async => await LocalStorage.logout(),
icon: const Icon(Icons.arrow_back, size: 18, color: Colors.redAccent),
label: MyText.bodyMedium(
'Back to Login',
color: contentTheme.brandRed,
fontWeight: 600,
fontSize: 14,
),
);
}
} }
// Red background using dynamic brandRed
class _RedWaveBackground extends StatelessWidget { class _RedWaveBackground extends StatelessWidget {
final Color brandRed; final Color brandRed;
const _RedWaveBackground({required this.brandRed}); const _RedWaveBackground({required this.brandRed});

View File

@ -26,9 +26,8 @@ class WelcomeScreen extends StatefulWidget {
class _WelcomeScreenState extends State<WelcomeScreen> class _WelcomeScreenState extends State<WelcomeScreen>
with SingleTickerProviderStateMixin, UIMixin { with SingleTickerProviderStateMixin, UIMixin {
late AnimationController _controller; late final AnimationController _controller;
late Animation<double> _logoAnimation; late final Animation<double> _logoAnimation;
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
@override @override
@ -54,42 +53,39 @@ class _WelcomeScreenState extends State<WelcomeScreen>
void _showLoginDialog(BuildContext context, LoginOption option) { void _showLoginDialog(BuildContext context, LoginOption option) {
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, // Prevent dismiss on outside tap barrierDismissible: false,
builder: (_) => Dialog( builder: (_) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
insetPadding: const EdgeInsets.all(24), insetPadding: const EdgeInsets.all(24),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(24), child: ConstrainedBox(
child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420),
constraints: const BoxConstraints(maxWidth: 420), child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ Row(
// Row with title and close button children: [
Row( Expanded(
children: [ child: MyText(
Expanded( option == LoginOption.email
child: MyText( ? "Login with Email"
option == LoginOption.email : "Login with OTP",
? "Login with Email" fontSize: 20,
: "Login with OTP", fontWeight: 700,
fontSize: 20,
fontWeight: 700,
),
), ),
IconButton( ),
icon: const Icon(Icons.close), IconButton(
onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close),
), onPressed: () => Navigator.of(context).pop(),
], ),
), ],
const SizedBox(height: 20), ),
option == LoginOption.email const SizedBox(height: 20),
? EmailLoginForm() option == LoginOption.email
: const OTPLoginScreen(), ? EmailLoginForm()
], : const OTPLoginScreen(),
), ],
), ),
), ),
), ),
@ -100,6 +96,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final isNarrow = screenWidth < 500;
return Scaffold( return Scaffold(
body: Stack( body: Stack(
@ -110,72 +107,18 @@ class _WelcomeScreenState extends State<WelcomeScreen>
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(maxWidth: isNarrow ? double.infinity : 420),
maxWidth: screenWidth < 500 ? double.infinity : 420,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Logo with circular background _buildLogo(),
ScaleTransition(
scale: _logoAnimation,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
padding: const EdgeInsets.all(20),
child: Image.asset(Images.logoDark),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildWelcomeText(),
// Welcome Text
MyText(
"Welcome to Marco",
fontSize: 26,
fontWeight: 800,
color: Colors.black87,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
MyText(
"Streamline Project Management\nBoost Productivity with Automation.",
fontSize: 14,
color: Colors.black54,
textAlign: TextAlign.center,
),
if (_isBetaEnvironment) ...[ if (_isBetaEnvironment) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Container( _buildBetaBadge(),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(6),
),
child: MyText(
'BETA',
color: Colors.white,
fontWeight: 600,
fontSize: 12,
),
),
], ],
const SizedBox(height: 36), const SizedBox(height: 36),
_buildActionButton( _buildActionButton(
context, context,
label: "Login with Username", label: "Login with Username",
@ -196,7 +139,6 @@ class _WelcomeScreenState extends State<WelcomeScreen>
icon: LucideIcons.phone_call, icon: LucideIcons.phone_call,
option: null, option: null,
), ),
const SizedBox(height: 36), const SizedBox(height: 36),
MyText( MyText(
'App version 1.0.0', 'App version 1.0.0',
@ -214,6 +156,60 @@ class _WelcomeScreenState extends State<WelcomeScreen>
); );
} }
Widget _buildLogo() {
return ScaleTransition(
scale: _logoAnimation,
child: Container(
width: 100,
height: 100,
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))],
),
child: Image.asset(Images.logoDark),
),
);
}
Widget _buildWelcomeText() {
return Column(
children: [
MyText(
"Welcome to Marco",
fontSize: 26,
fontWeight: 800,
color: Colors.black87,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
MyText(
"Streamline Project Management\nBoost Productivity with Automation.",
fontSize: 14,
color: Colors.black54,
textAlign: TextAlign.center,
),
],
);
}
Widget _buildBetaBadge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(6),
),
child: MyText(
'BETA',
color: Colors.white,
fontWeight: 600,
fontSize: 12,
),
);
}
Widget _buildActionButton( Widget _buildActionButton(
BuildContext context, { BuildContext context, {
required String label, required String label,
@ -236,9 +232,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: contentTheme.brandRed, backgroundColor: contentTheme.brandRed,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
borderRadius: BorderRadius.circular(14),
),
elevation: 4, elevation: 4,
shadowColor: Colors.black26, shadowColor: Colors.black26,
), ),
@ -254,7 +248,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
} }
} }
/// Custom red wave background shifted lower to reduce red area at top // Red wave background painter
class _RedWaveBackground extends StatelessWidget { class _RedWaveBackground extends StatelessWidget {
final Color brandRed; final Color brandRed;
const _RedWaveBackground({required this.brandRed}); const _RedWaveBackground({required this.brandRed});
@ -270,7 +264,6 @@ class _RedWaveBackground extends StatelessWidget {
class _WavePainter extends CustomPainter { class _WavePainter extends CustomPainter {
final Color brandRed; final Color brandRed;
_WavePainter(this.brandRed); _WavePainter(this.brandRed);
@override @override
@ -284,18 +277,8 @@ class _WavePainter extends CustomPainter {
final path1 = Path() final path1 = Path()
..moveTo(0, size.height * 0.2) ..moveTo(0, size.height * 0.2)
..quadraticBezierTo( ..quadraticBezierTo(size.width * 0.25, size.height * 0.05, size.width * 0.5, size.height * 0.15)
size.width * 0.25, ..quadraticBezierTo(size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
size.height * 0.05,
size.width * 0.5,
size.height * 0.15,
)
..quadraticBezierTo(
size.width * 0.75,
size.height * 0.25,
size.width,
size.height * 0.1,
)
..lineTo(size.width, 0) ..lineTo(size.width, 0)
..lineTo(0, 0) ..lineTo(0, 0)
..close(); ..close();
@ -303,15 +286,9 @@ class _WavePainter extends CustomPainter {
canvas.drawPath(path1, paint1); canvas.drawPath(path1, paint1);
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
final path2 = Path() final path2 = Path()
..moveTo(0, size.height * 0.25) ..moveTo(0, size.height * 0.25)
..quadraticBezierTo( ..quadraticBezierTo(size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
size.width * 0.4,
size.height * 0.1,
size.width,
size.height * 0.2,
)
..lineTo(size.width, 0) ..lineTo(size.width, 0)
..lineTo(0, 0) ..lineTo(0, 0)
..close(); ..close();

View File

@ -0,0 +1,189 @@
// lib/view/attendance/tabs/attendance_logs_tab.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/attendance/log_details_view.dart';
import 'package:marco/model/attendance/attendence_action_button.dart';
class AttendanceLogsTab extends StatelessWidget {
final AttendanceController controller;
const AttendanceLogsTab({super.key, required this.controller});
@override
Widget build(BuildContext context) {
return Obx(() {
final logs = List.of(controller.attendanceLogs);
logs.sort((a, b) {
final aDate = a.checkIn ?? DateTime(0);
final bDate = b.checkIn ?? DateTime(0);
return bDate.compareTo(aDate);
});
final dateFormat = DateFormat('dd MMM yyyy');
final dateRangeText = controller.startDateAttendance != null &&
controller.endDateAttendance != null
? '${dateFormat.format(controller.endDateAttendance!)} - ${dateFormat.format(controller.startDateAttendance!)}'
: 'Select date range';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.titleMedium("Attendance Logs", fontWeight: 600),
controller.isLoading.value
? const SizedBox(
height: 20, width: 20, child: LinearProgressIndicator())
: MyText.bodySmall(
dateRangeText,
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
],
),
),
if (controller.isLoadingAttendanceLogs.value)
SkeletonLoaders.employeeListSkeletonLoader()
else if (logs.isEmpty)
const SizedBox(
height: 120,
child: Center(
child: Text("No Attendance Logs Found for this Project"),
),
)
else
MyCard.bordered(
paddingAll: 8,
child: Column(
children: List.generate(logs.length, (index) {
final employee = logs[index];
final currentDate = employee.checkIn != null
? DateFormat('dd MMM yyyy').format(employee.checkIn!)
: '';
final previousDate =
index > 0 && logs[index - 1].checkIn != null
? DateFormat('dd MMM yyyy')
.format(logs[index - 1].checkIn!)
: '';
final showDateHeader =
index == 0 || currentDate != previousDate;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDateHeader)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: MyText.bodyMedium(
currentDate,
fontWeight: 700,
),
),
MyContainer(
paddingAll: 8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 31,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: MyText.bodyMedium(
employee.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${employee.designation})',
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
),
],
),
MySpacing.height(8),
if (employee.checkIn != null ||
employee.checkOut != null)
Row(
children: [
if (employee.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkIn!),
fontWeight: 600,
),
MySpacing.width(16),
],
if (employee.checkOut != null) ...[
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkOut!),
fontWeight: 600,
),
],
],
),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: employee,
attendanceController: controller,
),
MySpacing.width(8),
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
),
],
),
],
),
),
],
),
),
if (index != logs.length - 1)
Divider(color: Colors.grey.withOpacity(0.3)),
],
);
}),
),
),
],
);
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,157 @@
// lib/view/attendance/tabs/regularization_requests_tab.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/attendance/log_details_view.dart';
import 'package:marco/model/attendance/regualrize_action_button.dart';
class RegularizationRequestsTab extends StatelessWidget {
final AttendanceController controller;
const RegularizationRequestsTab({super.key, required this.controller});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0),
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
),
Obx(() {
final employees = controller.regularizationLogs;
if (controller.isLoadingRegularizationLogs.value) {
return SkeletonLoaders.employeeListSkeletonLoader();
}
if (employees.isEmpty) {
return const SizedBox(
height: 120,
child: Center(
child: Text("No Regularization Requests Found for this Project"),
),
);
}
return MyCard.bordered(
paddingAll: 8,
child: Column(
children: List.generate(employees.length, (index) {
final employee = employees[index];
return Column(
children: [
MyContainer(
paddingAll: 8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 31,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: MyText.bodyMedium(
employee.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${employee.role})',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
),
MySpacing.height(8),
if (employee.checkIn != null ||
employee.checkOut != null)
Row(
children: [
if (employee.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkIn!),
fontWeight: 600,
),
MySpacing.width(16),
],
if (employee.checkOut != null) ...[
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkOut!),
fontWeight: 600,
),
],
],
),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
RegularizeActionButton(
attendanceController: controller,
log: employee,
uniqueLogKey: employee.employeeId,
action: ButtonActions.approve,
),
const SizedBox(width: 8),
RegularizeActionButton(
attendanceController: controller,
log: employee,
uniqueLogKey: employee.employeeId,
action: ButtonActions.reject,
),
const SizedBox(width: 8),
if (employee.checkIn != null)
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
),
],
),
],
),
),
],
),
),
if (index != employees.length - 1)
Divider(color: Colors.grey.withOpacity(0.3)),
],
);
}),
),
);
}),
],
);
}
}

View File

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/dashboard/attendance_screen_controller.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/attendance/log_details_view.dart';
import 'package:marco/model/attendance/attendence_action_button.dart';
class TodaysAttendanceTab extends StatelessWidget {
final AttendanceController controller;
const TodaysAttendanceTab({super.key, required this.controller});
String _formatDate(DateTime date) {
return "${date.day}/${date.month}/${date.year}";
}
@override
Widget build(BuildContext context) {
return Obx(() {
final isLoading = controller.isLoadingEmployees.value;
final employees = controller.employees;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
Expanded(
child: MyText.titleMedium("Today's Attendance", fontWeight: 600),
),
MyText.bodySmall(
_formatDate(DateTime.now()),
fontWeight: 600,
color: Colors.grey[700],
),
],
),
),
if (isLoading)
SkeletonLoaders.employeeListSkeletonLoader()
else if (employees.isEmpty)
const SizedBox(height: 120, child: Center(child: Text("No Employees Assigned")))
else
MyCard.bordered(
paddingAll: 8,
child: Column(
children: List.generate(employees.length, (index) {
final employee = employees[index];
return Column(
children: [
MyContainer(
paddingAll: 5,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: employee.firstName, lastName: employee.lastName, size: 31),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
children: [
MyText.bodyMedium(employee.name, fontWeight: 600),
MyText.bodySmall('(${employee.designation})', fontWeight: 600, color: Colors.grey[700]),
],
),
MySpacing.height(8),
if (employee.checkIn != null || employee.checkOut != null)
Row(
children: [
if (employee.checkIn != null)
Row(
children: [
const Icon(Icons.arrow_circle_right, size: 16, color: Colors.green),
MySpacing.width(4),
Text(DateFormat('hh:mm a').format(employee.checkIn!)),
],
),
if (employee.checkOut != null) ...[
MySpacing.width(16),
const Icon(Icons.arrow_circle_left, size: 16, color: Colors.red),
MySpacing.width(4),
Text(DateFormat('hh:mm a').format(employee.checkOut!)),
],
],
),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: employee,
attendanceController: controller,
),
if (employee.checkIn != null) ...[
MySpacing.width(8),
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
),
],
],
),
],
),
),
],
),
),
if (index != employees.length - 1)
Divider(color: Colors.grey.withOpacity(0.3)),
],
);
}),
),
),
],
);
});
}
}

View File

@ -22,9 +22,10 @@ class DashboardScreen extends StatefulWidget {
static const String attendanceRoute = "/dashboard/attendance"; static const String attendanceRoute = "/dashboard/attendance";
static const String tasksRoute = "/dashboard/daily-task"; static const String tasksRoute = "/dashboard/daily-task";
static const String dailyTasksRoute = "/dashboard/daily-task-planing"; static const String dailyTasksRoute = "/dashboard/daily-task-planing";
static const String dailyTasksProgressRoute = static const String dailyTasksProgressRoute = "/dashboard/daily-task-progress";
"/dashboard/daily-task-progress";
static const String directoryMainPageRoute = "/dashboard/directory-main-page"; static const String directoryMainPageRoute = "/dashboard/directory-main-page";
static const String expenseMainPageRoute = "/dashboard/expense-main-page";
@override @override
State<DashboardScreen> createState() => _DashboardScreenState(); State<DashboardScreen> createState() => _DashboardScreenState();
@ -96,6 +97,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
DashboardScreen.dailyTasksProgressRoute), DashboardScreen.dailyTasksProgressRoute),
_StatItem(LucideIcons.folder, "Directory", contentTheme.info, _StatItem(LucideIcons.folder, "Directory", contentTheme.info,
DashboardScreen.directoryMainPageRoute), DashboardScreen.directoryMainPageRoute),
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
DashboardScreen.expenseMainPageRoute),
]; ];
return GetBuilder<ProjectController>( return GetBuilder<ProjectController>(

View File

@ -16,32 +16,20 @@ import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
class ContactDetailScreen extends StatefulWidget { // HELPER: Delta to HTML conversion
final ContactModel contact;
const ContactDetailScreen({super.key, required this.contact});
@override
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
}
String _convertDeltaToHtml(dynamic delta) { String _convertDeltaToHtml(dynamic delta) {
final buffer = StringBuffer(); final buffer = StringBuffer();
bool inList = false; bool inList = false;
for (var op in delta.toList()) { for (var op in delta.toList()) {
final data = op.data?.toString() ?? ''; final String data = op.data?.toString() ?? '';
final attr = op.attributes ?? {}; final attr = op.attributes ?? {};
final bool isListItem = attr.containsKey('list');
final isListItem = attr.containsKey('list');
// Start list
if (isListItem && !inList) { if (isListItem && !inList) {
buffer.write('<ul>'); buffer.write('<ul>');
inList = true; inList = true;
} }
// Close list if we are not in list mode anymore
if (!isListItem && inList) { if (!isListItem && inList) {
buffer.write('</ul>'); buffer.write('</ul>');
inList = false; inList = false;
@ -49,15 +37,12 @@ String _convertDeltaToHtml(dynamic delta) {
if (isListItem) buffer.write('<li>'); if (isListItem) buffer.write('<li>');
// Apply inline styles
if (attr.containsKey('bold')) buffer.write('<strong>'); if (attr.containsKey('bold')) buffer.write('<strong>');
if (attr.containsKey('italic')) buffer.write('<em>'); if (attr.containsKey('italic')) buffer.write('<em>');
if (attr.containsKey('underline')) buffer.write('<u>'); if (attr.containsKey('underline')) buffer.write('<u>');
if (attr.containsKey('strike')) buffer.write('<s>'); if (attr.containsKey('strike')) buffer.write('<s>');
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">'); if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
buffer.write(data.replaceAll('\n', '')); buffer.write(data.replaceAll('\n', ''));
if (attr.containsKey('link')) buffer.write('</a>'); if (attr.containsKey('link')) buffer.write('</a>');
if (attr.containsKey('strike')) buffer.write('</s>'); if (attr.containsKey('strike')) buffer.write('</s>');
if (attr.containsKey('underline')) buffer.write('</u>'); if (attr.containsKey('underline')) buffer.write('</u>');
@ -66,14 +51,21 @@ String _convertDeltaToHtml(dynamic delta) {
if (isListItem) if (isListItem)
buffer.write('</li>'); buffer.write('</li>');
else if (data.contains('\n')) buffer.write('<br>'); else if (data.contains('\n')) {
buffer.write('<br>');
}
} }
if (inList) buffer.write('</ul>'); if (inList) buffer.write('</ul>');
return buffer.toString(); return buffer.toString();
} }
class ContactDetailScreen extends StatefulWidget {
final ContactModel contact;
const ContactDetailScreen({super.key, required this.contact});
@override
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
}
class _ContactDetailScreenState extends State<ContactDetailScreen> { class _ContactDetailScreenState extends State<ContactDetailScreen> {
late final DirectoryController directoryController; late final DirectoryController directoryController;
late final ProjectController projectController; late final ProjectController projectController;
@ -85,7 +77,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
directoryController = Get.find<DirectoryController>(); directoryController = Get.find<DirectoryController>();
projectController = Get.find<ProjectController>(); projectController = Get.find<ProjectController>();
contact = widget.contact; contact = widget.contact;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
directoryController.fetchCommentsForContact(contact.id); directoryController.fetchCommentsForContact(contact.id);
}); });
@ -103,13 +94,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSubHeader(), _buildSubHeader(),
const Divider(height: 1, thickness: 0.5, color: Colors.grey),
Expanded( Expanded(
child: TabBarView( child: TabBarView(children: [
children: [ _buildDetailsTab(),
_buildDetailsTab(), _buildCommentsTab(context),
_buildCommentsTab(context), ]),
],
),
), ),
], ],
), ),
@ -130,10 +120,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back_ios_new, icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20),
color: Colors.black, size: 20), onPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
onPressed: () =>
Get.offAllNamed('/dashboard/directory-main-page'),
), ),
MySpacing.width(8), MySpacing.width(8),
Expanded( Expanded(
@ -141,30 +129,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
MyText.titleLarge('Contact Profile', MyText.titleLarge('Contact Profile', fontWeight: 700, color: Colors.black),
fontWeight: 700, color: Colors.black),
MySpacing.height(2), MySpacing.height(2),
GetBuilder<ProjectController>( GetBuilder<ProjectController>(
builder: (projectController) { builder: (p) => ProjectLabel(p.selectedProject?.name),
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
), ),
], ],
), ),
@ -176,38 +144,30 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
} }
Widget _buildSubHeader() { Widget _buildSubHeader() {
final firstName = contact.name.split(" ").first;
final lastName = contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
return Padding( return Padding(
padding: MySpacing.xy(16, 12), padding: MySpacing.xy(16, 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(children: [
children: [ Avatar(firstName: firstName, lastName: lastName, size: 35, backgroundColor: Colors.indigo),
Avatar( MySpacing.width(12),
firstName: contact.name.split(" ").first, Column(
lastName: contact.name.split(" ").length > 1 crossAxisAlignment: CrossAxisAlignment.start,
? contact.name.split(" ").last children: [
: "", MyText.titleSmall(contact.name, fontWeight: 600, color: Colors.black),
size: 35, MySpacing.height(2),
backgroundColor: Colors.indigo, MyText.bodySmall(contact.organization, fontWeight: 500, color: Colors.grey[700]),
), ],
MySpacing.width(12), ),
Column( ]),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(contact.name,
fontWeight: 600, color: Colors.black),
MySpacing.height(2),
MyText.bodySmall(contact.organization,
fontWeight: 500, color: Colors.grey[700]),
],
),
],
),
TabBar( TabBar(
labelColor: Colors.red, labelColor: Colors.red,
unselectedLabelColor: Colors.black, unselectedLabelColor: Colors.black,
indicator: MaterialIndicator( indicator: MaterialIndicator(
color: Colors.red, color: Colors.red,
height: 4, height: 4,
topLeftRadius: 8, topLeftRadius: 8,
@ -226,33 +186,37 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
} }
Widget _buildDetailsTab() { Widget _buildDetailsTab() {
final email = contact.contactEmails.isNotEmpty
? contact.contactEmails.first.emailAddress
: "-";
final phone = contact.contactPhones.isNotEmpty
? contact.contactPhones.first.phoneNumber
: "-";
final tags = contact.tags.map((e) => e.name).join(", "); final tags = contact.tags.map((e) => e.name).join(", ");
final bucketNames = contact.bucketIds final bucketNames = contact.bucketIds
.map((id) => directoryController.contactBuckets .map((id) => directoryController.contactBuckets
.firstWhereOrNull((b) => b.id == id) .firstWhereOrNull((b) => b.id == id)
?.name) ?.name)
.whereType<String>() .whereType<String>()
.join(", "); .join(", ");
final projectNames = contact.projectIds?.map((id) =>
final projectNames = contact.projectIds projectController.projects.firstWhereOrNull((p) => p.id == id)?.name).whereType<String>().join(", ") ?? "-";
?.map((id) => projectController.projects
.firstWhereOrNull((p) => p.id == id)
?.name)
.whereType<String>()
.join(", ") ??
"-";
final category = contact.contactCategory?.name ?? "-"; final category = contact.contactCategory?.name ?? "-";
Widget multiRows({required List<dynamic> items, required IconData icon, required String label, required String typeLabel, required Function(String)? onTap, required Function(String)? onLongPress}) {
return items.isNotEmpty
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_iconInfoRow(icon, label, items.first, onTap: () => onTap?.call(items.first), onLongPress: () => onLongPress?.call(items.first)),
...items.skip(1).map(
(val) => _iconInfoRow(
null,
'',
val,
onTap: () => onTap?.call(val),
onLongPress: () => onLongPress?.call(val),
),
),
],
)
: _iconInfoRow(icon, label, "-");
}
return Stack( return Stack(
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
@ -261,28 +225,38 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(12), MySpacing.height(12),
// BASIC INFO CARD
_infoCard("Basic Info", [ _infoCard("Basic Info", [
_iconInfoRow(Icons.email, "Email", email, multiRows(
onTap: () => LauncherUtils.launchEmail(email), items: contact.contactEmails.map((e) => e.emailAddress).toList(),
onLongPress: () => LauncherUtils.copyToClipboard(email, icon: Icons.email,
typeLabel: "Email")), label: "Email",
_iconInfoRow(Icons.phone, "Phone", phone, typeLabel: "Email",
onTap: () => LauncherUtils.launchPhone(phone), onTap: (email) => LauncherUtils.launchEmail(email),
onLongPress: () => LauncherUtils.copyToClipboard(phone, onLongPress: (email) => LauncherUtils.copyToClipboard(email, typeLabel: "Email"),
typeLabel: "Phone")), ),
multiRows(
items: contact.contactPhones.map((p) => p.phoneNumber).toList(),
icon: Icons.phone,
label: "Phone",
typeLabel: "Phone",
onTap: (phone) => LauncherUtils.launchPhone(phone),
onLongPress: (phone) => LauncherUtils.copyToClipboard(phone, typeLabel: "Phone"),
),
_iconInfoRow(Icons.location_on, "Address", contact.address), _iconInfoRow(Icons.location_on, "Address", contact.address),
]), ]),
// ORGANIZATION CARD
_infoCard("Organization", [ _infoCard("Organization", [
_iconInfoRow( _iconInfoRow(Icons.business, "Organization", contact.organization),
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category), _iconInfoRow(Icons.category, "Category", category),
]), ]),
// META INFO CARD
_infoCard("Meta Info", [ _infoCard("Meta Info", [
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), _iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
_iconInfoRow(Icons.folder_shared, "Contact Buckets", _iconInfoRow(Icons.folder_shared, "Contact Buckets", bucketNames.isNotEmpty ? bucketNames : "-"),
bucketNames.isNotEmpty ? bucketNames : "-"),
_iconInfoRow(Icons.work_outline, "Projects", projectNames), _iconInfoRow(Icons.work_outline, "Projects", projectNames),
]), ]),
// DESCRIPTION CARD
_infoCard("Description", [ _infoCard("Description", [
MySpacing.height(6), MySpacing.height(6),
Align( Align(
@ -294,7 +268,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
textAlign: TextAlign.left, textAlign: TextAlign.left,
), ),
), ),
]) ]),
], ],
), ),
), ),
@ -309,25 +283,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
); );
if (result == true) { if (result == true) {
await directoryController.fetchContacts(); await directoryController.fetchContacts();
final updated = final updated =
directoryController.allContacts.firstWhereOrNull( directoryController.allContacts.firstWhereOrNull((c) => c.id == contact.id);
(c) => c.id == contact.id,
);
if (updated != null) { if (updated != null) {
setState(() { setState(() => contact = updated);
contact = updated;
});
} }
} }
}, },
icon: const Icon(Icons.edit, color: Colors.white), icon: const Icon(Icons.edit, color: Colors.white),
label: const Text( label: const Text("Edit Contact", style: TextStyle(color: Colors.white)),
"Edit Contact",
style: TextStyle(color: Colors.white),
),
), ),
), ),
], ],
@ -337,24 +303,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
Widget _buildCommentsTab(BuildContext context) { Widget _buildCommentsTab(BuildContext context) {
return Obx(() { return Obx(() {
final contactId = contact.id; final contactId = contact.id;
if (!directoryController.contactCommentsMap.containsKey(contactId)) { if (!directoryController.contactCommentsMap.containsKey(contactId)) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final comments = directoryController.getCommentsForContact(contactId).reversed.toList();
final comments = directoryController
.getCommentsForContact(contactId)
.reversed
.toList();
final editingId = directoryController.editingCommentId.value; final editingId = directoryController.editingCommentId.value;
return Stack( return Stack(
children: [ children: [
comments.isEmpty comments.isEmpty
? Center( ? Center(
child: child: MyText.bodyLarge("No comments yet.", color: Colors.grey),
MyText.bodyLarge("No comments yet.", color: Colors.grey),
) )
: Padding( : Padding(
padding: MySpacing.xy(12, 12), padding: MySpacing.xy(12, 12),
@ -362,137 +321,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
padding: const EdgeInsets.only(bottom: 100), padding: const EdgeInsets.only(bottom: 100),
itemCount: comments.length, itemCount: comments.length,
separatorBuilder: (_, __) => MySpacing.height(14), separatorBuilder: (_, __) => MySpacing.height(14),
itemBuilder: (_, index) { itemBuilder: (_, index) => _buildCommentItem(comments[index], editingId, contact.id),
final comment = comments[index];
final isEditing = editingId == comment.id;
final initials = comment.createdBy.firstName.isNotEmpty
? comment.createdBy.firstName[0].toUpperCase()
: "?";
final decodedDelta = HtmlToDelta().convert(comment.note);
final quillController = isEditing
? quill.QuillController(
document: quill.Document.fromDelta(decodedDelta),
selection: TextSelection.collapsed(
offset: decodedDelta.length),
)
: null;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: MySpacing.xy(8, 7),
decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isEditing
? Colors.indigo
: Colors.grey.shade300,
width: 1.2,
),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2),
)
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: initials,
lastName: '',
size: 36),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodyMedium(
"By: ${comment.createdBy.firstName}",
fontWeight: 600,
color: Colors.indigo[800],
),
MySpacing.height(4),
MyText.bodySmall(
DateTimeUtils.convertUtcToLocal(
comment.createdAt.toString(),
format: 'dd MMM yyyy, hh:mm a',
),
color: Colors.grey[600],
),
],
),
),
IconButton(
icon: Icon(
isEditing ? Icons.close : Icons.edit,
size: 20,
color: Colors.indigo,
),
onPressed: () {
directoryController.editingCommentId.value =
isEditing ? null : comment.id;
},
),
],
),
// Comment Content
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () {
directoryController.editingCommentId.value =
null;
},
onSave: (controller) async {
final delta = controller.document.toDelta();
final htmlOutput = _convertDeltaToHtml(delta);
final updated =
comment.copyWith(note: htmlOutput);
await directoryController
.updateComment(updated);
// Re-fetch comments to get updated list
await directoryController
.fetchCommentsForContact(contactId);
// Exit editing mode
directoryController.editingCommentId.value =
null;
},
)
else
html.Html(
data: comment.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize.medium,
color: Colors.black87,
),
},
),
],
),
);
},
), ),
), ),
if (editingId == null)
// Floating Action Button
if (directoryController.editingCommentId.value == null)
Positioned( Positioned(
bottom: 20, bottom: 20,
right: 20, right: 20,
@ -503,17 +335,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
AddCommentBottomSheet(contactId: contactId), AddCommentBottomSheet(contactId: contactId),
isScrollControlled: true, isScrollControlled: true,
); );
if (result == true) { if (result == true) {
await directoryController await directoryController.fetchCommentsForContact(contactId);
.fetchCommentsForContact(contactId);
} }
}, },
icon: const Icon(Icons.add_comment, color: Colors.white), icon: const Icon(Icons.add_comment, color: Colors.white),
label: const Text( label: const Text("Add Comment", style: TextStyle(color: Colors.white)),
"Add Comment",
style: TextStyle(color: Colors.white),
),
), ),
), ),
], ],
@ -521,25 +348,127 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
}); });
} }
Widget _iconInfoRow(IconData icon, String label, String value, Widget _buildCommentItem(comment, editingId, contactId) {
{VoidCallback? onTap, VoidCallback? onLongPress}) { final isEditing = editingId == comment.id;
final initials = comment.createdBy.firstName.isNotEmpty
? comment.createdBy.firstName[0].toUpperCase()
: "?";
final decodedDelta = HtmlToDelta().convert(comment.note);
final quillController = isEditing
? quill.QuillController(
document: quill.Document.fromDelta(decodedDelta),
selection: TextSelection.collapsed(offset: decodedDelta.length),
)
: null;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: MySpacing.xy(8, 7),
decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isEditing ? Colors.indigo : Colors.grey.shade300,
width: 1.2,
),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: initials, lastName: '', size: 36),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium("By: ${comment.createdBy.firstName}",
fontWeight: 600, color: Colors.indigo[800]),
MySpacing.height(4),
MyText.bodySmall(
DateTimeUtils.convertUtcToLocal(
comment.createdAt.toString(),
format: 'dd MMM yyyy, hh:mm a',
),
color: Colors.grey[600],
),
],
),
),
IconButton(
icon: Icon(
isEditing ? Icons.close : Icons.edit,
size: 20,
color: Colors.indigo,
),
onPressed: () {
directoryController.editingCommentId.value = isEditing ? null : comment.id;
},
),
],
),
// Comment Content
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () => directoryController.editingCommentId.value = null,
onSave: (ctrl) async {
final delta = ctrl.document.toDelta();
final htmlOutput = _convertDeltaToHtml(delta);
final updated = comment.copyWith(note: htmlOutput);
await directoryController.updateComment(updated);
await directoryController.fetchCommentsForContact(contactId);
directoryController.editingCommentId.value = null;
},
)
else
html.Html(
data: comment.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize.medium,
color: Colors.black87,
),
},
),
],
),
);
}
Widget _iconInfoRow(
IconData? icon,
String label,
String value, {
VoidCallback? onTap,
VoidCallback? onLongPress,
}) {
return Padding( return Padding(
padding: MySpacing.y(8), padding: MySpacing.y(2),
child: GestureDetector( child: GestureDetector(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, size: 22, color: Colors.indigo), if (icon != null) ...[
MySpacing.width(12), Icon(icon, size: 22, color: Colors.indigo),
MySpacing.width(12),
] else
const SizedBox(width: 34),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodySmall(label, if (label.isNotEmpty)
fontWeight: 600, color: Colors.black87), MyText.bodySmall(label, fontWeight: 600, color: Colors.black87),
MySpacing.height(2), if (label.isNotEmpty) MySpacing.height(2),
MyText.bodyMedium(value, color: Colors.grey[800]), MyText.bodyMedium(value, color: Colors.grey[800]),
], ],
), ),
@ -560,8 +489,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.titleSmall(title, MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]),
fontWeight: 700, color: Colors.indigo[700]),
MySpacing.height(8), MySpacing.height(8),
...children, ...children,
], ],
@ -570,3 +498,26 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
); );
} }
} }
// Helper widget for Project label in AppBar
class ProjectLabel extends StatelessWidget {
final String? projectName;
const ProjectLabel(this.projectName, {super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.work_outline, size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName ?? 'Select Project',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}
}

View File

@ -24,8 +24,7 @@ class DirectoryView extends StatefulWidget {
class _DirectoryViewState extends State<DirectoryView> { class _DirectoryViewState extends State<DirectoryView> {
final DirectoryController controller = Get.find(); final DirectoryController controller = Get.find();
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
final PermissionController permissionController = final PermissionController permissionController = Get.put(PermissionController());
Get.put(PermissionController());
Future<void> _refreshDirectory() async { Future<void> _refreshDirectory() async {
try { try {
@ -213,7 +212,7 @@ class _DirectoryViewState extends State<DirectoryView> {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: IconButton( child: IconButton(
icon: Icon(Icons.filter_alt_outlined, icon: Icon(Icons.tune,
size: 20, size: 20,
color: isFilterActive color: isFilterActive
? Colors.indigo ? Colors.indigo
@ -267,7 +266,7 @@ class _DirectoryViewState extends State<DirectoryView> {
itemBuilder: (context) { itemBuilder: (context) {
List<PopupMenuEntry<int>> menuItems = []; List<PopupMenuEntry<int>> menuItems = [];
// Section: Actions (Always visible now) // Section: Actions
menuItems.add( menuItems.add(
const PopupMenuItem<int>( const PopupMenuItem<int>(
enabled: false, enabled: false,
@ -282,6 +281,37 @@ class _DirectoryViewState extends State<DirectoryView> {
), ),
); );
// Create Bucket option
menuItems.add(
PopupMenuItem<int>(
value: 2,
child: Row(
children: const [
Icon(Icons.add_box_outlined,
size: 20, color: Colors.black87),
SizedBox(width: 10),
Expanded(child: Text("Create Bucket")),
Icon(Icons.chevron_right,
size: 20, color: Colors.red),
],
),
onTap: () {
Future.delayed(Duration.zero, () async {
final created = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const CreateBucketBottomSheet(),
);
if (created == true) {
await controller.fetchBuckets();
}
});
},
),
);
// Manage Buckets option
menuItems.add( menuItems.add(
PopupMenuItem<int>( PopupMenuItem<int>(
value: 1, value: 1,
@ -318,6 +348,7 @@ class _DirectoryViewState extends State<DirectoryView> {
), ),
); );
// Show Inactive switch
menuItems.add( menuItems.add(
PopupMenuItem<int>( PopupMenuItem<int>(
value: 0, value: 0,
@ -409,62 +440,69 @@ class _DirectoryViewState extends State<DirectoryView> {
color: Colors.grey[700], color: Colors.grey[700],
overflow: TextOverflow.ellipsis), overflow: TextOverflow.ellipsis),
MySpacing.height(8), MySpacing.height(8),
...contact.contactEmails.map((e) =>
GestureDetector( // Show only the first email (if present)
onTap: () => LauncherUtils.launchEmail( if (contact.contactEmails.isNotEmpty)
e.emailAddress), GestureDetector(
onLongPress: () => onTap: () => LauncherUtils.launchEmail(
LauncherUtils.copyToClipboard( contact.contactEmails.first.emailAddress),
e.emailAddress, onLongPress: () =>
typeLabel: 'Email'), LauncherUtils.copyToClipboard(
child: Padding( contact.contactEmails.first.emailAddress,
padding: typeLabel: 'Email',
const EdgeInsets.only(bottom: 4), ),
child: Row( child: Padding(
children: [ padding: const EdgeInsets.only(bottom: 4),
const Icon(Icons.email_outlined,
size: 16, color: Colors.indigo),
MySpacing.width(4),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 180),
child: MyText.labelSmall(
e.emailAddress,
overflow: TextOverflow.ellipsis,
color: Colors.indigo,
decoration:
TextDecoration.underline,
),
),
],
),
),
)),
...contact.contactPhones.map((p) => Padding(
padding: const EdgeInsets.only(
bottom: 8, top: 4),
child: Row( child: Row(
children: [ children: [
GestureDetector( const Icon(Icons.email_outlined,
onTap: () => size: 16, color: Colors.indigo),
LauncherUtils.launchPhone( MySpacing.width(4),
p.phoneNumber), Expanded(
child: MyText.labelSmall(
contact.contactEmails.first.emailAddress,
overflow: TextOverflow.ellipsis,
color: Colors.indigo,
decoration:
TextDecoration.underline,
),
),
],
),
),
),
// Show only the first phone (if present)
if (contact.contactPhones.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: 8, top: 4),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => LauncherUtils
.launchPhone(contact
.contactPhones
.first
.phoneNumber),
onLongPress: () => onLongPress: () =>
LauncherUtils.copyToClipboard( LauncherUtils.copyToClipboard(
p.phoneNumber, contact.contactPhones.first
typeLabel: 'Phone'), .phoneNumber,
typeLabel: 'Phone',
),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.phone_outlined, const Icon(
Icons.phone_outlined,
size: 16, size: 16,
color: Colors.indigo), color: Colors.indigo),
MySpacing.width(4), MySpacing.width(4),
ConstrainedBox( Expanded(
constraints:
const BoxConstraints(
maxWidth: 140),
child: MyText.labelSmall( child: MyText.labelSmall(
p.phoneNumber, contact.contactPhones.first
.phoneNumber,
overflow: overflow:
TextOverflow.ellipsis, TextOverflow.ellipsis,
color: Colors.indigo, color: Colors.indigo,
@ -475,19 +513,22 @@ class _DirectoryViewState extends State<DirectoryView> {
], ],
), ),
), ),
MySpacing.width(8), ),
GestureDetector( MySpacing.width(8),
onTap: () => GestureDetector(
LauncherUtils.launchWhatsApp( onTap: () =>
p.phoneNumber), LauncherUtils.launchWhatsApp(
child: const FaIcon( contact.contactPhones.first
FontAwesomeIcons.whatsapp, .phoneNumber),
color: Colors.green, child: const FaIcon(
size: 16), FontAwesomeIcons.whatsapp,
color: Colors.green,
size: 16,
), ),
], ),
), ],
)), ),
),
if (tags.isNotEmpty) ...[ if (tags.isNotEmpty) ...[
MySpacing.height(2), MySpacing.height(2),
MyText.labelSmall(tags.join(', '), MyText.labelSmall(tags.join(', '),

View File

@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/employee/assign_projects_controller.dart'; import 'package:marco/controller/employee/assign_projects_controller.dart';
import 'package:marco/model/global_project_model.dart'; import 'package:marco/model/global_project_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AssignProjectBottomSheet extends StatefulWidget { class AssignProjectBottomSheet extends StatefulWidget {
final String employeeId; final String employeeId;
@ -23,6 +24,7 @@ class AssignProjectBottomSheet extends StatefulWidget {
class _AssignProjectBottomSheetState extends State<AssignProjectBottomSheet> { class _AssignProjectBottomSheetState extends State<AssignProjectBottomSheet> {
late final AssignProjectController assignController; late final AssignProjectController assignController;
final ScrollController _scrollController = ScrollController();
@override @override
void initState() { void initState() {
@ -38,229 +40,139 @@ class _AssignProjectBottomSheetState extends State<AssignProjectBottomSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); return GetBuilder<AssignProjectController>(
tag: '${widget.employeeId}_${widget.jobRoleId}',
builder: (_) {
return BaseBottomSheet(
title: "Assign to Project",
onCancel: () => Navigator.pop(context),
onSubmit: _handleAssign,
submitText: "Assign",
child: Obx(() {
if (assignController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
return SafeArea( final projects = assignController.allProjects;
top: false, if (projects.isEmpty) {
child: DraggableScrollableSheet( return const Center(child: Text('No projects available.'));
expand: false, }
maxChildSize: 0.9,
minChildSize: 0.4, return Column(
initialChildSize: 0.7, crossAxisAlignment: CrossAxisAlignment.start,
builder: (_, scrollController) { children: [
return Container( MyText.bodySmall(
decoration: BoxDecoration( 'Select the projects to assign this employee.',
color: theme.cardColor, color: Colors.grey[600],
borderRadius: ),
const BorderRadius.vertical(top: Radius.circular(24)), MySpacing.height(8),
boxShadow: const [
BoxShadow( // Select All
color: Colors.black12, Row(
blurRadius: 12, mainAxisAlignment: MainAxisAlignment.spaceBetween,
offset: Offset(0, -2), children: [
Text(
'Projects (${projects.length})',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
TextButton(
onPressed: () {
assignController.toggleSelectAll();
},
child: Obx(() {
return Text(
assignController.areAllSelected()
? 'Deselect All'
: 'Select All',
style: const TextStyle(
color: Colors.blueAccent,
fontWeight: FontWeight.w600,
),
);
}),
),
],
),
// List of Projects
SizedBox(
height: 300,
child: ListView.builder(
controller: _scrollController,
itemCount: projects.length,
itemBuilder: (context, index) {
final GlobalProjectModel project = projects[index];
return Obx(() {
final bool isSelected =
assignController.isProjectSelected(
project.id.toString(),
);
return Theme(
data: Theme.of(context).copyWith(
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>(
(states) => states.contains(WidgetState.selected)
? Colors.blueAccent
: Colors.white,
),
side: const BorderSide(
color: Colors.black,
width: 2,
),
checkColor:
WidgetStateProperty.all(Colors.white),
),
),
child: CheckboxListTile(
dense: true,
value: isSelected,
title: Text(
project.name,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
onChanged: (checked) {
assignController.toggleProjectSelection(
project.id.toString(),
checked ?? false,
);
},
activeColor: Colors.blueAccent,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
);
});
},
),
), ),
], ],
), );
padding: MySpacing.all(16), }),
child: Obx(() { );
if (assignController.isLoading.value) { },
return const Center(child: CircularProgressIndicator());
}
final projects = assignController.allProjects;
if (projects.isEmpty) {
return const Center(child: Text('No projects available.'));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag Handle
Center(
child: Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
),
MySpacing.height(12),
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.titleMedium('Assign to Project', fontWeight: 700),
],
),
MySpacing.height(4),
// Sub Info
MyText.bodySmall(
'Select the projects to assign this employee.',
color: Colors.grey[600],
),
MySpacing.height(8),
// Select All Toggle
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Projects (${projects.length})',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
TextButton(
onPressed: () {
assignController.toggleSelectAll();
},
child: Obx(() {
return Text(
assignController.areAllSelected()
? 'Deselect All'
: 'Select All',
style: const TextStyle(
color: Colors.blueAccent,
fontWeight: FontWeight.w600,
),
);
}),
),
],
),
// Project List
Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: projects.length,
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
final GlobalProjectModel project = projects[index];
return Obx(() {
final bool isSelected =
assignController.isProjectSelected(
project.id.toString(),
);
return Theme(
data: Theme.of(context).copyWith(
checkboxTheme: CheckboxThemeData(
fillColor:
WidgetStateProperty.resolveWith<Color>(
(states) {
if (states.contains(WidgetState.selected)) {
return Colors.blueAccent;
}
return Colors.white;
},
),
side: const BorderSide(
color: Colors.black,
width: 2,
),
checkColor:
WidgetStateProperty.all(Colors.white),
),
),
child: CheckboxListTile(
dense: true,
value: isSelected,
title: Text(
project.name,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
onChanged: (checked) {
assignController.toggleProjectSelection(
project.id.toString(),
checked ?? false,
);
},
activeColor: Colors.blueAccent,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
);
});
},
),
),
MySpacing.height(16),
// Cancel & Save Buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, color: Colors.red),
label: MyText.bodyMedium("Cancel",
color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 7,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
if (assignController.selectedProjects.isEmpty) {
showAppSnackbar(
title: "Error",
message: "Please select at least one project.",
type: SnackbarType.error,
);
return;
}
await _assignProjects();
},
icon: const Icon(Icons.check_circle_outline,
color: Colors.white),
label: MyText.bodyMedium("Assign",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 7,
),
),
),
),
],
),
],
);
}),
);
},
),
); );
} }
Future<void> _assignProjects() async { Future<void> _handleAssign() async {
if (assignController.selectedProjects.isEmpty) {
showAppSnackbar(
title: "Error",
message: "Please select at least one project.",
type: SnackbarType.error,
);
return;
}
final success = await assignController.assignProjectsToEmployee(); final success = await assignController.assignProjectsToEmployee();
if (success) { if (success) {
Get.back(); Get.back();

View File

@ -22,8 +22,7 @@ class EmployeesScreen extends StatefulWidget {
} }
class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin { class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
final EmployeesScreenController _employeeController = final EmployeesScreenController _employeeController = Get.put(EmployeesScreenController());
Get.put(EmployeesScreenController());
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs; final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
@ -32,39 +31,37 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_initEmployees(); _initEmployees();
_searchController.addListener(() { _searchController.addListener(() => _filterEmployees(_searchController.text));
_filterEmployees(_searchController.text);
});
}); });
} }
Future<void> _initEmployees() async { Future<void> _initEmployees() async {
final selectedProjectId = Get.find<ProjectController>().selectedProject?.id; final projectId = Get.find<ProjectController>().selectedProject?.id;
if (selectedProjectId != null) { if (_employeeController.isAllEmployeeSelected.value) {
_employeeController.selectedProjectId = selectedProjectId;
await _employeeController.fetchEmployeesByProject(selectedProjectId);
} else if (_employeeController.isAllEmployeeSelected.value) {
_employeeController.selectedProjectId = null; _employeeController.selectedProjectId = null;
await _employeeController.fetchAllEmployees(); await _employeeController.fetchAllEmployees();
} else if (projectId != null) {
_employeeController.selectedProjectId = projectId;
await _employeeController.fetchEmployeesByProject(projectId);
} else { } else {
_employeeController.clearEmployees(); _employeeController.clearEmployees();
} }
_filterEmployees(_searchController.text); _filterEmployees(_searchController.text);
} }
Future<void> _refreshEmployees() async { Future<void> _refreshEmployees() async {
try { try {
final selectedProjectId = final projectId = Get.find<ProjectController>().selectedProject?.id;
Get.find<ProjectController>().selectedProject?.id; final allSelected = _employeeController.isAllEmployeeSelected.value;
final isAllSelected = _employeeController.isAllEmployeeSelected.value;
if (isAllSelected) { _employeeController.selectedProjectId = allSelected ? null : projectId;
_employeeController.selectedProjectId = null;
if (allSelected) {
await _employeeController.fetchAllEmployees(); await _employeeController.fetchAllEmployees();
} else if (selectedProjectId != null) { } else if (projectId != null) {
_employeeController.selectedProjectId = selectedProjectId; await _employeeController.fetchEmployeesByProject(projectId);
await _employeeController.fetchEmployeesByProject(selectedProjectId);
} else { } else {
_employeeController.clearEmployees(); _employeeController.clearEmployees();
} }
@ -79,17 +76,20 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
void _filterEmployees(String query) { void _filterEmployees(String query) {
final employees = _employeeController.employees; final employees = _employeeController.employees;
if (query.isEmpty) { if (query.isEmpty) {
_filteredEmployees.assignAll(employees); _filteredEmployees.assignAll(employees);
return; return;
} }
final lowerQuery = query.toLowerCase();
final q = query.toLowerCase();
_filteredEmployees.assignAll( _filteredEmployees.assignAll(
employees.where((e) => employees.where((e) =>
e.name.toLowerCase().contains(lowerQuery) || e.name.toLowerCase().contains(q) ||
e.email.toLowerCase().contains(lowerQuery) || e.email.toLowerCase().contains(q) ||
e.phoneNumber.toLowerCase().contains(lowerQuery) || e.phoneNumber.toLowerCase().contains(q) ||
e.jobRole.toLowerCase().contains(lowerQuery)), e.jobRole.toLowerCase().contains(q),
),
); );
} }
@ -98,7 +98,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => AddEmployeeBottomSheet(), builder: (context) => AddEmployeeBottomSheet(),
); );
@ -113,7 +114,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24))), borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => AssignProjectBottomSheet( builder: (context) => AssignProjectBottomSheet(
employeeId: employeeId, employeeId: employeeId,
@ -134,7 +136,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
child: GetBuilder<EmployeesScreenController>( child: GetBuilder<EmployeesScreenController>(
init: _employeeController, init: _employeeController,
tag: 'employee_screen_controller', tag: 'employee_screen_controller',
builder: (controller) { builder: (_) {
_filterEmployees(_searchController.text); _filterEmployees(_searchController.text);
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 40), padding: const EdgeInsets.only(bottom: 40),
@ -168,34 +170,24 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
title: Padding( title: Padding(
padding: MySpacing.xy(16, 0), padding: MySpacing.xy(16, 0),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back_ios_new, icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20),
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'), onPressed: () => Get.offNamed('/dashboard'),
), ),
MySpacing.width(8), MySpacing.width(8),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
MyText.titleLarge( MyText.titleLarge('Employees', fontWeight: 700, color: Colors.black),
'Employees',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2), MySpacing.height(2),
GetBuilder<ProjectController>( GetBuilder<ProjectController>(
builder: (projectController) { builder: (projectController) {
final projectName = final projectName = projectController.selectedProject?.name ?? 'Select Project';
projectController.selectedProject?.name ??
'Select Project';
return Row( return Row(
children: [ children: [
const Icon(Icons.work_outline, const Icon(Icons.work_outline, size: 14, color: Colors.grey),
size: 14, color: Colors.grey),
MySpacing.width(4), MySpacing.width(4),
Expanded( Expanded(
child: MyText.bodySmall( child: MyText.bodySmall(
@ -228,13 +220,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red, color: Colors.red,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
boxShadow: const [ boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 3))],
BoxShadow(
color: Colors.black26,
blurRadius: 6,
offset: Offset(0, 3),
),
],
), ),
child: const Row( child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -271,11 +257,9 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
style: const TextStyle(fontSize: 13, height: 1.2), style: const TextStyle(fontSize: 13, height: 1.2),
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey), prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
prefixIconConstraints: prefixIconConstraints: const BoxConstraints(minWidth: 32, minHeight: 32),
const BoxConstraints(minWidth: 32, minHeight: 32),
hintText: 'Search contacts...', hintText: 'Search contacts...',
hintStyle: const TextStyle(fontSize: 13, color: Colors.grey), hintStyle: const TextStyle(fontSize: 13, color: Colors.grey),
filled: true, filled: true,
@ -324,46 +308,27 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
const Icon(Icons.tune, color: Colors.black), const Icon(Icons.tune, color: Colors.black),
Obx(() { Obx(() => _employeeController.isAllEmployeeSelected.value
return _employeeController.isAllEmployeeSelected.value ? Positioned(
? Positioned( right: -1,
right: -1, top: -1,
top: -1, child: Container(
child: Container( width: 10,
width: 10, height: 10,
height: 10, decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle),
decoration: const BoxDecoration( ),
color: Colors.red, shape: BoxShape.circle), )
), : const SizedBox.shrink()),
)
: const SizedBox.shrink();
}),
], ],
), ),
onSelected: (value) async { onSelected: (value) async {
if (value == 'all_employees') { if (value == 'all_employees') {
_employeeController.isAllEmployeeSelected.value = _employeeController.isAllEmployeeSelected.toggle();
!_employeeController.isAllEmployeeSelected.value; await _initEmployees();
if (_employeeController.isAllEmployeeSelected.value) {
_employeeController.selectedProjectId = null;
await _employeeController.fetchAllEmployees();
} else {
final selectedProjectId =
Get.find<ProjectController>().selectedProject?.id;
if (selectedProjectId != null) {
_employeeController.selectedProjectId = selectedProjectId;
await _employeeController
.fetchEmployeesByProject(selectedProjectId);
} else {
_employeeController.clearEmployees();
}
}
_filterEmployees(_searchController.text);
_employeeController.update(['employee_screen_controller']); _employeeController.update(['employee_screen_controller']);
} }
}, },
itemBuilder: (context) => [ itemBuilder: (_) => [
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'all_employees', value: 'all_employees',
child: Obx( child: Obx(
@ -371,17 +336,12 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
children: [ children: [
Checkbox( Checkbox(
value: _employeeController.isAllEmployeeSelected.value, value: _employeeController.isAllEmployeeSelected.value,
onChanged: (bool? value) => onChanged: (_) => Navigator.pop(context, 'all_employees'),
Navigator.pop(context, 'all_employees'),
checkColor: Colors.white, checkColor: Colors.white,
activeColor: Colors.red, activeColor: Colors.red,
side: const BorderSide(color: Colors.black, width: 1.5), side: const BorderSide(color: Colors.black, width: 1.5),
fillColor: MaterialStateProperty.resolveWith<Color>((states) { fillColor: MaterialStateProperty.resolveWith<Color>(
if (states.contains(MaterialState.selected)) { (states) => states.contains(MaterialState.selected) ? Colors.red : Colors.white),
return Colors.red;
}
return Colors.white;
}),
), ),
const Text('All Employees'), const Text('All Employees'),
], ],
@ -394,131 +354,95 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Widget _buildEmployeeList() { Widget _buildEmployeeList() {
return Obx(() { return Obx(() {
final isLoading = _employeeController.isLoading.value; if (_employeeController.isLoading.value) {
final employees = _filteredEmployees;
// Show skeleton loader while data is being fetched
if (isLoading) {
return ListView.separated( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
itemCount: 8, // number of skeleton items itemCount: 8,
separatorBuilder: (_, __) => MySpacing.height(12), separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, __) => SkeletonLoaders.employeeSkeletonCard(), itemBuilder: (_, __) => SkeletonLoaders.employeeSkeletonCard(),
); );
} }
// Show empty state when no employees are found final employees = _filteredEmployees;
if (employees.isEmpty) { if (employees.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 60), padding: const EdgeInsets.only(top: 60),
child: Center( child: Center(
child: MyText.bodySmall( child: MyText.bodySmall("No Employees Found", fontWeight: 600, color: Colors.grey[700]),
"No Employees Found",
fontWeight: 600,
color: Colors.grey[700],
),
), ),
); );
} }
// Show the actual employee list
return ListView.separated( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
padding: MySpacing.only(bottom: 80), padding: MySpacing.only(bottom: 80),
itemCount: employees.length, itemCount: employees.length,
separatorBuilder: (_, __) => MySpacing.height(12), separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (context, index) { itemBuilder: (_, index) {
final employee = employees[index]; final e = employees[index];
final nameParts = employee.name.trim().split(' '); final names = e.name.trim().split(' ');
final firstName = nameParts.first; final firstName = names.first;
final lastName = nameParts.length > 1 ? nameParts.last : ''; final lastName = names.length > 1 ? names.last : '';
return InkWell( return InkWell(
onTap: () => onTap: () => Get.to(() => EmployeeDetailPage(employeeId: e.id)),
Get.to(() => EmployeeDetailPage(employeeId: employee.id)), child: Row(
child: Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(vertical: 0), children: [
child: Row( Avatar(firstName: firstName, lastName: lastName, size: 35),
crossAxisAlignment: CrossAxisAlignment.start, MySpacing.width(12),
children: [ Expanded(
Avatar(firstName: firstName, lastName: lastName, size: 35), child: Column(
MySpacing.width(12), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: Column( MyText.titleSmall(e.name, fontWeight: 600, overflow: TextOverflow.ellipsis),
crossAxisAlignment: CrossAxisAlignment.start, if (e.jobRole.isNotEmpty)
children: [ MyText.bodySmall(e.jobRole, color: Colors.grey[700], overflow: TextOverflow.ellipsis),
MyText.titleSmall( MySpacing.height(8),
employee.name, if (e.email.isNotEmpty && e.email != '-')
fontWeight: 600, _buildLinkRow(icon: Icons.email_outlined, text: e.email, onTap: () => LauncherUtils.launchEmail(e.email), onLongPress: () => LauncherUtils.copyToClipboard(e.email, typeLabel: 'Email')),
overflow: TextOverflow.ellipsis, if (e.email.isNotEmpty && e.email != '-') MySpacing.height(6),
), if (e.phoneNumber.isNotEmpty)
if (employee.jobRole.isNotEmpty) _buildLinkRow(icon: Icons.phone_outlined, text: e.phoneNumber, onTap: () => LauncherUtils.launchPhone(e.phoneNumber), onLongPress: () => LauncherUtils.copyToClipboard(e.phoneNumber, typeLabel: 'Phone')),
MyText.bodySmall( ],
employee.jobRole,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
MySpacing.height(8),
if (employee.email.isNotEmpty && employee.email != '-')
GestureDetector(
onTap: () =>
LauncherUtils.launchEmail(employee.email),
onLongPress: () => LauncherUtils.copyToClipboard(
employee.email,
typeLabel: 'Email'),
child: Row(
children: [
const Icon(Icons.email_outlined,
size: 16, color: Colors.indigo),
MySpacing.width(4),
ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 180),
child: MyText.labelSmall(
employee.email,
overflow: TextOverflow.ellipsis,
color: Colors.indigo,
decoration: TextDecoration.underline,
),
),
],
),
),
if (employee.email.isNotEmpty && employee.email != '-')
MySpacing.height(6),
if (employee.phoneNumber.isNotEmpty)
GestureDetector(
onTap: () =>
LauncherUtils.launchPhone(employee.phoneNumber),
onLongPress: () => LauncherUtils.copyToClipboard(
employee.phoneNumber,
typeLabel: 'Phone'),
child: Row(
children: [
const Icon(Icons.phone_outlined,
size: 16, color: Colors.indigo),
MySpacing.width(4),
MyText.labelSmall(
employee.phoneNumber,
color: Colors.indigo,
decoration: TextDecoration.underline,
),
],
),
),
],
),
), ),
const Icon(Icons.arrow_forward_ios, ),
color: Colors.grey, size: 16), const Icon(Icons.arrow_forward_ios, color: Colors.grey, size: 16),
], ],
),
), ),
); );
}, },
); );
}); });
} }
Widget _buildLinkRow({
required IconData icon,
required String text,
required VoidCallback onTap,
required VoidCallback onLongPress,
}) {
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Row(
children: [
Icon(icon, size: 16, color: Colors.indigo),
MySpacing.width(4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 180),
child: MyText.labelSmall(
text,
overflow: TextOverflow.ellipsis,
color: Colors.indigo,
decoration: TextDecoration.underline,
),
),
],
),
);
}
} }

View File

@ -0,0 +1,668 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/expense/expense_detail_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/add_expense_bottom_sheet.dart';
import 'package:marco/model/expense/comment_bottom_sheet.dart';
import 'package:marco/model/expense/expense_detail_model.dart';
import 'package:marco/model/expense/reimbursement_bottom_sheet.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/expense_detail_helpers.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/model/employee_info.dart';
class ExpenseDetailScreen extends StatefulWidget {
final String expenseId;
const ExpenseDetailScreen({super.key, required this.expenseId});
@override
State<ExpenseDetailScreen> createState() => _ExpenseDetailScreenState();
}
class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>();
EmployeeInfo? employeeInfo;
final RxBool canSubmit = false.obs;
bool _checkedPermission = false;
@override
void initState() {
super.initState();
controller.init(widget.expenseId);
_loadEmployeeInfo();
}
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;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: _AppBar(projectController: projectController),
body: SafeArea(
child: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
final statusColor = getExpenseStatusColor(expense.status.name,
colorCode: expense.status.color);
final formattedAmount = formatExpenseAmount(expense.amount);
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
8, 8, 8, 30 + MediaQuery.of(context).padding.bottom),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
elevation: 3,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 14, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InvoiceHeader(expense: expense),
const Divider(height: 30, thickness: 1.2),
_InvoiceParties(expense: expense),
const Divider(height: 30, thickness: 1.2),
_InvoiceDetailsTable(expense: expense),
const Divider(height: 30, thickness: 1.2),
_InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2),
_InvoiceTotals(
expense: expense,
formattedAmount: formattedAmount,
statusColor: statusColor,
),
],
),
),
),
),
),
);
}),
),
floatingActionButton: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
if (!_checkedPermission) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
}
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
return const SizedBox.shrink();
}
return FloatingActionButton(
onPressed: () async {
final editData = {
'id': expense.id,
'projectName': expense.project.name,
'amount': expense.amount,
'supplerName': expense.supplerName,
'description': expense.description,
'transactionId': expense.transactionId,
'location': expense.location,
'transactionDate': expense.transactionDate,
'noOfPersons': expense.noOfPersons,
'expensesTypeId': expense.expensesType.id,
'paymentModeId': expense.paymentMode.id,
'paidById': expense.paidBy.id,
'paidByFirstName': expense.paidBy.firstName,
'paidByLastName': expense.paidBy.lastName,
'attachments': expense.documents
.map((doc) => {
'url': doc.preSignedUrl,
'fileName': doc.fileName,
'documentId': doc.documentId,
'contentType': doc.contentType,
})
.toList(),
};
logSafe('editData: $editData', level: LogLevel.info);
final addCtrl = Get.put(AddExpenseController());
await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData);
await showAddExpenseBottomSheet(isEdit: true);
await controller.fetchExpenseDetails();
},
backgroundColor: Colors.red,
tooltip: 'Edit Expense',
child: const Icon(Icons.edit),
);
}),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null) return const SizedBox();
return SafeArea(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0x11000000))),
),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: expense.nextStatus.where((next) {
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final rawPermissions = next.permissionIds;
final parsedPermissions =
controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id;
logSafe(
'🔐 Permission Logic:\n'
'🔸 Status: ${next.name}\n'
'🔸 Status ID: ${next.id}\n'
'🔸 Parsed Permissions: $parsedPermissions\n'
'🔸 Is Submit: $isSubmitStatus\n'
'🔸 Created By Current User: $isCreatedByCurrentUser',
level: LogLevel.debug,
);
if (isSubmitStatus) {
// Submit can be done ONLY by the creator
return isCreatedByCurrentUser;
}
// All other statuses - check permission normally
return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) {
return _statusButton(context, controller, expense, next);
}).toList(),
),
),
);
}),
);
}
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
ExpenseDetailModel expense, dynamic next) {
Color buttonColor = Colors.red;
if (next.color.isNotEmpty) {
try {
buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff')));
} catch (_) {}
}
DateTime onlyDate(DateTime date) {
return DateTime(date.year, date.month, date.day);
}
return ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 40),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
backgroundColor: buttonColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
onPressed: () async {
const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27';
if (expense.status.id == reimbursementId) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) => ReimbursementBottomSheet(
expenseId: expense.id,
statusId: next.id,
onClose: () {},
onSubmit: ({
required String comment,
required String reimburseTransactionId,
required String reimburseDate,
required String reimburseById,
required String statusId,
}) async {
final transactionDate = DateTime.tryParse(
controller.expense.value?.transactionDate ?? '');
final selectedReimburseDate =
DateTime.tryParse(reimburseDate);
final today = DateTime.now();
if (transactionDate == null ||
selectedReimburseDate == null) {
showAppSnackbar(
title: 'Invalid date',
message:
'Could not parse transaction or reimbursement date.',
type: SnackbarType.error,
);
return false;
}
if (onlyDate(selectedReimburseDate)
.isBefore(onlyDate(transactionDate))) {
showAppSnackbar(
title: 'Invalid Date',
message:
'Reimbursement date cannot be before the transaction date.',
type: SnackbarType.error,
);
return false;
}
if (onlyDate(selectedReimburseDate)
.isAfter(onlyDate(today))) {
showAppSnackbar(
title: 'Invalid Date',
message: 'Reimbursement date cannot be in the future.',
type: SnackbarType.error,
);
return false;
}
final success =
await controller.updateExpenseStatusWithReimbursement(
comment: comment,
reimburseTransactionId: reimburseTransactionId,
reimburseDate: reimburseDate,
reimburseById: reimburseById,
statusId: statusId,
);
if (success) {
Navigator.of(context).pop();
showAppSnackbar(
title: 'Success',
message: 'Expense reimbursed successfully.',
type: SnackbarType.success,
);
await controller.fetchExpenseDetails();
return true;
} else {
showAppSnackbar(
title: 'Error',
message: 'Failed to reimburse expense.',
type: SnackbarType.error,
);
return false;
}
}),
);
} else {
final comment = await showCommentBottomSheet(context, next.name);
if (comment == null) return;
final success =
await controller.updateExpenseStatus(next.id, comment: comment);
if (success) {
showAppSnackbar(
title: 'Success',
message:
'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}',
type: SnackbarType.success);
await controller.fetchExpenseDetails();
} else {
showAppSnackbar(
title: 'Error',
message: 'Failed to update status.',
type: SnackbarType.error);
}
}
},
child: MyText.labelMedium(
next.displayName.isNotEmpty ? next.displayName : next.name,
color: Colors.white,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
);
}
}
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController;
const _AppBar({required this.projectController});
@override
Widget build(BuildContext context) {
return AppBar(
automaticallyImplyLeading: false,
elevation: 1,
backgroundColor: Colors.white,
title: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Expense Details',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _InvoiceHeader extends StatelessWidget {
final ExpenseDetailModel expense;
const _InvoiceHeader({required this.expense});
@override
Widget build(BuildContext context) {
final dateString = DateTimeUtils.convertUtcToLocal(
expense.transactionDate.toString(),
format: 'dd-MM-yyyy');
final statusColor = getExpenseStatusColor(expense.status.name,
colorCode: expense.status.color);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Row(children: [
const Icon(Icons.calendar_month, size: 18, color: Colors.grey),
MySpacing.width(6),
MyText.bodySmall('Date:', fontWeight: 600),
MySpacing.width(6),
MyText.bodySmall(dateString, fontWeight: 600),
]),
Container(
decoration: BoxDecoration(
color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: Row(
children: [
Icon(Icons.flag, size: 16, color: statusColor),
MySpacing.width(4),
MyText.labelSmall(expense.status.name,
color: statusColor, fontWeight: 600),
],
),
),
])
],
);
}
}
class _InvoiceParties extends StatelessWidget {
final ExpenseDetailModel expense;
const _InvoiceParties({required this.expense});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
labelValueBlock('Project', expense.project.name),
MySpacing.height(16),
labelValueBlock('Paid By:',
'${expense.paidBy.firstName} ${expense.paidBy.lastName}'),
MySpacing.height(16),
labelValueBlock('Supplier', expense.supplerName),
MySpacing.height(16),
labelValueBlock('Created By:',
'${expense.createdBy.firstName} ${expense.createdBy.lastName}'),
],
);
}
}
class _InvoiceDetailsTable extends StatelessWidget {
final ExpenseDetailModel expense;
const _InvoiceDetailsTable({required this.expense});
@override
Widget build(BuildContext context) {
final transactionDate = DateTimeUtils.convertUtcToLocal(
expense.transactionDate.toString(),
format: 'dd-MM-yyyy hh:mm a');
final createdAt = DateTimeUtils.convertUtcToLocal(
expense.createdAt.toString(),
format: 'dd-MM-yyyy hh:mm a');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailItem("Expense Type:", expense.expensesType.name),
_detailItem("Payment Mode:", expense.paymentMode.name),
_detailItem("Transaction Date:", transactionDate),
_detailItem("Created At:", createdAt),
_detailItem("Pre-Approved:", expense.preApproved ? 'Yes' : 'No'),
_detailItem("Description:",
expense.description.trim().isNotEmpty ? expense.description : '-',
isDescription: true),
],
);
}
Widget _detailItem(String title, String value,
{bool isDescription = false}) =>
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(title, fontWeight: 600),
MySpacing.height(3),
isDescription
? ExpandableDescription(description: value)
: MyText.bodySmall(value, fontWeight: 500),
],
),
);
}
class _InvoiceDocuments extends StatelessWidget {
final List<ExpenseDocument> documents;
const _InvoiceDocuments({required this.documents});
@override
Widget build(BuildContext context) {
if (documents.isEmpty)
return MyText.bodyMedium('No Supporting Documents', color: Colors.grey);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall("Supporting Documents:", fontWeight: 600),
const SizedBox(height: 12),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: documents.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final doc = documents[index];
return GestureDetector(
onTap: () async {
final imageDocs = documents
.where((d) => d.contentType.startsWith('image/'))
.toList();
final initialIndex =
imageDocs.indexWhere((d) => d.documentId == doc.documentId);
if (imageDocs.isNotEmpty && initialIndex != -1) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources:
imageDocs.map((e) => e.preSignedUrl).toList(),
initialIndex: initialIndex,
),
);
} else {
final Uri url = Uri.parse(doc.preSignedUrl);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
showAppSnackbar(
title: 'Error',
message: 'Could not open the document.',
type: SnackbarType.error);
}
}
},
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
color: Colors.grey.shade100,
),
child: Row(
children: [
Icon(
doc.contentType.startsWith('image/')
? Icons.image
: Icons.insert_drive_file,
size: 20,
color: Colors.grey[600],
),
const SizedBox(width: 7),
Expanded(
child: MyText.labelSmall(
doc.fileName,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
},
),
],
);
}
}
class ExpensePermissionHelper {
static bool canEditExpense(
EmployeeInfo? employee, ExpenseDetailModel expense) {
return employee?.id == expense.createdBy.id &&
_isInAllowedEditStatus(expense.status.id);
}
static bool canSubmitExpense(
EmployeeInfo? employee, ExpenseDetailModel expense) {
return employee?.id == expense.createdBy.id &&
expense.nextStatus.isNotEmpty;
}
static bool _isInAllowedEditStatus(String statusId) {
const editableStatusIds = [
"d1ee5eec-24b6-4364-8673-a8f859c60729",
"965eda62-7907-4963-b4a1-657fb0b2724b",
"297e0d8f-f668-41b5-bfea-e03b354251c8"
];
return editableStatusIds.contains(statusId);
}
}
class _InvoiceTotals extends StatelessWidget {
final ExpenseDetailModel expense;
final String formattedAmount;
final Color statusColor;
const _InvoiceTotals({
required this.expense,
required this.formattedAmount,
required this.statusColor,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
MyText.bodyLarge("Total:", fontWeight: 700),
const Spacer(),
MyText.bodyLarge(formattedAmount, fontWeight: 700),
],
);
}
}

View File

@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart';
class ExpenseFilterBottomSheet extends StatelessWidget {
final ExpenseController expenseController;
final ScrollController scrollController;
const ExpenseFilterBottomSheet({
super.key,
required this.expenseController,
required this.scrollController,
});
// FIX: create search adapter
Future<List<EmployeeModel>> searchEmployeesForBottomSheet(
String query) async {
await expenseController
.searchEmployees(query); // async method, returns void
return expenseController.employeeSearchResults.toList();
}
@override
Widget build(BuildContext context) {
return Obx(() {
return BaseBottomSheet(
title: 'Filter Expenses',
onCancel: () => Get.back(),
onSubmit: () {
expenseController.fetchExpenses();
Get.back();
},
submitText: 'Submit',
submitColor: Colors.indigo,
submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView(
controller: scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => expenseController.clearFilters(),
child: MyText(
"Reset Filter",
style: MyTextStyle.labelMedium(
color: Colors.red,
fontWeight: 600,
),
),
),
),
MySpacing.height(8),
_buildProjectFilter(context),
MySpacing.height(16),
_buildStatusFilter(context),
MySpacing.height(16),
_buildDateRangeFilter(context),
MySpacing.height(16),
_buildPaidByFilter(context),
MySpacing.height(16),
_buildCreatedByFilter(context),
],
),
),
);
});
}
Widget _buildField(String label, Widget child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
child,
],
);
}
Widget _buildProjectFilter(BuildContext context) {
return _buildField(
"Project",
_popupSelector(
context,
currentValue: expenseController.selectedProject.value.isEmpty
? 'Select Project'
: expenseController.selectedProject.value,
items: expenseController.globalProjects,
onSelected: (value) => expenseController.selectedProject.value = value,
),
);
}
Widget _buildStatusFilter(BuildContext context) {
return _buildField(
"Expense Status",
_popupSelector(
context,
currentValue: expenseController.selectedStatus.value.isEmpty
? 'Select Expense Status'
: expenseController.expenseStatuses
.firstWhereOrNull(
(e) => e.id == expenseController.selectedStatus.value)
?.name ??
'Select Expense Status',
items: expenseController.expenseStatuses.map((e) => e.name).toList(),
onSelected: (name) {
final status = expenseController.expenseStatuses
.firstWhere((e) => e.name == name);
expenseController.selectedStatus.value = status.id;
},
),
);
}
Widget _buildDateRangeFilter(BuildContext context) {
return _buildField(
"Date Filter",
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
return SegmentedButton<String>(
segments: expenseController.dateTypes
.map(
(type) => ButtonSegment(
value: type,
label: MyText(
type,
style: MyTextStyle.bodySmall(
fontWeight: 600,
fontSize: 13,
height: 1.2,
),
),
),
)
.toList(),
selected: {expenseController.selectedDateType.value},
onSelectionChanged: (newSelection) {
if (newSelection.isNotEmpty) {
expenseController.selectedDateType.value = newSelection.first;
}
},
style: ButtonStyle(
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
backgroundColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? Colors.indigo.shade100
: Colors.grey.shade100,
),
foregroundColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? Colors.indigo
: Colors.black87,
),
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
side: MaterialStateProperty.resolveWith(
(states) => BorderSide(
color: states.contains(MaterialState.selected)
? Colors.indigo
: Colors.grey.shade300,
width: 1,
),
),
),
);
}),
MySpacing.height(16),
Row(
children: [
Expanded(
child: _dateButton(
label: expenseController.startDate.value == null
? 'Start Date'
: DateTimeUtils.formatDate(
expenseController.startDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
context,
expenseController.startDate,
lastDate: expenseController.endDate.value,
),
),
),
MySpacing.width(12),
Expanded(
child: _dateButton(
label: expenseController.endDate.value == null
? 'End Date'
: DateTimeUtils.formatDate(
expenseController.endDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
context,
expenseController.endDate,
firstDate: expenseController.startDate.value,
),
),
),
],
),
],
),
);
}
Widget _buildPaidByFilter(BuildContext context) {
return _buildField(
"Paid By",
_employeeSelector(
context: context,
selectedEmployees: expenseController.selectedPaidByEmployees,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
title: 'Search Paid By',
),
);
}
Widget _buildCreatedByFilter(BuildContext context) {
return _buildField(
"Created By",
_employeeSelector(
context: context,
selectedEmployees: expenseController.selectedCreatedByEmployees,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
title: 'Search Created By',
),
);
}
Future<void> _selectDate(
BuildContext context,
Rx<DateTime?> dateNotifier, {
DateTime? firstDate,
DateTime? lastDate,
}) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: dateNotifier.value ?? DateTime.now(),
firstDate: firstDate ?? DateTime(2020),
lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365)),
);
if (picked != null && picked != dateNotifier.value) {
dateNotifier.value = picked;
}
}
Widget _popupSelector(
BuildContext context, {
required String currentValue,
required List<String> items,
required ValueChanged<String> onSelected,
}) {
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: onSelected,
itemBuilder: (context) => items
.map((e) => PopupMenuItem<String>(
value: e,
child: MyText(e),
))
.toList(),
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: MyText(
currentValue,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
}
Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: MySpacing.xy(16, 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
MySpacing.width(8),
Expanded(
child: MyText(
label,
style: MyTextStyle.bodyMedium(),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Future<void> _showEmployeeSelectorBottomSheet({
required BuildContext context,
required RxList<EmployeeModel> selectedEmployees,
required Future<List<EmployeeModel>> Function(String) searchEmployees,
String title = 'Select Employee',
}) async {
final List<EmployeeModel>? result =
await showModalBottomSheet<List<EmployeeModel>>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => EmployeeSelectorBottomSheet(
selectedEmployees: selectedEmployees,
searchEmployees: searchEmployees,
title: title,
),
);
if (result != null) {
selectedEmployees.assignAll(result);
}
}
Widget _employeeSelector({
required BuildContext context,
required RxList<EmployeeModel> selectedEmployees,
required Future<List<EmployeeModel>> Function(String) searchEmployees,
String title = 'Search Employee',
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
if (selectedEmployees.isEmpty) {
return const SizedBox.shrink();
}
return Wrap(
spacing: 8,
children: selectedEmployees
.map((emp) => Chip(
label: MyText(emp.name),
onDeleted: () => selectedEmployees.remove(emp),
))
.toList(),
);
}),
MySpacing.height(8),
GestureDetector(
onTap: () => _showEmployeeSelectorBottomSheet(
context: context,
selectedEmployees: selectedEmployees,
searchEmployees: searchEmployees,
title: title,
),
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
const Icon(Icons.search, color: Colors.grey),
MySpacing.width(8),
Expanded(child: MyText(title)),
],
),
),
),
],
);
}
}

View File

@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/model/expense/add_expense_bottom_sheet.dart';
import 'package:marco/view/expense/expense_filter_bottom_sheet.dart';
import 'package:marco/helpers/widgets/expense_main_components.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
class ExpenseMainScreen extends StatefulWidget {
const ExpenseMainScreen({super.key});
@override
State<ExpenseMainScreen> createState() => _ExpenseMainScreenState();
}
class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
bool isHistoryView = false;
final searchController = TextEditingController();
final expenseController = Get.put(ExpenseController());
final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>();
@override
void initState() {
super.initState();
expenseController.fetchExpenses();
}
void _refreshExpenses() => expenseController.fetchExpenses();
void _openFilterBottomSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => ExpenseFilterBottomSheet(
expenseController: expenseController,
scrollController: ScrollController(),
),
);
}
List<ExpenseModel> _getFilteredExpenses() {
final query = searchController.text.trim().toLowerCase();
final now = DateTime.now();
final filtered = expenseController.expenses.where((e) {
return query.isEmpty ||
e.expensesType.name.toLowerCase().contains(query) ||
e.supplerName.toLowerCase().contains(query) ||
e.paymentMode.name.toLowerCase().contains(query);
}).toList()
..sort((a, b) => b.transactionDate.compareTo(a.transactionDate));
return isHistoryView
? filtered
.where((e) =>
e.transactionDate.isBefore(DateTime(now.year, now.month)))
.toList()
: filtered
.where((e) =>
e.transactionDate.month == now.month &&
e.transactionDate.year == now.year)
.toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: ExpenseAppBar(projectController: projectController),
body: SafeArea(
child: Column(
children: [
SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
onRefreshTap: _refreshExpenses,
expenseController: expenseController,
),
ToggleButtonsRow(
isHistoryView: isHistoryView,
onToggle: (v) => setState(() => isHistoryView = v),
),
Expanded(
child: Obx(() {
if (expenseController.isLoading.value &&
expenseController.expenses.isEmpty) {
return SkeletonLoaders.expenseListSkeletonLoader();
}
if (expenseController.errorMessage.isNotEmpty) {
return Center(
child: MyText.bodyMedium(
expenseController.errorMessage.value,
color: Colors.red,
),
);
}
final filteredList = _getFilteredExpenses();
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification scrollInfo) {
if (scrollInfo.metrics.pixels ==
scrollInfo.metrics.maxScrollExtent &&
!expenseController.isLoading.value) {
expenseController.loadMoreExpenses();
}
return false;
},
child: ExpenseList(
expenseList: filteredList,
onViewDetail: () => expenseController.fetchExpenses(),
),
);
}),
),
],
),
),
// FAB only if user has expenseUpload permission
floatingActionButton:
permissionController.hasPermission(Permissions.expenseUpload)
? FloatingActionButton(
backgroundColor: Colors.red,
onPressed: showAddExpenseBottomSheet,
child: const Icon(Icons.add, color: Colors.white),
)
: null,
);
}
}

View File

@ -138,7 +138,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
_buildActionBar(), _buildActionBar(),
Padding( Padding(
padding: MySpacing.x(flexSpacing), padding: MySpacing.x(8),
child: _buildDailyProgressReportTab(), child: _buildDailyProgressReportTab(),
), ),
], ],
@ -158,9 +158,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
children: [ children: [
_buildActionItem( _buildActionItem(
label: "Filter", label: "Filter",
icon: Icons.filter_list_alt, icon: Icons.tune,
tooltip: 'Filter Project', tooltip: 'Filter Project',
color: Colors.blueAccent,
onTap: _openFilterSheet, onTap: _openFilterSheet,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -181,7 +180,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
required IconData icon, required IconData icon,
required String tooltip, required String tooltip,
required VoidCallback onTap, required VoidCallback onTap,
required Color color, Color? color,
}) { }) {
return Row( return Row(
children: [ children: [
@ -189,13 +188,13 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
Tooltip( Tooltip(
message: tooltip, message: tooltip,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(22),
onTap: onTap, onTap: onTap,
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Icon(icon, color: color, size: 28), child: Icon(icon, color: color, size: 22),
), ),
), ),
), ),
@ -205,29 +204,27 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
} }
Future<void> _openFilterSheet() async { Future<void> _openFilterSheet() async {
final result = await showModalBottomSheet<Map<String, dynamic>>( final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.white, backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder( builder: (context) => DailyProgressReportFilter(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)), controller: dailyTaskController,
), permissionController: permissionController,
builder: (context) => DailyProgressReportFilter( ),
controller: dailyTaskController, );
permissionController: permissionController,
),
);
if (result != null) { if (result != null) {
final selectedProjectId = result['projectId'] as String?; final selectedProjectId = result['projectId'] as String?;
if (selectedProjectId != null && if (selectedProjectId != null &&
selectedProjectId != dailyTaskController.selectedProjectId) { selectedProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = selectedProjectId; dailyTaskController.selectedProjectId = selectedProjectId;
await dailyTaskController.fetchTaskData(selectedProjectId); await dailyTaskController.fetchTaskData(selectedProjectId);
dailyTaskController.update(['daily_progress_report_controller']); dailyTaskController.update(['daily_progress_report_controller']);
}
} }
} }
}
Future<void> _refreshData() async { Future<void> _refreshData() async {
final projectId = dailyTaskController.selectedProjectId; final projectId = dailyTaskController.selectedProjectId;
@ -318,7 +315,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
..sort((a, b) => b.compareTo(a)); ..sort((a, b) => b.compareTo(a));
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 4, borderRadiusAll: 10,
border: Border.all(color: Colors.grey.withOpacity(0.2)), border: Border.all(color: Colors.grey.withOpacity(0.2)),
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
paddingAll: 8, paddingAll: 8,

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/theme/app_theme.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/my_shadow.dart';
import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
@ -160,7 +159,7 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
), ),
), ),
Padding( Padding(
padding: MySpacing.x(flexSpacing), padding: MySpacing.x(8),
child: dailyProgressReportTab(), child: dailyProgressReportTab(),
), ),
], ],
@ -232,10 +231,9 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
final buildingKey = building.id.toString(); final buildingKey = building.id.toString();
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 12, borderRadiusAll: 10,
paddingAll: 0, paddingAll: 0,
margin: MySpacing.bottom(12), margin: MySpacing.bottom(10),
shadow: MyShadow(elevation: 3),
child: Theme( child: Theme(
data: Theme.of(context) data: Theme.of(context)
.copyWith(dividerColor: Colors.transparent), .copyWith(dividerColor: Colors.transparent),

View File

@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "marco") set(BINARY_NAME "marco")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.marco") set(APPLICATION_ID "com.marco.aiotstage")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@ -385,7 +385,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
@ -399,7 +399,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";
@ -413,7 +413,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco";

View File

@ -8,7 +8,7 @@
PRODUCT_NAME = marco PRODUCT_NAME = marco
// The application's bundle identifier // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.marco PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage
// The copyright displayed in application information // The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved.

View File

@ -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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.0.0+5
environment: environment:
sdk: ^3.5.3 sdk: ^3.5.3
@ -78,6 +78,7 @@ dependencies:
flutter_quill_delta_from_html: ^1.5.2 flutter_quill_delta_from_html: ^1.5.2
quill_delta: ^3.0.0-nullsafety.2 quill_delta: ^3.0.0-nullsafety.2
connectivity_plus: ^6.1.4 connectivity_plus: ^6.1.4
geocoding: ^4.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter