Enhance attendance management with tabbed navigation and permission handling; improve UI consistency and loading states

This commit is contained in:
Vaibhav Surve 2025-11-29 12:34:22 +05:30
parent 7bef2e9d89
commit 37ce612fca
7 changed files with 599 additions and 548 deletions

View File

@ -15,6 +15,9 @@ class PermissionController extends GetxController {
Timer? _refreshTimer; Timer? _refreshTimer;
var isLoading = true.obs; var isLoading = true.obs;
/// NEW: reactive flag to signal permissions are loaded
var permissionsLoaded = false.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -52,6 +55,10 @@ class PermissionController extends GetxController {
_updateState(userData); _updateState(userData);
await _storeData(); await _storeData();
logSafe("Data loaded and state updated successfully."); logSafe("Data loaded and state updated successfully.");
// NEW: mark permissions as loaded
permissionsLoaded.value = true;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error loading data from API", logSafe("Error loading data from API",
level: LogLevel.error, error: e, stackTrace: stacktrace); level: LogLevel.error, error: e, stackTrace: stacktrace);
@ -103,7 +110,7 @@ class PermissionController extends GetxController {
} }
void _startAutoRefresh() { void _startAutoRefresh() {
_refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async { _refreshTimer = Timer.periodic(const Duration(minutes: 30), (timer) async {
logSafe("Auto-refresh triggered."); logSafe("Auto-refresh triggered.");
final token = await _getAuthToken(); final token = await _getAuthToken();
if (token?.isNotEmpty ?? false) { if (token?.isNotEmpty ?? false) {
@ -117,8 +124,6 @@ class PermissionController extends GetxController {
bool hasPermission(String permissionId) { bool hasPermission(String permissionId) {
final hasPerm = permissions.any((p) => p.id == permissionId); final hasPerm = permissions.any((p) => p.id == permissionId);
// logSafe("Checking permission $permissionId: $hasPerm",
// level: LogLevel.debug);
return hasPerm; return hasPerm;
} }

View File

@ -7,7 +7,7 @@ class PillTabBar extends StatelessWidget {
final Color unselectedColor; final Color unselectedColor;
final Color indicatorColor; final Color indicatorColor;
final double height; final double height;
final ValueChanged<int>? onTap;
const PillTabBar({ const PillTabBar({
Key? key, Key? key,
required this.controller, required this.controller,
@ -16,6 +16,7 @@ class PillTabBar extends StatelessWidget {
this.unselectedColor = Colors.grey, this.unselectedColor = Colors.grey,
this.indicatorColor = Colors.blueAccent, this.indicatorColor = Colors.blueAccent,
this.height = 48, this.height = 48,
this.onTap,
}) : super(key: key); }) : super(key: key);
@override @override
@ -42,7 +43,8 @@ class PillTabBar extends StatelessWidget {
borderRadius: BorderRadius.circular(height / 2), borderRadius: BorderRadius.circular(height / 2),
), ),
indicatorSize: TabBarIndicatorSize.tab, indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), indicatorPadding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
labelColor: selectedColor, labelColor: selectedColor,
unselectedLabelColor: unselectedColor, unselectedLabelColor: unselectedColor,
labelStyle: const TextStyle( labelStyle: const TextStyle(

View File

@ -128,162 +128,172 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
'${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - ' '${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}'; '${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}';
return Column( return SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, padding: MySpacing.only(bottom: 80), // Added bottom spacing for scroll view
children: [ child: Column(
// Header row crossAxisAlignment: CrossAxisAlignment.start,
Padding( children: [
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), // Header row
child: Row( Padding(
mainAxisAlignment: MainAxisAlignment.spaceBetween, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
children: [ child: Row(
MyText.titleMedium("Attendance Logs", fontWeight: 600), mainAxisAlignment: MainAxisAlignment.spaceBetween,
widget.controller.isLoading.value
? SkeletonLoaders.dateSkeletonLoader()
: MyText.bodySmall(
dateRangeText,
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
],
),
),
// Pending-only header
_buildStatusHeader(),
MySpacing.height(8),
// Content: loader, empty, or logs
if (widget.controller.isLoadingAttendanceLogs.value)
SkeletonLoaders.employeeListSkeletonLoader()
else if (filteredLogs.isEmpty)
SizedBox(
height: 120,
child: Center(
child: Text(showPendingOnly
? "No Pending Actions Found"
: "No Attendance Logs Found for this Project"),
),
)
else
MyCard.bordered(
paddingAll: 8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final date in sortedDates) ...[ MyText.titleMedium("Attendance Logs", fontWeight: 600),
Padding( widget.controller.isLoading.value
padding: const EdgeInsets.symmetric(vertical: 8), ? SkeletonLoaders.dateSkeletonLoader()
child: MyText.bodyMedium(date, fontWeight: 700), : MyText.bodySmall(
), dateRangeText,
fontWeight: 600,
// Sort employees inside this date by action priority first, then latest entry color: Colors.grey[700],
for (final emp in (groupedLogs[date]! overflow: TextOverflow.ellipsis,
..sort(
(a, b) {
final priorityCompare = _getActionPriority(a)
.compareTo(_getActionPriority(b));
if (priorityCompare != 0) return priorityCompare;
final aTime = a.checkOut ?? a.checkIn ?? DateTime(0);
final bTime = b.checkOut ?? b.checkIn ?? DateTime(0);
return bTime.compareTo(
aTime);
},
))) ...[
MyContainer(
paddingAll: 8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: emp.firstName,
lastName: emp.lastName,
size: 31,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: MyText.bodyMedium(
emp.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${emp.designation})',
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
),
],
),
MySpacing.height(8),
if (emp.checkIn != null ||
emp.checkOut != null)
Row(
children: [
if (emp.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils.formatDate(
emp.checkIn!, 'hh:mm a'),
fontWeight: 600,
),
MySpacing.width(16),
],
if (emp.checkOut != null) ...[
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils.formatDate(
emp.checkOut!, 'hh:mm a'),
fontWeight: 600,
),
],
],
),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: emp,
attendanceController: widget.controller,
),
MySpacing.width(8),
AttendanceLogViewButton(
employee: emp,
attendanceController: widget.controller,
),
],
),
],
),
),
],
), ),
),
Divider(color: Colors.grey.withOpacity(0.3)),
],
],
], ],
), ),
), ),
],
// Pending-only header
_buildStatusHeader(),
MySpacing.height(8),
// Content: loader, empty, or logs
if (widget.controller.isLoadingAttendanceLogs.value)
SkeletonLoaders.employeeListSkeletonLoader()
else if (filteredLogs.isEmpty)
SizedBox(
height: 120,
child: Center(
child: Text(showPendingOnly
? "No Pending Actions Found"
: "No Attendance Logs Found for this Project"),
),
)
else
MyCard.bordered(
paddingAll: 8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final date in sortedDates) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: MyText.bodyMedium(date, fontWeight: 700),
),
// Sort employees inside this date by action priority first, then latest entry
for (final emp in (groupedLogs[date]!
..sort(
(a, b) {
final priorityCompare = _getActionPriority(a)
.compareTo(_getActionPriority(b));
if (priorityCompare != 0) return priorityCompare;
final aTime = a.checkOut ?? a.checkIn ?? DateTime(0);
final bTime = b.checkOut ?? b.checkIn ?? DateTime(0);
return bTime.compareTo(
aTime);
},
))) ...[
MyContainer(
paddingAll: 8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: emp.firstName,
lastName: emp.lastName,
size: 31,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: MyText.bodyMedium(
emp.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${emp.designation})',
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
),
],
),
MySpacing.height(8),
if (emp.checkIn != null ||
emp.checkOut != null)
Row(
children: [
if (emp.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils.formatDate(
emp.checkIn!, 'hh:mm a'),
fontWeight: 600,
),
MySpacing.width(16),
],
if (emp.checkOut != null) ...[
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils.formatDate(
emp.checkOut!, 'hh:mm a'),
fontWeight: 600,
),
],
],
),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: emp,
attendanceController: widget.controller,
),
MySpacing.width(8),
AttendanceLogViewButton(
employee: emp,
attendanceController: widget.controller,
),
],
),
],
),
),
],
),
),
Divider(color: Colors.grey.withOpacity(0.3)),
],
],
// Remove the trailing Divider if we are at the end of the logs
if (sortedDates.isNotEmpty)
// We can use MySpacing.height(8) here if we need to ensure the last divider doesn't show
// But keeping the original structure, the divider is inside the inner loop.
// A clean up would be needed to manage that last divider, but for now,
// the bottom padding handles the visible spacing.
const SizedBox.shrink(),
],
),
),
],
),
); );
}); });
} }
} }

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/theme/app_theme.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_flex.dart'; import 'package:on_field_work/helpers/widgets/my_flex.dart';
import 'package:on_field_work/helpers/widgets/my_flex_item.dart'; import 'package:on_field_work/helpers/widgets/my_flex_item.dart';
@ -8,13 +7,15 @@ import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart'; import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/model/attendance/attendence_filter_sheet.dart';
import 'package:on_field_work/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/view/Attendence/regularization_requests_tab.dart'; import 'package:on_field_work/view/Attendence/regularization_requests_tab.dart';
import 'package:on_field_work/view/Attendence/attendance_logs_tab.dart'; import 'package:on_field_work/view/Attendence/attendance_logs_tab.dart';
import 'package:on_field_work/view/Attendence/todays_attendance_tab.dart'; import 'package:on_field_work/view/Attendence/todays_attendance_tab.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
import 'package:on_field_work/model/attendance/attendence_filter_sheet.dart';
class AttendanceScreen extends StatefulWidget { class AttendanceScreen extends StatefulWidget {
const AttendanceScreen({super.key}); const AttendanceScreen({super.key});
@ -23,43 +24,83 @@ class AttendanceScreen extends StatefulWidget {
State<AttendanceScreen> createState() => _AttendanceScreenState(); State<AttendanceScreen> createState() => _AttendanceScreenState();
} }
class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin { class _AttendanceScreenState extends State<AttendanceScreen>
with SingleTickerProviderStateMixin, UIMixin {
final attendanceController = Get.put(AttendanceController()); final attendanceController = Get.put(AttendanceController());
final permissionController = Get.put(PermissionController()); final permissionController = Get.put(PermissionController());
final projectController = Get.find<ProjectController>(); final projectController = Get.put(ProjectController());
String selectedTab = 'todaysAttendance'; late TabController _tabController;
late List<Map<String, String>> _tabs;
bool _tabsInitialized = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Watch permissions loaded
ever(permissionController.permissionsLoaded, (loaded) {
if (loaded == true && !_tabsInitialized) {
_initializeTabs();
setState(() {});
}
});
// Watch project changes to reload data
ever<String>(projectController.selectedProjectId, (projectId) async { ever<String>(projectController.selectedProjectId, (projectId) async {
if (projectId.isNotEmpty) await _loadData(projectId); if (projectId.isNotEmpty && _tabsInitialized) {
await _fetchTabData(attendanceController.selectedTab);
}
}); });
WidgetsBinding.instance.addPostFrameCallback((_) { // If permissions are already loaded at init
final projectId = projectController.selectedProjectId.value; if (permissionController.permissionsLoaded.value) {
if (projectId.isNotEmpty) _loadData(projectId); _initializeTabs();
});
}
Future<void> _loadData(String projectId) async {
try {
attendanceController.selectedTab = 'todaysAttendance';
await attendanceController.loadAttendanceData(projectId);
// attendanceController.update(['attendance_dashboard_controller']);
} catch (e) {
debugPrint("Error loading data: $e");
} }
} }
Future<void> _refreshData() async { void _initializeTabs() async {
final allTabs = [
{'label': "Today's Attendance", 'value': 'todaysAttendance'},
{'label': "Attendance Logs", 'value': 'attendanceLogs'},
{'label': "Regularization Requests", 'value': 'regularizationRequests'},
];
final hasRegularizationPermission =
permissionController.hasPermission(Permissions.regularizeAttendance);
_tabs = allTabs.where((tab) {
return tab['value'] != 'regularizationRequests' ||
hasRegularizationPermission;
}).toList();
_tabController = TabController(length: _tabs.length, vsync: this);
// Keep selectedTab in sync and fetch data on tab change
_tabController.addListener(() async {
if (!_tabController.indexIsChanging) {
final selectedTab = _tabs[_tabController.index]['value']!;
attendanceController.selectedTab = selectedTab;
await _fetchTabData(selectedTab);
}
});
_tabsInitialized = true;
// Load initial data for default tab
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
final initialTab = _tabs[_tabController.index]['value']!;
attendanceController.selectedTab = initialTab;
await _fetchTabData(initialTab);
}
}
Future<void> _fetchTabData(String tab) async {
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (projectId.isEmpty) return;
// Call only the relevant API for current tab switch (tab) {
switch (selectedTab) {
case 'todaysAttendance': case 'todaysAttendance':
await attendanceController.fetchTodaysAttendance(projectId); await attendanceController.fetchTodaysAttendance(projectId);
break; break;
@ -76,6 +117,10 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
} }
} }
Future<void> _refreshData() async {
await _fetchTabData(attendanceController.selectedTab);
}
Widget _buildFilterSearchRow() { Widget _buildFilterSearchRow() {
return Padding( return Padding(
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
@ -88,7 +133,8 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
final query = attendanceController.searchQuery.value; final query = attendanceController.searchQuery.value;
return TextField( return TextField(
controller: TextEditingController(text: query) controller: TextEditingController(text: query)
..selection = TextSelection.collapsed(offset: query.length), ..selection =
TextSelection.collapsed(offset: query.length),
onChanged: (value) { onChanged: (value) {
attendanceController.searchQuery.value = value; attendanceController.searchQuery.value = value;
}, },
@ -109,11 +155,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
), ),
@ -121,17 +167,14 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
}), }),
), ),
), ),
MySpacing.width(8), MySpacing.width(8),
// 🛠 Filter Icon (no red dot here anymore)
Container( Container(
height: 35, height: 35,
width: 35, width: 35,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
), ),
child: IconButton( child: IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@ -144,19 +187,18 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: borderRadius:
BorderRadius.vertical(top: Radius.circular(12)), BorderRadius.vertical(top: Radius.circular(5)),
), ),
builder: (context) => AttendanceFilterBottomSheet( builder: (context) => AttendanceFilterBottomSheet(
controller: attendanceController, controller: attendanceController,
permissionController: permissionController, permissionController: permissionController,
selectedTab: selectedTab, selectedTab: _tabs[_tabController.index]['value']!,
), ),
); );
if (result != null) { if (result != null) {
final selectedProjectId = final selectedProjectId =
projectController.selectedProjectId.value; projectController.selectedProjectId.value;
final selectedView = result['selectedTab'] as String?;
final selectedOrgId = final selectedOrgId =
result['selectedOrganization'] as String?; result['selectedOrganization'] as String?;
@ -167,111 +209,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
} }
if (selectedProjectId.isNotEmpty) { if (selectedProjectId.isNotEmpty) {
try { await _fetchTabData(attendanceController.selectedTab);
await attendanceController.fetchTodaysAttendance(
selectedProjectId,
);
await attendanceController.fetchAttendanceLogs(
selectedProjectId,
);
await attendanceController.fetchRegularizationLogs(
selectedProjectId,
);
await attendanceController
.fetchProjectData(selectedProjectId);
} catch (_) {}
attendanceController
.update(['attendance_dashboard_controller']);
}
if (selectedView != null && selectedView != selectedTab) {
setState(() => selectedTab = selectedView);
attendanceController.selectedTab = selectedView;
if (selectedProjectId.isNotEmpty) {
await attendanceController
.fetchProjectData(selectedProjectId);
}
} }
} }
}, },
), ),
), ),
MySpacing.width(8),
// Pending Actions Menu (red dot here instead)
if (selectedTab == 'attendanceLogs')
Obx(() {
final showPending = attendanceController.showPendingOnly.value;
return Stack(
children: [
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
),
child: PopupMenuButton<int>(
padding: EdgeInsets.zero,
icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
itemBuilder: (context) => [
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Preferences",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
PopupMenuItem<int>(
value: 0,
enabled: false,
child: Obx(() => Row(
children: [
const SizedBox(width: 10),
const Expanded(
child: Text('Show Pending Actions')),
Switch.adaptive(
value: attendanceController
.showPendingOnly.value,
activeColor: Colors.indigo,
onChanged: (val) {
attendanceController
.showPendingOnly.value = val;
Navigator.pop(context);
},
),
],
)),
),
],
),
),
if (showPending)
Positioned(
top: 6,
right: 6,
child: Container(
height: 8,
width: 8,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
);
}),
], ],
), ),
); );
@ -290,22 +233,38 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
); );
} }
Widget _buildSelectedTabContent() { Widget _buildTabBarView() {
switch (selectedTab) { return TabBarView(
case 'attendanceLogs': controller: _tabController,
return AttendanceLogsTab(controller: attendanceController); children: _tabs.map((tab) {
case 'regularizationRequests': switch (tab['value']) {
return RegularizationRequestsTab(controller: attendanceController); case 'attendanceLogs':
case 'todaysAttendance': return AttendanceLogsTab(controller: attendanceController);
default: case 'regularizationRequests':
return TodaysAttendanceTab(controller: attendanceController); return RegularizationRequestsTab(controller: attendanceController);
} case 'todaysAttendance':
default:
return TodaysAttendanceTab(controller: attendanceController);
}
}).toList(),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary; final Color appBarColor = contentTheme.primary;
if (!_tabsInitialized) {
return Scaffold(
appBar: CustomAppBar(
title: "Attendance",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'),
),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold( return Scaffold(
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Attendance", title: "Attendance",
@ -314,7 +273,6 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
), ),
body: Stack( body: Stack(
children: [ children: [
// Gradient container at top
Container( Container(
height: 80, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -328,8 +286,6 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
), ),
), ),
), ),
// Main content
SafeArea( SafeArea(
child: GetBuilder<AttendanceController>( child: GetBuilder<AttendanceController>(
init: attendanceController, init: attendanceController,
@ -346,7 +302,21 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
padding: MySpacing.zero, padding: MySpacing.zero,
child: Column( child: Column(
children: [ children: [
MySpacing.height(flexSpacing), Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: PillTabBar(
controller: _tabController,
tabs: _tabs.map((e) => e['label']!).toList(),
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
onTap: (index) async {
final selectedTab = _tabs[index]['value']!;
attendanceController.selectedTab = selectedTab;
await _fetchTabData(selectedTab);
},
),
),
_buildFilterSearchRow(), _buildFilterSearchRow(),
MyFlex( MyFlex(
children: [ children: [
@ -354,7 +324,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
sizes: 'lg-12 md-12 sm-12', sizes: 'lg-12 md-12 sm-12',
child: noProjectSelected child: noProjectSelected
? _buildNoProjectWidget() ? _buildNoProjectWidget()
: _buildSelectedTabContent(), : SizedBox(
height: MediaQuery.of(context).size.height -
200,
child: _buildTabBarView(),
),
), ),
], ],
), ),
@ -372,7 +346,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
@override @override
void dispose() { void dispose() {
// 🧹 Clean up the controller when user leaves this screen _tabController.dispose();
if (Get.isRegistered<AttendanceController>()) { if (Get.isRegistered<AttendanceController>()) {
Get.delete<AttendanceController>(); Get.delete<AttendanceController>();
} }

View File

@ -22,124 +22,173 @@ class TodaysAttendanceTab extends StatelessWidget {
final isLoading = controller.isLoadingEmployees.value; final isLoading = controller.isLoadingEmployees.value;
final employees = controller.filteredEmployees; final employees = controller.filteredEmployees;
return Column( if (isLoading) {
crossAxisAlignment: CrossAxisAlignment.start, return SkeletonLoaders.employeeListSkeletonLoader();
children: [ }
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), if (employees.isEmpty) {
child: Row( return const Center(
children: [ child: Padding(
Expanded( padding: EdgeInsets.all(16.0),
child: child: Text("No Employees Assigned"),
MyText.titleMedium("Today's Attendance", fontWeight: 600),
),
MyText.bodySmall(
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
fontWeight: 600,
color: Colors.grey[700],
),
],
),
), ),
if (isLoading) );
SkeletonLoaders.employeeListSkeletonLoader() }
else if (employees.isEmpty)
const SizedBox( return ListView.builder(
height: 120, itemCount: employees.length + 1, // +1 for header
child: Center(child: Text("No Employees Assigned"))) padding: MySpacing.only(
else bottom: 80), // Adjusted padding to add spacing at the bottom
MyCard.bordered( itemBuilder: (context, index) {
paddingAll: 8, // --- Header Row ---
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 12, top: 4),
child: Row(
children: [
MyText.bodySmall(
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
fontWeight: 600,
color: Colors.grey[700],
),
],
),
);
}
final employee = employees[index - 1];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyCard.bordered(
paddingAll: 12,
child: Column( child: Column(
children: List.generate(employees.length, (index) { crossAxisAlignment: CrossAxisAlignment.start,
final employee = employees[index]; children: [
return Column( // 1. Employee Info Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyContainer( Avatar(
paddingAll: 5, firstName: employee.firstName,
child: Row( lastName: employee.lastName,
size: 35,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Avatar( MyText.titleMedium(employee.name, fontWeight: 600),
firstName: employee.firstName, MySpacing.height(2),
lastName: employee.lastName, MyText.bodySmall(
size: 31), employee.designation,
MySpacing.width(16), fontWeight: 500,
Expanded( color: Colors.grey[600],
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(DateTimeUtils.formatDate(
employee.checkIn!,
'hh:mm a')),
],
),
if (employee.checkOut != null) ...[
MySpacing.width(16),
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
Text(DateTimeUtils.formatDate(
employee.checkOut!, 'hh:mm a')),
],
],
),
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)),
], ],
); ),
}),
// Separator
if (employee.checkIn != null || employee.checkOut != null)
const Divider(height: 24),
// 2. Attendance Time Details Row
if (employee.checkIn != null || employee.checkOut != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Check-in Time
_buildLogTime(
icon: Icons.login,
color: Colors.green,
label: 'Check-in',
time: employee.checkIn,
),
// Check-out Time
_buildLogTime(
icon: Icons.logout,
color: Colors.red,
label: 'Check-out',
time: employee.checkOut,
),
],
),
// 3. Action Buttons Row
MySpacing.height(16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: employee,
attendanceController: controller,
),
if (employee.checkIn != null) ...[
MySpacing.width(8),
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
),
],
],
),
],
), ),
), ),
], );
},
); );
}); });
} }
// Helper function to build a cleaner log time widget
Widget _buildLogTime({
required IconData icon,
required Color color,
required String label,
required DateTime? time,
}) {
if (time == null) {
return MyContainer(
padding: MySpacing.xy(12, 6),
borderRadiusAll: 5,
color: Colors.grey[100],
child: Row(
children: [
Icon(icon, size: 16, color: Colors.grey),
MySpacing.width(6),
MyText.bodySmall('$label: **N/A**',
fontWeight: 600, color: Colors.grey),
],
),
);
}
return MyContainer(
padding: MySpacing.xy(12, 6),
borderRadiusAll: 6,
color: color.withOpacity(0.1),
child: Row(
children: [
Icon(icon, size: 16, color: color),
MySpacing.width(6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelSmall(label, color: color, fontWeight: 600),
MyText.bodyMedium(
DateTimeUtils.formatDate(time, 'hh:mm a'),
fontWeight: 600,
color: Colors.black87,
),
],
),
],
),
);
}
} }

