Initial Commit: Duty Schedule and Attendance Logbook
This commit is contained in:
parent
73dc735cce
commit
c6f536edeb
|
|
@ -16,6 +16,7 @@ import 'utils/app_time.dart';
|
||||||
import 'utils/notification_permission.dart';
|
import 'utils/notification_permission.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/notification_bridge.dart';
|
import 'services/notification_bridge.dart';
|
||||||
|
import 'services/background_location_service.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
@ -255,6 +256,11 @@ Future<void> main() async {
|
||||||
|
|
||||||
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);
|
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.
|
// ensure token saved shortly after startup if already signed in.
|
||||||
// Run this after runApp so startup is not blocked by network/token ops.
|
// Run this after runApp so startup is not blocked by network/token ops.
|
||||||
final supaClient = Supabase.instance.client;
|
final supaClient = Supabase.instance.client;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
43
lib/models/attendance_log.dart
Normal file
43
lib/models/attendance_log.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
lib/models/chat_message.dart
Normal file
33
lib/models/chat_message.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
27
lib/models/live_position.dart
Normal file
27
lib/models/live_position.dart
Normal 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
57
lib/models/pass_slip.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
class Profile {
|
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 id;
|
||||||
final String role;
|
final String role;
|
||||||
final String fullName;
|
final String fullName;
|
||||||
|
final String religion;
|
||||||
|
final bool allowTracking;
|
||||||
|
|
||||||
factory Profile.fromMap(Map<String, dynamic> map) {
|
factory Profile.fromMap(Map<String, dynamic> map) {
|
||||||
return Profile(
|
return Profile(
|
||||||
id: map['id'] as String,
|
id: map['id'] as String,
|
||||||
role: map['role'] as String? ?? 'standard',
|
role: map['role'] as String? ?? 'standard',
|
||||||
fullName: map['full_name'] as String? ?? '',
|
fullName: map['full_name'] as String? ?? '',
|
||||||
|
religion: map['religion'] as String? ?? 'catholic',
|
||||||
|
allowTracking: map['allow_tracking'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,11 @@ class AdminUserController {
|
||||||
required String userId,
|
required String userId,
|
||||||
required String fullName,
|
required String fullName,
|
||||||
required String role,
|
required String role,
|
||||||
|
String religion = 'catholic',
|
||||||
}) async {
|
}) async {
|
||||||
await _client
|
await _client
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({'full_name': fullName, 'role': role})
|
.update({'full_name': fullName, 'role': role, 'religion': religion})
|
||||||
.eq('id', userId);
|
.eq('id', userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
98
lib/providers/attendance_provider.dart
Normal file
98
lib/providers/attendance_provider.dart
Normal 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},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/providers/chat_provider.dart
Normal file
61
lib/providers/chat_provider.dart
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
123
lib/providers/pass_slip_provider.dart
Normal file
123
lib/providers/pass_slip_provider.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/providers/ramadan_provider.dart
Normal file
106
lib/providers/ramadan_provider.dart
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
143
lib/providers/whereabouts_provider.dart
Normal file
143
lib/providers/whereabouts_provider.dart
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,9 @@ final geofenceProvider = FutureProvider<GeofenceConfig?>((ref) async {
|
||||||
return GeofenceConfig.fromJson(setting.value);
|
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 dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
final profileAsync = ref.watch(currentProfileProvider);
|
final profileAsync = ref.watch(currentProfileProvider);
|
||||||
|
|
@ -29,24 +32,17 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
|
||||||
return Stream.value(const <DutySchedule>[]);
|
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>(
|
final wrapper = StreamRecoveryWrapper<DutySchedule>(
|
||||||
stream: isAdmin
|
stream: client
|
||||||
? client
|
|
||||||
.from('duty_schedules')
|
.from('duty_schedules')
|
||||||
.stream(primaryKey: ['id'])
|
.stream(primaryKey: ['id'])
|
||||||
.order('start_time')
|
|
||||||
: client
|
|
||||||
.from('duty_schedules')
|
|
||||||
.stream(primaryKey: ['id'])
|
|
||||||
.eq('user_id', profile.id)
|
|
||||||
.order('start_time'),
|
.order('start_time'),
|
||||||
onPollData: () async {
|
onPollData: () async {
|
||||||
final query = client.from('duty_schedules').select();
|
final data = await client
|
||||||
final data = isAdmin
|
.from('duty_schedules')
|
||||||
? await query.order('start_time')
|
.select()
|
||||||
: await query.eq('user_id', profile.id).order('start_time');
|
.order('start_time');
|
||||||
return data.map(DutySchedule.fromMap).toList();
|
return data.map(DutySchedule.fromMap).toList();
|
||||||
},
|
},
|
||||||
fromMap: DutySchedule.fromMap,
|
fromMap: DutySchedule.fromMap,
|
||||||
|
|
@ -223,6 +219,24 @@ class WorkforceController {
|
||||||
.eq('id', swapId);
|
.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) {
|
String _formatDate(DateTime value) {
|
||||||
final date = DateTime(value.year, value.month, value.day);
|
final date = DateTime(value.year, value.month, value.day);
|
||||||
final month = date.month.toString().padLeft(2, '0');
|
final month = date.month.toString().padLeft(2, '0');
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import '../screens/tasks/tasks_list_screen.dart';
|
||||||
import '../screens/tickets/ticket_detail_screen.dart';
|
import '../screens/tickets/ticket_detail_screen.dart';
|
||||||
import '../screens/tickets/tickets_list_screen.dart';
|
import '../screens/tickets/tickets_list_screen.dart';
|
||||||
import '../screens/workforce/workforce_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 '../widgets/app_shell.dart';
|
||||||
import '../screens/teams/teams_screen.dart';
|
import '../screens/teams/teams_screen.dart';
|
||||||
import '../theme/m3_motion.dart';
|
import '../theme/m3_motion.dart';
|
||||||
|
|
@ -67,6 +69,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
if (isReportsRoute && !hasReportsAccess) {
|
if (isReportsRoute && !hasReportsAccess) {
|
||||||
return '/tickets';
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
|
@ -157,6 +167,20 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
child: const WorkforceScreen(),
|
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(
|
GoRoute(
|
||||||
path: '/reports',
|
path: '/reports',
|
||||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,19 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
'admin',
|
'admin',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static const List<String> _religions = [
|
||||||
|
'catholic',
|
||||||
|
'islam',
|
||||||
|
'protestant',
|
||||||
|
'other',
|
||||||
|
];
|
||||||
|
|
||||||
final _fullNameController = TextEditingController();
|
final _fullNameController = TextEditingController();
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
|
|
||||||
String? _selectedUserId;
|
String? _selectedUserId;
|
||||||
String? _selectedRole;
|
String? _selectedRole;
|
||||||
|
String _selectedReligion = 'catholic';
|
||||||
final Set<String> _selectedOfficeIds = {};
|
final Set<String> _selectedOfficeIds = {};
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
|
@ -299,6 +307,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedUserId = profile.id;
|
_selectedUserId = profile.id;
|
||||||
_selectedRole = profile.role;
|
_selectedRole = profile.role;
|
||||||
|
_selectedReligion = profile.religion;
|
||||||
_fullNameController.text = profile.fullName;
|
_fullNameController.text = profile.fullName;
|
||||||
_selectedOfficeIds
|
_selectedOfficeIds
|
||||||
..clear()
|
..clear()
|
||||||
|
|
@ -345,6 +354,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedUserId = null;
|
_selectedUserId = null;
|
||||||
_selectedRole = null;
|
_selectedRole = null;
|
||||||
|
_selectedReligion = 'catholic';
|
||||||
_selectedOfficeIds.clear();
|
_selectedOfficeIds.clear();
|
||||||
_fullNameController.clear();
|
_fullNameController.clear();
|
||||||
});
|
});
|
||||||
|
|
@ -377,6 +387,22 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
decoration: const InputDecoration(labelText: 'Role'),
|
decoration: const InputDecoration(labelText: 'Role'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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.
|
// Email and lock status are retrieved from auth via Edge Function / admin API.
|
||||||
Consumer(
|
Consumer(
|
||||||
|
|
@ -529,7 +555,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
try {
|
try {
|
||||||
await ref
|
await ref
|
||||||
.read(adminUserControllerProvider)
|
.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 toAdd = _selectedOfficeIds.difference(currentOfficeIds);
|
||||||
final toRemove = currentOfficeIds.difference(_selectedOfficeIds);
|
final toRemove = currentOfficeIds.difference(_selectedOfficeIds);
|
||||||
|
|
|
||||||
1444
lib/screens/attendance/attendance_screen.dart
Normal file
1444
lib/screens/attendance/attendance_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
229
lib/screens/whereabouts/whereabouts_screen.dart
Normal file
229
lib/screens/whereabouts/whereabouts_screen.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../theme/m3_motion.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:tasq/utils/app_time.dart';
|
import 'package:tasq/utils/app_time.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
import 'package:timezone/timezone.dart' as tz;
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
|
||||||
import '../../models/duty_schedule.dart';
|
import '../../models/duty_schedule.dart';
|
||||||
import '../../models/profile.dart';
|
import '../../models/profile.dart';
|
||||||
import '../../models/swap_request.dart';
|
import '../../models/swap_request.dart';
|
||||||
|
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/workforce_provider.dart';
|
import '../../providers/workforce_provider.dart';
|
||||||
|
import '../../providers/chat_provider.dart';
|
||||||
|
import '../../providers/ramadan_provider.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
|
@ -94,9 +94,42 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
||||||
final profilesAsync = ref.watch(profilesProvider);
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
final currentUserId = ref.watch(currentUserIdProvider);
|
final currentUserId = ref.watch(currentUserIdProvider);
|
||||||
|
final showPast = ref.watch(showPastSchedulesProvider);
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
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();
|
||||||
|
|
||||||
return schedulesAsync.when(
|
|
||||||
data: (schedules) {
|
|
||||||
if (schedules.isEmpty) {
|
if (schedules.isEmpty) {
|
||||||
return const Center(child: Text('No schedules yet.'));
|
return const Center(child: Text('No schedules yet.'));
|
||||||
}
|
}
|
||||||
|
|
@ -130,10 +163,9 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppTime.formatDate(day),
|
'${_dayOfWeek(day)}, ${AppTime.formatDate(day)}',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
fontWeight: FontWeight.w700,
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
...items.map(
|
...items.map(
|
||||||
|
|
@ -149,6 +181,7 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
profileById,
|
profileById,
|
||||||
),
|
),
|
||||||
isMine: schedule.userId == currentUserId,
|
isMine: schedule.userId == currentUserId,
|
||||||
|
isAdmin: isAdmin,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -160,6 +193,9 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) =>
|
error: (error, _) =>
|
||||||
Center(child: Text('Failed to load schedules: $error')),
|
Center(child: Text('Failed to load schedules: $error')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,9 +204,6 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
DutySchedule schedule,
|
DutySchedule schedule,
|
||||||
bool isAdmin,
|
bool isAdmin,
|
||||||
) {
|
) {
|
||||||
if (!isAdmin) {
|
|
||||||
return _shiftLabel(schedule.shiftType);
|
|
||||||
}
|
|
||||||
final profile = profileById[schedule.userId];
|
final profile = profileById[schedule.userId];
|
||||||
final name = profile?.fullName.isNotEmpty == true
|
final name = profile?.fullName.isNotEmpty == true
|
||||||
? profile!.fullName
|
? profile!.fullName
|
||||||
|
|
@ -178,6 +211,11 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
return '${_shiftLabel(schedule.shiftType)} · $name';
|
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) {
|
String _shiftLabel(String value) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'am':
|
case 'am':
|
||||||
|
|
@ -216,12 +254,14 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
required this.relieverLabels,
|
required this.relieverLabels,
|
||||||
required this.isMine,
|
required this.isMine,
|
||||||
|
required this.isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DutySchedule schedule;
|
final DutySchedule schedule;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final List<String> relieverLabels;
|
final List<String> relieverLabels;
|
||||||
final bool isMine;
|
final bool isMine;
|
||||||
|
final bool isAdmin;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
@ -229,12 +269,6 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? [];
|
final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? [];
|
||||||
final now = AppTime.now();
|
final now = AppTime.now();
|
||||||
final isPast = schedule.startTime.isBefore(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(
|
final hasRequestedSwap = swaps.any(
|
||||||
(swap) =>
|
(swap) =>
|
||||||
swap.requesterScheduleId == schedule.id &&
|
swap.requesterScheduleId == schedule.id &&
|
||||||
|
|
@ -278,14 +312,13 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
if (canCheckIn)
|
if (isAdmin)
|
||||||
FilledButton.icon(
|
IconButton(
|
||||||
onPressed: () => _handleCheckIn(context, ref, schedule),
|
tooltip: 'Edit schedule',
|
||||||
icon: const Icon(Icons.location_on),
|
onPressed: () => _editSchedule(context, ref),
|
||||||
label: const Text('Check in'),
|
icon: const Icon(Icons.edit, size: 20),
|
||||||
),
|
),
|
||||||
if (canRequestSwap) ...[
|
if (canRequestSwap)
|
||||||
if (canCheckIn) const SizedBox(height: 8),
|
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: hasRequestedSwap
|
onPressed: hasRequestedSwap
|
||||||
? () => _openSwapsTab(context)
|
? () => _openSwapsTab(context)
|
||||||
|
|
@ -296,7 +329,6 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -325,106 +357,187 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleCheckIn(
|
Future<void> _editSchedule(BuildContext context, WidgetRef ref) async {
|
||||||
BuildContext context,
|
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
|
||||||
WidgetRef ref,
|
final staff =
|
||||||
DutySchedule schedule,
|
profiles
|
||||||
) async {
|
.where(
|
||||||
final geofence = await ref.read(geofenceProvider.future);
|
(p) =>
|
||||||
if (geofence == null) {
|
p.role == 'it_staff' ||
|
||||||
if (!context.mounted) return;
|
p.role == 'admin' ||
|
||||||
await _showAlert(
|
p.role == 'dispatcher',
|
||||||
context,
|
)
|
||||||
title: 'Geofence missing',
|
.toList()
|
||||||
message: 'Geofence is not configured.',
|
..sort((a, b) => a.fullName.compareTo(b.fullName));
|
||||||
);
|
if (staff.isEmpty) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
var selectedUserId = schedule.userId;
|
||||||
if (!serviceEnabled) {
|
var selectedShift = schedule.shiftType;
|
||||||
if (!context.mounted) return;
|
var selectedDate = DateTime(
|
||||||
await _showAlert(
|
schedule.startTime.year,
|
||||||
context,
|
schedule.startTime.month,
|
||||||
title: 'Location disabled',
|
schedule.startTime.day,
|
||||||
message: 'Location services are disabled.',
|
|
||||||
);
|
);
|
||||||
return;
|
var startTime = TimeOfDay.fromDateTime(schedule.startTime);
|
||||||
}
|
var endTime = TimeOfDay.fromDateTime(schedule.endTime);
|
||||||
|
|
||||||
var permission = await Geolocator.checkPermission();
|
final confirmed = await m3ShowDialog<bool>(
|
||||||
if (permission == LocationPermission.denied) {
|
context: context,
|
||||||
permission = await Geolocator.requestPermission();
|
builder: (dialogContext) {
|
||||||
}
|
return StatefulBuilder(
|
||||||
if (permission == LocationPermission.denied ||
|
builder: (context, setState) {
|
||||||
permission == LocationPermission.deniedForever) {
|
return AlertDialog(
|
||||||
if (!context.mounted) return;
|
shape: AppSurfaces.of(context).dialogShape,
|
||||||
await _showAlert(
|
title: const Text('Edit Schedule'),
|
||||||
context,
|
content: SingleChildScrollView(
|
||||||
title: 'Permission denied',
|
child: Column(
|
||||||
message: 'Location permission denied.',
|
mainAxisSize: MainAxisSize.min,
|
||||||
);
|
children: [
|
||||||
return;
|
DropdownButtonFormField<String>(
|
||||||
}
|
initialValue: selectedUserId,
|
||||||
if (!context.mounted) return;
|
items: [
|
||||||
final progressContext = await _showCheckInProgress(context);
|
for (final p in staff)
|
||||||
try {
|
DropdownMenuItem(
|
||||||
final position = await Geolocator.getCurrentPosition(
|
value: p.id,
|
||||||
locationSettings: const LocationSettings(
|
child: Text(
|
||||||
accuracy: LocationAccuracy.high,
|
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<String>(
|
||||||
|
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 (!geofence.hasPolygon) {
|
if (confirmed != true || !context.mounted) return;
|
||||||
if (!context.mounted) return;
|
|
||||||
await _showAlert(
|
final startDateTime = DateTime(
|
||||||
context,
|
selectedDate.year,
|
||||||
title: 'Geofence missing',
|
selectedDate.month,
|
||||||
message: 'Geofence polygon is not configured.',
|
selectedDate.day,
|
||||||
|
startTime.hour,
|
||||||
|
startTime.minute,
|
||||||
);
|
);
|
||||||
return;
|
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 isInside = geofence.containsPolygon(
|
try {
|
||||||
position.latitude,
|
await ref
|
||||||
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
|
|
||||||
.read(workforceControllerProvider)
|
.read(workforceControllerProvider)
|
||||||
.checkIn(
|
.updateSchedule(
|
||||||
dutyScheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
lat: position.latitude,
|
userId: selectedUserId,
|
||||||
lng: position.longitude,
|
shiftType: selectedShift,
|
||||||
|
startTime: startDateTime,
|
||||||
|
endTime: endDateTime,
|
||||||
);
|
);
|
||||||
ref.invalidate(dutySchedulesProvider);
|
ref.invalidate(dutySchedulesProvider);
|
||||||
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await _showAlert(
|
showErrorSnackBar(context, 'Update failed: $e');
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -610,62 +723,6 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showAlert(
|
|
||||||
BuildContext context, {
|
|
||||||
required String title,
|
|
||||||
required String message,
|
|
||||||
}) async {
|
|
||||||
await m3ShowDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text(title),
|
|
||||||
content: Text(message),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
||||||
child: const Text('OK'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<BuildContext> _showCheckInProgress(BuildContext context) {
|
|
||||||
final completer = Completer<BuildContext>();
|
|
||||||
m3ShowDialog<void>(
|
|
||||||
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) {
|
void _openSwapsTab(BuildContext context) {
|
||||||
final controller = DefaultTabController.maybeOf(context);
|
final controller = DefaultTabController.maybeOf(context);
|
||||||
if (controller != null) {
|
if (controller != null) {
|
||||||
|
|
@ -789,17 +846,36 @@ class _ScheduleGeneratorPanelState
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isRamadan = ref.watch(isRamadanActiveProvider);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Schedule Generator',
|
'Schedule Generator',
|
||||||
style: Theme.of(
|
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,
|
context,
|
||||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
).colorScheme.tertiaryContainer,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_dateField(
|
_dateField(
|
||||||
|
|
@ -925,7 +1001,7 @@ class _ScheduleGeneratorPanelState
|
||||||
final staff = _sortedStaff();
|
final staff = _sortedStaff();
|
||||||
if (staff.isEmpty) {
|
if (staff.isEmpty) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showWarningSnackBar(context, 'No IT staff available for scheduling.');
|
showWarningSnackBar(context, 'No staff available for scheduling.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1176,6 +1252,10 @@ class _ScheduleGeneratorPanelState
|
||||||
initialValue: selectedShift,
|
initialValue: selectedShift,
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem(value: 'am', child: Text('AM Duty')),
|
DropdownMenuItem(value: 'am', child: Text('AM Duty')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'normal',
|
||||||
|
child: Text('Normal Duty'),
|
||||||
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'on_call',
|
value: 'on_call',
|
||||||
child: Text('On Call'),
|
child: Text('On Call'),
|
||||||
|
|
@ -1381,7 +1461,12 @@ class _ScheduleGeneratorPanelState
|
||||||
List<Profile> _sortedStaff() {
|
List<Profile> _sortedStaff() {
|
||||||
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
|
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
|
||||||
final staff = profiles
|
final staff = profiles
|
||||||
.where((profile) => profile.role == 'it_staff')
|
.where(
|
||||||
|
(profile) =>
|
||||||
|
profile.role == 'it_staff' ||
|
||||||
|
profile.role == 'admin' ||
|
||||||
|
profile.role == 'dispatcher',
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
staff.sort((a, b) {
|
staff.sort((a, b) {
|
||||||
final nameA = a.fullName.isNotEmpty ? a.fullName : a.id;
|
final nameA = a.fullName.isNotEmpty ? a.fullName : a.id;
|
||||||
|
|
@ -1429,11 +1514,24 @@ class _ScheduleGeneratorPanelState
|
||||||
startMinute: 0,
|
startMinute: 0,
|
||||||
duration: const Duration(hours: 8),
|
duration: const Duration(hours: 8),
|
||||||
);
|
);
|
||||||
|
// Default normal shift (8am-5pm = 9 hours)
|
||||||
templates['normal'] = _ShiftTemplate(
|
templates['normal'] = _ShiftTemplate(
|
||||||
startHour: 8,
|
startHour: 8,
|
||||||
startMinute: 0,
|
startMinute: 0,
|
||||||
duration: const Duration(hours: 9),
|
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;
|
return templates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1448,6 +1546,12 @@ class _ScheduleGeneratorPanelState
|
||||||
final normalizedStart = DateTime(start.year, start.month, start.day);
|
final normalizedStart = DateTime(start.year, start.month, start.day);
|
||||||
final normalizedEnd = DateTime(end.year, end.month, end.day);
|
final normalizedEnd = DateTime(end.year, end.month, end.day);
|
||||||
final existing = schedules;
|
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);
|
var weekStart = _startOfWeek(normalizedStart);
|
||||||
|
|
||||||
while (!weekStart.isAfter(normalizedEnd)) {
|
while (!weekStart.isAfter(normalizedEnd)) {
|
||||||
|
|
@ -1483,31 +1587,32 @@ class _ScheduleGeneratorPanelState
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Rotation indices only for IT Staff
|
||||||
final amBaseIndex = _nextIndexFromLastWeek(
|
final amBaseIndex = _nextIndexFromLastWeek(
|
||||||
shiftType: 'am',
|
shiftType: 'am',
|
||||||
staff: staff,
|
staff: itStaff,
|
||||||
lastWeek: lastWeek,
|
lastWeek: lastWeek,
|
||||||
defaultIndex: 0,
|
defaultIndex: 0,
|
||||||
);
|
);
|
||||||
final pmBaseIndex = _nextIndexFromLastWeek(
|
final pmBaseIndex = _nextIndexFromLastWeek(
|
||||||
shiftType: 'pm',
|
shiftType: 'pm',
|
||||||
staff: staff,
|
staff: itStaff,
|
||||||
lastWeek: lastWeek,
|
lastWeek: lastWeek,
|
||||||
defaultIndex: staff.length > 1 ? 1 : 0,
|
defaultIndex: itStaff.length > 1 ? 1 : 0,
|
||||||
);
|
);
|
||||||
final amUserId = staff.isEmpty
|
final amUserId = itStaff.isEmpty
|
||||||
? null
|
? null
|
||||||
: staff[amBaseIndex % staff.length].id;
|
: itStaff[amBaseIndex % itStaff.length].id;
|
||||||
final pmUserId = staff.isEmpty
|
final pmUserId = itStaff.isEmpty
|
||||||
? null
|
? null
|
||||||
: staff[pmBaseIndex % staff.length].id;
|
: itStaff[pmBaseIndex % itStaff.length].id;
|
||||||
final nextWeekPmUserId = staff.isEmpty
|
final nextWeekPmUserId = itStaff.isEmpty
|
||||||
? null
|
? null
|
||||||
: staff[(pmBaseIndex + 1) % staff.length].id;
|
: itStaff[(pmBaseIndex + 1) % itStaff.length].id;
|
||||||
final pmRelievers = _buildRelievers(pmBaseIndex, staff);
|
final pmRelievers = _buildRelievers(pmBaseIndex, itStaff);
|
||||||
final nextWeekRelievers = staff.isEmpty
|
final nextWeekRelievers = itStaff.isEmpty
|
||||||
? <String>[]
|
? <String>[]
|
||||||
: _buildRelievers((pmBaseIndex + 1) % staff.length, staff);
|
: _buildRelievers((pmBaseIndex + 1) % itStaff.length, itStaff);
|
||||||
var weekendNormalOffset = 0;
|
var weekendNormalOffset = 0;
|
||||||
|
|
||||||
for (
|
for (
|
||||||
|
|
@ -1520,17 +1625,20 @@ class _ScheduleGeneratorPanelState
|
||||||
}
|
}
|
||||||
final isWeekend =
|
final isWeekend =
|
||||||
day.weekday == DateTime.saturday || day.weekday == DateTime.sunday;
|
day.weekday == DateTime.saturday || day.weekday == DateTime.sunday;
|
||||||
|
final dayIsRamadan = isApproximateRamadan(day);
|
||||||
|
|
||||||
if (isWeekend) {
|
if (isWeekend) {
|
||||||
if (staff.isNotEmpty) {
|
// Weekend: only IT Staff get normal + on_call (rotating)
|
||||||
|
if (itStaff.isNotEmpty) {
|
||||||
final normalIndex =
|
final normalIndex =
|
||||||
(amBaseIndex + pmBaseIndex + weekendNormalOffset) %
|
(amBaseIndex + pmBaseIndex + weekendNormalOffset) %
|
||||||
staff.length;
|
itStaff.length;
|
||||||
_tryAddDraft(
|
_tryAddDraft(
|
||||||
draft,
|
draft,
|
||||||
existing,
|
existing,
|
||||||
templates,
|
templates,
|
||||||
'normal',
|
'normal',
|
||||||
staff[normalIndex].id,
|
itStaff[normalIndex].id,
|
||||||
day,
|
day,
|
||||||
const [],
|
const [],
|
||||||
);
|
);
|
||||||
|
|
@ -1548,7 +1656,14 @@ class _ScheduleGeneratorPanelState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} 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(
|
_tryAddDraft(
|
||||||
draft,
|
draft,
|
||||||
existing,
|
existing,
|
||||||
|
|
@ -1580,20 +1695,46 @@ class _ScheduleGeneratorPanelState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remaining IT Staff get normal shift
|
||||||
final assignedToday = <String?>[
|
final assignedToday = <String?>[
|
||||||
amUserId,
|
amUserId,
|
||||||
pmUserId,
|
pmUserId,
|
||||||
].whereType<String>().toSet();
|
].whereType<String>().toSet();
|
||||||
for (final profile in staff) {
|
for (final profile in itStaff) {
|
||||||
if (assignedToday.contains(profile.id)) continue;
|
if (assignedToday.contains(profile.id)) continue;
|
||||||
|
final normalKey = dayIsRamadan && profile.religion == 'islam'
|
||||||
|
? 'normal_ramadan_islam'
|
||||||
|
: dayIsRamadan
|
||||||
|
? 'normal_ramadan_other'
|
||||||
|
: 'normal';
|
||||||
_tryAddDraft(
|
_tryAddDraft(
|
||||||
draft,
|
draft,
|
||||||
existing,
|
existing,
|
||||||
templates,
|
templates,
|
||||||
'normal',
|
normalKey,
|
||||||
profile.id,
|
profile.id,
|
||||||
day,
|
day,
|
||||||
const [],
|
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 shiftType,
|
||||||
String userId,
|
String userId,
|
||||||
DateTime day,
|
DateTime day,
|
||||||
List<String> relieverIds,
|
List<String> relieverIds, {
|
||||||
) {
|
String? displayShiftType,
|
||||||
|
}) {
|
||||||
final template = templates[_normalizeShiftType(shiftType)]!;
|
final template = templates[_normalizeShiftType(shiftType)]!;
|
||||||
final start = template.buildStart(day);
|
final start = template.buildStart(day);
|
||||||
final end = template.buildEnd(start);
|
final end = template.buildEnd(start);
|
||||||
final candidate = _DraftSchedule(
|
final candidate = _DraftSchedule(
|
||||||
localId: _draftCounter++,
|
localId: _draftCounter++,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
shiftType: shiftType,
|
shiftType: displayShiftType ?? shiftType,
|
||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: end,
|
endTime: end,
|
||||||
relieverIds: relieverIds,
|
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();
|
.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<String, Profile> 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<void> _send() async {
|
||||||
|
final body = _msgController.text.trim();
|
||||||
|
if (body.isEmpty) return;
|
||||||
|
_msgController.clear();
|
||||||
|
await ref
|
||||||
|
.read(chatControllerProvider)
|
||||||
|
.sendMessage(threadId: widget.threadId, body: body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
75
lib/services/background_location_service.dart
Normal file
75
lib/services/background_location_service.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -331,6 +331,7 @@ class NavSection {
|
||||||
}
|
}
|
||||||
|
|
||||||
List<NavSection> _buildSections(String role) {
|
List<NavSection> _buildSections(String role) {
|
||||||
|
final isStandard = role == 'standard';
|
||||||
final mainItems = [
|
final mainItems = [
|
||||||
NavItem(
|
NavItem(
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
|
|
@ -338,6 +339,20 @@ List<NavSection> _buildSections(String role) {
|
||||||
icon: Icons.grid_view,
|
icon: Icons.grid_view,
|
||||||
selectedIcon: Icons.grid_view_rounded,
|
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(
|
NavItem(
|
||||||
label: 'Tickets',
|
label: 'Tickets',
|
||||||
route: '/tickets',
|
route: '/tickets',
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class ProfileAvatar extends StatelessWidget {
|
||||||
return CircleAvatar(
|
return CircleAvatar(
|
||||||
radius: radius,
|
radius: radius,
|
||||||
backgroundImage: NetworkImage(avatarUrl!),
|
backgroundImage: NetworkImage(avatarUrl!),
|
||||||
onBackgroundImageError: (_, __) {
|
onBackgroundImageError: (_, _) {
|
||||||
// Silently fall back to initials if image fails
|
// Silently fall back to initials if image fails
|
||||||
},
|
},
|
||||||
child: null, // Image will display if loaded successfully
|
child: null, // Image will display if loaded successfully
|
||||||
|
|
|
||||||
10
pubspec.lock
10
pubspec.lock
|
|
@ -774,7 +774,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.4"
|
version: "4.5.4"
|
||||||
intl:
|
intl:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
|
|
@ -1602,6 +1602,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ dependencies:
|
||||||
fl_chart: ^0.70.2
|
fl_chart: ^0.70.2
|
||||||
google_generative_ai: ^0.4.0
|
google_generative_ai: ^0.4.0
|
||||||
http: ^1.2.0
|
http: ^1.2.0
|
||||||
|
workmanager: ^0.5.2
|
||||||
|
intl: ^0.20.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user