Initial Commit: Duty Schedule and Attendance Logbook

This commit is contained in:
Marc Rejohn Castillano 2026-03-07 10:16:28 +08:00
parent 73dc735cce
commit c6f536edeb
24 changed files with 3165 additions and 282 deletions

View File

@ -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<void> 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;

View File

@ -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<String, dynamic> json) {
return RamadanConfig(
enabled: json['enabled'] as bool? ?? false,
autoDetect: json['auto_detect'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() => {
'enabled': enabled,
'auto_detect': autoDetect,
};
}

View File

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

View File

@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'thread_id': threadId,
'sender_id': senderId,
'body': body,
};
}

View File

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

57
lib/models/pass_slip.dart Normal file
View File

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

View File

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

View File

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

View File

@ -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<ReportDateRange>((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<List<AttendanceLog>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) return Stream.value(const <AttendanceLog>[]);
final hasFullAccess =
profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
final wrapper = StreamRecoveryWrapper<AttendanceLog>(
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<AttendanceController>((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<String?> 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<void> 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},
);
}
}

View File

@ -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<List<ChatMessage>, String>((
ref,
threadId,
) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ChatMessage>(
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<ChatController>((ref) {
final client = ref.watch(supabaseClientProvider);
return ChatController(client);
});
class ChatController {
ChatController(this._client);
final SupabaseClient _client;
Future<void> 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,
});
}
}

View File

@ -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<List<PassSlip>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) return Stream.value(const <PassSlip>[]);
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher';
final wrapper = StreamRecoveryWrapper<PassSlip>(
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<PassSlip?>((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<List<PassSlip>>((ref) {
final slips = ref.watch(passSlipsProvider).valueOrNull ?? [];
return slips.where((s) => s.isActive).toList();
});
final passSlipControllerProvider = Provider<PassSlipController>((ref) {
final client = ref.watch(supabaseClientProvider);
return PassSlipController(client);
});
class PassSlipController {
PassSlipController(this._client);
final SupabaseClient _client;
Future<void> 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<void> 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<void> 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<void> completeSlip(String slipId) async {
await _client
.from('pass_slips')
.update({
'status': 'completed',
'slip_end': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', slipId);
}
}

View File

@ -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<RamadanConfig>((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<bool>((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<RamadanController>((ref) {
final client = ref.watch(supabaseClientProvider);
return RamadanController(client);
});
class RamadanController {
RamadanController(this._client);
final SupabaseClient _client;
Future<void> setEnabled(bool enabled) async {
final current = await _client
.from('app_settings')
.select()
.eq('key', 'ramadan_mode')
.maybeSingle();
final value = current != null
? Map<String, dynamic>.from(current['value'] as Map)
: <String, dynamic>{'auto_detect': true};
value['enabled'] = enabled;
await _client.from('app_settings').upsert({
'key': 'ramadan_mode',
'value': value,
});
}
Future<void> setAutoDetect(bool autoDetect) async {
final current = await _client
.from('app_settings')
.select()
.eq('key', 'ramadan_mode')
.maybeSingle();
final value = current != null
? Map<String, dynamic>.from(current['value'] as Map)
: <String, dynamic>{'enabled': false};
value['auto_detect'] = autoDetect;
await _client.from('app_settings').upsert({
'key': 'ramadan_mode',
'value': value,
});
}
}

View File

@ -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<List<LivePosition>>((ref) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<LivePosition>(
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<WhereaboutsController>((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<bool> 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<void> 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<LocationReportingService>((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<void> _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
}
}
}

View File

@ -21,6 +21,9 @@ final geofenceProvider = FutureProvider<GeofenceConfig?>((ref) async {
return GeofenceConfig.fromJson(setting.value);
});
/// Toggle to show/hide past schedules. Defaults to false (hide past).
final showPastSchedulesProvider = StateProvider<bool>((ref) => false);
final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
@ -29,24 +32,17 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
return Stream.value(const <DutySchedule>[]);
}
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher';
// All roles now see all schedules (RLS updated in migration)
final wrapper = StreamRecoveryWrapper<DutySchedule>(
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<void> 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');

View File

@ -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<GoRouter>((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<GoRouter>((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(

View File

@ -35,11 +35,19 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
'admin',
];
static const List<String> _religions = [
'catholic',
'islam',
'protestant',
'other',
];
final _fullNameController = TextEditingController();
final _searchController = TextEditingController();
String? _selectedUserId;
String? _selectedRole;
String _selectedReligion = 'catholic';
final Set<String> _selectedOfficeIds = {};
bool _isSaving = false;
@ -299,6 +307,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
setState(() {
_selectedUserId = profile.id;
_selectedRole = profile.role;
_selectedReligion = profile.religion;
_fullNameController.text = profile.fullName;
_selectedOfficeIds
..clear()
@ -345,6 +354,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
setState(() {
_selectedUserId = null;
_selectedRole = null;
_selectedReligion = 'catholic';
_selectedOfficeIds.clear();
_fullNameController.clear();
});
@ -377,6 +387,22 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
decoration: const InputDecoration(labelText: 'Role'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
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<UserManagementScreen> {
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);

File diff suppressed because it is too large Load Diff

View File

@ -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<WhereaboutsScreen> createState() => _WhereaboutsScreenState();
}
class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
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<String, Profile> 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<LivePosition> positions,
Map<String, Profile> 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 = <PolygonLayer>[];
if (geofenceConfig != null && geofenceConfig.hasPolygon) {
final List<LatLng> 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<LivePosition> positions,
Map<String, Profile> 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';
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<void> 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<void> 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<void> stopBackgroundLocationUpdates() async {
await Workmanager().cancelByUniqueName(_taskName);
}

View File

@ -331,6 +331,7 @@ class NavSection {
}
List<NavSection> _buildSections(String role) {
final isStandard = role == 'standard';
final mainItems = [
NavItem(
label: 'Dashboard',
@ -338,6 +339,20 @@ List<NavSection> _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',

View File

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

View File

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

View File

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