diff --git a/lib/model/attendance/attendence_filter_sheet.dart b/lib/model/attendance/attendence_filter_sheet.dart new file mode 100644 index 0000000..ae28942 --- /dev/null +++ b/lib/model/attendance/attendence_filter_sheet.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; +import 'package:intl/intl.dart'; + +class AttendanceFilterBottomSheet extends StatelessWidget { + final AttendanceController controller; + final PermissionController permissionController; + final String selectedTab; + + const AttendanceFilterBottomSheet({ + super.key, + required this.controller, + required this.permissionController, + required this.selectedTab, + }); + + String getLabelText() { + final startDate = controller.startDateAttendance; + final endDate = controller.endDateAttendance; + if (startDate != null && endDate != null) { + final start = DateFormat('dd MM yyyy').format(startDate); + final end = DateFormat('dd MM yyyy').format(endDate); + return "$start - $end"; + } + return "Select Date Range"; + } + + @override + Widget build(BuildContext context) { + String? tempSelectedProjectId = controller.selectedProjectId; + String tempSelectedTab = selectedTab; + bool showProjectList = false; + + final accessibleProjects = controller.projects + .where((project) => + permissionController.isUserAssignedToProject(project.id.toString())) + .toList(); + + return StatefulBuilder(builder: (context, setState) { + List filterWidgets; + + if (showProjectList) { + filterWidgets = accessibleProjects.isEmpty + ? [ + const Padding( + padding: EdgeInsets.all(12.0), + child: Center(child: Text('No Projects Assigned')), + ), + ] + : accessibleProjects.map((project) { + final isSelected = + tempSelectedProjectId == project.id.toString(); + return ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text(project.name), + trailing: isSelected ? const Icon(Icons.check) : null, + onTap: () { + setState(() { + tempSelectedProjectId = project.id.toString(); + showProjectList = false; + }); + }, + ); + }).toList(); + } else { + final selectedProject = accessibleProjects.isNotEmpty + ? accessibleProjects.firstWhere( + (p) => p.id.toString() == tempSelectedProjectId, + orElse: () => accessibleProjects[0], + ) + : null; + + final selectedProjectName = + selectedProject?.name ?? "Select Project"; + + filterWidgets = [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('Select Project', + style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text(selectedProjectName), + trailing: const Icon(Icons.arrow_drop_down), + onTap: () => setState(() => showProjectList = true), + ), + const Divider(), + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('Select View', + style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ...[ + {'label': 'Today\'s Attendance', 'value': 'todaysAttendance'}, + {'label': 'Attendance Logs', 'value': 'attendanceLogs'}, + {'label': 'Regularization Requests', 'value': 'regularizationRequests'}, + ].map((item) { + return RadioListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + title: Text(item['label']!), + value: item['value']!, + groupValue: tempSelectedTab, + onChanged: (value) => setState(() => tempSelectedTab = value!), + ); + }).toList(), + ]; + + if (tempSelectedTab == 'attendanceLogs') { + filterWidgets.addAll([ + const Divider(), + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "Select Date Range", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => controller.selectDateRangeForAttendance( + context, + controller, + ), + child: Ink( + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(10), + ), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(Icons.date_range, color: Colors.blue.shade600), + const SizedBox(width: 12), + Expanded( + child: Text( + getLabelText(), + style: const TextStyle( + fontSize: 16, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ), + ), + ]); + } + } + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...filterWidgets, + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Apply Filter'), + onPressed: () { + Navigator.pop(context, { + 'projectId': tempSelectedProjectId, + 'selectedTab': tempSelectedTab, + }); + }, + ), + ), + ), + ], + ), + ), + ); + }); + } +} diff --git a/lib/model/attendance/log_details_view.dart b/lib/model/attendance/log_details_view.dart new file mode 100644 index 0000000..deb0331 --- /dev/null +++ b/lib/model/attendance/log_details_view.dart @@ -0,0 +1,317 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/utils/attendance_actions.dart'; + +class AttendanceLogViewButton extends StatelessWidget { + final dynamic employee; + final dynamic attendanceController; // Use correct types as needed + + const AttendanceLogViewButton({ + Key? key, + required this.employee, + required this.attendanceController, + }) : super(key: key); + + Future _openGoogleMaps( + BuildContext context, double lat, double lon) async { + final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon'; + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open Google Maps')), + ); + } + } + + void _showImageDialog(BuildContext context, String imageUrl) { + showDialog( + context: context, + builder: (_) => Dialog( + child: Image.network( + imageUrl, + fit: BoxFit.cover, + height: 400, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.broken_image, + size: 50, + color: Colors.grey, + ); + }, + ), + ), + ); + } + + void _showLogsBottomSheet(BuildContext context) async { + await attendanceController.fetchLogsView(employee.id.toString()); + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + backgroundColor: Theme.of(context).cardColor, + builder: (context) => Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.titleMedium( + "Attendance Log", + fontWeight: 700, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 12), + if (attendanceController.attendenceLogsView.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Column( + children: const [ + Icon(Icons.info_outline, size: 40, color: Colors.grey), + SizedBox(height: 8), + Text("No attendance logs available."), + ], + ), + ) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: attendanceController.attendenceLogsView.length, + separatorBuilder: (_, __) => const SizedBox(height: 16), + itemBuilder: (_, index) { + final log = attendanceController.attendenceLogsView[index]; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ) + ], + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _getLogIcon(log), + const SizedBox(width: 10), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.bodyLarge( + log.formattedDate ?? '-', + fontWeight: 600, + ), + MyText.bodySmall( + "Time: ${log.formattedTime ?? '-'}", + color: Colors.grey[700], + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (log.latitude != null && + log.longitude != null) + GestureDetector( + onTap: () { + final lat = double.tryParse(log + .latitude + .toString()) ?? + 0.0; + final lon = double.tryParse(log + .longitude + .toString()) ?? + 0.0; + if (lat >= -90 && + lat <= 90 && + lon >= -180 && + lon <= 180) { + _openGoogleMaps( + context, lat, lon); + } else { + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text( + 'Invalid location coordinates')), + ); + } + }, + child: const Padding( + padding: + EdgeInsets.only(right: 8.0), + child: Icon(Icons.location_on, + size: 18, color: Colors.blue), + ), + ), + Expanded( + child: MyText.bodyMedium( + log.comment?.isNotEmpty == true + ? log.comment + : "No description provided", + fontWeight: 500, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 16), + if (log.thumbPreSignedUrl != null) + GestureDetector( + onTap: () { + if (log.preSignedUrl != null) { + _showImageDialog( + context, log.preSignedUrl!); + } + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + log.thumbPreSignedUrl!, + height: 60, + width: 60, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) { + return const Icon(Icons.broken_image, + size: 20, color: Colors.grey); + }, + ), + ), + ) + else + const Icon(Icons.broken_image, + size: 20, color: Colors.grey), + ], + ), + ], + ), + ); + }, + ) + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 30, + child: ElevatedButton( + onPressed: () => _showLogsBottomSheet(context), + style: ElevatedButton.styleFrom( + backgroundColor: AttendanceActionColors.colors[ButtonActions.checkIn], + textStyle: const TextStyle(fontSize: 12), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + child: const FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "View", + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.white), + ), + ), + ), + ); + } + + Widget _getLogIcon(dynamic log) { + int activity = log.activity ?? -1; + + IconData iconData; + Color iconColor; + bool isTodayOrYesterday = false; + + try { + if (log.formattedDate != null) { + final logDate = DateTime.parse(log.formattedDate); + final now = DateTime.now(); + + final today = DateTime(now.year, now.month, now.day); + final logDay = DateTime(logDate.year, logDate.month, logDate.day); + final yesterday = today.subtract(Duration(days: 1)); + + isTodayOrYesterday = (logDay == today) || (logDay == yesterday); + } + } catch (_) { + isTodayOrYesterday = false; + } + + switch (activity) { + case 0: + case 1: + iconData = Icons.arrow_circle_right; + iconColor = Colors.green; + break; + case 2: + iconData = Icons.hourglass_top; + iconColor = Colors.blueGrey; + break; + case 4: + if (isTodayOrYesterday) { + iconData = Icons.arrow_circle_left; + iconColor = Colors.red; + } else { + iconData = Icons.check; + iconColor = Colors.green; + } + break; + case 5: + iconData = Icons.close; + iconColor = Colors.red; + break; + default: + iconData = Icons.info; + iconColor = Colors.grey; + } + + return Icon(iconData, color: iconColor, size: 20); + } +}