Implement Asia/Manila Time Zone
Handled saving of Relievers
This commit is contained in:
parent
747edbdd8c
commit
678a73a696
|
|
@ -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'] ?? '';
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
30
lib/utils/app_time.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user