diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index 9f3059c..40a0e77 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -9,6 +9,7 @@ class LoginController extends MyController { MyFormValidator basicValidator = MyFormValidator(); bool showPassword = false, isChecked = false; + RxBool isLoading = false.obs; // Add reactive loading state final String _dummyEmail = "admin@marcobms.com"; final String _dummyPassword = "User@123"; @@ -16,10 +17,7 @@ class LoginController extends MyController { @override void onInit() { basicValidator.addField('username', required: true, label: "User_Name", validators: [MyEmailValidator()], controller: TextEditingController(text: _dummyEmail)); - - basicValidator.addField('password', - required: true, label: "Password", validators: [MyLengthValidator(min: 6, max: 10)], controller: TextEditingController(text: _dummyPassword)); - + basicValidator.addField('password', required: true, label: "Password", validators: [MyLengthValidator(min: 6, max: 10)], controller: TextEditingController(text: _dummyPassword)); super.onInit(); } @@ -35,7 +33,10 @@ class LoginController extends MyController { Future onLogin() async { if (basicValidator.validateForm()) { + // Set loading to true + isLoading.value = true; update(); + var errors = await AuthService.loginUser(basicValidator.getData()); if (errors != null) { basicValidator.addErrors(errors); @@ -45,6 +46,9 @@ class LoginController extends MyController { String nextUrl = Uri.parse(ModalRoute.of(Get.context!)?.settings.name ?? "").queryParameters['next'] ?? "/home"; Get.toNamed(nextUrl); } + + // Set loading to false after the API call is complete + isLoading.value = false; update(); } } @@ -57,3 +61,4 @@ class LoginController extends MyController { Get.offAndToNamed('/auth/register_account'); } } + diff --git a/lib/controller/dashboard/attendance_screen_controller.dart b/lib/controller/dashboard/attendance_screen_controller.dart index 3f36f1f..6408c14 100644 --- a/lib/controller/dashboard/attendance_screen_controller.dart +++ b/lib/controller/dashboard/attendance_screen_controller.dart @@ -24,6 +24,8 @@ class AttendanceController extends GetxController { List regularizationLogs = []; List attendenceLogsView = []; + RxBool isLoading = false.obs; // Added loading flag + @override void onInit() { super.onInit(); @@ -42,7 +44,9 @@ class AttendanceController extends GetxController { } Future fetchProjects() async { + isLoading.value = true; // Set loading to true before API call final response = await ApiService.getProjects(); + isLoading.value = false; // Set loading to false after API call completes if (response != null && response.isNotEmpty) { projects = response.map((json) => ProjectModel.fromJson(json)).toList(); @@ -57,19 +61,22 @@ class AttendanceController extends GetxController { Future _fetchProjectData(String? projectId) async { if (projectId == null) return; + isLoading.value = true; // Set loading to true before API call await Future.wait([ fetchEmployeesByProject(projectId), - fetchAttendanceLogs(projectId, - dateFrom: startDateAttendance, dateTo: endDateAttendance), + fetchAttendanceLogs(projectId, dateFrom: startDateAttendance, dateTo: endDateAttendance), fetchRegularizationLogs(projectId), ]); + isLoading.value = false; // Set loading to false after data is fetched } Future fetchEmployeesByProject(String? projectId) async { if (projectId == null) return; - final response = - await ApiService.getEmployeesByProject(int.parse(projectId)); + isLoading.value = true; // Set loading to true before API call + final response = await ApiService.getEmployeesByProject(int.parse(projectId)); + isLoading.value = false; // Set loading to false after API call completes + if (response != null) { employees = response.map((json) => EmployeeModel.fromJson(json)).toList(); update(); @@ -84,20 +91,25 @@ class AttendanceController extends GetxController { int projectId, { String comment = "Marked via mobile app", required int action, + bool imageCapture = true, // <- add this flag }) async { try { - final image = await ImagePicker().pickImage( - source: ImageSource.camera, - imageQuality: 80, - ); - if (image == null) return false; + XFile? image; + if (imageCapture) { + image = await ImagePicker().pickImage( + source: ImageSource.camera, + imageQuality: 80, + ); + if (image == null) return false; + } final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, ); - final imageName = - ApiService.generateImageName(employeeId, employees.length + 1); + final imageName = imageCapture + ? ApiService.generateImageName(employeeId, employees.length + 1) + : ""; // Empty or null if not capturing image return await ApiService.uploadAttendanceImage( id, @@ -109,6 +121,7 @@ class AttendanceController extends GetxController { projectId: projectId, comment: comment, action: action, + imageCapture: imageCapture, // <- pass flag down ); } catch (e) { print("Error capturing or uploading attendance: $e"); @@ -125,8 +138,7 @@ class AttendanceController extends GetxController { firstDate: DateTime(2022), lastDate: DateTime.now(), initialDateRange: DateTimeRange( - start: startDateAttendance ?? - DateTime.now().subtract(const Duration(days: 7)), + start: startDateAttendance ?? DateTime.now().subtract(const Duration(days: 7)), end: endDateAttendance ?? DateTime.now(), ), ); @@ -150,15 +162,16 @@ class AttendanceController extends GetxController { }) async { if (projectId == null) return; + isLoading.value = true; // Set loading to true before API call final response = await ApiService.getAttendanceLogs( int.parse(projectId), dateFrom: dateFrom, dateTo: dateTo, ); + isLoading.value = false; // Set loading to false after API call completes if (response != null) { - attendanceLogs = - response.map((json) => AttendanceLogModel.fromJson(json)).toList(); + attendanceLogs = response.map((json) => AttendanceLogModel.fromJson(json)).toList(); print("Attendance logs fetched: ${response}"); update(); } else { @@ -173,8 +186,9 @@ class AttendanceController extends GetxController { }) async { if (projectId == null) return; - final response = - await ApiService.getRegularizationLogs(int.parse(projectId)); + isLoading.value = true; // Set loading to true before API call + final response = await ApiService.getRegularizationLogs(int.parse(projectId)); + isLoading.value = false; // Set loading to false after API call completes if (response != null) { regularizationLogs = response @@ -189,8 +203,9 @@ class AttendanceController extends GetxController { Future fetchLogsView(String? id) async { if (id == null) return; - final response = - await ApiService.getAttendanceLogView(int.parse(id)); + isLoading.value = true; // Set loading to true before API call + final response = await ApiService.getAttendanceLogView(int.parse(id)); + isLoading.value = false; // Set loading to false after API call completes if (response != null) { attendenceLogsView = response diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 41c966b..41ebfb0 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -128,7 +128,7 @@ class ApiService { final fileSize = await imageFile.length(); final contentType = "image/${imageFile.path.split('.').last}"; - final imageObject = { + imageObject = { "fileName": '$imageName', "contentType": '$contentType', "fileSize": fileSize, @@ -147,10 +147,15 @@ class ApiService { "comment": comment, "action": action, "date": DateFormat('yyyy-MM-dd').format(now), - "latitude": '$latitude', - "longitude": '$longitude', }; + // Only include latitude and longitude if imageCapture is true + if (imageCapture) { + body["latitude"] = '$latitude'; + body["longitude"] = '$longitude'; + } + + // Only add imageObject if it's not null if (imageObject != null) { body["image"] = imageObject; } diff --git a/lib/helpers/services/storage/local_storage.dart b/lib/helpers/services/storage/local_storage.dart index 1b3b827..f4b6b63 100644 --- a/lib/helpers/services/storage/local_storage.dart +++ b/lib/helpers/services/storage/local_storage.dart @@ -3,7 +3,7 @@ import 'package:marco/helpers/services/localizations/language.dart'; import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:marco/model/user_permission.dart'; -import 'package:marco/model/employee_info.dart'; // Import the EmployeeInfo model +import 'package:marco/model/employee_info.dart'; import 'dart:convert'; class LocalStorage { static const String _loggedInUserKey = "user"; diff --git a/lib/helpers/utils/attendance_actions.dart b/lib/helpers/utils/attendance_actions.dart new file mode 100644 index 0000000..6abf4ea --- /dev/null +++ b/lib/helpers/utils/attendance_actions.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +// Define action texts +class ButtonActions { + static const String checkIn = "Check In"; + static const String checkOut = "Check Out"; + static const String requestRegularize = " Request Regularize"; + static const String rejected = "Rejected"; + static const String approved = "Approved"; + static const String requested = "Requested"; + static const String approve = "Approve"; + static const String reject = "Reject"; +} + +// Map action texts to colors +class AttendanceActionColors { + static const Map colors = { + ButtonActions.checkIn: Colors.green, + ButtonActions.checkOut: Colors.red, + ButtonActions.requestRegularize: Colors.blue, + ButtonActions.rejected: Colors.orange, + ButtonActions.approved: Colors.green, + ButtonActions.requested: Colors.yellow, + ButtonActions.approve: Colors.blueAccent, + ButtonActions.reject: Colors.pink, + }; +} diff --git a/lib/model/attendance_log_model.dart b/lib/model/attendance_log_model.dart index 9f965ff..d7c284b 100644 --- a/lib/model/attendance_log_model.dart +++ b/lib/model/attendance_log_model.dart @@ -5,6 +5,7 @@ class AttendanceLogModel { final DateTime? checkOut; final int activity; final int id; + final int employeeId; AttendanceLogModel({ required this.name, @@ -13,6 +14,7 @@ class AttendanceLogModel { this.checkOut, required this.activity, required this.id, + required this.employeeId, }); factory AttendanceLogModel.fromJson(Map json) { @@ -23,6 +25,7 @@ class AttendanceLogModel { checkOut: json['checkOutTime'] != null ? DateTime.tryParse(json['checkOutTime']) : null, activity: json['activity'] ?? 0, id: json['id'] != null ? json['id'] : null, + employeeId: json['employeeId'] != null ? json['employeeId'] : null, ); } } diff --git a/lib/model/attendance_log_view_model.dart b/lib/model/attendance_log_view_model.dart index 6380b5c..86f003e 100644 --- a/lib/model/attendance_log_view_model.dart +++ b/lib/model/attendance_log_view_model.dart @@ -2,19 +2,31 @@ import 'package:intl/intl.dart'; class AttendanceLogViewModel { final DateTime? activityTime; final String? imageUrl; - final String? description; + final String? comment; + final String? thumbPreSignedUrl; + final String? preSignedUrl; + final String? longitude; + final String? latitude; AttendanceLogViewModel({ this.activityTime, this.imageUrl, - this.description, + this.comment, + this.thumbPreSignedUrl, + this.preSignedUrl, + this.longitude, + this.latitude, }); factory AttendanceLogViewModel.fromJson(Map json) { return AttendanceLogViewModel( activityTime: json['activityTime'] != null ? DateTime.tryParse(json['activityTime']) : null, imageUrl: json['imageUrl'], - description: json['description'], + comment: json['comment'], + thumbPreSignedUrl: json['thumbPreSignedUrl'], + preSignedUrl: json['preSignedUrl'], + longitude: json['longitude'], + latitude: json['latitude'], ); } diff --git a/lib/model/my_paginated_table.dart b/lib/model/my_paginated_table.dart new file mode 100644 index 0000000..762403d --- /dev/null +++ b/lib/model/my_paginated_table.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_container.dart'; + +class MyPaginatedTable extends StatefulWidget { + final String? title; + final List columns; + final List rows; + final double columnSpacing; + final double horizontalMargin; + + const MyPaginatedTable({ + super.key, + this.title, + required this.columns, + required this.rows, + this.columnSpacing = 23, + this.horizontalMargin = 35, + }); + + @override + _MyPaginatedTableState createState() => _MyPaginatedTableState(); +} + +class _MyPaginatedTableState extends State { + int _start = 0; + int _rowsPerPage = 10; + + @override + Widget build(BuildContext context) { + final visibleRows = widget.rows.skip(_start).take(_rowsPerPage).toList(); + final totalRows = widget.rows.length; + final totalPages = (totalRows / _rowsPerPage).ceil(); + final currentPage = (_start / _rowsPerPage).ceil() + 1; + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (widget.title != null) + Padding( + padding: MySpacing.xy(8, 6), // Using standard spacing for title + child: MyText.titleMedium(widget.title!, fontWeight: 600, fontSize: 20), + ), + if (widget.rows.isEmpty) + Padding( + padding: MySpacing.all(16), // Standard padding for empty state + child: MyText.bodySmall('No data available'), + ), + if (widget.rows.isNotEmpty) + LayoutBuilder( + builder: (context, constraints) { + final spacing = _calculateSmartSpacing(constraints.maxWidth); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: MyContainer.bordered( + borderColor: Colors.black.withAlpha(40), + padding: EdgeInsets.zero, + child: DataTable( + columns: widget.columns, + rows: visibleRows, + columnSpacing: spacing, + horizontalMargin: widget.horizontalMargin, + ), + ), + ); + }, + ), + MySpacing.height(8), // Standard height spacing after table + PaginatedFooter( + currentPage: currentPage, + totalPages: totalPages, + onPrevious: () { + setState(() { + _start = (_start - _rowsPerPage).clamp(0, totalRows - _rowsPerPage); + }); + }, + onNext: () { + setState(() { + _start = (_start + _rowsPerPage).clamp(0, totalRows - _rowsPerPage); + }); + }, + onPageSizeChanged: (newRowsPerPage) { + setState(() { + _rowsPerPage = newRowsPerPage; + _start = 0; + }); + }, + ), + ], + ); + } + + double _calculateSmartSpacing(double maxWidth) { + int columnCount = widget.columns.length; + double horizontalPadding = widget.horizontalMargin * 2; + double availableWidth = maxWidth - horizontalPadding; + + // Desired min/max column spacing + const double minSpacing = 16; + const double maxSpacing = 80; + + // Total width assuming minimal spacing + double minTotalWidth = (columnCount * minSpacing) + horizontalPadding; + + if (minTotalWidth >= availableWidth) { + // Not enough room — return minimal spacing + return minSpacing; + } + + // Fit evenly within the available width + double spacing = (availableWidth / columnCount) - 40; // 40 for estimated cell content width + return spacing.clamp(minSpacing, maxSpacing); + } +} + +class PaginatedFooter extends StatelessWidget { + final int currentPage; + final int totalPages; + final VoidCallback onPrevious; + final VoidCallback onNext; + final Function(int) onPageSizeChanged; + + const PaginatedFooter({ + required this.currentPage, + required this.totalPages, + required this.onPrevious, + required this.onNext, + required this.onPageSizeChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.x(16), // Standard horizontal spacing for footer + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (currentPage > 1) + IconButton( + onPressed: onPrevious, + icon: Icon(Icons.chevron_left), + ), + Text( + 'Page $currentPage of $totalPages', + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onBackground, + ), + ), + SizedBox(width: 8), + if (currentPage < totalPages) + IconButton( + onPressed: onNext, + icon: Icon(Icons.chevron_right), + ), + SizedBox(width: 16), + PopupMenuButton( + icon: Icon(Icons.more_vert), + onSelected: (value) { + onPageSizeChanged(value); + }, + itemBuilder: (BuildContext context) { + return [5, 10, 20, 50].map((e) { + return PopupMenuItem( + value: e, + child: Text('$e rows per page'), + ); + }).toList(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/model/paginated_table.dart b/lib/model/paginated_table.dart new file mode 100644 index 0000000..52b2e83 --- /dev/null +++ b/lib/model/paginated_table.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +class PaginatedTableWidget extends StatefulWidget { + final List columns; + final List rows; + final int initialRowsPerPage; + final Function(int, int)? onPageChanged; + final double columnSpacing; + + const PaginatedTableWidget({ + required this.columns, + required this.rows, + this.initialRowsPerPage = 5, + this.onPageChanged, + this.columnSpacing = 16.0, // Default column spacing + }); + + @override + _PaginatedTableWidgetState createState() => _PaginatedTableWidgetState(); +} + +class _PaginatedTableWidgetState extends State { + late int rowsPerPage; + + @override + void initState() { + super.initState(); + rowsPerPage = widget.initialRowsPerPage; + } + + @override + Widget build(BuildContext context) { + return PaginatedDataTable( + rowsPerPage: rowsPerPage, + availableRowsPerPage: [5, 10, 20, 50, 100], + onRowsPerPageChanged: (newRowsPerPage) { + setState(() { + rowsPerPage = newRowsPerPage ?? rowsPerPage; + }); + }, + columns: widget.columns, + source: _DataTableSource( + rows: widget.rows, + ), + columnSpacing: widget.columnSpacing, + ); + } +} + +class _DataTableSource extends DataTableSource { + final List rows; + + _DataTableSource({required this.rows}); + + @override + DataRow? getRow(int index) { + if (index >= rows.length) { + return null; + } + return rows[index]; + } + + @override + int get rowCount => rows.length; + + @override + bool get isRowCountApproximate => false; + + @override + int get selectedRowCount => 0; +} diff --git a/lib/model/regularization_log_model.dart b/lib/model/regularization_log_model.dart index 8d26b1b..c332e72 100644 --- a/lib/model/regularization_log_model.dart +++ b/lib/model/regularization_log_model.dart @@ -1,4 +1,6 @@ class RegularizationLogModel { + final int id; + final int employeeId; final String name; final String role; final DateTime? checkIn; @@ -6,6 +8,8 @@ class RegularizationLogModel { final int activity; RegularizationLogModel({ + required this.id, + required this.employeeId, required this.name, required this.role, this.checkIn, @@ -13,12 +17,19 @@ class RegularizationLogModel { required this.activity, }); + factory RegularizationLogModel.fromJson(Map json) { return RegularizationLogModel( + id: json['id'] ?? 0, + employeeId: json['employeeId'] ?? 0, name: "${json['firstName'] ?? ''} ${json['lastName'] ?? ''}".trim(), role: json['jobRoleName'] ?? '', - checkIn: json['checkInTime'] != null ? DateTime.tryParse(json['checkInTime']) : null, - checkOut: json['checkOutTime'] != null ? DateTime.tryParse(json['checkOutTime']) : null, + checkIn: json['checkInTime'] != null + ? DateTime.tryParse(json['checkInTime']) + : null, + checkOut: json['checkOutTime'] != null + ? DateTime.tryParse(json['checkOutTime']) + : null, activity: json['activity'] ?? 0, ); } diff --git a/lib/view/auth/login_screen.dart b/lib/view/auth/login_screen.dart index eb99c61..363c71d 100644 --- a/lib/view/auth/login_screen.dart +++ b/lib/view/auth/login_screen.dart @@ -34,192 +34,196 @@ class _LoginScreenState extends State with UIMixin { init: controller, tag: 'login_controller', builder: (controller) { - return Form( - key: controller.basicValidator.formKey, - child: SingleChildScrollView( - padding: MySpacing.xy(2, 40), - child: Container( - width: double.infinity, - padding: MySpacing.all(24), - decoration: BoxDecoration( - color: theme.colorScheme.primary.withOpacity(0.02), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: contentTheme.primary.withOpacity(0.5), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - /// Logo - Center( - child: Image.asset( - Images.logoDark, - height: 120, - fit: BoxFit.contain, - ), - ), - MySpacing.height(20), - - /// Welcome Text - Center( - child: MyText.bodyLarge("Welcome Back!", fontWeight: 600), - ), - MySpacing.height(4), - Center( - child: MyText.bodySmall("Please sign in to continue."), - ), - MySpacing.height(20), - - /// Email Field - MyText.bodySmall("Email Address", fontWeight: 600), - MySpacing.height(8), - Material( - elevation: 2, - shadowColor: contentTheme.secondary.withAlpha(30), - borderRadius: BorderRadius.circular(12), - child: TextFormField( - validator: - controller.basicValidator.getValidation('username'), - controller: - controller.basicValidator.getController('username'), - keyboardType: TextInputType.emailAddress, - style: MyTextStyle.labelMedium(), - decoration: InputDecoration( - hintText: "Enter your email", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - filled: true, - fillColor: theme.cardColor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(2), - borderSide: BorderSide.none, + return Obx(() { + return controller.isLoading.value + ? Center(child: CircularProgressIndicator()) // Show loading spinner when isLoading is true + : Form( + key: controller.basicValidator.formKey, + child: SingleChildScrollView( + padding: MySpacing.xy(2, 40), + child: Container( + width: double.infinity, + padding: MySpacing.all(24), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withOpacity(0.02), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: contentTheme.primary.withOpacity(0.5), ), - prefixIcon: const Icon(LucideIcons.mail, size: 18), - contentPadding: MySpacing.xy(12, 16), ), - ), - ), - MySpacing.height(16), - - /// Password Field Label - MyText.bodySmall("Password", fontWeight: 600), - MySpacing.height(8), - Material( - elevation: 2, - shadowColor: contentTheme.secondary.withAlpha(25), - borderRadius: BorderRadius.circular(12), - child: TextFormField( - validator: - controller.basicValidator.getValidation('password'), - controller: - controller.basicValidator.getController('password'), - keyboardType: TextInputType.visiblePassword, - obscureText: !controller.showPassword, - style: MyTextStyle.labelMedium(), - decoration: InputDecoration( - hintText: "Enter your password", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - filled: true, - fillColor: theme.cardColor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(2), - borderSide: BorderSide.none, - ), - prefixIcon: const Icon(LucideIcons.lock, size: 18), - suffixIcon: InkWell( - onTap: controller.onChangeShowPassword, - child: Icon( - controller.showPassword - ? LucideIcons.eye - : LucideIcons.eye_off, - size: 18, - ), - ), - contentPadding: MySpacing.all(3), - ), - ), - ), - - MySpacing.height(16), - - /// Remember Me + Forgot Password - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - InkWell( - onTap: () => controller - .onChangeCheckBox(!controller.isChecked), - child: Row( - children: [ - Checkbox( - onChanged: controller.onChangeCheckBox, - value: controller.isChecked, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - fillColor: WidgetStatePropertyAll( - contentTheme.secondary), - checkColor: contentTheme.onPrimary, - visualDensity: getCompactDensity, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// Logo + Center( + child: Image.asset( + Images.logoDark, + height: 120, + fit: BoxFit.contain, ), - MySpacing.width(8), - MyText.bodySmall("Remember Me"), - ], - ), - ), - MyButton.text( - onPressed: controller.goToForgotPassword, - elevation: 0, - padding: MySpacing.xy(8, 0), - splashColor: contentTheme.secondary.withAlpha(36), - child: MyText.bodySmall( - 'Forgot password?', - fontWeight: 600, - color: contentTheme.secondary, - ), - ), - ], - ), - MySpacing.height(28), + ), + MySpacing.height(20), - /// Login Button - Center( - child: MyButton.rounded( - onPressed: controller.onLogin, - elevation: 2, - padding: MySpacing.xy(24, 16), - borderRadiusAll: 16, - backgroundColor: contentTheme.primary, - child: MyText.labelMedium( - 'Login', - fontWeight: 600, - color: contentTheme.onPrimary, + /// Welcome Text + Center( + child: MyText.bodyLarge("Welcome Back!", fontWeight: 600), + ), + MySpacing.height(4), + Center( + child: MyText.bodySmall("Please sign in to continue."), + ), + MySpacing.height(20), + + /// Email Field + MyText.bodySmall("Email Address", fontWeight: 600), + MySpacing.height(8), + Material( + elevation: 2, + shadowColor: contentTheme.secondary.withAlpha(30), + borderRadius: BorderRadius.circular(12), + child: TextFormField( + validator: + controller.basicValidator.getValidation('username'), + controller: + controller.basicValidator.getController('username'), + keyboardType: TextInputType.emailAddress, + style: MyTextStyle.labelMedium(), + decoration: InputDecoration( + hintText: "Enter your email", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: theme.cardColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(2), + borderSide: BorderSide.none, + ), + prefixIcon: const Icon(LucideIcons.mail, size: 18), + contentPadding: MySpacing.xy(12, 16), + ), + ), + ), + MySpacing.height(16), + + /// Password Field Label + MyText.bodySmall("Password", fontWeight: 600), + MySpacing.height(8), + Material( + elevation: 2, + shadowColor: contentTheme.secondary.withAlpha(25), + borderRadius: BorderRadius.circular(12), + child: TextFormField( + validator: + controller.basicValidator.getValidation('password'), + controller: + controller.basicValidator.getController('password'), + keyboardType: TextInputType.visiblePassword, + obscureText: !controller.showPassword, + style: MyTextStyle.labelMedium(), + decoration: InputDecoration( + hintText: "Enter your password", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: theme.cardColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(2), + borderSide: BorderSide.none, + ), + prefixIcon: const Icon(LucideIcons.lock, size: 18), + suffixIcon: InkWell( + onTap: controller.onChangeShowPassword, + child: Icon( + controller.showPassword + ? LucideIcons.eye + : LucideIcons.eye_off, + size: 18, + ), + ), + contentPadding: MySpacing.all(3), + ), + ), + ), + + MySpacing.height(16), + + /// Remember Me + Forgot Password + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InkWell( + onTap: () => controller + .onChangeCheckBox(!controller.isChecked), + child: Row( + children: [ + Checkbox( + onChanged: controller.onChangeCheckBox, + value: controller.isChecked, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + fillColor: WidgetStatePropertyAll( + contentTheme.secondary), + checkColor: contentTheme.onPrimary, + visualDensity: getCompactDensity, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + MySpacing.width(8), + MyText.bodySmall("Remember Me"), + ], + ), + ), + MyButton.text( + onPressed: controller.goToForgotPassword, + elevation: 0, + padding: MySpacing.xy(8, 0), + splashColor: contentTheme.secondary.withAlpha(36), + child: MyText.bodySmall( + 'Forgot password?', + fontWeight: 600, + color: contentTheme.secondary, + ), + ), + ], + ), + MySpacing.height(28), + + /// Login Button + Center( + child: MyButton.rounded( + onPressed: controller.onLogin, + elevation: 2, + padding: MySpacing.xy(24, 16), + borderRadiusAll: 16, + backgroundColor: contentTheme.primary, + child: MyText.labelMedium( + 'Login', + fontWeight: 600, + color: contentTheme.onPrimary, + ), + ), + ), + MySpacing.height(16), + + /// Register Link + Center( + child: MyButton.text( + onPressed: controller.gotoRegister, + elevation: 0, + padding: MySpacing.xy(12, 8), + splashColor: contentTheme.secondary.withAlpha(30), + child: MyText.bodySmall( + "Request a Demo", + color: contentTheme.secondary, + fontWeight: 600, + ), + ), + ), + ], ), ), ), - MySpacing.height(16), - - /// Register Link - Center( - child: MyButton.text( - onPressed: controller.gotoRegister, - elevation: 0, - padding: MySpacing.xy(12, 8), - splashColor: contentTheme.secondary.withAlpha(30), - child: MyText.bodySmall( - "Request a Demo", - color: contentTheme.secondary, - fontWeight: 600, - ), - ), - ), - ], - ), - ), - ), - ); + ); + }); }, ), ); diff --git a/lib/view/dashboard/attendanceScreen.dart b/lib/view/dashboard/attendanceScreen.dart index 08562a3..0e995c6 100644 --- a/lib/view/dashboard/attendanceScreen.dart +++ b/lib/view/dashboard/attendanceScreen.dart @@ -16,6 +16,11 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/view/layouts/layout.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:intl/intl.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/utils/attendance_actions.dart'; +import 'package:marco/helpers/widgets/my_refresh_wrapper.dart'; +import 'package:marco/model/my_paginated_table.dart'; class AttendanceScreen extends StatefulWidget { const AttendanceScreen({super.key}); @@ -26,117 +31,136 @@ class AttendanceScreen extends StatefulWidget { class _AttendanceScreenState extends State with UIMixin { AttendanceController attendanceController = Get.put(AttendanceController()); + PermissionController permissionController = Get.put(PermissionController()); @override Widget build(BuildContext context) { return Layout( - child: GetBuilder( - init: attendanceController, - tag: 'attendance_dashboard_controller', - builder: (controller) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: MySpacing.x(flexSpacing), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.titleMedium("Attendance", - fontSize: 18, fontWeight: 600), - MyBreadcrumb( - children: [ - MyBreadcrumbItem(name: 'Dashboard'), - MyBreadcrumbItem(name: 'Attendance', active: true), - ], - ), - ], + child: MyRefreshWrapper( + onRefresh: () async { + if (attendanceController.selectedProjectId != null) { + await attendanceController.fetchEmployeesByProject( + attendanceController.selectedProjectId!); + await attendanceController + .fetchAttendanceLogs(attendanceController.selectedProjectId!); + } else { + await attendanceController.fetchProjects(); + } + }, + child: GetBuilder( + init: attendanceController, + tag: 'attendance_dashboard_controller', + builder: (controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: MySpacing.x(flexSpacing), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.titleMedium("Attendance", + fontSize: 18, fontWeight: 600), + MyBreadcrumb( + children: [ + MyBreadcrumbItem(name: 'Dashboard'), + MyBreadcrumbItem(name: 'Attendance', active: true), + ], + ), + ], + ), ), - ), - MySpacing.height(flexSpacing), - Padding( - padding: MySpacing.x(flexSpacing / 2), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(flexSpacing), - // Move project selection here - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: MyContainer.bordered( - padding: MySpacing.xy(8, 8), - child: PopupMenuButton( - onSelected: (value) { - setState(() { - attendanceController.selectedProjectId = - value; - attendanceController - .fetchEmployeesByProject(value); - attendanceController - .fetchAttendanceLogs(value); - attendanceController - .fetchAttendanceLogs(value); - }); - }, - itemBuilder: (BuildContext context) { - if (attendanceController.projects.isEmpty) { - return [ - PopupMenuItem( - value: '', - child: MyText.bodySmall('No Data', - fontWeight: 600), - ) - ]; - } - return attendanceController.projects - .map((project) { - return PopupMenuItem( - value: project.id.toString(), - height: 32, - child: MyText.bodySmall( - project.name, - color: theme.colorScheme.onSurface, - fontWeight: 600, - ), - ); - }).toList(); - }, - color: theme.cardTheme.color, - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - MyText.labelSmall( - attendanceController.selectedProjectId != - null - ? attendanceController.projects - .firstWhereOrNull((proj) => - proj.id.toString() == - attendanceController - .selectedProjectId) - ?.name ?? - 'Select a Project' - : 'Select a Project', + MySpacing.height(flexSpacing), + Padding( + padding: MySpacing.x(flexSpacing / 2), + child: MyFlex( + children: [ + // Popup Menu for Project Selection + MyFlexItem( + sizes: 'lg-12', + child: MyContainer.bordered( + padding: MySpacing.xy(8, 8), + child: PopupMenuButton( + onSelected: (value) { + setState(() { + attendanceController.selectedProjectId = value; + attendanceController + .fetchEmployeesByProject(value); + attendanceController.fetchAttendanceLogs(value); + }); + }, + itemBuilder: (BuildContext context) { + if (attendanceController.projects.isEmpty) { + return [ + PopupMenuItem( + value: '', + child: MyText.bodySmall('No Data', + fontWeight: 600), + ) + ]; + } + return attendanceController.projects + .map((project) { + return PopupMenuItem( + value: project.id.toString(), + height: 32, + child: MyText.bodySmall( + project.name, color: theme.colorScheme.onSurface, + fontWeight: 600, ), - Icon(LucideIcons.chevron_down, - size: 20, - color: theme.colorScheme.onSurface), - ], - ), + ); + }).toList(); + }, + color: theme.cardTheme.color, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.labelSmall( + attendanceController.selectedProjectId != null + ? attendanceController.projects + .firstWhereOrNull((proj) => + proj.id.toString() == + attendanceController + .selectedProjectId) + ?.name ?? + 'Select a Project' + : 'Select a Project', + color: theme.colorScheme.onSurface, + ), + Icon(LucideIcons.chevron_down, + size: 20, + color: theme.colorScheme.onSurface), + ], ), ), ), - ], - ), - MySpacing.height(flexSpacing), - MyFlex( - children: [ - MyFlexItem( - child: DefaultTabController( - length: 3, + ), + + // Tabs for Employee List, Logs, and Regularization + MyFlexItem( + sizes: 'lg-12', + child: Obx(() { + bool hasRegularizationPermission = + permissionController.hasPermission( + Permissions.regularizeAttendance); + + final tabs = [ + const Tab(text: 'Employee List'), + const Tab(text: 'Logs'), + if (hasRegularizationPermission) + const Tab(text: 'Regularization'), + ]; + + final views = [ + employeeListTab(), + reportsTab(context), + if (hasRegularizationPermission) + regularizationTab(context), + ]; + + return DefaultTabController( + length: tabs.length, child: MyCard.bordered( borderRadiusAll: 4, border: @@ -153,36 +177,26 @@ class _AttendanceScreenState extends State with UIMixin { unselectedLabelColor: theme .colorScheme.onSurface .withAlpha(150), - tabs: const [ - Tab(text: 'Employee List'), - Tab(text: 'Logs'), - Tab(text: 'Regularization'), - ], + tabs: tabs, ), MySpacing.height(16), SizedBox( - height: 500, - child: TabBarView( - children: [ - employeeListTab(), - reportsTab(context), - regularizationTab(context), - ], - ), + height: 550, + child: TabBarView(children: views), ), ], ), ), - ), - ), - ], - ), - ], + ); + }), + ), + ], + ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ); } @@ -194,115 +208,92 @@ class _AttendanceScreenState extends State with UIMixin { ); } - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - sortAscending: true, - columnSpacing: 15, - headingRowColor: - WidgetStatePropertyAll(contentTheme.primary.withAlpha(40)), - dataRowMaxHeight: 60, - showBottomBorder: true, - clipBehavior: Clip.antiAliasWithSaveLayer, - border: TableBorder.all( - borderRadius: BorderRadius.circular(4), - style: BorderStyle.solid, - width: 0.4, - color: Colors.grey, + final columns = [ + DataColumn( + label: MyText.labelLarge('Name', color: contentTheme.primary), + ), + DataColumn( + label: MyText.labelLarge('Actions', color: contentTheme.primary), + ), + ]; + + final rows = attendanceController.employees.mapIndexed((index, employee) { + int? activity = employee.activity; + String buttonText = (activity == 0 || activity == 4) + ? ButtonActions.checkIn + : ButtonActions.checkOut; + + return DataRow(cells: [ + DataCell( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MyText.bodyMedium(employee.name, fontWeight: 600), + SizedBox(height: 2), + MyText.bodySmall(employee.designation, color: Colors.grey), + ], + ), ), - columns: [ - DataColumn( - label: MyText.labelLarge('Name', color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Designation', - color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Actions', color: contentTheme.primary)), - ], - rows: attendanceController.employees.mapIndexed((index, employee) { - // Set actionText directly from employee's action - String actionText = ""; - int? activity = - employee.activity; // Assuming employee has an 'action' field - - // Set action text based on employee's activity value - if (activity == 1) { - actionText = "Check In"; - } else if (activity == 0) { - actionText = "Check Out"; - } else if (activity == 4) { - // Activity 4 logic - actionText = "Check In"; - } - - return DataRow(cells: [ - DataCell(MyText.bodyMedium(employee.name, fontWeight: 600)), - DataCell(MyText.bodyMedium(employee.designation, fontWeight: 600)), - DataCell(ElevatedButton( - onPressed: () async { - if (attendanceController.selectedProjectId == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Please select a project first")), - ); - return; - } - - // Determine the updated action based on current activity - int updatedAction; - String actionText; - - if (activity == 0 || activity == 4) { - // The user is currently checked in (activity == 0), so they need to check out - updatedAction = 0; - actionText = "Check In"; - } else { - // The user is currently checked out (activity == 1), so they need to check in - updatedAction = 1; - actionText = "Check Out"; - } - - // Call the method to capture attendance with the updated action - final success = - await attendanceController.captureAndUploadAttendance( - employee.id, // Pass the employee's ID - employee.employeeId, - - int.parse(attendanceController - .selectedProjectId!), // Pass the selected project ID - comment: actionText, // Action text (Check In / Check Out) - action: updatedAction, - ); - - // Show success or failure message + DataCell( + ElevatedButton( + onPressed: () async { + if (attendanceController.selectedProjectId == null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? 'Attendance marked successfully!' - : 'Image upload failed.', - ), - ), + const SnackBar( + content: Text("Please select a project first")), ); - if (success) { - // Fetch the updated list of employees and logs after the attendance upload - attendanceController.fetchEmployeesByProject( - attendanceController.selectedProjectId!); - attendanceController.fetchAttendanceLogs( - attendanceController.selectedProjectId!); - // You can add more fetch calls if necessary, such as regularization logs. - } - }, - style: ElevatedButton.styleFrom( - padding: EdgeInsets.symmetric( - vertical: 4, horizontal: 6), // Adjust padding - minimumSize: Size(60, 20), // Adjust minimum size for the button - textStyle: TextStyle(fontSize: 12), // Smaller font size - ), - child: Text( - activity == 0 || activity == 4 ? 'Check In' : 'Check Out'), - )), - ]); - }).toList(), + return; + } + + int updatedAction = (activity == 0 || activity == 4) ? 0 : 1; + String actionText = (updatedAction == 0) + ? ButtonActions.checkIn + : ButtonActions.checkOut; + + final success = + await attendanceController.captureAndUploadAttendance( + employee.id, + employee.employeeId, + int.parse(attendanceController.selectedProjectId!), + comment: actionText, + action: updatedAction, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? 'Attendance marked successfully!' + : 'Image upload failed.'), + ), + ); + + if (success) { + attendanceController.fetchEmployeesByProject( + attendanceController.selectedProjectId!); + attendanceController.fetchAttendanceLogs( + attendanceController.selectedProjectId!); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AttendanceActionColors.colors[buttonText], + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6), + minimumSize: const Size(60, 20), + textStyle: const TextStyle(fontSize: 12), + ), + child: Text(buttonText), + ), + ), + ]); + }).toList(); + + return Padding( + padding: const EdgeInsets.all(8.0), // You can adjust this as needed + child: SingleChildScrollView( + child: MyPaginatedTable( + columns: columns, + rows: rows, + ), ), ); } @@ -310,171 +301,467 @@ class _AttendanceScreenState extends State with UIMixin { Widget reportsTab(BuildContext context) { final attendanceController = Get.find(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton.icon( - icon: Icon(Icons.date_range), - label: Text("Select Date Range for Attendance"), - onPressed: () => attendanceController.selectDateRangeForAttendance( - context, attendanceController), + final columns = [ + DataColumn(label: MyText.labelLarge('Name', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Check-In', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Check-Out', color: contentTheme.primary)), + DataColumn(label: MyText.labelLarge('View', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Action', color: contentTheme.primary)), + ]; + + final rows = attendanceController.attendanceLogs.mapIndexed((index, log) { + return DataRow(cells: [ + DataCell( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MyText.bodyMedium(log.name, fontWeight: 600), + SizedBox(height: 2), + MyText.bodySmall(log.role, color: Colors.grey), + ], ), ), - if (attendanceController.attendanceLogs.isEmpty) - Expanded( - child: Center( - child: MyText.bodySmall("No Attendance Records Found", - fontWeight: 600), - ), - ) - else - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - sortAscending: true, - columnSpacing: 15, - headingRowColor: - WidgetStatePropertyAll(contentTheme.primary.withAlpha(40)), - dataRowMaxHeight: 60, - showBottomBorder: true, - clipBehavior: Clip.antiAliasWithSaveLayer, - border: TableBorder.all( - borderRadius: BorderRadius.circular(4), - style: BorderStyle.solid, - width: 0.4, - color: Colors.grey, - ), - columns: [ - DataColumn( - label: MyText.labelLarge('Name', - color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Role', - color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Check-In', - color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Check-Out', - color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Action', - color: contentTheme.primary)), - ], - rows: attendanceController.attendanceLogs - .mapIndexed((index, log) => DataRow(cells: [ - DataCell( - MyText.bodyMedium(log.name, fontWeight: 600)), - DataCell( - MyText.bodyMedium(log.role, fontWeight: 600)), - DataCell(MyText.bodyMedium( - log.checkIn != null - ? DateFormat('dd MMM yyyy hh:mm a') - .format(log.checkIn!) - : '-', - fontWeight: 600, - )), - DataCell(MyText.bodyMedium( - log.checkOut != null - ? DateFormat('dd MMM yyyy hh:mm a') - .format(log.checkOut!) - : '-', - fontWeight: 600, - )), - DataCell( - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: EdgeInsets.symmetric( - vertical: 4, - horizontal: 6), // Adjust padding - minimumSize: - Size(60, 20), // Adjust minimum size - textStyle: TextStyle( - fontSize: 12), // Smaller font size - ), - onPressed: () async { - // Call fetchLogsView to load the log data - await attendanceController.fetchLogsView(log.id - .toString()); // Assuming `log.id` is available - // Open the bottom sheet to display the log details - showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(16)), - ), - backgroundColor: theme.cardTheme.color, - builder: (context) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - MyText.titleMedium( - "Attendance Log Details", - fontWeight: 700), - const SizedBox(height: 16), - // Display the log details - if (attendanceController - .attendenceLogsView.isNotEmpty) - ...attendanceController - .attendenceLogsView - .map((log) => Column( - crossAxisAlignment: - CrossAxisAlignment - .start, - children: [ - MyText.bodyMedium( - "Date: ${log.formattedDate ?? '-'}", - fontWeight: 600, - ), - MyText.bodyMedium( - "Time: ${log.formattedTime ?? '-'}", - fontWeight: 600, - ), - MyText.bodyMedium( - "Description: ${log.description ?? '-'}", - fontWeight: 600, - ), - const Divider( - thickness: 1, - height: 24), - ], - )), - Align( - alignment: Alignment.centerRight, - child: ElevatedButton( - onPressed: () => - Navigator.pop(context), - child: const Text("Close"), - ), - ) - ], - ), - ); - }, - ); - }, - child: const Text('View'), - ), - ) - ])) - .toList(), + DataCell( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + log.checkIn != null + ? DateFormat('dd MMM yyyy').format(log.checkIn!) + : '-', + fontWeight: 600, ), + MyText.bodyMedium( + log.checkIn != null + ? DateFormat('hh:mm a').format(log.checkIn!) + : '', + fontWeight: 600, + ), + ], + ), + ), + DataCell( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + log.checkOut != null + ? DateFormat('dd MMM yyyy').format(log.checkOut!) + : '-', + fontWeight: 600, + ), + MyText.bodyMedium( + log.checkOut != null + ? DateFormat('hh:mm a').format(log.checkOut!) + : '', + fontWeight: 600, + ), + ], + ), + ), + DataCell( + IconButton( + icon: const Icon(Icons.visibility, size: 18), + onPressed: () async { + await attendanceController.fetchLogsView(log.id.toString()); + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + backgroundColor: Theme.of(context).cardColor, + builder: (context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium("Attendance Log Details", + fontWeight: 700), + const SizedBox(height: 16), + if (attendanceController + .attendenceLogsView.isNotEmpty) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: MyText.bodyMedium("Date", + fontWeight: 600)), + Expanded( + child: MyText.bodyMedium("Time", + fontWeight: 600)), + Expanded( + child: MyText.bodyMedium("Description", + fontWeight: 600)), + Expanded( + child: MyText.bodyMedium("Image", + fontWeight: 600)), + ], + ), + const Divider(thickness: 1, height: 24), + ], + if (attendanceController.attendenceLogsView.isNotEmpty) + ...attendanceController.attendenceLogsView + .map((log) => Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: MyText.bodyMedium( + log.formattedDate ?? '-', + fontWeight: 600)), + Expanded( + child: MyText.bodyMedium( + log.formattedTime ?? '-', + fontWeight: 600)), + Expanded( + child: MyText.bodyMedium( + log.comment ?? '-', + fontWeight: 600)), + Expanded( + child: GestureDetector( + onTap: () { + if (log.preSignedUrl != null) { + showDialog( + context: context, + builder: (_) => Dialog( + child: Image.network( + log.preSignedUrl!, + fit: BoxFit.cover, + height: 400, + errorBuilder: (context, + error, stackTrace) { + return Icon( + Icons.broken_image, + size: 50, + color: Colors.grey); + }, + ), + ), + ); + } + }, + child: log.thumbPreSignedUrl != null + ? Image.network( + log.thumbPreSignedUrl!, + height: 40, + width: 40, + fit: BoxFit.cover, + errorBuilder: (context, error, + stackTrace) { + return Icon( + Icons.broken_image, + size: 40, + color: Colors.grey); + }, + ) + : Icon(Icons.broken_image, + size: 40, color: Colors.grey), + ), + ), + ], + )), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text("Close"), + ), + ), + ], + ), + ); + }, + ); + }, + ), + ), + DataCell( + ElevatedButton( + onPressed: () async { + if (attendanceController.selectedProjectId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Please select a project first")), + ); + return; + } + + int updatedAction; + String actionText; + + if (log.activity == 0 || log.activity == 4) { + updatedAction = 0; + actionText = "Check In"; + } else if (log.activity == 1) { + updatedAction = 1; + actionText = "Check Out"; + } else if (log.activity == 2) { + updatedAction = 2; + actionText = "Request Regularize"; + } else { + updatedAction = 0; + actionText = "Unknown Action"; + } + + final success = + await attendanceController.captureAndUploadAttendance( + log.id, + log.employeeId, + int.parse(attendanceController.selectedProjectId!), + comment: actionText, + action: updatedAction, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? 'Attendance marked successfully!' + : 'Image upload failed.')), + ); + + if (success) { + attendanceController.fetchEmployeesByProject( + attendanceController.selectedProjectId!); + attendanceController.fetchAttendanceLogs( + attendanceController.selectedProjectId!); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AttendanceActionColors.colors[ + (log.activity == 0 || log.activity == 4) + ? ButtonActions.checkIn + : ButtonActions.checkOut], + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6), + minimumSize: const Size(60, 20), + textStyle: const TextStyle(fontSize: 12), + ), + child: Text( + (log.activity == 0 || log.activity == 4) + ? ButtonActions.checkIn + : (log.activity == 2) + ? ButtonActions.requestRegularize + : ButtonActions.checkOut, ), ), - ], + ), + ]); + }).toList(); + + return SingleChildScrollView( + // Wrap the Column in SingleChildScrollView to handle overflow + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextButton.icon( + icon: const Icon(Icons.date_range), + label: const Text("Select Date Range for Attendance"), + onPressed: () => attendanceController + .selectDateRangeForAttendance(context, attendanceController), + ), + ), + if (attendanceController.attendanceLogs.isEmpty) + Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: MyText.bodySmall( + "No Attendance Records Found", + fontWeight: 600, + ), + ), + ) + else + SingleChildScrollView( + child: MyPaginatedTable( + columns: columns, + rows: rows, + columnSpacing: 8.0, + ), + ), + ], + ), ); } Widget regularizationTab(BuildContext context) { final attendanceController = Get.find(); + final columns = [ + DataColumn(label: MyText.labelLarge('Name', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Check-In', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Check-Out', color: contentTheme.primary)), + DataColumn( + label: MyText.labelLarge('Action', color: contentTheme.primary)), + ]; + + final rows = attendanceController.regularizationLogs + .mapIndexed((index, log) => DataRow(cells: [ + DataCell( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MyText.bodyMedium(log.name, fontWeight: 600), + SizedBox(height: 2), + MyText.bodySmall(log.role, color: Colors.grey), + ], + ), + ), + DataCell( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + log.checkIn != null + ? DateFormat('dd/MMM/yyyy').format(log.checkIn!) + : '-', + fontWeight: 600, + ), + MyText.bodyMedium( + log.checkIn != null + ? DateFormat('hh:mm a').format(log.checkIn!) + : '', + fontWeight: 600, + ), + ], + ), + ), + DataCell( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + log.checkOut != null + ? DateFormat('dd MMM yyyy').format(log.checkOut!) + : '-', + fontWeight: 600, + ), + MyText.bodyMedium( + log.checkOut != null + ? DateFormat('hh:mm a').format(log.checkOut!) + : '', + fontWeight: 600, + ), + ], + ), + ), + DataCell( + Row( + children: [ + // Approve Button + ElevatedButton( + onPressed: () async { + if (attendanceController.selectedProjectId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Please select a project first")), + ); + return; + } + + final success = await attendanceController + .captureAndUploadAttendance( + log.id, + log.employeeId, + int.parse(attendanceController.selectedProjectId!), + comment: "Accepted", + action: 4, // Approve action + imageCapture: false, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? 'Approval marked successfully!' + : 'Failed to mark approval.')), + ); + + if (success) { + attendanceController.fetchEmployeesByProject( + attendanceController.selectedProjectId!); + attendanceController.fetchAttendanceLogs( + attendanceController.selectedProjectId!); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AttendanceActionColors + .colors[ButtonActions.approve], + padding: const EdgeInsets.symmetric( + vertical: 4, horizontal: 6), + minimumSize: const Size(60, 20), + textStyle: const TextStyle(fontSize: 12), + ), + child: const Text("Approve"), + ), + + // Space between buttons + const SizedBox(width: 8), + + // Reject Button + ElevatedButton( + onPressed: () async { + if (attendanceController.selectedProjectId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Please select a project first")), + ); + return; + } + + final success = await attendanceController + .captureAndUploadAttendance( + log.id, + log.employeeId, + int.parse(attendanceController.selectedProjectId!), + comment: "Rejected", + action: 5, // Reject action + imageCapture: false, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? 'Attendance marked as Rejected!' + : 'Failed to mark attendance.')), + ); + + if (success) { + attendanceController.fetchEmployeesByProject( + attendanceController.selectedProjectId!); + attendanceController.fetchAttendanceLogs( + attendanceController.selectedProjectId!); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + AttendanceActionColors.colors[ButtonActions.reject], + padding: const EdgeInsets.symmetric( + vertical: 4, horizontal: 6), + minimumSize: const Size(60, 20), + textStyle: const TextStyle(fontSize: 12), + ), + child: const Text("Reject"), + ), + ], + ), + ), + ])) + .toList(); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -491,67 +778,11 @@ class _AttendanceScreenState extends State with UIMixin { else Expanded( child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - sortAscending: true, - columnSpacing: 15, - headingRowColor: - WidgetStatePropertyAll(contentTheme.primary.withAlpha(40)), - dataRowMaxHeight: 60, - showBottomBorder: true, - clipBehavior: Clip.antiAliasWithSaveLayer, - border: TableBorder.all( - borderRadius: BorderRadius.circular(4), - style: BorderStyle.solid, - width: 0.4, - color: Colors.grey, - ), - columns: [ - DataColumn( - label: MyText.labelLarge('Name', - color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Role', - color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Check-In', - color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Check-Out', - color: contentTheme.primary)), - DataColumn( - label: MyText.labelLarge('Action', - color: contentTheme.primary)), - ], - rows: attendanceController.regularizationLogs - .mapIndexed((index, log) => DataRow(cells: [ - DataCell( - MyText.bodyMedium(log.name, fontWeight: 600)), - DataCell( - MyText.bodyMedium(log.role, fontWeight: 600)), - DataCell(MyText.bodyMedium( - log.checkIn != null - ? DateFormat('dd MMM yyyy hh:mm a') - .format(log.checkIn!) - : '-', - fontWeight: 600, - )), - DataCell(MyText.bodyMedium( - log.checkOut != null - ? DateFormat('dd MMM yyyy hh:mm a') - .format(log.checkOut!) - : '-', - fontWeight: 600, - )), - DataCell(IconButton( - icon: Icon(Icons.info_outline, - color: contentTheme.primary), - onPressed: () { - // Add action logic - }, - )), - ])) - .toList(), + child: MyPaginatedTable( + // Use MyPaginatedTable here for pagination + columns: columns, + rows: rows, + columnSpacing: 15.0, ), ), ), diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 86535f0..81302f1 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -47,10 +47,10 @@ class DashboardScreen extends StatelessWidget with UIMixin { List _buildDashboardStats() { final stats = [ - _StatItem(LucideIcons.layout_dashboard, "Dashboard", contentTheme.primary), + _StatItem(LucideIcons.gauge, "Dashboard", contentTheme.primary), _StatItem(LucideIcons.folder, "Projects", contentTheme.secondary), - _StatItem(LucideIcons.check, "Attendance", contentTheme.success), - _StatItem(LucideIcons.users, "Task", contentTheme.info), + _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success), + _StatItem(LucideIcons.logs, "Task", contentTheme.info), ]; return List.generate(