Implement Asia/Manila Time Zone

Handled saving of Relievers
This commit is contained in:
Marc Rejohn Castillano 2026-02-11 20:12:48 +08:00
parent 747edbdd8c
commit 678a73a696
20 changed files with 551 additions and 168 deletions

View File

@ -6,12 +6,15 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'app.dart'; import 'app.dart';
import 'providers/notifications_provider.dart'; import 'providers/notifications_provider.dart';
import 'utils/app_time.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: '.env'); await dotenv.load(fileName: '.env');
AppTime.initialize(location: 'Asia/Manila');
final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? '';
final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? ''; final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? '';

View File

@ -1,19 +1,69 @@
class GeofenceConfig { class GeofenceConfig {
GeofenceConfig({ GeofenceConfig({this.lat, this.lng, this.radiusMeters, this.polygon});
required this.lat,
required this.lng, final double? lat;
required this.radiusMeters, final double? lng;
}); final double? radiusMeters;
final List<GeofencePoint>? 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<String, dynamic> json) {
final rawPolygon = json['polygon'] ?? json['points'];
final polygonPoints = <GeofencePoint>[];
if (rawPolygon is List) {
for (final entry in rawPolygon) {
if (entry is Map<String, dynamic>) {
polygonPoints.add(GeofencePoint.fromJson(entry));
} else if (entry is Map) {
polygonPoints.add(
GeofencePoint.fromJson(Map<String, dynamic>.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 lat;
final double lng; final double lng;
final double radiusMeters;
factory GeofenceConfig.fromJson(Map<String, dynamic> json) { factory GeofencePoint.fromJson(Map<String, dynamic> json) {
return GeofenceConfig( return GeofencePoint(
lat: (json['lat'] as num).toDouble(), lat: (json['lat'] as num).toDouble(),
lng: (json['lng'] as num).toDouble(), lng: (json['lng'] as num).toDouble(),
radiusMeters: (json['radius_m'] as num).toDouble(),
); );
} }
} }

View File

@ -1,3 +1,5 @@
import '../utils/app_time.dart';
class DutySchedule { class DutySchedule {
DutySchedule({ DutySchedule({
required this.id, required this.id,
@ -9,6 +11,7 @@ class DutySchedule {
required this.createdAt, required this.createdAt,
required this.checkInAt, required this.checkInAt,
required this.checkInLocation, required this.checkInLocation,
required this.relieverIds,
}); });
final String id; final String id;
@ -20,20 +23,30 @@ class DutySchedule {
final DateTime createdAt; final DateTime createdAt;
final DateTime? checkInAt; final DateTime? checkInAt;
final Object? checkInLocation; final Object? checkInLocation;
final List<String> relieverIds;
factory DutySchedule.fromMap(Map<String, dynamic> map) { factory DutySchedule.fromMap(Map<String, dynamic> 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()
: <String>[];
return DutySchedule( return DutySchedule(
id: map['id'] as String, id: map['id'] as String,
userId: map['user_id'] as String, userId: map['user_id'] as String,
shiftType: map['shift_type'] as String? ?? 'normal', shiftType: map['shift_type'] as String? ?? 'normal',
startTime: DateTime.parse(map['start_time'] as String), startTime: AppTime.parse(map['start_time'] as String),
endTime: DateTime.parse(map['end_time'] as String), endTime: AppTime.parse(map['end_time'] as String),
status: map['status'] as String? ?? 'scheduled', 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 checkInAt: map['check_in_at'] == null
? null ? null
: DateTime.parse(map['check_in_at'] as String), : AppTime.parse(map['check_in_at'] as String),
checkInLocation: map['check_in_location'], checkInLocation: map['check_in_location'],
relieverIds: relievers,
); );
} }
} }

View File

@ -1,3 +1,5 @@
import '../utils/app_time.dart';
class NotificationItem { class NotificationItem {
NotificationItem({ NotificationItem({
required this.id, required this.id,
@ -32,10 +34,10 @@ class NotificationItem {
taskId: map['task_id'] as String?, taskId: map['task_id'] as String?,
messageId: map['message_id'] as int?, messageId: map['message_id'] as int?,
type: map['type'] as String? ?? 'mention', 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 readAt: map['read_at'] == null
? null ? null
: DateTime.parse(map['read_at'] as String), : AppTime.parse(map['read_at'] as String),
); );
} }
} }

View File

@ -1,3 +1,5 @@
import '../utils/app_time.dart';
class SwapRequest { class SwapRequest {
SwapRequest({ SwapRequest({
required this.id, required this.id,
@ -26,10 +28,10 @@ class SwapRequest {
recipientId: map['recipient_id'] as String, recipientId: map['recipient_id'] as String,
shiftId: map['shift_id'] as String, shiftId: map['shift_id'] as String,
status: map['status'] as String? ?? 'pending', 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 updatedAt: map['updated_at'] == null
? null ? null
: DateTime.parse(map['updated_at'] as String), : AppTime.parse(map['updated_at'] as String),
approvedBy: map['approved_by'] as String?, approvedBy: map['approved_by'] as String?,
); );
} }

View File

@ -1,3 +1,5 @@
import '../utils/app_time.dart';
class Task { class Task {
Task({ Task({
required this.id, required this.id,
@ -37,14 +39,14 @@ class Task {
status: map['status'] as String? ?? 'queued', status: map['status'] as String? ?? 'queued',
priority: map['priority'] as int? ?? 1, priority: map['priority'] as int? ?? 1,
queueOrder: map['queue_order'] as int?, 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?, creatorId: map['creator_id'] as String?,
startedAt: map['started_at'] == null startedAt: map['started_at'] == null
? null ? null
: DateTime.parse(map['started_at'] as String), : AppTime.parse(map['started_at'] as String),
completedAt: map['completed_at'] == null completedAt: map['completed_at'] == null
? null ? null
: DateTime.parse(map['completed_at'] as String), : AppTime.parse(map['completed_at'] as String),
); );
} }
} }

View File

@ -1,3 +1,5 @@
import '../utils/app_time.dart';
class TaskAssignment { class TaskAssignment {
TaskAssignment({ TaskAssignment({
required this.taskId, required this.taskId,
@ -13,7 +15,7 @@ class TaskAssignment {
return TaskAssignment( return TaskAssignment(
taskId: map['task_id'] as String, taskId: map['task_id'] as String,
userId: map['user_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),
); );
} }
} }

View File

@ -1,3 +1,5 @@
import '../utils/app_time.dart';
class Ticket { class Ticket {
Ticket({ Ticket({
required this.id, required this.id,
@ -30,17 +32,17 @@ class Ticket {
description: map['description'] as String? ?? '', description: map['description'] as String? ?? '',
officeId: map['office_id'] as String? ?? '', officeId: map['office_id'] as String? ?? '',
status: map['status'] as String? ?? 'pending', 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?, creatorId: map['creator_id'] as String?,
respondedAt: map['responded_at'] == null respondedAt: map['responded_at'] == null
? null ? null
: DateTime.parse(map['responded_at'] as String), : AppTime.parse(map['responded_at'] as String),
promotedAt: map['promoted_at'] == null promotedAt: map['promoted_at'] == null
? null ? null
: DateTime.parse(map['promoted_at'] as String), : AppTime.parse(map['promoted_at'] as String),
closedAt: map['closed_at'] == null closedAt: map['closed_at'] == null
? null ? null
: DateTime.parse(map['closed_at'] as String), : AppTime.parse(map['closed_at'] as String),
); );
} }
} }

View File

@ -1,3 +1,5 @@
import '../utils/app_time.dart';
class TicketMessage { class TicketMessage {
TicketMessage({ TicketMessage({
required this.id, required this.id,
@ -22,7 +24,7 @@ class TicketMessage {
taskId: map['task_id'] as String?, taskId: map['task_id'] as String?,
senderId: map['sender_id'] as String?, senderId: map['sender_id'] as String?,
content: map['content'] as String? ?? '', content: map['content'] as String? ?? '',
createdAt: DateTime.parse(map['created_at'] as String), createdAt: AppTime.parse(map['created_at'] as String),
); );
} }
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import '../utils/app_time.dart';
final adminUserControllerProvider = Provider<AdminUserController>((ref) { final adminUserControllerProvider = Provider<AdminUserController>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
@ -16,7 +17,7 @@ class AdminUserStatus {
bool get isLocked { bool get isLocked {
if (bannedUntil == null) return false; 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<String, dynamic>)['user'] as Map<String, dynamic>; final user = (data as Map<String, dynamic>)['user'] as Map<String, dynamic>;
final bannedUntilRaw = user['banned_until'] as String?; final bannedUntilRaw = user['banned_until'] as String?;
final bannedUntilParsed = bannedUntilRaw == null
? null
: DateTime.tryParse(bannedUntilRaw);
return AdminUserStatus( return AdminUserStatus(
email: user['email'] as String?, email: user['email'] as String?,
bannedUntil: bannedUntilRaw == null bannedUntil: bannedUntilParsed == null
? null ? null
: DateTime.tryParse(bannedUntilRaw), : AppTime.toAppTime(bannedUntilParsed),
); );
} }

View File

@ -4,6 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/notification_item.dart'; import '../models/notification_item.dart';
import 'profile_provider.dart'; import 'profile_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import '../utils/app_time.dart';
final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) { final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
final userId = ref.watch(currentUserIdProvider); final userId = ref.watch(currentUserIdProvider);
@ -69,7 +70,7 @@ class NotificationsController {
Future<void> markRead(String id) async { Future<void> markRead(String id) async {
await _client await _client
.from('notifications') .from('notifications')
.update({'read_at': DateTime.now().toUtc().toIso8601String()}) .update({'read_at': AppTime.nowUtc().toIso8601String()})
.eq('id', id); .eq('id', id);
} }
@ -78,7 +79,7 @@ class NotificationsController {
if (userId == null) return; if (userId == null) return;
await _client await _client
.from('notifications') .from('notifications')
.update({'read_at': DateTime.now().toUtc().toIso8601String()}) .update({'read_at': AppTime.nowUtc().toIso8601String()})
.eq('ticket_id', ticketId) .eq('ticket_id', ticketId)
.eq('user_id', userId) .eq('user_id', userId)
.filter('read_at', 'is', null); .filter('read_at', 'is', null);
@ -89,7 +90,7 @@ class NotificationsController {
if (userId == null) return; if (userId == null) return;
await _client await _client
.from('notifications') .from('notifications')
.update({'read_at': DateTime.now().toUtc().toIso8601String()}) .update({'read_at': AppTime.nowUtc().toIso8601String()})
.eq('task_id', taskId) .eq('task_id', taskId)
.eq('user_id', userId) .eq('user_id', userId)
.filter('read_at', 'is', null); .filter('read_at', 'is', null);

View File

@ -9,6 +9,7 @@ import '../../providers/admin_user_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/user_offices_provider.dart'; import '../../providers/user_offices_provider.dart';
import '../../utils/app_time.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
@ -124,8 +125,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
final filteredProfiles = query.isEmpty final filteredProfiles = query.isEmpty
? profiles ? profiles
: profiles.where((profile) { : profiles.where((profile) {
final label = final label = profile.fullName.isNotEmpty
profile.fullName.isNotEmpty ? profile.fullName : profile.id; ? profile.fullName
: profile.id;
return label.toLowerCase().contains(query) || return label.toLowerCase().contains(query) ||
profile.id.toLowerCase().contains(query); profile.id.toLowerCase().contains(query);
}).toList(); }).toList();
@ -156,8 +158,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
TasQColumn<Profile>( TasQColumn<Profile>(
header: 'User', header: 'User',
cellBuilder: (context, profile) { cellBuilder: (context, profile) {
final label = final label = profile.fullName.isEmpty
profile.fullName.isEmpty ? profile.id : profile.fullName; ? profile.id
: profile.fullName;
return Text(label); return Text(label);
}, },
), ),
@ -166,8 +169,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
cellBuilder: (context, profile) { cellBuilder: (context, profile) {
final status = _statusCache[profile.id]; final status = _statusCache[profile.id];
final hasError = _statusErrors.contains(profile.id); final hasError = _statusErrors.contains(profile.id);
final email = final email = hasError
hasError ? 'Unavailable' : (status?.email ?? 'Unknown'); ? 'Unavailable'
: (status?.email ?? 'Unknown');
return Text(email); return Text(email);
}, },
), ),
@ -188,8 +192,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
final status = _statusCache[profile.id]; final status = _statusCache[profile.id];
final hasError = _statusErrors.contains(profile.id); final hasError = _statusErrors.contains(profile.id);
final isLoading = _statusLoading.contains(profile.id); final isLoading = _statusLoading.contains(profile.id);
final statusLabel = final statusLabel = _userStatusLabel(status, hasError, isLoading);
_userStatusLabel(status, hasError, isLoading);
return _StatusBadge(label: statusLabel); return _StatusBadge(label: statusLabel);
}, },
), ),
@ -204,8 +207,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
onRowTap: (profile) => onRowTap: (profile) =>
_showUserDialog(context, profile, offices, assignments), _showUserDialog(context, profile, offices, assignments),
mobileTileBuilder: (context, profile, actions) { mobileTileBuilder: (context, profile, actions) {
final label = final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
profile.fullName.isEmpty ? profile.id : profile.fullName;
final status = _statusCache[profile.id]; final status = _statusCache[profile.id];
final hasError = _statusErrors.contains(profile.id); final hasError = _statusErrors.contains(profile.id);
final isLoading = _statusLoading.contains(profile.id); final isLoading = _statusLoading.contains(profile.id);
@ -673,7 +675,7 @@ String _userStatusLabel(
String _formatLastActiveLabel(DateTime? value) { String _formatLastActiveLabel(DateTime? value) {
if (value == null) return 'N/A'; if (value == null) return 'N/A';
final now = DateTime.now(); final now = AppTime.now();
final diff = now.difference(value); final diff = now.difference(value);
if (diff.inMinutes < 1) return 'Just now'; if (diff.inMinutes < 1) return 'Just now';
if (diff.inHours < 1) return '${diff.inMinutes}m ago'; if (diff.inHours < 1) return '${diff.inMinutes}m ago';

View File

@ -12,6 +12,7 @@ import '../../providers/tickets_provider.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/status_pill.dart'; import '../../widgets/status_pill.dart';
import '../../utils/app_time.dart';
class DashboardMetrics { class DashboardMetrics {
DashboardMetrics({ DashboardMetrics({
@ -90,7 +91,7 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[]; final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[];
final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[]; final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[];
final now = DateTime.now(); final now = AppTime.now();
final startOfDay = DateTime(now.year, now.month, now.day); final startOfDay = DateTime(now.year, now.month, now.day);
final staffProfiles = profiles final staffProfiles = profiles

View File

@ -11,6 +11,7 @@ import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart'; import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/typing_provider.dart'; import '../../providers/typing_provider.dart';
import '../../utils/app_time.dart';
import '../../widgets/app_breakpoints.dart'; import '../../widgets/app_breakpoints.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
@ -396,13 +397,13 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
final animateExecution = task.startedAt != null && task.completedAt == null; final animateExecution = task.startedAt != null && task.completedAt == null;
if (!animateQueue && !animateExecution) { if (!animateQueue && !animateExecution) {
return _buildTatContent(task, DateTime.now()); return _buildTatContent(task, AppTime.now());
} }
return StreamBuilder<int>( return StreamBuilder<int>(
stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick), stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick),
builder: (context, snapshot) { builder: (context, snapshot) {
return _buildTatContent(task, DateTime.now()); return _buildTatContent(task, AppTime.now());
}, },
); );
} }

View File

@ -12,6 +12,7 @@ import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart'; import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/typing_provider.dart'; import '../../providers/typing_provider.dart';
import '../../utils/app_time.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
@ -171,8 +172,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
final next = await showDateRangePicker( final next = await showDateRangePicker(
context: context, context: context,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)), lastDate: AppTime.now().add(const Duration(days: 365)),
currentDate: DateTime.now(), currentDate: AppTime.now(),
initialDateRange: _selectedDateRange, initialDateRange: _selectedDateRange,
); );
if (!mounted) return; if (!mounted) return;

View File

@ -10,6 +10,7 @@ import '../../providers/notifications_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/typing_provider.dart'; import '../../providers/typing_provider.dart';
import '../../utils/app_time.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
@ -135,8 +136,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
final next = await showDateRangePicker( final next = await showDateRangePicker(
context: context, context: context,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)), lastDate: AppTime.now().add(const Duration(days: 365)),
currentDate: DateTime.now(), currentDate: AppTime.now(),
initialDateRange: _selectedDateRange, initialDateRange: _selectedDateRange,
); );
if (!mounted) return; if (!mounted) return;

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@ -7,6 +9,7 @@ import '../../models/profile.dart';
import '../../models/swap_request.dart'; import '../../models/swap_request.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../providers/workforce_provider.dart'; import '../../providers/workforce_provider.dart';
import '../../utils/app_time.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
class WorkforceScreen extends ConsumerWidget { class WorkforceScreen extends ConsumerWidget {
@ -137,6 +140,10 @@ class _SchedulePanel extends ConsumerWidget {
schedule, schedule,
isAdmin, isAdmin,
), ),
relieverLabels: _relieverLabelsFromIds(
schedule.relieverIds,
profileById,
),
isMine: schedule.userId == currentUserId, isMine: schedule.userId == currentUserId,
), ),
), ),
@ -217,30 +224,46 @@ class _SchedulePanel extends ConsumerWidget {
final weekday = weekdays[value.weekday - 1]; final weekday = weekdays[value.weekday - 1];
return '$weekday, $month $day, ${value.year}'; return '$weekday, $month $day, ${value.year}';
} }
List<String> _relieverLabelsFromIds(
List<String> relieverIds,
Map<String, Profile> profileById,
) {
if (relieverIds.isEmpty) return const [];
return relieverIds
.map(
(id) => profileById[id]?.fullName.isNotEmpty == true
? profileById[id]!.fullName
: id,
)
.toList();
}
} }
class _ScheduleTile extends ConsumerWidget { class _ScheduleTile extends ConsumerWidget {
const _ScheduleTile({ const _ScheduleTile({
required this.schedule, required this.schedule,
required this.displayName, required this.displayName,
required this.relieverLabels,
required this.isMine, required this.isMine,
}); });
final DutySchedule schedule; final DutySchedule schedule;
final String displayName; final String displayName;
final List<String> relieverLabels;
final bool isMine; final bool isMine;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentUserId = ref.watch(currentUserIdProvider); final currentUserId = ref.watch(currentUserIdProvider);
final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? []; final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? [];
final now = DateTime.now(); final now = AppTime.now();
final isPast = schedule.startTime.isBefore(now); final isPast = schedule.startTime.isBefore(now);
final canCheckIn = final canCheckIn =
isMine && isMine &&
schedule.checkInAt == null && schedule.checkInAt == null &&
(schedule.status == 'scheduled' || schedule.status == 'late') && (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); now.isBefore(schedule.endTime);
final hasRequestedSwap = swaps.any( final hasRequestedSwap = swaps.any(
(swap) => (swap) =>
@ -253,7 +276,9 @@ class _ScheduleTile extends ConsumerWidget {
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Column(
children: [
Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
@ -305,6 +330,27 @@ class _ScheduleTile extends ConsumerWidget {
), ),
], ],
), ),
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); final geofence = await ref.read(geofenceProvider.future);
if (geofence == null) { if (geofence == null) {
if (!context.mounted) return; if (!context.mounted) return;
_showMessage(context, 'Geofence is not configured.'); await _showAlert(
context,
title: 'Geofence missing',
message: 'Geofence is not configured.',
);
return; return;
} }
final serviceEnabled = await Geolocator.isLocationServiceEnabled(); final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) { if (!serviceEnabled) {
if (!context.mounted) return; if (!context.mounted) return;
_showMessage(context, 'Location services are disabled.'); await _showAlert(
context,
title: 'Location disabled',
message: 'Location services are disabled.',
);
return; return;
} }
@ -335,28 +389,43 @@ class _ScheduleTile extends ConsumerWidget {
if (permission == LocationPermission.denied || if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) { permission == LocationPermission.deniedForever) {
if (!context.mounted) return; if (!context.mounted) return;
_showMessage(context, 'Location permission denied.'); await _showAlert(
context,
title: 'Permission denied',
message: 'Location permission denied.',
);
return; return;
} }
if (!context.mounted) return;
final progressContext = await _showCheckInProgress(context);
try {
final position = await Geolocator.getCurrentPosition( final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high), locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
); );
final distance = Geolocator.distanceBetween( final isInside = geofence.hasPolygon
? geofence.containsPolygon(position.latitude, position.longitude)
: geofence.hasCircle &&
Geolocator.distanceBetween(
position.latitude, position.latitude,
position.longitude, position.longitude,
geofence.lat, geofence.lat!,
geofence.lng, geofence.lng!,
); ) <=
geofence.radiusMeters!;
if (distance > geofence.radiusMeters) { if (!isInside) {
if (!context.mounted) return; if (!context.mounted) return;
_showMessage(context, 'You are outside the geofence. Wala ka sa CRMC.'); await _showAlert(
context,
title: 'Outside geofence',
message: 'You are outside the geofence. Wala ka sa CRMC.',
);
return; return;
} }
try {
final status = await ref final status = await ref
.read(workforceControllerProvider) .read(workforceControllerProvider)
.checkIn( .checkIn(
@ -366,10 +435,22 @@ class _ScheduleTile extends ConsumerWidget {
); );
ref.invalidate(dutySchedulesProvider); ref.invalidate(dutySchedulesProvider);
if (!context.mounted) return; if (!context.mounted) return;
_showMessage(context, 'Checked in ($status).'); await _showAlert(
context,
title: 'Checked in',
message: 'Checked in ($status).',
);
} catch (error) { } catch (error) {
if (!context.mounted) return; 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))); ).showSnackBar(SnackBar(content: Text(message)));
} }
Future<void> _showAlert(
BuildContext context, {
required String title,
required String message,
}) async {
await showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('OK'),
),
],
);
},
);
}
Future<BuildContext> _showCheckInProgress(BuildContext context) {
final completer = Completer<BuildContext>();
showDialog<void>(
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) { void _openSwapsTab(BuildContext context) {
final controller = DefaultTabController.maybeOf(context); final controller = DefaultTabController.maybeOf(context);
if (controller != null) { if (controller != null) {
@ -500,13 +636,15 @@ class _DraftSchedule {
required this.shiftType, required this.shiftType,
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
}); List<String>? relieverIds,
}) : relieverIds = relieverIds ?? <String>[];
final int localId; final int localId;
String userId; String userId;
String shiftType; String shiftType;
DateTime startTime; DateTime startTime;
DateTime endTime; DateTime endTime;
List<String> relieverIds;
} }
class _RotationEntry { class _RotationEntry {
@ -669,7 +807,7 @@ class _ScheduleGeneratorPanelState
} }
Future<void> _pickDate({required bool isStart}) async { Future<void> _pickDate({required bool isStart}) async {
final now = DateTime.now(); final now = AppTime.now();
final initial = isStart ? _startDate ?? now : _endDate ?? _startDate ?? now; final initial = isStart ? _startDate ?? now : _endDate ?? _startDate ?? now;
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
@ -807,14 +945,24 @@ class _ScheduleGeneratorPanelState
separatorBuilder: (context, index) => const SizedBox(height: 8), separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final draft = _draftSchedules[index]; final draft = _draftSchedules[index];
final profile = _profileById()[draft.userId]; final profileById = _profileById();
final profile = profileById[draft.userId];
final userLabel = profile?.fullName.isNotEmpty == true final userLabel = profile?.fullName.isNotEmpty == true
? profile!.fullName ? profile!.fullName
: draft.userId; : draft.userId;
final relieverLabels = draft.relieverIds
.map(
(id) => profileById[id]?.fullName.isNotEmpty == true
? profileById[id]!.fullName
: id,
)
.toList();
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row( child: Column(
children: [
Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
@ -822,9 +970,8 @@ class _ScheduleGeneratorPanelState
children: [ children: [
Text( Text(
'${_shiftLabel(draft.shiftType)} · $userLabel', '${_shiftLabel(draft.shiftType)} · $userLabel',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
fontWeight: FontWeight.w600, ?.copyWith(fontWeight: FontWeight.w600),
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
@ -846,6 +993,27 @@ class _ScheduleGeneratorPanelState
), ),
], ],
), ),
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),
),
),
],
),
],
),
), ),
); );
}, },
@ -864,8 +1032,8 @@ class _ScheduleGeneratorPanelState
setState(() { setState(() {
_draftSchedules.removeWhere((item) => item.localId == draft.localId); _draftSchedules.removeWhere((item) => item.localId == draft.localId);
_warnings = _buildWarnings( _warnings = _buildWarnings(
_startDate ?? DateTime.now(), _startDate ?? AppTime.now(),
_endDate ?? DateTime.now(), _endDate ?? AppTime.now(),
_draftSchedules, _draftSchedules,
); );
}); });
@ -878,7 +1046,7 @@ class _ScheduleGeneratorPanelState
return; 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 selectedDate = DateTime(start.year, start.month, start.day);
var selectedUserId = existing?.userId ?? staff.first.id; var selectedUserId = existing?.userId ?? staff.first.id;
var selectedShift = existing?.shiftType ?? 'am'; var selectedShift = existing?.shiftType ?? 'am';
@ -1099,6 +1267,7 @@ class _ScheduleGeneratorPanelState
'start_time': draft.startTime.toIso8601String(), 'start_time': draft.startTime.toIso8601String(),
'end_time': draft.endTime.toIso8601String(), 'end_time': draft.endTime.toIso8601String(),
'status': 'scheduled', 'status': 'scheduled',
'reliever_ids': draft.relieverIds,
}, },
) )
.toList(); .toList();
@ -1249,6 +1418,10 @@ class _ScheduleGeneratorPanelState
final nextWeekPmUserId = staff.isEmpty final nextWeekPmUserId = staff.isEmpty
? null ? null
: staff[(pmBaseIndex + 1) % staff.length].id; : staff[(pmBaseIndex + 1) % staff.length].id;
final pmRelievers = _buildRelievers(pmBaseIndex, staff);
final nextWeekRelievers = staff.isEmpty
? <String>[]
: _buildRelievers((pmBaseIndex + 1) % staff.length, staff);
var weekendNormalOffset = 0; var weekendNormalOffset = 0;
for ( for (
@ -1273,6 +1446,7 @@ class _ScheduleGeneratorPanelState
'normal', 'normal',
staff[normalIndex].id, staff[normalIndex].id,
day, day,
const [],
); );
weekendNormalOffset += 1; weekendNormalOffset += 1;
} }
@ -1284,15 +1458,40 @@ class _ScheduleGeneratorPanelState
'on_call', 'on_call',
nextWeekPmUserId, nextWeekPmUserId,
day, day,
nextWeekRelievers,
); );
} }
} else { } else {
if (amUserId != null) { if (amUserId != null) {
_tryAddDraft(draft, existing, templates, 'am', amUserId, day); _tryAddDraft(
draft,
existing,
templates,
'am',
amUserId,
day,
const [],
);
} }
if (pmUserId != null) { if (pmUserId != null) {
_tryAddDraft(draft, existing, templates, 'pm', pmUserId, day); _tryAddDraft(
_tryAddDraft(draft, existing, templates, 'on_call', pmUserId, day); draft,
existing,
templates,
'pm',
pmUserId,
day,
pmRelievers,
);
_tryAddDraft(
draft,
existing,
templates,
'on_call',
pmUserId,
day,
pmRelievers,
);
} }
final assignedToday = <String?>[ final assignedToday = <String?>[
@ -1301,7 +1500,15 @@ class _ScheduleGeneratorPanelState
].whereType<String>().toSet(); ].whereType<String>().toSet();
for (final profile in staff) { for (final profile in staff) {
if (assignedToday.contains(profile.id)) continue; 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 shiftType,
String userId, String userId,
DateTime day, DateTime day,
List<String> relieverIds,
) { ) {
final template = templates[_normalizeShiftType(shiftType)]!; final template = templates[_normalizeShiftType(shiftType)]!;
final start = template.buildStart(day); final start = template.buildStart(day);
@ -1329,6 +1537,7 @@ class _ScheduleGeneratorPanelState
shiftType: shiftType, shiftType: shiftType,
startTime: start, startTime: start,
endTime: end, endTime: end,
relieverIds: relieverIds,
); );
if (_hasConflict(candidate, draft, existing)) { if (_hasConflict(candidate, draft, existing)) {
@ -1364,6 +1573,16 @@ class _ScheduleGeneratorPanelState
return defaultIndex % staff.length; return defaultIndex % staff.length;
} }
List<String> _buildRelievers(int primaryIndex, List<Profile> staff) {
if (staff.length <= 1) return const [];
final relievers = <String>[];
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( bool _hasConflict(
_DraftSchedule candidate, _DraftSchedule candidate,
List<_DraftSchedule> drafts, List<_DraftSchedule> drafts,
@ -1545,11 +1764,11 @@ class _SwapRequestsPanel extends ConsumerWidget {
final profilesAsync = ref.watch(profilesProvider); final profilesAsync = ref.watch(profilesProvider);
final currentUserId = ref.watch(currentUserIdProvider); final currentUserId = ref.watch(currentUserIdProvider);
final scheduleById = { final Map<String, DutySchedule> scheduleById = {
for (final schedule in schedulesAsync.valueOrNull ?? []) for (final schedule in schedulesAsync.valueOrNull ?? [])
schedule.id: schedule, schedule.id: schedule,
}; };
final profileById = { final Map<String, Profile> profileById = {
for (final profile in profilesAsync.valueOrNull ?? []) for (final profile in profilesAsync.valueOrNull ?? [])
profile.id: profile, profile.id: profile,
}; };
@ -1577,6 +1796,9 @@ class _SwapRequestsPanel extends ConsumerWidget {
final subtitle = schedule == null final subtitle = schedule == null
? 'Shift not found' ? 'Shift not found'
: '${_shiftLabel(schedule.shiftType)} · ${_formatDate(schedule.startTime)} · ${_formatTime(schedule.startTime)}'; : '${_shiftLabel(schedule.shiftType)} · ${_formatDate(schedule.startTime)} · ${_formatTime(schedule.startTime)}';
final relieverLabels = schedule == null
? const <String>[]
: _relieverLabelsFromIds(schedule.relieverIds, profileById);
final isPending = item.status == 'pending'; final isPending = item.status == 'pending';
final canRespond = final canRespond =
@ -1600,6 +1822,25 @@ class _SwapRequestsPanel extends ConsumerWidget {
const SizedBox(height: 6), const SizedBox(height: 6),
Text('Status: ${item.status}'), Text('Status: ${item.status}'),
const SizedBox(height: 12), 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( Row(
children: [ children: [
if (canRespond) ...[ if (canRespond) ...[
@ -1703,4 +1944,18 @@ class _SwapRequestsPanel extends ConsumerWidget {
final weekday = weekdays[value.weekday - 1]; final weekday = weekdays[value.weekday - 1];
return '$weekday, $month $day, ${value.year}'; return '$weekday, $month $day, ${value.year}';
} }
List<String> _relieverLabelsFromIds(
List<String> relieverIds,
Map<String, Profile> profileById,
) {
if (relieverIds.isEmpty) return const [];
return relieverIds
.map(
(id) => profileById[id]?.fullName.isNotEmpty == true
? profileById[id]!.fullName
: id,
)
.toList();
}
} }

30
lib/utils/app_time.dart Normal file
View File

@ -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));
}
}

View File

@ -821,6 +821,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" 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: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@ -17,6 +17,7 @@ dependencies:
google_fonts: ^6.2.1 google_fonts: ^6.2.1
audioplayers: ^6.1.0 audioplayers: ^6.1.0
geolocator: ^13.0.1 geolocator: ^13.0.1
timezone: ^0.9.4
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: