Implement Asia/Manila Time Zone

Handled saving of Relievers
This commit is contained in:
2026-02-11 20:12:48 +08:00
parent 747edbdd8c
commit 678a73a696
20 changed files with 551 additions and 168 deletions
+362 -107
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,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<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,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
? <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();
}
}