feat(shifts): implement submit for approval functionality
- Added `submitForApproval` method to `ShiftsRepositoryInterface` and its implementation in `ShiftsRepositoryImpl`. - Created `SubmitForApprovalUseCase` to handle the submission logic. - Updated `ShiftsBloc` to handle `SubmitForApprovalEvent` and manage submission state. - Enhanced `HistoryShiftsTab` and `MyShiftsTab` to support submission actions and display appropriate UI feedback. - Refactored date utilities for better calendar management and filtering of past shifts. - Improved UI components for better spacing and alignment. - Localized success messages for shift submission actions.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart';
|
||||
import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart';
|
||||
import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart';
|
||||
|
||||
/// Implementation of [ClockInRepositoryInterface] using the V2 REST API.
|
||||
@@ -19,7 +21,8 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
||||
final ApiResponse response = await _apiService.get(
|
||||
StaffEndpoints.clockInShiftsToday,
|
||||
);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>;
|
||||
final List<dynamic> items =
|
||||
response.data['items'] as List<dynamic>? ?? <dynamic>[];
|
||||
return items
|
||||
.map(
|
||||
(dynamic json) =>
|
||||
@@ -37,36 +40,20 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> clockIn({
|
||||
required String shiftId,
|
||||
String? notes,
|
||||
}) async {
|
||||
Future<AttendanceStatus> clockIn(ClockInArguments arguments) async {
|
||||
await _apiService.post(
|
||||
StaffEndpoints.clockIn,
|
||||
data: <String, dynamic>{
|
||||
'shiftId': shiftId,
|
||||
'sourceType': 'GEO',
|
||||
if (notes != null && notes.isNotEmpty) 'notes': notes,
|
||||
},
|
||||
data: arguments.toJson(),
|
||||
);
|
||||
// Re-fetch the attendance status to get the canonical state after clock-in.
|
||||
return getAttendanceStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> clockOut({
|
||||
String? notes,
|
||||
int? breakTimeMinutes,
|
||||
String? shiftId,
|
||||
}) async {
|
||||
Future<AttendanceStatus> clockOut(ClockOutArguments arguments) async {
|
||||
await _apiService.post(
|
||||
StaffEndpoints.clockOut,
|
||||
data: <String, dynamic>{
|
||||
if (shiftId != null) 'shiftId': shiftId,
|
||||
'sourceType': 'GEO',
|
||||
if (notes != null && notes.isNotEmpty) 'notes': notes,
|
||||
if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes,
|
||||
},
|
||||
data: arguments.toJson(),
|
||||
);
|
||||
// Re-fetch the attendance status to get the canonical state after clock-out.
|
||||
return getAttendanceStatus();
|
||||
@@ -76,14 +63,19 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
||||
static Shift _mapTodayShiftJsonToShift(Map<String, dynamic> json) {
|
||||
return Shift(
|
||||
id: json['shiftId'] as String,
|
||||
orderId: json['orderId'] as String? ?? '',
|
||||
orderId: null,
|
||||
title: json['clientName'] as String? ?? json['roleName'] as String? ?? '',
|
||||
status: ShiftStatus.assigned,
|
||||
startsAt: DateTime.parse(json['startTime'] as String),
|
||||
endsAt: DateTime.parse(json['endTime'] as String),
|
||||
locationName: json['location'] as String?,
|
||||
locationName: json['locationAddress'] as String? ??
|
||||
json['location'] as String?,
|
||||
latitude: Shift.parseDouble(json['latitude']),
|
||||
longitude: Shift.parseDouble(json['longitude']),
|
||||
geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?,
|
||||
clockInMode: json['clockInMode'] as String?,
|
||||
allowClockInOverride: json['allowClockInOverride'] as bool?,
|
||||
nfcTagId: json['nfcTagId'] as String?,
|
||||
requiredWorkers: 0,
|
||||
assignedWorkers: 0,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,50 @@
|
||||
// ignore_for_file: avoid_print
|
||||
// Print statements are intentional — background isolates cannot use
|
||||
// dart:developer or structured loggers from the DI container.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Lightweight HTTP client for background isolate API calls.
|
||||
///
|
||||
/// Cannot use Dio or DI — uses [HttpClient] directly with auth tokens
|
||||
/// from [StorageService] (SharedPreferences, works across isolates).
|
||||
class BackgroundApiClient {
|
||||
/// Creates a [BackgroundApiClient] with its own HTTP client and storage.
|
||||
BackgroundApiClient() : _client = HttpClient(), _storage = StorageService();
|
||||
|
||||
final HttpClient _client;
|
||||
final StorageService _storage;
|
||||
|
||||
/// POSTs JSON to [path] under the V2 API base URL.
|
||||
///
|
||||
/// Returns the HTTP status code, or null if no auth token is available.
|
||||
Future<int?> post(String path, Map<String, dynamic> body) async {
|
||||
final String? token = await _storage.getString(
|
||||
BackgroundGeofenceService._keyAuthToken,
|
||||
);
|
||||
if (token == null || token.isEmpty) {
|
||||
print('[BackgroundApiClient] No auth token stored, skipping POST');
|
||||
return null;
|
||||
}
|
||||
|
||||
final Uri uri = Uri.parse('${AppConfig.v2ApiBaseUrl}$path');
|
||||
final HttpClientRequest request = await _client.postUrl(uri);
|
||||
request.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
|
||||
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
|
||||
request.write(jsonEncode(body));
|
||||
final HttpClientResponse response = await request.close();
|
||||
await response.drain<void>();
|
||||
return response.statusCode;
|
||||
}
|
||||
|
||||
/// Closes the underlying [HttpClient].
|
||||
void dispose() => _client.close(force: false);
|
||||
}
|
||||
|
||||
/// Top-level callback dispatcher for background geofence tasks.
|
||||
///
|
||||
/// Must be a top-level function because workmanager executes it in a separate
|
||||
@@ -13,83 +56,134 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
/// is retained solely for this entry-point pattern.
|
||||
@pragma('vm:entry-point')
|
||||
void backgroundGeofenceDispatcher() {
|
||||
const BackgroundTaskService().executeTask(
|
||||
(String task, Map<String, dynamic>? inputData) async {
|
||||
print('[BackgroundGeofence] Task triggered: $task');
|
||||
print('[BackgroundGeofence] Input data: $inputData');
|
||||
print(
|
||||
'[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}',
|
||||
);
|
||||
const BackgroundTaskService().executeTask((
|
||||
String task,
|
||||
Map<String, dynamic>? inputData,
|
||||
) async {
|
||||
print('[BackgroundGeofence] Task triggered: $task');
|
||||
print('[BackgroundGeofence] Input data: $inputData');
|
||||
print(
|
||||
'[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}',
|
||||
);
|
||||
|
||||
final double? targetLat = inputData?['targetLat'] as double?;
|
||||
final double? targetLng = inputData?['targetLng'] as double?;
|
||||
final String? shiftId = inputData?['shiftId'] as String?;
|
||||
final double? targetLat = inputData?['targetLat'] as double?;
|
||||
final double? targetLng = inputData?['targetLng'] as double?;
|
||||
final String? shiftId = inputData?['shiftId'] as String?;
|
||||
final double geofenceRadius =
|
||||
(inputData?['geofenceRadiusMeters'] as num?)?.toDouble() ??
|
||||
BackgroundGeofenceService.defaultGeofenceRadiusMeters;
|
||||
|
||||
print(
|
||||
'[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, '
|
||||
'shiftId=$shiftId',
|
||||
);
|
||||
print(
|
||||
'[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, '
|
||||
'shiftId=$shiftId, geofenceRadius=${geofenceRadius.round()}m',
|
||||
);
|
||||
|
||||
if (targetLat == null || targetLng == null) {
|
||||
print(
|
||||
'[BackgroundGeofence] Missing target coordinates, skipping check',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const LocationService locationService = LocationService();
|
||||
final DeviceLocation location = await locationService.getCurrentLocation();
|
||||
print(
|
||||
'[BackgroundGeofence] Current position: '
|
||||
'lat=${location.latitude}, lng=${location.longitude}',
|
||||
);
|
||||
|
||||
final double distance = calculateDistance(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
targetLat,
|
||||
targetLng,
|
||||
);
|
||||
print(
|
||||
'[BackgroundGeofence] Distance from target: ${distance.round()}m',
|
||||
);
|
||||
|
||||
if (distance > BackgroundGeofenceService.geofenceRadiusMeters) {
|
||||
print(
|
||||
'[BackgroundGeofence] Worker is outside geofence '
|
||||
'(${distance.round()}m > '
|
||||
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), '
|
||||
'showing notification',
|
||||
);
|
||||
|
||||
final String title = inputData?['leftGeofenceTitle'] as String? ??
|
||||
"You've Left the Workplace";
|
||||
final String body = inputData?['leftGeofenceBody'] as String? ??
|
||||
'You appear to be more than 500m from your shift location.';
|
||||
|
||||
final NotificationService notificationService =
|
||||
NotificationService();
|
||||
await notificationService.showNotification(
|
||||
id: BackgroundGeofenceService.leftGeofenceNotificationId,
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
} else {
|
||||
print(
|
||||
'[BackgroundGeofence] Worker is within geofence '
|
||||
'(${distance.round()}m <= '
|
||||
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[BackgroundGeofence] Error during background check: $e');
|
||||
}
|
||||
|
||||
print('[BackgroundGeofence] Background check completed');
|
||||
if (targetLat == null || targetLng == null) {
|
||||
print('[BackgroundGeofence] Missing target coordinates, skipping check');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final BackgroundApiClient client = BackgroundApiClient();
|
||||
try {
|
||||
const LocationService locationService = LocationService();
|
||||
final DeviceLocation location = await locationService
|
||||
.getCurrentLocation();
|
||||
print(
|
||||
'[BackgroundGeofence] Current position: '
|
||||
'lat=${location.latitude}, lng=${location.longitude}',
|
||||
);
|
||||
|
||||
final double distance = calculateDistance(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
targetLat,
|
||||
targetLng,
|
||||
);
|
||||
print('[BackgroundGeofence] Distance from target: ${distance.round()}m');
|
||||
|
||||
// POST location stream to the V2 API before geofence check.
|
||||
unawaited(
|
||||
_postLocationStream(
|
||||
client: client,
|
||||
shiftId: shiftId,
|
||||
location: location,
|
||||
),
|
||||
);
|
||||
|
||||
if (distance > geofenceRadius) {
|
||||
print(
|
||||
'[BackgroundGeofence] Worker is outside geofence '
|
||||
'(${distance.round()}m > '
|
||||
'${geofenceRadius.round()}m), '
|
||||
'showing notification',
|
||||
);
|
||||
|
||||
// Fallback for when localized strings are not available in the
|
||||
// background isolate. The primary path passes localized strings
|
||||
// via inputData from the UI layer.
|
||||
final String title =
|
||||
inputData?['leftGeofenceTitle'] as String? ??
|
||||
'You have left the work area';
|
||||
final String body =
|
||||
inputData?['leftGeofenceBody'] as String? ??
|
||||
'You appear to have moved outside your shift location.';
|
||||
|
||||
final NotificationService notificationService = NotificationService();
|
||||
await notificationService.showNotification(
|
||||
id: BackgroundGeofenceService.leftGeofenceNotificationId,
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
} else {
|
||||
print(
|
||||
'[BackgroundGeofence] Worker is within geofence '
|
||||
'(${distance.round()}m <= '
|
||||
'${geofenceRadius.round()}m)',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[BackgroundGeofence] Error during background check: $e');
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
|
||||
print('[BackgroundGeofence] Background check completed');
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// Posts a location data point to the V2 location-streams endpoint.
|
||||
///
|
||||
/// Uses [BackgroundApiClient] for isolate-safe HTTP access.
|
||||
/// Failures are silently caught — location streaming is best-effort.
|
||||
Future<void> _postLocationStream({
|
||||
required BackgroundApiClient client,
|
||||
required String? shiftId,
|
||||
required DeviceLocation location,
|
||||
}) async {
|
||||
if (shiftId == null) return;
|
||||
|
||||
try {
|
||||
final int? status = await client.post(
|
||||
StaffEndpoints.locationStreams.path,
|
||||
<String, dynamic>{
|
||||
'shiftId': shiftId,
|
||||
'sourceType': 'GEO',
|
||||
'points': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'capturedAt': location.timestamp.toUtc().toIso8601String(),
|
||||
'latitude': location.latitude,
|
||||
'longitude': location.longitude,
|
||||
'accuracyMeters': location.accuracy,
|
||||
},
|
||||
],
|
||||
'metadata': <String, String>{'source': 'background-workmanager'},
|
||||
},
|
||||
);
|
||||
print('[BackgroundGeofence] Location stream POST status: $status');
|
||||
} catch (e) {
|
||||
print('[BackgroundGeofence] Location stream POST failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Service that manages periodic background geofence checks while clocked in.
|
||||
@@ -98,13 +192,12 @@ void backgroundGeofenceDispatcher() {
|
||||
/// delivery is handled by [ClockInNotificationService]. The background isolate
|
||||
/// logic lives in the top-level [backgroundGeofenceDispatcher] function above.
|
||||
class BackgroundGeofenceService {
|
||||
|
||||
/// Creates a [BackgroundGeofenceService] instance.
|
||||
BackgroundGeofenceService({
|
||||
required BackgroundTaskService backgroundTaskService,
|
||||
required StorageService storageService,
|
||||
}) : _backgroundTaskService = backgroundTaskService,
|
||||
_storageService = storageService;
|
||||
}) : _backgroundTaskService = backgroundTaskService,
|
||||
_storageService = storageService;
|
||||
|
||||
/// The core background task service for scheduling periodic work.
|
||||
final BackgroundTaskService _backgroundTaskService;
|
||||
@@ -124,6 +217,9 @@ class BackgroundGeofenceService {
|
||||
/// Storage key for the active tracking flag.
|
||||
static const String _keyTrackingActive = 'geofence_tracking_active';
|
||||
|
||||
/// Storage key for the Firebase auth token used in background isolate.
|
||||
static const String _keyAuthToken = 'geofence_auth_token';
|
||||
|
||||
/// Unique task name for the periodic background check.
|
||||
static const String taskUniqueName = 'geofence_background_check';
|
||||
|
||||
@@ -136,8 +232,12 @@ class BackgroundGeofenceService {
|
||||
/// it directly (background isolate has no DI access).
|
||||
static const int leftGeofenceNotificationId = 2;
|
||||
|
||||
/// Geofence radius in meters.
|
||||
static const double geofenceRadiusMeters = 500;
|
||||
/// Default geofence radius in meters, used as fallback when no per-shift
|
||||
/// radius is provided.
|
||||
static const double defaultGeofenceRadiusMeters = 500;
|
||||
|
||||
/// Storage key for the per-shift geofence radius.
|
||||
static const String _keyGeofenceRadius = 'geofence_radius_meters';
|
||||
|
||||
/// Starts periodic 15-minute background geofence checks.
|
||||
///
|
||||
@@ -150,12 +250,17 @@ class BackgroundGeofenceService {
|
||||
required String shiftId,
|
||||
required String leftGeofenceTitle,
|
||||
required String leftGeofenceBody,
|
||||
double geofenceRadiusMeters = defaultGeofenceRadiusMeters,
|
||||
String? authToken,
|
||||
}) async {
|
||||
await Future.wait(<Future<bool>>[
|
||||
_storageService.setDouble(_keyTargetLat, targetLat),
|
||||
_storageService.setDouble(_keyTargetLng, targetLng),
|
||||
_storageService.setString(_keyShiftId, shiftId),
|
||||
_storageService.setDouble(_keyGeofenceRadius, geofenceRadiusMeters),
|
||||
_storageService.setBool(_keyTrackingActive, true),
|
||||
if (authToken != null)
|
||||
_storageService.setString(_keyAuthToken, authToken),
|
||||
]);
|
||||
|
||||
await _backgroundTaskService.registerPeriodicTask(
|
||||
@@ -166,6 +271,7 @@ class BackgroundGeofenceService {
|
||||
'targetLat': targetLat,
|
||||
'targetLng': targetLng,
|
||||
'shiftId': shiftId,
|
||||
'geofenceRadiusMeters': geofenceRadiusMeters,
|
||||
'leftGeofenceTitle': leftGeofenceTitle,
|
||||
'leftGeofenceBody': leftGeofenceBody,
|
||||
},
|
||||
@@ -182,10 +288,20 @@ class BackgroundGeofenceService {
|
||||
_storageService.remove(_keyTargetLat),
|
||||
_storageService.remove(_keyTargetLng),
|
||||
_storageService.remove(_keyShiftId),
|
||||
_storageService.remove(_keyGeofenceRadius),
|
||||
_storageService.remove(_keyAuthToken),
|
||||
_storageService.setBool(_keyTrackingActive, false),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Stores a fresh auth token for background isolate API calls.
|
||||
///
|
||||
/// Called by the foreground [GeofenceBloc] both initially and
|
||||
/// periodically (~45 min) to keep the token fresh across long shifts.
|
||||
Future<void> storeAuthToken(String token) async {
|
||||
await _storageService.setString(_keyAuthToken, token);
|
||||
}
|
||||
|
||||
/// Whether background tracking is currently active.
|
||||
Future<bool> get isTrackingActive async {
|
||||
final bool? active = await _storageService.getBool(_keyTrackingActive);
|
||||
|
||||
@@ -7,13 +7,99 @@ class ClockInArguments extends UseCaseArgument {
|
||||
const ClockInArguments({
|
||||
required this.shiftId,
|
||||
this.notes,
|
||||
this.deviceId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.accuracyMeters,
|
||||
this.capturedAt,
|
||||
this.overrideReason,
|
||||
this.nfcTagId,
|
||||
this.proofNonce,
|
||||
this.proofTimestamp,
|
||||
this.attestationProvider,
|
||||
this.attestationToken,
|
||||
});
|
||||
|
||||
/// The ID of the shift to clock in to.
|
||||
final String shiftId;
|
||||
|
||||
/// Optional notes provided by the user during clock-in.
|
||||
final String? notes;
|
||||
|
||||
/// Device identifier for audit trail.
|
||||
final String? deviceId;
|
||||
|
||||
/// Latitude of the device at clock-in time.
|
||||
final double? latitude;
|
||||
|
||||
/// Longitude of the device at clock-in time.
|
||||
final double? longitude;
|
||||
|
||||
/// Horizontal accuracy of the GPS fix in meters.
|
||||
final double? accuracyMeters;
|
||||
|
||||
/// Timestamp when the location was captured on-device.
|
||||
final DateTime? capturedAt;
|
||||
|
||||
/// Justification when the worker overrides a geofence check.
|
||||
final String? overrideReason;
|
||||
|
||||
/// NFC tag identifier when clocking in via NFC tap.
|
||||
final String? nfcTagId;
|
||||
|
||||
/// Server-generated nonce for proof-of-presence validation.
|
||||
final String? proofNonce;
|
||||
|
||||
/// Device-local timestamp when the proof was captured.
|
||||
final DateTime? proofTimestamp;
|
||||
|
||||
/// Name of the attestation provider (e.g. `'apple'`, `'android'`).
|
||||
final String? attestationProvider;
|
||||
|
||||
/// Signed attestation token from the device integrity API.
|
||||
final String? attestationToken;
|
||||
|
||||
/// Serializes the arguments to a JSON map for the V2 API request body.
|
||||
///
|
||||
/// Only includes non-null fields. The `sourceType` is inferred from
|
||||
/// whether [nfcTagId] is present.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'shiftId': shiftId,
|
||||
'sourceType': nfcTagId != null ? 'NFC' : 'GEO',
|
||||
if (notes != null && notes!.isNotEmpty) 'notes': notes,
|
||||
if (deviceId != null) 'deviceId': deviceId,
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (accuracyMeters != null) 'accuracyMeters': accuracyMeters,
|
||||
if (capturedAt != null)
|
||||
'capturedAt': capturedAt!.toUtc().toIso8601String(),
|
||||
if (overrideReason != null && overrideReason!.isNotEmpty)
|
||||
'overrideReason': overrideReason,
|
||||
if (nfcTagId != null) 'nfcTagId': nfcTagId,
|
||||
if (proofNonce != null) 'proofNonce': proofNonce,
|
||||
if (proofTimestamp != null)
|
||||
'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(),
|
||||
if (attestationProvider != null)
|
||||
'attestationProvider': attestationProvider,
|
||||
if (attestationToken != null) 'attestationToken': attestationToken,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[shiftId, notes];
|
||||
List<Object?> get props => <Object?>[
|
||||
shiftId,
|
||||
notes,
|
||||
deviceId,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracyMeters,
|
||||
capturedAt,
|
||||
overrideReason,
|
||||
nfcTagId,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
attestationProvider,
|
||||
attestationToken,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@ class ClockOutArguments extends UseCaseArgument {
|
||||
this.notes,
|
||||
this.breakTimeMinutes,
|
||||
this.shiftId,
|
||||
this.deviceId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.accuracyMeters,
|
||||
this.capturedAt,
|
||||
this.overrideReason,
|
||||
this.nfcTagId,
|
||||
this.proofNonce,
|
||||
this.proofTimestamp,
|
||||
this.attestationProvider,
|
||||
this.attestationToken,
|
||||
});
|
||||
|
||||
/// Optional notes provided by the user during clock-out.
|
||||
@@ -18,6 +29,82 @@ class ClockOutArguments extends UseCaseArgument {
|
||||
/// The shift id used by the V2 API to resolve the assignment.
|
||||
final String? shiftId;
|
||||
|
||||
/// Device identifier for audit trail.
|
||||
final String? deviceId;
|
||||
|
||||
/// Latitude of the device at clock-out time.
|
||||
final double? latitude;
|
||||
|
||||
/// Longitude of the device at clock-out time.
|
||||
final double? longitude;
|
||||
|
||||
/// Horizontal accuracy of the GPS fix in meters.
|
||||
final double? accuracyMeters;
|
||||
|
||||
/// Timestamp when the location was captured on-device.
|
||||
final DateTime? capturedAt;
|
||||
|
||||
/// Justification when the worker overrides a geofence check.
|
||||
final String? overrideReason;
|
||||
|
||||
/// NFC tag identifier when clocking out via NFC tap.
|
||||
final String? nfcTagId;
|
||||
|
||||
/// Server-generated nonce for proof-of-presence validation.
|
||||
final String? proofNonce;
|
||||
|
||||
/// Device-local timestamp when the proof was captured.
|
||||
final DateTime? proofTimestamp;
|
||||
|
||||
/// Name of the attestation provider (e.g. `'apple'`, `'android'`).
|
||||
final String? attestationProvider;
|
||||
|
||||
/// Signed attestation token from the device integrity API.
|
||||
final String? attestationToken;
|
||||
|
||||
/// Serializes the arguments to a JSON map for the V2 API request body.
|
||||
///
|
||||
/// Only includes non-null fields. The `sourceType` is inferred from
|
||||
/// whether [nfcTagId] is present.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
if (shiftId != null) 'shiftId': shiftId,
|
||||
'sourceType': nfcTagId != null ? 'NFC' : 'GEO',
|
||||
if (notes != null && notes!.isNotEmpty) 'notes': notes,
|
||||
if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes,
|
||||
if (deviceId != null) 'deviceId': deviceId,
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (accuracyMeters != null) 'accuracyMeters': accuracyMeters,
|
||||
if (capturedAt != null)
|
||||
'capturedAt': capturedAt!.toUtc().toIso8601String(),
|
||||
if (overrideReason != null && overrideReason!.isNotEmpty)
|
||||
'overrideReason': overrideReason,
|
||||
if (nfcTagId != null) 'nfcTagId': nfcTagId,
|
||||
if (proofNonce != null) 'proofNonce': proofNonce,
|
||||
if (proofTimestamp != null)
|
||||
'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(),
|
||||
if (attestationProvider != null)
|
||||
'attestationProvider': attestationProvider,
|
||||
if (attestationToken != null) 'attestationToken': attestationToken,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[notes, breakTimeMinutes, shiftId];
|
||||
List<Object?> get props => <Object?>[
|
||||
notes,
|
||||
breakTimeMinutes,
|
||||
shiftId,
|
||||
deviceId,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracyMeters,
|
||||
capturedAt,
|
||||
overrideReason,
|
||||
nfcTagId,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
attestationProvider,
|
||||
attestationToken,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for Clock In/Out functionality
|
||||
abstract class ClockInRepositoryInterface {
|
||||
|
||||
import '../arguments/clock_in_arguments.dart';
|
||||
import '../arguments/clock_out_arguments.dart';
|
||||
|
||||
/// Repository interface for Clock In/Out functionality.
|
||||
abstract interface class ClockInRepositoryInterface {
|
||||
|
||||
/// Retrieves the shifts assigned to the user for the current day.
|
||||
/// Returns empty list if no shift is assigned for today.
|
||||
Future<List<Shift>> getTodaysShifts();
|
||||
@@ -11,17 +14,12 @@ abstract class ClockInRepositoryInterface {
|
||||
/// This helps in restoring the UI state if the app was killed.
|
||||
Future<AttendanceStatus> getAttendanceStatus();
|
||||
|
||||
/// Checks the user in for the specified [shiftId].
|
||||
/// Checks the user in using the fields from [arguments].
|
||||
/// Returns the updated [AttendanceStatus].
|
||||
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
|
||||
Future<AttendanceStatus> clockIn(ClockInArguments arguments);
|
||||
|
||||
/// Checks the user out for the currently active shift.
|
||||
/// Checks the user out using the fields from [arguments].
|
||||
///
|
||||
/// The V2 API resolves the assignment from [shiftId]. Optionally accepts
|
||||
/// [breakTimeMinutes] if tracked.
|
||||
Future<AttendanceStatus> clockOut({
|
||||
String? notes,
|
||||
int? breakTimeMinutes,
|
||||
String? shiftId,
|
||||
});
|
||||
/// The V2 API resolves the assignment from the shift ID.
|
||||
Future<AttendanceStatus> clockOut(ClockOutArguments arguments);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@ class ClockInUseCase implements UseCase<ClockInArguments, AttendanceStatus> {
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> call(ClockInArguments arguments) {
|
||||
return _repository.clockIn(
|
||||
shiftId: arguments.shiftId,
|
||||
notes: arguments.notes,
|
||||
);
|
||||
return _repository.clockIn(arguments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,6 @@ class ClockOutUseCase implements UseCase<ClockOutArguments, AttendanceStatus> {
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> call(ClockOutArguments arguments) {
|
||||
return _repository.clockOut(
|
||||
notes: arguments.notes,
|
||||
breakTimeMinutes: arguments.breakTimeMinutes,
|
||||
shiftId: arguments.shiftId,
|
||||
);
|
||||
return _repository.clockOut(arguments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../validators/clock_in_validation_context.dart';
|
||||
import '../validators/validators/time_window_validator.dart';
|
||||
|
||||
/// Holds the computed time-window check-in/check-out availability flags.
|
||||
class TimeWindowFlags {
|
||||
/// Creates a [TimeWindowFlags] with default allowed values.
|
||||
const TimeWindowFlags({
|
||||
this.isCheckInAllowed = true,
|
||||
this.isCheckOutAllowed = true,
|
||||
this.checkInAvailabilityTime,
|
||||
this.checkOutAvailabilityTime,
|
||||
});
|
||||
|
||||
/// Whether the time window currently allows check-in.
|
||||
final bool isCheckInAllowed;
|
||||
|
||||
/// Whether the time window currently allows check-out.
|
||||
final bool isCheckOutAllowed;
|
||||
|
||||
/// Formatted time when check-in becomes available, or `null`.
|
||||
final String? checkInAvailabilityTime;
|
||||
|
||||
/// Formatted time when check-out becomes available, or `null`.
|
||||
final String? checkOutAvailabilityTime;
|
||||
}
|
||||
|
||||
/// Computes time-window check-in/check-out flags for the given [shift].
|
||||
///
|
||||
/// Returns a [TimeWindowFlags] indicating whether the current time falls
|
||||
/// within the allowed clock-in and clock-out windows. Uses
|
||||
/// [TimeWindowValidator] for the underlying validation logic.
|
||||
TimeWindowFlags computeTimeWindowFlags(Shift? shift) {
|
||||
if (shift == null) {
|
||||
return const TimeWindowFlags();
|
||||
}
|
||||
|
||||
const TimeWindowValidator validator = TimeWindowValidator();
|
||||
final DateTime shiftStart = shift.startsAt;
|
||||
final DateTime shiftEnd = shift.endsAt;
|
||||
|
||||
// Check-in window.
|
||||
bool isCheckInAllowed = true;
|
||||
String? checkInAvailabilityTime;
|
||||
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: shiftStart,
|
||||
);
|
||||
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
||||
if (!isCheckInAllowed) {
|
||||
checkInAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||
}
|
||||
|
||||
// Check-out window.
|
||||
bool isCheckOutAllowed = true;
|
||||
String? checkOutAvailabilityTime;
|
||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||
if (!isCheckOutAllowed) {
|
||||
checkOutAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
|
||||
return TimeWindowFlags(
|
||||
isCheckInAllowed: isCheckInAllowed,
|
||||
isCheckOutAllowed: isCheckOutAllowed,
|
||||
checkInAvailabilityTime: checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: checkOutAvailabilityTime,
|
||||
);
|
||||
}
|
||||
@@ -4,21 +4,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../../domain/arguments/clock_in_arguments.dart';
|
||||
import '../../../domain/arguments/clock_out_arguments.dart';
|
||||
import '../../../domain/usecases/clock_in_usecase.dart';
|
||||
import '../../../domain/usecases/clock_out_usecase.dart';
|
||||
import '../../../domain/usecases/get_attendance_status_usecase.dart';
|
||||
import '../../../domain/usecases/get_todays_shift_usecase.dart';
|
||||
import '../../../domain/validators/clock_in_validation_context.dart';
|
||||
import '../../../domain/validators/clock_in_validation_result.dart';
|
||||
import '../../../domain/validators/validators/composite_clock_in_validator.dart';
|
||||
import '../../../domain/validators/validators/time_window_validator.dart';
|
||||
import '../geofence/geofence_bloc.dart';
|
||||
import '../geofence/geofence_event.dart';
|
||||
import '../geofence/geofence_state.dart';
|
||||
import 'clock_in_event.dart';
|
||||
import 'clock_in_state.dart';
|
||||
import 'package:staff_clock_in/src/data/services/background_geofence_service.dart';
|
||||
import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart';
|
||||
import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart';
|
||||
import 'package:staff_clock_in/src/domain/usecases/clock_in_usecase.dart';
|
||||
import 'package:staff_clock_in/src/domain/usecases/clock_out_usecase.dart';
|
||||
import 'package:staff_clock_in/src/domain/usecases/get_attendance_status_usecase.dart';
|
||||
import 'package:staff_clock_in/src/domain/usecases/get_todays_shift_usecase.dart';
|
||||
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart';
|
||||
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart';
|
||||
import 'package:staff_clock_in/src/domain/utils/time_window_utils.dart';
|
||||
import 'package:staff_clock_in/src/domain/validators/validators/composite_clock_in_validator.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_bloc.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_event.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_state.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_event.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_state.dart';
|
||||
|
||||
/// BLoC responsible for clock-in/clock-out operations and shift management.
|
||||
///
|
||||
@@ -92,7 +93,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
selectedShift ??= shifts.last;
|
||||
}
|
||||
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
|
||||
final TimeWindowFlags timeFlags = computeTimeWindowFlags(
|
||||
selectedShift,
|
||||
);
|
||||
|
||||
@@ -122,7 +123,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
ShiftSelected event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift);
|
||||
final TimeWindowFlags timeFlags = computeTimeWindowFlags(event.shift);
|
||||
emit(state.copyWith(
|
||||
selectedShift: event.shift,
|
||||
isCheckInAllowed: timeFlags.isCheckInAllowed,
|
||||
@@ -201,8 +202,20 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final DeviceLocation? location = geofenceState.currentLocation;
|
||||
|
||||
final AttendanceStatus newStatus = await _clockIn(
|
||||
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
|
||||
ClockInArguments(
|
||||
shiftId: event.shiftId,
|
||||
notes: event.notes,
|
||||
latitude: location?.latitude,
|
||||
longitude: location?.longitude,
|
||||
accuracyMeters: location?.accuracy,
|
||||
capturedAt: location?.timestamp,
|
||||
overrideReason: geofenceState.isGeofenceOverridden
|
||||
? geofenceState.overrideNotes
|
||||
: null,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
@@ -224,20 +237,39 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
|
||||
/// Handles a clock-out request.
|
||||
///
|
||||
/// Emits a failure state and returns early when no active shift ID is
|
||||
/// available — this prevents the API call from being made without a valid
|
||||
/// shift reference.
|
||||
/// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc].
|
||||
Future<void> _onCheckOut(
|
||||
CheckOutRequested event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
final String? activeShiftId = state.attendance.activeShiftId;
|
||||
if (activeShiftId == null) {
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: 'errors.shift.no_active_shift',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final GeofenceState currentGeofence = _geofenceBloc.state;
|
||||
final DeviceLocation? location = currentGeofence.currentLocation;
|
||||
|
||||
final AttendanceStatus newStatus = await _clockOut(
|
||||
ClockOutArguments(
|
||||
notes: event.notes,
|
||||
breakTimeMinutes: event.breakTimeMinutes ?? 0,
|
||||
shiftId: state.attendance.activeShiftId,
|
||||
breakTimeMinutes: event.breakTimeMinutes,
|
||||
shiftId: activeShiftId,
|
||||
latitude: location?.latitude,
|
||||
longitude: location?.longitude,
|
||||
accuracyMeters: location?.accuracy,
|
||||
capturedAt: location?.timestamp,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
@@ -269,7 +301,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
if (state.status != ClockInStatus.success) return;
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
|
||||
final TimeWindowFlags timeFlags = computeTimeWindowFlags(
|
||||
state.selectedShift,
|
||||
);
|
||||
emit(state.copyWith(
|
||||
@@ -299,52 +331,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
return super.close();
|
||||
}
|
||||
|
||||
/// Computes time-window check-in/check-out flags for the given [shift].
|
||||
///
|
||||
/// Uses [TimeWindowValidator] so this business logic stays out of widgets.
|
||||
static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) {
|
||||
if (shift == null) {
|
||||
return const _TimeWindowFlags();
|
||||
}
|
||||
|
||||
const TimeWindowValidator validator = TimeWindowValidator();
|
||||
final DateTime shiftStart = shift.startsAt;
|
||||
final DateTime shiftEnd = shift.endsAt;
|
||||
|
||||
// Check-in window.
|
||||
bool isCheckInAllowed = true;
|
||||
String? checkInAvailabilityTime;
|
||||
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: shiftStart,
|
||||
);
|
||||
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
||||
if (!isCheckInAllowed) {
|
||||
checkInAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||
}
|
||||
|
||||
// Check-out window.
|
||||
bool isCheckOutAllowed = true;
|
||||
String? checkOutAvailabilityTime;
|
||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||
if (!isCheckOutAllowed) {
|
||||
checkOutAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
|
||||
return _TimeWindowFlags(
|
||||
isCheckInAllowed: isCheckInAllowed,
|
||||
isCheckOutAllowed: isCheckOutAllowed,
|
||||
checkInAvailabilityTime: checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: checkOutAvailabilityTime,
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the
|
||||
/// geofence has target coordinates.
|
||||
void _dispatchBackgroundTrackingStarted({
|
||||
@@ -361,6 +347,9 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
shiftId: activeShiftId,
|
||||
targetLat: geofenceState.targetLat!,
|
||||
targetLng: geofenceState.targetLng!,
|
||||
geofenceRadiusMeters:
|
||||
state.selectedShift?.geofenceRadiusMeters?.toDouble() ??
|
||||
BackgroundGeofenceService.defaultGeofenceRadiusMeters,
|
||||
greetingTitle: event.clockInGreetingTitle,
|
||||
greetingBody: event.clockInGreetingBody,
|
||||
leftGeofenceTitle: event.leftGeofenceTitle,
|
||||
@@ -370,26 +359,3 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal value holder for time-window computation results.
|
||||
class _TimeWindowFlags {
|
||||
/// Creates a [_TimeWindowFlags] with default allowed values.
|
||||
const _TimeWindowFlags({
|
||||
this.isCheckInAllowed = true,
|
||||
this.isCheckOutAllowed = true,
|
||||
this.checkInAvailabilityTime,
|
||||
this.checkOutAvailabilityTime,
|
||||
});
|
||||
|
||||
/// Whether the time window currently allows check-in.
|
||||
final bool isCheckInAllowed;
|
||||
|
||||
/// Whether the time window currently allows check-out.
|
||||
final bool isCheckOutAllowed;
|
||||
|
||||
/// Formatted time when check-in becomes available, or `null`.
|
||||
final String? checkInAvailabilityTime;
|
||||
|
||||
/// Formatted time when check-out becomes available, or `null`.
|
||||
final String? checkOutAvailabilityTime;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
@@ -25,9 +26,11 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
required GeofenceServiceInterface geofenceService,
|
||||
required BackgroundGeofenceService backgroundGeofenceService,
|
||||
required ClockInNotificationService notificationService,
|
||||
required AuthTokenProvider authTokenProvider,
|
||||
}) : _geofenceService = geofenceService,
|
||||
_backgroundGeofenceService = backgroundGeofenceService,
|
||||
_notificationService = notificationService,
|
||||
_authTokenProvider = authTokenProvider,
|
||||
super(const GeofenceState.initial()) {
|
||||
on<GeofenceStarted>(_onStarted);
|
||||
on<GeofenceResultUpdated>(_onResultUpdated);
|
||||
@@ -52,6 +55,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
/// The notification service for clock-in related notifications.
|
||||
final ClockInNotificationService _notificationService;
|
||||
|
||||
/// Provides fresh Firebase ID tokens for background isolate storage.
|
||||
final AuthTokenProvider _authTokenProvider;
|
||||
|
||||
/// Periodic timer that refreshes the auth token in SharedPreferences
|
||||
/// so the background isolate always has a valid token for API calls.
|
||||
Timer? _tokenRefreshTimer;
|
||||
|
||||
/// How often to refresh the auth token for background use.
|
||||
/// Set to 45 minutes — well before Firebase's 1-hour expiry.
|
||||
static const Duration _tokenRefreshInterval = Duration(minutes: 45);
|
||||
|
||||
/// Active subscription to the foreground geofence location stream.
|
||||
StreamSubscription<GeofenceResult>? _geofenceSubscription;
|
||||
|
||||
@@ -239,6 +253,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
shiftId: event.shiftId,
|
||||
leftGeofenceTitle: event.leftGeofenceTitle,
|
||||
leftGeofenceBody: event.leftGeofenceBody,
|
||||
geofenceRadiusMeters: event.geofenceRadiusMeters,
|
||||
);
|
||||
|
||||
// Get and store initial auth token for background location streaming.
|
||||
await _refreshAndStoreToken();
|
||||
|
||||
// Start periodic token refresh to keep it valid across long shifts.
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_tokenRefreshTimer = Timer.periodic(
|
||||
_tokenRefreshInterval,
|
||||
(_) => _refreshAndStoreToken(),
|
||||
);
|
||||
|
||||
// Show greeting notification using localized strings from the UI.
|
||||
@@ -261,6 +286,9 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
BackgroundTrackingStopped event,
|
||||
Emitter<GeofenceState> emit,
|
||||
) async {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_tokenRefreshTimer = null;
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
@@ -298,6 +326,8 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
GeofenceStopped event,
|
||||
Emitter<GeofenceState> emit,
|
||||
) async {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_tokenRefreshTimer = null;
|
||||
await _geofenceSubscription?.cancel();
|
||||
_geofenceSubscription = null;
|
||||
await _serviceStatusSubscription?.cancel();
|
||||
@@ -305,8 +335,26 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
emit(const GeofenceState.initial());
|
||||
}
|
||||
|
||||
/// Fetches a fresh Firebase ID token and stores it in SharedPreferences
|
||||
/// for the background isolate to use.
|
||||
Future<void> _refreshAndStoreToken() async {
|
||||
try {
|
||||
final String? token = await _authTokenProvider.getIdToken(
|
||||
forceRefresh: true,
|
||||
);
|
||||
if (token != null) {
|
||||
await _backgroundGeofenceService.storeAuthToken(token);
|
||||
}
|
||||
} catch (e) {
|
||||
// Best-effort — if token refresh fails, the background isolate will
|
||||
// skip the POST (it checks for null/empty token).
|
||||
developer.log('Token refresh failed: $e', name: 'GeofenceBloc', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_geofenceSubscription?.cancel();
|
||||
_serviceStatusSubscription?.cancel();
|
||||
return super.close();
|
||||
|
||||
@@ -73,6 +73,7 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
||||
required this.greetingBody,
|
||||
required this.leftGeofenceTitle,
|
||||
required this.leftGeofenceBody,
|
||||
this.geofenceRadiusMeters = 500,
|
||||
});
|
||||
|
||||
/// The shift ID being tracked.
|
||||
@@ -84,6 +85,9 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
||||
/// Target longitude of the shift location.
|
||||
final double targetLng;
|
||||
|
||||
/// Geofence radius in meters for this shift. Defaults to 500m.
|
||||
final double geofenceRadiusMeters;
|
||||
|
||||
/// Localized greeting notification title passed from the UI layer.
|
||||
final String greetingTitle;
|
||||
|
||||
@@ -103,6 +107,7 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
||||
shiftId,
|
||||
targetLat,
|
||||
targetLng,
|
||||
geofenceRadiusMeters,
|
||||
greetingTitle,
|
||||
greetingBody,
|
||||
leftGeofenceTitle,
|
||||
|
||||
@@ -56,7 +56,7 @@ class AttendanceCard extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
@@ -65,13 +65,13 @@ class AttendanceCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (scheduledTime != null) ...<Widget>[
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
"Scheduled: $scheduledTime",
|
||||
style: UiTypography.footnote2r.textInactive,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.footnote1r.copyWith(color: UiColors.primary),
|
||||
|
||||
@@ -281,7 +281,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
|
||||
size: 12,
|
||||
color: UiColors.textInactive,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
i18n.starts_in(min: _getMinutesUntilShift().toString()),
|
||||
style: UiTypography.titleUppercase4m.textSecondary,
|
||||
|
||||
@@ -55,7 +55,7 @@ class DateSelector extends StatelessWidget {
|
||||
: UiColors.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
|
||||
@@ -228,7 +228,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
isExpanded: true,
|
||||
|
||||
@@ -79,6 +79,7 @@ class StaffClockInModule extends Module {
|
||||
geofenceService: i.get<GeofenceServiceInterface>(),
|
||||
backgroundGeofenceService: i.get<BackgroundGeofenceService>(),
|
||||
notificationService: i.get<ClockInNotificationService>(),
|
||||
authTokenProvider: i.get<AuthTokenProvider>(),
|
||||
),
|
||||
);
|
||||
i.add<ClockInBloc>(
|
||||
|
||||
Reference in New Issue
Block a user