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 'providers/notifications_provider.dart';
import 'utils/app_time.dart';
Future<void> 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'] ?? '';

View File

@ -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<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 lng;
final double radiusMeters;
factory GeofenceConfig.fromJson(Map<String, dynamic> json) {
return GeofenceConfig(
factory GeofencePoint.fromJson(Map<String, dynamic> json) {
return GeofencePoint(
lat: (json['lat'] 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 {
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<String> relieverIds;
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(
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,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AdminUserController>((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<String, dynamic>)['user'] as Map<String, dynamic>;
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),
);
}

View File

@ -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<List<NotificationItem>>((ref) {
final userId = ref.watch(currentUserIdProvider);
@ -69,7 +70,7 @@ class NotificationsController {
Future<void> 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);

View File

@ -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';
@ -124,8 +125,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
final filteredProfiles = query.isEmpty
? profiles
: profiles.where((profile) {
final label =
profile.fullName.isNotEmpty ? profile.fullName : profile.id;
final label = profile.fullName.isNotEmpty
? profile.fullName
: profile.id;
return label.toLowerCase().contains(query) ||
profile.id.toLowerCase().contains(query);
}).toList();
@ -156,8 +158,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
TasQColumn<Profile>(
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<UserManagementScreen> {
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<UserManagementScreen> {
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<UserManagementScreen> {
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';

View File

@ -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<AsyncValue<DashboardMetrics>>((ref) {
final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[];
final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[];
final now = DateTime.now();
final now = AppTime.now();
final startOfDay = DateTime(now.year, now.month, now.day);
final staffProfiles = profiles

View File

@ -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<TaskDetailScreen> {
final animateExecution = task.startedAt != null && task.completedAt == null;
if (!animateQueue && !animateExecution) {
return _buildTatContent(task, DateTime.now());
return _buildTatContent(task, AppTime.now());
}
return StreamBuilder<int>(
stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick),
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/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<TasksListScreen> {
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;

View File

@ -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<TicketsListScreen> {
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;

View File

@ -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<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 {
const _ScheduleTile({
required this.schedule,
required this.displayName,
required this.relieverLabels,
required this.isMine,
});
final DutySchedule schedule;
final String displayName;
final List<String> 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,7 +276,9 @@ class _ScheduleTile extends ConsumerWidget {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
child: Column(
children: [
Row(
children: [
Expanded(
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);
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;
}
if (!context.mounted) return;
final progressContext = await _showCheckInProgress(context);
try {
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.longitude,
geofence.lat,
geofence.lng,
);
geofence.lat!,
geofence.lng!,
) <=
geofence.radiusMeters!;
if (distance > geofence.radiusMeters) {
if (!isInside) {
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;
}
try {
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<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) {
final controller = DefaultTabController.maybeOf(context);
if (controller != null) {
@ -500,13 +636,15 @@ class _DraftSchedule {
required this.shiftType,
required this.startTime,
required this.endTime,
});
List<String>? relieverIds,
}) : relieverIds = relieverIds ?? <String>[];
final int localId;
String userId;
String shiftType;
DateTime startTime;
DateTime endTime;
List<String> relieverIds;
}
class _RotationEntry {
@ -669,7 +807,7 @@ class _ScheduleGeneratorPanelState
}
Future<void> _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,14 +945,24 @@ 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: [
Row(
children: [
Expanded(
child: Column(
@ -822,9 +970,8 @@ class _ScheduleGeneratorPanelState
children: [
Text(
'${_shiftLabel(draft.shiftType)} · $userLabel',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
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(() {
_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
? <String>[]
: _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 = <String?>[
@ -1301,7 +1500,15 @@ class _ScheduleGeneratorPanelState
].whereType<String>().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<String> 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<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(
_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<String, DutySchedule> scheduleById = {
for (final schedule in schedulesAsync.valueOrNull ?? [])
schedule.id: schedule,
};
final profileById = {
final Map<String, Profile> 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 <String>[]
: _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<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"
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:

View File

@ -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: