diff --git a/lib/main.dart b/lib/main.dart index 46448b84..73928678 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'utils/app_time.dart'; import 'utils/notification_permission.dart'; import 'services/notification_service.dart'; import 'services/notification_bridge.dart'; +import 'services/background_location_service.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; @@ -255,6 +256,11 @@ Future main() async { await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey); + // Initialize background location service (Workmanager) + if (!kIsWeb) { + await initBackgroundLocationService(); + } + // ensure token saved shortly after startup if already signed in. // Run this after runApp so startup is not blocked by network/token ops. final supaClient = Supabase.instance.client; diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index a45d2000..ccbee355 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -81,3 +81,22 @@ class AppSetting { ); } } + +class RamadanConfig { + RamadanConfig({required this.enabled, required this.autoDetect}); + + final bool enabled; + final bool autoDetect; + + factory RamadanConfig.fromJson(Map json) { + return RamadanConfig( + enabled: json['enabled'] as bool? ?? false, + autoDetect: json['auto_detect'] as bool? ?? true, + ); + } + + Map toJson() => { + 'enabled': enabled, + 'auto_detect': autoDetect, + }; +} diff --git a/lib/models/attendance_log.dart b/lib/models/attendance_log.dart new file mode 100644 index 00000000..b6d937c4 --- /dev/null +++ b/lib/models/attendance_log.dart @@ -0,0 +1,43 @@ +import '../utils/app_time.dart'; + +class AttendanceLog { + AttendanceLog({ + required this.id, + required this.userId, + required this.dutyScheduleId, + required this.checkInAt, + required this.checkInLat, + required this.checkInLng, + this.checkOutAt, + this.checkOutLat, + this.checkOutLng, + }); + + final String id; + final String userId; + final String dutyScheduleId; + final DateTime checkInAt; + final double checkInLat; + final double checkInLng; + final DateTime? checkOutAt; + final double? checkOutLat; + final double? checkOutLng; + + bool get isCheckedOut => checkOutAt != null; + + factory AttendanceLog.fromMap(Map map) { + return AttendanceLog( + id: map['id'] as String, + userId: map['user_id'] as String, + dutyScheduleId: map['duty_schedule_id'] as String, + checkInAt: AppTime.parse(map['check_in_at'] as String), + checkInLat: (map['check_in_lat'] as num).toDouble(), + checkInLng: (map['check_in_lng'] as num).toDouble(), + checkOutAt: map['check_out_at'] == null + ? null + : AppTime.parse(map['check_out_at'] as String), + checkOutLat: (map['check_out_lat'] as num?)?.toDouble(), + checkOutLng: (map['check_out_lng'] as num?)?.toDouble(), + ); + } +} diff --git a/lib/models/chat_message.dart b/lib/models/chat_message.dart new file mode 100644 index 00000000..fe932e41 --- /dev/null +++ b/lib/models/chat_message.dart @@ -0,0 +1,33 @@ +import '../utils/app_time.dart'; + +class ChatMessage { + ChatMessage({ + required this.id, + required this.threadId, + required this.senderId, + required this.body, + required this.createdAt, + }); + + final String id; + final String threadId; + final String senderId; + final String body; + final DateTime createdAt; + + factory ChatMessage.fromMap(Map map) { + return ChatMessage( + id: map['id'] as String, + threadId: map['thread_id'] as String, + senderId: map['sender_id'] as String, + body: map['body'] as String, + createdAt: AppTime.parse(map['created_at'] as String), + ); + } + + Map toJson() => { + 'thread_id': threadId, + 'sender_id': senderId, + 'body': body, + }; +} diff --git a/lib/models/live_position.dart b/lib/models/live_position.dart new file mode 100644 index 00000000..d25501e7 --- /dev/null +++ b/lib/models/live_position.dart @@ -0,0 +1,27 @@ +import '../utils/app_time.dart'; + +class LivePosition { + LivePosition({ + required this.userId, + required this.lat, + required this.lng, + required this.updatedAt, + required this.inPremise, + }); + + final String userId; + final double lat; + final double lng; + final DateTime updatedAt; + final bool inPremise; + + factory LivePosition.fromMap(Map map) { + return LivePosition( + userId: map['user_id'] as String, + lat: (map['lat'] as num).toDouble(), + lng: (map['lng'] as num).toDouble(), + updatedAt: AppTime.parse(map['updated_at'] as String), + inPremise: map['in_premise'] as bool? ?? false, + ); + } +} diff --git a/lib/models/pass_slip.dart b/lib/models/pass_slip.dart new file mode 100644 index 00000000..98abfffb --- /dev/null +++ b/lib/models/pass_slip.dart @@ -0,0 +1,57 @@ +import '../utils/app_time.dart'; + +class PassSlip { + PassSlip({ + required this.id, + required this.userId, + required this.dutyScheduleId, + required this.reason, + required this.status, + required this.requestedAt, + this.approvedBy, + this.approvedAt, + this.slipStart, + this.slipEnd, + }); + + final String id; + final String userId; + final String dutyScheduleId; + final String reason; + final String status; // 'pending', 'approved', 'rejected', 'completed' + final DateTime requestedAt; + final String? approvedBy; + final DateTime? approvedAt; + final DateTime? slipStart; + final DateTime? slipEnd; + + /// Whether the slip is active (approved but not yet completed). + bool get isActive => status == 'approved' && slipEnd == null; + + /// Whether the active slip has exceeded 1 hour. + bool get isExceeded => + isActive && + slipStart != null && + AppTime.now().difference(slipStart!) > const Duration(hours: 1); + + factory PassSlip.fromMap(Map map) { + return PassSlip( + id: map['id'] as String, + userId: map['user_id'] as String, + dutyScheduleId: map['duty_schedule_id'] as String, + reason: map['reason'] as String, + status: map['status'] as String? ?? 'pending', + requestedAt: AppTime.parse(map['requested_at'] as String), + approvedBy: map['approved_by'] as String?, + approvedAt: map['approved_at'] == null + ? null + : AppTime.parse(map['approved_at'] as String), + slipStart: map['slip_start'] == null + ? null + : AppTime.parse(map['slip_start'] as String), + slipEnd: map['slip_end'] == null + ? null + : AppTime.parse(map['slip_end'] as String), + ); + } +} diff --git a/lib/models/profile.dart b/lib/models/profile.dart index 7af0418a..b9ca0d93 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -1,15 +1,25 @@ class Profile { - Profile({required this.id, required this.role, required this.fullName}); + Profile({ + required this.id, + required this.role, + required this.fullName, + this.religion = 'catholic', + this.allowTracking = false, + }); final String id; final String role; final String fullName; + final String religion; + final bool allowTracking; factory Profile.fromMap(Map map) { return Profile( id: map['id'] as String, role: map['role'] as String? ?? 'standard', fullName: map['full_name'] as String? ?? '', + religion: map['religion'] as String? ?? 'catholic', + allowTracking: map['allow_tracking'] as bool? ?? false, ); } } diff --git a/lib/providers/admin_user_provider.dart b/lib/providers/admin_user_provider.dart index 8bf7c3d1..312528de 100644 --- a/lib/providers/admin_user_provider.dart +++ b/lib/providers/admin_user_provider.dart @@ -61,10 +61,11 @@ class AdminUserController { required String userId, required String fullName, required String role, + String religion = 'catholic', }) async { await _client .from('profiles') - .update({'full_name': fullName, 'role': role}) + .update({'full_name': fullName, 'role': role, 'religion': religion}) .eq('id', userId); } diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart new file mode 100644 index 00000000..4f87863e --- /dev/null +++ b/lib/providers/attendance_provider.dart @@ -0,0 +1,98 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/attendance_log.dart'; +import '../utils/app_time.dart'; +import 'profile_provider.dart'; +import 'reports_provider.dart'; +import 'supabase_provider.dart'; +import 'stream_recovery.dart'; +import 'realtime_controller.dart'; + +/// Date range for attendance logbook, defaults to "Last 7 Days". +final attendanceDateRangeProvider = StateProvider((ref) { + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + return ReportDateRange( + start: today.subtract(const Duration(days: 7)), + end: today.add(const Duration(days: 1)), + label: 'Last 7 Days', + ); +}); + +/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff). +final attendanceLogsProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + final profileAsync = ref.watch(currentProfileProvider); + final profile = profileAsync.valueOrNull; + if (profile == null) return Stream.value(const []); + + final hasFullAccess = + profile.role == 'admin' || + profile.role == 'dispatcher' || + profile.role == 'it_staff'; + + final wrapper = StreamRecoveryWrapper( + stream: hasFullAccess + ? client + .from('attendance_logs') + .stream(primaryKey: ['id']) + .order('check_in_at', ascending: false) + : client + .from('attendance_logs') + .stream(primaryKey: ['id']) + .eq('user_id', profile.id) + .order('check_in_at', ascending: false), + onPollData: () async { + final query = client.from('attendance_logs').select(); + final data = hasFullAccess + ? await query.order('check_in_at', ascending: false) + : await query + .eq('user_id', profile.id) + .order('check_in_at', ascending: false); + return data.map(AttendanceLog.fromMap).toList(); + }, + fromMap: AttendanceLog.fromMap, + channelName: 'attendance_logs', + onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) => result.data); +}); + +final attendanceControllerProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return AttendanceController(client); +}); + +class AttendanceController { + AttendanceController(this._client); + + final SupabaseClient _client; + + /// Check in to a duty schedule. Returns the attendance log ID. + Future checkIn({ + required String dutyScheduleId, + required double lat, + required double lng, + }) async { + final data = await _client.rpc( + 'attendance_check_in', + params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng}, + ); + return data as String?; + } + + /// Check out from an attendance log. + Future checkOut({ + required String attendanceId, + required double lat, + required double lng, + }) async { + await _client.rpc( + 'attendance_check_out', + params: {'p_attendance_id': attendanceId, 'p_lat': lat, 'p_lng': lng}, + ); + } +} diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart new file mode 100644 index 00000000..8b146300 --- /dev/null +++ b/lib/providers/chat_provider.dart @@ -0,0 +1,61 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/chat_message.dart'; +import 'supabase_provider.dart'; +import 'stream_recovery.dart'; +import 'realtime_controller.dart'; + +/// Real-time chat messages for a swap request thread. +final chatMessagesProvider = StreamProvider.family, String>(( + ref, + threadId, +) { + final client = ref.watch(supabaseClientProvider); + + final wrapper = StreamRecoveryWrapper( + stream: client + .from('chat_messages') + .stream(primaryKey: ['id']) + .eq('thread_id', threadId) + .order('created_at'), + onPollData: () async { + final data = await client + .from('chat_messages') + .select() + .eq('thread_id', threadId) + .order('created_at'); + return data.map(ChatMessage.fromMap).toList(); + }, + fromMap: ChatMessage.fromMap, + channelName: 'chat_messages_$threadId', + onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) => result.data); +}); + +final chatControllerProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return ChatController(client); +}); + +class ChatController { + ChatController(this._client); + + final SupabaseClient _client; + + Future sendMessage({ + required String threadId, + required String body, + }) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + await _client.from('chat_messages').insert({ + 'thread_id': threadId, + 'sender_id': userId, + 'body': body, + }); + } +} diff --git a/lib/providers/pass_slip_provider.dart b/lib/providers/pass_slip_provider.dart new file mode 100644 index 00000000..bddbd8c1 --- /dev/null +++ b/lib/providers/pass_slip_provider.dart @@ -0,0 +1,123 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/pass_slip.dart'; +import 'profile_provider.dart'; +import 'supabase_provider.dart'; +import 'stream_recovery.dart'; +import 'realtime_controller.dart'; + +/// All visible pass slips (own for staff, all for admin/dispatcher). +final passSlipsProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + final profileAsync = ref.watch(currentProfileProvider); + final profile = profileAsync.valueOrNull; + if (profile == null) return Stream.value(const []); + + final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher'; + + final wrapper = StreamRecoveryWrapper( + stream: isAdmin + ? client + .from('pass_slips') + .stream(primaryKey: ['id']) + .order('requested_at', ascending: false) + : client + .from('pass_slips') + .stream(primaryKey: ['id']) + .eq('user_id', profile.id) + .order('requested_at', ascending: false), + onPollData: () async { + final query = client.from('pass_slips').select(); + final data = isAdmin + ? await query.order('requested_at', ascending: false) + : await query + .eq('user_id', profile.id) + .order('requested_at', ascending: false); + return data.map(PassSlip.fromMap).toList(); + }, + fromMap: PassSlip.fromMap, + channelName: 'pass_slips', + onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) => result.data); +}); + +/// Currently active pass slip for the logged-in user (approved, not completed). +final activePassSlipProvider = Provider((ref) { + final slips = ref.watch(passSlipsProvider).valueOrNull ?? []; + final userId = ref.watch(currentUserIdProvider); + if (userId == null) return null; + try { + return slips.firstWhere((s) => s.userId == userId && s.isActive); + } catch (_) { + return null; + } +}); + +/// Active pass slips for all users (for dashboard IT Staff Pulse). +final activePassSlipsProvider = Provider>((ref) { + final slips = ref.watch(passSlipsProvider).valueOrNull ?? []; + return slips.where((s) => s.isActive).toList(); +}); + +final passSlipControllerProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return PassSlipController(client); +}); + +class PassSlipController { + PassSlipController(this._client); + + final SupabaseClient _client; + + Future requestSlip({ + required String dutyScheduleId, + required String reason, + }) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + await _client.from('pass_slips').insert({ + 'user_id': userId, + 'duty_schedule_id': dutyScheduleId, + 'reason': reason, + }); + } + + Future approveSlip(String slipId) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + await _client + .from('pass_slips') + .update({ + 'status': 'approved', + 'approved_by': userId, + 'approved_at': DateTime.now().toUtc().toIso8601String(), + 'slip_start': DateTime.now().toUtc().toIso8601String(), + }) + .eq('id', slipId); + } + + Future rejectSlip(String slipId) async { + await _client + .from('pass_slips') + .update({ + 'status': 'rejected', + 'approved_by': _client.auth.currentUser?.id, + 'approved_at': DateTime.now().toUtc().toIso8601String(), + }) + .eq('id', slipId); + } + + Future completeSlip(String slipId) async { + await _client + .from('pass_slips') + .update({ + 'status': 'completed', + 'slip_end': DateTime.now().toUtc().toIso8601String(), + }) + .eq('id', slipId); + } +} diff --git a/lib/providers/ramadan_provider.dart b/lib/providers/ramadan_provider.dart new file mode 100644 index 00000000..08425b01 --- /dev/null +++ b/lib/providers/ramadan_provider.dart @@ -0,0 +1,106 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/app_settings.dart'; +import 'supabase_provider.dart'; + +/// Fetches the Ramadan configuration from app_settings. +final ramadanConfigProvider = FutureProvider((ref) async { + final client = ref.watch(supabaseClientProvider); + final data = await client + .from('app_settings') + .select() + .eq('key', 'ramadan_mode') + .maybeSingle(); + if (data == null) return RamadanConfig(enabled: false, autoDetect: true); + final setting = AppSetting.fromMap(data); + return RamadanConfig.fromJson(setting.value); +}); + +/// Whether Ramadan mode is currently active. +/// Combines manual toggle with auto-detection via Hijri calendar approximation. +final isRamadanActiveProvider = Provider((ref) { + final config = ref.watch(ramadanConfigProvider).valueOrNull; + if (config == null) return false; + + // Manual override takes priority + if (config.enabled) return true; + + // Auto-detect based on approximate Hijri calendar + if (config.autoDetect) { + return isApproximateRamadan(DateTime.now()); + } + + return false; +}); + +/// Approximate Ramadan detection using a simplified Hijri calendar calculation. +/// Ramadan moves ~11 days earlier each Gregorian year. +/// This provides a reasonable approximation; for exact dates, a Hijri calendar +/// package could be used. +bool isApproximateRamadan(DateTime now) { + // Known Ramadan start dates (approximate): + // 2025: Feb 28 - Mar 30 + // 2026: Feb 17 - Mar 19 + // 2027: Feb 7 - Mar 8 + // 2028: Jan 27 - Feb 25 + final ramadanWindows = { + 2025: (start: DateTime(2025, 2, 28), end: DateTime(2025, 3, 30)), + 2026: (start: DateTime(2026, 2, 17), end: DateTime(2026, 3, 19)), + 2027: (start: DateTime(2027, 2, 7), end: DateTime(2027, 3, 8)), + 2028: (start: DateTime(2028, 1, 27), end: DateTime(2028, 2, 25)), + }; + + final window = ramadanWindows[now.year]; + if (window != null) { + return !now.isBefore(window.start) && !now.isAfter(window.end); + } + return false; +} + +final ramadanControllerProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return RamadanController(client); +}); + +class RamadanController { + RamadanController(this._client); + + final SupabaseClient _client; + + Future setEnabled(bool enabled) async { + final current = await _client + .from('app_settings') + .select() + .eq('key', 'ramadan_mode') + .maybeSingle(); + + final value = current != null + ? Map.from(current['value'] as Map) + : {'auto_detect': true}; + value['enabled'] = enabled; + + await _client.from('app_settings').upsert({ + 'key': 'ramadan_mode', + 'value': value, + }); + } + + Future setAutoDetect(bool autoDetect) async { + final current = await _client + .from('app_settings') + .select() + .eq('key', 'ramadan_mode') + .maybeSingle(); + + final value = current != null + ? Map.from(current['value'] as Map) + : {'enabled': false}; + value['auto_detect'] = autoDetect; + + await _client.from('app_settings').upsert({ + 'key': 'ramadan_mode', + 'value': value, + }); + } +} diff --git a/lib/providers/whereabouts_provider.dart b/lib/providers/whereabouts_provider.dart new file mode 100644 index 00000000..c68f32f2 --- /dev/null +++ b/lib/providers/whereabouts_provider.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/live_position.dart'; +import '../services/background_location_service.dart'; +import 'profile_provider.dart'; +import 'supabase_provider.dart'; +import 'stream_recovery.dart'; +import 'realtime_controller.dart'; + +/// All live positions of tracked users. +final livePositionsProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + + final wrapper = StreamRecoveryWrapper( + stream: client.from('live_positions').stream(primaryKey: ['user_id']), + onPollData: () async { + final data = await client.from('live_positions').select(); + return data.map(LivePosition.fromMap).toList(); + }, + fromMap: LivePosition.fromMap, + channelName: 'live_positions', + onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) => result.data); +}); + +final whereaboutsControllerProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return WhereaboutsController(client); +}); + +class WhereaboutsController { + WhereaboutsController(this._client); + + final SupabaseClient _client; + + /// Upsert current position. Returns `in_premise` status. + Future updatePosition(double lat, double lng) async { + final data = await _client.rpc( + 'update_live_position', + params: {'p_lat': lat, 'p_lng': lng}, + ); + return data as bool? ?? false; + } + + /// Toggle allow_tracking preference. + Future setTracking(bool allow) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + await _client + .from('profiles') + .update({'allow_tracking': allow}) + .eq('id', userId); + + // Start or stop background location updates + if (allow) { + await startBackgroundLocationUpdates(); + } else { + await stopBackgroundLocationUpdates(); + // Remove the live position entry + await _client.from('live_positions').delete().eq('user_id', userId); + } + } +} + +/// Background location reporting service. +/// Starts a 1-minute periodic timer that reports position to the server. +final locationReportingProvider = + Provider.autoDispose((ref) { + final client = ref.watch(supabaseClientProvider); + final profileAsync = ref.watch(currentProfileProvider); + final profile = profileAsync.valueOrNull; + + final service = LocationReportingService(client); + + // Auto-start if user has tracking enabled + if (profile != null && profile.allowTracking) { + service.start(); + // Also ensure background task is registered + startBackgroundLocationUpdates(); + } + + ref.onDispose(service.stop); + return service; + }); + +class LocationReportingService { + LocationReportingService(this._client); + + final SupabaseClient _client; + Timer? _timer; + bool _isRunning = false; + + bool get isRunning => _isRunning; + + void start() { + if (_isRunning) return; + _isRunning = true; + // Report immediately, then every 60 seconds + _reportPosition(); + _timer = Timer.periodic(const Duration(seconds: 60), (_) { + _reportPosition(); + }); + } + + void stop() { + _isRunning = false; + _timer?.cancel(); + _timer = null; + } + + Future _reportPosition() async { + try { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return; + + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return; + } + + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + await _client.rpc( + 'update_live_position', + params: {'p_lat': position.latitude, 'p_lng': position.longitude}, + ); + } catch (_) { + // Silently ignore errors in background reporting + } + } +} diff --git a/lib/providers/workforce_provider.dart b/lib/providers/workforce_provider.dart index 4f33f6c3..9239fa9b 100644 --- a/lib/providers/workforce_provider.dart +++ b/lib/providers/workforce_provider.dart @@ -21,6 +21,9 @@ final geofenceProvider = FutureProvider((ref) async { return GeofenceConfig.fromJson(setting.value); }); +/// Toggle to show/hide past schedules. Defaults to false (hide past). +final showPastSchedulesProvider = StateProvider((ref) => false); + final dutySchedulesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final profileAsync = ref.watch(currentProfileProvider); @@ -29,24 +32,17 @@ final dutySchedulesProvider = StreamProvider>((ref) { return Stream.value(const []); } - final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher'; - + // All roles now see all schedules (RLS updated in migration) final wrapper = StreamRecoveryWrapper( - stream: isAdmin - ? client - .from('duty_schedules') - .stream(primaryKey: ['id']) - .order('start_time') - : client - .from('duty_schedules') - .stream(primaryKey: ['id']) - .eq('user_id', profile.id) - .order('start_time'), + stream: client + .from('duty_schedules') + .stream(primaryKey: ['id']) + .order('start_time'), onPollData: () async { - final query = client.from('duty_schedules').select(); - final data = isAdmin - ? await query.order('start_time') - : await query.eq('user_id', profile.id).order('start_time'); + final data = await client + .from('duty_schedules') + .select() + .order('start_time'); return data.map(DutySchedule.fromMap).toList(); }, fromMap: DutySchedule.fromMap, @@ -223,6 +219,24 @@ class WorkforceController { .eq('id', swapId); } + Future updateSchedule({ + required String scheduleId, + required String userId, + required String shiftType, + required DateTime startTime, + required DateTime endTime, + }) async { + await _client + .from('duty_schedules') + .update({ + 'user_id': userId, + 'shift_type': shiftType, + 'start_time': startTime.toUtc().toIso8601String(), + 'end_time': endTime.toUtc().toIso8601String(), + }) + .eq('id', scheduleId); + } + String _formatDate(DateTime value) { final date = DateTime(value.year, value.month, value.day); final month = date.month.toString().padLeft(2, '0'); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index d3b59094..169422cf 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -22,6 +22,8 @@ import '../screens/tasks/tasks_list_screen.dart'; import '../screens/tickets/ticket_detail_screen.dart'; import '../screens/tickets/tickets_list_screen.dart'; import '../screens/workforce/workforce_screen.dart'; +import '../screens/attendance/attendance_screen.dart'; +import '../screens/whereabouts/whereabouts_screen.dart'; import '../widgets/app_shell.dart'; import '../screens/teams/teams_screen.dart'; import '../theme/m3_motion.dart'; @@ -67,6 +69,14 @@ final appRouterProvider = Provider((ref) { if (isReportsRoute && !hasReportsAccess) { return '/tickets'; } + // Attendance & Whereabouts: not accessible to standard users + final isStandardOnly = role == 'standard'; + final isAttendanceRoute = + state.matchedLocation == '/attendance' || + state.matchedLocation == '/whereabouts'; + if (isAttendanceRoute && isStandardOnly) { + return '/dashboard'; + } return null; }, routes: [ @@ -157,6 +167,20 @@ final appRouterProvider = Provider((ref) { child: const WorkforceScreen(), ), ), + GoRoute( + path: '/attendance', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: const AttendanceScreen(), + ), + ), + GoRoute( + path: '/whereabouts', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: const WhereaboutsScreen(), + ), + ), GoRoute( path: '/reports', pageBuilder: (context, state) => M3SharedAxisPage( diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index c7959260..cf481592 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -35,11 +35,19 @@ class _UserManagementScreenState extends ConsumerState { 'admin', ]; + static const List _religions = [ + 'catholic', + 'islam', + 'protestant', + 'other', + ]; + final _fullNameController = TextEditingController(); final _searchController = TextEditingController(); String? _selectedUserId; String? _selectedRole; + String _selectedReligion = 'catholic'; final Set _selectedOfficeIds = {}; bool _isSaving = false; @@ -299,6 +307,7 @@ class _UserManagementScreenState extends ConsumerState { setState(() { _selectedUserId = profile.id; _selectedRole = profile.role; + _selectedReligion = profile.religion; _fullNameController.text = profile.fullName; _selectedOfficeIds ..clear() @@ -345,6 +354,7 @@ class _UserManagementScreenState extends ConsumerState { setState(() { _selectedUserId = null; _selectedRole = null; + _selectedReligion = 'catholic'; _selectedOfficeIds.clear(); _fullNameController.clear(); }); @@ -377,6 +387,22 @@ class _UserManagementScreenState extends ConsumerState { decoration: const InputDecoration(labelText: 'Role'), ), const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('religion_${_selectedUserId ?? 'none'}'), + initialValue: _selectedReligion, + items: _religions + .map( + (r) => DropdownMenuItem( + value: r, + child: Text(r[0].toUpperCase() + r.substring(1)), + ), + ) + .toList(), + onChanged: (value) => + setDialogState(() => _selectedReligion = value ?? 'catholic'), + decoration: const InputDecoration(labelText: 'Religion'), + ), + const SizedBox(height: 12), // Email and lock status are retrieved from auth via Edge Function / admin API. Consumer( @@ -529,7 +555,12 @@ class _UserManagementScreenState extends ConsumerState { try { await ref .read(adminUserControllerProvider) - .updateProfile(userId: profile.id, fullName: fullName, role: role); + .updateProfile( + userId: profile.id, + fullName: fullName, + role: role, + religion: _selectedReligion, + ); final toAdd = _selectedOfficeIds.difference(currentOfficeIds); final toRemove = currentOfficeIds.difference(_selectedOfficeIds); diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart new file mode 100644 index 00000000..6a620bc1 --- /dev/null +++ b/lib/screens/attendance/attendance_screen.dart @@ -0,0 +1,1444 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; + +import '../../models/attendance_log.dart'; +import '../../models/duty_schedule.dart'; +import '../../models/pass_slip.dart'; +import '../../models/profile.dart'; +import '../../providers/attendance_provider.dart'; +import '../../providers/notifications_provider.dart'; +import '../../providers/pass_slip_provider.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/reports_provider.dart'; +import '../../providers/whereabouts_provider.dart'; +import '../../providers/workforce_provider.dart'; +import '../../theme/m3_motion.dart'; +import '../../utils/app_time.dart'; +import '../../widgets/responsive_body.dart'; + +class AttendanceScreen extends ConsumerStatefulWidget { + const AttendanceScreen({super.key}); + + @override + ConsumerState createState() => _AttendanceScreenState(); +} + +class _AttendanceScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ResponsiveBody( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + children: [ + Expanded( + child: Text( + 'Attendance', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Check In'), + Tab(text: 'Logbook'), + Tab(text: 'Pass Slip'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: const [_CheckInTab(), _LogbookTab(), _PassSlipTab()], + ), + ), + ], + ), + ); + } +} + +// ──────────────────────────────────────────────── +// Tab 1 – Check In / Check Out +// ──────────────────────────────────────────────── + +class _CheckInTab extends ConsumerStatefulWidget { + const _CheckInTab(); + + @override + ConsumerState<_CheckInTab> createState() => _CheckInTabState(); +} + +class _CheckInTabState extends ConsumerState<_CheckInTab> { + bool _loading = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final profile = ref.watch(currentProfileProvider).valueOrNull; + final schedulesAsync = ref.watch(dutySchedulesProvider); + final logsAsync = ref.watch(attendanceLogsProvider); + final allowTracking = profile?.allowTracking ?? false; + + if (profile == null) { + return const Center(child: CircularProgressIndicator()); + } + + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + + // Find today's schedule for the current user + final schedules = schedulesAsync.valueOrNull ?? []; + final todaySchedule = schedules.where((s) { + final sDay = DateTime( + s.startTime.year, + s.startTime.month, + s.startTime.day, + ); + return s.userId == profile.id && sDay == today; + }).toList(); + + // Find active attendance log (checked in but not out) + final logs = logsAsync.valueOrNull ?? []; + final activeLog = logs + .where((l) => l.userId == profile.id && !l.isCheckedOut) + .toList(); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Location tracking toggle + Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon( + Icons.location_on, + size: 20, + color: allowTracking ? colors.primary : colors.outline, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Location Tracking', + style: theme.textTheme.bodyMedium, + ), + ), + Switch( + value: allowTracking, + onChanged: (v) => + ref.read(whereaboutsControllerProvider).setTracking(v), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Today's schedule + Text( + "Today's Schedule", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + + if (todaySchedule.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + 'No schedule assigned for today.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + ) + else + ...todaySchedule.map((schedule) { + final hasCheckedIn = schedule.checkInAt != null; + final isActive = activeLog.any( + (l) => l.dutyScheduleId == schedule.id, + ); + final completedLog = logs + .where( + (l) => l.dutyScheduleId == schedule.id && l.isCheckedOut, + ) + .toList(); + final isCompleted = completedLog.isNotEmpty; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.schedule, size: 20, color: colors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + _shiftLabel(schedule.shiftType), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + _statusChip( + context, + isCompleted + ? 'Completed' + : isActive + ? 'On Duty' + : hasCheckedIn + ? 'Checked In' + : 'Scheduled', + ), + ], + ), + const SizedBox(height: 8), + Text( + '${AppTime.formatTime(schedule.startTime)} – ${AppTime.formatTime(schedule.endTime)}', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + if (!hasCheckedIn && !isCompleted) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _loading + ? null + : () => _handleCheckIn(schedule), + icon: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.login), + label: const Text('Check In'), + ), + ) + else if (isActive) + SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + onPressed: _loading + ? null + : () => _handleCheckOut( + activeLog.firstWhere( + (l) => l.dutyScheduleId == schedule.id, + ), + ), + icon: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.logout), + label: const Text('Check Out'), + ), + ) + else if (isCompleted) + Row( + children: [ + Icon( + Icons.check_circle, + size: 16, + color: colors.primary, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Checked out at ${AppTime.formatTime(completedLog.first.checkOutAt!)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }), + ], + ), + ); + } + + Future _handleCheckIn(DutySchedule schedule) async { + setState(() => _loading = true); + try { + final geoCfg = await ref.read(geofenceProvider.future); + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + // Client-side geofence check + if (geoCfg != null) { + bool inside = false; + if (geoCfg.hasPolygon) { + inside = geoCfg.containsPolygon( + position.latitude, + position.longitude, + ); + } else if (geoCfg.hasCircle) { + final dist = Geolocator.distanceBetween( + position.latitude, + position.longitude, + geoCfg.lat!, + geoCfg.lng!, + ); + inside = dist <= (geoCfg.radiusMeters ?? 0); + } + if (!inside && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('You are outside the geofence area.')), + ); + return; + } + } + await ref + .read(attendanceControllerProvider) + .checkIn( + dutyScheduleId: schedule.id, + lat: position.latitude, + lng: position.longitude, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Checked in successfully.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Check-in failed: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _handleCheckOut(AttendanceLog log) async { + setState(() => _loading = true); + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + await ref + .read(attendanceControllerProvider) + .checkOut( + attendanceId: log.id, + lat: position.latitude, + lng: position.longitude, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Checked out successfully.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Check-out failed: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Widget _statusChip(BuildContext context, String label) { + final colors = Theme.of(context).colorScheme; + Color bg; + Color fg; + switch (label) { + case 'Completed': + bg = colors.primaryContainer; + fg = colors.onPrimaryContainer; + case 'On Duty': + bg = colors.tertiaryContainer; + fg = colors.onTertiaryContainer; + case 'Checked In': + bg = colors.secondaryContainer; + fg = colors.onSecondaryContainer; + default: + bg = colors.surfaceContainerHighest; + fg = colors.onSurface; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: fg), + ), + ); + } + + String _shiftLabel(String shiftType) { + switch (shiftType) { + case 'normal': + return 'Normal Shift'; + case 'night': + return 'Night Shift'; + case 'overtime': + return 'Overtime'; + default: + return shiftType; + } + } +} + +// ──────────────────────────────────────────────── +// Tab 2 – Logbook +// ──────────────────────────────────────────────── + +class _LogbookTab extends ConsumerWidget { + const _LogbookTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final range = ref.watch(attendanceDateRangeProvider); + final logsAsync = ref.watch(attendanceLogsProvider); + final profilesAsync = ref.watch(profilesProvider); + + final Map profileById = { + for (final p in profilesAsync.valueOrNull ?? []) p.id: p, + }; + + return Column( + children: [ + // Date filter card + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(Icons.calendar_today, size: 18, color: colors.primary), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(range.label, style: theme.textTheme.labelLarge), + Text( + AppTime.formatDateRange(range.dateTimeRange), + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + FilledButton.tonalIcon( + onPressed: () => _showDateFilterDialog(context, ref), + icon: const Icon(Icons.tune, size: 18), + label: const Text('Change'), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 8), + Expanded( + child: logsAsync.when( + data: (logs) { + final filtered = logs.where((log) { + return !log.checkInAt.isBefore(range.start) && + log.checkInAt.isBefore(range.end); + }).toList(); + + if (filtered.isEmpty) { + return Center( + child: Text( + 'No attendance logs for this period.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 700) { + return _buildDataTable(context, filtered, profileById); + } + return _buildLogList(context, filtered, profileById); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Failed to load logs: $e')), + ), + ), + ], + ); + } + + void _showDateFilterDialog(BuildContext context, WidgetRef ref) { + m3ShowDialog( + context: context, + builder: (ctx) => _AttendanceDateFilterDialog( + current: ref.read(attendanceDateRangeProvider), + onApply: (newRange) { + ref.read(attendanceDateRangeProvider.notifier).state = newRange; + }, + ), + ); + } + + Widget _buildDataTable( + BuildContext context, + List logs, + Map profileById, + ) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DataTable( + columns: const [ + DataColumn(label: Text('Staff')), + DataColumn(label: Text('Role')), + DataColumn(label: Text('Date')), + DataColumn(label: Text('Check In')), + DataColumn(label: Text('Check Out')), + DataColumn(label: Text('Duration')), + DataColumn(label: Text('Status')), + ], + rows: logs.map((log) { + final p = profileById[log.userId]; + final name = p?.fullName ?? log.userId; + final role = p?.role ?? '-'; + final date = AppTime.formatDate(log.checkInAt); + final checkIn = AppTime.formatTime(log.checkInAt); + final checkOut = log.isCheckedOut + ? AppTime.formatTime(log.checkOutAt!) + : '—'; + final duration = log.isCheckedOut + ? _formatDuration(log.checkOutAt!.difference(log.checkInAt)) + : 'On duty'; + final status = log.isCheckedOut ? 'Completed' : 'On duty'; + + return DataRow( + cells: [ + DataCell(Text(name)), + DataCell(Text(_roleLabel(role))), + DataCell(Text(date)), + DataCell(Text(checkIn)), + DataCell(Text(checkOut)), + DataCell(Text(duration)), + DataCell( + Text( + status, + style: TextStyle( + color: log.isCheckedOut ? Colors.green : Colors.orange, + ), + ), + ), + ], + ); + }).toList(), + ), + ); + } + + Widget _buildLogList( + BuildContext context, + List logs, + Map profileById, + ) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + final p = profileById[log.userId]; + final name = p?.fullName ?? log.userId; + final role = p?.role ?? '-'; + + return Card( + child: ListTile( + title: Text(name), + subtitle: Text( + '${_roleLabel(role)} · ${AppTime.formatDate(log.checkInAt)}\n' + 'In: ${AppTime.formatTime(log.checkInAt)}' + '${log.isCheckedOut ? " · Out: ${AppTime.formatTime(log.checkOutAt!)}" : " · On duty"}', + ), + isThreeLine: true, + trailing: log.isCheckedOut + ? Text( + _formatDuration(log.checkOutAt!.difference(log.checkInAt)), + style: Theme.of(context).textTheme.bodySmall, + ) + : Chip( + label: const Text('On duty'), + backgroundColor: Theme.of( + context, + ).colorScheme.tertiaryContainer, + ), + ), + ); + }, + ); + } + + String _formatDuration(Duration d) { + final hours = d.inHours; + final minutes = d.inMinutes.remainder(60); + return '${hours}h ${minutes}m'; + } + + String _roleLabel(String role) { + switch (role) { + case 'admin': + return 'Admin'; + case 'dispatcher': + return 'Dispatcher'; + case 'it_staff': + return 'IT Staff'; + default: + return 'Standard'; + } + } +} + +// ──────────────────────────────────────────────── +// Date filter dialog (reuses Metabase-style pattern) +// ──────────────────────────────────────────────── + +class _AttendanceDateFilterDialog extends StatefulWidget { + const _AttendanceDateFilterDialog({ + required this.current, + required this.onApply, + }); + + final ReportDateRange current; + final ValueChanged onApply; + + @override + State<_AttendanceDateFilterDialog> createState() => + _AttendanceDateFilterDialogState(); +} + +class _AttendanceDateFilterDialogState + extends State<_AttendanceDateFilterDialog> + with SingleTickerProviderStateMixin { + late TabController _tabCtrl; + int _relativeAmount = 7; + String _relativeUnit = 'days'; + DateTime? _customStart; + DateTime? _customEnd; + + static const _units = ['days', 'weeks', 'months', 'quarters', 'years']; + + @override + void initState() { + super.initState(); + _tabCtrl = TabController(length: 3, vsync: this); + _customStart = widget.current.start; + _customEnd = widget.current.end; + } + + @override + void dispose() { + _tabCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final text = theme.textTheme; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Row( + children: [ + Icon(Icons.date_range, color: colors.primary), + const SizedBox(width: 8), + Text('Filter Date Range', style: text.titleMedium), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + TabBar( + controller: _tabCtrl, + labelStyle: text.labelMedium, + tabs: const [ + Tab(text: 'Presets'), + Tab(text: 'Relative'), + Tab(text: 'Custom'), + ], + ), + Flexible( + child: TabBarView( + controller: _tabCtrl, + children: [ + _buildPresetsTab(context), + _buildRelativeTab(context), + _buildCustomTab(context), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildPresetsTab(BuildContext context) { + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + + final presets = <_Preset>[ + _Preset('Today', today, today.add(const Duration(days: 1))), + _Preset('Yesterday', today.subtract(const Duration(days: 1)), today), + _Preset( + 'Last 7 Days', + today.subtract(const Duration(days: 7)), + today.add(const Duration(days: 1)), + ), + _Preset( + 'Last 30 Days', + today.subtract(const Duration(days: 30)), + today.add(const Duration(days: 1)), + ), + _Preset( + 'Last 90 Days', + today.subtract(const Duration(days: 90)), + today.add(const Duration(days: 1)), + ), + _Preset( + 'This Week', + today.subtract(Duration(days: today.weekday - 1)), + today.add(const Duration(days: 1)), + ), + _Preset( + 'This Month', + DateTime(now.year, now.month, 1), + DateTime(now.year, now.month + 1, 1), + ), + _Preset( + 'This Year', + DateTime(now.year, 1, 1), + DateTime(now.year + 1, 1, 1), + ), + ]; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: presets.map((p) { + final isSelected = widget.current.label == p.label; + return ChoiceChip( + label: Text(p.label), + selected: isSelected, + onSelected: (_) { + widget.onApply( + ReportDateRange(start: p.start, end: p.end, label: p.label), + ); + Navigator.pop(context); + }, + ); + }).toList(), + ), + ); + } + + Widget _buildRelativeTab(BuildContext context) { + final theme = Theme.of(context); + final text = theme.textTheme; + final preview = _computeRelativeRange(_relativeAmount, _relativeUnit); + final previewText = AppTime.formatDateRange(preview.dateTimeRange); + + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Previous', style: text.labelLarge), + const SizedBox(height: 12), + Row( + children: [ + SizedBox( + width: 80, + child: TextFormField( + initialValue: _relativeAmount.toString(), + keyboardType: TextInputType.number, + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + onChanged: (v) => setState(() { + _relativeAmount = int.tryParse(v) ?? _relativeAmount; + }), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + initialValue: _relativeUnit, + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + items: _units + .map((u) => DropdownMenuItem(value: u, child: Text(u))) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _relativeUnit = v); + }, + ), + ), + const SizedBox(width: 12), + Text('ago', style: text.bodyLarge), + ], + ), + const SizedBox(height: 16), + Text( + previewText, + style: text.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: () { + widget.onApply(preview); + Navigator.pop(context); + }, + child: const Text('Apply'), + ), + ), + ], + ), + ); + } + + ReportDateRange _computeRelativeRange(int amount, String unit) { + final now = AppTime.now(); + final end = now; + DateTime start; + switch (unit) { + case 'days': + start = now.subtract(Duration(days: amount)); + case 'weeks': + start = now.subtract(Duration(days: amount * 7)); + case 'months': + start = DateTime(now.year, now.month - amount, now.day); + case 'quarters': + start = DateTime(now.year, now.month - (amount * 3), now.day); + case 'years': + start = DateTime(now.year - amount, now.month, now.day); + default: + start = now.subtract(Duration(days: amount)); + } + return ReportDateRange(start: start, end: end, label: 'Last $amount $unit'); + } + + Widget _buildCustomTab(BuildContext context) { + final theme = Theme.of(context); + final text = theme.textTheme; + + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Start Date', style: text.labelLarge), + const SizedBox(height: 8), + OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 16), + label: Text( + _customStart != null + ? AppTime.formatDate(_customStart!) + : 'Select start date', + ), + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _customStart ?? AppTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (picked != null) setState(() => _customStart = picked); + }, + ), + const SizedBox(height: 16), + Text('End Date', style: text.labelLarge), + const SizedBox(height: 8), + OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 16), + label: Text( + _customEnd != null + ? AppTime.formatDate(_customEnd!) + : 'Select end date', + ), + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _customEnd ?? AppTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (picked != null) setState(() => _customEnd = picked); + }, + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: (_customStart != null && _customEnd != null) + ? () { + widget.onApply( + ReportDateRange( + start: _customStart!, + end: _customEnd!, + label: 'Custom', + ), + ); + Navigator.pop(context); + } + : null, + child: const Text('Apply'), + ), + ), + ], + ), + ); + } +} + +class _Preset { + const _Preset(this.label, this.start, this.end); + final String label; + final DateTime start; + final DateTime end; +} + +// ──────────────────────────────────────────────── +// Tab 3 – Pass Slip +// ──────────────────────────────────────────────── + +class _PassSlipTab extends ConsumerStatefulWidget { + const _PassSlipTab(); + + @override + ConsumerState<_PassSlipTab> createState() => _PassSlipTabState(); +} + +class _PassSlipTabState extends ConsumerState<_PassSlipTab> { + final _reasonController = TextEditingController(); + bool _submitting = false; + + @override + void dispose() { + _reasonController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final profile = ref.watch(currentProfileProvider).valueOrNull; + final slipsAsync = ref.watch(passSlipsProvider); + final schedulesAsync = ref.watch(dutySchedulesProvider); + final profilesAsync = ref.watch(profilesProvider); + final activeSlip = ref.watch(activePassSlipProvider); + final isAdmin = profile?.role == 'admin' || profile?.role == 'dispatcher'; + + final Map profileById = { + for (final p in profilesAsync.valueOrNull ?? []) p.id: p, + }; + + if (profile == null) { + return const Center(child: CircularProgressIndicator()); + } + + // Find today's schedule for passing to request form + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + final schedules = schedulesAsync.valueOrNull ?? []; + final todaySchedule = schedules.where((s) { + final sDay = DateTime( + s.startTime.year, + s.startTime.month, + s.startTime.day, + ); + return s.userId == profile.id && sDay == today; + }).toList(); + + return slipsAsync.when( + data: (slips) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Active slip banner + if (activeSlip != null) ...[ + Card( + color: activeSlip.isExceeded + ? colors.errorContainer + : colors.tertiaryContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + activeSlip.isExceeded + ? Icons.warning + : Icons.directions_walk, + color: activeSlip.isExceeded + ? colors.onErrorContainer + : colors.onTertiaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + activeSlip.isExceeded + ? 'Pass Slip Exceeded (>1 hour)' + : 'Active Pass Slip', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: activeSlip.isExceeded + ? colors.onErrorContainer + : colors.onTertiaryContainer, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Reason: ${activeSlip.reason}', + style: theme.textTheme.bodyMedium, + ), + if (activeSlip.slipStart != null) + Text( + 'Started: ${AppTime.formatTime(activeSlip.slipStart!)}', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _submitting + ? null + : () => _completeSlip(activeSlip.id), + icon: const Icon(Icons.check), + label: const Text('Complete / Return'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + + // Request form (only for non-admin staff with a schedule today) + if (!isAdmin && activeSlip == null && todaySchedule.isNotEmpty) ...[ + Text( + 'Request Pass Slip', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _reasonController, + decoration: const InputDecoration( + labelText: 'Reason', + hintText: 'Brief reason for pass slip', + ), + maxLines: 2, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + onPressed: _submitting + ? null + : () => _requestSlip(todaySchedule.first.id), + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.send), + label: const Text('Submit Request'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + + // Pending slips for admin approval + if (isAdmin) ...[ + Text( + 'Pending Approvals', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...slips + .where((s) => s.status == 'pending') + .map( + (slip) => _buildSlipCard( + context, + slip, + profileById, + showActions: true, + ), + ), + if (slips.where((s) => s.status == 'pending').isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No pending pass slip requests.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 16), + ], + + // History + Text( + 'Pass Slip History', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...slips + .where((s) => s.status != 'pending' || !isAdmin) + .take(50) + .map( + (slip) => _buildSlipCard( + context, + slip, + profileById, + showActions: false, + ), + ), + if (slips.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + 'No pass slip records.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Failed to load pass slips: $e')), + ); + } + + Widget _buildSlipCard( + BuildContext context, + PassSlip slip, + Map profileById, { + required bool showActions, + }) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final p = profileById[slip.userId]; + final name = p?.fullName ?? slip.userId; + + Color statusColor; + switch (slip.status) { + case 'approved': + statusColor = Colors.green; + case 'rejected': + statusColor = colors.error; + case 'completed': + statusColor = colors.primary; + default: + statusColor = Colors.orange; + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(name, style: theme.textTheme.titleSmall)), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + slip.status.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text(slip.reason, style: theme.textTheme.bodyMedium), + Text( + 'Requested: ${AppTime.formatDate(slip.requestedAt)} ${AppTime.formatTime(slip.requestedAt)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + if (slip.slipStart != null) + Text( + 'Started: ${AppTime.formatTime(slip.slipStart!)}' + '${slip.slipEnd != null ? " · Ended: ${AppTime.formatTime(slip.slipEnd!)}" : ""}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + if (showActions && slip.status == 'pending') ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _submitting ? null : () => _rejectSlip(slip.id), + child: Text( + 'Reject', + style: TextStyle(color: colors.error), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _submitting ? null : () => _approveSlip(slip.id), + child: const Text('Approve'), + ), + ], + ), + ], + ], + ), + ), + ); + } + + Future _requestSlip(String scheduleId) async { + final reason = _reasonController.text.trim(); + if (reason.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Please enter a reason.'))); + return; + } + setState(() => _submitting = true); + try { + await ref + .read(passSlipControllerProvider) + .requestSlip(dutyScheduleId: scheduleId, reason: reason); + _reasonController.clear(); + + // Notify all admin users via push notification + final profiles = ref.read(profilesProvider).valueOrNull ?? []; + final adminIds = profiles + .where((p) => p.role == 'admin') + .map((p) => p.id) + .toList(); + final currentProfile = ref.read(currentProfileProvider).valueOrNull; + final actorName = currentProfile?.fullName ?? 'A staff member'; + if (adminIds.isNotEmpty && currentProfile != null) { + ref + .read(notificationsControllerProvider) + .createNotification( + userIds: adminIds, + type: 'pass_slip_request', + actorId: currentProfile.id, + pushTitle: 'Pass Slip Request', + pushBody: '$actorName requested a pass slip: $reason', + ); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Pass slip request submitted.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed: $e'))); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Future _approveSlip(String slipId) async { + setState(() => _submitting = true); + try { + await ref.read(passSlipControllerProvider).approveSlip(slipId); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Pass slip approved.'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed: $e'))); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Future _rejectSlip(String slipId) async { + setState(() => _submitting = true); + try { + await ref.read(passSlipControllerProvider).rejectSlip(slipId); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Pass slip rejected.'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed: $e'))); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Future _completeSlip(String slipId) async { + setState(() => _submitting = true); + try { + await ref.read(passSlipControllerProvider).completeSlip(slipId); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Pass slip completed.'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed: $e'))); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } +} diff --git a/lib/screens/whereabouts/whereabouts_screen.dart b/lib/screens/whereabouts/whereabouts_screen.dart new file mode 100644 index 00000000..f666d6bd --- /dev/null +++ b/lib/screens/whereabouts/whereabouts_screen.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import '../../models/app_settings.dart'; +import '../../models/live_position.dart'; +import '../../models/profile.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/whereabouts_provider.dart'; +import '../../providers/workforce_provider.dart'; +import '../../widgets/responsive_body.dart'; +import '../../utils/app_time.dart'; + +class WhereaboutsScreen extends ConsumerStatefulWidget { + const WhereaboutsScreen({super.key}); + + @override + ConsumerState createState() => _WhereaboutsScreenState(); +} + +class _WhereaboutsScreenState extends ConsumerState { + final _mapController = MapController(); + + @override + Widget build(BuildContext context) { + final positionsAsync = ref.watch(livePositionsProvider); + final profilesAsync = ref.watch(profilesProvider); + final geofenceAsync = ref.watch(geofenceProvider); + + final Map profileById = { + for (final p in profilesAsync.valueOrNull ?? []) p.id: p, + }; + + return ResponsiveBody( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Text( + 'Whereabouts', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + ), + Expanded( + child: positionsAsync.when( + data: (positions) => _buildMap( + context, + positions, + profileById, + geofenceAsync.valueOrNull, + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => + Center(child: Text('Failed to load positions: $e')), + ), + ), + // Staff list below the map + positionsAsync.when( + data: (positions) => + _buildStaffList(context, positions, profileById), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + ], + ), + ); + } + + Widget _buildMap( + BuildContext context, + List positions, + Map profileById, + GeofenceConfig? geofenceConfig, + ) { + final markers = positions.map((pos) { + final name = profileById[pos.userId]?.fullName ?? 'Unknown'; + final inPremise = pos.inPremise; + return Marker( + point: LatLng(pos.lat, pos.lng), + width: 80, + height: 60, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 4, + ), + ], + ), + child: Text( + name.split(' ').first, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + Icons.location_pin, + size: 28, + color: inPremise ? Colors.green : Colors.orange, + ), + ], + ), + ); + }).toList(); + + // Build geofence polygon overlay if available + final polygonLayers = []; + if (geofenceConfig != null && geofenceConfig.hasPolygon) { + final List points = geofenceConfig.polygon! + .map((p) => LatLng(p.lat, p.lng)) + .toList(); + if (points.isNotEmpty) { + polygonLayers.add( + PolygonLayer( + polygons: [ + Polygon( + points: points, + color: Colors.blue.withValues(alpha: 0.1), + borderColor: Colors.blue, + borderStrokeWidth: 2.0, + ), + ], + ), + ); + } + } + + // Default center: CRMC Cotabato City area + const defaultCenter = LatLng(7.2046, 124.2460); + + return FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: positions.isNotEmpty + ? LatLng(positions.first.lat, positions.first.lng) + : defaultCenter, + initialZoom: 16.0, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.tasq.app', + ), + ...polygonLayers, + MarkerLayer(markers: markers), + ], + ); + } + + Widget _buildStaffList( + BuildContext context, + List positions, + Map profileById, + ) { + if (positions.isEmpty) return const SizedBox.shrink(); + + return Container( + constraints: const BoxConstraints(maxHeight: 180), + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: positions.length, + itemBuilder: (context, index) { + final pos = positions[index]; + final p = profileById[pos.userId]; + final name = p?.fullName ?? 'Unknown'; + final role = p?.role ?? '-'; + final timeAgo = _timeAgo(pos.updatedAt); + + return ListTile( + dense: true, + leading: CircleAvatar( + radius: 16, + backgroundColor: pos.inPremise + ? Colors.green.shade100 + : Colors.orange.shade100, + child: Icon( + pos.inPremise ? Icons.check : Icons.location_off, + size: 16, + color: pos.inPremise ? Colors.green : Colors.orange, + ), + ), + title: Text(name), + subtitle: Text('${_roleLabel(role)} · $timeAgo'), + trailing: Text( + pos.inPremise ? 'In premise' : 'Off-site', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: pos.inPremise ? Colors.green : Colors.orange, + ), + ), + onTap: () => _mapController.move(LatLng(pos.lat, pos.lng), 17.0), + ); + }, + ), + ); + } + + String _timeAgo(DateTime dt) { + final diff = AppTime.now().difference(dt); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + return '${diff.inHours}h ago'; + } + + String _roleLabel(String role) { + switch (role) { + case 'admin': + return 'Admin'; + case 'dispatcher': + return 'Dispatcher'; + case 'it_staff': + return 'IT Staff'; + default: + return 'Standard'; + } + } +} diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index a91d298b..9634cd6d 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -1,17 +1,17 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import '../../theme/m3_motion.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tasq/utils/app_time.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:timezone/timezone.dart' as tz; import '../../models/duty_schedule.dart'; import '../../models/profile.dart'; import '../../models/swap_request.dart'; + import '../../providers/profile_provider.dart'; import '../../providers/workforce_provider.dart'; +import '../../providers/chat_provider.dart'; +import '../../providers/ramadan_provider.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; @@ -94,72 +94,108 @@ class _SchedulePanel extends ConsumerWidget { final schedulesAsync = ref.watch(dutySchedulesProvider); final profilesAsync = ref.watch(profilesProvider); final currentUserId = ref.watch(currentUserIdProvider); + final showPast = ref.watch(showPastSchedulesProvider); - return schedulesAsync.when( - data: (schedules) { - if (schedules.isEmpty) { - return const Center(child: Text('No schedules yet.')); - } - - final Map profileById = { - for (final profile in profilesAsync.valueOrNull ?? []) - profile.id: profile, - }; - final grouped = >{}; - for (final schedule in schedules) { - final day = DateTime( - schedule.startTime.year, - schedule.startTime.month, - schedule.startTime.day, - ); - grouped.putIfAbsent(day, () => []).add(schedule); - } - - final days = grouped.keys.toList()..sort(); - - return ListView.builder( - padding: const EdgeInsets.only(bottom: 24), - itemCount: days.length, - itemBuilder: (context, index) { - final day = days[index]; - final items = grouped[day]! - ..sort((a, b) => a.startTime.compareTo(b.startTime)); - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppTime.formatDate(day), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - ...items.map( - (schedule) => _ScheduleTile( - schedule: schedule, - displayName: _scheduleName( - profileById, - schedule, - isAdmin, - ), - relieverLabels: _relieverLabelsFromIds( - schedule.relieverIds, - profileById, - ), - isMine: schedule.userId == currentUserId, - ), - ), - ], + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + Text( + 'Duty Schedules', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load schedules: $error')), + const Spacer(), + FilterChip( + label: const Text('Show past'), + selected: showPast, + onSelected: (v) => + ref.read(showPastSchedulesProvider.notifier).state = v, + ), + ], + ), + ), + const SizedBox(height: 4), + Expanded( + child: schedulesAsync.when( + data: (allSchedules) { + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + final schedules = showPast + ? allSchedules + : allSchedules + .where((s) => !s.endTime.isBefore(today)) + .toList(); + + if (schedules.isEmpty) { + return const Center(child: Text('No schedules yet.')); + } + + final Map profileById = { + for (final profile in profilesAsync.valueOrNull ?? []) + profile.id: profile, + }; + final grouped = >{}; + for (final schedule in schedules) { + final day = DateTime( + schedule.startTime.year, + schedule.startTime.month, + schedule.startTime.day, + ); + grouped.putIfAbsent(day, () => []).add(schedule); + } + + final days = grouped.keys.toList()..sort(); + + return ListView.builder( + padding: const EdgeInsets.only(bottom: 24), + itemCount: days.length, + itemBuilder: (context, index) { + final day = days[index]; + final items = grouped[day]! + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_dayOfWeek(day)}, ${AppTime.formatDate(day)}', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + ...items.map( + (schedule) => _ScheduleTile( + schedule: schedule, + displayName: _scheduleName( + profileById, + schedule, + isAdmin, + ), + relieverLabels: _relieverLabelsFromIds( + schedule.relieverIds, + profileById, + ), + isMine: schedule.userId == currentUserId, + isAdmin: isAdmin, + ), + ), + ], + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => + Center(child: Text('Failed to load schedules: $error')), + ), + ), + ], ); } @@ -168,9 +204,6 @@ class _SchedulePanel extends ConsumerWidget { DutySchedule schedule, bool isAdmin, ) { - if (!isAdmin) { - return _shiftLabel(schedule.shiftType); - } final profile = profileById[schedule.userId]; final name = profile?.fullName.isNotEmpty == true ? profile!.fullName @@ -178,6 +211,11 @@ class _SchedulePanel extends ConsumerWidget { return '${_shiftLabel(schedule.shiftType)} · $name'; } + static String _dayOfWeek(DateTime day) { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return days[day.weekday - 1]; + } + String _shiftLabel(String value) { switch (value) { case 'am': @@ -216,12 +254,14 @@ class _ScheduleTile extends ConsumerWidget { required this.displayName, required this.relieverLabels, required this.isMine, + required this.isAdmin, }); final DutySchedule schedule; final String displayName; final List relieverLabels; final bool isMine; + final bool isAdmin; @override Widget build(BuildContext context, WidgetRef ref) { @@ -229,12 +269,6 @@ class _ScheduleTile extends ConsumerWidget { final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? []; 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(hours: 2))) && - now.isBefore(schedule.endTime); final hasRequestedSwap = swaps.any( (swap) => swap.requesterScheduleId == schedule.id && @@ -278,14 +312,13 @@ class _ScheduleTile extends ConsumerWidget { 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 (isAdmin) + IconButton( + tooltip: 'Edit schedule', + onPressed: () => _editSchedule(context, ref), + icon: const Icon(Icons.edit, size: 20), ), - if (canRequestSwap) ...[ - if (canCheckIn) const SizedBox(height: 8), + if (canRequestSwap) OutlinedButton.icon( onPressed: hasRequestedSwap ? () => _openSwapsTab(context) @@ -295,7 +328,6 @@ class _ScheduleTile extends ConsumerWidget { hasRequestedSwap ? 'Swap Requested' : 'Request swap', ), ), - ], ], ), ], @@ -325,106 +357,187 @@ class _ScheduleTile extends ConsumerWidget { ); } - Future _handleCheckIn( - BuildContext context, - WidgetRef ref, - DutySchedule schedule, - ) async { - final geofence = await ref.read(geofenceProvider.future); - if (geofence == null) { - if (!context.mounted) return; - await _showAlert( - context, - title: 'Geofence missing', - message: 'Geofence is not configured.', - ); - return; + Future _editSchedule(BuildContext context, WidgetRef ref) async { + final profiles = ref.read(profilesProvider).valueOrNull ?? []; + final staff = + profiles + .where( + (p) => + p.role == 'it_staff' || + p.role == 'admin' || + p.role == 'dispatcher', + ) + .toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName)); + if (staff.isEmpty) return; + + var selectedUserId = schedule.userId; + var selectedShift = schedule.shiftType; + var selectedDate = DateTime( + schedule.startTime.year, + schedule.startTime.month, + schedule.startTime.day, + ); + var startTime = TimeOfDay.fromDateTime(schedule.startTime); + var endTime = TimeOfDay.fromDateTime(schedule.endTime); + + final confirmed = await m3ShowDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + shape: AppSurfaces.of(context).dialogShape, + title: const Text('Edit Schedule'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: selectedUserId, + items: [ + for (final p in staff) + DropdownMenuItem( + value: p.id, + child: Text( + p.fullName.isNotEmpty ? p.fullName : p.id, + ), + ), + ], + onChanged: (v) { + if (v != null) setState(() => selectedUserId = v); + }, + decoration: const InputDecoration(labelText: 'Assignee'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedShift, + items: const [ + DropdownMenuItem(value: 'am', child: Text('AM Duty')), + DropdownMenuItem(value: 'pm', child: Text('PM Duty')), + DropdownMenuItem( + value: 'on_call', + child: Text('On Call'), + ), + DropdownMenuItem( + value: 'normal', + child: Text('Normal'), + ), + ], + onChanged: (v) { + if (v != null) setState(() => selectedShift = v); + }, + decoration: const InputDecoration( + labelText: 'Shift type', + ), + ), + const SizedBox(height: 12), + InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => selectedDate = picked); + } + }, + child: InputDecorator( + decoration: const InputDecoration(labelText: 'Date'), + child: Text(AppTime.formatDate(selectedDate)), + ), + ), + const SizedBox(height: 12), + InkWell( + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: startTime, + ); + if (picked != null) { + setState(() => startTime = picked); + } + }, + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Start time', + ), + child: Text(startTime.format(context)), + ), + ), + const SizedBox(height: 12), + InkWell( + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: endTime, + ); + if (picked != null) { + setState(() => endTime = picked); + } + }, + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'End time', + ), + child: Text(endTime.format(context)), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + + if (confirmed != true || !context.mounted) return; + + final startDateTime = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + startTime.hour, + startTime.minute, + ); + var endDateTime = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + endTime.hour, + endTime.minute, + ); + if (!endDateTime.isAfter(startDateTime)) { + endDateTime = endDateTime.add(const Duration(days: 1)); } - final serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) { - if (!context.mounted) return; - await _showAlert( - context, - title: 'Location disabled', - message: 'Location services are disabled.', - ); - return; - } - - var permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - } - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { - if (!context.mounted) return; - 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, - ), - ); - - if (!geofence.hasPolygon) { - if (!context.mounted) return; - await _showAlert( - context, - title: 'Geofence missing', - message: 'Geofence polygon is not configured.', - ); - return; - } - - final isInside = geofence.containsPolygon( - position.latitude, - position.longitude, - ); - - 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 + await ref .read(workforceControllerProvider) - .checkIn( - dutyScheduleId: schedule.id, - lat: position.latitude, - lng: position.longitude, + .updateSchedule( + scheduleId: schedule.id, + userId: selectedUserId, + shiftType: selectedShift, + startTime: startDateTime, + endTime: endDateTime, ); ref.invalidate(dutySchedulesProvider); + } catch (e) { if (!context.mounted) return; - await _showAlert( - context, - title: 'Checked in', - message: 'Checked in ($status).', - ); - } catch (error) { - if (!context.mounted) return; - await _showAlert( - context, - title: 'Check-in failed', - message: 'Check-in failed: $error', - ); - } finally { - if (progressContext.mounted) { - Navigator.of(progressContext).pop(); - } + showErrorSnackBar(context, 'Update failed: $e'); } } @@ -610,62 +723,6 @@ class _ScheduleTile extends ConsumerWidget { } } - Future _showAlert( - BuildContext context, { - required String title, - required String message, - }) async { - await m3ShowDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), - ), - ], - ); - }, - ); - } - - Future _showCheckInProgress(BuildContext context) { - final completer = Completer(); - m3ShowDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - if (!completer.isCompleted) { - completer.complete(dialogContext); - } - return AlertDialog( - shape: AppSurfaces.of(context).dialogShape, - 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) { @@ -789,17 +846,36 @@ class _ScheduleGeneratorPanelState return const SizedBox.shrink(); } + final isRamadan = ref.watch(isRamadanActiveProvider); + return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Schedule Generator', - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + Row( + children: [ + Text( + 'Schedule Generator', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (isRamadan) ...[ + const SizedBox(width: 8), + Chip( + label: const Text('Ramadan'), + avatar: const Icon(Icons.nights_stay, size: 16), + backgroundColor: Theme.of( + context, + ).colorScheme.tertiaryContainer, + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.onTertiaryContainer, + ), + ), + ], + ], ), const SizedBox(height: 12), _dateField( @@ -925,7 +1001,7 @@ class _ScheduleGeneratorPanelState final staff = _sortedStaff(); if (staff.isEmpty) { if (!mounted) return; - showWarningSnackBar(context, 'No IT staff available for scheduling.'); + showWarningSnackBar(context, 'No staff available for scheduling.'); return; } @@ -1176,6 +1252,10 @@ class _ScheduleGeneratorPanelState initialValue: selectedShift, items: const [ DropdownMenuItem(value: 'am', child: Text('AM Duty')), + DropdownMenuItem( + value: 'normal', + child: Text('Normal Duty'), + ), DropdownMenuItem( value: 'on_call', child: Text('On Call'), @@ -1381,7 +1461,12 @@ class _ScheduleGeneratorPanelState List _sortedStaff() { final profiles = ref.read(profilesProvider).valueOrNull ?? []; final staff = profiles - .where((profile) => profile.role == 'it_staff') + .where( + (profile) => + profile.role == 'it_staff' || + profile.role == 'admin' || + profile.role == 'dispatcher', + ) .toList(); staff.sort((a, b) { final nameA = a.fullName.isNotEmpty ? a.fullName : a.id; @@ -1429,11 +1514,24 @@ class _ScheduleGeneratorPanelState startMinute: 0, duration: const Duration(hours: 8), ); + // Default normal shift (8am-5pm = 9 hours) templates['normal'] = _ShiftTemplate( startHour: 8, startMinute: 0, duration: const Duration(hours: 9), ); + // Islam Ramadan normal shift (8am-4pm = 8 hours) + templates['normal_ramadan_islam'] = _ShiftTemplate( + startHour: 8, + startMinute: 0, + duration: const Duration(hours: 8), + ); + // Non-Islam Ramadan normal shift (8am-5pm = 9 hours, same as default) + templates['normal_ramadan_other'] = _ShiftTemplate( + startHour: 8, + startMinute: 0, + duration: const Duration(hours: 9), + ); return templates; } @@ -1448,6 +1546,12 @@ class _ScheduleGeneratorPanelState final normalizedStart = DateTime(start.year, start.month, start.day); final normalizedEnd = DateTime(end.year, end.month, end.day); final existing = schedules; + + // Only IT Staff rotate through AM/PM/on_call shifts + final itStaff = staff.where((p) => p.role == 'it_staff').toList(); + // Admin/Dispatcher always get normal shift (no rotation) + final nonRotating = staff.where((p) => p.role != 'it_staff').toList(); + var weekStart = _startOfWeek(normalizedStart); while (!weekStart.isAfter(normalizedEnd)) { @@ -1483,31 +1587,32 @@ class _ScheduleGeneratorPanelState ), ]; + // Rotation indices only for IT Staff final amBaseIndex = _nextIndexFromLastWeek( shiftType: 'am', - staff: staff, + staff: itStaff, lastWeek: lastWeek, defaultIndex: 0, ); final pmBaseIndex = _nextIndexFromLastWeek( shiftType: 'pm', - staff: staff, + staff: itStaff, lastWeek: lastWeek, - defaultIndex: staff.length > 1 ? 1 : 0, + defaultIndex: itStaff.length > 1 ? 1 : 0, ); - final amUserId = staff.isEmpty + final amUserId = itStaff.isEmpty ? null - : staff[amBaseIndex % staff.length].id; - final pmUserId = staff.isEmpty + : itStaff[amBaseIndex % itStaff.length].id; + final pmUserId = itStaff.isEmpty ? null - : staff[pmBaseIndex % staff.length].id; - final nextWeekPmUserId = staff.isEmpty + : itStaff[pmBaseIndex % itStaff.length].id; + final nextWeekPmUserId = itStaff.isEmpty ? null - : staff[(pmBaseIndex + 1) % staff.length].id; - final pmRelievers = _buildRelievers(pmBaseIndex, staff); - final nextWeekRelievers = staff.isEmpty + : itStaff[(pmBaseIndex + 1) % itStaff.length].id; + final pmRelievers = _buildRelievers(pmBaseIndex, itStaff); + final nextWeekRelievers = itStaff.isEmpty ? [] - : _buildRelievers((pmBaseIndex + 1) % staff.length, staff); + : _buildRelievers((pmBaseIndex + 1) % itStaff.length, itStaff); var weekendNormalOffset = 0; for ( @@ -1520,17 +1625,20 @@ class _ScheduleGeneratorPanelState } final isWeekend = day.weekday == DateTime.saturday || day.weekday == DateTime.sunday; + final dayIsRamadan = isApproximateRamadan(day); + if (isWeekend) { - if (staff.isNotEmpty) { + // Weekend: only IT Staff get normal + on_call (rotating) + if (itStaff.isNotEmpty) { final normalIndex = (amBaseIndex + pmBaseIndex + weekendNormalOffset) % - staff.length; + itStaff.length; _tryAddDraft( draft, existing, templates, 'normal', - staff[normalIndex].id, + itStaff[normalIndex].id, day, const [], ); @@ -1548,7 +1656,14 @@ class _ScheduleGeneratorPanelState ); } } else { - if (amUserId != null) { + // Weekday: IT Staff rotate AM/PM/on_call + final isFriday = day.weekday == DateTime.friday; + final profileMap = _profileById(); + final amProfile = amUserId != null ? profileMap[amUserId] : null; + final skipAmForRamadan = + dayIsRamadan && isFriday && amProfile?.religion == 'islam'; + + if (amUserId != null && !skipAmForRamadan) { _tryAddDraft( draft, existing, @@ -1580,20 +1695,46 @@ class _ScheduleGeneratorPanelState ); } + // Remaining IT Staff get normal shift final assignedToday = [ amUserId, pmUserId, ].whereType().toSet(); - for (final profile in staff) { + for (final profile in itStaff) { if (assignedToday.contains(profile.id)) continue; + final normalKey = dayIsRamadan && profile.religion == 'islam' + ? 'normal_ramadan_islam' + : dayIsRamadan + ? 'normal_ramadan_other' + : 'normal'; _tryAddDraft( draft, existing, templates, - 'normal', + normalKey, profile.id, day, const [], + displayShiftType: 'normal', + ); + } + + // Admin/Dispatcher always get normal shift (no rotation) + for (final profile in nonRotating) { + final normalKey = dayIsRamadan && profile.religion == 'islam' + ? 'normal_ramadan_islam' + : dayIsRamadan + ? 'normal_ramadan_other' + : 'normal'; + _tryAddDraft( + draft, + existing, + templates, + normalKey, + profile.id, + day, + const [], + displayShiftType: 'normal', ); } } @@ -1612,15 +1753,16 @@ class _ScheduleGeneratorPanelState String shiftType, String userId, DateTime day, - List relieverIds, - ) { + List relieverIds, { + String? displayShiftType, + }) { final template = templates[_normalizeShiftType(shiftType)]!; final start = template.buildStart(day); final end = template.buildEnd(start); final candidate = _DraftSchedule( localId: _draftCounter++, userId: userId, - shiftType: shiftType, + shiftType: displayShiftType ?? shiftType, startTime: start, endTime: end, relieverIds: relieverIds, @@ -1961,6 +2103,12 @@ class _SwapRequestsPanel extends ConsumerWidget { ], ], ), + if (item.chatThreadId != null) + _SwapChatSection( + threadId: item.chatThreadId!, + currentUserId: currentUserId ?? '', + profileById: profileById, + ), ], ), ), @@ -2072,3 +2220,169 @@ class _SwapRequestsPanel extends ConsumerWidget { .toList(); } } + +/// Expandable chat section within a swap request card. +class _SwapChatSection extends ConsumerStatefulWidget { + const _SwapChatSection({ + required this.threadId, + required this.currentUserId, + required this.profileById, + }); + + final String threadId; + final String currentUserId; + final Map profileById; + + @override + ConsumerState<_SwapChatSection> createState() => _SwapChatSectionState(); +} + +class _SwapChatSectionState extends ConsumerState<_SwapChatSection> { + final _msgController = TextEditingController(); + bool _expanded = false; + + @override + void dispose() { + _msgController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const Icon(Icons.chat_bubble_outline, size: 18), + const SizedBox(width: 8), + const Text('Chat'), + const Spacer(), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 20, + ), + ], + ), + ), + ), + if (_expanded) _buildChatBody(context), + ], + ); + } + + Widget _buildChatBody(BuildContext context) { + final messagesAsync = ref.watch(chatMessagesProvider(widget.threadId)); + + return messagesAsync.when( + data: (messages) { + return Column( + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: messages.isEmpty + ? const Padding( + padding: EdgeInsets.all(12), + child: Text('No messages yet.'), + ) + : ListView.builder( + reverse: true, + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[index]; + final isMe = msg.senderId == widget.currentUserId; + final sender = + widget.profileById[msg.senderId]?.fullName ?? + 'Unknown'; + return Align( + alignment: isMe + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: isMe + ? Theme.of( + context, + ).colorScheme.primaryContainer + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (!isMe) + Text( + sender, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + Text(msg.body), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: TextField( + controller: _msgController, + decoration: const InputDecoration( + hintText: 'Type a message...', + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + onSubmitted: (_) => _send(), + ), + ), + const SizedBox(width: 4), + IconButton( + onPressed: _send, + icon: const Icon(Icons.send), + iconSize: 20, + ), + ], + ), + ], + ); + }, + loading: () => + const Center(child: CircularProgressIndicator(strokeWidth: 2)), + error: (e, _) => Text('Chat error: $e'), + ); + } + + Future _send() async { + final body = _msgController.text.trim(); + if (body.isEmpty) return; + _msgController.clear(); + await ref + .read(chatControllerProvider) + .sendMessage(threadId: widget.threadId, body: body); + } +} diff --git a/lib/services/background_location_service.dart b/lib/services/background_location_service.dart new file mode 100644 index 00000000..543edb01 --- /dev/null +++ b/lib/services/background_location_service.dart @@ -0,0 +1,75 @@ +import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:workmanager/workmanager.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +/// Unique task name for the background location update. +const _taskName = 'com.tasq.backgroundLocationUpdate'; + +/// Top-level callback required by Workmanager. Must be a top-level or static +/// function. +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask((task, inputData) async { + try { + // Re-initialize Supabase in the isolate + await dotenv.load(); + final url = dotenv.env['SUPABASE_URL'] ?? ''; + final anonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? ''; + if (url.isEmpty || anonKey.isEmpty) return Future.value(true); + + await Supabase.initialize(url: url, anonKey: anonKey); + final client = Supabase.instance.client; + + // Must have an active session + final session = client.auth.currentSession; + if (session == null) return Future.value(true); + + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return Future.value(true); + + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return Future.value(true); + } + + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + await client.rpc( + 'update_live_position', + params: {'p_lat': position.latitude, 'p_lng': position.longitude}, + ); + } catch (e) { + debugPrint('Background location update error: $e'); + } + return Future.value(true); + }); +} + +/// Initialize Workmanager and register periodic background location task. +Future initBackgroundLocationService() async { + await Workmanager().initialize(callbackDispatcher, isInDebugMode: false); +} + +/// Register a periodic task to report location every ~15 minutes +/// (Android minimum for periodic Workmanager tasks). +Future startBackgroundLocationUpdates() async { + await Workmanager().registerPeriodicTask( + _taskName, + _taskName, + frequency: const Duration(minutes: 15), + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingWorkPolicy.keep, + ); +} + +/// Cancel the periodic background location task. +Future stopBackgroundLocationUpdates() async { + await Workmanager().cancelByUniqueName(_taskName); +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 5f6672db..884e4f78 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -331,6 +331,7 @@ class NavSection { } List _buildSections(String role) { + final isStandard = role == 'standard'; final mainItems = [ NavItem( label: 'Dashboard', @@ -338,6 +339,20 @@ List _buildSections(String role) { icon: Icons.grid_view, selectedIcon: Icons.grid_view_rounded, ), + if (!isStandard) + NavItem( + label: 'Attendance', + route: '/attendance', + icon: Icons.fact_check_outlined, + selectedIcon: Icons.fact_check, + ), + if (!isStandard) + NavItem( + label: 'Whereabouts', + route: '/whereabouts', + icon: Icons.share_location_outlined, + selectedIcon: Icons.share_location, + ), NavItem( label: 'Tickets', route: '/tickets', diff --git a/lib/widgets/profile_avatar.dart b/lib/widgets/profile_avatar.dart index b71b1677..993ab4b7 100644 --- a/lib/widgets/profile_avatar.dart +++ b/lib/widgets/profile_avatar.dart @@ -61,7 +61,7 @@ class ProfileAvatar extends StatelessWidget { return CircleAvatar( radius: radius, backgroundImage: NetworkImage(avatarUrl!), - onBackgroundImageError: (_, __) { + onBackgroundImageError: (_, _) { // Silently fall back to initials if image fails }, child: null, // Image will display if loaded successfully diff --git a/pubspec.lock b/pubspec.lock index d161b2fb..77e2852e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -774,7 +774,7 @@ packages: source: hosted version: "4.5.4" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" @@ -1602,6 +1602,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: ed13530cccd28c5c9959ad42d657cd0666274ca74c56dea0ca183ddd527d3a00 + url: "https://pub.dev" + source: hosted + version: "0.5.2" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e55feab5..5520f291 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: fl_chart: ^0.70.2 google_generative_ai: ^0.4.0 http: ^1.2.0 + workmanager: ^0.5.2 + intl: ^0.20.2 dev_dependencies: flutter_test: