Merge dev into feature branch

This commit is contained in:
2026-03-19 13:16:04 +05:30
273 changed files with 7867 additions and 3654 deletions

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
@@ -9,55 +8,42 @@ import 'package:staff_authentication/src/utils/test_phone_numbers.dart';
/// V2 API implementation of [AuthRepositoryInterface].
///
/// Uses the Firebase Auth SDK for client-side phone verification,
/// Uses [FirebaseAuthService] from core for client-side phone verification,
/// then calls the V2 unified API to hydrate the session context.
/// All Data Connect dependencies have been removed.
/// All direct `firebase_auth` imports have been removed in favour of the
/// core abstraction.
class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl].
///
/// Requires a [domain.BaseApiService] for V2 API calls.
AuthRepositoryImpl({required domain.BaseApiService apiService})
: _apiService = apiService;
/// Requires a [domain.BaseApiService] for V2 API calls and a
/// [FirebaseAuthService] for client-side Firebase Auth operations.
AuthRepositoryImpl({
required domain.BaseApiService apiService,
required FirebaseAuthService firebaseAuthService,
}) : _apiService = apiService,
_firebaseAuthService = firebaseAuthService;
/// The V2 API service for backend calls.
final domain.BaseApiService _apiService;
/// Firebase Auth instance for client-side phone verification.
final FirebaseAuth _auth = FirebaseAuth.instance;
/// Completer for the pending phone verification request.
Completer<String?>? _pendingVerification;
/// Core Firebase Auth service abstraction.
final FirebaseAuthService _firebaseAuthService;
@override
Stream<domain.User?> get currentUser =>
_auth.authStateChanges().map((User? firebaseUser) {
if (firebaseUser == null) {
return null;
}
return domain.User(
id: firebaseUser.uid,
email: firebaseUser.email,
displayName: firebaseUser.displayName,
phone: firebaseUser.phoneNumber,
status: domain.UserStatus.active,
);
});
Stream<domain.User?> get currentUser => _firebaseAuthService.authStateChanges;
/// Initiates phone verification via the V2 API.
///
/// Calls `POST /auth/staff/phone/start` first. The server decides the
/// verification mode:
/// - `CLIENT_FIREBASE_SDK` mobile must do Firebase phone auth client-side
/// - `IDENTITY_TOOLKIT_SMS` server sent the SMS, returns `sessionInfo`
/// - `CLIENT_FIREBASE_SDK` -- mobile must do Firebase phone auth client-side
/// - `IDENTITY_TOOLKIT_SMS` -- server sent the SMS, returns `sessionInfo`
///
/// For mobile without recaptcha tokens, the server returns
/// `CLIENT_FIREBASE_SDK` and we fall back to the Firebase Auth SDK.
@override
Future<String?> signInWithPhone({required String phoneNumber}) async {
// Step 1: Try V2 to let the server decide the auth mode.
// Falls back to CLIENT_FIREBASE_SDK if the API call fails (e.g. server
// down, 500, or non-JSON response).
String mode = 'CLIENT_FIREBASE_SDK';
String? sessionInfo;
@@ -74,7 +60,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
mode = startData['mode'] as String? ?? 'CLIENT_FIREBASE_SDK';
sessionInfo = startData['sessionInfo'] as String?;
} catch (_) {
// V2 start call failed fall back to client-side Firebase SDK.
// V2 start call failed -- fall back to client-side Firebase SDK.
}
// Step 2: If server sent the SMS, return the sessionInfo for verify step.
@@ -82,55 +68,16 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
return sessionInfo;
}
// Step 3: CLIENT_FIREBASE_SDK mode do Firebase phone auth client-side.
final Completer<String?> completer = Completer<String?>();
_pendingVerification = completer;
await _auth.verifyPhoneNumber(
// Step 3: CLIENT_FIREBASE_SDK mode -- do Firebase phone auth client-side.
return _firebaseAuthService.verifyPhoneNumber(
phoneNumber: phoneNumber,
verificationCompleted: (PhoneAuthCredential credential) {
if (TestPhoneNumbers.isTestNumber(phoneNumber)) return;
},
verificationFailed: (FirebaseAuthException e) {
if (!completer.isCompleted) {
if (e.code == 'network-request-failed' ||
e.message?.contains('Unable to resolve host') == true) {
completer.completeError(
const domain.NetworkException(
technicalMessage: 'Auth network failure',
),
);
} else {
completer.completeError(
domain.SignInFailedException(
technicalMessage: 'Firebase ${e.code}: ${e.message}',
),
);
}
}
},
codeSent: (String verificationId, _) {
if (!completer.isCompleted) {
completer.complete(verificationId);
}
},
codeAutoRetrievalTimeout: (String verificationId) {
if (!completer.isCompleted) {
completer.complete(verificationId);
}
},
onAutoVerified: TestPhoneNumbers.isTestNumber(phoneNumber) ? null : null,
);
return completer.future;
}
@override
void cancelPendingPhoneVerification() {
final Completer<String?>? completer = _pendingVerification;
if (completer != null && !completer.isCompleted) {
completer.completeError(Exception('Phone verification cancelled.'));
}
_pendingVerification = null;
_firebaseAuthService.cancelPendingPhoneVerification();
}
/// Verifies the OTP and completes authentication via the V2 API.
@@ -145,53 +92,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
required String smsCode,
required AuthMode mode,
}) async {
// Step 1: Sign in with Firebase credential (client-side).
final PhoneAuthCredential credential = PhoneAuthProvider.credential(
// Step 1: Sign in with Firebase credential via core service.
final PhoneSignInResult signInResult =
await _firebaseAuthService.signInWithPhoneCredential(
verificationId: verificationId,
smsCode: smsCode,
);
final UserCredential userCredential;
try {
userCredential = await _auth.signInWithCredential(credential);
} on FirebaseAuthException catch (e) {
if (e.code == 'invalid-verification-code') {
throw const domain.InvalidCredentialsException(
technicalMessage: 'Invalid OTP code entered.',
);
}
rethrow;
}
final User? firebaseUser = userCredential.user;
if (firebaseUser == null) {
throw const domain.SignInFailedException(
technicalMessage:
'Phone verification failed, no Firebase user received.',
);
}
// Step 2: Get the Firebase ID token.
final String? idToken = await firebaseUser.getIdToken();
if (idToken == null) {
throw const domain.SignInFailedException(
technicalMessage: 'Failed to obtain Firebase ID token.',
);
}
// Step 3: Call V2 verify endpoint with the Firebase ID token.
// Step 2: Call V2 verify endpoint with the Firebase ID token.
final String v2Mode = mode == AuthMode.signup ? 'sign-up' : 'sign-in';
final domain.ApiResponse response = await _apiService.post(
AuthEndpoints.staffPhoneVerify,
data: <String, dynamic>{
'idToken': idToken,
'idToken': signInResult.idToken,
'mode': v2Mode,
},
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
// Step 4: Check for business logic errors from the V2 API.
// Step 3: Check for business logic errors from the V2 API.
final Map<String, dynamic>? staffData =
data['staff'] as Map<String, dynamic>?;
final Map<String, dynamic>? userData =
@@ -202,7 +122,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// - Sign-in: staff must exist
if (mode == AuthMode.login) {
if (staffData == null) {
await _auth.signOut();
await _firebaseAuthService.signOut();
throw const domain.UserNotFoundException(
technicalMessage:
'Your account is not registered yet. Please register first.',
@@ -210,7 +130,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
}
}
// Step 5: Populate StaffSessionStore from the V2 auth envelope.
// Step 4: Populate StaffSessionStore from the V2 auth envelope.
if (staffData != null) {
final domain.StaffSession staffSession =
domain.StaffSession.fromJson(data);
@@ -219,10 +139,10 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Build the domain user from the V2 response.
final domain.User domainUser = domain.User(
id: userData?['id'] as String? ?? firebaseUser.uid,
id: userData?['id'] as String? ?? signInResult.uid,
email: userData?['email'] as String?,
displayName: userData?['displayName'] as String?,
phone: userData?['phone'] as String? ?? firebaseUser.phoneNumber,
phone: userData?['phone'] as String? ?? signInResult.phoneNumber,
status: domain.UserStatus.active,
);
@@ -238,7 +158,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Sign-out should not fail even if the API call fails.
// The local sign-out below will clear the session regardless.
}
await _auth.signOut();
await _firebaseAuthService.signOut();
StaffSessionStore.instance.clear();
}
}

View File

@@ -1,4 +1,3 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -11,13 +10,20 @@ import 'package:staff_authentication/src/domain/repositories/profile_setup_repos
class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
/// Creates a [ProfileSetupRepositoryImpl].
///
/// Requires a [BaseApiService] for V2 API calls.
ProfileSetupRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
/// Requires a [BaseApiService] for V2 API calls and a
/// [FirebaseAuthService] to resolve the current user's phone number.
ProfileSetupRepositoryImpl({
required BaseApiService apiService,
required FirebaseAuthService firebaseAuthService,
}) : _apiService = apiService,
_firebaseAuthService = firebaseAuthService;
/// The V2 API service for backend calls.
final BaseApiService _apiService;
/// Core Firebase Auth service for querying current user info.
final FirebaseAuthService _firebaseAuthService;
@override
Future<void> submitProfile({
required String fullName,
@@ -38,7 +44,7 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
// to the Firebase Auth current user's phone if the caller passed empty.
final String resolvedPhone = phoneNumber.isNotEmpty
? phoneNumber
: (FirebaseAuth.instance.currentUser?.phoneNumber ?? '');
: (_firebaseAuthService.currentUserPhoneNumber ?? '');
final ApiResponse response = await _apiService.post(
StaffEndpoints.profileSetup,

View File

@@ -36,7 +36,7 @@ class _PhoneInputState extends State<PhoneInput> {
if (!mounted) return;
_currentPhone = value;
final AuthBloc bloc = context.read<AuthBloc>();
final AuthBloc bloc = ReadContext(context).read<AuthBloc>();
if (!bloc.isClosed) {
bloc.add(AuthPhoneUpdated(value));
}

View File

@@ -48,7 +48,7 @@ class _ProfileSetupLocationState extends State<ProfileSetupLocation> {
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
context.read<ProfileSetupBloc>().add(
ReadContext(context).read<ProfileSetupBloc>().add(
ProfileSetupLocationQueryChanged(query),
);
});
@@ -62,7 +62,7 @@ class _ProfileSetupLocationState extends State<ProfileSetupLocation> {
)..add(location);
widget.onLocationsChanged(updatedList);
_locationController.clear();
context.read<ProfileSetupBloc>().add(
ReadContext(context).read<ProfileSetupBloc>().add(
const ProfileSetupClearLocationSuggestions(),
);
}

View File

@@ -32,10 +32,16 @@ class StaffAuthenticationModule extends Module {
void binds(Injector i) {
// Repositories
i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
() => AuthRepositoryImpl(
apiService: i.get<BaseApiService>(),
firebaseAuthService: i.get<FirebaseAuthService>(),
),
);
i.addLazySingleton<ProfileSetupRepository>(
() => ProfileSetupRepositoryImpl(apiService: i.get<BaseApiService>()),
() => ProfileSetupRepositoryImpl(
apiService: i.get<BaseApiService>(),
firebaseAuthService: i.get<FirebaseAuthService>(),
),
);
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);

View File

@@ -14,7 +14,6 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
firebase_auth: ^6.1.2
http: ^1.2.0
pinput: ^5.0.0
smart_auth: ^1.1.0
@@ -28,6 +27,7 @@ dependencies:
path: ../../../design_system
core_localization:
path: ../../../core_localization
bloc: ^8.1.4
dev_dependencies:

View File

@@ -201,7 +201,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
height: 32,
child: OutlinedButton(
onPressed: () =>
context.read<AvailabilityBloc>().add(PerformQuickSet(type)),
ReadContext(context).read<AvailabilityBloc>().add(PerformQuickSet(type)),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
side: BorderSide(
@@ -252,14 +252,14 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
children: <Widget>[
_buildNavButton(
UiIcons.chevronLeft,
() => context.read<AvailabilityBloc>().add(
() => ReadContext(context).read<AvailabilityBloc>().add(
const NavigateWeek(-1),
),
),
Text(monthYear, style: UiTypography.title2b),
_buildNavButton(
UiIcons.chevronRight,
() => context.read<AvailabilityBloc>().add(
() => ReadContext(context).read<AvailabilityBloc>().add(
const NavigateWeek(1),
),
),
@@ -307,7 +307,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
return Expanded(
child: GestureDetector(
onTap: () =>
context.read<AvailabilityBloc>().add(SelectDate(dayDate)),
ReadContext(context).read<AvailabilityBloc>().add(SelectDate(dayDate)),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(vertical: UiConstants.space3),

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,11 +21,12 @@ 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) =>
_mapTodayShiftJsonToShift(json as Map<String, dynamic>),
Shift.fromJson(json as Map<String, dynamic>),
)
.toList();
}
@@ -37,55 +40,22 @@ 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();
}
/// Maps a V2 `listTodayShifts` JSON item to the domain [Shift] entity.
static Shift _mapTodayShiftJsonToShift(Map<String, dynamic> json) {
return Shift(
id: json['shiftId'] as String,
orderId: json['orderId'] as String? ?? '',
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?,
latitude: Shift.parseDouble(json['latitude']),
longitude: Shift.parseDouble(json['longitude']),
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.round(),
},
],
'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!.round(),
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!.round(),
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,19 +202,51 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
await handleError(
emit: emit.call,
action: () async {
final AttendanceStatus newStatus = await _clockIn(
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
);
emit(state.copyWith(
status: ClockInStatus.success,
attendance: newStatus,
));
final DeviceLocation? location = geofenceState.currentLocation;
// Start background tracking after successful clock-in.
_dispatchBackgroundTrackingStarted(
event: event,
activeShiftId: newStatus.activeShiftId,
);
try {
final AttendanceStatus newStatus = await _clockIn(
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,
attendance: newStatus,
));
// Start background tracking after successful clock-in.
_dispatchBackgroundTrackingStarted(
event: event,
activeShiftId: newStatus.activeShiftId,
);
} on AppException catch (_) {
// The clock-in API call failed. Re-fetch attendance status to
// reconcile: if the worker is already clocked in (e.g. duplicate
// session from Postgres constraint 23505), treat it as success.
final AttendanceStatus currentStatus = await _getAttendanceStatus();
if (currentStatus.isClockedIn) {
emit(state.copyWith(
status: ClockInStatus.success,
attendance: currentStatus,
));
_dispatchBackgroundTrackingStarted(
event: event,
activeShiftId: currentStatus.activeShiftId,
);
} else {
// Worker is genuinely not clocked in — surface the error.
rethrow;
}
}
},
onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure,
@@ -224,34 +257,78 @@ 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 AttendanceStatus newStatus = await _clockOut(
ClockOutArguments(
notes: event.notes,
breakTimeMinutes: event.breakTimeMinutes ?? 0,
shiftId: state.attendance.activeShiftId,
),
);
emit(state.copyWith(
status: ClockInStatus.success,
attendance: newStatus,
));
final GeofenceState currentGeofence = _geofenceBloc.state;
final DeviceLocation? location = currentGeofence.currentLocation;
// Stop background tracking after successful clock-out.
_geofenceBloc.add(
BackgroundTrackingStopped(
clockOutTitle: event.clockOutTitle,
clockOutBody: event.clockOutBody,
),
);
try {
final AttendanceStatus newStatus = await _clockOut(
ClockOutArguments(
notes: event.notes,
breakTimeMinutes: event.breakTimeMinutes,
shiftId: activeShiftId,
latitude: location?.latitude,
longitude: location?.longitude,
accuracyMeters: location?.accuracy,
capturedAt: location?.timestamp,
overrideReason: currentGeofence.isGeofenceOverridden
? currentGeofence.overrideNotes
: null,
),
);
emit(state.copyWith(
status: ClockInStatus.success,
attendance: newStatus,
));
// Stop background tracking after successful clock-out.
_geofenceBloc.add(
BackgroundTrackingStopped(
clockOutTitle: event.clockOutTitle,
clockOutBody: event.clockOutBody,
),
);
} on AppException catch (_) {
// The clock-out API call failed. Re-fetch attendance status to
// reconcile: if the worker is already clocked out (e.g. duplicate
// end-session), treat it as success.
final AttendanceStatus currentStatus = await _getAttendanceStatus();
if (!currentStatus.isClockedIn) {
emit(state.copyWith(
status: ClockInStatus.success,
attendance: currentStatus,
));
_geofenceBloc.add(
BackgroundTrackingStopped(
clockOutTitle: event.clockOutTitle,
clockOutBody: event.clockOutBody,
),
);
} else {
// Worker is still clocked in — surface the error.
rethrow;
}
}
},
onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure,
@@ -269,7 +346,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 +376,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 +392,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 +404,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

@@ -107,7 +107,7 @@ class ClockInActionSection extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const GeofenceStatusBanner(),
GeofenceStatusBanner(isClockedIn: isCheckedIn),
const SizedBox(height: UiConstants.space3),
EarlyCheckInBanner(
availabilityTime: checkInAvailabilityTime ?? soonLabel,
@@ -120,7 +120,7 @@ class ClockInActionSection extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const GeofenceStatusBanner(),
GeofenceStatusBanner(isClockedIn: isCheckedIn),
const SizedBox(height: UiConstants.space3),
EarlyCheckOutBanner(
availabilityTime: checkOutAvailabilityTime ?? soonLabel,
@@ -134,8 +134,9 @@ class ClockInActionSection extends StatelessWidget {
final bool hasCoordinates =
selectedShift?.latitude != null && selectedShift?.longitude != null;
// Geofence only gates clock-in, never clock-out. When already
// checked in the swipe must always be enabled for checkout.
// Geofence gates both clock-in and clock-out. When outside the
// geofence, the slider is locked until the worker provides a
// justification via the override modal.
final bool isGeofenceBlocking =
hasCoordinates &&
!geofenceState.isLocationVerified &&
@@ -146,7 +147,7 @@ class ClockInActionSection extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4,
children: <Widget>[
const GeofenceStatusBanner(),
GeofenceStatusBanner(isClockedIn: isCheckedIn),
_currentInteraction.buildActionWidget(
isCheckedIn: isCheckedIn,
isDisabled: isGeofenceBlocking,

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

@@ -4,6 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
import '../../bloc/geofence/geofence_bloc.dart';
import '../../bloc/geofence/geofence_state.dart';
import 'outside_work_area_banner.dart';
import 'permission_denied_banner.dart';
import 'permission_denied_forever_banner.dart';
import 'service_disabled_banner.dart';
@@ -17,9 +18,18 @@ import 'verifying_banner.dart';
///
/// Reads [GeofenceBloc] state directly and renders the appropriate
/// banner variant based on permission, location, and verification conditions.
/// When [isClockedIn] is true and the worker is too far, a non-blocking
/// informational banner is shown instead of the override flow.
class GeofenceStatusBanner extends StatelessWidget {
/// Creates a [GeofenceStatusBanner].
const GeofenceStatusBanner({super.key});
const GeofenceStatusBanner({this.isClockedIn = false, super.key});
/// Whether the worker is currently clocked in.
///
/// When true and the device is outside the geofence, a lightweight
/// [OutsideWorkAreaBanner] is shown instead of [TooFarBanner] so that
/// the clock-out slider remains accessible.
final bool isClockedIn;
@override
Widget build(BuildContext context) {
@@ -77,6 +87,14 @@ class GeofenceStatusBanner extends StatelessWidget {
if (!state.isLocationVerified &&
!state.isLocationTimedOut &&
state.distanceFromTarget != null) {
// When already clocked in, show a non-blocking informational banner
// instead of the "Clock in anyway" override flow so the clock-out
// slider remains accessible.
if (isClockedIn) {
return OutsideWorkAreaBanner(
distanceMeters: state.distanceFromTarget!,
);
}
return TooFarBanner(distanceMeters: state.distanceFromTarget!);
}

View File

@@ -0,0 +1,45 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_core/core.dart';
import 'banner_action_button.dart';
import 'geofence_override_modal.dart';
/// Warning banner shown when the worker is clocked in but has moved outside
/// the geofence radius.
///
/// Mirrors [TooFarBanner] with a "Clock out anyway" action that opens the
/// [GeofenceOverrideModal] so the worker can provide justification before
/// the clock-out slider unlocks.
class OutsideWorkAreaBanner extends StatelessWidget {
/// Creates an [OutsideWorkAreaBanner].
const OutsideWorkAreaBanner({required this.distanceMeters, super.key});
/// Distance from the target location in meters.
final double distanceMeters;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagPending,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.outside_work_area_title,
titleColor: UiColors.textWarning,
description: i18n.outside_work_area_desc(
distance: formatDistance(distanceMeters),
),
descriptionColor: UiColors.textWarning,
action: BannerActionButton(
label: i18n.clock_out_anyway,
color: UiColors.textWarning,
onPressed: () => GeofenceOverrideModal.show(context),
),
);
}
}

View File

@@ -27,7 +27,7 @@ class LocationMapPlaceholder extends StatelessWidget {
// In a real app with keys, this would verify visually.
// For now we use a generic placeholder color/icon to avoid broken images.
fit: BoxFit.cover,
onError: (_, __) {},
onError: (_, _) {},
),
),
child: Stack(

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

@@ -48,7 +48,13 @@ class ShiftCard extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(child: _ShiftDetails(shift: shift, isSelected: isSelected, i18n: i18n)),
Expanded(
child: _ShiftDetails(
shift: shift,
isSelected: isSelected,
i18n: i18n,
),
),
_ShiftTimeRange(shift: shift),
],
),
@@ -76,16 +82,36 @@ class _ShiftDetails extends StatelessWidget {
@override
Widget build(BuildContext context) {
final String displayTitle = shift.roleName ?? shift.title;
final String? displaySubtitle = shift.clientName;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(shift.title, style: UiTypography.body2b),
// TODO: Ask BE to add clientName to the listTodayShifts response.
// Currently showing locationName as subtitle fallback.
Text(
shift.locationName ?? '',
style: UiTypography.body3r.textSecondary,
),
Text(displayTitle, style: UiTypography.body2b),
if (displaySubtitle != null && displaySubtitle.isNotEmpty)
Text(displaySubtitle, style: UiTypography.body3r.textSecondary),
if (shift.locationName != null && shift.locationName!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: UiConstants.space1),
child: Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 14,
color: UiColors.textSecondary,
),
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(
shift.locationName!,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
);
}

View File

@@ -57,10 +57,10 @@ class StaffClockInModule extends Module {
);
// Use Cases
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
i.add<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new);
i.add<ClockInUseCase>(ClockInUseCase.new);
i.add<ClockOutUseCase>(ClockOutUseCase.new);
i.addLazySingleton<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
i.addLazySingleton<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new);
i.addLazySingleton<ClockInUseCase>(ClockInUseCase.new);
i.addLazySingleton<ClockOutUseCase>(ClockOutUseCase.new);
// Validators
i.addLazySingleton<CompositeClockInValidator>(
@@ -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

@@ -30,4 +30,24 @@ class HomeRepositoryImpl implements HomeRepository {
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
return completion.completed;
}
@override
Future<List<BenefitHistory>> getBenefitsHistory({
int limit = 20,
int offset = 0,
}) async {
final ApiResponse response = await _apiService.get(
StaffEndpoints.benefitsHistory,
params: <String, dynamic>{
'limit': limit,
'offset': offset,
},
);
final List<dynamic> items =
response.data['items'] as List<dynamic>? ?? <dynamic>[];
return items
.map((dynamic json) =>
BenefitHistory.fromJson(json as Map<String, dynamic>))
.toList();
}
}

View File

@@ -12,4 +12,10 @@ abstract class HomeRepository {
/// Retrieves whether the staff member's profile is complete.
Future<bool> getProfileCompletion();
/// Retrieves paginated benefit history for the staff member.
Future<List<BenefitHistory>> getBenefitsHistory({
int limit = 20,
int offset = 0,
});
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/domain/repositories/home_repository.dart';
/// Use case for fetching paginated benefit history for a staff member.
///
/// Delegates to [HomeRepository.getBenefitsHistory] and returns
/// a list of [BenefitHistory] records.
class GetBenefitsHistoryUseCase {
/// Creates a [GetBenefitsHistoryUseCase].
GetBenefitsHistoryUseCase(this._repository);
/// The repository used for data access.
final HomeRepository _repository;
/// Executes the use case to fetch benefit history.
Future<List<BenefitHistory>> call({int limit = 20, int offset = 0}) {
return _repository.getBenefitsHistory(limit: limit, offset: offset);
}
}

View File

@@ -2,22 +2,29 @@ import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/domain/repositories/home_repository.dart';
import 'package:staff_home/src/domain/usecases/get_benefits_history_usecase.dart';
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
part 'benefits_overview_state.dart';
/// Cubit managing the benefits overview page state.
///
/// Fetches the dashboard and extracts benefits for the detail page.
/// Fetches the dashboard benefits and lazily loads per-benefit history.
class BenefitsOverviewCubit extends Cubit<BenefitsOverviewState>
with BlocErrorHandler<BenefitsOverviewState> {
/// Creates a [BenefitsOverviewCubit].
BenefitsOverviewCubit({required HomeRepository repository})
: _repository = repository,
BenefitsOverviewCubit({
required GetDashboardUseCase getDashboard,
required GetBenefitsHistoryUseCase getBenefitsHistory,
}) : _getDashboard = getDashboard,
_getBenefitsHistory = getBenefitsHistory,
super(const BenefitsOverviewState.initial());
/// The repository used for data access.
final HomeRepository _repository;
/// Use case for fetching dashboard data.
final GetDashboardUseCase _getDashboard;
/// Use case for fetching benefit history.
final GetBenefitsHistoryUseCase _getBenefitsHistory;
/// Loads benefits from the dashboard endpoint.
Future<void> loadBenefits() async {
@@ -26,7 +33,7 @@ class BenefitsOverviewCubit extends Cubit<BenefitsOverviewState>
await handleError(
emit: emit,
action: () async {
final StaffDashboard dashboard = await _repository.getDashboard();
final StaffDashboard dashboard = await _getDashboard();
if (isClosed) return;
emit(
state.copyWith(
@@ -44,4 +51,96 @@ class BenefitsOverviewCubit extends Cubit<BenefitsOverviewState>
},
);
}
/// Loads benefit history for a specific benefit (lazy, on first expand).
///
/// Skips if already loading or already loaded for the given [benefitId].
Future<void> loadBenefitHistory(String benefitId) async {
if (isClosed) return;
if (state.loadingHistoryIds.contains(benefitId)) return;
if (state.loadedHistoryIds.contains(benefitId)) return;
emit(state.copyWith(
loadingHistoryIds: <String>{...state.loadingHistoryIds, benefitId},
));
await handleError(
emit: emit,
action: () async {
final List<BenefitHistory> history =
await _getBenefitsHistory(limit: 20, offset: 0);
if (isClosed) return;
final List<BenefitHistory> filtered = history
.where((BenefitHistory h) => h.benefitId == benefitId)
.toList();
emit(state.copyWith(
historyByBenefitId: <String, List<BenefitHistory>>{
...state.historyByBenefitId,
benefitId: filtered,
},
loadingHistoryIds: <String>{...state.loadingHistoryIds}
..remove(benefitId),
loadedHistoryIds: <String>{...state.loadedHistoryIds, benefitId},
hasMoreHistory: <String, bool>{
...state.hasMoreHistory,
benefitId: history.length >= 20,
},
));
},
onError: (String errorKey) {
if (isClosed) return state;
return state.copyWith(
loadingHistoryIds: <String>{...state.loadingHistoryIds}
..remove(benefitId),
);
},
);
}
/// Loads more history for infinite scroll on the full history page.
///
/// Appends results to existing history for the given [benefitId].
Future<void> loadMoreBenefitHistory(String benefitId) async {
if (isClosed) return;
if (state.loadingHistoryIds.contains(benefitId)) return;
if (!(state.hasMoreHistory[benefitId] ?? true)) return;
final List<BenefitHistory> existing =
state.historyByBenefitId[benefitId] ?? <BenefitHistory>[];
emit(state.copyWith(
loadingHistoryIds: <String>{...state.loadingHistoryIds, benefitId},
));
await handleError(
emit: emit,
action: () async {
final List<BenefitHistory> history =
await _getBenefitsHistory(limit: 20, offset: existing.length);
if (isClosed) return;
final List<BenefitHistory> filtered = history
.where((BenefitHistory h) => h.benefitId == benefitId)
.toList();
emit(state.copyWith(
historyByBenefitId: <String, List<BenefitHistory>>{
...state.historyByBenefitId,
benefitId: <BenefitHistory>[...existing, ...filtered],
},
loadingHistoryIds: <String>{...state.loadingHistoryIds}
..remove(benefitId),
hasMoreHistory: <String, bool>{
...state.hasMoreHistory,
benefitId: history.length >= 20,
},
));
},
onError: (String errorKey) {
if (isClosed) return state;
return state.copyWith(
loadingHistoryIds: <String>{...state.loadingHistoryIds}
..remove(benefitId),
);
},
);
}
}

View File

@@ -1,33 +1,78 @@
part of 'benefits_overview_cubit.dart';
/// Status of the benefits overview data fetch.
enum BenefitsOverviewStatus { initial, loading, loaded, error }
/// State for [BenefitsOverviewCubit].
///
/// Holds both the top-level benefits list and per-benefit history data
/// used by [BenefitHistoryPreview] and [BenefitHistoryPage].
class BenefitsOverviewState extends Equatable {
final BenefitsOverviewStatus status;
final List<Benefit> benefits;
final String? errorMessage;
/// Creates a [BenefitsOverviewState].
const BenefitsOverviewState({
required this.status,
this.benefits = const [],
this.benefits = const <Benefit>[],
this.errorMessage,
this.historyByBenefitId = const <String, List<BenefitHistory>>{},
this.loadingHistoryIds = const <String>{},
this.loadedHistoryIds = const <String>{},
this.hasMoreHistory = const <String, bool>{},
});
/// Initial state with no data.
const BenefitsOverviewState.initial()
: this(status: BenefitsOverviewStatus.initial);
/// Current status of the top-level benefits fetch.
final BenefitsOverviewStatus status;
/// The list of staff benefits.
final List<Benefit> benefits;
/// Error message when [status] is [BenefitsOverviewStatus.error].
final String? errorMessage;
/// Cached history records keyed by benefit ID.
final Map<String, List<BenefitHistory>> historyByBenefitId;
/// Benefit IDs currently loading history.
final Set<String> loadingHistoryIds;
/// Benefit IDs whose history has been loaded at least once.
final Set<String> loadedHistoryIds;
/// Whether more pages of history are available per benefit.
final Map<String, bool> hasMoreHistory;
/// Creates a copy with the given fields replaced.
BenefitsOverviewState copyWith({
BenefitsOverviewStatus? status,
List<Benefit>? benefits,
String? errorMessage,
Map<String, List<BenefitHistory>>? historyByBenefitId,
Set<String>? loadingHistoryIds,
Set<String>? loadedHistoryIds,
Map<String, bool>? hasMoreHistory,
}) {
return BenefitsOverviewState(
status: status ?? this.status,
benefits: benefits ?? this.benefits,
errorMessage: errorMessage ?? this.errorMessage,
historyByBenefitId: historyByBenefitId ?? this.historyByBenefitId,
loadingHistoryIds: loadingHistoryIds ?? this.loadingHistoryIds,
loadedHistoryIds: loadedHistoryIds ?? this.loadedHistoryIds,
hasMoreHistory: hasMoreHistory ?? this.hasMoreHistory,
);
}
@override
List<Object?> get props => [status, benefits, errorMessage];
List<Object?> get props => <Object?>[
status,
benefits,
errorMessage,
historyByBenefitId,
loadingHistoryIds,
loadedHistoryIds,
hasMoreHistory,
];
}

View File

@@ -0,0 +1,123 @@
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';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
import 'package:staff_home/src/presentation/widgets/benefit_history_page/index.dart';
/// Full-screen page displaying paginated benefit history.
///
/// Supports infinite scroll via [ScrollController] and
/// [BenefitsOverviewCubit.loadMoreBenefitHistory].
class BenefitHistoryPage extends StatefulWidget {
/// Creates a [BenefitHistoryPage].
const BenefitHistoryPage({
required this.benefitId,
required this.benefitTitle,
super.key,
});
/// The ID of the benefit whose history to display.
final String benefitId;
/// The human-readable benefit title shown in the app bar.
final String benefitTitle;
@override
State<BenefitHistoryPage> createState() => _BenefitHistoryPageState();
}
class _BenefitHistoryPageState extends State<BenefitHistoryPage> {
/// Scroll controller for infinite scroll detection.
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
final BenefitsOverviewCubit cubit =
Modular.get<BenefitsOverviewCubit>();
if (!cubit.state.loadedHistoryIds.contains(widget.benefitId)) {
cubit.loadBenefitHistory(widget.benefitId);
}
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final dynamic i18n = t.staff.home.benefits.overview;
final String pageTitle =
i18n.history_page_title(benefit: widget.benefitTitle) as String;
return Scaffold(
appBar: UiAppBar(
title: pageTitle,
showBackButton: true,
),
body: BlocProvider.value(
value: Modular.get<BenefitsOverviewCubit>(),
child: BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
buildWhen: (BenefitsOverviewState previous,
BenefitsOverviewState current) =>
previous.historyByBenefitId[widget.benefitId] !=
current.historyByBenefitId[widget.benefitId] ||
previous.loadingHistoryIds != current.loadingHistoryIds ||
previous.loadedHistoryIds != current.loadedHistoryIds,
builder: (BuildContext context, BenefitsOverviewState state) {
final bool isLoading =
state.loadingHistoryIds.contains(widget.benefitId);
final bool isLoaded =
state.loadedHistoryIds.contains(widget.benefitId);
final List<BenefitHistory> history =
state.historyByBenefitId[widget.benefitId] ??
<BenefitHistory>[];
final bool hasMore =
state.hasMoreHistory[widget.benefitId] ?? true;
// Initial loading state
if (isLoading && !isLoaded) {
return const BenefitHistorySkeleton();
}
// Empty state
if (isLoaded && history.isEmpty) {
return BenefitHistoryEmptyState(
message: i18n.no_history as String,
);
}
// Loaded list with infinite scroll
return BenefitHistoryList(
history: history,
hasMore: hasMore,
isLoading: isLoading,
scrollController: _scrollController,
);
},
),
),
);
}
/// Triggers loading more history when scrolled near the bottom.
void _onScroll() {
if (!_scrollController.hasClients) return;
final double maxScroll = _scrollController.position.maxScrollExtent;
final double currentScroll = _scrollController.offset;
if (maxScroll - currentScroll <= 200) {
final BenefitsOverviewCubit cubit =
ReadContext(context).read<BenefitsOverviewCubit>();
cubit.loadMoreBenefitHistory(widget.benefitId);
}
}
}

View File

@@ -19,9 +19,8 @@ class BenefitsOverviewPage extends StatelessWidget {
subtitle: t.staff.home.benefits.overview.subtitle,
showBackButton: true,
),
body: BlocProvider<BenefitsOverviewCubit>(
create: (context) =>
Modular.get<BenefitsOverviewCubit>()..loadBenefits(),
body: BlocProvider<BenefitsOverviewCubit>.value(
value: Modular.get<BenefitsOverviewCubit>()..loadBenefits(),
child: BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
builder: (context, state) {
if (state.status == BenefitsOverviewStatus.loading ||

View File

@@ -0,0 +1,23 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Empty state shown when a benefit has no history entries.
class BenefitHistoryEmptyState extends StatelessWidget {
/// Creates a [BenefitHistoryEmptyState].
const BenefitHistoryEmptyState({
required this.message,
super.key,
});
/// The localized message displayed as the empty-state title.
final String message;
@override
Widget build(BuildContext context) {
return UiEmptyState(
icon: UiIcons.clock,
title: message,
description: '',
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_history_row.dart';
/// Scrollable list of [BenefitHistoryRow] items with a bottom loading
/// indicator for infinite-scroll pagination.
class BenefitHistoryList extends StatelessWidget {
/// Creates a [BenefitHistoryList].
const BenefitHistoryList({
required this.history,
required this.hasMore,
required this.isLoading,
required this.scrollController,
super.key,
});
/// The benefit history entries to display.
final List<BenefitHistory> history;
/// Whether additional pages are available to fetch.
final bool hasMore;
/// Whether a page load is currently in progress.
final bool isLoading;
/// Controller shared with the parent for infinite-scroll detection.
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space4,
),
itemCount: history.length + (hasMore ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (index >= history.length) {
// Bottom loading indicator
return isLoading
? const Padding(
padding: EdgeInsets.all(UiConstants.space4),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink();
}
return BenefitHistoryRow(history: history[index]);
},
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Shimmer skeleton shown while the initial benefit history page loads.
class BenefitHistorySkeleton extends StatelessWidget {
/// Creates a [BenefitHistorySkeleton].
const BenefitHistorySkeleton({super.key});
@override
Widget build(BuildContext context) {
return UiShimmer(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
children: <Widget>[
for (int i = 0; i < 8; i++)
Padding(
padding:
const EdgeInsets.symmetric(vertical: UiConstants.space2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
UiShimmerLine(width: 100, height: 14),
UiShimmerLine(width: 80, height: 14),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,3 @@
export 'benefit_history_empty_state.dart';
export 'benefit_history_list.dart';
export 'benefit_history_skeleton.dart';

View File

@@ -2,8 +2,9 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_card_header.dart';
import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_history_preview.dart';
/// Card widget displaying detailed benefit information.
/// Card widget displaying detailed benefit information with history preview.
class BenefitCard extends StatelessWidget {
/// Creates a [BenefitCard].
const BenefitCard({required this.benefit, super.key});
@@ -24,6 +25,11 @@ class BenefitCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
BenefitCardHeader(benefit: benefit),
const SizedBox(height: UiConstants.space4),
BenefitHistoryPreview(
benefitId: benefit.benefitId,
benefitTitle: benefit.title,
),
],
),
);

View File

@@ -0,0 +1,177 @@
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';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_history_row.dart';
/// Expandable preview section showing recent benefit history on a card.
///
/// Collapses by default. On first expand, triggers a lazy load of history
/// for the given [benefitId] via [BenefitsOverviewCubit.loadBenefitHistory].
/// Shows the first 5 records and a "Show all" button when more exist.
class BenefitHistoryPreview extends StatefulWidget {
/// Creates a [BenefitHistoryPreview].
const BenefitHistoryPreview({
required this.benefitId,
required this.benefitTitle,
super.key,
});
/// The ID of the benefit whose history to display.
final String benefitId;
/// The human-readable benefit title, passed to the full history page.
final String benefitTitle;
@override
State<BenefitHistoryPreview> createState() => _BenefitHistoryPreviewState();
}
class _BenefitHistoryPreviewState extends State<BenefitHistoryPreview> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final dynamic i18n = t.staff.home.benefits.overview;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space2,
children: <Widget>[
InkWell(
onTap: _toggleExpanded,
child: Padding(
padding: const EdgeInsets.only(top: UiConstants.space4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
i18n.history_header as String,
style: UiTypography.footnote2b.textSecondary,
),
Icon(
_isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown,
size: UiConstants.iconSm,
color: UiColors.iconSecondary,
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: _isExpanded ? _buildContent(i18n) : const SizedBox.shrink(),
),
],
);
}
/// Toggles expansion and triggers history load on first expand.
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
});
if (_isExpanded) {
final BenefitsOverviewCubit cubit =
ReadContext(context).read<BenefitsOverviewCubit>();
cubit.loadBenefitHistory(widget.benefitId);
}
}
/// Builds the expanded content section.
Widget _buildContent(dynamic i18n) {
return BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
buildWhen: (BenefitsOverviewState previous,
BenefitsOverviewState current) =>
previous.historyByBenefitId[widget.benefitId] !=
current.historyByBenefitId[widget.benefitId] ||
previous.loadingHistoryIds != current.loadingHistoryIds ||
previous.loadedHistoryIds != current.loadedHistoryIds,
builder: (BuildContext context, BenefitsOverviewState state) {
final bool isLoading =
state.loadingHistoryIds.contains(widget.benefitId);
final bool isLoaded =
state.loadedHistoryIds.contains(widget.benefitId);
final List<BenefitHistory> history =
state.historyByBenefitId[widget.benefitId] ?? <BenefitHistory>[];
if (isLoading && !isLoaded) {
return _buildShimmer();
}
if (isLoaded && history.isEmpty) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Text(
i18n.no_history as String,
style: UiTypography.body3r.textSecondary,
),
);
}
final int previewCount = history.length > 5 ? 5 : history.length;
final bool showAll = history.length > 5;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
for (int i = 0; i < previewCount; i++)
BenefitHistoryRow(history: history[i]),
if (!showAll)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _navigateToFullHistory,
child: Text(
i18n.show_all as String,
style: UiTypography.footnote1m.copyWith(
color: UiColors.primary,
),
),
),
),
],
);
},
);
}
/// Builds shimmer placeholder rows while loading.
Widget _buildShimmer() {
return UiShimmer(
child: Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Column(
children: <Widget>[
for (int i = 0; i < 3; i++)
Padding(
padding:
const EdgeInsets.symmetric(vertical: UiConstants.space2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
UiShimmerLine(width: 100, height: 12),
UiShimmerLine(width: 60, height: 12),
],
),
),
],
),
),
);
}
/// Navigates to the full benefit history page.
void _navigateToFullHistory() {
Modular.to.toBenefitHistory(
benefitId: widget.benefitId,
benefitTitle: widget.benefitTitle,
);
}
}

View File

@@ -0,0 +1,132 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// A single row displaying one [BenefitHistory] record.
///
/// Shows the effective date, optional notes, accrued hours badge, and a
/// status chip. Used in both [BenefitHistoryPreview] and [BenefitHistoryPage].
class BenefitHistoryRow extends StatelessWidget {
/// Creates a [BenefitHistoryRow].
const BenefitHistoryRow({required this.history, super.key});
/// The history record to display.
final BenefitHistory history;
@override
Widget build(BuildContext context) {
final dynamic i18n = t.staff.home.benefits.overview;
return Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Left: notes + date
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (history.notes != null && history.notes!.isNotEmpty)
Text(
history.notes!,
style: UiTypography.body2r,
),
const SizedBox(height: UiConstants.space2),
Text(
DateFormat('d MMM, yyyy').format(history.effectiveAt),
style: UiTypography.footnote1r.textSecondary,
),
],
),
),
const SizedBox(width: UiConstants.space3),
// Right: status chip + hours badge
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
_buildStatusChip(i18n),
const SizedBox(height: UiConstants.space2),
_buildHoursBadge(i18n),
],
),
],
),
);
}
/// Builds the hours badge showing tracked hours.
Widget _buildHoursBadge(dynamic i18n) {
final String label = '+${history.trackedHours}h';
return Text(
label,
style: UiTypography.footnote1r.textSecondary,
);
}
/// Builds a chip indicating the benefit history status.
Widget _buildStatusChip(dynamic i18n) {
final _StatusStyle statusStyle = _resolveStatusStyle(history.status);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: statusStyle.backgroundColor,
borderRadius: UiConstants.radiusFull,
border: Border.all(color: statusStyle.borderColor, width: 0.5),
),
child: Text(
statusStyle.label,
style: UiTypography.footnote2m.copyWith(color: statusStyle.textColor),
),
);
}
/// Maps a [BenefitStatus] to display style values.
_StatusStyle _resolveStatusStyle(BenefitStatus status) {
final dynamic i18n = t.staff.home.benefits.overview.status;
switch (status) {
case BenefitStatus.active:
return _StatusStyle(
label: i18n.submitted,
backgroundColor: UiColors.tagSuccess,
textColor: UiColors.textSuccess,
borderColor: UiColors.tagSuccess,
);
case BenefitStatus.pending:
return _StatusStyle(
label: i18n.pending,
backgroundColor: UiColors.tagPending,
textColor: UiColors.mutedForeground,
borderColor: UiColors.border,
);
case BenefitStatus.inactive:
case BenefitStatus.unknown:
return _StatusStyle(
label: i18n.pending,
backgroundColor: UiColors.muted,
textColor: UiColors.mutedForeground,
borderColor: UiColors.border,
);
}
}
}
/// Internal value type for status chip styling.
class _StatusStyle {
const _StatusStyle({
required this.label,
required this.backgroundColor,
required this.textColor,
required this.borderColor,
});
final String label;
final Color backgroundColor;
final Color textColor;
final Color borderColor;
}

