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:
Achintha Isuru
2026-03-18 14:37:55 -04:00
parent 3e5b6af8dc
commit 3a5f2cc9c6
50 changed files with 1269 additions and 408 deletions

View File

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

View File

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

View File

@@ -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,
];
}

View File

@@ -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,
];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -228,7 +228,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
),
),
),
const SizedBox(width: 10),
const SizedBox(width: UiConstants.space2),
Expanded(
child: DropdownButtonFormField<String>(
isExpanded: true,

View File

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

View File

@@ -32,14 +32,11 @@ class _TimeCardPageState extends State<TimeCardPage> {
@override
Widget build(BuildContext context) {
final Translations t = Translations.of(context);
return BlocProvider.value(
value: _bloc,
child: Scaffold(
appBar: UiAppBar(
title: t.staff_time_card.title,
showBackButton: true,
),
body: BlocConsumer<TimeCardBloc, TimeCardState>(
return Scaffold(
appBar: UiAppBar(title: t.staff_time_card.title, showBackButton: true),
body: BlocProvider.value(
value: _bloc,
child: BlocConsumer<TimeCardBloc, TimeCardState>(
listener: (BuildContext context, TimeCardState state) {
if (state is TimeCardError) {
UiSnackbar.show(

View File

@@ -17,9 +17,7 @@ class ShiftHistoryList extends StatelessWidget {
children: <Widget>[
Text(
t.staff_time_card.shift_history,
style: UiTypography.title2b.copyWith(
color: UiColors.textPrimary,
),
style: UiTypography.title2b,
),
const SizedBox(height: UiConstants.space3),
if (timesheets.isEmpty)

View File

@@ -6,7 +6,6 @@ import 'package:krow_domain/krow_domain.dart';
/// A card widget displaying details of a single shift/timecard.
class TimesheetCard extends StatelessWidget {
const TimesheetCard({super.key, required this.timesheet});
final TimeCardEntry timesheet;
@@ -25,9 +24,10 @@ class TimesheetCard extends StatelessWidget {
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
border: Border.all(color: UiColors.border, width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -60,20 +60,22 @@ class TimesheetCard extends StatelessWidget {
if (timesheet.clockInAt != null && timesheet.clockOutAt != null)
_IconText(
icon: UiIcons.clock,
text: '${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}',
text:
'${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}',
),
if (timesheet.location != null)
_IconText(icon: UiIcons.mapPin, text: timesheet.location!),
],
),
const SizedBox(height: UiConstants.space3),
const SizedBox(height: UiConstants.space5),
Container(
padding: const EdgeInsets.only(top: UiConstants.space3),
padding: const EdgeInsets.only(top: UiConstants.space4),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: UiColors.border)),
border: Border(top: BorderSide(color: UiColors.border, width: 0.5)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(
'${totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}',
@@ -81,7 +83,7 @@ class TimesheetCard extends StatelessWidget {
),
Text(
'\$${totalPay.toStringAsFixed(2)}',
style: UiTypography.title2b.primary,
style: UiTypography.title1b,
),
],
),
@@ -93,7 +95,6 @@ class TimesheetCard extends StatelessWidget {
}
class _IconText extends StatelessWidget {
const _IconText({required this.icon, required this.text});
final IconData icon;
final String text;
@@ -105,10 +106,7 @@ class _IconText extends StatelessWidget {
children: <Widget>[
Icon(icon, size: 14, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space1),
Text(
text,
style: UiTypography.body2r.textSecondary,
),
Text(text, style: UiTypography.body2r.textSecondary),
],
);
}

View File

@@ -147,6 +147,16 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
);
}
@override
Future<void> submitForApproval(String shiftId, {String? note}) async {
await _apiService.post(
StaffEndpoints.shiftSubmitForApproval(shiftId),
data: <String, dynamic>{
if (note != null) 'note': note,
},
);
}
@override
Future<bool> getProfileCompletion() async {
final ApiResponse response =

View File

@@ -47,4 +47,9 @@ abstract interface class ShiftsRepositoryInterface {
/// Returns whether the staff profile is complete.
Future<bool> getProfileCompletion();
/// Submits a completed shift for timesheet approval.
///
/// Only allowed for shifts in CHECKED_OUT or COMPLETED status.
Future<void> submitForApproval(String shiftId, {String? note});
}

View File

@@ -0,0 +1,18 @@
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Submits a completed shift for timesheet approval.
///
/// Delegates to [ShiftsRepositoryInterface.submitForApproval] which calls
/// `POST /staff/shifts/:shiftId/submit-for-approval`.
class SubmitForApprovalUseCase {
/// Creates a [SubmitForApprovalUseCase].
SubmitForApprovalUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
/// Executes the use case.
Future<void> call(String shiftId, {String? note}) async {
return repository.submitForApproval(shiftId, note: note);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:krow_domain/krow_domain.dart';
/// Computes a Friday-based week calendar for the given [weekOffset].
///
/// Returns a list of 7 [DateTime] values starting from the Friday of the
/// week identified by [weekOffset] (0 = current week, negative = past,
/// positive = future). Each date is midnight-normalised.
List<DateTime> getCalendarDaysForOffset(int weekOffset) {
final DateTime now = DateTime.now();
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
final int daysSinceFriday = (reactDayIndex + 2) % 7;
final DateTime start = now
.subtract(Duration(days: daysSinceFriday))
.add(Duration(days: weekOffset * 7));
final DateTime startDate = DateTime(start.year, start.month, start.day);
return List<DateTime>.generate(
7,
(int index) => startDate.add(Duration(days: index)),
);
}
/// Filters out [OpenShift] entries whose date is strictly before today.
///
/// Comparison is done at midnight granularity so shifts scheduled for
/// today are always included.
List<OpenShift> filterPastOpenShifts(List<OpenShift> shifts) {
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
return shifts.where((OpenShift shift) {
final DateTime dateOnly = DateTime(
shift.date.year,
shift.date.month,
shift.date.day,
);
return !dateOnly.isBefore(today);
}).toList();
}

View File

@@ -53,7 +53,7 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
isProfileComplete: isProfileComplete,
));
} else {
emit(const ShiftDetailsError('Shift not found'));
emit(const ShiftDetailsError('errors.shift.not_found'));
}
},
onError: (String errorKey) => ShiftDetailsError(errorKey),
@@ -74,7 +74,7 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
);
emit(
ShiftActionSuccess(
'Shift successfully booked!',
'shift_booked',
shiftDate: event.date,
),
);
@@ -91,7 +91,7 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
emit: emit.call,
action: () async {
await declineShift(event.shiftId);
emit(const ShiftActionSuccess('Shift declined'));
emit(const ShiftActionSuccess('shift_declined_success'));
},
onError: (String errorKey) => ShiftDetailsError(errorKey),
);

View File

@@ -14,6 +14,8 @@ import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart';
import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart';
part 'shifts_event.dart';
part 'shifts_state.dart';
@@ -31,6 +33,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
required this.getProfileCompletion,
required this.acceptShift,
required this.declineShift,
required this.submitForApproval,
}) : super(const ShiftsState()) {
on<LoadShiftsEvent>(_onLoadShifts);
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
@@ -41,6 +44,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
on<CheckProfileCompletionEvent>(_onCheckProfileCompletion);
on<AcceptShiftEvent>(_onAcceptShift);
on<DeclineShiftEvent>(_onDeclineShift);
on<SubmitForApprovalEvent>(_onSubmitForApproval);
}
/// Use case for assigned shifts.
@@ -67,6 +71,9 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
/// Use case for declining a shift.
final DeclineShiftUseCase declineShift;
/// Use case for submitting a shift for timesheet approval.
final SubmitForApprovalUseCase submitForApproval;
Future<void> _onLoadShifts(
LoadShiftsEvent event,
Emitter<ShiftsState> emit,
@@ -78,7 +85,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
await handleError(
emit: emit.call,
action: () async {
final List<DateTime> days = _getCalendarDaysForOffset(0);
final List<DateTime> days = getCalendarDaysForOffset(0);
// Load assigned, pending, and cancelled shifts in parallel.
final List<Object> results = await Future.wait(<Future<Object>>[
@@ -110,6 +117,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
historyLoaded: false,
myShiftsLoaded: true,
searchQuery: '',
clearErrorMessage: true,
),
);
},
@@ -136,6 +144,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
historyShifts: historyResult,
historyLoading: false,
historyLoaded: true,
clearErrorMessage: true,
),
);
},
@@ -167,9 +176,10 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
);
emit(
state.copyWith(
availableShifts: _filterPastOpenShifts(availableResult),
availableShifts: filterPastOpenShifts(availableResult),
availableLoading: false,
availableLoaded: true,
clearErrorMessage: true,
),
);
},
@@ -219,9 +229,10 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
emit(
state.copyWith(
status: ShiftsStatus.loaded,
availableShifts: _filterPastOpenShifts(availableResult),
availableShifts: filterPastOpenShifts(availableResult),
availableLoading: false,
availableLoaded: true,
clearErrorMessage: true,
),
);
},
@@ -239,6 +250,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
LoadShiftsForRangeEvent event,
Emitter<ShiftsState> emit,
) async {
emit(state.copyWith(myShifts: const <AssignedShift>[], myShiftsLoaded: false));
await handleError(
emit: emit.call,
action: () async {
@@ -251,6 +263,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
status: ShiftsStatus.loaded,
myShifts: myShiftsResult,
myShiftsLoaded: true,
clearErrorMessage: true,
),
);
},
@@ -281,7 +294,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
emit(
state.copyWith(
availableShifts: _filterPastOpenShifts(result),
availableShifts: filterPastOpenShifts(result),
searchQuery: search,
),
);
@@ -342,30 +355,37 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
);
}
/// Gets calendar days for the given week offset (Friday-based week).
List<DateTime> _getCalendarDaysForOffset(int weekOffset) {
final DateTime now = DateTime.now();
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
final int daysSinceFriday = (reactDayIndex + 2) % 7;
final DateTime start = now
.subtract(Duration(days: daysSinceFriday))
.add(Duration(days: weekOffset * 7));
final DateTime startDate = DateTime(start.year, start.month, start.day);
return List<DateTime>.generate(
7, (int index) => startDate.add(Duration(days: index)));
Future<void> _onSubmitForApproval(
SubmitForApprovalEvent event,
Emitter<ShiftsState> emit,
) async {
// Guard: another submission is already in progress.
if (state.submittingShiftId != null) return;
// Guard: this shift was already submitted.
if (state.submittedShiftIds.contains(event.shiftId)) return;
emit(state.copyWith(submittingShiftId: event.shiftId));
await handleError(
emit: emit.call,
action: () async {
await submitForApproval(event.shiftId, note: event.note);
emit(
state.copyWith(
clearSubmittingShiftId: true,
clearErrorMessage: true,
submittedShiftIds: <String>{
...state.submittedShiftIds,
event.shiftId,
},
),
);
},
onError: (String errorKey) => state.copyWith(
clearSubmittingShiftId: true,
status: ShiftsStatus.error,
errorMessage: errorKey,
),
);
}
/// Filters out open shifts whose date is in the past.
List<OpenShift> _filterPastOpenShifts(List<OpenShift> shifts) {
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
return shifts.where((OpenShift shift) {
final DateTime dateOnly = DateTime(
shift.date.year,
shift.date.month,
shift.date.day,
);
return !dateOnly.isBefore(today);
}).toList();
}
}

View File

@@ -93,3 +93,18 @@ class CheckProfileCompletionEvent extends ShiftsEvent {
@override
List<Object?> get props => <Object?>[];
}
/// Submits a completed shift for timesheet approval.
class SubmitForApprovalEvent extends ShiftsEvent {
/// Creates a [SubmitForApprovalEvent].
const SubmitForApprovalEvent({required this.shiftId, this.note});
/// The shift row id to submit.
final String shiftId;
/// Optional note to include with the submission.
final String? note;
@override
List<Object?> get props => <Object?>[shiftId, note];
}

View File

@@ -21,6 +21,8 @@ class ShiftsState extends Equatable {
this.searchQuery = '',
this.profileComplete,
this.errorMessage,
this.submittingShiftId,
this.submittedShiftIds = const <String>{},
});
/// Current lifecycle status.
@@ -65,6 +67,12 @@ class ShiftsState extends Equatable {
/// Error message key for display.
final String? errorMessage;
/// The shift ID currently being submitted for approval (null when idle).
final String? submittingShiftId;
/// Set of shift IDs that have been successfully submitted for approval.
final Set<String> submittedShiftIds;
/// Creates a copy with the given fields replaced.
ShiftsState copyWith({
ShiftsStatus? status,
@@ -81,6 +89,10 @@ class ShiftsState extends Equatable {
String? searchQuery,
bool? profileComplete,
String? errorMessage,
bool clearErrorMessage = false,
String? submittingShiftId,
bool clearSubmittingShiftId = false,
Set<String>? submittedShiftIds,
}) {
return ShiftsState(
status: status ?? this.status,
@@ -96,7 +108,13 @@ class ShiftsState extends Equatable {
myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded,
searchQuery: searchQuery ?? this.searchQuery,
profileComplete: profileComplete ?? this.profileComplete,
errorMessage: errorMessage ?? this.errorMessage,
errorMessage: clearErrorMessage
? null
: (errorMessage ?? this.errorMessage),
submittingShiftId: clearSubmittingShiftId
? null
: (submittingShiftId ?? this.submittingShiftId),
submittedShiftIds: submittedShiftIds ?? this.submittedShiftIds,
);
}
@@ -116,5 +134,7 @@ class ShiftsState extends Equatable {
searchQuery,
profileComplete,
errorMessage,
submittingShiftId,
submittedShiftIds,
];
}

View File

@@ -47,12 +47,6 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
return DateFormat('EEEE, MMMM d, y').format(dt);
}
double _calculateDuration(ShiftDetail detail) {
final int minutes = detail.endTime.difference(detail.startTime).inMinutes;
final double hours = minutes / 60;
return hours < 0 ? hours + 24 : hours;
}
@override
Widget build(BuildContext context) {
return BlocProvider<ShiftDetailsBloc>(
@@ -67,7 +61,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
_isApplying = false;
UiSnackbar.show(
context,
message: state.message,
message: _translateSuccessKey(context, state.message),
type: UiSnackbarType.success,
);
Modular.to.toShifts(
@@ -98,14 +92,8 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
}
final ShiftDetail detail = state.detail;
final dynamic i18n =
Translations.of(context).staff_shifts.shift_details;
final bool isProfileComplete = state.isProfileComplete;
final double duration = _calculateDuration(detail);
final double hourlyRate = detail.hourlyRateCents / 100;
final double estimatedTotal = hourlyRate * duration;
return Scaffold(
appBar: UiAppBar(
centerTitle: false,
@@ -122,45 +110,46 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
Padding(
padding: const EdgeInsets.all(UiConstants.space6),
child: UiNoticeBanner(
title: 'Complete Your Account',
description:
'Complete your account to book this shift and start earning',
title: context.t.staff_shifts.shift_details
.complete_account_title,
description: context.t.staff_shifts.shift_details
.complete_account_description,
icon: UiIcons.sparkles,
),
),
ShiftDetailsHeader(detail: detail),
const Divider(height: 1, thickness: 0.5),
ShiftStatsRow(
estimatedTotal: estimatedTotal,
hourlyRate: hourlyRate,
duration: duration,
totalLabel: i18n.est_total,
hourlyRateLabel: i18n.hourly_rate,
hoursLabel: i18n.hours,
estimatedTotal: detail.estimatedTotal,
hourlyRate: detail.hourlyRate,
duration: detail.durationHours,
totalLabel: context.t.staff_shifts.shift_details.est_total,
hourlyRateLabel: context.t.staff_shifts.shift_details.hourly_rate,
hoursLabel: context.t.staff_shifts.shift_details.hours,
),
const Divider(height: 1, thickness: 0.5),
ShiftDateTimeSection(
date: detail.date,
startTime: detail.startTime,
endTime: detail.endTime,
shiftDateLabel: i18n.shift_date,
clockInLabel: i18n.start_time,
clockOutLabel: i18n.end_time,
shiftDateLabel: context.t.staff_shifts.shift_details.shift_date,
clockInLabel: context.t.staff_shifts.shift_details.start_time,
clockOutLabel: context.t.staff_shifts.shift_details.end_time,
),
const Divider(height: 1, thickness: 0.5),
ShiftLocationSection(
location: detail.location,
address: detail.address ?? '',
locationLabel: i18n.location,
tbdLabel: i18n.tbd,
getDirectionLabel: i18n.get_direction,
locationLabel: context.t.staff_shifts.shift_details.location,
tbdLabel: context.t.staff_shifts.shift_details.tbd,
getDirectionLabel: context.t.staff_shifts.shift_details.get_direction,
),
const Divider(height: 1, thickness: 0.5),
if (detail.description != null &&
detail.description!.isNotEmpty)
ShiftDescriptionSection(
description: detail.description!,
descriptionLabel: i18n.job_description,
descriptionLabel: context.t.staff_shifts.shift_details.job_description,
),
],
),
@@ -190,13 +179,11 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
}
void _bookShift(BuildContext context, ShiftDetail detail) {
final dynamic i18n =
Translations.of(context).staff_shifts.shift_details.book_dialog;
showDialog<void>(
context: context,
builder: (BuildContext ctx) => AlertDialog(
title: Text(i18n.title as String),
content: Text(i18n.message as String),
title: Text(context.t.staff_shifts.shift_details.book_dialog.title),
content: Text(context.t.staff_shifts.shift_details.book_dialog.message),
actions: <Widget>[
TextButton(
onPressed: () => Modular.to.popSafe(),
@@ -228,14 +215,12 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
if (_actionDialogOpen) return;
_actionDialogOpen = true;
_isApplying = true;
final dynamic i18n =
Translations.of(context).staff_shifts.shift_details.applying_dialog;
showDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: false,
builder: (BuildContext ctx) => AlertDialog(
title: Text(i18n.title as String),
title: Text(context.t.staff_shifts.shift_details.applying_dialog.title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
@@ -250,7 +235,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
style: UiTypography.body2b.textPrimary,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
const SizedBox(height: UiConstants.space1),
Text(
'${_formatDate(detail.date)} \u2022 ${_formatTime(detail.startTime)} - ${_formatTime(detail.endTime)}',
style: UiTypography.body3r.textSecondary,
@@ -270,6 +255,18 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
_actionDialogOpen = false;
}
/// Translates a success message key to a localized string.
String _translateSuccessKey(BuildContext context, String key) {
switch (key) {
case 'shift_booked':
return context.t.staff_shifts.shift_details.shift_booked;
case 'shift_declined_success':
return context.t.staff_shifts.shift_details.shift_declined_success;
default:
return key;
}
}
void _showEligibilityErrorDialog(BuildContext context) {
showDialog<void>(
context: context,
@@ -288,16 +285,16 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
],
),
content: Text(
'You are missing required certifications or documents to claim this shift. Please upload them to continue.',
context.t.staff_shifts.shift_details.missing_certifications,
style: UiTypography.body2r.textSecondary,
),
actions: <Widget>[
UiButton.secondary(
text: 'Cancel',
text: Translations.of(context).common.cancel,
onPressed: () => Navigator.of(ctx).pop(),
),
UiButton.primary(
text: 'Go to Certificates',
text: context.t.staff_shifts.shift_details.go_to_certificates,
onPressed: () {
Modular.to.popSafe();
Modular.to.toCertificates();

View File

@@ -12,10 +12,15 @@ import 'package:staff_shifts/src/presentation/widgets/tabs/my_shifts_tab.dart';
import 'package:staff_shifts/src/presentation/widgets/tabs/find_shifts_tab.dart';
import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.dart';
/// Tabbed page for browsing staff shifts (My Shifts, Find Work, History).
///
/// Manages tab state locally and delegates data loading to [ShiftsBloc].
class ShiftsPage extends StatefulWidget {
final ShiftTabType? initialTab;
final DateTime? selectedDate;
final bool refreshAvailable;
/// Creates a [ShiftsPage].
///
/// [initialTab] selects the active tab on first render.
/// [selectedDate] pre-selects a calendar date in the My Shifts tab.
/// [refreshAvailable] triggers a forced reload of available shifts.
const ShiftsPage({
super.key,
this.initialTab,
@@ -23,6 +28,15 @@ class ShiftsPage extends StatefulWidget {
this.refreshAvailable = false,
});
/// The tab to display on initial render. Defaults to [ShiftTabType.find].
final ShiftTabType? initialTab;
/// Optional date to pre-select in the My Shifts calendar.
final DateTime? selectedDate;
/// When true, forces a refresh of available shifts on load.
final bool refreshAvailable;
@override
State<ShiftsPage> createState() => _ShiftsPageState();
}
@@ -251,6 +265,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
pendingAssignments: pendingAssignments,
cancelledShifts: cancelledShifts,
initialDate: _selectedDate,
submittedShiftIds: state.submittedShiftIds,
);
case ShiftTabType.find:
if (availableLoading) {
@@ -264,7 +279,10 @@ class _ShiftsPageState extends State<ShiftsPage> {
if (historyLoading) {
return const ShiftsPageSkeleton();
}
return HistoryShiftsTab(historyShifts: historyShifts);
return HistoryShiftsTab(
historyShifts: historyShifts,
submittedShiftIds: state.submittedShiftIds,
);
}
}
@@ -333,7 +351,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
),
),
if (showCount) ...[
const SizedBox(width: 4),
const SizedBox(width: UiConstants.space1),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space1,

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -107,7 +108,7 @@ class ShiftAssignmentCard extends StatelessWidget {
children: <Widget>[
const Icon(UiIcons.calendar,
size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
const SizedBox(width: UiConstants.space1),
Text(
_formatDate(assignment.startTime),
style: UiTypography.footnote1r.textSecondary,
@@ -115,19 +116,19 @@ class ShiftAssignmentCard extends StatelessWidget {
const SizedBox(width: UiConstants.space3),
const Icon(UiIcons.clock,
size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
const SizedBox(width: UiConstants.space1),
Text(
'${_formatTime(assignment.startTime)} - ${_formatTime(assignment.endTime)}',
style: UiTypography.footnote1r.textSecondary,
),
],
),
const SizedBox(height: 4),
const SizedBox(height: UiConstants.space1),
Row(
children: <Widget>[
const Icon(UiIcons.mapPin,
size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(
assignment.location,
@@ -160,7 +161,10 @@ class ShiftAssignmentCard extends StatelessWidget {
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
),
child: Text('Decline', style: UiTypography.body2m.textError),
child: Text(
context.t.staff_shifts.shift_details.decline,
style: UiTypography.body2m.textError,
),
),
),
const SizedBox(width: UiConstants.space2),
@@ -178,14 +182,17 @@ class ShiftAssignmentCard extends StatelessWidget {
),
child: isConfirming
? const SizedBox(
height: 16,
width: 16,
height: UiConstants.space4,
width: UiConstants.space4,
child: CircularProgressIndicator(
strokeWidth: 2,
color: UiColors.white,
),
)
: Text('Accept', style: UiTypography.body2m.white),
: Text(
context.t.staff_shifts.shift_details.accept_shift,
style: UiTypography.body2m.white,
),
),
),
],

View File

@@ -1,21 +1,30 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext;
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
/// Tab displaying completed shift history.
class HistoryShiftsTab extends StatelessWidget {
/// Creates a [HistoryShiftsTab].
const HistoryShiftsTab({super.key, required this.historyShifts});
const HistoryShiftsTab({
super.key,
required this.historyShifts,
this.submittedShiftIds = const <String>{},
});
/// Completed shifts.
final List<CompletedShift> historyShifts;
/// Set of shift IDs that have been successfully submitted for approval.
final Set<String> submittedShiftIds;
@override
Widget build(BuildContext context) {
if (historyShifts.isEmpty) {
@@ -32,14 +41,31 @@ class HistoryShiftsTab extends StatelessWidget {
children: <Widget>[
const SizedBox(height: UiConstants.space5),
...historyShifts.map(
(CompletedShift shift) => Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: ShiftCard(
data: ShiftCardData.fromCompleted(shift),
onTap: () =>
Modular.to.toShiftDetailsById(shift.shiftId),
),
),
(CompletedShift shift) {
final bool isSubmitted =
submittedShiftIds.contains(shift.shiftId);
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: ShiftCard(
data: ShiftCardData.fromCompleted(shift),
onTap: () =>
Modular.to.toShiftDetailsById(shift.shiftId),
showApprovalAction: !isSubmitted,
isSubmitted: isSubmitted,
onSubmitForApproval: () {
ReadContext(context).read<ShiftsBloc>().add(
SubmitForApprovalEvent(shiftId: shift.shiftId),
);
UiSnackbar.show(
context,
message: context.t.staff_shifts
.my_shift_card.timesheet_submitted,
type: UiSnackbarType.success,
);
},
),
);
},
),
const SizedBox(height: UiConstants.space32),
],

View File

@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart';
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
@@ -20,6 +21,7 @@ class MyShiftsTab extends StatefulWidget {
required this.pendingAssignments,
required this.cancelledShifts,
this.initialDate,
this.submittedShiftIds = const <String>{},
});
/// Assigned shifts for the current week.
@@ -34,6 +36,9 @@ class MyShiftsTab extends StatefulWidget {
/// Initial date to select in the calendar.
final DateTime? initialDate;
/// Set of shift IDs that have been successfully submitted for approval.
final Set<String> submittedShiftIds;
@override
State<MyShiftsTab> createState() => _MyShiftsTabState();
}
@@ -42,9 +47,6 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
DateTime _selectedDate = DateTime.now();
int _weekOffset = 0;
/// Tracks which completed-shift cards have been submitted locally.
final Set<String> _submittedShiftIds = <String>{};
@override
void initState() {
super.initState();
@@ -90,20 +92,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
});
}
List<DateTime> _getCalendarDays() {
final DateTime now = DateTime.now();
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
final int daysSinceFriday = (reactDayIndex + 2) % 7;
final DateTime start = now
.subtract(Duration(days: daysSinceFriday))
.add(Duration(days: _weekOffset * 7));
final DateTime startDate =
DateTime(start.year, start.month, start.day);
return List<DateTime>.generate(
7,
(int index) => startDate.add(Duration(days: index)),
);
}
List<DateTime> _getCalendarDays() => getCalendarDaysForOffset(_weekOffset);
void _loadShiftsForCurrentWeek() {
final List<DateTime> calendarDays = _getCalendarDays();
@@ -402,7 +391,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
final bool isCompleted =
shift.status == AssignmentStatus.completed;
final bool isSubmitted =
_submittedShiftIds.contains(shift.shiftId);
widget.submittedShiftIds.contains(shift.shiftId);
return Padding(
padding: const EdgeInsets.only(
@@ -415,9 +404,11 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
showApprovalAction: isCompleted,
isSubmitted: isSubmitted,
onSubmitForApproval: () {
setState(() {
_submittedShiftIds.add(shift.shiftId);
});
ReadContext(context).read<ShiftsBloc>().add(
SubmitForApprovalEvent(
shiftId: shift.shiftId,
),
);
UiSnackbar.show(
context,
message: context.t.staff_shifts

View File

@@ -14,6 +14,7 @@ import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.
import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart';
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart';
import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart';
@@ -45,6 +46,9 @@ class StaffShiftsModule extends Module {
i.addLazySingleton(ApplyForShiftUseCase.new);
i.addLazySingleton(GetShiftDetailUseCase.new);
i.addLazySingleton(GetProfileCompletionUseCase.new);
i.addLazySingleton(
() => SubmitForApprovalUseCase(i.get<ShiftsRepositoryInterface>()),
);
// BLoC
i.add(
@@ -57,6 +61,7 @@ class StaffShiftsModule extends Module {
getProfileCompletion: i.get(),
acceptShift: i.get(),
declineShift: i.get(),
submitForApproval: i.get(),
),
);
i.add(