View File

@ -92,7 +92,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
contentTheme.primary.withOpacity(0.3), // lighter/faded contentTheme.primary.withOpacity(0.3), // lighter/faded
@ -393,7 +393,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
decoration: BoxDecoration( decoration: BoxDecoration(
color: isEnabled ? Colors.white : Colors.grey.shade100, color: isEnabled ? Colors.white : Colors.grey.shade100,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
border: Border.all( border: Border.all(
color: Colors.black12.withOpacity(.1), color: Colors.black12.withOpacity(.1),
width: 0.7, width: 0.7,

View File

@ -61,152 +61,163 @@ class _LayoutState extends State<Layout> with UIMixin {
final primaryColor = contentTheme.primary; final primaryColor = contentTheme.primary;
return Scaffold( return Scaffold(
key: controller.scaffoldKey, key: controller.scaffoldKey,
endDrawer: const UserProfileBar(), endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton, floatingActionButton: widget.floatingActionButton,
body: Container( body: Column(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
primaryColor,
primaryColor.withOpacity(0.7),
primaryColor.withOpacity(0.0),
],
stops: const [0.0, 0.1, 0.3],
),
),
child: Column(
children: [ children: [
_buildHeaderContent(isMobile), // Solid primary background area
Container(
width: double.infinity,
color: primaryColor,
child: _buildHeaderContent(isMobile),
),
Expanded( Expanded(
child: SafeArea( child: Container(
child: GestureDetector( width: double.infinity,
behavior: HitTestBehavior.translucent, decoration: BoxDecoration(
onTap: () {}, gradient: LinearGradient(
child: SingleChildScrollView( begin: Alignment.topCenter,
key: controller.scrollKey, end: Alignment.bottomCenter,
padding: EdgeInsets.zero, colors: [
child: widget.child, primaryColor,
primaryColor.withOpacity(0.7),
primaryColor.withOpacity(0.0),
],
stops: const [0.0, 0.1, 0.3],
),
),
child: SafeArea(
top: false,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {},
child: SingleChildScrollView(
key: controller.scrollKey,
padding: EdgeInsets.zero,
child: widget.child,
),
), ),
), ),
), ),
), ),
], ],
));
}
Widget _buildHeaderContent(bool isMobile) {
final selectedTenant = TenantService.currentTenant;
return Padding(
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
child: Container(
margin: const EdgeInsets.only(bottom: 18),
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Row(
children: [
// Logo section
Stack(
clipBehavior: Clip.none,
children: [
Image.asset(
Images.logoDark,
height: 50,
width: 50,
fit: BoxFit.contain,
),
// Beta badge
if (ApiEndpoints.baseUrl.contains("stage"))
Positioned(
bottom: 0,
left: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white, width: 1.2),
),
child: const Text(
'B',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
const SizedBox(width: 12),
// Titles
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
"Dashboard",
fontWeight: 700,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: Colors.black87,
),
if (selectedTenant != null)
MyText.bodySmall(
"Organization: ${selectedTenant.name}",
color: Colors.black54,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Menu button with red dot if MPIN missing
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.menu, color: Colors.black87),
onPressed: () =>
controller.scaffoldKey.currentState?.openEndDrawer(),
),
if (!hasMpin)
Positioned(
right: 10,
top: 10,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
),
],
), ),
), ),
); );
} }
Widget _buildHeaderContent(bool isMobile) {
final selectedTenant = TenantService.currentTenant;
return Padding(
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Row(
children: [
// Logo inside white background card
Stack(
clipBehavior: Clip.none,
children: [
Image.asset(
Images.logoDark,
height: 50,
width: 50,
fit: BoxFit.contain,
),
if (ApiEndpoints.baseUrl.contains("stage"))
Positioned(
bottom: 0,
left: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.white, width: 1.2),
),
child: const Text(
'B',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
"Dashboard",
fontWeight: 700,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: Colors.black87,
),
if (selectedTenant != null)
MyText.bodySmall(
"Organization: ${selectedTenant.name}",
color: Colors.black54,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.menu, color: Colors.black87),
onPressed: () =>
controller.scaffoldKey.currentState?.openEndDrawer(),
),
if (!hasMpin)
Positioned(
right: 10,
top: 10,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
),
],
),
),
);
}
} }