View File

@@ -1,10 +1,9 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'quick_actions_skeleton.dart';
import 'recommended_section_skeleton.dart';
import 'shift_section_skeleton.dart';
import 'skeleton_divider.dart';
import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/quick_actions_skeleton.dart';
import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/recommended_section_skeleton.dart';
import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/shift_section_skeleton.dart';
import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/skeleton_divider.dart';
/// Shimmer loading skeleton for the staff home page.
///

View File

@@ -1,7 +1,6 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'shift_card_skeleton.dart';
import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton/shift_card_skeleton.dart';
/// Skeleton for a shift section (section header + 2 shift card placeholders).
class ShiftSectionSkeleton extends StatelessWidget {

View File

@@ -0,0 +1,193 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// A reusable compact card for displaying shift information on the home page.
///
/// Accepts display-ready primitive fields so it works with any shift type
/// (today shifts, tomorrow shifts, etc.).
class HomeShiftCard extends StatelessWidget {
/// Creates a [HomeShiftCard].
const HomeShiftCard({
super.key,
required this.shiftId,
required this.title,
this.subtitle,
required this.location,
required this.startTime,
required this.endTime,
this.hourlyRate,
this.totalRate,
this.onTap,
});
/// Unique identifier of the shift.
final String shiftId;
/// Primary display text (client name or role name).
final String title;
/// Secondary display text (role name when title is client name).
final String? subtitle;
/// Location address to display.
final String location;
/// Shift start time.
final DateTime startTime;
/// Shift end time.
final DateTime endTime;
/// Hourly rate in dollars, null if not available.
final double? hourlyRate;
/// Total rate in dollars, null if not available.
final double? totalRate;
/// Callback when the card is tapped.
final VoidCallback? onTap;
/// Formats a [DateTime] as a lowercase 12-hour time string (e.g. "9:00am").
String _formatTime(DateTime time) {
return DateFormat('h:mma').format(time).toLowerCase();
}
/// Computes the shift duration in whole hours.
double _durationHours() {
final int minutes = endTime.difference(startTime).inMinutes;
double hours = minutes / 60;
if (hours < 0) hours += 24;
return hours.roundToDouble();
}
@override
Widget build(BuildContext context) {
final bool hasRate = hourlyRate != null && hourlyRate! > 0;
final double durationHours = _durationHours();
final double estimatedTotal = (totalRate != null && totalRate! > 0)
? totalRate!
: (hourlyRate ?? 0) * durationHours;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
spacing: UiConstants.space3,
children: [
Container(
width: UiConstants.space10,
height: UiConstants.space10,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
border: Border.all(color: UiColors.border),
),
child: Icon(
UiIcons.building,
size: UiConstants.space5,
color: UiColors.mutedForeground,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: UiTypography.body1m.textPrimary,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null)
Text(
subtitle!,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
),
],
),
if (hasRate) ...<Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${estimatedTotal.toStringAsFixed(0)}',
style: UiTypography.title1m.textPrimary,
),
Text(
'\$${hourlyRate!.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
style: UiTypography.footnote2r.textSecondary,
),
],
),
],
],
),
const SizedBox(height: UiConstants.space3),
// Time and location row
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space1,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
UiIcons.clock,
size: UiConstants.space3,
color: UiColors.mutedForeground,
),
const SizedBox(width: UiConstants.space1),
Text(
'${_formatTime(startTime)} - ${_formatTime(endTime)}',
style: UiTypography.body3r.textSecondary,
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
UiIcons.mapPin,
size: UiConstants.space3,
color: UiColors.mutedForeground,
),
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(
location,
style: UiTypography.body3r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
],
),
),
);
}
}

