Worklog, anti geo spoofing and UI Polish
This commit is contained in:
@@ -181,6 +181,8 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
|||||||
'p_duty_id': params['p_duty_id'],
|
'p_duty_id': params['p_duty_id'],
|
||||||
'p_lat': params['p_lat'],
|
'p_lat': params['p_lat'],
|
||||||
'p_lng': params['p_lng'],
|
'p_lng': params['p_lng'],
|
||||||
|
'p_accuracy': params['p_accuracy'],
|
||||||
|
'p_is_mocked': params['p_is_mocked'],
|
||||||
},
|
},
|
||||||
) as String?;
|
) as String?;
|
||||||
syncedLocalIds.add(localId);
|
syncedLocalIds.add(localId);
|
||||||
@@ -273,6 +275,8 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
|||||||
'p_lat': fields['check_out_lat'],
|
'p_lat': fields['check_out_lat'],
|
||||||
'p_lng': fields['check_out_lng'],
|
'p_lng': fields['check_out_lng'],
|
||||||
'p_justification': fields['check_out_justification'],
|
'p_justification': fields['check_out_justification'],
|
||||||
|
'p_accuracy': fields['check_out_accuracy'],
|
||||||
|
'p_is_mocked': fields['check_out_is_mocked'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
syncedIds.add(attendanceId);
|
syncedIds.add(attendanceId);
|
||||||
@@ -390,12 +394,20 @@ class AttendanceController {
|
|||||||
required String dutyScheduleId,
|
required String dutyScheduleId,
|
||||||
required double lat,
|
required double lat,
|
||||||
required double lng,
|
required double lng,
|
||||||
|
double accuracy = 0,
|
||||||
|
bool isMocked = false,
|
||||||
String shiftType = 'normal',
|
String shiftType = 'normal',
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final data = await _client.rpc(
|
final data = await _client.rpc(
|
||||||
'attendance_check_in',
|
'attendance_check_in',
|
||||||
params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng},
|
params: {
|
||||||
|
'p_duty_id': dutyScheduleId,
|
||||||
|
'p_lat': lat,
|
||||||
|
'p_lng': lng,
|
||||||
|
'p_accuracy': accuracy,
|
||||||
|
'p_is_mocked': isMocked,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return data as String?;
|
return data as String?;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -418,7 +430,14 @@ class AttendanceController {
|
|||||||
|
|
||||||
_queuePendingCheckIn(
|
_queuePendingCheckIn(
|
||||||
log,
|
log,
|
||||||
{'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng, 'localId': id},
|
{
|
||||||
|
'p_duty_id': dutyScheduleId,
|
||||||
|
'p_lat': lat,
|
||||||
|
'p_lng': lng,
|
||||||
|
'p_accuracy': accuracy,
|
||||||
|
'p_is_mocked': isMocked,
|
||||||
|
'localId': id,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
debugPrint('[AttendanceController] checkIn queued offline: $id');
|
debugPrint('[AttendanceController] checkIn queued offline: $id');
|
||||||
return id;
|
return id;
|
||||||
@@ -431,6 +450,8 @@ class AttendanceController {
|
|||||||
required double lat,
|
required double lat,
|
||||||
required double lng,
|
required double lng,
|
||||||
String? justification,
|
String? justification,
|
||||||
|
double accuracy = 0,
|
||||||
|
bool isMocked = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await _client.rpc(
|
await _client.rpc(
|
||||||
@@ -440,6 +461,8 @@ class AttendanceController {
|
|||||||
'p_lat': lat,
|
'p_lat': lat,
|
||||||
'p_lng': lng,
|
'p_lng': lng,
|
||||||
'p_justification': justification,
|
'p_justification': justification,
|
||||||
|
'p_accuracy': accuracy,
|
||||||
|
'p_is_mocked': isMocked,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -451,6 +474,8 @@ class AttendanceController {
|
|||||||
'check_out_lat': lat,
|
'check_out_lat': lat,
|
||||||
'check_out_lng': lng,
|
'check_out_lng': lng,
|
||||||
'check_out_justification': justification,
|
'check_out_justification': justification,
|
||||||
|
'check_out_accuracy': accuracy,
|
||||||
|
'check_out_is_mocked': isMocked,
|
||||||
});
|
});
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[AttendanceController] checkOut queued offline: $attendanceId',
|
'[AttendanceController] checkOut queued offline: $attendanceId',
|
||||||
@@ -463,10 +488,18 @@ class AttendanceController {
|
|||||||
required double lat,
|
required double lat,
|
||||||
required double lng,
|
required double lng,
|
||||||
String? justification,
|
String? justification,
|
||||||
|
double accuracy = 0,
|
||||||
|
bool isMocked = false,
|
||||||
}) async {
|
}) async {
|
||||||
final data = await _client.rpc(
|
final data = await _client.rpc(
|
||||||
'overtime_check_in',
|
'overtime_check_in',
|
||||||
params: {'p_lat': lat, 'p_lng': lng, 'p_justification': justification},
|
params: {
|
||||||
|
'p_lat': lat,
|
||||||
|
'p_lng': lng,
|
||||||
|
'p_justification': justification,
|
||||||
|
'p_accuracy': accuracy,
|
||||||
|
'p_is_mocked': isMocked,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return data as String?;
|
return data as String?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,21 @@ class ChatController {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!isOfflineSaveError(e)) rethrow;
|
if (!isOfflineSaveError(e)) rethrow;
|
||||||
|
|
||||||
|
final ref = _ref;
|
||||||
|
if (ref != null) {
|
||||||
|
final existing =
|
||||||
|
ref.read(offlinePendingChatMessagesProvider)[threadId] ?? [];
|
||||||
|
// Idempotency guard: suppress if the same body was queued within the
|
||||||
|
// last 10 s — prevents duplicate messages from rapid taps while offline.
|
||||||
|
final cutoff = AppTime.now().subtract(const Duration(seconds: 10));
|
||||||
|
if (existing.any((m) => m.body == body && m.createdAt.isAfter(cutoff))) {
|
||||||
|
debugPrint(
|
||||||
|
'[ChatController] duplicate offline message suppressed for thread=$threadId',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final message = ChatMessage(
|
final message = ChatMessage(
|
||||||
id: id,
|
id: id,
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
@@ -171,13 +186,13 @@ class ChatController {
|
|||||||
createdAt: AppTime.now(),
|
createdAt: AppTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
final ref = _ref;
|
final ref2 = _ref;
|
||||||
if (ref != null) {
|
if (ref2 != null) {
|
||||||
final current = Map<String, List<ChatMessage>>.from(
|
final current = Map<String, List<ChatMessage>>.from(
|
||||||
ref.read(offlinePendingChatMessagesProvider),
|
ref2.read(offlinePendingChatMessagesProvider),
|
||||||
);
|
);
|
||||||
current[threadId] = [...(current[threadId] ?? []), message];
|
current[threadId] = [...(current[threadId] ?? []), message];
|
||||||
ref.read(offlinePendingChatMessagesProvider.notifier).state =
|
ref2.read(offlinePendingChatMessagesProvider.notifier).state =
|
||||||
Map.unmodifiable(current);
|
Map.unmodifiable(current);
|
||||||
}
|
}
|
||||||
mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat');
|
mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat');
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class ConnectivityMonitor {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final res = await http
|
final res = await http
|
||||||
.head(Uri.parse('https://tasq.crmc.ph'))
|
.head(Uri.parse('https://google.com'))
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
return res.statusCode < 500;
|
return res.statusCode < 500;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -328,9 +328,10 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
|||||||
onOfflineData: () async {
|
onOfflineData: () async {
|
||||||
if (!AppRepository.isConfigured) return [];
|
if (!AppRepository.isConfigured) return [];
|
||||||
try {
|
try {
|
||||||
return await AppRepository.instance.get<Task>(
|
final all = await AppRepository.instance.get<Task>(
|
||||||
policy: OfflineFirstGetPolicy.localOnly,
|
policy: OfflineFirstGetPolicy.localOnly,
|
||||||
);
|
);
|
||||||
|
return {for (final t in all) t.id: t}.values.toList();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -363,6 +364,7 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
|||||||
'[tasksProvider] reconnected — syncing ${pending.length} pending task(s)',
|
'[tasksProvider] reconnected — syncing ${pending.length} pending task(s)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final failedTaskIds = <String>{};
|
||||||
for (final task in pending) {
|
for (final task in pending) {
|
||||||
try {
|
try {
|
||||||
final syncPayload = <String, dynamic>{
|
final syncPayload = <String, dynamic>{
|
||||||
@@ -393,18 +395,26 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
final msg = e.toString();
|
final msg = e.toString();
|
||||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||||
// Task already in DB (Brick may have synced it first) — safe to
|
// Task already in DB — treat as success so it's cleared from pending.
|
||||||
// leave cleanup to the realtime stream listener in tasks_list_screen.
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[tasksProvider] pending task already in DB id=${task.id}',
|
'[tasksProvider] pending task already in DB id=${task.id}',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
failedTaskIds.add(task.id);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[tasksProvider] failed to sync pending task id=${task.id}: $e',
|
'[tasksProvider] failed to sync pending task id=${task.id}: $e',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Clear successfully synced pending tasks. Don't rely on the
|
||||||
|
// tasks_list_screen listener alone — it only fires when that screen is
|
||||||
|
// active, leaving the banner stuck on other screens indefinitely.
|
||||||
|
if (pending.isNotEmpty) {
|
||||||
|
final remaining =
|
||||||
|
pending.where((t) => failedTaskIds.contains(t.id)).toList();
|
||||||
|
ref.read(offlinePendingTasksProvider.notifier).state = remaining;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 2. Sync offline field edits for EXISTING tasks (PATCH) ──────────
|
// ── 2. Sync offline field edits for EXISTING tasks (PATCH) ──────────
|
||||||
// pendingUpdates already had pending-task entries removed in step 1.
|
// pendingUpdates already had pending-task entries removed in step 1.
|
||||||
@@ -573,9 +583,10 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
|||||||
// When offline, seed from Brick local SQLite cache instead of network.
|
// When offline, seed from Brick local SQLite cache instead of network.
|
||||||
if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) {
|
if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) {
|
||||||
try {
|
try {
|
||||||
final cached = await AppRepository.instance.get<Task>(
|
final all = await AppRepository.instance.get<Task>(
|
||||||
policy: OfflineFirstGetPolicy.localOnly,
|
policy: OfflineFirstGetPolicy.localOnly,
|
||||||
);
|
);
|
||||||
|
final cached = {for (final t in all) t.id: t}.values.toList();
|
||||||
debugPrint('[tasksProvider] offline seed: ${cached.length} tasks from local cache');
|
debugPrint('[tasksProvider] offline seed: ${cached.length} tasks from local cache');
|
||||||
final payload = _buildTaskPayload(
|
final payload = _buildTaskPayload(
|
||||||
tasks: cached,
|
tasks: cached,
|
||||||
@@ -2788,3 +2799,52 @@ class TaskAssignmentsController {
|
|||||||
ref.read(offlinePendingAssignmentsProvider.notifier).state = current;
|
ref.read(offlinePendingAssignmentsProvider.notifier).state = current;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Work Log helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// All task_assignments rows where user_id = [userId].
|
||||||
|
/// Used by the Work Log tab to identify which tasks the user is the assignee of.
|
||||||
|
final taskAssignmentsForUserProvider =
|
||||||
|
FutureProvider.autoDispose.family<List<TaskAssignment>, String>(
|
||||||
|
(ref, userId) async {
|
||||||
|
try {
|
||||||
|
final data = await Supabase.instance.client
|
||||||
|
.from('task_assignments')
|
||||||
|
.select()
|
||||||
|
.eq('user_id', userId);
|
||||||
|
return data.map(TaskAssignment.fromMap).toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// All task_activity_logs rows for the given task IDs — single batch query.
|
||||||
|
/// Key is a sorted, comma-joined string of task IDs so Riverpod caches the
|
||||||
|
/// provider correctly across rebuilds (List equality is reference-based in Dart).
|
||||||
|
/// Returns logs in DESCENDING order (newest first).
|
||||||
|
final taskActivityLogsForTasksProvider =
|
||||||
|
FutureProvider.autoDispose.family<List<TaskActivityLog>, String>(
|
||||||
|
(ref, taskIdsKey) async {
|
||||||
|
if (taskIdsKey.isEmpty) return [];
|
||||||
|
final taskIds = taskIdsKey.split(',');
|
||||||
|
try {
|
||||||
|
final data = await Supabase.instance.client
|
||||||
|
.from('task_activity_logs')
|
||||||
|
.select()
|
||||||
|
.inFilter('task_id', taskIds)
|
||||||
|
.order('created_at', ascending: false);
|
||||||
|
return data.map(TaskActivityLog.fromMap).toList();
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
final all = await cachedListFromBrick<TaskActivityLog>();
|
||||||
|
final idSet = taskIds.toSet();
|
||||||
|
final filtered = all.where((l) => idSet.contains(l.taskId)).toList();
|
||||||
|
filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
return filtered;
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -28,9 +28,14 @@ import '../../providers/notifications_provider.dart';
|
|||||||
import '../../providers/it_service_request_provider.dart';
|
import '../../providers/it_service_request_provider.dart';
|
||||||
import '../../models/it_service_request.dart';
|
import '../../models/it_service_request.dart';
|
||||||
import '../../models/swap_request.dart';
|
import '../../models/swap_request.dart';
|
||||||
|
import '../../providers/tasks_provider.dart';
|
||||||
|
import '../../providers/tickets_provider.dart';
|
||||||
|
import 'logbook_day_activity.dart';
|
||||||
|
import 'work_log_tab.dart';
|
||||||
import '../../theme/m3_motion.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
|
import '../../utils/device_security.dart';
|
||||||
import '../../utils/location_permission.dart';
|
import '../../utils/location_permission.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import '../../widgets/face_verification_overlay.dart';
|
import '../../widgets/face_verification_overlay.dart';
|
||||||
@@ -63,7 +68,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 5, vsync: this);
|
_tabController = TabController(length: 6, vsync: this);
|
||||||
_tabController.addListener(_onTabChanged);
|
_tabController.addListener(_onTabChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +89,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||||||
final showFab = _tabController.index >= 3; // Pass Slip or Leave tabs
|
final showFab = _tabController.index >= 4; // Pass Slip or Leave tabs
|
||||||
|
|
||||||
return ResponsiveBody(
|
return ResponsiveBody(
|
||||||
maxWidth: 1200,
|
maxWidth: 1200,
|
||||||
@@ -106,6 +111,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
|||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Check In'),
|
Tab(text: 'Check In'),
|
||||||
Tab(text: 'Logbook'),
|
Tab(text: 'Logbook'),
|
||||||
|
Tab(text: 'Work Log'),
|
||||||
Tab(text: 'My Schedule'),
|
Tab(text: 'My Schedule'),
|
||||||
Tab(text: 'Pass Slip'),
|
Tab(text: 'Pass Slip'),
|
||||||
Tab(text: 'Leave'),
|
Tab(text: 'Leave'),
|
||||||
@@ -117,6 +123,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
|||||||
children: [
|
children: [
|
||||||
const _CheckInTab(),
|
const _CheckInTab(),
|
||||||
const _LogbookTab(),
|
const _LogbookTab(),
|
||||||
|
const WorkLogTab(),
|
||||||
_MyScheduleTab(),
|
_MyScheduleTab(),
|
||||||
const _PassSlipTab(),
|
const _PassSlipTab(),
|
||||||
const _LeaveTab(),
|
const _LeaveTab(),
|
||||||
@@ -304,6 +311,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
// Geofence state
|
// Geofence state
|
||||||
bool _insideGeofence = false;
|
bool _insideGeofence = false;
|
||||||
bool _checkingGeofence = true;
|
bool _checkingGeofence = true;
|
||||||
|
bool _deviceCompromised = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -312,6 +320,9 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
if (mounted) setState(() => _currentTime = AppTime.now());
|
if (mounted) setState(() => _currentTime = AppTime.now());
|
||||||
});
|
});
|
||||||
_checkGeofenceStatus();
|
_checkGeofenceStatus();
|
||||||
|
isDeviceCompromised().then((compromised) {
|
||||||
|
if (mounted) setState(() => _deviceCompromised = compromised);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -340,6 +351,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (position.isMocked) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_insideGeofence = false;
|
||||||
|
_checkingGeofence = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
bool inside = true;
|
bool inside = true;
|
||||||
if (geoCfg != null) {
|
if (geoCfg != null) {
|
||||||
if (geoCfg.hasPolygon) {
|
if (geoCfg.hasPolygon) {
|
||||||
@@ -987,6 +1007,13 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleCheckIn(DutySchedule schedule) async {
|
Future<void> _handleCheckIn(DutySchedule schedule) async {
|
||||||
|
if (_deviceCompromised) {
|
||||||
|
showWarningSnackBar(
|
||||||
|
context,
|
||||||
|
'Check-in is not allowed on rooted, jailbroken, or developer-mode devices.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
// Ensure location permission before check-in
|
// Ensure location permission before check-in
|
||||||
@@ -1006,6 +1033,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (position.isMocked || position.accuracy == 0.0) {
|
||||||
|
if (mounted) {
|
||||||
|
showWarningSnackBar(
|
||||||
|
context,
|
||||||
|
position.isMocked
|
||||||
|
? 'Mock location detected. Check-in is not allowed.'
|
||||||
|
: 'Location accuracy is invalid. Please disable mock location apps.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Client-side geofence check (can be bypassed in debug mode or by an approved IT request)
|
// Client-side geofence check (can be bypassed in debug mode or by an approved IT request)
|
||||||
final debugBypass =
|
final debugBypass =
|
||||||
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
||||||
@@ -1066,6 +1104,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
dutyScheduleId: schedule.id,
|
dutyScheduleId: schedule.id,
|
||||||
lat: position.latitude,
|
lat: position.latitude,
|
||||||
lng: position.longitude,
|
lng: position.longitude,
|
||||||
|
accuracy: position.accuracy,
|
||||||
|
isMocked: position.isMocked,
|
||||||
);
|
);
|
||||||
// automatically enable tracking when user checks in
|
// automatically enable tracking when user checks in
|
||||||
try {
|
try {
|
||||||
@@ -1110,6 +1150,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (position.isMocked) {
|
||||||
|
if (mounted) {
|
||||||
|
showWarningSnackBar(
|
||||||
|
context,
|
||||||
|
'Mock location detected. Check-out is not allowed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if outside geofence — require justification if so
|
// Check if outside geofence — require justification if so
|
||||||
String? checkOutJustification;
|
String? checkOutJustification;
|
||||||
@@ -1150,6 +1199,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
lat: position.latitude,
|
lat: position.latitude,
|
||||||
lng: position.longitude,
|
lng: position.longitude,
|
||||||
justification: checkOutJustification,
|
justification: checkOutJustification,
|
||||||
|
accuracy: position.accuracy,
|
||||||
|
isMocked: position.isMocked,
|
||||||
);
|
);
|
||||||
// automatically disable tracking when user checks out
|
// automatically disable tracking when user checks out
|
||||||
try {
|
try {
|
||||||
@@ -1197,6 +1248,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (position.isMocked) {
|
||||||
|
if (mounted) {
|
||||||
|
showWarningSnackBar(
|
||||||
|
context,
|
||||||
|
'Mock location detected. Check-out is not allowed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if outside geofence — require justification if so
|
// Check if outside geofence — require justification if so
|
||||||
String? checkOutJustification;
|
String? checkOutJustification;
|
||||||
@@ -1237,6 +1297,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
lat: position.latitude,
|
lat: position.latitude,
|
||||||
lng: position.longitude,
|
lng: position.longitude,
|
||||||
justification: checkOutJustification,
|
justification: checkOutJustification,
|
||||||
|
accuracy: position.accuracy,
|
||||||
|
isMocked: position.isMocked,
|
||||||
);
|
);
|
||||||
// Update live position immediately on check-out
|
// Update live position immediately on check-out
|
||||||
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
||||||
@@ -1316,6 +1378,13 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleOvertimeCheckIn() async {
|
Future<void> _handleOvertimeCheckIn() async {
|
||||||
|
if (_deviceCompromised) {
|
||||||
|
showWarningSnackBar(
|
||||||
|
context,
|
||||||
|
'Check-in is not allowed on rooted, jailbroken, or developer-mode devices.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final justification = _justificationController.text.trim();
|
final justification = _justificationController.text.trim();
|
||||||
if (justification.isEmpty) {
|
if (justification.isEmpty) {
|
||||||
showWarningSnackBar(
|
showWarningSnackBar(
|
||||||
@@ -1332,6 +1401,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (position.isMocked || position.accuracy == 0.0) {
|
||||||
|
if (mounted) {
|
||||||
|
showWarningSnackBar(
|
||||||
|
context,
|
||||||
|
position.isMocked
|
||||||
|
? 'Mock location detected. Check-in is not allowed.'
|
||||||
|
: 'Location accuracy is invalid. Please disable mock location apps.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
final debugBypass =
|
final debugBypass =
|
||||||
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
||||||
if (geoCfg != null && !debugBypass) {
|
if (geoCfg != null && !debugBypass) {
|
||||||
@@ -1363,6 +1443,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
lat: position.latitude,
|
lat: position.latitude,
|
||||||
lng: position.longitude,
|
lng: position.longitude,
|
||||||
justification: justification,
|
justification: justification,
|
||||||
|
accuracy: position.accuracy,
|
||||||
|
isMocked: position.isMocked,
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -1711,6 +1793,8 @@ class _LogbookEntry {
|
|||||||
this.checkInLng,
|
this.checkInLng,
|
||||||
this.checkOutLat,
|
this.checkOutLat,
|
||||||
this.checkOutLng,
|
this.checkOutLng,
|
||||||
|
this.rawLog,
|
||||||
|
this.swapRequestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
@@ -1739,6 +1823,10 @@ class _LogbookEntry {
|
|||||||
final double? checkInLng;
|
final double? checkInLng;
|
||||||
final double? checkOutLat;
|
final double? checkOutLat;
|
||||||
final double? checkOutLng;
|
final double? checkOutLng;
|
||||||
|
// Raw attendance log for building detailed day activity view.
|
||||||
|
final AttendanceLog? rawLog;
|
||||||
|
// Non-null when the duty schedule was created by an accepted swap.
|
||||||
|
final String? swapRequestId;
|
||||||
|
|
||||||
/// Whether this entry can be re-verified (within 10 min of check-in).
|
/// Whether this entry can be re-verified (within 10 min of check-in).
|
||||||
bool canReverify(String currentUserId) {
|
bool canReverify(String currentUserId) {
|
||||||
@@ -1812,6 +1900,8 @@ class _ShiftGroup {
|
|||||||
final String name;
|
final String name;
|
||||||
final String shiftLabel;
|
final String shiftLabel;
|
||||||
final List<_LogbookEntry> sessions;
|
final List<_LogbookEntry> sessions;
|
||||||
|
// Pass slips tied to this shift's duty schedule — populated after grouping.
|
||||||
|
List<PassSlip> passSlips = const [];
|
||||||
|
|
||||||
DateTime get date => sessions.first.date;
|
DateTime get date => sessions.first.date;
|
||||||
|
|
||||||
@@ -1848,6 +1938,9 @@ class _ShiftGroup {
|
|||||||
/// Whether any session in this group is still in progress.
|
/// Whether any session in this group is still in progress.
|
||||||
bool get hasOngoingSession =>
|
bool get hasOngoingSession =>
|
||||||
sessions.any((s) => !s.isAbsent && !s.isLeave && s.checkOutAt == null);
|
sessions.any((s) => !s.isAbsent && !s.isLeave && s.checkOutAt == null);
|
||||||
|
|
||||||
|
/// Whether this shift was created by an accepted swap request.
|
||||||
|
bool get isSwapped => sessions.any((s) => s.swapRequestId != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────
|
// ────────────────────────────────────────────────
|
||||||
@@ -1874,6 +1967,7 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
final profilesAsync = ref.watch(profilesProvider);
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
||||||
final leavesAsync = ref.watch(leavesProvider);
|
final leavesAsync = ref.watch(leavesProvider);
|
||||||
|
final passSlips = ref.watch(passSlipsProvider).valueOrNull ?? [];
|
||||||
|
|
||||||
final selectedUserIds = ref.watch(attendanceUserFilterProvider);
|
final selectedUserIds = ref.watch(attendanceUserFilterProvider);
|
||||||
|
|
||||||
@@ -1981,8 +2075,11 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
return _buildShiftGroupList(
|
return _buildShiftGroupList(
|
||||||
context,
|
context,
|
||||||
entries,
|
entries,
|
||||||
|
passSlips: passSlips,
|
||||||
currentUserId: currentUserId,
|
currentUserId: currentUserId,
|
||||||
onReverify: (logId) => _reverify(context, ref, logId),
|
onReverify: (logId) => _reverify(context, ref, logId),
|
||||||
|
onViewActivity: (group) =>
|
||||||
|
_showDayActivity(context, group),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
@@ -2150,6 +2247,8 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
checkInLng: l.checkInLng,
|
checkInLng: l.checkInLng,
|
||||||
checkOutLat: l.checkOutLat,
|
checkOutLat: l.checkOutLat,
|
||||||
checkOutLng: l.checkOutLng,
|
checkOutLng: l.checkOutLng,
|
||||||
|
rawLog: l,
|
||||||
|
swapRequestId: scheduleById[l.dutyScheduleId]?.swapRequestId,
|
||||||
),
|
),
|
||||||
...absentSchedules.map((s) => _LogbookEntry.absent(s, profileById)),
|
...absentSchedules.map((s) => _LogbookEntry.absent(s, profileById)),
|
||||||
|
|
||||||
@@ -2207,11 +2306,21 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
Widget _buildShiftGroupList(
|
Widget _buildShiftGroupList(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<_LogbookEntry> entries, {
|
List<_LogbookEntry> entries, {
|
||||||
|
required List<PassSlip> passSlips,
|
||||||
required String currentUserId,
|
required String currentUserId,
|
||||||
required void Function(String logId) onReverify,
|
required void Function(String logId) onReverify,
|
||||||
|
required void Function(_ShiftGroup group) onViewActivity,
|
||||||
}) {
|
}) {
|
||||||
final groupedByDate = _groupByDate(entries);
|
final groupedByDate = _groupByDate(entries);
|
||||||
|
|
||||||
|
// Build lookup: dutyScheduleId → pass slips for that shift.
|
||||||
|
final slipsBySchedule = <String, List<PassSlip>>{};
|
||||||
|
for (final slip in passSlips) {
|
||||||
|
slipsBySchedule
|
||||||
|
.putIfAbsent(slip.dutyScheduleId, () => [])
|
||||||
|
.add(slip);
|
||||||
|
}
|
||||||
|
|
||||||
final groups = groupedByDate.entries.toList();
|
final groups = groupedByDate.entries.toList();
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
@@ -2221,9 +2330,16 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
|
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
|
||||||
child: _DateGroupTile(
|
child: _DateGroupTile(
|
||||||
dateLabel: groups[i].key,
|
dateLabel: groups[i].key,
|
||||||
shiftGroups: _groupByShift(groups[i].value),
|
shiftGroups: _groupByShift(groups[i].value)
|
||||||
|
..forEach((g) {
|
||||||
|
if (g.dutyScheduleId != null) {
|
||||||
|
g.passSlips =
|
||||||
|
slipsBySchedule[g.dutyScheduleId!] ?? const [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
currentUserId: currentUserId,
|
currentUserId: currentUserId,
|
||||||
onReverify: onReverify,
|
onReverify: onReverify,
|
||||||
|
onViewActivity: onViewActivity,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -2274,6 +2390,84 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showDayActivity(BuildContext context, _ShiftGroup group) {
|
||||||
|
final allTasks = ref.read(tasksProvider).valueOrNull ?? [];
|
||||||
|
final allTickets = ref.read(ticketsProvider).valueOrNull ?? [];
|
||||||
|
final allIsrs = ref.read(itServiceRequestsProvider).valueOrNull ?? [];
|
||||||
|
|
||||||
|
final dayStart =
|
||||||
|
DateTime(group.date.year, group.date.month, group.date.day);
|
||||||
|
final dayEnd = dayStart.add(const Duration(days: 1));
|
||||||
|
|
||||||
|
bool onDay(DateTime? dt) =>
|
||||||
|
dt != null && !dt.isBefore(dayStart) && dt.isBefore(dayEnd);
|
||||||
|
|
||||||
|
final data = LogbookDayActivityData(
|
||||||
|
userId: group.userId,
|
||||||
|
name: group.name,
|
||||||
|
date: group.date,
|
||||||
|
shiftLabel: group.shiftLabel,
|
||||||
|
isSwapped: group.isSwapped,
|
||||||
|
sessions: group.sessions
|
||||||
|
.where((e) => e.rawLog != null)
|
||||||
|
.map((e) => e.rawLog!)
|
||||||
|
.toList(),
|
||||||
|
passSlips: group.passSlips,
|
||||||
|
tasks: allTasks
|
||||||
|
.where((t) =>
|
||||||
|
t.creatorId == group.userId &&
|
||||||
|
(onDay(t.startedAt) ||
|
||||||
|
onDay(t.completedAt) ||
|
||||||
|
onDay(t.createdAt)))
|
||||||
|
.toList(),
|
||||||
|
tickets: allTickets
|
||||||
|
.where((t) =>
|
||||||
|
t.creatorId == group.userId &&
|
||||||
|
(onDay(t.createdAt) ||
|
||||||
|
onDay(t.respondedAt) ||
|
||||||
|
onDay(t.promotedAt)))
|
||||||
|
.toList(),
|
||||||
|
serviceRequests: allIsrs
|
||||||
|
.where((r) =>
|
||||||
|
(r.creatorId == group.userId ||
|
||||||
|
r.requestedByUserId == group.userId) &&
|
||||||
|
(onDay(r.createdAt) ||
|
||||||
|
onDay(r.updatedAt) ||
|
||||||
|
onDay(r.completedAt)))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final isMobile =
|
||||||
|
MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
|
||||||
|
if (isMobile) {
|
||||||
|
m3ShowBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (_) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
minChildSize: 0.4,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
expand: false,
|
||||||
|
builder: (_, scrollController) => SingleChildScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
child: LogbookDayActivitySheet(data: data),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
m3ShowDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => Dialog(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 560,
|
||||||
|
child: LogbookDayActivitySheet(data: data),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────
|
// ────────────────────────────────────────────────
|
||||||
@@ -2286,12 +2480,14 @@ class _DateGroupTile extends StatelessWidget {
|
|||||||
required this.shiftGroups,
|
required this.shiftGroups,
|
||||||
required this.currentUserId,
|
required this.currentUserId,
|
||||||
required this.onReverify,
|
required this.onReverify,
|
||||||
|
required this.onViewActivity,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String dateLabel;
|
final String dateLabel;
|
||||||
final List<_ShiftGroup> shiftGroups;
|
final List<_ShiftGroup> shiftGroups;
|
||||||
final String currentUserId;
|
final String currentUserId;
|
||||||
final void Function(String logId) onReverify;
|
final void Function(String logId) onReverify;
|
||||||
|
final void Function(_ShiftGroup group) onViewActivity;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -2312,17 +2508,21 @@ class _DateGroupTile extends StatelessWidget {
|
|||||||
style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
|
style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
children: shiftGroups.map((group) {
|
children: shiftGroups.map((group) {
|
||||||
return _buildShiftGroupTile(context, group);
|
return _buildShiftGroupTile(context, group, onViewActivity);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildShiftGroupTile(BuildContext context, _ShiftGroup group) {
|
Widget _buildShiftGroupTile(
|
||||||
|
BuildContext context,
|
||||||
|
_ShiftGroup group,
|
||||||
|
void Function(_ShiftGroup) onViewActivity,
|
||||||
|
) {
|
||||||
// If the group is an absence or leave, render as a single row.
|
// If the group is an absence or leave, render as a single row.
|
||||||
final first = group.sessions.first;
|
final first = group.sessions.first;
|
||||||
if (first.isAbsent || first.isLeave) {
|
if (first.isAbsent || first.isLeave) {
|
||||||
return _buildSessionTile(context, first);
|
return _buildSessionTile(context, first, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
@@ -2341,12 +2541,67 @@ class _DateGroupTile extends StatelessWidget {
|
|||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
|
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// View Activity button
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.timeline_outlined),
|
||||||
|
tooltip: 'View day activity',
|
||||||
|
onPressed: () => onViewActivity(group),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
// Default expand/collapse icon
|
||||||
|
const Icon(Icons.expand_more),
|
||||||
|
],
|
||||||
|
),
|
||||||
title: Column(
|
title: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
'${group.name} · ${group.shiftLabel}',
|
children: [
|
||||||
style: textTheme.titleSmall,
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${group.name} · ${group.shiftLabel}',
|
||||||
|
style: textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Swap badge — fades in when shift is a swap
|
||||||
|
AnimatedOpacity(
|
||||||
|
opacity: group.isSwapped ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: group.isSwapped
|
||||||
|
? Container(
|
||||||
|
margin: const EdgeInsets.only(left: 6),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 7, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.swap_horiz_rounded,
|
||||||
|
size: 12,
|
||||||
|
color: colors.onSecondaryContainer),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
'Swapped',
|
||||||
|
style: textTheme.labelSmall?.copyWith(
|
||||||
|
color: colors.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -2365,14 +2620,104 @@ class _DateGroupTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
children: group.sessions.map((session) {
|
children: [
|
||||||
return _buildSessionTile(context, session);
|
...group.sessions.asMap().entries.map((e) {
|
||||||
}).toList(),
|
return _buildSessionTile(
|
||||||
|
context, e.value, e.key, group.sessions.length);
|
||||||
|
}),
|
||||||
|
// Pass slip rows
|
||||||
|
if (group.passSlips.isNotEmpty)
|
||||||
|
...group.passSlips.asMap().entries.map((e) {
|
||||||
|
return M3FadeSlideIn(
|
||||||
|
delay: Duration(milliseconds: e.key * 40),
|
||||||
|
child: _buildPassSlipRow(context, e.value),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSessionTile(BuildContext context, _LogbookEntry entry) {
|
Widget _buildPassSlipRow(BuildContext context, PassSlip slip) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
Color statusColor;
|
||||||
|
String statusLabel;
|
||||||
|
switch (slip.status) {
|
||||||
|
case 'approved':
|
||||||
|
statusColor = Colors.green;
|
||||||
|
statusLabel = 'Approved';
|
||||||
|
case 'pending':
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
statusLabel = 'Pending';
|
||||||
|
case 'rejected':
|
||||||
|
statusColor = colors.error;
|
||||||
|
statusLabel = 'Rejected';
|
||||||
|
default:
|
||||||
|
statusColor = colors.outline;
|
||||||
|
statusLabel = slip.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
String timeRange;
|
||||||
|
if (slip.slipStart != null) {
|
||||||
|
final start = AppTime.formatTime(slip.slipStart!);
|
||||||
|
final end = slip.slipEnd != null
|
||||||
|
? AppTime.formatTime(slip.slipEnd!)
|
||||||
|
: 'ongoing';
|
||||||
|
timeRange = '$start – $end';
|
||||||
|
} else {
|
||||||
|
timeRange = 'Pending start';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: colors.tertiaryContainer,
|
||||||
|
child: Icon(Icons.badge_outlined,
|
||||||
|
size: 16, color: colors.onTertiaryContainer),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Text('Pass Slip', style: textTheme.bodyMedium),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
statusLabel,
|
||||||
|
style: textTheme.labelSmall?.copyWith(color: statusColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
slip.reason,
|
||||||
|
style: textTheme.bodySmall
|
||||||
|
?.copyWith(color: colors.onSurfaceVariant),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
timeRange,
|
||||||
|
style: textTheme.labelSmall
|
||||||
|
?.copyWith(color: colors.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSessionTile(
|
||||||
|
BuildContext context, _LogbookEntry entry, int index, int total) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
final isReverifyable = entry.canReverify(currentUserId);
|
final isReverifyable = entry.canReverify(currentUserId);
|
||||||
|
|
||||||
@@ -2381,7 +2726,20 @@ class _DateGroupTile extends StatelessWidget {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Text(entry.name)),
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
entry.isLeave
|
||||||
|
? 'Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}'
|
||||||
|
: entry.isAbsent
|
||||||
|
? 'No Check-In Recorded'
|
||||||
|
: entry.justification?.isNotEmpty == true
|
||||||
|
? entry.justification!
|
||||||
|
: total > 1
|
||||||
|
? 'Session ${index + 1} of $total'
|
||||||
|
: 'Attendance Session',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (!isLeaveOrAbsent) _verificationBadge(context, entry),
|
if (!isLeaveOrAbsent) _verificationBadge(context, entry),
|
||||||
if (isReverifyable)
|
if (isReverifyable)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -0,0 +1,596 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../models/attendance_log.dart';
|
||||||
|
import '../../models/it_service_request.dart';
|
||||||
|
import '../../models/pass_slip.dart';
|
||||||
|
import '../../models/task.dart';
|
||||||
|
import '../../models/ticket.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Data class
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class LogbookDayActivityData {
|
||||||
|
final String userId;
|
||||||
|
final String name;
|
||||||
|
final DateTime date;
|
||||||
|
final String shiftLabel;
|
||||||
|
final DateTime? scheduledStart;
|
||||||
|
final DateTime? scheduledEnd;
|
||||||
|
final bool isSwapped;
|
||||||
|
final List<AttendanceLog> sessions;
|
||||||
|
final List<PassSlip> passSlips;
|
||||||
|
final List<Task> tasks;
|
||||||
|
final List<Ticket> tickets;
|
||||||
|
final List<ItServiceRequest> serviceRequests;
|
||||||
|
|
||||||
|
const LogbookDayActivityData({
|
||||||
|
required this.userId,
|
||||||
|
required this.name,
|
||||||
|
required this.date,
|
||||||
|
required this.shiftLabel,
|
||||||
|
this.scheduledStart,
|
||||||
|
this.scheduledEnd,
|
||||||
|
this.isSwapped = false,
|
||||||
|
required this.sessions,
|
||||||
|
required this.passSlips,
|
||||||
|
required this.tasks,
|
||||||
|
required this.tickets,
|
||||||
|
required this.serviceRequests,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Timeline event model
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum _DayEventType {
|
||||||
|
shiftScheduled,
|
||||||
|
swapNote,
|
||||||
|
checkIn,
|
||||||
|
passSlipOut,
|
||||||
|
passSlipReturn,
|
||||||
|
checkOut,
|
||||||
|
stillOnDuty,
|
||||||
|
taskCreated,
|
||||||
|
taskStarted,
|
||||||
|
taskCompleted,
|
||||||
|
ticketCreated,
|
||||||
|
ticketResponded,
|
||||||
|
ticketPromoted,
|
||||||
|
isrCreated,
|
||||||
|
isrUpdated,
|
||||||
|
isrCompleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DayEvent {
|
||||||
|
final _DayEventType type;
|
||||||
|
final DateTime time;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
|
const _DayEvent({
|
||||||
|
required this.type,
|
||||||
|
required this.time,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Event assembly
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
List<_DayEvent> _buildEvents(LogbookDayActivityData data) {
|
||||||
|
final events = <_DayEvent>[];
|
||||||
|
final t = AppTime.formatTime;
|
||||||
|
|
||||||
|
if (data.scheduledStart != null) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.shiftScheduled,
|
||||||
|
time: data.scheduledStart!,
|
||||||
|
title: 'Shift scheduled — ${data.shiftLabel}',
|
||||||
|
subtitle: data.scheduledEnd != null
|
||||||
|
? '${t(data.scheduledStart!)} – ${t(data.scheduledEnd!)}'
|
||||||
|
: t(data.scheduledStart!),
|
||||||
|
));
|
||||||
|
if (data.isSwapped) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.swapNote,
|
||||||
|
time: data.scheduledStart!,
|
||||||
|
title: 'Shift received via swap',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final sortedSessions = [...data.sessions]
|
||||||
|
..sort((a, b) => a.checkInAt.compareTo(b.checkInAt));
|
||||||
|
|
||||||
|
for (final session in sortedSessions) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.checkIn,
|
||||||
|
time: session.checkInAt,
|
||||||
|
title: 'Checked in',
|
||||||
|
subtitle: t(session.checkInAt),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Pass slips that started during this session window.
|
||||||
|
final sessionEnd = session.checkOutAt ?? AppTime.now();
|
||||||
|
for (final slip in data.passSlips) {
|
||||||
|
if (slip.slipStart == null) continue;
|
||||||
|
if (slip.slipStart!.isBefore(session.checkInAt) ||
|
||||||
|
slip.slipStart!.isAfter(sessionEnd)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.passSlipOut,
|
||||||
|
time: slip.slipStart!,
|
||||||
|
title: 'Pass slip — left',
|
||||||
|
subtitle: slip.reason,
|
||||||
|
));
|
||||||
|
if (slip.slipEnd != null) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.passSlipReturn,
|
||||||
|
time: slip.slipEnd!,
|
||||||
|
title: 'Pass slip — returned',
|
||||||
|
subtitle: '${t(slip.slipStart!)} – ${t(slip.slipEnd!)}',
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.passSlipReturn,
|
||||||
|
time: slip.slipStart!.add(const Duration(seconds: 1)),
|
||||||
|
title: 'Pass slip — still out',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.checkOutAt != null) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.checkOut,
|
||||||
|
time: session.checkOutAt!,
|
||||||
|
title: 'Checked out',
|
||||||
|
subtitle: t(session.checkOutAt!),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.stillOnDuty,
|
||||||
|
time: AppTime.now(),
|
||||||
|
title: 'Currently on duty',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final task in data.tasks) {
|
||||||
|
if (task.startedAt != null) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.taskStarted,
|
||||||
|
time: task.startedAt!,
|
||||||
|
title: 'Task started',
|
||||||
|
subtitle: task.title,
|
||||||
|
));
|
||||||
|
} else if (task.completedAt != null) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.taskCompleted,
|
||||||
|
time: task.completedAt!,
|
||||||
|
title: 'Task completed',
|
||||||
|
subtitle: task.title,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.taskCreated,
|
||||||
|
time: task.createdAt,
|
||||||
|
title: 'Task created',
|
||||||
|
subtitle: task.title,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final ticket in data.tickets) {
|
||||||
|
if (ticket.promotedAt != null) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.ticketPromoted,
|
||||||
|
time: ticket.promotedAt!,
|
||||||
|
title: 'Ticket promoted to task',
|
||||||
|
subtitle: ticket.subject,
|
||||||
|
));
|
||||||
|
} else if (ticket.respondedAt != null) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.ticketResponded,
|
||||||
|
time: ticket.respondedAt!,
|
||||||
|
title: 'Ticket responded',
|
||||||
|
subtitle: ticket.subject,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.ticketCreated,
|
||||||
|
time: ticket.createdAt,
|
||||||
|
title: 'Ticket created',
|
||||||
|
subtitle: ticket.subject,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final isr in data.serviceRequests) {
|
||||||
|
if (isr.completedAt != null) {
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: _DayEventType.isrCompleted,
|
||||||
|
time: isr.completedAt!,
|
||||||
|
title: 'IT request completed',
|
||||||
|
subtitle: isr.requestNumber,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
final isCreatedDay = _sameDay(isr.createdAt, data.date);
|
||||||
|
events.add(_DayEvent(
|
||||||
|
type: isCreatedDay ? _DayEventType.isrCreated : _DayEventType.isrUpdated,
|
||||||
|
time: isCreatedDay ? isr.createdAt : isr.updatedAt,
|
||||||
|
title: isCreatedDay ? 'IT request created' : 'IT request updated',
|
||||||
|
subtitle: isr.requestNumber,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.sort((a, b) => a.time.compareTo(b.time));
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _sameDay(DateTime a, DateTime b) =>
|
||||||
|
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Main sheet widget
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class LogbookDayActivitySheet extends ConsumerWidget {
|
||||||
|
const LogbookDayActivitySheet({super.key, required this.data});
|
||||||
|
|
||||||
|
final LogbookDayActivityData data;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final events = _buildEvents(data);
|
||||||
|
|
||||||
|
final dateStr = _formatDate(data.date);
|
||||||
|
final initials = _initials(data.name);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// ── Drag handle (mobile) ──────────────────────────────
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(top: 12, bottom: 4),
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// ── Header ───────────────────────────────────────────
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 22,
|
||||||
|
backgroundColor: colors.primaryContainer,
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colors.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(data.name,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
)),
|
||||||
|
Text(dateStr,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// ── Summary chips ─────────────────────────────────────
|
||||||
|
if (data.tasks.isNotEmpty ||
|
||||||
|
data.tickets.isNotEmpty ||
|
||||||
|
data.serviceRequests.isNotEmpty ||
|
||||||
|
data.passSlips.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
if (data.tasks.isNotEmpty)
|
||||||
|
_SummaryChip(
|
||||||
|
label: '${data.tasks.length} task${data.tasks.length == 1 ? '' : 's'}',
|
||||||
|
icon: Icons.task_alt_rounded,
|
||||||
|
color: colors.secondaryContainer,
|
||||||
|
onColor: colors.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
if (data.tickets.isNotEmpty)
|
||||||
|
_SummaryChip(
|
||||||
|
label: '${data.tickets.length} ticket${data.tickets.length == 1 ? '' : 's'}',
|
||||||
|
icon: Icons.confirmation_num_outlined,
|
||||||
|
color: colors.tertiaryContainer,
|
||||||
|
onColor: colors.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
if (data.serviceRequests.isNotEmpty)
|
||||||
|
_SummaryChip(
|
||||||
|
label: '${data.serviceRequests.length} ISR${data.serviceRequests.length == 1 ? '' : 's'}',
|
||||||
|
icon: Icons.computer_outlined,
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
onColor: colors.onSurface,
|
||||||
|
),
|
||||||
|
if (data.passSlips.isNotEmpty)
|
||||||
|
_SummaryChip(
|
||||||
|
label: '${data.passSlips.length} pass slip${data.passSlips.length == 1 ? '' : 's'}',
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
color: colors.primaryContainer,
|
||||||
|
onColor: colors.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1),
|
||||||
|
// ── Timeline ─────────────────────────────────────────
|
||||||
|
Flexible(
|
||||||
|
child: events.isEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'No activity recorded for this day.',
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||||
|
itemCount: events.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return M3FadeSlideIn(
|
||||||
|
delay: Duration(
|
||||||
|
milliseconds: index.clamp(0, 14) * 45),
|
||||||
|
child: _TimelineTile(
|
||||||
|
event: events[index],
|
||||||
|
isFirst: index == 0,
|
||||||
|
isLast: index == events.length - 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatDate(DateTime d) {
|
||||||
|
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
const months = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
final wd = weekdays[d.weekday - 1];
|
||||||
|
final mo = months[d.month - 1];
|
||||||
|
return '$wd, $mo ${d.day}, ${d.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _initials(String name) {
|
||||||
|
final parts = name.trim().split(RegExp(r'\s+'));
|
||||||
|
if (parts.isEmpty) return '?';
|
||||||
|
if (parts.length == 1) return parts[0][0].toUpperCase();
|
||||||
|
return '${parts[0][0]}${parts[parts.length - 1][0]}'.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Summary chip
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _SummaryChip extends StatelessWidget {
|
||||||
|
const _SummaryChip({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.onColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final Color onColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: onColor),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: onColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Timeline tile
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _TimelineTile extends StatelessWidget {
|
||||||
|
const _TimelineTile({
|
||||||
|
required this.event,
|
||||||
|
required this.isFirst,
|
||||||
|
required this.isLast,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _DayEvent event;
|
||||||
|
final bool isFirst;
|
||||||
|
final bool isLast;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final dotColor = _dotColor(colors);
|
||||||
|
|
||||||
|
return IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// ── Dot + connector line ──────────────────────────
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Top connector (hidden for first item)
|
||||||
|
SizedBox(
|
||||||
|
height: isFirst ? 6 : 0,
|
||||||
|
width: 2,
|
||||||
|
child: isFirst
|
||||||
|
? null
|
||||||
|
: ColoredBox(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
|
// Dot
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: dotColor,
|
||||||
|
border: Border.all(
|
||||||
|
color: colors.surface,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom connector (hidden for last item)
|
||||||
|
if (!isLast)
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 2,
|
||||||
|
child: ColoredBox(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// ── Content ───────────────────────────────────────
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
event.title,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (event.subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
event.subtitle!,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
AppTime.formatTime(event.time),
|
||||||
|
style: textTheme.labelSmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _dotColor(ColorScheme colors) {
|
||||||
|
switch (event.type) {
|
||||||
|
case _DayEventType.shiftScheduled:
|
||||||
|
case _DayEventType.swapNote:
|
||||||
|
return colors.outline;
|
||||||
|
case _DayEventType.checkIn:
|
||||||
|
case _DayEventType.checkOut:
|
||||||
|
return colors.primary;
|
||||||
|
case _DayEventType.passSlipOut:
|
||||||
|
case _DayEventType.passSlipReturn:
|
||||||
|
return colors.tertiary;
|
||||||
|
case _DayEventType.stillOnDuty:
|
||||||
|
return colors.primaryContainer;
|
||||||
|
case _DayEventType.taskCreated:
|
||||||
|
case _DayEventType.taskStarted:
|
||||||
|
case _DayEventType.taskCompleted:
|
||||||
|
return colors.secondary;
|
||||||
|
case _DayEventType.ticketCreated:
|
||||||
|
case _DayEventType.ticketResponded:
|
||||||
|
case _DayEventType.ticketPromoted:
|
||||||
|
return colors.secondaryContainer;
|
||||||
|
case _DayEventType.isrCreated:
|
||||||
|
case _DayEventType.isrUpdated:
|
||||||
|
case _DayEventType.isrCompleted:
|
||||||
|
return colors.tertiaryContainer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:safe_device/safe_device.dart';
|
||||||
|
|
||||||
|
/// Returns true if the device appears compromised for attendance purposes:
|
||||||
|
/// rooted (Android) / jailbroken (iOS), or developer mode enabled.
|
||||||
|
/// Returns false on any error so a detection failure never blocks a valid user.
|
||||||
|
Future<bool> isDeviceCompromised() async {
|
||||||
|
if (!Platform.isAndroid && !Platform.isIOS) return false;
|
||||||
|
try {
|
||||||
|
final isJailBroken = await SafeDevice.isJailBroken;
|
||||||
|
final isDeveloperMode = await SafeDevice.isDevelopmentModeEnable;
|
||||||
|
return isJailBroken || isDeveloperMode;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import '../models/task_activity_log.dart';
|
||||||
|
|
||||||
|
/// Finds the canonical execution start time for a task.
|
||||||
|
///
|
||||||
|
/// [logs] is expected in DESCENDING order (newest first), matching the
|
||||||
|
/// `order('created_at', ascending: false)` used by Supabase queries.
|
||||||
|
/// The reversed iteration therefore walks chronologically forward and stops
|
||||||
|
/// at the earliest 'started' event.
|
||||||
|
DateTime? resolveTaskExecutionStart(
|
||||||
|
List<TaskActivityLog> logs,
|
||||||
|
DateTime? fallbackStartedAt,
|
||||||
|
) {
|
||||||
|
DateTime? started;
|
||||||
|
for (final entry in logs.reversed) {
|
||||||
|
if (entry.actionType == 'started') {
|
||||||
|
started = entry.createdAt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return started ?? fallbackStartedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the effective worked duration for a task, subtracting all
|
||||||
|
/// paused intervals between [fallbackStartedAt] (or the first 'started'
|
||||||
|
/// log event) and [endAt].
|
||||||
|
///
|
||||||
|
/// [logs] must be in DESCENDING order (newest first) — the same order
|
||||||
|
/// returned by `task_activity_logs` queries with `ascending: false`.
|
||||||
|
Duration computeEffectiveTaskDuration({
|
||||||
|
required DateTime? fallbackStartedAt,
|
||||||
|
required DateTime endAt,
|
||||||
|
required List<TaskActivityLog> logs,
|
||||||
|
}) {
|
||||||
|
final start = resolveTaskExecutionStart(logs, fallbackStartedAt);
|
||||||
|
if (start == null || !endAt.isAfter(start)) return Duration.zero;
|
||||||
|
|
||||||
|
Duration pausedTotal = Duration.zero;
|
||||||
|
DateTime? pausedSince;
|
||||||
|
|
||||||
|
// logs.reversed → ascending (chronological) order within [start, endAt]
|
||||||
|
final events = logs.reversed.where((e) {
|
||||||
|
if (e.createdAt.isBefore(start)) return false;
|
||||||
|
if (e.createdAt.isAfter(endAt)) return false;
|
||||||
|
return e.actionType == 'paused' || e.actionType == 'resumed';
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final event in events) {
|
||||||
|
if (event.actionType == 'paused') {
|
||||||
|
pausedSince ??= event.createdAt;
|
||||||
|
} else if (event.actionType == 'resumed' && pausedSince != null) {
|
||||||
|
if (event.createdAt.isAfter(pausedSince)) {
|
||||||
|
pausedTotal += event.createdAt.difference(pausedSince);
|
||||||
|
}
|
||||||
|
pausedSince = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task is still paused at endAt — count the open pause interval.
|
||||||
|
if (pausedSince != null && endAt.isAfter(pausedSince)) {
|
||||||
|
pausedTotal += endAt.difference(pausedSince);
|
||||||
|
}
|
||||||
|
|
||||||
|
final total = endAt.difference(start) - pausedTotal;
|
||||||
|
return total.isNegative ? Duration.zero : total;
|
||||||
|
}
|
||||||
@@ -254,11 +254,18 @@ class _StatusChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CacheGrid extends StatelessWidget {
|
class _CacheGrid extends StatefulWidget {
|
||||||
final Map<String, int> counts;
|
final Map<String, int> counts;
|
||||||
|
|
||||||
const _CacheGrid({required this.counts});
|
const _CacheGrid({required this.counts});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CacheGrid> createState() => _CacheGridState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheGridState extends State<_CacheGrid> with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
|
||||||
static const _groups = [
|
static const _groups = [
|
||||||
('Tasks', 'tasks', Icons.task_alt_rounded),
|
('Tasks', 'tasks', Icons.task_alt_rounded),
|
||||||
('Tickets', 'tickets', Icons.confirmation_number_rounded),
|
('Tickets', 'tickets', Icons.confirmation_number_rounded),
|
||||||
@@ -274,6 +281,21 @@ class _CacheGrid extends StatelessWidget {
|
|||||||
('Offices', 'offices', Icons.business_rounded),
|
('Offices', 'offices', Icons.business_rounded),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
vsync: this,
|
||||||
|
)..forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
@@ -284,22 +306,41 @@ class _CacheGrid extends StatelessWidget {
|
|||||||
crossAxisCount: 3,
|
crossAxisCount: 3,
|
||||||
mainAxisSpacing: 8,
|
mainAxisSpacing: 8,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 8,
|
||||||
childAspectRatio: 1.1,
|
childAspectRatio: 0.9,
|
||||||
),
|
),
|
||||||
itemCount: _groups.length,
|
itemCount: _groups.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final (label, key, icon) = _groups[i];
|
final (label, key, icon) = _groups[i];
|
||||||
final count = counts[key] ?? -1;
|
final count = widget.counts[key] ?? -1;
|
||||||
final cached = count >= 0;
|
final cached = count >= 0;
|
||||||
return AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 400),
|
final start = i * 0.055;
|
||||||
child: _CacheTile(
|
final end = (start + 0.45).clamp(0.0, 1.0);
|
||||||
key: ValueKey('$key-$count'),
|
final curve = CurvedAnimation(
|
||||||
label: label,
|
parent: _controller,
|
||||||
icon: icon,
|
curve: Interval(start, end, curve: Curves.easeOutCubic),
|
||||||
count: count,
|
);
|
||||||
cached: cached,
|
final fadeAnim = Tween<double>(begin: 0.0, end: 1.0).animate(curve);
|
||||||
scheme: scheme,
|
final slideAnim = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.25),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(curve);
|
||||||
|
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: fadeAnim,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: slideAnim,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
child: _CacheTile(
|
||||||
|
key: ValueKey('$key-$count'),
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
count: count,
|
||||||
|
cached: cached,
|
||||||
|
scheme: scheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -328,24 +369,25 @@ class _CacheTile extends StatelessWidget {
|
|||||||
final bg = cached ? scheme.primaryContainer.withValues(alpha: 0.5) : scheme.surfaceContainerHighest;
|
final bg = cached ? scheme.primaryContainer.withValues(alpha: 0.5) : scheme.surfaceContainerHighest;
|
||||||
final fg = cached ? scheme.onPrimaryContainer : scheme.onSurfaceVariant;
|
final fg = cached ? scheme.onPrimaryContainer : scheme.onSurfaceVariant;
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)),
|
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(14)),
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: fg, size: 22),
|
Icon(icon, color: fg, size: 24),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(color: fg, fontSize: 10, fontWeight: FontWeight.w500),
|
style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.w500),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
maxLines: 1,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
cached ? '$count items' : 'Not cached',
|
cached ? '$count items' : 'Not cached',
|
||||||
style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 9),
|
style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 10),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import geolocator_apple
|
|||||||
import package_info_plus
|
import package_info_plus
|
||||||
import printing
|
import printing
|
||||||
import quill_native_bridge_macos
|
import quill_native_bridge_macos
|
||||||
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
@@ -36,6 +37,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||||
QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin"))
|
QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin"))
|
||||||
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
@@ -1789,6 +1789,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
version: "0.28.0"
|
||||||
|
safe_device:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: safe_device
|
||||||
|
sha256: f6404b240191e83bcf814abddeb350158cdc69a6cc749aff73f199090b08e402
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.10"
|
||||||
|
share_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: share_plus
|
||||||
|
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.1.4"
|
||||||
|
share_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: share_plus_platform_interface
|
||||||
|
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.2"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies:
|
|||||||
google_fonts: ^8.0.2
|
google_fonts: ^8.0.2
|
||||||
audioplayers: ^6.1.0
|
audioplayers: ^6.1.0
|
||||||
geolocator: ^13.0.1
|
geolocator: ^13.0.1
|
||||||
|
safe_device: ^1.1.4
|
||||||
timezone: ^0.10.1
|
timezone: ^0.10.1
|
||||||
flutter_map: ^8.2.2
|
flutter_map: ^8.2.2
|
||||||
latlong2: ^0.9.0
|
latlong2: ^0.9.0
|
||||||
@@ -52,6 +53,7 @@ dependencies:
|
|||||||
flutter_image_compress: ^2.4.0
|
flutter_image_compress: ^2.4.0
|
||||||
dio: ^5.1.2
|
dio: ^5.1.2
|
||||||
package_info_plus: ^9.0.0
|
package_info_plus: ^9.0.0
|
||||||
|
share_plus: ^10.0.0
|
||||||
pub_semver: ^2.1.1
|
pub_semver: ^2.1.1
|
||||||
ota_update: ^7.1.0
|
ota_update: ^7.1.0
|
||||||
brick_offline_first: ^4.0.0
|
brick_offline_first: ^4.0.0
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include <geolocator_windows/geolocator_windows.h>
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <printing/printing_plugin.h>
|
#include <printing/printing_plugin.h>
|
||||||
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
PrintingPluginRegisterWithRegistrar(
|
PrintingPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
||||||
|
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
geolocator_windows
|
geolocator_windows
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
printing
|
printing
|
||||||
|
share_plus
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user