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

View File

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

View File

@ -128,161 +128,171 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
'${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.titleMedium("Attendance Logs", fontWeight: 600),
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,
return SingleChildScrollView(
padding: MySpacing.only(bottom: 80), // Added bottom spacing for scroll view
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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,
),
],
),
],
),
),
],
MyText.titleMedium("Attendance Logs", fontWeight: 600),
widget.controller.isLoading.value
? SkeletonLoaders.dateSkeletonLoader()
: MyText.bodySmall(
dateRangeText,
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
),
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: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/widgets/my_flex.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/controller/attendance/attendance_screen_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/view/Attendence/regularization_requests_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/helpers/widgets/my_refresh_indicator.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 {
const AttendanceScreen({super.key});
@ -23,43 +24,83 @@ class AttendanceScreen extends StatefulWidget {
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 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
void 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 {
if (projectId.isNotEmpty) await _loadData(projectId);
if (projectId.isNotEmpty && _tabsInitialized) {
await _fetchTabData(attendanceController.selectedTab);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) _loadData(projectId);
});
}
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");
// If permissions are already loaded at init
if (permissionController.permissionsLoaded.value) {
_initializeTabs();
}
}
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;
if (projectId.isEmpty) return;
// Call only the relevant API for current tab
switch (selectedTab) {
switch (tab) {
case 'todaysAttendance':
await attendanceController.fetchTodaysAttendance(projectId);
break;
@ -76,6 +117,10 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
}
}
Future<void> _refreshData() async {
await _fetchTabData(attendanceController.selectedTab);
}
Widget _buildFilterSearchRow() {
return Padding(
padding: MySpacing.xy(8, 8),
@ -88,7 +133,8 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
final query = attendanceController.searchQuery.value;
return TextField(
controller: TextEditingController(text: query)
..selection = TextSelection.collapsed(offset: query.length),
..selection =
TextSelection.collapsed(offset: query.length),
onChanged: (value) {
attendanceController.searchQuery.value = value;
},
@ -109,11 +155,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
@ -121,17 +167,14 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
}),
),
),
MySpacing.width(8),
// 🛠 Filter Icon (no red dot here anymore)
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
),
child: IconButton(
padding: EdgeInsets.zero,
@ -144,19 +187,18 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(top: Radius.circular(12)),
BorderRadius.vertical(top: Radius.circular(5)),
),
builder: (context) => AttendanceFilterBottomSheet(
controller: attendanceController,
permissionController: permissionController,
selectedTab: selectedTab,
selectedTab: _tabs[_tabController.index]['value']!,
),
);
if (result != null) {
final selectedProjectId =
projectController.selectedProjectId.value;
final selectedView = result['selectedTab'] as String?;
final selectedOrgId =
result['selectedOrganization'] as String?;
@ -167,111 +209,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
}
if (selectedProjectId.isNotEmpty) {
try {
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);
}
await _fetchTabData(attendanceController.selectedTab);
}
}
},
),
),
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() {
switch (selectedTab) {
case 'attendanceLogs':
return AttendanceLogsTab(controller: attendanceController);
case 'regularizationRequests':
return RegularizationRequestsTab(controller: attendanceController);
case 'todaysAttendance':
default:
return TodaysAttendanceTab(controller: attendanceController);
}
Widget _buildTabBarView() {
return TabBarView(
controller: _tabController,
children: _tabs.map((tab) {
switch (tab['value']) {
case 'attendanceLogs':
return AttendanceLogsTab(controller: attendanceController);
case 'regularizationRequests':
return RegularizationRequestsTab(controller: attendanceController);
case 'todaysAttendance':
default:
return TodaysAttendanceTab(controller: attendanceController);
}
}).toList(),
);
}
@override
Widget build(BuildContext context) {
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(
appBar: CustomAppBar(
title: "Attendance",
@ -314,7 +273,6 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
),
body: Stack(
children: [
// Gradient container at top
Container(
height: 80,
decoration: BoxDecoration(
@ -328,8 +286,6 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
),
),
),
// Main content
SafeArea(
child: GetBuilder<AttendanceController>(
init: attendanceController,
@ -346,7 +302,21 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
padding: MySpacing.zero,
child: Column(
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(),
MyFlex(
children: [
@ -354,7 +324,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
sizes: 'lg-12 md-12 sm-12',
child: noProjectSelected
? _buildNoProjectWidget()
: _buildSelectedTabContent(),
: SizedBox(
height: MediaQuery.of(context).size.height -
200,
child: _buildTabBarView(),
),
),
],
),
@ -372,7 +346,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
@override
void dispose() {
// 🧹 Clean up the controller when user leaves this screen
_tabController.dispose();
if (Get.isRegistered<AttendanceController>()) {
Get.delete<AttendanceController>();
}

View File

@ -22,124 +22,173 @@ class TodaysAttendanceTab extends StatelessWidget {
final isLoading = controller.isLoadingEmployees.value;
final employees = controller.filteredEmployees;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
Expanded(
child:
MyText.titleMedium("Today's Attendance", fontWeight: 600),
),
MyText.bodySmall(
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
fontWeight: 600,
color: Colors.grey[700],
),
],
),
if (isLoading) {
return SkeletonLoaders.employeeListSkeletonLoader();
}
if (employees.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text("No Employees Assigned"),
),
if (isLoading)
SkeletonLoaders.employeeListSkeletonLoader()
else if (employees.isEmpty)
const SizedBox(
height: 120,
child: Center(child: Text("No Employees Assigned")))
else
MyCard.bordered(
paddingAll: 8,
);
}
return ListView.builder(
itemCount: employees.length + 1, // +1 for header
padding: MySpacing.only(
bottom: 80), // Adjusted padding to add spacing at the bottom
itemBuilder: (context, index) {
// --- 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(
children: List.generate(employees.length, (index) {
final employee = employees[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Employee Info Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyContainer(
paddingAll: 5,
child: Row(
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 35,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 31),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
children: [
MyText.bodyMedium(employee.name,
fontWeight: 600),
MyText.bodySmall(
'(${employee.designation})',
fontWeight: 600,
color: Colors.grey[700]),
],
),
MySpacing.height(8),
if (employee.checkIn != null ||
employee.checkOut != null)
Row(
children: [
if (employee.checkIn != null)
Row(
children: [
const Icon(
Icons.arrow_circle_right,
size: 16,
color: Colors.green),
MySpacing.width(4),
Text(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,
),
],
],
),
],
),
MyText.titleMedium(employee.name, fontWeight: 600),
MySpacing.height(2),
MyText.bodySmall(
employee.designation,
fontWeight: 500,
color: Colors.grey[600],
),
],
),
),
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(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(5),
gradient: LinearGradient(
colors: [
contentTheme.primary.withOpacity(0.3), // lighter/faded
@ -393,7 +393,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
decoration: BoxDecoration(
color: isEnabled ? Colors.white : Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: Colors.black12.withOpacity(.1),
width: 0.7,

View File

@ -61,152 +61,163 @@ class _LayoutState extends State<Layout> with UIMixin {
final primaryColor = contentTheme.primary;
return Scaffold(
key: controller.scaffoldKey,
endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton,
body: Container(
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(
key: controller.scaffoldKey,
endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton,
body: Column(
children: [
_buildHeaderContent(isMobile),
// Solid primary background area
Container(
width: double.infinity,
color: primaryColor,
child: _buildHeaderContent(isMobile),
),
Expanded(
child: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {},
child: SingleChildScrollView(
key: controller.scrollKey,
padding: EdgeInsets.zero,
child: widget.child,
child: Container(
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: 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),
),
),
),
],
),
],
),
),
);
}
}