View File

@@ -21,11 +21,20 @@ class RecommendedShiftCard extends StatelessWidget {
return DateFormat('h:mma').format(time).toLowerCase();
}
/// Computes the shift duration in whole hours.
double _durationHours() {
final int minutes = shift.endTime.difference(shift.startTime).inMinutes;
double hours = minutes / 60;
if (hours < 0) hours += 24;
return hours.roundToDouble();
}
@override
Widget build(BuildContext context) {
final dynamic recI18n = t.staff.home.recommended_card;
final Size size = MediaQuery.sizeOf(context);
final double hourlyRate = shift.hourlyRateCents / 100;
final double durationHours = _durationHours();
final double estimatedTotal = shift.hourlyRate * durationHours;
return GestureDetector(
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
@@ -69,14 +78,16 @@ class RecommendedShiftCard extends StatelessWidget {
children: <Widget>[
Flexible(
child: Text(
shift.roleName,
shift.roleName.isNotEmpty
? shift.roleName
: shift.clientName,
style: UiTypography.body1m.textPrimary,
overflow: TextOverflow.ellipsis,
),
),
Text(
'\$${hourlyRate.toStringAsFixed(0)}/h',
style: UiTypography.headline4b,
'\$${estimatedTotal.toStringAsFixed(0)}',
style: UiTypography.title1m.textPrimary,
),
],
),
@@ -85,12 +96,14 @@ class RecommendedShiftCard extends StatelessWidget {
spacing: UiConstants.space1,
children: <Widget>[
Text(
shift.orderType.toJson(),
shift.clientName.isNotEmpty
? shift.clientName
: shift.orderType.toJson(),
style: UiTypography.body3r.textSecondary,
),
Text(
'\$${hourlyRate.toStringAsFixed(0)}/hr',
style: UiTypography.body3r.textSecondary,
'\$${shift.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
style: UiTypography.footnote2r.textSecondary,
),
],
),

View File

@@ -1,7 +1,6 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'section_header.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart';
/// A common layout widget for home page sections.
///

View File

@@ -3,12 +3,12 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
import 'package:staff_home/src/presentation/widgets/home_page/home_shift_card.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
/// A widget that displays today's shifts section.
@@ -45,7 +45,29 @@ class TodaysShiftsSection extends StatelessWidget {
: Column(
children: shifts
.map(
(TodayShift shift) => _TodayShiftCard(shift: shift),
(TodayShift shift) => HomeShiftCard(
shiftId: shift.shiftId,
title: shift.roleName.isNotEmpty
? shift.roleName
: shift.clientName,
subtitle: shift.clientName.isNotEmpty
? shift.clientName
: null,
location:
shift.locationAddress?.isNotEmpty == true
? shift.locationAddress!
: shift.location,
startTime: shift.startTime,
endTime: shift.endTime,
hourlyRate: shift.hourlyRate > 0
? shift.hourlyRate
: null,
totalRate: shift.totalRate > 0
? shift.totalRate
: null,
onTap: () => Modular.to
.toShiftDetailsById(shift.shiftId),
),
)
.toList(),
),
@@ -55,70 +77,6 @@ class TodaysShiftsSection extends StatelessWidget {
}
}
/// Compact card for a today's shift.
class _TodayShiftCard extends StatelessWidget {
const _TodayShiftCard({required this.shift});
/// The today-shift to display.
final TodayShift shift;
String _formatTime(DateTime time) {
return DateFormat('h:mma').format(time).toLowerCase();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
child: Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
Container(
width: UiConstants.space12,
height: UiConstants.space12,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: Icon(
UiIcons.building,
color: UiColors.mutedForeground,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
shift.roleName,
style: UiTypography.body1m.textPrimary,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: UiConstants.space1),
Text(
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}',
style: UiTypography.body3r.textSecondary,
),
],
),
),
],
),
),
);
}
}
/// Inline shimmer skeleton for the shifts section loading state.
class _ShiftsSectionSkeleton extends StatelessWidget {
const _ShiftsSectionSkeleton();

View File

@@ -1,14 +1,13 @@
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';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
import 'package:staff_home/src/presentation/widgets/home_page/home_shift_card.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
/// A widget that displays tomorrow's shifts section.
@@ -35,8 +34,26 @@ class TomorrowsShiftsSection extends StatelessWidget {
: Column(
children: shifts
.map(
(AssignedShift shift) =>
_TomorrowShiftCard(shift: shift),
(AssignedShift shift) => HomeShiftCard(
shiftId: shift.shiftId,
title: shift.clientName.isNotEmpty
? shift.clientName
: shift.roleName,
subtitle: shift.clientName.isNotEmpty
? shift.roleName
: null,
location: shift.location,
startTime: shift.startTime,
endTime: shift.endTime,
hourlyRate: shift.hourlyRate > 0
? shift.hourlyRate
: null,
totalRate: shift.totalRate > 0
? shift.totalRate
: null,
onTap: () => Modular.to
.toShiftDetailsById(shift.shiftId),
),
)
.toList(),
),
@@ -45,89 +62,3 @@ class TomorrowsShiftsSection extends StatelessWidget {
);
}
}
/// Compact card for a tomorrow's shift.
class _TomorrowShiftCard extends StatelessWidget {
const _TomorrowShiftCard({required this.shift});
/// The assigned shift to display.
final AssignedShift shift;
String _formatTime(DateTime time) {
return DateFormat('h:mma').format(time).toLowerCase();
}
@override
Widget build(BuildContext context) {
final double hourlyRate = shift.hourlyRateCents / 100;
return GestureDetector(
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
child: Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
Container(
width: UiConstants.space12,
height: UiConstants.space12,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: Icon(
UiIcons.building,
color: UiColors.mutedForeground,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Flexible(
child: Text(
shift.roleName,
style: UiTypography.body1m.textPrimary,
overflow: TextOverflow.ellipsis,
),
),
Text.rich(
TextSpan(
text:
'\$${hourlyRate % 1 == 0 ? hourlyRate.toInt() : hourlyRate.toStringAsFixed(2)}',
style: UiTypography.body1b.textPrimary,
children: <InlineSpan>[
TextSpan(
text: '/h',
style: UiTypography.body3r,
),
],
),
),
],
),
const SizedBox(height: UiConstants.space1),
Text(
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}',
style: UiTypography.body3r.textSecondary,
),
],
),
),
],
),
),
);
}
}

