From 678a73a6964b6985d76ac830e446257cf684ff03 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Wed, 11 Feb 2026 20:12:48 +0800 Subject: [PATCH] Implement Asia/Manila Time Zone Handled saving of Relievers --- lib/main.dart | 3 + lib/models/app_settings.dart | 68 ++- lib/models/duty_schedule.dart | 21 +- lib/models/notification_item.dart | 6 +- lib/models/swap_request.dart | 6 +- lib/models/task.dart | 8 +- lib/models/task_assignment.dart | 4 +- lib/models/ticket.dart | 10 +- lib/models/ticket_message.dart | 4 +- lib/providers/admin_user_provider.dart | 10 +- lib/providers/notifications_provider.dart | 7 +- lib/screens/admin/user_management_screen.dart | 34 +- lib/screens/dashboard/dashboard_screen.dart | 3 +- lib/screens/tasks/task_detail_screen.dart | 5 +- lib/screens/tasks/tasks_list_screen.dart | 17 +- lib/screens/tickets/tickets_list_screen.dart | 5 +- lib/screens/workforce/workforce_screen.dart | 469 ++++++++++++++---- lib/utils/app_time.dart | 30 ++ pubspec.lock | 8 + pubspec.yaml | 1 + 20 files changed, 551 insertions(+), 168 deletions(-) create mode 100644 lib/utils/app_time.dart diff --git a/lib/main.dart b/lib/main.dart index 47b79ec2..b439e0ad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,12 +6,15 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'app.dart'; import 'providers/notifications_provider.dart'; +import 'utils/app_time.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: '.env'); + AppTime.initialize(location: 'Asia/Manila'); + final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? ''; diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 82839468..a45d2000 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -1,19 +1,69 @@ class GeofenceConfig { - GeofenceConfig({ - required this.lat, - required this.lng, - required this.radiusMeters, - }); + GeofenceConfig({this.lat, this.lng, this.radiusMeters, this.polygon}); + + final double? lat; + final double? lng; + final double? radiusMeters; + final List? polygon; + + bool get hasPolygon => polygon?.isNotEmpty == true; + bool get hasCircle => + lat != null && lng != null && radiusMeters != null && radiusMeters! > 0; + + bool containsPolygon(double pointLat, double pointLng) { + final points = polygon; + if (points == null || points.isEmpty) return false; + var inside = false; + for (var i = 0, j = points.length - 1; i < points.length; j = i++) { + final xi = points[i].lng; + final yi = points[i].lat; + final xj = points[j].lng; + final yj = points[j].lat; + + final intersects = + ((yi > pointLat) != (yj > pointLat)) && + (pointLng < (xj - xi) * (pointLat - yi) / (yj - yi) + xi); + if (intersects) { + inside = !inside; + } + } + return inside; + } + + factory GeofenceConfig.fromJson(Map json) { + final rawPolygon = json['polygon'] ?? json['points']; + final polygonPoints = []; + if (rawPolygon is List) { + for (final entry in rawPolygon) { + if (entry is Map) { + polygonPoints.add(GeofencePoint.fromJson(entry)); + } else if (entry is Map) { + polygonPoints.add( + GeofencePoint.fromJson(Map.from(entry)), + ); + } + } + } + + return GeofenceConfig( + lat: (json['lat'] as num?)?.toDouble(), + lng: (json['lng'] as num?)?.toDouble(), + radiusMeters: (json['radius_m'] as num?)?.toDouble(), + polygon: polygonPoints.isEmpty ? null : polygonPoints, + ); + } +} + +class GeofencePoint { + const GeofencePoint({required this.lat, required this.lng}); final double lat; final double lng; - final double radiusMeters; - factory GeofenceConfig.fromJson(Map json) { - return GeofenceConfig( + factory GeofencePoint.fromJson(Map json) { + return GeofencePoint( lat: (json['lat'] as num).toDouble(), lng: (json['lng'] as num).toDouble(), - radiusMeters: (json['radius_m'] as num).toDouble(), ); } } diff --git a/lib/models/duty_schedule.dart b/lib/models/duty_schedule.dart index d3e780ea..ae549f1e 100644 --- a/lib/models/duty_schedule.dart +++ b/lib/models/duty_schedule.dart @@ -1,3 +1,5 @@ +import '../utils/app_time.dart'; + class DutySchedule { DutySchedule({ required this.id, @@ -9,6 +11,7 @@ class DutySchedule { required this.createdAt, required this.checkInAt, required this.checkInLocation, + required this.relieverIds, }); final String id; @@ -20,20 +23,30 @@ class DutySchedule { final DateTime createdAt; final DateTime? checkInAt; final Object? checkInLocation; + final List relieverIds; factory DutySchedule.fromMap(Map map) { + final relieversRaw = map['reliever_ids']; + final relievers = relieversRaw is List + ? relieversRaw + .where((e) => e != null) + .map((entry) => entry.toString()) + .where((s) => s.isNotEmpty) + .toList() + : []; return DutySchedule( id: map['id'] as String, userId: map['user_id'] as String, shiftType: map['shift_type'] as String? ?? 'normal', - startTime: DateTime.parse(map['start_time'] as String), - endTime: DateTime.parse(map['end_time'] as String), + startTime: AppTime.parse(map['start_time'] as String), + endTime: AppTime.parse(map['end_time'] as String), status: map['status'] as String? ?? 'scheduled', - createdAt: DateTime.parse(map['created_at'] as String), + createdAt: AppTime.parse(map['created_at'] as String), checkInAt: map['check_in_at'] == null ? null - : DateTime.parse(map['check_in_at'] as String), + : AppTime.parse(map['check_in_at'] as String), checkInLocation: map['check_in_location'], + relieverIds: relievers, ); } } diff --git a/lib/models/notification_item.dart b/lib/models/notification_item.dart index e041c864..9af4bfb7 100644 --- a/lib/models/notification_item.dart +++ b/lib/models/notification_item.dart @@ -1,3 +1,5 @@ +import '../utils/app_time.dart'; + class NotificationItem { NotificationItem({ required this.id, @@ -32,10 +34,10 @@ class NotificationItem { taskId: map['task_id'] as String?, messageId: map['message_id'] as int?, type: map['type'] as String? ?? 'mention', - createdAt: DateTime.parse(map['created_at'] as String), + createdAt: AppTime.parse(map['created_at'] as String), readAt: map['read_at'] == null ? null - : DateTime.parse(map['read_at'] as String), + : AppTime.parse(map['read_at'] as String), ); } } diff --git a/lib/models/swap_request.dart b/lib/models/swap_request.dart index 50f5fbcc..212742cb 100644 --- a/lib/models/swap_request.dart +++ b/lib/models/swap_request.dart @@ -1,3 +1,5 @@ +import '../utils/app_time.dart'; + class SwapRequest { SwapRequest({ required this.id, @@ -26,10 +28,10 @@ class SwapRequest { recipientId: map['recipient_id'] as String, shiftId: map['shift_id'] as String, status: map['status'] as String? ?? 'pending', - createdAt: DateTime.parse(map['created_at'] as String), + createdAt: AppTime.parse(map['created_at'] as String), updatedAt: map['updated_at'] == null ? null - : DateTime.parse(map['updated_at'] as String), + : AppTime.parse(map['updated_at'] as String), approvedBy: map['approved_by'] as String?, ); } diff --git a/lib/models/task.dart b/lib/models/task.dart index 4b6eb621..e06d79b3 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -1,3 +1,5 @@ +import '../utils/app_time.dart'; + class Task { Task({ required this.id, @@ -37,14 +39,14 @@ class Task { status: map['status'] as String? ?? 'queued', priority: map['priority'] as int? ?? 1, queueOrder: map['queue_order'] as int?, - createdAt: DateTime.parse(map['created_at'] as String), + createdAt: AppTime.parse(map['created_at'] as String), creatorId: map['creator_id'] as String?, startedAt: map['started_at'] == null ? null - : DateTime.parse(map['started_at'] as String), + : AppTime.parse(map['started_at'] as String), completedAt: map['completed_at'] == null ? null - : DateTime.parse(map['completed_at'] as String), + : AppTime.parse(map['completed_at'] as String), ); } } diff --git a/lib/models/task_assignment.dart b/lib/models/task_assignment.dart index b04c621f..885402ad 100644 --- a/lib/models/task_assignment.dart +++ b/lib/models/task_assignment.dart @@ -1,3 +1,5 @@ +import '../utils/app_time.dart'; + class TaskAssignment { TaskAssignment({ required this.taskId, @@ -13,7 +15,7 @@ class TaskAssignment { return TaskAssignment( taskId: map['task_id'] as String, userId: map['user_id'] as String, - createdAt: DateTime.parse(map['created_at'] as String), + createdAt: AppTime.parse(map['created_at'] as String), ); } } diff --git a/lib/models/ticket.dart b/lib/models/ticket.dart index 251792ce..6bc1c094 100644 --- a/lib/models/ticket.dart +++ b/lib/models/ticket.dart @@ -1,3 +1,5 @@ +import '../utils/app_time.dart'; + class Ticket { Ticket({ required this.id, @@ -30,17 +32,17 @@ class Ticket { description: map['description'] as String? ?? '', officeId: map['office_id'] as String? ?? '', status: map['status'] as String? ?? 'pending', - createdAt: DateTime.parse(map['created_at'] as String), + createdAt: AppTime.parse(map['created_at'] as String), creatorId: map['creator_id'] as String?, respondedAt: map['responded_at'] == null ? null - : DateTime.parse(map['responded_at'] as String), + : AppTime.parse(map['responded_at'] as String), promotedAt: map['promoted_at'] == null ? null - : DateTime.parse(map['promoted_at'] as String), + : AppTime.parse(map['promoted_at'] as String), closedAt: map['closed_at'] == null ? null - : DateTime.parse(map['closed_at'] as String), + : AppTime.parse(map['closed_at'] as String), ); } } diff --git a/lib/models/ticket_message.dart b/lib/models/ticket_message.dart index 3e9aadb8..aa781d88 100644 --- a/lib/models/ticket_message.dart +++ b/lib/models/ticket_message.dart @@ -1,3 +1,5 @@ +import '../utils/app_time.dart'; + class TicketMessage { TicketMessage({ required this.id, @@ -22,7 +24,7 @@ class TicketMessage { taskId: map['task_id'] as String?, senderId: map['sender_id'] as String?, content: map['content'] as String? ?? '', - createdAt: DateTime.parse(map['created_at'] as String), + createdAt: AppTime.parse(map['created_at'] as String), ); } } diff --git a/lib/providers/admin_user_provider.dart b/lib/providers/admin_user_provider.dart index ab43dc18..a2b0958d 100644 --- a/lib/providers/admin_user_provider.dart +++ b/lib/providers/admin_user_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'supabase_provider.dart'; +import '../utils/app_time.dart'; final adminUserControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); @@ -16,7 +17,7 @@ class AdminUserStatus { bool get isLocked { if (bannedUntil == null) return false; - return bannedUntil!.isAfter(DateTime.now().toUtc()); + return bannedUntil!.isAfter(AppTime.now()); } } @@ -67,11 +68,14 @@ class AdminUserController { ); final user = (data as Map)['user'] as Map; final bannedUntilRaw = user['banned_until'] as String?; + final bannedUntilParsed = bannedUntilRaw == null + ? null + : DateTime.tryParse(bannedUntilRaw); return AdminUserStatus( email: user['email'] as String?, - bannedUntil: bannedUntilRaw == null + bannedUntil: bannedUntilParsed == null ? null - : DateTime.tryParse(bannedUntilRaw), + : AppTime.toAppTime(bannedUntilParsed), ); } diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index 42014348..756e2ba8 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -4,6 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/notification_item.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; +import '../utils/app_time.dart'; final notificationsProvider = StreamProvider>((ref) { final userId = ref.watch(currentUserIdProvider); @@ -69,7 +70,7 @@ class NotificationsController { Future markRead(String id) async { await _client .from('notifications') - .update({'read_at': DateTime.now().toUtc().toIso8601String()}) + .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('id', id); } @@ -78,7 +79,7 @@ class NotificationsController { if (userId == null) return; await _client .from('notifications') - .update({'read_at': DateTime.now().toUtc().toIso8601String()}) + .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('ticket_id', ticketId) .eq('user_id', userId) .filter('read_at', 'is', null); @@ -89,7 +90,7 @@ class NotificationsController { if (userId == null) return; await _client .from('notifications') - .update({'read_at': DateTime.now().toUtc().toIso8601String()}) + .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('task_id', taskId) .eq('user_id', userId) .filter('read_at', 'is', null); diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index 2aee7186..d53aba9e 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -9,6 +9,7 @@ import '../../providers/admin_user_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/user_offices_provider.dart'; +import '../../utils/app_time.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; @@ -122,13 +123,14 @@ class _UserManagementScreenState extends ConsumerState { final query = _searchController.text.trim().toLowerCase(); final filteredProfiles = query.isEmpty - ? profiles - : profiles.where((profile) { - final label = - profile.fullName.isNotEmpty ? profile.fullName : profile.id; - return label.toLowerCase().contains(query) || - profile.id.toLowerCase().contains(query); - }).toList(); + ? profiles + : profiles.where((profile) { + final label = profile.fullName.isNotEmpty + ? profile.fullName + : profile.id; + return label.toLowerCase().contains(query) || + profile.id.toLowerCase().contains(query); + }).toList(); final officeCountByUser = {}; for (final assignment in assignments) { @@ -156,8 +158,9 @@ class _UserManagementScreenState extends ConsumerState { TasQColumn( header: 'User', cellBuilder: (context, profile) { - final label = - profile.fullName.isEmpty ? profile.id : profile.fullName; + final label = profile.fullName.isEmpty + ? profile.id + : profile.fullName; return Text(label); }, ), @@ -166,8 +169,9 @@ class _UserManagementScreenState extends ConsumerState { cellBuilder: (context, profile) { final status = _statusCache[profile.id]; final hasError = _statusErrors.contains(profile.id); - final email = - hasError ? 'Unavailable' : (status?.email ?? 'Unknown'); + final email = hasError + ? 'Unavailable' + : (status?.email ?? 'Unknown'); return Text(email); }, ), @@ -188,8 +192,7 @@ class _UserManagementScreenState extends ConsumerState { final status = _statusCache[profile.id]; final hasError = _statusErrors.contains(profile.id); final isLoading = _statusLoading.contains(profile.id); - final statusLabel = - _userStatusLabel(status, hasError, isLoading); + final statusLabel = _userStatusLabel(status, hasError, isLoading); return _StatusBadge(label: statusLabel); }, ), @@ -204,8 +207,7 @@ class _UserManagementScreenState extends ConsumerState { onRowTap: (profile) => _showUserDialog(context, profile, offices, assignments), mobileTileBuilder: (context, profile, actions) { - final label = - profile.fullName.isEmpty ? profile.id : profile.fullName; + final label = profile.fullName.isEmpty ? profile.id : profile.fullName; final status = _statusCache[profile.id]; final hasError = _statusErrors.contains(profile.id); final isLoading = _statusLoading.contains(profile.id); @@ -673,7 +675,7 @@ String _userStatusLabel( String _formatLastActiveLabel(DateTime? value) { if (value == null) return 'N/A'; - final now = DateTime.now(); + final now = AppTime.now(); final diff = now.difference(value); if (diff.inMinutes < 1) return 'Just now'; if (diff.inHours < 1) return '${diff.inMinutes}m ago'; diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 9b6c8661..e7d7137d 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -12,6 +12,7 @@ import '../../providers/tickets_provider.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/status_pill.dart'; +import '../../utils/app_time.dart'; class DashboardMetrics { DashboardMetrics({ @@ -90,7 +91,7 @@ final dashboardMetricsProvider = Provider>((ref) { final assignments = assignmentsAsync.valueOrNull ?? const []; final messages = messagesAsync.valueOrNull ?? const []; - final now = DateTime.now(); + final now = AppTime.now(); final startOfDay = DateTime(now.year, now.month, now.day); final staffProfiles = profiles diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index adfb8f21..89390fd2 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -11,6 +11,7 @@ import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/typing_provider.dart'; +import '../../utils/app_time.dart'; import '../../widgets/app_breakpoints.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; @@ -396,13 +397,13 @@ class _TaskDetailScreenState extends ConsumerState { final animateExecution = task.startedAt != null && task.completedAt == null; if (!animateQueue && !animateExecution) { - return _buildTatContent(task, DateTime.now()); + return _buildTatContent(task, AppTime.now()); } return StreamBuilder( stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick), builder: (context, snapshot) { - return _buildTatContent(task, DateTime.now()); + return _buildTatContent(task, AppTime.now()); }, ); } diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 43fd4d6a..7d24d912 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -12,6 +12,7 @@ import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/typing_provider.dart'; +import '../../utils/app_time.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; @@ -171,8 +172,8 @@ class _TasksListScreenState extends ConsumerState { final next = await showDateRangePicker( context: context, firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - currentDate: DateTime.now(), + lastDate: AppTime.now().add(const Duration(days: 365)), + currentDate: AppTime.now(), initialDateRange: _selectedDateRange, ); if (!mounted) return; @@ -625,12 +626,12 @@ class _StatusSummaryRow extends StatelessWidget { builder: (context, constraints) { final maxWidth = constraints.maxWidth; final maxPerRow = maxWidth >= 1000 - ? 4 - : maxWidth >= 720 - ? 3 - : maxWidth >= 480 - ? 2 - : entries.length; + ? 4 + : maxWidth >= 720 + ? 3 + : maxWidth >= 480 + ? 2 + : entries.length; final perRow = entries.length < maxPerRow ? entries.length : maxPerRow; final spacing = maxWidth < 480 ? 8.0 : 12.0; final itemWidth = perRow == 0 diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index ed385fb9..795ed72a 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -10,6 +10,7 @@ import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/typing_provider.dart'; +import '../../utils/app_time.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; @@ -135,8 +136,8 @@ class _TicketsListScreenState extends ConsumerState { final next = await showDateRangePicker( context: context, firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - currentDate: DateTime.now(), + lastDate: AppTime.now().add(const Duration(days: 365)), + currentDate: AppTime.now(), initialDateRange: _selectedDateRange, ); if (!mounted) return; diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index b1eb647f..8e983d7e 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; @@ -7,6 +9,7 @@ import '../../models/profile.dart'; import '../../models/swap_request.dart'; import '../../providers/profile_provider.dart'; import '../../providers/workforce_provider.dart'; +import '../../utils/app_time.dart'; import '../../widgets/responsive_body.dart'; class WorkforceScreen extends ConsumerWidget { @@ -137,6 +140,10 @@ class _SchedulePanel extends ConsumerWidget { schedule, isAdmin, ), + relieverLabels: _relieverLabelsFromIds( + schedule.relieverIds, + profileById, + ), isMine: schedule.userId == currentUserId, ), ), @@ -217,30 +224,46 @@ class _SchedulePanel extends ConsumerWidget { final weekday = weekdays[value.weekday - 1]; return '$weekday, $month $day, ${value.year}'; } + + List _relieverLabelsFromIds( + List relieverIds, + Map profileById, + ) { + if (relieverIds.isEmpty) return const []; + return relieverIds + .map( + (id) => profileById[id]?.fullName.isNotEmpty == true + ? profileById[id]!.fullName + : id, + ) + .toList(); + } } class _ScheduleTile extends ConsumerWidget { const _ScheduleTile({ required this.schedule, required this.displayName, + required this.relieverLabels, required this.isMine, }); final DutySchedule schedule; final String displayName; + final List relieverLabels; final bool isMine; @override Widget build(BuildContext context, WidgetRef ref) { final currentUserId = ref.watch(currentUserIdProvider); final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? []; - final now = DateTime.now(); + final now = AppTime.now(); final isPast = schedule.startTime.isBefore(now); final canCheckIn = isMine && schedule.checkInAt == null && (schedule.status == 'scheduled' || schedule.status == 'late') && - now.isAfter(schedule.startTime.subtract(const Duration(minutes: 15))) && + now.isAfter(schedule.startTime.subtract(const Duration(hours: 2))) && now.isBefore(schedule.endTime); final hasRequestedSwap = swaps.any( (swap) => @@ -253,56 +276,79 @@ class _ScheduleTile extends ConsumerWidget { return Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( + child: Column( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - displayName, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - '${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 4), - Text( - _statusLabel(schedule.status), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: _statusColor(context, schedule.status), - ), - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, + Row( children: [ - if (canCheckIn) - FilledButton.icon( - onPressed: () => _handleCheckIn(context, ref, schedule), - icon: const Icon(Icons.location_on), - label: const Text('Check in'), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + _statusLabel(schedule.status), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: _statusColor(context, schedule.status), + ), + ), + ], ), - if (canRequestSwap) ...[ - if (canCheckIn) const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: hasRequestedSwap - ? () => _openSwapsTab(context) - : () => _requestSwap(context, ref, schedule), - icon: const Icon(Icons.swap_horiz), - label: Text( - hasRequestedSwap ? 'Swap Requested' : 'Request swap', - ), - ), - ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (canCheckIn) + FilledButton.icon( + onPressed: () => _handleCheckIn(context, ref, schedule), + icon: const Icon(Icons.location_on), + label: const Text('Check in'), + ), + if (canRequestSwap) ...[ + if (canCheckIn) const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: hasRequestedSwap + ? () => _openSwapsTab(context) + : () => _requestSwap(context, ref, schedule), + icon: const Icon(Icons.swap_horiz), + label: Text( + hasRequestedSwap ? 'Swap Requested' : 'Request swap', + ), + ), + ], + ], + ), ], ), + if (relieverLabels.isNotEmpty) + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: const Text('Relievers'), + children: [ + for (final label in relieverLabels) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 8, + right: 8, + bottom: 6, + ), + child: Text(label), + ), + ), + ], + ), ], ), ), @@ -317,14 +363,22 @@ class _ScheduleTile extends ConsumerWidget { final geofence = await ref.read(geofenceProvider.future); if (geofence == null) { if (!context.mounted) return; - _showMessage(context, 'Geofence is not configured.'); + await _showAlert( + context, + title: 'Geofence missing', + message: 'Geofence is not configured.', + ); return; } final serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { if (!context.mounted) return; - _showMessage(context, 'Location services are disabled.'); + await _showAlert( + context, + title: 'Location disabled', + message: 'Location services are disabled.', + ); return; } @@ -335,28 +389,43 @@ class _ScheduleTile extends ConsumerWidget { if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { if (!context.mounted) return; - _showMessage(context, 'Location permission denied.'); + await _showAlert( + context, + title: 'Permission denied', + message: 'Location permission denied.', + ); return; } - - final position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings(accuracy: LocationAccuracy.high), - ); - - final distance = Geolocator.distanceBetween( - position.latitude, - position.longitude, - geofence.lat, - geofence.lng, - ); - - if (distance > geofence.radiusMeters) { - if (!context.mounted) return; - _showMessage(context, 'You are outside the geofence. Wala ka sa CRMC.'); - return; - } - + if (!context.mounted) return; + final progressContext = await _showCheckInProgress(context); try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + final isInside = geofence.hasPolygon + ? geofence.containsPolygon(position.latitude, position.longitude) + : geofence.hasCircle && + Geolocator.distanceBetween( + position.latitude, + position.longitude, + geofence.lat!, + geofence.lng!, + ) <= + geofence.radiusMeters!; + + if (!isInside) { + if (!context.mounted) return; + await _showAlert( + context, + title: 'Outside geofence', + message: 'You are outside the geofence. Wala ka sa CRMC.', + ); + return; + } + final status = await ref .read(workforceControllerProvider) .checkIn( @@ -366,10 +435,22 @@ class _ScheduleTile extends ConsumerWidget { ); ref.invalidate(dutySchedulesProvider); if (!context.mounted) return; - _showMessage(context, 'Checked in ($status).'); + await _showAlert( + context, + title: 'Checked in', + message: 'Checked in ($status).', + ); } catch (error) { if (!context.mounted) return; - _showMessage(context, 'Check-in failed: $error'); + await _showAlert( + context, + title: 'Check-in failed', + message: 'Check-in failed: $error', + ); + } finally { + if (progressContext.mounted) { + Navigator.of(progressContext).pop(); + } } } @@ -446,6 +527,61 @@ class _ScheduleTile extends ConsumerWidget { ).showSnackBar(SnackBar(content: Text(message))); } + Future _showAlert( + BuildContext context, { + required String title, + required String message, + }) async { + await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ); + }, + ); + } + + Future _showCheckInProgress(BuildContext context) { + final completer = Completer(); + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + if (!completer.isCompleted) { + completer.complete(dialogContext); + } + return AlertDialog( + title: const Text('Validating location'), + content: Row( + children: [ + const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Flexible( + child: Text( + 'Please wait while we verify your location.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + }, + ); + return completer.future; + } + void _openSwapsTab(BuildContext context) { final controller = DefaultTabController.maybeOf(context); if (controller != null) { @@ -500,13 +636,15 @@ class _DraftSchedule { required this.shiftType, required this.startTime, required this.endTime, - }); + List? relieverIds, + }) : relieverIds = relieverIds ?? []; final int localId; String userId; String shiftType; DateTime startTime; DateTime endTime; + List relieverIds; } class _RotationEntry { @@ -669,7 +807,7 @@ class _ScheduleGeneratorPanelState } Future _pickDate({required bool isStart}) async { - final now = DateTime.now(); + final now = AppTime.now(); final initial = isStart ? _startDate ?? now : _endDate ?? _startDate ?? now; final picked = await showDatePicker( context: context, @@ -807,43 +945,73 @@ class _ScheduleGeneratorPanelState separatorBuilder: (context, index) => const SizedBox(height: 8), itemBuilder: (context, index) { final draft = _draftSchedules[index]; - final profile = _profileById()[draft.userId]; + final profileById = _profileById(); + final profile = profileById[draft.userId]; final userLabel = profile?.fullName.isNotEmpty == true ? profile!.fullName : draft.userId; + final relieverLabels = draft.relieverIds + .map( + (id) => profileById[id]?.fullName.isNotEmpty == true + ? profileById[id]!.fullName + : id, + ) + .toList(); return Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( + child: Column( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_shiftLabel(draft.shiftType)} · $userLabel', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + '${_formatDate(draft.startTime)} · ${_formatTime(draft.startTime)} - ${_formatTime(draft.endTime)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + tooltip: 'Edit', + onPressed: () => _editDraft(draft), + icon: const Icon(Icons.edit), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => _deleteDraft(draft), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + if (relieverLabels.isNotEmpty) + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: const Text('Relievers'), children: [ - Text( - '${_shiftLabel(draft.shiftType)} · $userLabel', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, + for (final label in relieverLabels) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 8, + right: 8, + bottom: 6, + ), + child: Text(label), + ), ), - ), - const SizedBox(height: 4), - Text( - '${_formatDate(draft.startTime)} · ${_formatTime(draft.startTime)} - ${_formatTime(draft.endTime)}', - style: Theme.of(context).textTheme.bodySmall, - ), ], ), - ), - IconButton( - tooltip: 'Edit', - onPressed: () => _editDraft(draft), - icon: const Icon(Icons.edit), - ), - IconButton( - tooltip: 'Delete', - onPressed: () => _deleteDraft(draft), - icon: const Icon(Icons.delete_outline), - ), ], ), ), @@ -864,8 +1032,8 @@ class _ScheduleGeneratorPanelState setState(() { _draftSchedules.removeWhere((item) => item.localId == draft.localId); _warnings = _buildWarnings( - _startDate ?? DateTime.now(), - _endDate ?? DateTime.now(), + _startDate ?? AppTime.now(), + _endDate ?? AppTime.now(), _draftSchedules, ); }); @@ -878,7 +1046,7 @@ class _ScheduleGeneratorPanelState return; } - final start = existing?.startTime ?? _startDate ?? DateTime.now(); + final start = existing?.startTime ?? _startDate ?? AppTime.now(); var selectedDate = DateTime(start.year, start.month, start.day); var selectedUserId = existing?.userId ?? staff.first.id; var selectedShift = existing?.shiftType ?? 'am'; @@ -1099,6 +1267,7 @@ class _ScheduleGeneratorPanelState 'start_time': draft.startTime.toIso8601String(), 'end_time': draft.endTime.toIso8601String(), 'status': 'scheduled', + 'reliever_ids': draft.relieverIds, }, ) .toList(); @@ -1249,6 +1418,10 @@ class _ScheduleGeneratorPanelState final nextWeekPmUserId = staff.isEmpty ? null : staff[(pmBaseIndex + 1) % staff.length].id; + final pmRelievers = _buildRelievers(pmBaseIndex, staff); + final nextWeekRelievers = staff.isEmpty + ? [] + : _buildRelievers((pmBaseIndex + 1) % staff.length, staff); var weekendNormalOffset = 0; for ( @@ -1273,6 +1446,7 @@ class _ScheduleGeneratorPanelState 'normal', staff[normalIndex].id, day, + const [], ); weekendNormalOffset += 1; } @@ -1284,15 +1458,40 @@ class _ScheduleGeneratorPanelState 'on_call', nextWeekPmUserId, day, + nextWeekRelievers, ); } } else { if (amUserId != null) { - _tryAddDraft(draft, existing, templates, 'am', amUserId, day); + _tryAddDraft( + draft, + existing, + templates, + 'am', + amUserId, + day, + const [], + ); } if (pmUserId != null) { - _tryAddDraft(draft, existing, templates, 'pm', pmUserId, day); - _tryAddDraft(draft, existing, templates, 'on_call', pmUserId, day); + _tryAddDraft( + draft, + existing, + templates, + 'pm', + pmUserId, + day, + pmRelievers, + ); + _tryAddDraft( + draft, + existing, + templates, + 'on_call', + pmUserId, + day, + pmRelievers, + ); } final assignedToday = [ @@ -1301,7 +1500,15 @@ class _ScheduleGeneratorPanelState ].whereType().toSet(); for (final profile in staff) { if (assignedToday.contains(profile.id)) continue; - _tryAddDraft(draft, existing, templates, 'normal', profile.id, day); + _tryAddDraft( + draft, + existing, + templates, + 'normal', + profile.id, + day, + const [], + ); } } } @@ -1319,6 +1526,7 @@ class _ScheduleGeneratorPanelState String shiftType, String userId, DateTime day, + List relieverIds, ) { final template = templates[_normalizeShiftType(shiftType)]!; final start = template.buildStart(day); @@ -1329,6 +1537,7 @@ class _ScheduleGeneratorPanelState shiftType: shiftType, startTime: start, endTime: end, + relieverIds: relieverIds, ); if (_hasConflict(candidate, draft, existing)) { @@ -1364,6 +1573,16 @@ class _ScheduleGeneratorPanelState return defaultIndex % staff.length; } + List _buildRelievers(int primaryIndex, List staff) { + if (staff.length <= 1) return const []; + final relievers = []; + for (var offset = 1; offset < staff.length; offset += 1) { + relievers.add(staff[(primaryIndex + offset) % staff.length].id); + if (relievers.length == 3) break; + } + return relievers; + } + bool _hasConflict( _DraftSchedule candidate, List<_DraftSchedule> drafts, @@ -1545,11 +1764,11 @@ class _SwapRequestsPanel extends ConsumerWidget { final profilesAsync = ref.watch(profilesProvider); final currentUserId = ref.watch(currentUserIdProvider); - final scheduleById = { + final Map scheduleById = { for (final schedule in schedulesAsync.valueOrNull ?? []) schedule.id: schedule, }; - final profileById = { + final Map profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; @@ -1577,6 +1796,9 @@ class _SwapRequestsPanel extends ConsumerWidget { final subtitle = schedule == null ? 'Shift not found' : '${_shiftLabel(schedule.shiftType)} · ${_formatDate(schedule.startTime)} · ${_formatTime(schedule.startTime)}'; + final relieverLabels = schedule == null + ? const [] + : _relieverLabelsFromIds(schedule.relieverIds, profileById); final isPending = item.status == 'pending'; final canRespond = @@ -1600,6 +1822,25 @@ class _SwapRequestsPanel extends ConsumerWidget { const SizedBox(height: 6), Text('Status: ${item.status}'), const SizedBox(height: 12), + if (relieverLabels.isNotEmpty) + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: const Text('Relievers'), + children: [ + for (final label in relieverLabels) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 8, + right: 8, + bottom: 6, + ), + child: Text(label), + ), + ), + ], + ), Row( children: [ if (canRespond) ...[ @@ -1703,4 +1944,18 @@ class _SwapRequestsPanel extends ConsumerWidget { final weekday = weekdays[value.weekday - 1]; return '$weekday, $month $day, ${value.year}'; } + + List _relieverLabelsFromIds( + List relieverIds, + Map profileById, + ) { + if (relieverIds.isEmpty) return const []; + return relieverIds + .map( + (id) => profileById[id]?.fullName.isNotEmpty == true + ? profileById[id]!.fullName + : id, + ) + .toList(); + } } diff --git a/lib/utils/app_time.dart b/lib/utils/app_time.dart new file mode 100644 index 00000000..820eca3a --- /dev/null +++ b/lib/utils/app_time.dart @@ -0,0 +1,30 @@ +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; + +class AppTime { + static bool _initialized = false; + + static void initialize({String location = 'Asia/Manila'}) { + if (_initialized) return; + tz.initializeTimeZones(); + tz.setLocalLocation(tz.getLocation(location)); + _initialized = true; + } + + static DateTime now() { + return tz.TZDateTime.now(tz.local); + } + + static DateTime nowUtc() { + return now().toUtc(); + } + + static DateTime toAppTime(DateTime value) { + final utc = value.isUtc ? value : value.toUtc(); + return tz.TZDateTime.from(utc, tz.local); + } + + static DateTime parse(String value) { + return toAppTime(DateTime.parse(value)); + } +} diff --git a/pubspec.lock b/pubspec.lock index aad4e5c8..95624496 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -821,6 +821,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index df936ddf..1dc2a787 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: google_fonts: ^6.2.1 audioplayers: ^6.1.0 geolocator: ^13.0.1 + timezone: ^0.9.4 dev_dependencies: flutter_test: