Merge dev into feature branch
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for Clock In/Out functionality
|
||||
abstract class ClockInRepositoryInterface {
|
||||
|
||||
import '../arguments/clock_in_arguments.dart';
|
||||
import '../arguments/clock_out_arguments.dart';
|
||||
|
||||
/// Repository interface for Clock In/Out functionality.
|
||||
abstract interface class ClockInRepositoryInterface {
|
||||
|
||||
/// Retrieves the shifts assigned to the user for the current day.
|
||||
/// Returns empty list if no shift is assigned for today.
|
||||
Future<List<Shift>> getTodaysShifts();
|
||||
@@ -11,17 +14,12 @@ abstract class ClockInRepositoryInterface {
|
||||
/// This helps in restoring the UI state if the app was killed.
|
||||
Future<AttendanceStatus> getAttendanceStatus();
|
||||
|
||||
/// Checks the user in for the specified [shiftId].
|
||||
/// Checks the user in using the fields from [arguments].
|
||||
/// Returns the updated [AttendanceStatus].
|
||||
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
|
||||
Future<AttendanceStatus> clockIn(ClockInArguments arguments);
|
||||
|
||||
/// Checks the user out for the currently active shift.
|
||||
/// Checks the user out using the fields from [arguments].
|
||||
///
|
||||
/// The V2 API resolves the assignment from [shiftId]. Optionally accepts
|
||||
/// [breakTimeMinutes] if tracked.
|
||||
Future<AttendanceStatus> clockOut({
|
||||
String? notes,
|
||||
int? breakTimeMinutes,
|
||||
String? shiftId,
|
||||
});
|
||||
/// The V2 API resolves the assignment from the shift ID.
|
||||
Future<AttendanceStatus> clockOut(ClockOutArguments arguments);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@ class ClockInUseCase implements UseCase<ClockInArguments, AttendanceStatus> {
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> call(ClockInArguments arguments) {
|
||||
return _repository.clockIn(
|
||||
shiftId: arguments.shiftId,
|
||||
notes: arguments.notes,
|
||||
);
|
||||
return _repository.clockIn(arguments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,6 @@ class ClockOutUseCase implements UseCase<ClockOutArguments, AttendanceStatus> {
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> call(ClockOutArguments arguments) {
|
||||
return _repository.clockOut(
|
||||
notes: arguments.notes,
|
||||
breakTimeMinutes: arguments.breakTimeMinutes,
|
||||
shiftId: arguments.shiftId,
|
||||
);
|
||||
return _repository.clockOut(arguments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../validators/clock_in_validation_context.dart';
|
||||
import '../validators/validators/time_window_validator.dart';
|
||||
|
||||
/// Holds the computed time-window check-in/check-out availability flags.
|
||||
class TimeWindowFlags {
|
||||
/// Creates a [TimeWindowFlags] with default allowed values.
|
||||
const TimeWindowFlags({
|
||||
this.isCheckInAllowed = true,
|
||||
this.isCheckOutAllowed = true,
|
||||
this.checkInAvailabilityTime,
|
||||
this.checkOutAvailabilityTime,
|
||||
});
|
||||
|
||||
/// Whether the time window currently allows check-in.
|
||||
final bool isCheckInAllowed;
|
||||
|
||||
/// Whether the time window currently allows check-out.
|
||||
final bool isCheckOutAllowed;
|
||||
|
||||
/// Formatted time when check-in becomes available, or `null`.
|
||||
final String? checkInAvailabilityTime;
|
||||
|
||||
/// Formatted time when check-out becomes available, or `null`.
|
||||
final String? checkOutAvailabilityTime;
|
||||
}
|
||||
|
||||
/// Computes time-window check-in/check-out flags for the given [shift].
|
||||
///
|
||||
/// Returns a [TimeWindowFlags] indicating whether the current time falls
|
||||
/// within the allowed clock-in and clock-out windows. Uses
|
||||
/// [TimeWindowValidator] for the underlying validation logic.
|
||||
TimeWindowFlags computeTimeWindowFlags(Shift? shift) {
|
||||
if (shift == null) {
|
||||
return const TimeWindowFlags();
|
||||
}
|
||||
|
||||
const TimeWindowValidator validator = TimeWindowValidator();
|
||||
final DateTime shiftStart = shift.startsAt;
|
||||
final DateTime shiftEnd = shift.endsAt;
|
||||
|
||||
// Check-in window.
|
||||
bool isCheckInAllowed = true;
|
||||
String? checkInAvailabilityTime;
|
||||
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: shiftStart,
|
||||
);
|
||||
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
||||
if (!isCheckInAllowed) {
|
||||
checkInAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||
}
|
||||
|
||||
// Check-out window.
|
||||
bool isCheckOutAllowed = true;
|
||||
String? checkOutAvailabilityTime;
|
||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||
if (!isCheckOutAllowed) {
|
||||
checkOutAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
|
||||
return TimeWindowFlags(
|
||||
isCheckInAllowed: isCheckInAllowed,
|
||||
isCheckOutAllowed: isCheckOutAllowed,
|
||||
checkInAvailabilityTime: checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: checkOutAvailabilityTime,
|
||||
);
|
||||
}
|
||||
@@ -4,21 +4,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../../domain/arguments/clock_in_arguments.dart';
|
||||
import '../../../domain/arguments/clock_out_arguments.dart';
|
||||
import '../../../domain/usecases/clock_in_usecase.dart';
|
||||
import '../../../domain/usecases/clock_out_usecase.dart';
|
||||
import '../../../domain/usecases/get_attendance_status_usecase.dart';
|
||||
import '../../../domain/usecases/get_todays_shift_usecase.dart';
|
||||
import '../../../domain/validators/clock_in_validation_context.dart';
|
||||
import '../../../domain/validators/clock_in_validation_result.dart';
|
||||
import '../../../domain/validators/validators/composite_clock_in_validator.dart';
|
||||
import '../../../domain/validators/validators/time_window_validator.dart';
|
||||
import '../geofence/geofence_bloc.dart';
|
||||
import '../geofence/geofence_event.dart';
|
||||
import '../geofence/geofence_state.dart';
|
||||
import 'clock_in_event.dart';
|
||||
import 'clock_in_state.dart';
|
||||
import 'package:staff_clock_in/src/data/services/background_geofence_service.dart';
|
||||
import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart';
|
||||
import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart';
|
||||
import 'package:staff_clock_in/src/domain/usecases/clock_in_usecase.dart';
|
||||
import 'package:staff_clock_in/src/domain/usecases/clock_out_usecase.dart';
|
||||
import 'package:staff_clock_in/src/domain/usecases/get_attendance_status_usecase.dart';
|
||||
import 'package:staff_clock_in/src/domain/usecases/get_todays_shift_usecase.dart';
|
||||
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart';
|
||||
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart';
|
||||
import 'package:staff_clock_in/src/domain/utils/time_window_utils.dart';
|
||||
import 'package:staff_clock_in/src/domain/validators/validators/composite_clock_in_validator.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_bloc.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_event.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_state.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_event.dart';
|
||||
import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_state.dart';
|
||||
|
||||
/// BLoC responsible for clock-in/clock-out operations and shift management.
|
||||
///
|
||||
@@ -92,7 +93,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
selectedShift ??= shifts.last;
|
||||
}
|
||||
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
|
||||
final TimeWindowFlags timeFlags = computeTimeWindowFlags(
|
||||
selectedShift,
|
||||
);
|
||||
|
||||
@@ -122,7 +123,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
ShiftSelected event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift);
|
||||
final TimeWindowFlags timeFlags = computeTimeWindowFlags(event.shift);
|
||||
emit(state.copyWith(
|
||||
selectedShift: event.shift,
|
||||
isCheckInAllowed: timeFlags.isCheckInAllowed,
|
||||
@@ -201,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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
@@ -25,9 +26,11 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
required GeofenceServiceInterface geofenceService,
|
||||
required BackgroundGeofenceService backgroundGeofenceService,
|
||||
required ClockInNotificationService notificationService,
|
||||
required AuthTokenProvider authTokenProvider,
|
||||
}) : _geofenceService = geofenceService,
|
||||
_backgroundGeofenceService = backgroundGeofenceService,
|
||||
_notificationService = notificationService,
|
||||
_authTokenProvider = authTokenProvider,
|
||||
super(const GeofenceState.initial()) {
|
||||
on<GeofenceStarted>(_onStarted);
|
||||
on<GeofenceResultUpdated>(_onResultUpdated);
|
||||
@@ -52,6 +55,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
/// The notification service for clock-in related notifications.
|
||||
final ClockInNotificationService _notificationService;
|
||||
|
||||
/// Provides fresh Firebase ID tokens for background isolate storage.
|
||||
final AuthTokenProvider _authTokenProvider;
|
||||
|
||||
/// Periodic timer that refreshes the auth token in SharedPreferences
|
||||
/// so the background isolate always has a valid token for API calls.
|
||||
Timer? _tokenRefreshTimer;
|
||||
|
||||
/// How often to refresh the auth token for background use.
|
||||
/// Set to 45 minutes — well before Firebase's 1-hour expiry.
|
||||
static const Duration _tokenRefreshInterval = Duration(minutes: 45);
|
||||
|
||||
/// Active subscription to the foreground geofence location stream.
|
||||
StreamSubscription<GeofenceResult>? _geofenceSubscription;
|
||||
|
||||
@@ -239,6 +253,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
shiftId: event.shiftId,
|
||||
leftGeofenceTitle: event.leftGeofenceTitle,
|
||||
leftGeofenceBody: event.leftGeofenceBody,
|
||||
geofenceRadiusMeters: event.geofenceRadiusMeters,
|
||||
);
|
||||
|
||||
// Get and store initial auth token for background location streaming.
|
||||
await _refreshAndStoreToken();
|
||||
|
||||
// Start periodic token refresh to keep it valid across long shifts.
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_tokenRefreshTimer = Timer.periodic(
|
||||
_tokenRefreshInterval,
|
||||
(_) => _refreshAndStoreToken(),
|
||||
);
|
||||
|
||||
// Show greeting notification using localized strings from the UI.
|
||||
@@ -261,6 +286,9 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
BackgroundTrackingStopped event,
|
||||
Emitter<GeofenceState> emit,
|
||||
) async {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_tokenRefreshTimer = null;
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
@@ -298,6 +326,8 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
GeofenceStopped event,
|
||||
Emitter<GeofenceState> emit,
|
||||
) async {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_tokenRefreshTimer = null;
|
||||
await _geofenceSubscription?.cancel();
|
||||
_geofenceSubscription = null;
|
||||
await _serviceStatusSubscription?.cancel();
|
||||
@@ -305,8 +335,26 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
emit(const GeofenceState.initial());
|
||||
}
|
||||
|
||||
/// Fetches a fresh Firebase ID token and stores it in SharedPreferences
|
||||
/// for the background isolate to use.
|
||||
Future<void> _refreshAndStoreToken() async {
|
||||
try {
|
||||
final String? token = await _authTokenProvider.getIdToken(
|
||||
forceRefresh: true,
|
||||
);
|
||||
if (token != null) {
|
||||
await _backgroundGeofenceService.storeAuthToken(token);
|
||||
}
|
||||
} catch (e) {
|
||||
// Best-effort — if token refresh fails, the background isolate will
|
||||
// skip the POST (it checks for null/empty token).
|
||||
developer.log('Token refresh failed: $e', name: 'GeofenceBloc', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_geofenceSubscription?.cancel();
|
||||
_serviceStatusSubscription?.cancel();
|
||||
return super.close();
|
||||
|
||||
@@ -73,6 +73,7 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
||||
required this.greetingBody,
|
||||
required this.leftGeofenceTitle,
|
||||
required this.leftGeofenceBody,
|
||||
this.geofenceRadiusMeters = 500,
|
||||
});
|
||||
|
||||
/// The shift ID being tracked.
|
||||
@@ -84,6 +85,9 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
||||
/// Target longitude of the shift location.
|
||||
final double targetLng;
|
||||
|
||||
/// Geofence radius in meters for this shift. Defaults to 500m.
|
||||
final double geofenceRadiusMeters;
|
||||
|
||||
/// Localized greeting notification title passed from the UI layer.
|
||||
final String greetingTitle;
|
||||
|
||||
@@ -103,6 +107,7 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
||||
shiftId,
|
||||
targetLat,
|
||||
targetLng,
|
||||
geofenceRadiusMeters,
|
||||
greetingTitle,
|
||||
greetingBody,
|
||||
leftGeofenceTitle,
|
||||
|
||||
@@ -56,7 +56,7 @@ class AttendanceCard extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
@@ -65,13 +65,13 @@ class AttendanceCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (scheduledTime != null) ...<Widget>[
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
"Scheduled: $scheduledTime",
|
||||
style: UiTypography.footnote2r.textInactive,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.footnote1r.copyWith(color: UiColors.primary),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -281,7 +281,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
|
||||
size: 12,
|
||||
color: UiColors.textInactive,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
i18n.starts_in(min: _getMinutesUntilShift().toString()),
|
||||
style: UiTypography.titleUppercase4m.textSecondary,
|
||||
|
||||
@@ -55,7 +55,7 @@ class DateSelector extends StatelessWidget {
|
||||
: UiColors.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -228,7 +228,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
isExpanded: true,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'benefit_history_empty_state.dart';
|
||||
export 'benefit_history_list.dart';
|
||||
export 'benefit_history_skeleton.dart';
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? ?? '',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 _) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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>[
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user