View File

@@ -4,9 +4,11 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/data/repositories/home_repository_impl.dart';
import 'package:staff_home/src/domain/repositories/home_repository.dart';
import 'package:staff_home/src/domain/usecases/get_benefits_history_usecase.dart';
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
import 'package:staff_home/src/presentation/pages/benefit_history_page.dart';
import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart';
import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
@@ -33,6 +35,9 @@ class StaffHomeModule extends Module {
i.addLazySingleton<GetProfileCompletionUseCase>(
() => GetProfileCompletionUseCase(i.get<HomeRepository>()),
);
i.addLazySingleton<GetBenefitsHistoryUseCase>(
() => GetBenefitsHistoryUseCase(i.get<HomeRepository>()),
);
// Presentation layer - Cubits
i.addLazySingleton<HomeCubit>(
@@ -42,9 +47,12 @@ class StaffHomeModule extends Module {
),
);
// Cubit for benefits overview page
// Cubit for benefits overview page (includes history support)
i.addLazySingleton<BenefitsOverviewCubit>(
() => BenefitsOverviewCubit(repository: i.get<HomeRepository>()),
() => BenefitsOverviewCubit(
getDashboard: i.get<GetDashboardUseCase>(),
getBenefitsHistory: i.get<GetBenefitsHistoryUseCase>(),
),
);
}
@@ -58,5 +66,16 @@ class StaffHomeModule extends Module {
StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefits),
child: (BuildContext context) => const BenefitsOverviewPage(),
);
r.child(
StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefitHistory),
child: (BuildContext context) {
final Map<String, dynamic>? args =
r.args.data as Map<String, dynamic>?;
return BenefitHistoryPage(
benefitId: args?['benefitId'] as String? ?? '',
benefitTitle: args?['benefitTitle'] as String? ?? '',
);
},
);
}
}

View File

@@ -1,8 +1,7 @@
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_payments/src/data/repositories/payments_repository_impl.dart';
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
import 'package:staff_payments/src/domain/usecases/get_payment_chart_usecase.dart';

View File

@@ -27,7 +27,7 @@ class EarlyPayPage extends StatelessWidget {
border: Border.all(color: UiColors.primary.withValues(alpha: 0.1)),
),
child: Column(
children: [
children: <Widget>[
Text(
context.t.staff_payments.early_pay.available_label,
style: UiTypography.body2m.textSecondary,
@@ -65,13 +65,13 @@ class EarlyPayPage extends StatelessWidget {
border: Border.all(color: UiColors.separatorPrimary),
),
child: Row(
children: [
children: <Widget>[
const Icon(UiIcons.bank, size: 24, color: UiColors.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
Text('Chase Bank', style: UiTypography.body2b.textPrimary),
Text('Ending in 4321', style: UiTypography.footnote2r.textSecondary),
],

View File

@@ -18,13 +18,13 @@ class PaymentItemSkeleton extends StatelessWidget {
borderRadius: UiConstants.radiusLg,
),
child: const Row(
children: [
children: <Widget>[
UiShimmerCircle(size: 40),
SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
UiShimmerLine(width: 140, height: 14),
SizedBox(height: UiConstants.space2),
UiShimmerLine(width: 100, height: 12),

View File

@@ -16,12 +16,12 @@ class PaymentsPageSkeleton extends StatelessWidget {
return UiShimmer(
child: SingleChildScrollView(
child: Column(
children: [
children: <Widget>[
// Header section with gradient
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colors: <Color>[
UiColors.primary,
UiColors.primary.withValues(alpha: 0.8),
],
@@ -37,7 +37,7 @@ class PaymentsPageSkeleton extends StatelessWidget {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
// Title placeholder
const UiShimmerLine(width: 120, height: 24),
const SizedBox(height: UiConstants.space6),
@@ -45,7 +45,7 @@ class PaymentsPageSkeleton extends StatelessWidget {
// Balance center
const Center(
child: Column(
children: [
children: <Widget>[
UiShimmerLine(width: 100, height: 14),
SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 160, height: 36),
@@ -73,7 +73,7 @@ class PaymentsPageSkeleton extends StatelessWidget {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
// Earnings graph placeholder
UiShimmerBox(
width: double.infinity,
@@ -83,10 +83,10 @@ class PaymentsPageSkeleton extends StatelessWidget {
const SizedBox(height: UiConstants.space6),
// Quick stats row
Row(
children: [
const Row(
children: <Widget>[
Expanded(child: UiShimmerStatsCard()),
const SizedBox(width: UiConstants.space3),
SizedBox(width: UiConstants.space3),
Expanded(child: UiShimmerStatsCard()),
],
),
@@ -99,7 +99,7 @@ class PaymentsPageSkeleton extends StatelessWidget {
// Payment history items
UiShimmerList(
itemCount: 4,
itemBuilder: (index) => const PaymentItemSkeleton(),
itemBuilder: (int index) => const PaymentItemSkeleton(),
),
],
),

View File

@@ -1,17 +1,19 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
/// Repository implementation for the main profile page.
///
/// Uses the V2 API to fetch staff profile, section statuses, and completion.
class ProfileRepositoryImpl {
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
/// Creates a [ProfileRepositoryImpl].
ProfileRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
final BaseApiService _api;
/// Fetches the staff profile from the V2 session endpoint.
@override
Future<Staff> getStaffProfile() async {
final ApiResponse response =
await _api.get(StaffEndpoints.session);
@@ -20,7 +22,7 @@ class ProfileRepositoryImpl {
return Staff.fromJson(json);
}
/// Fetches the profile section completion statuses.
@override
Future<ProfileSectionStatus> getProfileSections() async {
final ApiResponse response =
await _api.get(StaffEndpoints.profileSections);
@@ -29,7 +31,7 @@ class ProfileRepositoryImpl {
return ProfileSectionStatus.fromJson(json);
}
/// Signs out the current user.
@override
Future<void> signOut() async {
await _api.post(AuthEndpoints.signOut);
}

View File

@@ -0,0 +1,16 @@
import 'package:krow_domain/krow_domain.dart';
/// Abstract interface for the staff profile repository.
///
/// Defines the contract for fetching staff profile data,
/// section completion statuses, and signing out.
abstract interface class ProfileRepositoryInterface {
/// Fetches the staff profile from the backend.
Future<Staff> getStaffProfile();
/// Fetches the profile section completion statuses.
Future<ProfileSectionStatus> getProfileSections();
/// Signs out the current user.
Future<void> signOut();
}

View File

@@ -0,0 +1,17 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
/// Use case for retrieving profile section completion statuses.
class GetProfileSectionsUseCase implements NoInputUseCase<ProfileSectionStatus> {
/// Creates a [GetProfileSectionsUseCase] with the required [repository].
GetProfileSectionsUseCase(this._repository);
final ProfileRepositoryInterface _repository;
@override
Future<ProfileSectionStatus> call() {
return _repository.getProfileSections();
}
}

View File

@@ -0,0 +1,17 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
/// Use case for retrieving the staff member's profile.
class GetStaffProfileUseCase implements NoInputUseCase<Staff> {
/// Creates a [GetStaffProfileUseCase] with the required [repository].
GetStaffProfileUseCase(this._repository);
final ProfileRepositoryInterface _repository;
@override
Future<Staff> call() {
return _repository.getStaffProfile();
}
}

View File

@@ -0,0 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
/// Use case for signing out the current user.
class SignOutUseCase implements NoInputUseCase<void> {
/// Creates a [SignOutUseCase] with the required [repository].
SignOutUseCase(this._repository);
final ProfileRepositoryInterface _repository;
@override
Future<void> call() {
return _repository.signOut();
}
}

View File

@@ -2,19 +2,30 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart';
import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart';
import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart';
import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart';
import 'package:staff_profile/src/presentation/blocs/profile_state.dart';
/// Cubit for managing the Profile feature state.
///
/// Uses the V2 API via [ProfileRepositoryImpl] for all data fetching.
/// Delegates all data fetching to use cases, following Clean Architecture.
/// Loads the staff profile and section completion statuses in a single flow.
class ProfileCubit extends Cubit<ProfileState>
with BlocErrorHandler<ProfileState> {
/// Creates a [ProfileCubit] with the required repository.
ProfileCubit(this._repository) : super(const ProfileState());
/// Creates a [ProfileCubit] with the required use cases.
ProfileCubit({
required GetStaffProfileUseCase getStaffProfileUseCase,
required GetProfileSectionsUseCase getProfileSectionsUseCase,
required SignOutUseCase signOutUseCase,
}) : _getStaffProfileUseCase = getStaffProfileUseCase,
_getProfileSectionsUseCase = getProfileSectionsUseCase,
_signOutUseCase = signOutUseCase,
super(const ProfileState());
final ProfileRepositoryImpl _repository;
final GetStaffProfileUseCase _getStaffProfileUseCase;
final GetProfileSectionsUseCase _getProfileSectionsUseCase;
final SignOutUseCase _signOutUseCase;
/// Loads the staff member's profile.
Future<void> loadProfile() async {
@@ -23,7 +34,7 @@ class ProfileCubit extends Cubit<ProfileState>
await handleError(
emit: emit,
action: () async {
final Staff profile = await _repository.getStaffProfile();
final Staff profile = await _getStaffProfileUseCase();
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
},
onError: (String errorKey) =>
@@ -37,7 +48,7 @@ class ProfileCubit extends Cubit<ProfileState>
emit: emit,
action: () async {
final ProfileSectionStatus sections =
await _repository.getProfileSections();
await _getProfileSectionsUseCase();
emit(state.copyWith(
personalInfoComplete: sections.personalInfoCompleted,
emergencyContactsComplete: sections.emergencyContactCompleted,
@@ -62,7 +73,7 @@ class ProfileCubit extends Cubit<ProfileState>
await handleError(
emit: emit,
action: () async {
await _repository.signOut();
await _signOutUseCase();
emit(state.copyWith(status: ProfileStatus.signedOut));
},
onError: (String _) =>

View File

@@ -19,7 +19,7 @@ class LogoutButton extends StatelessWidget {
/// sign-out process via the ProfileCubit.
void _handleSignOut(BuildContext context, ProfileState state) {
if (state.status != ProfileStatus.loading) {
context.read<ProfileCubit>().signOut();
ReadContext(context).read<ProfileCubit>().signOut();
}
}
@@ -47,7 +47,7 @@ class LogoutButton extends StatelessWidget {
onTap: () {
_handleSignOut(
context,
context.read<ProfileCubit>().state,
ReadContext(context).read<ProfileCubit>().state,
);
},
borderRadius: UiConstants.radiusLg,

View File

@@ -26,9 +26,9 @@ class MenuSectionSkeleton extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Section title placeholder
Padding(
padding: const EdgeInsets.only(left: UiConstants.space1),
child: const UiShimmerLine(width: 100, height: 12),
const Padding(
padding: EdgeInsets.only(left: UiConstants.space1),
child: UiShimmerLine(width: 100, height: 12),
),
const SizedBox(height: UiConstants.space3),
// Menu items grid

View File

@@ -25,18 +25,18 @@ class ProfileHeaderSkeleton extends StatelessWidget {
bottom: Radius.circular(UiConstants.space6),
),
),
child: SafeArea(
child: const SafeArea(
bottom: false,
child: Column(
children: <Widget>[
// Avatar placeholder
const UiShimmerCircle(size: 112),
const SizedBox(height: UiConstants.space4),
UiShimmerCircle(size: 112),
SizedBox(height: UiConstants.space4),
// Name placeholder
const UiShimmerLine(width: 160, height: 20),
const SizedBox(height: UiConstants.space2),
UiShimmerLine(width: 160, height: 20),
SizedBox(height: UiConstants.space2),
// Level badge placeholder
const UiShimmerBox(width: 100, height: 24),
UiShimmerBox(width: 100, height: 24),
],
),
),

View File

@@ -26,13 +26,11 @@ class ProfilePageSkeleton extends StatelessWidget {
// Content offset to overlap the header bottom radius
Transform.translate(
offset: const Offset(0, -UiConstants.space6),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: Column(
spacing: UiConstants.space6,
children: const <Widget>[
children: <Widget>[
// Reliability stats row (5 items)
ReliabilityStatsSkeleton(),

View File

@@ -4,13 +4,17 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart';
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart';
import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart';
import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart';
import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart';
import 'package:staff_profile/src/presentation/pages/staff_profile_page.dart';
/// The entry module for the Staff Profile feature.
///
/// Uses the V2 REST API via [BaseApiService] for all backend access.
/// Section completion statuses are fetched in a single API call.
/// Registers repository interface, use cases, and cubit for DI.
class StaffProfileModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@@ -18,15 +22,36 @@ class StaffProfileModule extends Module {
@override
void binds(Injector i) {
// Repository
i.addLazySingleton<ProfileRepositoryImpl>(
i.addLazySingleton<ProfileRepositoryInterface>(
() => ProfileRepositoryImpl(
apiService: i.get<BaseApiService>(),
),
);
// Use Cases
i.addLazySingleton<GetStaffProfileUseCase>(
() => GetStaffProfileUseCase(
i.get<ProfileRepositoryInterface>(),
),
);
i.addLazySingleton<GetProfileSectionsUseCase>(
() => GetProfileSectionsUseCase(
i.get<ProfileRepositoryInterface>(),
),
);
i.addLazySingleton<SignOutUseCase>(
() => SignOutUseCase(
i.get<ProfileRepositoryInterface>(),
),
);
// Cubit
i.addLazySingleton<ProfileCubit>(
() => ProfileCubit(i.get<ProfileRepositoryImpl>()),
() => ProfileCubit(
getStaffProfileUseCase: i.get<GetStaffProfileUseCase>(),
getProfileSectionsUseCase: i.get<GetProfileSectionsUseCase>(),
signOutUseCase: i.get<SignOutUseCase>(),
),
);
}

View File

@@ -40,27 +40,42 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
Future<StaffCertificate> uploadCertificate({
required String certificateType,
required String name,
required String filePath,
String? filePath,
String? existingFileUri,
DateTime? expiryDate,
String? issuer,
String? certificateNumber,
}) async {
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: FileVisibility.private,
);
String fileUri;
String? signedUrl;
// 2. Generate a signed URL
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
if (filePath != null) {
// NEW FILE: Full upload pipeline
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: FileVisibility.private,
);
// 3. Initiate verification
// 2. Generate a signed URL
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
fileUri = uploadRes.fileUri;
signedUrl = signedUrlRes.signedUrl;
} else if (existingFileUri != null) {
// EXISTING FILE: Metadata-only update — skip upload steps
fileUri = existingFileUri;
} else {
throw ArgumentError('Either filePath or existingFileUri must be provided');
}
// 3. Create verification (works for both new and existing files)
final VerificationResponse verificationRes =
await _verificationService.createVerification(
fileUri: uploadRes.fileUri,
fileUri: fileUri,
type: 'certification',
subjectType: 'worker',
subjectId: certificateType,
@@ -71,21 +86,21 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
},
);
// 4. Save certificate via V2 API
// 4. Save/update certificate via V2 API (upserts on certificate_type)
await _api.post(
StaffEndpoints.certificates,
data: <String, dynamic>{
'certificateType': certificateType,
'name': name,
'fileUri': signedUrlRes.signedUrl,
'expiresAt': expiryDate?.toIso8601String(),
if (signedUrl != null) 'fileUri': signedUrl,
'expiresAt': expiryDate?.toUtc().toIso8601String(),
'issuer': issuer,
'certificateNumber': certificateNumber,
'verificationId': verificationRes.verificationId,
},
);
// 5. Return updated list
// 5. Return updated certificate
final List<StaffCertificate> certificates = await getCertificates();
return certificates.firstWhere(
(StaffCertificate c) => c.certificateType == certificateType,

View File

@@ -9,10 +9,15 @@ abstract interface class CertificatesRepository {
Future<List<StaffCertificate>> getCertificates();
/// Uploads a certificate file and saves the record.
///
/// When [filePath] is provided, a new file is uploaded to cloud storage.
/// When only [existingFileUri] is provided, the existing stored file is
/// reused and only metadata (e.g. expiry date) is updated.
Future<StaffCertificate> uploadCertificate({
required String certificateType,
required String name,
required String filePath,
String? filePath,
String? existingFileUri,
DateTime? expiryDate,
String? issuer,
String? certificateNumber,

View File

@@ -15,6 +15,7 @@ class UploadCertificateUseCase
certificateType: params.certificateType,
name: params.name,
filePath: params.filePath,
existingFileUri: params.existingFileUri,
expiryDate: params.expiryDate,
issuer: params.issuer,
certificateNumber: params.certificateNumber,
@@ -25,14 +26,21 @@ class UploadCertificateUseCase
/// Parameters for [UploadCertificateUseCase].
class UploadCertificateParams {
/// Creates [UploadCertificateParams].
///
/// Either [filePath] (for a new file upload) or [existingFileUri] (for a
/// metadata-only update using an already-stored file) must be provided.
UploadCertificateParams({
required this.certificateType,
required this.name,
required this.filePath,
this.filePath,
this.existingFileUri,
this.expiryDate,
this.issuer,
this.certificateNumber,
});
}) : assert(
filePath != null || existingFileUri != null,
'Either filePath or existingFileUri must be provided',
);
/// The type of certification (e.g. "FOOD_HYGIENE", "SIA_BADGE").
final String certificateType;
@@ -40,8 +48,12 @@ class UploadCertificateParams {
/// The name of the certificate.
final String name;
/// The local file path to upload.
final String filePath;
/// The local file path to upload, or null when reusing an existing file.
final String? filePath;
/// The remote URI of an already-uploaded file, used for metadata-only
/// updates (e.g. changing only the expiry date).
final String? existingFileUri;
/// The expiry date of the certificate.
final DateTime? expiryDate;

View File

@@ -43,6 +43,12 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
super.initState();
_cubit = Modular.get<CertificateUploadCubit>();
// Pre-populate file path with existing remote URI when editing so
// the form is valid without re-picking a file.
if (widget.certificate?.fileUri != null) {
_cubit.setSelectedFilePath(widget.certificate!.fileUri);
}
if (widget.certificate != null) {
_selectedExpiryDate = widget.certificate!.expiresAt;
_issuerController.text = widget.certificate!.issuer ?? '';
@@ -148,9 +154,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
@override
Widget build(BuildContext context) {
return BlocProvider<CertificateUploadCubit>.value(
value: _cubit..setSelectedFilePath(
widget.certificate?.fileUri,
),
value: _cubit,
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
listener: (BuildContext context, CertificateUploadState state) {
if (state.status == CertificateUploadStatus.success) {
@@ -182,7 +186,8 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
PdfFileTypesBanner(
message: t.staff_documents.upload.pdf_banner,
title: t.staff_documents.upload.pdf_banner_title,
description: t.staff_documents.upload.pdf_banner_description,
),
const SizedBox(height: UiConstants.space6),
@@ -222,18 +227,27 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
padding: const EdgeInsets.all(UiConstants.space5),
child: CertificateUploadActions(
isAttested: state.isAttested,
isFormValid: state.selectedFilePath != null &&
isFormValid: (state.selectedFilePath != null ||
widget.certificate?.fileUri != null) &&
state.isAttested &&
_nameController.text.isNotEmpty,
isUploading: state.status == CertificateUploadStatus.uploading,
hasExistingCertificate: widget.certificate != null,
onUploadPressed: () {
final String? selectedPath = state.selectedFilePath;
final bool isLocalFile = selectedPath != null &&
!selectedPath.startsWith('http') &&
!selectedPath.startsWith('gs://');
BlocProvider.of<CertificateUploadCubit>(context)
.uploadCertificate(
UploadCertificateParams(
certificateType: _selectedType,
name: _nameController.text,
filePath: state.selectedFilePath!,
filePath: isLocalFile ? selectedPath : null,
existingFileUri: !isLocalFile
? (selectedPath ?? widget.certificate?.fileUri)
: null,
expiryDate: _selectedExpiryDate,
issuer: _issuerController.text,
certificateNumber: _numberController.text,

View File

@@ -5,6 +5,7 @@ import 'package:core_localization/core_localization.dart';
/// Widget for certificate metadata input fields (name, issuer, number).
class CertificateMetadataFields extends StatelessWidget {
const CertificateMetadataFields({
super.key,
required this.nameController,
required this.issuerController,
required this.numberController,
@@ -32,9 +33,7 @@ class CertificateMetadataFields extends StatelessWidget {
enabled: isNewCertificate,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.name_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
border: OutlineInputBorder(borderRadius: UiConstants.radiusLg),
),
),
const SizedBox(height: UiConstants.space4),
@@ -50,27 +49,20 @@ class CertificateMetadataFields extends StatelessWidget {
enabled: isNewCertificate,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.issuer_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
border: OutlineInputBorder(borderRadius: UiConstants.radiusLg),
),
),
const SizedBox(height: UiConstants.space4),
// Certificate Number Field
Text(
'Certificate Number',
style: UiTypography.body2m.textPrimary,
),
Text('Certificate Number', style: UiTypography.body2m.textPrimary),
const SizedBox(height: UiConstants.space2),
TextField(
controller: numberController,
enabled: isNewCertificate,
decoration: InputDecoration(
hintText: 'Enter number if applicable',
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
border: OutlineInputBorder(borderRadius: UiConstants.radiusLg),
),
),
],

View File

@@ -8,6 +8,7 @@ import '../../blocs/certificate_upload/certificate_upload_cubit.dart';
/// Widget for attestation checkbox and action buttons in certificate upload form.
class CertificateUploadActions extends StatelessWidget {
const CertificateUploadActions({
super.key,
required this.isAttested,
required this.isFormValid,
required this.isUploading,
@@ -34,10 +35,9 @@ class CertificateUploadActions extends StatelessWidget {
children: <Widget>[
Checkbox(
value: isAttested,
onChanged: (bool? val) =>
BlocProvider.of<CertificateUploadCubit>(context).setAttested(
val ?? false,
),
onChanged: (bool? val) => BlocProvider.of<CertificateUploadCubit>(
context,
).setAttested(val ?? false),
activeColor: UiColors.primary,
),
Expanded(
@@ -54,17 +54,11 @@ class CertificateUploadActions extends StatelessWidget {
onPressed: isFormValid ? onUploadPressed : null,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg),
),
child: isUploading
? const CircularProgressIndicator(
color: Colors.white,
)
? const CircularProgressIndicator(color: Colors.white)
: Text(
t.staff_certificates.upload_modal.save,
style: UiTypography.body1m.white,
@@ -87,9 +81,7 @@ class CertificateUploadActions extends StatelessWidget {
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
side: const BorderSide(
color: UiColors.destructive,
),
side: const BorderSide(color: UiColors.destructive),
),
),
),

View File

@@ -6,6 +6,7 @@ import 'package:core_localization/core_localization.dart';
/// Widget for selecting certificate expiry date.
class ExpiryDateField extends StatelessWidget {
const ExpiryDateField({
super.key,
required this.selectedDate,
required this.onTap,
});

View File

@@ -3,12 +3,24 @@ import 'package:flutter/material.dart';
/// Banner displaying accepted file types and size limit for PDF upload.
class PdfFileTypesBanner extends StatelessWidget {
const PdfFileTypesBanner({super.key, required this.message});
const PdfFileTypesBanner({
super.key,
required this.title,
this.description,
});
final String message;
/// Short title for the banner.
final String title;
/// Optional description with additional details.
final String? description;
@override
Widget build(BuildContext context) {
return UiNoticeBanner(title: message, icon: UiIcons.info);
return UiNoticeBanner(
title: title,
description: description,
icon: UiIcons.info,
);
}
}

View File

@@ -12,23 +12,23 @@ class CertificatesHeaderSkeleton extends StatelessWidget {
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space5),
decoration: const BoxDecoration(color: UiColors.primary),
child: SafeArea(
child: const SafeArea(
bottom: false,
child: Column(
children: <Widget>[
const SizedBox(height: UiConstants.space4),
const UiShimmerCircle(size: 64),
const SizedBox(height: UiConstants.space3),
SizedBox(height: UiConstants.space4),
UiShimmerCircle(size: 64),
SizedBox(height: UiConstants.space3),
UiShimmerLine(
width: 120,
height: 14,
),
const SizedBox(height: UiConstants.space2),
SizedBox(height: UiConstants.space2),
UiShimmerLine(
width: 80,
height: 12,
),
const SizedBox(height: UiConstants.space6),
SizedBox(height: UiConstants.space6),
],
),
),

View File

@@ -70,7 +70,8 @@ class DocumentUploadPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
PdfFileTypesBanner(
message: t.staff_documents.upload.pdf_banner,
title: t.staff_documents.upload.pdf_banner_title,
description: t.staff_documents.upload.pdf_banner_description,
),
const SizedBox(height: UiConstants.space6),
DocumentFileSelector(

View File

@@ -3,12 +3,24 @@ import 'package:flutter/material.dart';
/// Banner displaying accepted file types and size limit for PDF upload.
class PdfFileTypesBanner extends StatelessWidget {
const PdfFileTypesBanner({required this.message, super.key});
const PdfFileTypesBanner({
super.key,
required this.title,
this.description,
});
final String message;
/// Short title for the banner.
final String title;
/// Optional description with additional details.
final String? description;
@override
Widget build(BuildContext context) {
return UiNoticeBanner(title: message, icon: UiIcons.info);
return UiNoticeBanner(
title: title,
description: description,
icon: UiIcons.info,
);
}
}

View File

@@ -121,14 +121,14 @@ class _FormI9PageState extends State<FormI9Page> {
void _handleNext(BuildContext context, int currentStep) {
if (currentStep < _steps.length - 1) {
context.read<FormI9Cubit>().nextStep(_steps.length);
ReadContext(context).read<FormI9Cubit>().nextStep(_steps.length);
} else {
context.read<FormI9Cubit>().submit();
ReadContext(context).read<FormI9Cubit>().submit();
}
}
void _handleBack(BuildContext context) {
context.read<FormI9Cubit>().previousStep();
ReadContext(context).read<FormI9Cubit>().previousStep();
}
@override
@@ -178,8 +178,9 @@ class _FormI9PageState extends State<FormI9Page> {
}
},
builder: (BuildContext context, FormI9State state) {
if (state.status == FormI9Status.success)
if (state.status == FormI9Status.success) {
return _buildSuccessView(i18n);
}
return Scaffold(
backgroundColor: UiColors.background,
@@ -458,7 +459,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.first_name,
value: state.firstName,
onChanged: (String val) =>
context.read<FormI9Cubit>().firstNameChanged(val),
ReadContext(context).read<FormI9Cubit>().firstNameChanged(val),
placeholder: i18n.fields.hints.first_name,
),
),
@@ -468,7 +469,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.last_name,
value: state.lastName,
onChanged: (String val) =>
context.read<FormI9Cubit>().lastNameChanged(val),
ReadContext(context).read<FormI9Cubit>().lastNameChanged(val),
placeholder: i18n.fields.hints.last_name,
),
),
@@ -482,7 +483,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.middle_initial,
value: state.middleInitial,
onChanged: (String val) =>
context.read<FormI9Cubit>().middleInitialChanged(val),
ReadContext(context).read<FormI9Cubit>().middleInitialChanged(val),
placeholder: i18n.fields.hints.middle_initial,
),
),
@@ -493,7 +494,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.other_last_names,
value: state.otherLastNames,
onChanged: (String val) =>
context.read<FormI9Cubit>().otherLastNamesChanged(val),
ReadContext(context).read<FormI9Cubit>().otherLastNamesChanged(val),
placeholder: i18n.fields.maiden_name,
),
),
@@ -504,7 +505,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.dob,
value: state.dob,
onChanged: (String val) =>
context.read<FormI9Cubit>().dobChanged(val),
ReadContext(context).read<FormI9Cubit>().dobChanged(val),
placeholder: i18n.fields.hints.dob,
keyboardType: TextInputType.datetime,
),
@@ -517,7 +518,7 @@ class _FormI9PageState extends State<FormI9Page> {
onChanged: (String val) {
String text = val.replaceAll(RegExp(r'\D'), '');
if (text.length > 9) text = text.substring(0, 9);
context.read<FormI9Cubit>().ssnChanged(text);
ReadContext(context).read<FormI9Cubit>().ssnChanged(text);
},
),
const SizedBox(height: UiConstants.space4),
@@ -525,7 +526,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.email,
value: state.email,
onChanged: (String val) =>
context.read<FormI9Cubit>().emailChanged(val),
ReadContext(context).read<FormI9Cubit>().emailChanged(val),
keyboardType: TextInputType.emailAddress,
placeholder: i18n.fields.hints.email,
),
@@ -534,7 +535,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.phone,
value: state.phone,
onChanged: (String val) =>
context.read<FormI9Cubit>().phoneChanged(val),
ReadContext(context).read<FormI9Cubit>().phoneChanged(val),
keyboardType: TextInputType.phone,
placeholder: i18n.fields.hints.phone,
),
@@ -553,7 +554,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.address_long,
value: state.address,
onChanged: (String val) =>
context.read<FormI9Cubit>().addressChanged(val),
ReadContext(context).read<FormI9Cubit>().addressChanged(val),
placeholder: i18n.fields.hints.address,
),
const SizedBox(height: UiConstants.space4),
@@ -561,7 +562,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.apt,
value: state.aptNumber,
onChanged: (String val) =>
context.read<FormI9Cubit>().aptNumberChanged(val),
ReadContext(context).read<FormI9Cubit>().aptNumberChanged(val),
placeholder: i18n.fields.hints.apt,
),
const SizedBox(height: UiConstants.space4),
@@ -573,7 +574,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.city,
value: state.city,
onChanged: (String val) =>
context.read<FormI9Cubit>().cityChanged(val),
ReadContext(context).read<FormI9Cubit>().cityChanged(val),
placeholder: i18n.fields.hints.city,
),
),
@@ -592,7 +593,7 @@ class _FormI9PageState extends State<FormI9Page> {
DropdownButtonFormField<String>(
initialValue: state.state.isEmpty ? null : state.state,
onChanged: (String? val) =>
context.read<FormI9Cubit>().stateChanged(val ?? ''),
ReadContext(context).read<FormI9Cubit>().stateChanged(val ?? ''),
items: _usStates.map((String stateAbbr) {
return DropdownMenuItem<String>(
value: stateAbbr,
@@ -625,7 +626,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.zip,
value: state.zipCode,
onChanged: (String val) =>
context.read<FormI9Cubit>().zipCodeChanged(val),
ReadContext(context).read<FormI9Cubit>().zipCodeChanged(val),
placeholder: i18n.fields.hints.zip,
keyboardType: TextInputType.number,
),
@@ -659,7 +660,7 @@ class _FormI9PageState extends State<FormI9Page> {
i18n.fields.uscis_number_label,
value: state.uscisNumber,
onChanged: (String val) =>
context.read<FormI9Cubit>().uscisNumberChanged(val),
ReadContext(context).read<FormI9Cubit>().uscisNumberChanged(val),
placeholder: i18n.fields.hints.uscis,
),
)
@@ -717,7 +718,7 @@ class _FormI9PageState extends State<FormI9Page> {
}) {
final bool isSelected = state.citizenshipStatus == value;
return GestureDetector(
onTap: () => context.read<FormI9Cubit>().citizenshipStatusChanged(value),
onTap: () => ReadContext(context).read<FormI9Cubit>().citizenshipStatusChanged(value),
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
@@ -802,7 +803,7 @@ class _FormI9PageState extends State<FormI9Page> {
CheckboxListTile(
value: state.preparerUsed,
onChanged: (bool? val) {
context.read<FormI9Cubit>().preparerUsedChanged(val ?? false);
ReadContext(context).read<FormI9Cubit>().preparerUsedChanged(val ?? false);
},
contentPadding: EdgeInsets.zero,
title: Text(
@@ -836,7 +837,7 @@ class _FormI9PageState extends State<FormI9Page> {
TextPosition(offset: state.signature.length),
),
onChanged: (String val) =>
context.read<FormI9Cubit>().signatureChanged(val),
ReadContext(context).read<FormI9Cubit>().signatureChanged(val),
decoration: InputDecoration(
hintText: i18n.fields.signature_hint,
filled: true,

View File

@@ -111,14 +111,14 @@ class _FormW4PageState extends State<FormW4Page> {
void _handleNext(BuildContext context, int currentStep) {
if (currentStep < _steps.length - 1) {
context.read<FormW4Cubit>().nextStep(_steps.length);
ReadContext(context).read<FormW4Cubit>().nextStep(_steps.length);
} else {
context.read<FormW4Cubit>().submit();
ReadContext(context).read<FormW4Cubit>().submit();
}
}
void _handleBack(BuildContext context) {
context.read<FormW4Cubit>().previousStep();
ReadContext(context).read<FormW4Cubit>().previousStep();
}
int _totalCredits(FormW4State state) {
@@ -180,8 +180,9 @@ class _FormW4PageState extends State<FormW4Page> {
}
},
builder: (BuildContext context, FormW4State state) {
if (state.status == FormW4Status.success)
if (state.status == FormW4Status.success) {
return _buildSuccessView(i18n);
}
return Scaffold(
backgroundColor: UiColors.background,
@@ -457,7 +458,7 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.first_name,
value: state.firstName,
onChanged: (String val) =>
context.read<FormW4Cubit>().firstNameChanged(val),
ReadContext(context).read<FormW4Cubit>().firstNameChanged(val),
placeholder: i18n.fields.placeholder_john,
),
),
@@ -467,7 +468,7 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.last_name,
value: state.lastName,
onChanged: (String val) =>
context.read<FormW4Cubit>().lastNameChanged(val),
ReadContext(context).read<FormW4Cubit>().lastNameChanged(val),
placeholder: i18n.fields.placeholder_smith,
),
),
@@ -482,7 +483,7 @@ class _FormW4PageState extends State<FormW4Page> {
onChanged: (String val) {
String text = val.replaceAll(RegExp(r'\D'), '');
if (text.length > 9) text = text.substring(0, 9);
context.read<FormW4Cubit>().ssnChanged(text);
ReadContext(context).read<FormW4Cubit>().ssnChanged(text);
},
),
const SizedBox(height: UiConstants.space4),
@@ -490,7 +491,7 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.address,
value: state.address,
onChanged: (String val) =>
context.read<FormW4Cubit>().addressChanged(val),
ReadContext(context).read<FormW4Cubit>().addressChanged(val),
placeholder: i18n.fields.placeholder_address,
),
const SizedBox(height: UiConstants.space4),
@@ -498,7 +499,7 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.city_state_zip,
value: state.cityStateZip,
onChanged: (String val) =>
context.read<FormW4Cubit>().cityStateZipChanged(val),
ReadContext(context).read<FormW4Cubit>().cityStateZipChanged(val),
placeholder: i18n.fields.placeholder_csz,
),
],
@@ -556,7 +557,7 @@ class _FormW4PageState extends State<FormW4Page> {
) {
final bool isSelected = state.filingStatus == value;
return GestureDetector(
onTap: () => context.read<FormW4Cubit>().filingStatusChanged(value),
onTap: () => ReadContext(context).read<FormW4Cubit>().filingStatusChanged(value),
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
@@ -640,7 +641,7 @@ class _FormW4PageState extends State<FormW4Page> {
),
const SizedBox(height: UiConstants.space6),
GestureDetector(
onTap: () => context.read<FormW4Cubit>().multipleJobsChanged(
onTap: () => ReadContext(context).read<FormW4Cubit>().multipleJobsChanged(
!state.multipleJobs,
),
child: Container(
@@ -751,7 +752,7 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.children_each,
(FormW4State s) => s.qualifyingChildren,
(int val) =>
context.read<FormW4Cubit>().qualifyingChildrenChanged(val),
ReadContext(context).read<FormW4Cubit>().qualifyingChildrenChanged(val),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
@@ -764,7 +765,7 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.other_each,
(FormW4State s) => s.otherDependents,
(int val) =>
context.read<FormW4Cubit>().otherDependentsChanged(val),
ReadContext(context).read<FormW4Cubit>().otherDependentsChanged(val),
),
],
),
@@ -880,7 +881,7 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.other_income,
value: state.otherIncome,
onChanged: (String val) =>
context.read<FormW4Cubit>().otherIncomeChanged(val),
ReadContext(context).read<FormW4Cubit>().otherIncomeChanged(val),
placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number,
),
@@ -896,7 +897,7 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.deductions,
value: state.deductions,
onChanged: (String val) =>
context.read<FormW4Cubit>().deductionsChanged(val),
ReadContext(context).read<FormW4Cubit>().deductionsChanged(val),
placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number,
),
@@ -912,7 +913,7 @@ class _FormW4PageState extends State<FormW4Page> {
i18n.fields.extra_withholding,
value: state.extraWithholding,
onChanged: (String val) =>
context.read<FormW4Cubit>().extraWithholdingChanged(val),
ReadContext(context).read<FormW4Cubit>().extraWithholdingChanged(val),
placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number,
),
@@ -995,7 +996,7 @@ class _FormW4PageState extends State<FormW4Page> {
TextPosition(offset: state.signature.length),
),
onChanged: (String val) =>
context.read<FormW4Cubit>().signatureChanged(val),
ReadContext(context).read<FormW4Cubit>().signatureChanged(val),
decoration: InputDecoration(
hintText: i18n.fields.signature_hint,
filled: true,

View File

@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
/// Widget displaying the overall progress of tax form completion.
class TaxFormsProgressOverview extends StatelessWidget {
const TaxFormsProgressOverview({required this.forms});
const TaxFormsProgressOverview({super.key, required this.forms});
final List<TaxForm> forms;

View File

@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
/// Widget displaying status badge for a tax form.
class TaxFormStatusBadge extends StatelessWidget {
const TaxFormStatusBadge({required this.status});
const TaxFormStatusBadge({super.key, required this.status});
final TaxFormStatus status;

View File

@@ -3,15 +3,10 @@ import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
class AccountCard extends StatelessWidget {
const AccountCard({super.key, required this.account, required this.strings});
final BankAccount account;
final dynamic strings;
const AccountCard({
super.key,
required this.account,
required this.strings,
});
@override
Widget build(BuildContext context) {
final bool isPrimary = account.isPrimary;

View File

@@ -1,45 +1,27 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports
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';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_core/core.dart';
import '../blocs/time_card_bloc.dart';
import '../widgets/month_selector.dart';
import '../widgets/shift_history_list.dart';
import '../widgets/time_card_skeleton/time_card_skeleton.dart';
import '../widgets/time_card_summary.dart';
import 'package:staff_time_card/src/presentation/blocs/time_card_bloc.dart';
import 'package:staff_time_card/src/presentation/widgets/month_selector.dart';
import 'package:staff_time_card/src/presentation/widgets/shift_history_list.dart';
import 'package:staff_time_card/src/presentation/widgets/time_card_skeleton/time_card_skeleton.dart';
import 'package:staff_time_card/src/presentation/widgets/time_card_summary.dart';
/// The main page for displaying the staff time card.
class TimeCardPage extends StatefulWidget {
class TimeCardPage extends StatelessWidget {
/// Creates a [TimeCardPage].
const TimeCardPage({super.key});
@override
State<TimeCardPage> createState() => _TimeCardPageState();
}
class _TimeCardPageState extends State<TimeCardPage> {
late final TimeCardBloc _bloc;
@override
void initState() {
super.initState();
_bloc = Modular.get<TimeCardBloc>();
_bloc.add(LoadTimeCards(DateTime.now()));
}
@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<TimeCardBloc>.value(
value: Modular.get<TimeCardBloc>()..add(LoadTimeCards(DateTime.now())),
child: BlocConsumer<TimeCardBloc, TimeCardState>(
listener: (BuildContext context, TimeCardState state) {
if (state is TimeCardError) {
UiSnackbar.show(
@@ -75,22 +57,24 @@ class _TimeCardPageState extends State<TimeCardPage> {
children: <Widget>[
MonthSelector(
selectedDate: state.selectedMonth,
onPreviousMonth: () => _bloc.add(
ChangeMonth(
DateTime(
state.selectedMonth.year,
state.selectedMonth.month - 1,
),
),
),
onNextMonth: () => _bloc.add(
ChangeMonth(
DateTime(
state.selectedMonth.year,
state.selectedMonth.month + 1,
),
),
),
onPreviousMonth: () =>
ReadContext(context).read<TimeCardBloc>().add(
ChangeMonth(
DateTime(
state.selectedMonth.year,
state.selectedMonth.month - 1,
),
),
),
onNextMonth: () =>
ReadContext(context).read<TimeCardBloc>().add(
ChangeMonth(
DateTime(
state.selectedMonth.year,
state.selectedMonth.month + 1,
),
),
),
),
const SizedBox(height: UiConstants.space6),
TimeCardSummary(

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

@@ -30,7 +30,7 @@ class StaffTimeCardModule extends Module {
);
// UseCases
i.add<GetTimeCardsUseCase>(GetTimeCardsUseCase.new);
i.addLazySingleton<GetTimeCardsUseCase>(GetTimeCardsUseCase.new);
// Blocs
i.add<TimeCardBloc>(TimeCardBloc.new);

View File

@@ -11,7 +11,7 @@ class EmergencyContactAddButton extends StatelessWidget {
return Center(
child: TextButton.icon(
onPressed: () =>
context.read<EmergencyContactBloc>().add(EmergencyContactAdded()),
ReadContext(context).read<EmergencyContactBloc>().add(EmergencyContactAdded()),
icon: const Icon(UiIcons.add, size: 20.0),
label: Text(
'Add Another Contact',

View File

@@ -44,7 +44,7 @@ class EmergencyContactFormItem extends StatelessWidget {
initialValue: contact.fullName,
hint: 'Contact name',
icon: UiIcons.user,
onChanged: (val) => context.read<EmergencyContactBloc>().add(
onChanged: (val) => ReadContext(context).read<EmergencyContactBloc>().add(
EmergencyContactUpdated(index, contact.copyWith(fullName: val)),
),
),
@@ -54,7 +54,7 @@ class EmergencyContactFormItem extends StatelessWidget {
initialValue: contact.phone,
hint: '+1 (555) 000-0000',
icon: UiIcons.phone,
onChanged: (val) => context.read<EmergencyContactBloc>().add(
onChanged: (val) => ReadContext(context).read<EmergencyContactBloc>().add(
EmergencyContactUpdated(index, contact.copyWith(phone: val)),
),
),
@@ -66,7 +66,7 @@ class EmergencyContactFormItem extends StatelessWidget {
items: _kRelationshipTypes,
onChanged: (val) {
if (val != null) {
context.read<EmergencyContactBloc>().add(
ReadContext(context).read<EmergencyContactBloc>().add(
EmergencyContactUpdated(
index,
contact.copyWith(relationshipType: val),
@@ -144,7 +144,7 @@ class EmergencyContactFormItem extends StatelessWidget {
color: UiColors.textError,
size: 20.0,
),
onPressed: () => context.read<EmergencyContactBloc>().add(
onPressed: () => ReadContext(context).read<EmergencyContactBloc>().add(
EmergencyContactRemoved(index),
),
),

View File

@@ -39,11 +39,11 @@ class TappableRow extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space3,
children: [
children: <Widget>[
FieldLabel(text: i18n.locations_label),
GestureDetector(
onTap: enabled ? onTap : null,
child: Container(
child: SizedBox(
width: double.infinity,
child: Row(
children: <Widget>[

View File

@@ -37,9 +37,9 @@ class _FaqsWidgetState extends State<FaqsWidget> {
void _onSearchChanged(String value) {
if (value.isEmpty) {
context.read<FaqsBloc>().add(const FetchFaqsEvent());
ReadContext(context).read<FaqsBloc>().add(const FetchFaqsEvent());
} else {
context.read<FaqsBloc>().add(SearchFaqsEvent(query: value));
ReadContext(context).read<FaqsBloc>().add(SearchFaqsEvent(query: value));
}
}

View File

@@ -23,7 +23,7 @@ class PrivacySectionWidget extends StatelessWidget {
type: UiSnackbarType.success,
);
// Clear the flag after showing the snackbar
context.read<PrivacySecurityBloc>().add(
ReadContext(context).read<PrivacySecurityBloc>().add(
const ClearProfileVisibilityUpdatedEvent(),
);
}

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,8 @@ class _ShiftsPageState extends State<ShiftsPage> {
pendingAssignments: pendingAssignments,
cancelledShifts: cancelledShifts,
initialDate: _selectedDate,
submittedShiftIds: state.submittedShiftIds,
submittingShiftId: state.submittingShiftId,
);
case ShiftTabType.find:
if (availableLoading) {
@@ -264,7 +280,11 @@ class _ShiftsPageState extends State<ShiftsPage> {
if (historyLoading) {
return const ShiftsPageSkeleton();
}
return HistoryShiftsTab(historyShifts: historyShifts);
return HistoryShiftsTab(
historyShifts: historyShifts,
submittedShiftIds: state.submittedShiftIds,
submittingShiftId: state.submittingShiftId,
);
}
}
@@ -278,89 +298,85 @@ class _ShiftsPageState extends State<ShiftsPage> {
}) {
final isActive = _activeTab == type;
return Expanded(
child: Semantics(
identifier: 'shift_tab_${type.name}',
label: label,
child: GestureDetector(
onTap: !enabled
? null
: () {
setState(() => _activeTab = type);
if (type == ShiftTabType.history) {
_bloc.add(LoadHistoryShiftsEvent());
}
if (type == ShiftTabType.find) {
_bloc.add(LoadAvailableShiftsEvent());
}
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space2,
horizontal: UiConstants.space2,
),
decoration: BoxDecoration(
color: isActive
? UiColors.white
: UiColors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: !enabled
? UiColors.white.withValues(alpha: 0.5)
: isActive
? UiColors.primary
: UiColors.white,
child: GestureDetector(
onTap: !enabled
? null
: () {
setState(() => _activeTab = type);
if (type == ShiftTabType.history) {
_bloc.add(LoadHistoryShiftsEvent());
}
if (type == ShiftTabType.find) {
_bloc.add(LoadAvailableShiftsEvent());
}
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space2,
horizontal: UiConstants.space2,
),
decoration: BoxDecoration(
color: isActive
? UiColors.white
: UiColors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: !enabled
? UiColors.white.withValues(alpha: 0.5)
: isActive
? UiColors.primary
: UiColors.white,
),
const SizedBox(width: UiConstants.space1),
Flexible(
child: Text(
label,
style:
(isActive
? UiTypography.body3m.copyWith(
color: UiColors.primary,
)
: UiTypography.body3m.white)
.copyWith(
color: !enabled
? UiColors.white.withValues(alpha: 0.5)
: null,
),
overflow: TextOverflow.ellipsis,
),
),
if (showCount) ...[
const SizedBox(width: UiConstants.space1),
Flexible(
child: Text(
label,
style:
(isActive
? UiTypography.body3m.copyWith(
color: UiColors.primary,
)
: UiTypography.body3m.white)
.copyWith(
color: !enabled
? UiColors.white.withValues(alpha: 0.5)
: null,
),
overflow: TextOverflow.ellipsis,
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space1,
vertical: 2,
),
),
if (showCount) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space1,
vertical: 2,
),
constraints: const BoxConstraints(minWidth: 18),
decoration: BoxDecoration(
color: isActive
? UiColors.primary.withValues(alpha: 0.1)
: UiColors.white.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusFull,
),
child: Center(
child: Text(
"$count",
style: UiTypography.footnote1b.copyWith(
color: isActive ? UiColors.primary : UiColors.white,
),
constraints: const BoxConstraints(minWidth: 18),
decoration: BoxDecoration(
color: isActive
? UiColors.primary.withValues(alpha: 0.1)
: UiColors.white.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusFull,
),
child: Center(
child: Text(
"$count",
style: UiTypography.footnote1b.copyWith(
color: isActive ? UiColors.primary : UiColors.white,
),
),
),
],
),
],
),
],
),
),
),

Some files were not shown because too many files have changed in this diff Show More