Merge dev into feature branch
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
@@ -84,7 +85,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
if (!_isInitialState) {
|
||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||
_showSessionErrorDialog(
|
||||
state.errorMessage ?? 'Session error occurred',
|
||||
state.errorMessage ?? t.session.error_title,
|
||||
);
|
||||
} else {
|
||||
_isInitialState = false;
|
||||
@@ -101,22 +102,21 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Shows a dialog when the session expires.
|
||||
void _showSessionExpiredDialog() {
|
||||
final Translations translations = t;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Session Expired'),
|
||||
content: const Text(
|
||||
'Your session has expired. Please log in again to continue.',
|
||||
),
|
||||
title: Text(translations.session.expired_title),
|
||||
content: Text(translations.session.expired_message),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log In'),
|
||||
child: Text(translations.session.log_in),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -126,27 +126,28 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Shows a dialog when a session error occurs, with retry option.
|
||||
void _showSessionErrorDialog(String errorMessage) {
|
||||
final Translations translations = t;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Session Error'),
|
||||
title: Text(translations.session.error_title),
|
||||
content: Text(errorMessage),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// User can retry by dismissing and continuing
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: const Text('Continue'),
|
||||
child: Text(translations.common.continue_text),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
child: Text(translations.session.log_out),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -33,8 +33,9 @@ dependencies:
|
||||
client_create_order:
|
||||
path: ../../packages/features/client/orders/create_order
|
||||
krow_core:
|
||||
path: ../../packages/core
|
||||
|
||||
path: ../../packages/core
|
||||
krow_domain:
|
||||
path: ../../packages/domain
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_modular: ^6.3.2
|
||||
flutter_bloc: ^8.1.3
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
@@ -97,7 +98,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
if (!_isInitialState) {
|
||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||
_showSessionErrorDialog(
|
||||
state.errorMessage ?? 'Session error occurred',
|
||||
state.errorMessage ?? t.session.error_title,
|
||||
);
|
||||
} else {
|
||||
_isInitialState = false;
|
||||
@@ -114,22 +115,21 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Shows a dialog when the session expires.
|
||||
void _showSessionExpiredDialog() {
|
||||
final Translations translations = t;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Session Expired'),
|
||||
content: const Text(
|
||||
'Your session has expired. Please log in again to continue.',
|
||||
),
|
||||
title: Text(translations.session.expired_title),
|
||||
content: Text(translations.session.expired_message),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log In'),
|
||||
child: Text(translations.session.log_in),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -139,27 +139,28 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Shows a dialog when a session error occurs, with retry option.
|
||||
void _showSessionErrorDialog(String errorMessage) {
|
||||
final Translations translations = t;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Session Error'),
|
||||
title: Text(translations.session.error_title),
|
||||
content: Text(errorMessage),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// User can retry by dismissing and continuing
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: const Text('Continue'),
|
||||
child: Text(translations.common.continue_text),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
Navigator.of(dialogContext).pop();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
child: Text(translations.session.log_out),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ dependencies:
|
||||
path: ../../packages/features/staff/staff_main
|
||||
krow_core:
|
||||
path: ../../packages/core
|
||||
krow_domain:
|
||||
path: ../../packages/domain
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_modular: ^6.3.0
|
||||
firebase_core: ^4.4.0
|
||||
|
||||
@@ -42,6 +42,10 @@ export 'src/services/session/client_session_store.dart';
|
||||
export 'src/services/session/staff_session_store.dart';
|
||||
export 'src/services/session/v2_session_service.dart';
|
||||
|
||||
// Auth
|
||||
export 'src/services/auth/auth_token_provider.dart';
|
||||
export 'src/services/auth/firebase_auth_service.dart';
|
||||
|
||||
// Device Services
|
||||
export 'src/services/device/camera/camera_service.dart';
|
||||
export 'src/services/device/gallery/gallery_service.dart';
|
||||
|
||||
@@ -3,6 +3,10 @@ import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:krow_core/src/services/auth/auth_token_provider.dart';
|
||||
import 'package:krow_core/src/services/auth/firebase_auth_service.dart';
|
||||
import 'package:krow_core/src/services/auth/firebase_auth_token_provider.dart';
|
||||
|
||||
import '../core.dart';
|
||||
|
||||
/// A module that provides core services and shared dependencies.
|
||||
@@ -57,7 +61,13 @@ class CoreModule extends Module {
|
||||
),
|
||||
);
|
||||
|
||||
// 6. Register Geofence Device Services
|
||||
// 6. Auth Token Provider
|
||||
i.addLazySingleton<AuthTokenProvider>(FirebaseAuthTokenProvider.new);
|
||||
|
||||
// 7. Firebase Auth Service (so features never import firebase_auth)
|
||||
i.addLazySingleton<FirebaseAuthService>(FirebaseAuthServiceImpl.new);
|
||||
|
||||
// 8. Register Geofence Device Services
|
||||
i.addLazySingleton<LocationService>(() => const LocationService());
|
||||
i.addLazySingleton<NotificationService>(() => NotificationService());
|
||||
i.addLazySingleton<StorageService>(() => StorageService());
|
||||
|
||||
@@ -60,6 +60,20 @@ extension StaffNavigator on IModularNavigator {
|
||||
safePush(StaffPaths.benefits);
|
||||
}
|
||||
|
||||
/// Navigates to the full history page for a specific benefit.
|
||||
void toBenefitHistory({
|
||||
required String benefitId,
|
||||
required String benefitTitle,
|
||||
}) {
|
||||
safePush(
|
||||
StaffPaths.benefitHistory,
|
||||
arguments: <String, dynamic>{
|
||||
'benefitId': benefitId,
|
||||
'benefitTitle': benefitTitle,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void toStaffMain() {
|
||||
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ class StaffPaths {
|
||||
/// Benefits overview page.
|
||||
static const String benefits = '/worker-main/home/benefits';
|
||||
|
||||
/// Benefit history page for a specific benefit.
|
||||
static const String benefitHistory = '/worker-main/home/benefits/history';
|
||||
|
||||
/// Shifts tab - view and manage shifts.
|
||||
///
|
||||
/// Browse available shifts, accepted shifts, and shift history.
|
||||
|
||||
@@ -48,6 +48,26 @@ abstract final class ClientEndpoints {
|
||||
static const ApiEndpoint coverageCoreTeam =
|
||||
ApiEndpoint('/client/coverage/core-team');
|
||||
|
||||
/// Coverage incidents.
|
||||
static const ApiEndpoint coverageIncidents =
|
||||
ApiEndpoint('/client/coverage/incidents');
|
||||
|
||||
/// Blocked staff.
|
||||
static const ApiEndpoint coverageBlockedStaff =
|
||||
ApiEndpoint('/client/coverage/blocked-staff');
|
||||
|
||||
/// Coverage swap requests.
|
||||
static const ApiEndpoint coverageSwapRequests =
|
||||
ApiEndpoint('/client/coverage/swap-requests');
|
||||
|
||||
/// Dispatch teams.
|
||||
static const ApiEndpoint coverageDispatchTeams =
|
||||
ApiEndpoint('/client/coverage/dispatch-teams');
|
||||
|
||||
/// Dispatch candidates.
|
||||
static const ApiEndpoint coverageDispatchCandidates =
|
||||
ApiEndpoint('/client/coverage/dispatch-candidates');
|
||||
|
||||
/// Hubs list.
|
||||
static const ApiEndpoint hubs = ApiEndpoint('/client/hubs');
|
||||
|
||||
@@ -162,4 +182,28 @@ abstract final class ClientEndpoints {
|
||||
/// Cancel late worker assignment.
|
||||
static ApiEndpoint coverageCancelLateWorker(String assignmentId) =>
|
||||
ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel');
|
||||
|
||||
/// Register or delete device push token (POST to register, DELETE to remove).
|
||||
static const ApiEndpoint devicesPushTokens =
|
||||
ApiEndpoint('/client/devices/push-tokens');
|
||||
|
||||
/// Create shift manager.
|
||||
static const ApiEndpoint shiftManagerCreate =
|
||||
ApiEndpoint('/client/shift-managers');
|
||||
|
||||
/// Resolve coverage swap request by ID.
|
||||
static ApiEndpoint coverageSwapRequestResolve(String id) =>
|
||||
ApiEndpoint('/client/coverage/swap-requests/$id/resolve');
|
||||
|
||||
/// Cancel coverage swap request by ID.
|
||||
static ApiEndpoint coverageSwapRequestCancel(String id) =>
|
||||
ApiEndpoint('/client/coverage/swap-requests/$id/cancel');
|
||||
|
||||
/// Create dispatch team membership.
|
||||
static const ApiEndpoint coverageDispatchTeamMembershipsCreate =
|
||||
ApiEndpoint('/client/coverage/dispatch-teams/memberships');
|
||||
|
||||
/// Delete dispatch team membership by ID.
|
||||
static ApiEndpoint coverageDispatchTeamMembershipsDelete(String id) =>
|
||||
ApiEndpoint('/client/coverage/dispatch-teams/memberships/$id');
|
||||
}
|
||||
|
||||
@@ -2,39 +2,42 @@ import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
|
||||
|
||||
/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
|
||||
/// rapid orders).
|
||||
///
|
||||
/// Paths are at the unified API root level (not under `/core/`).
|
||||
abstract final class CoreEndpoints {
|
||||
/// Upload a file.
|
||||
static const ApiEndpoint uploadFile =
|
||||
ApiEndpoint('/core/upload-file');
|
||||
static const ApiEndpoint uploadFile = ApiEndpoint('/upload-file');
|
||||
|
||||
/// Create a signed URL for a file.
|
||||
static const ApiEndpoint createSignedUrl =
|
||||
ApiEndpoint('/core/create-signed-url');
|
||||
static const ApiEndpoint createSignedUrl = ApiEndpoint('/create-signed-url');
|
||||
|
||||
/// Invoke a Large Language Model.
|
||||
static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm');
|
||||
static const ApiEndpoint invokeLlm = ApiEndpoint('/invoke-llm');
|
||||
|
||||
/// Root for verification operations.
|
||||
static const ApiEndpoint verifications =
|
||||
ApiEndpoint('/core/verifications');
|
||||
static const ApiEndpoint verifications = ApiEndpoint('/verifications');
|
||||
|
||||
/// Get status of a verification job.
|
||||
static ApiEndpoint verificationStatus(String id) =>
|
||||
ApiEndpoint('/core/verifications/$id');
|
||||
ApiEndpoint('/verifications/$id');
|
||||
|
||||
/// Review a verification decision.
|
||||
static ApiEndpoint verificationReview(String id) =>
|
||||
ApiEndpoint('/core/verifications/$id/review');
|
||||
ApiEndpoint('/verifications/$id/review');
|
||||
|
||||
/// Retry a verification job.
|
||||
static ApiEndpoint verificationRetry(String id) =>
|
||||
ApiEndpoint('/core/verifications/$id/retry');
|
||||
ApiEndpoint('/verifications/$id/retry');
|
||||
|
||||
/// Transcribe audio to text for rapid orders.
|
||||
static const ApiEndpoint transcribeRapidOrder =
|
||||
ApiEndpoint('/core/rapid-orders/transcribe');
|
||||
ApiEndpoint('/rapid-orders/transcribe');
|
||||
|
||||
/// Parse text to structured rapid order.
|
||||
static const ApiEndpoint parseRapidOrder =
|
||||
ApiEndpoint('/core/rapid-orders/parse');
|
||||
ApiEndpoint('/rapid-orders/parse');
|
||||
|
||||
/// Combined transcribe + parse in a single call.
|
||||
static const ApiEndpoint processRapidOrder =
|
||||
ApiEndpoint('/rapid-orders/process');
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ abstract final class StaffEndpoints {
|
||||
/// Benefits.
|
||||
static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits');
|
||||
|
||||
/// Benefits history.
|
||||
static const ApiEndpoint benefitsHistory =
|
||||
ApiEndpoint('/staff/profile/benefits/history');
|
||||
|
||||
/// Time card.
|
||||
static const ApiEndpoint timeCard =
|
||||
ApiEndpoint('/staff/profile/time-card');
|
||||
@@ -112,6 +116,10 @@ abstract final class StaffEndpoints {
|
||||
/// Privacy settings.
|
||||
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
|
||||
|
||||
/// Preferred locations.
|
||||
static const ApiEndpoint locations =
|
||||
ApiEndpoint('/staff/profile/locations');
|
||||
|
||||
/// FAQs.
|
||||
static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs');
|
||||
|
||||
@@ -177,4 +185,16 @@ abstract final class StaffEndpoints {
|
||||
/// Delete certificate by ID.
|
||||
static ApiEndpoint certificateDelete(String certificateId) =>
|
||||
ApiEndpoint('/staff/profile/certificates/$certificateId');
|
||||
|
||||
/// Submit shift for approval.
|
||||
static ApiEndpoint shiftSubmitForApproval(String shiftId) =>
|
||||
ApiEndpoint('/staff/shifts/$shiftId/submit-for-approval');
|
||||
|
||||
/// Location streams.
|
||||
static const ApiEndpoint locationStreams =
|
||||
ApiEndpoint('/staff/location-streams');
|
||||
|
||||
/// Register or delete device push token (POST to register, DELETE to remove).
|
||||
static const ApiEndpoint devicesPushTokens =
|
||||
ApiEndpoint('/staff/devices/push-tokens');
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ mixin ApiErrorHandler {
|
||||
);
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return UnknownException(
|
||||
return const UnknownException(
|
||||
technicalMessage: 'Request cancelled',
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/// Provides the current Firebase ID token for API authentication.
|
||||
///
|
||||
/// Lives in core so feature packages can access auth tokens
|
||||
/// without importing firebase_auth directly.
|
||||
abstract interface class AuthTokenProvider {
|
||||
/// Returns the current ID token, refreshing if expired.
|
||||
///
|
||||
/// Pass [forceRefresh] to force a token refresh from Firebase.
|
||||
/// Returns null if no user is signed in.
|
||||
Future<String?> getIdToken({bool forceRefresh});
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
|
||||
import 'package:krow_domain/krow_domain.dart'
|
||||
show
|
||||
InvalidCredentialsException,
|
||||
NetworkException,
|
||||
SignInFailedException,
|
||||
User,
|
||||
UserStatus;
|
||||
|
||||
/// Abstraction over Firebase Auth client-side operations.
|
||||
///
|
||||
/// Provides phone-based and email-based authentication, sign-out,
|
||||
/// auth state observation, and current user queries. Lives in core
|
||||
/// so feature packages never import `firebase_auth` directly.
|
||||
abstract interface class FirebaseAuthService {
|
||||
/// Stream of the currently signed-in user mapped to a domain [User].
|
||||
///
|
||||
/// Emits `null` when the user signs out.
|
||||
Stream<User?> get authStateChanges;
|
||||
|
||||
/// Returns the current user's phone number, or `null` if unavailable.
|
||||
String? get currentUserPhoneNumber;
|
||||
|
||||
/// Returns the current user's UID, or `null` if not signed in.
|
||||
String? get currentUserUid;
|
||||
|
||||
/// Initiates phone number verification via Firebase Auth SDK.
|
||||
///
|
||||
/// Returns a [Future] that completes with the verification ID when
|
||||
/// the SMS code is sent. The [onAutoVerified] callback fires if the
|
||||
/// device auto-retrieves the credential (Android only).
|
||||
Future<String?> verifyPhoneNumber({
|
||||
required String phoneNumber,
|
||||
void Function()? onAutoVerified,
|
||||
});
|
||||
|
||||
/// Cancels any pending phone verification request.
|
||||
void cancelPendingPhoneVerification();
|
||||
|
||||
/// Signs in with a phone auth credential built from
|
||||
/// [verificationId] and [smsCode].
|
||||
///
|
||||
/// Returns the signed-in domain [User] or throws a domain exception.
|
||||
Future<PhoneSignInResult> signInWithPhoneCredential({
|
||||
required String verificationId,
|
||||
required String smsCode,
|
||||
});
|
||||
|
||||
/// Signs in with email and password via Firebase Auth SDK.
|
||||
///
|
||||
/// Returns the Firebase UID on success or throws a domain exception.
|
||||
Future<String> signInWithEmailAndPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
});
|
||||
|
||||
/// Signs out the current user from Firebase Auth locally.
|
||||
Future<void> signOut();
|
||||
|
||||
/// Returns the current user's Firebase ID token.
|
||||
///
|
||||
/// Returns `null` if no user is signed in.
|
||||
Future<String?> getIdToken();
|
||||
}
|
||||
|
||||
/// Result of a phone credential sign-in.
|
||||
///
|
||||
/// Contains the Firebase user's UID, phone number, and ID token
|
||||
/// so the caller can proceed with V2 API verification without
|
||||
/// importing `firebase_auth`.
|
||||
class PhoneSignInResult {
|
||||
/// Creates a [PhoneSignInResult].
|
||||
const PhoneSignInResult({
|
||||
required this.uid,
|
||||
required this.phoneNumber,
|
||||
required this.idToken,
|
||||
});
|
||||
|
||||
/// The Firebase user UID.
|
||||
final String uid;
|
||||
|
||||
/// The phone number associated with the credential.
|
||||
final String? phoneNumber;
|
||||
|
||||
/// The Firebase ID token for the signed-in user.
|
||||
final String? idToken;
|
||||
}
|
||||
|
||||
/// Firebase-backed implementation of [FirebaseAuthService].
|
||||
///
|
||||
/// Wraps the `firebase_auth` package so that feature packages
|
||||
/// interact with Firebase Auth only through this core service.
|
||||
class FirebaseAuthServiceImpl implements FirebaseAuthService {
|
||||
/// Creates a [FirebaseAuthServiceImpl].
|
||||
///
|
||||
/// Optionally accepts a [firebase.FirebaseAuth] instance for testing.
|
||||
FirebaseAuthServiceImpl({firebase.FirebaseAuth? auth})
|
||||
: _auth = auth ?? firebase.FirebaseAuth.instance;
|
||||
|
||||
/// The Firebase Auth instance.
|
||||
final firebase.FirebaseAuth _auth;
|
||||
|
||||
/// Completer for the pending phone verification request.
|
||||
Completer<String?>? _pendingVerification;
|
||||
|
||||
@override
|
||||
Stream<User?> get authStateChanges =>
|
||||
_auth.authStateChanges().map((firebase.User? firebaseUser) {
|
||||
if (firebaseUser == null) {
|
||||
return null;
|
||||
}
|
||||
return User(
|
||||
id: firebaseUser.uid,
|
||||
email: firebaseUser.email,
|
||||
displayName: firebaseUser.displayName,
|
||||
phone: firebaseUser.phoneNumber,
|
||||
status: UserStatus.active,
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
String? get currentUserPhoneNumber => _auth.currentUser?.phoneNumber;
|
||||
|
||||
@override
|
||||
String? get currentUserUid => _auth.currentUser?.uid;
|
||||
|
||||
@override
|
||||
Future<String?> verifyPhoneNumber({
|
||||
required String phoneNumber,
|
||||
void Function()? onAutoVerified,
|
||||
}) async {
|
||||
final Completer<String?> completer = Completer<String?>();
|
||||
_pendingVerification = completer;
|
||||
|
||||
await _auth.verifyPhoneNumber(
|
||||
phoneNumber: phoneNumber,
|
||||
verificationCompleted: (firebase.PhoneAuthCredential credential) {
|
||||
onAutoVerified?.call();
|
||||
},
|
||||
verificationFailed: (firebase.FirebaseAuthException e) {
|
||||
if (!completer.isCompleted) {
|
||||
if (e.code == 'network-request-failed' ||
|
||||
e.message?.contains('Unable to resolve host') == true) {
|
||||
completer.completeError(
|
||||
const NetworkException(
|
||||
technicalMessage: 'Auth network failure',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
completer.completeError(
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void cancelPendingPhoneVerification() {
|
||||
final Completer<String?>? completer = _pendingVerification;
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.completeError(Exception('Phone verification cancelled.'));
|
||||
}
|
||||
_pendingVerification = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PhoneSignInResult> signInWithPhoneCredential({
|
||||
required String verificationId,
|
||||
required String smsCode,
|
||||
}) async {
|
||||
final firebase.PhoneAuthCredential credential =
|
||||
firebase.PhoneAuthProvider.credential(
|
||||
verificationId: verificationId,
|
||||
smsCode: smsCode,
|
||||
);
|
||||
|
||||
final firebase.UserCredential userCredential;
|
||||
try {
|
||||
userCredential = await _auth.signInWithCredential(credential);
|
||||
} on firebase.FirebaseAuthException catch (e) {
|
||||
if (e.code == 'invalid-verification-code') {
|
||||
throw const InvalidCredentialsException(
|
||||
technicalMessage: 'Invalid OTP code entered.',
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final firebase.User? firebaseUser = userCredential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw const SignInFailedException(
|
||||
technicalMessage:
|
||||
'Phone verification failed, no Firebase user received.',
|
||||
);
|
||||
}
|
||||
|
||||
final String? idToken = await firebaseUser.getIdToken();
|
||||
if (idToken == null) {
|
||||
throw const SignInFailedException(
|
||||
technicalMessage: 'Failed to obtain Firebase ID token.',
|
||||
);
|
||||
}
|
||||
|
||||
return PhoneSignInResult(
|
||||
uid: firebaseUser.uid,
|
||||
phoneNumber: firebaseUser.phoneNumber,
|
||||
idToken: idToken,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signInWithEmailAndPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
final firebase.UserCredential credential =
|
||||
await _auth.signInWithEmailAndPassword(email: email, password: password);
|
||||
|
||||
final firebase.User? firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw const SignInFailedException(
|
||||
technicalMessage: 'Local Firebase sign-in returned null user.',
|
||||
);
|
||||
}
|
||||
|
||||
return firebaseUser.uid;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
await _auth.signOut();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getIdToken() async {
|
||||
final firebase.User? user = _auth.currentUser;
|
||||
return user?.getIdToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
|
||||
import 'package:krow_core/src/services/auth/auth_token_provider.dart';
|
||||
|
||||
/// Firebase-backed implementation of [AuthTokenProvider].
|
||||
///
|
||||
/// Delegates to [FirebaseAuth] to get the current user's
|
||||
/// ID token. Must run in the main isolate (Firebase SDK requirement).
|
||||
class FirebaseAuthTokenProvider implements AuthTokenProvider {
|
||||
@override
|
||||
Future<String?> getIdToken({bool forceRefresh = false}) async {
|
||||
final User? user = FirebaseAuth.instance.currentUser;
|
||||
return user?.getIdToken(forceRefresh);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,45 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Converts a break duration label (e.g. `'MIN_30'`) to its value in minutes.
|
||||
///
|
||||
/// Recognised labels: `MIN_10`, `MIN_15`, `MIN_30`, `MIN_45`, `MIN_60`.
|
||||
/// Returns `0` for any unrecognised value (including `'NO_BREAK'`).
|
||||
int breakMinutesFromLabel(String label) {
|
||||
switch (label) {
|
||||
case 'MIN_10':
|
||||
return 10;
|
||||
case 'MIN_15':
|
||||
return 15;
|
||||
case 'MIN_30':
|
||||
return 30;
|
||||
case 'MIN_45':
|
||||
return 45;
|
||||
case 'MIN_60':
|
||||
return 60;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] to a `yyyy-MM-dd` date string.
|
||||
///
|
||||
/// Example: `DateTime(2026, 3, 5)` -> `'2026-03-05'`.
|
||||
String formatDateToIso(DateTime date) {
|
||||
return '${date.year.toString().padLeft(4, '0')}-'
|
||||
'${date.month.toString().padLeft(2, '0')}-'
|
||||
'${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] to `HH:mm` (24-hour) time string.
|
||||
///
|
||||
/// Converts to local time before formatting.
|
||||
/// Example: a UTC DateTime of 14:30 in UTC-5 -> `'09:30'`.
|
||||
String formatTimeHHmm(DateTime dt) {
|
||||
final DateTime local = dt.toLocal();
|
||||
return '${local.hour.toString().padLeft(2, '0')}:'
|
||||
'${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Formats a time string (ISO 8601 or HH:mm) into 12-hour format
|
||||
/// (e.g. "9:00 AM").
|
||||
///
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
"english": "English",
|
||||
"spanish": "Español"
|
||||
},
|
||||
"session": {
|
||||
"expired_title": "Session Expired",
|
||||
"expired_message": "Your session has expired. Please log in again to continue.",
|
||||
"error_title": "Session Error",
|
||||
"log_in": "Log In",
|
||||
"log_out": "Log Out"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Language",
|
||||
"change_language": "Change Language"
|
||||
@@ -665,7 +672,14 @@
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"submitted": "Submitted"
|
||||
}
|
||||
},
|
||||
"history_header": "HISTORY",
|
||||
"no_history": "No history yet",
|
||||
"show_all": "Show all",
|
||||
"hours_accrued": "+${hours}h accrued",
|
||||
"hours_used": "-${hours}h used",
|
||||
"history_page_title": "$benefit History",
|
||||
"loading_more": "Loading..."
|
||||
}
|
||||
},
|
||||
"auto_match": {
|
||||
@@ -964,11 +978,15 @@
|
||||
"retry": "Retry",
|
||||
"clock_in_anyway": "Clock In Anyway",
|
||||
"override_title": "Justification Required",
|
||||
"override_desc": "Your location could not be verified. Please explain why you are clocking in without location verification.",
|
||||
"override_desc": "Your location could not be verified. Please explain why you are proceeding without location verification.",
|
||||
"override_hint": "Enter your justification...",
|
||||
"override_submit": "Clock In",
|
||||
"override_submit": "Submit",
|
||||
"overridden_title": "Location Not Verified",
|
||||
"overridden_desc": "You are clocking in without location verification. Your justification has been recorded."
|
||||
"overridden_desc": "You are proceeding without location verification. Your justification has been recorded.",
|
||||
"outside_work_area_warning": "You've moved away from the work area",
|
||||
"outside_work_area_title": "You've moved away from the work area",
|
||||
"outside_work_area_desc": "You are $distance away from your shift location. To clock out, provide a reason below.",
|
||||
"clock_out_anyway": "Clock out anyway"
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
@@ -1159,6 +1177,8 @@
|
||||
"upload": {
|
||||
"instructions": "Please select a valid PDF file to upload.",
|
||||
"pdf_banner": "Only PDF files are accepted. Maximum file size is 10MB.",
|
||||
"pdf_banner_title": "PDF files only",
|
||||
"pdf_banner_description": "Upload a PDF document up to 10MB in size.",
|
||||
"file_not_found": "File not found.",
|
||||
"submit": "Submit Document",
|
||||
"select_pdf": "Select PDF File",
|
||||
@@ -1337,14 +1357,22 @@
|
||||
"applying_dialog": {
|
||||
"title": "Applying"
|
||||
},
|
||||
"eligibility_requirements": "Eligibility Requirements"
|
||||
"eligibility_requirements": "Eligibility Requirements",
|
||||
"missing_certifications": "You are missing required certifications or documents to claim this shift. Please upload them to continue.",
|
||||
"go_to_certificates": "Go to Certificates",
|
||||
"shift_booked": "Shift successfully booked!",
|
||||
"shift_not_found": "Shift not found",
|
||||
"shift_declined_success": "Shift declined",
|
||||
"complete_account_title": "Complete Your Account",
|
||||
"complete_account_description": "Complete your account to book this shift and start earning"
|
||||
},
|
||||
"my_shift_card": {
|
||||
"submit_for_approval": "Submit for Approval",
|
||||
"timesheet_submitted": "Timesheet submitted for client approval",
|
||||
"checked_in": "Checked in",
|
||||
"submitted": "SUBMITTED",
|
||||
"ready_to_submit": "READY TO SUBMIT"
|
||||
"ready_to_submit": "READY TO SUBMIT",
|
||||
"submitting": "SUBMITTING..."
|
||||
},
|
||||
"shift_location": {
|
||||
"could_not_open_maps": "Could not open maps"
|
||||
@@ -1457,11 +1485,14 @@
|
||||
"shift": {
|
||||
"no_open_roles": "There are no open positions available for this shift.",
|
||||
"application_not_found": "Your application couldn't be found.",
|
||||
"no_active_shift": "You don't have an active shift to clock out from."
|
||||
"no_active_shift": "You don't have an active shift to clock out from.",
|
||||
"not_found": "Shift not found. It may have been removed or is no longer available."
|
||||
},
|
||||
"clock_in": {
|
||||
"location_verification_required": "Please wait for location verification before clocking in.",
|
||||
"notes_required_for_timeout": "Please add a note explaining why your location can't be verified."
|
||||
"notes_required_for_timeout": "Please add a note explaining why your location can't be verified.",
|
||||
"already_clocked_in": "You're already clocked in to this shift.",
|
||||
"already_clocked_out": "You've already clocked out of this shift."
|
||||
},
|
||||
"generic": {
|
||||
"unknown": "Something went wrong. Please try again.",
|
||||
@@ -1762,7 +1793,9 @@
|
||||
"workers": "Workers",
|
||||
"error_occurred": "An error occurred",
|
||||
"retry": "Retry",
|
||||
"shifts": "Shifts"
|
||||
"shifts": "Shifts",
|
||||
"overall_coverage": "Overall Coverage",
|
||||
"live_activity": "LIVE ACTIVITY"
|
||||
},
|
||||
"calendar": {
|
||||
"prev_week": "\u2190 Prev Week",
|
||||
@@ -1771,7 +1804,9 @@
|
||||
},
|
||||
"stats": {
|
||||
"checked_in": "Checked In",
|
||||
"en_route": "En Route"
|
||||
"en_route": "En Route",
|
||||
"on_site": "On Site",
|
||||
"late": "Late"
|
||||
},
|
||||
"alert": {
|
||||
"workers_running_late(count)": {
|
||||
@@ -1779,6 +1814,45 @@
|
||||
"other": "$count workers are running late"
|
||||
},
|
||||
"auto_backup_searching": "Auto-backup system is searching for replacements."
|
||||
},
|
||||
"review": {
|
||||
"title": "Rate this worker",
|
||||
"subtitle": "Share your feedback",
|
||||
"rating_labels": {
|
||||
"poor": "Poor",
|
||||
"fair": "Fair",
|
||||
"good": "Good",
|
||||
"great": "Great",
|
||||
"excellent": "Excellent"
|
||||
},
|
||||
"favorite_label": "Favorite",
|
||||
"block_label": "Block",
|
||||
"feedback_placeholder": "Share details about this worker's performance...",
|
||||
"submit": "Submit Review",
|
||||
"success": "Review submitted successfully",
|
||||
"issue_flags": {
|
||||
"late": "Late",
|
||||
"uniform": "Uniform",
|
||||
"misconduct": "Misconduct",
|
||||
"no_show": "No Show",
|
||||
"attitude": "Attitude",
|
||||
"performance": "Performance",
|
||||
"left_early": "Left Early"
|
||||
}
|
||||
},
|
||||
"cancel": {
|
||||
"title": "Cancel Worker?",
|
||||
"subtitle": "This cannot be undone",
|
||||
"confirm_message": "Are you sure you want to cancel $name?",
|
||||
"helper_text": "They will receive a cancellation notification. A replacement will be automatically requested.",
|
||||
"reason_placeholder": "Reason for cancellation (optional)",
|
||||
"keep_worker": "Keep Worker",
|
||||
"confirm": "Yes, Cancel",
|
||||
"success": "Worker cancelled. Searching for replacement."
|
||||
},
|
||||
"actions": {
|
||||
"rate": "Rate",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"client_reports_common": {
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
"english": "English",
|
||||
"spanish": "Español"
|
||||
},
|
||||
"session": {
|
||||
"expired_title": "Sesión Expirada",
|
||||
"expired_message": "Tu sesión ha expirado. Por favor inicia sesión de nuevo para continuar.",
|
||||
"error_title": "Error de Sesión",
|
||||
"log_in": "Iniciar Sesión",
|
||||
"log_out": "Cerrar Sesión"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Idioma",
|
||||
"change_language": "Cambiar Idioma"
|
||||
@@ -660,7 +667,14 @@
|
||||
"status": {
|
||||
"pending": "Pendiente",
|
||||
"submitted": "Enviado"
|
||||
}
|
||||
},
|
||||
"history_header": "HISTORIAL",
|
||||
"no_history": "Sin historial aún",
|
||||
"show_all": "Ver todo",
|
||||
"hours_accrued": "+${hours}h acumuladas",
|
||||
"hours_used": "-${hours}h utilizadas",
|
||||
"history_page_title": "Historial de $benefit",
|
||||
"loading_more": "Cargando..."
|
||||
}
|
||||
},
|
||||
"auto_match": {
|
||||
@@ -959,11 +973,15 @@
|
||||
"retry": "Reintentar",
|
||||
"clock_in_anyway": "Registrar Entrada",
|
||||
"override_title": "Justificación Requerida",
|
||||
"override_desc": "No se pudo verificar su ubicación. Explique por qué registra entrada sin verificación de ubicación.",
|
||||
"override_desc": "No se pudo verificar su ubicación. Explique por qué continúa sin verificación de ubicación.",
|
||||
"override_hint": "Ingrese su justificación...",
|
||||
"override_submit": "Registrar Entrada",
|
||||
"override_submit": "Enviar",
|
||||
"overridden_title": "Ubicación No Verificada",
|
||||
"overridden_desc": "Está registrando entrada sin verificación de ubicación. Su justificación ha sido registrada."
|
||||
"overridden_desc": "Está continuando sin verificación de ubicación. Su justificación ha sido registrada.",
|
||||
"outside_work_area_warning": "Te has alejado del área de trabajo",
|
||||
"outside_work_area_title": "Te has alejado del área de trabajo",
|
||||
"outside_work_area_desc": "Estás a $distance de la ubicación de tu turno. Para registrar tu salida, proporciona una razón a continuación.",
|
||||
"clock_out_anyway": "Registrar salida de todos modos"
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
@@ -1154,6 +1172,8 @@
|
||||
"upload": {
|
||||
"instructions": "Por favor selecciona un archivo PDF válido para subir.",
|
||||
"pdf_banner": "Solo se aceptan archivos PDF. Tamaño máximo del archivo: 10MB.",
|
||||
"pdf_banner_title": "Solo archivos PDF",
|
||||
"pdf_banner_description": "Sube un documento PDF de hasta 10MB de tamaño.",
|
||||
"submit": "Enviar Documento",
|
||||
"select_pdf": "Seleccionar Archivo PDF",
|
||||
"attestation": "Certifico que este documento es genuino y válido.",
|
||||
@@ -1332,14 +1352,22 @@
|
||||
"applying_dialog": {
|
||||
"title": "Solicitando"
|
||||
},
|
||||
"eligibility_requirements": "Requisitos de Elegibilidad"
|
||||
"eligibility_requirements": "Requisitos de Elegibilidad",
|
||||
"missing_certifications": "Te faltan certificaciones o documentos requeridos para reclamar este turno. Por favor, súbelos para continuar.",
|
||||
"go_to_certificates": "Ir a Certificados",
|
||||
"shift_booked": "¡Turno reservado con éxito!",
|
||||
"shift_not_found": "Turno no encontrado",
|
||||
"shift_declined_success": "Turno rechazado",
|
||||
"complete_account_title": "Completa Tu Cuenta",
|
||||
"complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar"
|
||||
},
|
||||
"my_shift_card": {
|
||||
"submit_for_approval": "Enviar para Aprobación",
|
||||
"timesheet_submitted": "Hoja de tiempo enviada para aprobación del cliente",
|
||||
"checked_in": "Registrado",
|
||||
"submitted": "ENVIADO",
|
||||
"ready_to_submit": "LISTO PARA ENVIAR"
|
||||
"ready_to_submit": "LISTO PARA ENVIAR",
|
||||
"submitting": "ENVIANDO..."
|
||||
},
|
||||
"shift_location": {
|
||||
"could_not_open_maps": "No se pudo abrir mapas"
|
||||
@@ -1452,11 +1480,14 @@
|
||||
"shift": {
|
||||
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
|
||||
"application_not_found": "No se pudo encontrar tu solicitud.",
|
||||
"no_active_shift": "No tienes un turno activo para registrar salida."
|
||||
"no_active_shift": "No tienes un turno activo para registrar salida.",
|
||||
"not_found": "Turno no encontrado. Puede haber sido eliminado o ya no está disponible."
|
||||
},
|
||||
"clock_in": {
|
||||
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",
|
||||
"notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n."
|
||||
"notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n.",
|
||||
"already_clocked_in": "Ya est\u00e1s registrado en este turno.",
|
||||
"already_clocked_out": "Ya registraste tu salida de este turno."
|
||||
},
|
||||
"generic": {
|
||||
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
|
||||
@@ -1762,7 +1793,9 @@
|
||||
"workers": "Trabajadores",
|
||||
"error_occurred": "Ocurri\u00f3 un error",
|
||||
"retry": "Reintentar",
|
||||
"shifts": "Turnos"
|
||||
"shifts": "Turnos",
|
||||
"overall_coverage": "Cobertura General",
|
||||
"live_activity": "ACTIVIDAD EN VIVO"
|
||||
},
|
||||
"calendar": {
|
||||
"prev_week": "\u2190 Semana Anterior",
|
||||
@@ -1771,7 +1804,9 @@
|
||||
},
|
||||
"stats": {
|
||||
"checked_in": "Registrado",
|
||||
"en_route": "En Camino"
|
||||
"en_route": "En Camino",
|
||||
"on_site": "En Sitio",
|
||||
"late": "Tarde"
|
||||
},
|
||||
"alert": {
|
||||
"workers_running_late(count)": {
|
||||
@@ -1779,6 +1814,45 @@
|
||||
"other": "$count trabajadores est\u00e1n llegando tarde"
|
||||
},
|
||||
"auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos."
|
||||
},
|
||||
"review": {
|
||||
"title": "Calificar a este trabajador",
|
||||
"subtitle": "Comparte tu opini\u00f3n",
|
||||
"rating_labels": {
|
||||
"poor": "Malo",
|
||||
"fair": "Regular",
|
||||
"good": "Bueno",
|
||||
"great": "Muy Bueno",
|
||||
"excellent": "Excelente"
|
||||
},
|
||||
"favorite_label": "Favorito",
|
||||
"block_label": "Bloquear",
|
||||
"feedback_placeholder": "Comparte detalles sobre el desempe\u00f1o de este trabajador...",
|
||||
"submit": "Enviar Rese\u00f1a",
|
||||
"success": "Rese\u00f1a enviada exitosamente",
|
||||
"issue_flags": {
|
||||
"late": "Tarde",
|
||||
"uniform": "Uniforme",
|
||||
"misconduct": "Mala Conducta",
|
||||
"no_show": "No Se Present\u00f3",
|
||||
"attitude": "Actitud",
|
||||
"performance": "Rendimiento",
|
||||
"left_early": "Sali\u00f3 Temprano"
|
||||
}
|
||||
},
|
||||
"cancel": {
|
||||
"title": "\u00bfCancelar Trabajador?",
|
||||
"subtitle": "Esta acci\u00f3n no se puede deshacer",
|
||||
"confirm_message": "\u00bfEst\u00e1s seguro de que deseas cancelar a $name?",
|
||||
"helper_text": "Recibir\u00e1n una notificaci\u00f3n de cancelaci\u00f3n. Se solicitar\u00e1 un reemplazo autom\u00e1ticamente.",
|
||||
"reason_placeholder": "Raz\u00f3n de la cancelaci\u00f3n (opcional)",
|
||||
"keep_worker": "Mantener Trabajador",
|
||||
"confirm": "S\u00ed, Cancelar",
|
||||
"success": "Trabajador cancelado. Buscando reemplazo."
|
||||
},
|
||||
"actions": {
|
||||
"rate": "Calificar",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
},
|
||||
"client_reports_common": {
|
||||
|
||||
@@ -124,6 +124,8 @@ String _translateShiftError(String errorType) {
|
||||
return t.errors.shift.application_not_found;
|
||||
case 'no_active_shift':
|
||||
return t.errors.shift.no_active_shift;
|
||||
case 'not_found':
|
||||
return t.errors.shift.not_found;
|
||||
default:
|
||||
return t.errors.generic.unknown;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ class UiChip extends StatelessWidget {
|
||||
|
||||
final Row content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
if (leadingIcon != null) ...<Widget>[
|
||||
Icon(leadingIcon, size: iconSize, color: contentColor),
|
||||
|
||||
@@ -84,6 +84,7 @@ class UiNoticeBanner extends StatelessWidget {
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: titleColor ?? UiColors.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -18,6 +18,7 @@ export 'src/entities/enums/invoice_status.dart';
|
||||
export 'src/entities/enums/onboarding_status.dart';
|
||||
export 'src/entities/enums/order_type.dart';
|
||||
export 'src/entities/enums/payment_status.dart';
|
||||
export 'src/entities/enums/review_issue_flag.dart';
|
||||
export 'src/entities/enums/shift_status.dart';
|
||||
export 'src/entities/enums/staff_industry.dart';
|
||||
export 'src/entities/enums/staff_skill.dart';
|
||||
@@ -72,6 +73,7 @@ export 'src/entities/orders/recent_order.dart';
|
||||
|
||||
// Financial & Payroll
|
||||
export 'src/entities/benefits/benefit.dart';
|
||||
export 'src/entities/benefits/benefit_history.dart';
|
||||
export 'src/entities/financial/invoice.dart';
|
||||
export 'src/entities/financial/billing_account.dart';
|
||||
export 'src/entities/financial/current_bill.dart';
|
||||
|
||||
@@ -2,6 +2,14 @@ import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a geographic location obtained from the device.
|
||||
class DeviceLocation extends Equatable {
|
||||
|
||||
/// Creates a [DeviceLocation] instance.
|
||||
const DeviceLocation({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.accuracy,
|
||||
required this.timestamp,
|
||||
});
|
||||
/// Latitude in degrees.
|
||||
final double latitude;
|
||||
|
||||
@@ -14,14 +22,6 @@ class DeviceLocation extends Equatable {
|
||||
/// Time when this location was determined.
|
||||
final DateTime timestamp;
|
||||
|
||||
/// Creates a [DeviceLocation] instance.
|
||||
const DeviceLocation({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.accuracy,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [latitude, longitude, accuracy, timestamp];
|
||||
List<Object?> get props => <Object?>[latitude, longitude, accuracy, timestamp];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'package:krow_domain/src/entities/enums/benefit_status.dart';
|
||||
|
||||
/// A historical record of a staff benefit accrual period.
|
||||
///
|
||||
/// Returned by `GET /staff/profile/benefits/history`.
|
||||
class BenefitHistory extends Equatable {
|
||||
/// Creates a [BenefitHistory] instance.
|
||||
const BenefitHistory({
|
||||
required this.historyId,
|
||||
required this.benefitId,
|
||||
required this.benefitType,
|
||||
required this.title,
|
||||
required this.status,
|
||||
required this.effectiveAt,
|
||||
required this.trackedHours,
|
||||
required this.targetHours,
|
||||
this.endedAt,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
/// Deserialises a [BenefitHistory] from a V2 API JSON map.
|
||||
factory BenefitHistory.fromJson(Map<String, dynamic> json) {
|
||||
return BenefitHistory(
|
||||
historyId: json['historyId'] as String,
|
||||
benefitId: json['benefitId'] as String,
|
||||
benefitType: json['benefitType'] as String,
|
||||
title: json['title'] as String,
|
||||
status: BenefitStatus.fromJson(json['status'] as String?),
|
||||
effectiveAt: DateTime.parse(json['effectiveAt'] as String),
|
||||
endedAt: json['endedAt'] != null
|
||||
? DateTime.parse(json['endedAt'] as String)
|
||||
: null,
|
||||
trackedHours: (json['trackedHours'] as num).toInt(),
|
||||
targetHours: (json['targetHours'] as num).toInt(),
|
||||
notes: json['notes'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Unique identifier for this history record.
|
||||
final String historyId;
|
||||
|
||||
/// The benefit this record belongs to.
|
||||
final String benefitId;
|
||||
|
||||
/// Type code (e.g. SICK_LEAVE, VACATION).
|
||||
final String benefitType;
|
||||
|
||||
/// Human-readable title.
|
||||
final String title;
|
||||
|
||||
/// Status of the benefit during this period.
|
||||
final BenefitStatus status;
|
||||
|
||||
/// When this benefit period became effective.
|
||||
final DateTime effectiveAt;
|
||||
|
||||
/// When this benefit period ended, or `null` if still active.
|
||||
final DateTime? endedAt;
|
||||
|
||||
/// Hours tracked during this period.
|
||||
final int trackedHours;
|
||||
|
||||
/// Target hours for this period.
|
||||
final int targetHours;
|
||||
|
||||
/// Optional notes about the accrual.
|
||||
final String? notes;
|
||||
|
||||
/// Serialises this [BenefitHistory] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'historyId': historyId,
|
||||
'benefitId': benefitId,
|
||||
'benefitType': benefitType,
|
||||
'title': title,
|
||||
'status': status.toJson(),
|
||||
'effectiveAt': effectiveAt.toIso8601String(),
|
||||
'endedAt': endedAt?.toIso8601String(),
|
||||
'trackedHours': trackedHours,
|
||||
'targetHours': targetHours,
|
||||
'notes': notes,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
historyId,
|
||||
benefitId,
|
||||
benefitType,
|
||||
title,
|
||||
status,
|
||||
effectiveAt,
|
||||
endedAt,
|
||||
trackedHours,
|
||||
targetHours,
|
||||
notes,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Issue flags that can be attached to a worker review.
|
||||
///
|
||||
/// Maps to the allowed values for the `issue_flags` field in the
|
||||
/// V2 coverage reviews endpoint.
|
||||
enum ReviewIssueFlag {
|
||||
/// Worker arrived late.
|
||||
late('LATE'),
|
||||
|
||||
/// Uniform violation.
|
||||
uniform('UNIFORM'),
|
||||
|
||||
/// Worker misconduct.
|
||||
misconduct('MISCONDUCT'),
|
||||
|
||||
/// Worker did not show up.
|
||||
noShow('NO_SHOW'),
|
||||
|
||||
/// Attitude issue.
|
||||
attitude('ATTITUDE'),
|
||||
|
||||
/// Performance issue.
|
||||
performance('PERFORMANCE'),
|
||||
|
||||
/// Worker left before shift ended.
|
||||
leftEarly('LEFT_EARLY'),
|
||||
|
||||
/// Fallback for unrecognised API values.
|
||||
unknown('UNKNOWN');
|
||||
|
||||
const ReviewIssueFlag(this.value);
|
||||
|
||||
/// The V2 API string representation.
|
||||
final String value;
|
||||
|
||||
/// Deserialises from a V2 API string with safe fallback.
|
||||
static ReviewIssueFlag fromJson(String? value) {
|
||||
if (value == null) return ReviewIssueFlag.unknown;
|
||||
for (final ReviewIssueFlag flag in ReviewIssueFlag.values) {
|
||||
if (flag.value == value) return flag;
|
||||
}
|
||||
return ReviewIssueFlag.unknown;
|
||||
}
|
||||
|
||||
/// Serialises to the V2 API string.
|
||||
String toJson() => value;
|
||||
}
|
||||
@@ -18,6 +18,10 @@ class AssignedShift extends Equatable {
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.hourlyRateCents,
|
||||
required this.hourlyRate,
|
||||
required this.totalRateCents,
|
||||
required this.totalRate,
|
||||
required this.clientName,
|
||||
required this.orderType,
|
||||
required this.status,
|
||||
});
|
||||
@@ -33,6 +37,10 @@ class AssignedShift extends Equatable {
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: DateTime.parse(json['endTime'] as String),
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||
status: AssignmentStatus.fromJson(json['status'] as String?),
|
||||
);
|
||||
@@ -62,6 +70,18 @@ class AssignedShift extends Equatable {
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Total pay for this shift in cents.
|
||||
final int totalRateCents;
|
||||
|
||||
/// Total pay for this shift in dollars.
|
||||
final double totalRate;
|
||||
|
||||
/// Name of the client / business for this shift.
|
||||
final String clientName;
|
||||
|
||||
/// Order type.
|
||||
final OrderType orderType;
|
||||
|
||||
@@ -79,6 +99,10 @@ class AssignedShift extends Equatable {
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'totalRateCents': totalRateCents,
|
||||
'totalRate': totalRate,
|
||||
'clientName': clientName,
|
||||
'orderType': orderType.toJson(),
|
||||
'status': status.toJson(),
|
||||
};
|
||||
@@ -94,6 +118,10 @@ class AssignedShift extends Equatable {
|
||||
startTime,
|
||||
endTime,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
totalRateCents,
|
||||
totalRate,
|
||||
clientName,
|
||||
orderType,
|
||||
status,
|
||||
];
|
||||
|
||||
@@ -12,10 +12,18 @@ class CompletedShift extends Equatable {
|
||||
required this.shiftId,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.clientName,
|
||||
required this.date,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.minutesWorked,
|
||||
required this.hourlyRateCents,
|
||||
required this.hourlyRate,
|
||||
required this.totalRateCents,
|
||||
required this.totalRate,
|
||||
required this.paymentStatus,
|
||||
required this.status,
|
||||
this.timesheetStatus,
|
||||
});
|
||||
|
||||
/// Deserialises from the V2 API JSON response.
|
||||
@@ -25,10 +33,22 @@ class CompletedShift extends Equatable {
|
||||
shiftId: json['shiftId'] as String,
|
||||
title: json['title'] as String? ?? '',
|
||||
location: json['location'] as String? ?? '',
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
startTime: json['startTime'] != null
|
||||
? DateTime.parse(json['startTime'] as String)
|
||||
: DateTime.now(),
|
||||
endTime: json['endTime'] != null
|
||||
? DateTime.parse(json['endTime'] as String)
|
||||
: DateTime.now(),
|
||||
minutesWorked: json['minutesWorked'] as int? ?? 0,
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||
paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?),
|
||||
status: AssignmentStatus.completed,
|
||||
timesheetStatus: json['timesheetStatus'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,18 +64,42 @@ class CompletedShift extends Equatable {
|
||||
/// Human-readable location label.
|
||||
final String location;
|
||||
|
||||
/// Name of the client / business for this shift.
|
||||
final String clientName;
|
||||
|
||||
/// The date the shift was worked.
|
||||
final DateTime date;
|
||||
|
||||
/// Scheduled start time.
|
||||
final DateTime startTime;
|
||||
|
||||
/// Scheduled end time.
|
||||
final DateTime endTime;
|
||||
|
||||
/// Total minutes worked (regular + overtime).
|
||||
final int minutesWorked;
|
||||
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Total pay for this shift in cents.
|
||||
final int totalRateCents;
|
||||
|
||||
/// Total pay for this shift in dollars.
|
||||
final double totalRate;
|
||||
|
||||
/// Payment processing status.
|
||||
final PaymentStatus paymentStatus;
|
||||
|
||||
/// Assignment status (should always be `completed` for this class).
|
||||
final AssignmentStatus status;
|
||||
|
||||
/// Timesheet status (e.g. `SUBMITTED`, `APPROVED`, `PAID`, or null).
|
||||
final String? timesheetStatus;
|
||||
|
||||
/// Serialises to JSON.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
@@ -63,9 +107,17 @@ class CompletedShift extends Equatable {
|
||||
'shiftId': shiftId,
|
||||
'title': title,
|
||||
'location': location,
|
||||
'clientName': clientName,
|
||||
'date': date.toIso8601String(),
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'minutesWorked': minutesWorked,
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'totalRateCents': totalRateCents,
|
||||
'totalRate': totalRate,
|
||||
'paymentStatus': paymentStatus.toJson(),
|
||||
'timesheetStatus': timesheetStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,8 +127,17 @@ class CompletedShift extends Equatable {
|
||||
shiftId,
|
||||
title,
|
||||
location,
|
||||
clientName,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
minutesWorked,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
totalRateCents,
|
||||
totalRate,
|
||||
paymentStatus,
|
||||
timesheetStatus,
|
||||
status,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ class OpenShift extends Equatable {
|
||||
required this.shiftId,
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
this.clientName = '',
|
||||
required this.location,
|
||||
required this.date,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.hourlyRateCents,
|
||||
required this.hourlyRate,
|
||||
required this.orderType,
|
||||
required this.instantBook,
|
||||
required this.requiredWorkerCount,
|
||||
@@ -28,11 +30,13 @@ class OpenShift extends Equatable {
|
||||
shiftId: json['shiftId'] as String,
|
||||
roleId: json['roleId'] as String,
|
||||
roleName: json['roleName'] as String,
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
location: json['location'] as String? ?? '',
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: DateTime.parse(json['endTime'] as String),
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||
instantBook: json['instantBook'] as bool? ?? false,
|
||||
requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1,
|
||||
@@ -48,6 +52,9 @@ class OpenShift extends Equatable {
|
||||
/// Display name of the role.
|
||||
final String roleName;
|
||||
|
||||
/// Name of the client/business offering this shift.
|
||||
final String clientName;
|
||||
|
||||
/// Human-readable location label.
|
||||
final String location;
|
||||
|
||||
@@ -63,6 +70,9 @@ class OpenShift extends Equatable {
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Order type.
|
||||
final OrderType orderType;
|
||||
|
||||
@@ -78,11 +88,13 @@ class OpenShift extends Equatable {
|
||||
'shiftId': shiftId,
|
||||
'roleId': roleId,
|
||||
'roleName': roleName,
|
||||
'clientName': clientName,
|
||||
'location': location,
|
||||
'date': date.toIso8601String(),
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'orderType': orderType.toJson(),
|
||||
'instantBook': instantBook,
|
||||
'requiredWorkerCount': requiredWorkerCount,
|
||||
@@ -94,11 +106,13 @@ class OpenShift extends Equatable {
|
||||
shiftId,
|
||||
roleId,
|
||||
roleName,
|
||||
clientName,
|
||||
location,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
orderType,
|
||||
instantBook,
|
||||
requiredWorkerCount,
|
||||
|
||||
@@ -11,7 +11,7 @@ class Shift extends Equatable {
|
||||
/// Creates a [Shift].
|
||||
const Shift({
|
||||
required this.id,
|
||||
required this.orderId,
|
||||
this.orderId,
|
||||
required this.title,
|
||||
required this.status,
|
||||
required this.startsAt,
|
||||
@@ -25,19 +25,39 @@ class Shift extends Equatable {
|
||||
required this.requiredWorkers,
|
||||
required this.assignedWorkers,
|
||||
this.notes,
|
||||
this.clockInMode,
|
||||
this.allowClockInOverride,
|
||||
this.nfcTagId,
|
||||
this.clientName,
|
||||
this.roleName,
|
||||
});
|
||||
|
||||
/// Deserialises from the V2 API JSON response.
|
||||
///
|
||||
/// Supports both the standard shift JSON shape (`id`, `startsAt`, `endsAt`)
|
||||
/// and the today-shifts endpoint shape (`shiftId`, `startTime`, `endTime`).
|
||||
factory Shift.fromJson(Map<String, dynamic> json) {
|
||||
final String? clientName = json['clientName'] as String?;
|
||||
final String? roleName = json['roleName'] as String?;
|
||||
|
||||
return Shift(
|
||||
id: json['id'] as String,
|
||||
orderId: json['orderId'] as String,
|
||||
title: json['title'] as String? ?? '',
|
||||
id: json['id'] as String? ?? json['shiftId'] as String,
|
||||
orderId: json['orderId'] as String?,
|
||||
title: json['title'] as String? ??
|
||||
roleName ??
|
||||
clientName ??
|
||||
'',
|
||||
status: ShiftStatus.fromJson(json['status'] as String?),
|
||||
startsAt: DateTime.parse(json['startsAt'] as String),
|
||||
endsAt: DateTime.parse(json['endsAt'] as String),
|
||||
startsAt: DateTime.parse(
|
||||
json['startsAt'] as String? ?? json['startTime'] as String,
|
||||
),
|
||||
endsAt: DateTime.parse(
|
||||
json['endsAt'] as String? ?? json['endTime'] as String,
|
||||
),
|
||||
timezone: json['timezone'] as String? ?? 'UTC',
|
||||
locationName: json['locationName'] as String?,
|
||||
locationName: json['locationName'] as String? ??
|
||||
json['locationAddress'] as String? ??
|
||||
json['location'] as String?,
|
||||
locationAddress: json['locationAddress'] as String?,
|
||||
latitude: parseDouble(json['latitude']),
|
||||
longitude: parseDouble(json['longitude']),
|
||||
@@ -45,14 +65,19 @@ class Shift extends Equatable {
|
||||
requiredWorkers: json['requiredWorkers'] as int? ?? 1,
|
||||
assignedWorkers: json['assignedWorkers'] as int? ?? 0,
|
||||
notes: json['notes'] as String?,
|
||||
clockInMode: json['clockInMode'] as String?,
|
||||
allowClockInOverride: json['allowClockInOverride'] as bool?,
|
||||
nfcTagId: json['nfcTagId'] as String?,
|
||||
clientName: clientName,
|
||||
roleName: roleName,
|
||||
);
|
||||
}
|
||||
|
||||
/// The shift row id.
|
||||
final String id;
|
||||
|
||||
/// The parent order id.
|
||||
final String orderId;
|
||||
/// The parent order id (may be null for today-shifts endpoint).
|
||||
final String? orderId;
|
||||
|
||||
/// Display title.
|
||||
final String title;
|
||||
@@ -93,6 +118,21 @@ class Shift extends Equatable {
|
||||
/// Free-form notes for the shift.
|
||||
final String? notes;
|
||||
|
||||
/// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`).
|
||||
final String? clockInMode;
|
||||
|
||||
/// Whether the worker is allowed to override the clock-in method.
|
||||
final bool? allowClockInOverride;
|
||||
|
||||
/// NFC tag identifier for NFC-based clock-in.
|
||||
final String? nfcTagId;
|
||||
|
||||
/// Name of the client (business) this shift belongs to.
|
||||
final String? clientName;
|
||||
|
||||
/// Name of the role the worker is assigned for this shift.
|
||||
final String? roleName;
|
||||
|
||||
/// Serialises to JSON.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
@@ -111,6 +151,11 @@ class Shift extends Equatable {
|
||||
'requiredWorkers': requiredWorkers,
|
||||
'assignedWorkers': assignedWorkers,
|
||||
'notes': notes,
|
||||
'clockInMode': clockInMode,
|
||||
'allowClockInOverride': allowClockInOverride,
|
||||
'nfcTagId': nfcTagId,
|
||||
'clientName': clientName,
|
||||
'roleName': roleName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,5 +185,10 @@ class Shift extends Equatable {
|
||||
requiredWorkers,
|
||||
assignedWorkers,
|
||||
notes,
|
||||
clockInMode,
|
||||
allowClockInOverride,
|
||||
nfcTagId,
|
||||
clientName,
|
||||
roleName,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/src/entities/enums/application_status.dart';
|
||||
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
||||
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
||||
import 'package:krow_domain/src/entities/shifts/shift.dart';
|
||||
|
||||
/// Full detail view of a shift for the staff member.
|
||||
///
|
||||
@@ -18,17 +19,27 @@ class ShiftDetail extends Equatable {
|
||||
this.description,
|
||||
required this.location,
|
||||
this.address,
|
||||
required this.clientName,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.date,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
required this.hourlyRateCents,
|
||||
required this.hourlyRate,
|
||||
required this.totalRateCents,
|
||||
required this.totalRate,
|
||||
required this.orderType,
|
||||
required this.requiredCount,
|
||||
required this.confirmedCount,
|
||||
this.assignmentStatus,
|
||||
this.applicationStatus,
|
||||
this.clockInMode,
|
||||
required this.allowClockInOverride,
|
||||
this.geofenceRadiusMeters,
|
||||
this.nfcTagId,
|
||||
});
|
||||
|
||||
/// Deserialises from the V2 API JSON response.
|
||||
@@ -39,12 +50,18 @@ class ShiftDetail extends Equatable {
|
||||
description: json['description'] as String?,
|
||||
location: json['location'] as String? ?? '',
|
||||
address: json['address'] as String?,
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
latitude: Shift.parseDouble(json['latitude']),
|
||||
longitude: Shift.parseDouble(json['longitude']),
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: DateTime.parse(json['endTime'] as String),
|
||||
roleId: json['roleId'] as String,
|
||||
roleName: json['roleName'] as String,
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||
requiredCount: json['requiredCount'] as int? ?? 1,
|
||||
confirmedCount: json['confirmedCount'] as int? ?? 0,
|
||||
@@ -54,6 +71,10 @@ class ShiftDetail extends Equatable {
|
||||
applicationStatus: json['applicationStatus'] != null
|
||||
? ApplicationStatus.fromJson(json['applicationStatus'] as String?)
|
||||
: null,
|
||||
clockInMode: json['clockInMode'] as String?,
|
||||
allowClockInOverride: json['allowClockInOverride'] as bool? ?? false,
|
||||
geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?,
|
||||
nfcTagId: json['nfcTagId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +93,15 @@ class ShiftDetail extends Equatable {
|
||||
/// Street address of the shift location.
|
||||
final String? address;
|
||||
|
||||
/// Name of the client / business for this shift.
|
||||
final String clientName;
|
||||
|
||||
/// Latitude for map display and geofence validation.
|
||||
final double? latitude;
|
||||
|
||||
/// Longitude for map display and geofence validation.
|
||||
final double? longitude;
|
||||
|
||||
/// Date of the shift (same as startTime, kept for display grouping).
|
||||
final DateTime date;
|
||||
|
||||
@@ -90,6 +120,15 @@ class ShiftDetail extends Equatable {
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Total pay for this shift in cents.
|
||||
final int totalRateCents;
|
||||
|
||||
/// Total pay for this shift in dollars.
|
||||
final double totalRate;
|
||||
|
||||
/// Order type.
|
||||
final OrderType orderType;
|
||||
|
||||
@@ -105,6 +144,26 @@ class ShiftDetail extends Equatable {
|
||||
/// Current worker's application status, if applied.
|
||||
final ApplicationStatus? applicationStatus;
|
||||
|
||||
/// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`).
|
||||
final String? clockInMode;
|
||||
|
||||
/// Whether the worker is allowed to override the clock-in method.
|
||||
final bool allowClockInOverride;
|
||||
|
||||
/// Geofence radius in meters for clock-in validation.
|
||||
final int? geofenceRadiusMeters;
|
||||
|
||||
/// NFC tag identifier for NFC-based clock-in.
|
||||
final String? nfcTagId;
|
||||
|
||||
/// Duration of the shift in hours.
|
||||
double get durationHours {
|
||||
return endTime.difference(startTime).inMinutes / 60;
|
||||
}
|
||||
|
||||
/// Estimated total pay in dollars.
|
||||
double get estimatedTotal => hourlyRate * durationHours;
|
||||
|
||||
/// Serialises to JSON.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
@@ -113,17 +172,27 @@ class ShiftDetail extends Equatable {
|
||||
'description': description,
|
||||
'location': location,
|
||||
'address': address,
|
||||
'clientName': clientName,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'date': date.toIso8601String(),
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'roleId': roleId,
|
||||
'roleName': roleName,
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'totalRateCents': totalRateCents,
|
||||
'totalRate': totalRate,
|
||||
'orderType': orderType.toJson(),
|
||||
'requiredCount': requiredCount,
|
||||
'confirmedCount': confirmedCount,
|
||||
'assignmentStatus': assignmentStatus?.toJson(),
|
||||
'applicationStatus': applicationStatus?.toJson(),
|
||||
'clockInMode': clockInMode,
|
||||
'allowClockInOverride': allowClockInOverride,
|
||||
'geofenceRadiusMeters': geofenceRadiusMeters,
|
||||
'nfcTagId': nfcTagId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,16 +203,26 @@ class ShiftDetail extends Equatable {
|
||||
description,
|
||||
location,
|
||||
address,
|
||||
clientName,
|
||||
latitude,
|
||||
longitude,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
roleId,
|
||||
roleName,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
totalRateCents,
|
||||
totalRate,
|
||||
orderType,
|
||||
requiredCount,
|
||||
confirmedCount,
|
||||
assignmentStatus,
|
||||
applicationStatus,
|
||||
clockInMode,
|
||||
allowClockInOverride,
|
||||
geofenceRadiusMeters,
|
||||
nfcTagId,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ class TodayShift extends Equatable {
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.attendanceStatus,
|
||||
this.clientName = '',
|
||||
this.hourlyRateCents = 0,
|
||||
this.hourlyRate = 0.0,
|
||||
this.totalRateCents = 0,
|
||||
this.totalRate = 0.0,
|
||||
this.locationAddress,
|
||||
this.clockInAt,
|
||||
});
|
||||
|
||||
@@ -30,6 +36,12 @@ class TodayShift extends Equatable {
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: DateTime.parse(json['endTime'] as String),
|
||||
attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?),
|
||||
clientName: json['clientName'] as String? ?? '',
|
||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||
locationAddress: json['locationAddress'] as String?,
|
||||
clockInAt: json['clockInAt'] != null
|
||||
? DateTime.parse(json['clockInAt'] as String)
|
||||
: null,
|
||||
@@ -48,6 +60,24 @@ class TodayShift extends Equatable {
|
||||
/// Human-readable location label (clock-point or shift location).
|
||||
final String location;
|
||||
|
||||
/// Name of the client / business for this shift.
|
||||
final String clientName;
|
||||
|
||||
/// Pay rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Pay rate in dollars per hour.
|
||||
final double hourlyRate;
|
||||
|
||||
/// Total pay for this shift in cents.
|
||||
final int totalRateCents;
|
||||
|
||||
/// Total pay for this shift in dollars.
|
||||
final double totalRate;
|
||||
|
||||
/// Full street address of the shift location, if available.
|
||||
final String? locationAddress;
|
||||
|
||||
/// Scheduled start time.
|
||||
final DateTime startTime;
|
||||
|
||||
@@ -67,6 +97,12 @@ class TodayShift extends Equatable {
|
||||
'shiftId': shiftId,
|
||||
'roleName': roleName,
|
||||
'location': location,
|
||||
'clientName': clientName,
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
'hourlyRate': hourlyRate,
|
||||
'totalRateCents': totalRateCents,
|
||||
'totalRate': totalRate,
|
||||
'locationAddress': locationAddress,
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'attendanceStatus': attendanceStatus.toJson(),
|
||||
@@ -80,6 +116,12 @@ class TodayShift extends Equatable {
|
||||
shiftId,
|
||||
roleName,
|
||||
location,
|
||||
clientName,
|
||||
hourlyRateCents,
|
||||
hourlyRate,
|
||||
totalRateCents,
|
||||
totalRate,
|
||||
locationAddress,
|
||||
startTime,
|
||||
endTime,
|
||||
attendanceStatus,
|
||||
|
||||
@@ -33,7 +33,10 @@ class ClientAuthenticationModule extends Module {
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<AuthRepositoryInterface>(
|
||||
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
() => AuthRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
firebaseAuthService: i.get<FirebaseAuthService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart'
|
||||
show
|
||||
@@ -10,7 +9,6 @@ import 'package:krow_domain/krow_domain.dart'
|
||||
AppException,
|
||||
BaseApiService,
|
||||
ClientSession,
|
||||
InvalidCredentialsException,
|
||||
NetworkException,
|
||||
PasswordMismatchException,
|
||||
SignInFailedException,
|
||||
@@ -21,20 +19,23 @@ import 'package:krow_domain/krow_domain.dart'
|
||||
|
||||
/// Production implementation of the [AuthRepositoryInterface] for the client app.
|
||||
///
|
||||
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for
|
||||
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve
|
||||
/// business context. Sign-up provisioning (tenant, business, memberships) is
|
||||
/// handled entirely server-side by the V2 API.
|
||||
/// Uses [FirebaseAuthService] from core for local Firebase sign-in (to maintain
|
||||
/// local auth state for the [AuthInterceptor]), then calls V2 `GET /auth/session`
|
||||
/// to retrieve business context. Sign-up provisioning (tenant, business,
|
||||
/// memberships) is handled entirely server-side by the V2 API.
|
||||
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService].
|
||||
AuthRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
/// Creates an [AuthRepositoryImpl] with the given dependencies.
|
||||
AuthRepositoryImpl({
|
||||
required BaseApiService apiService,
|
||||
required FirebaseAuthService firebaseAuthService,
|
||||
}) : _apiService = apiService,
|
||||
_firebaseAuthService = firebaseAuthService;
|
||||
|
||||
/// The V2 API service for backend calls.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
/// Firebase Auth instance for client-side sign-in/sign-up.
|
||||
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance;
|
||||
/// Core Firebase Auth service abstraction.
|
||||
final FirebaseAuthService _firebaseAuthService;
|
||||
|
||||
@override
|
||||
Future<User> signInWithEmail({
|
||||
@@ -42,38 +43,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
// Step 1: Call V2 sign-in endpoint — server handles Firebase Auth
|
||||
// Step 1: Call V2 sign-in endpoint -- server handles Firebase Auth
|
||||
// via Identity Toolkit and returns a full auth envelope.
|
||||
final ApiResponse response = await _apiService.post(
|
||||
AuthEndpoints.clientSignIn,
|
||||
data: <String, dynamic>{
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
data: <String, dynamic>{'email': email, 'password': password},
|
||||
);
|
||||
|
||||
final Map<String, dynamic> body =
|
||||
response.data as Map<String, dynamic>;
|
||||
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
|
||||
|
||||
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
|
||||
// to subsequent requests. The V2 API already validated credentials, so
|
||||
// email/password sign-in establishes the local Firebase Auth state.
|
||||
final firebase.UserCredential credential =
|
||||
await _auth.signInWithEmailAndPassword(
|
||||
await _firebaseAuthService.signInWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final firebase.User? firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw const SignInFailedException(
|
||||
technicalMessage: 'Local Firebase sign-in failed after V2 sign-in',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Populate session store from the V2 auth envelope directly
|
||||
// (no need for a separate GET /auth/session call).
|
||||
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
|
||||
return _populateStoreFromAuthEnvelope(body, email);
|
||||
} on AppException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
@@ -106,38 +95,34 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
|
||||
// for subsequent requests. The V2 API already created the Firebase
|
||||
// account, so this should succeed.
|
||||
final firebase.UserCredential credential =
|
||||
await _auth.signInWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final firebase.User? firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
try {
|
||||
await _firebaseAuthService.signInWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
} on SignInFailedException {
|
||||
throw const SignUpFailedException(
|
||||
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Populate store from the sign-up response envelope.
|
||||
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
|
||||
} on firebase.FirebaseAuthException catch (e) {
|
||||
if (e.code == 'email-already-in-use') {
|
||||
throw AccountExistsException(
|
||||
technicalMessage: 'Firebase: ${e.message}',
|
||||
);
|
||||
} else if (e.code == 'weak-password') {
|
||||
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
|
||||
} else if (e.code == 'network-request-failed') {
|
||||
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
|
||||
} else {
|
||||
throw SignUpFailedException(
|
||||
technicalMessage: 'Firebase auth error: ${e.message}',
|
||||
);
|
||||
}
|
||||
return _populateStoreFromAuthEnvelope(body, email);
|
||||
} on AppException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
// Map common Firebase-originated errors from the V2 API response
|
||||
// to domain exceptions.
|
||||
final String errorMessage = e.toString();
|
||||
if (errorMessage.contains('EMAIL_EXISTS') ||
|
||||
errorMessage.contains('email-already-in-use')) {
|
||||
throw AccountExistsException(technicalMessage: errorMessage);
|
||||
} else if (errorMessage.contains('WEAK_PASSWORD') ||
|
||||
errorMessage.contains('weak-password')) {
|
||||
throw WeakPasswordException(technicalMessage: errorMessage);
|
||||
} else if (errorMessage.contains('network-request-failed')) {
|
||||
throw NetworkException(technicalMessage: errorMessage);
|
||||
}
|
||||
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
|
||||
}
|
||||
}
|
||||
@@ -155,16 +140,13 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
// Step 1: Call V2 sign-out endpoint for server-side token revocation.
|
||||
await _apiService.post(AuthEndpoints.clientSignOut);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'V2 sign-out request failed: $e',
|
||||
name: 'AuthRepository',
|
||||
);
|
||||
developer.log('V2 sign-out request failed: $e', name: 'AuthRepository');
|
||||
// Continue with local sign-out even if server-side fails.
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 2: Sign out from local Firebase Auth.
|
||||
await _auth.signOut();
|
||||
// Step 2: Sign out from local Firebase Auth via core service.
|
||||
await _firebaseAuthService.signOut();
|
||||
} catch (e) {
|
||||
throw Exception('Error signing out locally: $e');
|
||||
}
|
||||
@@ -181,7 +163,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
/// returns a domain [User].
|
||||
User _populateStoreFromAuthEnvelope(
|
||||
Map<String, dynamic> envelope,
|
||||
firebase.User firebaseUser,
|
||||
String fallbackEmail,
|
||||
) {
|
||||
final Map<String, dynamic>? userJson =
|
||||
@@ -202,14 +183,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
'userId': userJson['id'] ?? userJson['userId'],
|
||||
},
|
||||
};
|
||||
final ClientSession clientSession =
|
||||
ClientSession.fromJson(normalisedEnvelope);
|
||||
final ClientSession clientSession = ClientSession.fromJson(
|
||||
normalisedEnvelope,
|
||||
);
|
||||
ClientSessionStore.instance.setSession(clientSession);
|
||||
}
|
||||
|
||||
final String userId =
|
||||
userJson?['id'] as String? ?? firebaseUser.uid;
|
||||
final String? email = userJson?['email'] as String? ?? fallbackEmail;
|
||||
final String userId = userJson?['id'] as String? ??
|
||||
(_firebaseAuthService.currentUserUid ?? '');
|
||||
final String email = userJson?['email'] as String? ?? fallbackEmail;
|
||||
|
||||
return User(
|
||||
id: userId,
|
||||
|
||||
@@ -14,7 +14,6 @@ dependencies:
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
firebase_auth: ^6.1.2
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||
@@ -29,8 +29,8 @@ class BillingModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<BillingRepository>(
|
||||
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
i.addLazySingleton<BillingRepositoryInterface>(
|
||||
() => BillingRepositoryInterfaceImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Implementation of [BillingRepository] using the V2 REST API.
|
||||
/// Implementation of [BillingRepositoryInterface] using the V2 REST API.
|
||||
///
|
||||
/// All backend calls go through [BaseApiService] with [ClientEndpoints].
|
||||
class BillingRepositoryImpl implements BillingRepository {
|
||||
/// Creates a [BillingRepositoryImpl].
|
||||
BillingRepositoryImpl({required BaseApiService apiService})
|
||||
class BillingRepositoryInterfaceImpl implements BillingRepositoryInterface {
|
||||
/// Creates a [BillingRepositoryInterfaceImpl].
|
||||
BillingRepositoryInterfaceImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
/// The API service used for all HTTP requests.
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
/// This interface defines the contract for accessing billing-related data,
|
||||
/// acting as a boundary between the Domain and Data layers.
|
||||
/// It allows the Domain layer to remain independent of specific data sources.
|
||||
abstract class BillingRepository {
|
||||
abstract class BillingRepositoryInterface {
|
||||
/// Fetches bank accounts associated with the business.
|
||||
Future<List<BillingAccount>> getBankAccounts();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for approving an invoice.
|
||||
class ApproveInvoiceUseCase extends UseCase<String, void> {
|
||||
@@ -8,7 +8,7 @@ class ApproveInvoiceUseCase extends UseCase<String, void> {
|
||||
ApproveInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(String input) => _repository.approveInvoice(input);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Params for [DisputeInvoiceUseCase].
|
||||
class DisputeInvoiceParams {
|
||||
@@ -20,7 +20,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
|
||||
DisputeInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(DisputeInvoiceParams input) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the bank accounts associated with the business.
|
||||
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
|
||||
@@ -9,7 +9,7 @@ class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
|
||||
GetBankAccountsUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<BillingAccount>> call() => _repository.getBankAccounts();
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the current bill amount in cents.
|
||||
///
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
/// Delegates data retrieval to the [BillingRepositoryInterface].
|
||||
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetCurrentBillAmountUseCase].
|
||||
GetCurrentBillAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<int> call() => _repository.getCurrentBillCents();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the invoice history.
|
||||
///
|
||||
@@ -11,7 +11,7 @@ class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
GetInvoiceHistoryUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> call() => _repository.getInvoiceHistory();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the pending invoices.
|
||||
///
|
||||
@@ -11,7 +11,7 @@ class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
GetPendingInvoicesUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> call() => _repository.getPendingInvoices();
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the savings amount in cents.
|
||||
///
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
/// Delegates data retrieval to the [BillingRepositoryInterface].
|
||||
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetSavingsAmountUseCase].
|
||||
GetSavingsAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<int> call() => _repository.getSavingsCents();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
|
||||
|
||||
/// Parameters for [GetSpendBreakdownUseCase].
|
||||
class SpendBreakdownParams {
|
||||
@@ -20,14 +20,14 @@ class SpendBreakdownParams {
|
||||
|
||||
/// Use case for fetching the spending breakdown by category.
|
||||
///
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
/// Delegates data retrieval to the [BillingRepositoryInterface].
|
||||
class GetSpendBreakdownUseCase
|
||||
extends UseCase<SpendBreakdownParams, List<SpendItem>> {
|
||||
/// Creates a [GetSpendBreakdownUseCase].
|
||||
GetSpendBreakdownUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
final BillingRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<SpendItem>> call(SpendBreakdownParams input) =>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
@@ -14,6 +12,9 @@ import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// BLoC for managing billing state and data loading.
|
||||
///
|
||||
/// Fetches billing summary data (current bill, savings, invoices,
|
||||
/// spend breakdown, bank accounts) and manages period tab selection.
|
||||
class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
with BlocErrorHandler<BillingState> {
|
||||
/// Creates a [BillingBloc] with the given use cases.
|
||||
@@ -35,64 +36,97 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
on<BillingPeriodChanged>(_onPeriodChanged);
|
||||
}
|
||||
|
||||
/// Use case for fetching bank accounts.
|
||||
final GetBankAccountsUseCase _getBankAccounts;
|
||||
|
||||
/// Use case for fetching the current bill amount.
|
||||
final GetCurrentBillAmountUseCase _getCurrentBillAmount;
|
||||
|
||||
/// Use case for fetching the savings amount.
|
||||
final GetSavingsAmountUseCase _getSavingsAmount;
|
||||
|
||||
/// Use case for fetching pending invoices.
|
||||
final GetPendingInvoicesUseCase _getPendingInvoices;
|
||||
|
||||
/// Use case for fetching invoice history.
|
||||
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
||||
|
||||
/// Use case for fetching spending breakdown.
|
||||
final GetSpendBreakdownUseCase _getSpendBreakdown;
|
||||
|
||||
/// Executes [loader] and returns null on failure, logging the error.
|
||||
Future<T?> _loadSafe<T>(Future<T> Function() loader) async {
|
||||
try {
|
||||
return await loader();
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Partial billing load failed: $e',
|
||||
name: 'BillingBloc',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads all billing data concurrently.
|
||||
///
|
||||
/// Uses [handleError] to surface errors to the UI via state
|
||||
/// instead of silently swallowing them. Individual data fetches
|
||||
/// use [handleErrorWithResult] so partial failures populate
|
||||
/// with defaults rather than failing the entire load.
|
||||
Future<void> _onLoadStarted(
|
||||
BillingLoadStarted event,
|
||||
Emitter<BillingState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: BillingStatus.loading));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
emit(state.copyWith(status: BillingStatus.loading));
|
||||
|
||||
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab);
|
||||
final SpendBreakdownParams spendParams =
|
||||
_dateRangeFor(state.periodTab);
|
||||
|
||||
final List<Object?> results = await Future.wait<Object?>(
|
||||
<Future<Object?>>[
|
||||
_loadSafe<int>(() => _getCurrentBillAmount.call()),
|
||||
_loadSafe<int>(() => _getSavingsAmount.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()),
|
||||
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)),
|
||||
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()),
|
||||
],
|
||||
);
|
||||
final List<Object?> results = await Future.wait<Object?>(
|
||||
<Future<Object?>>[
|
||||
handleErrorWithResult<int>(
|
||||
action: () => _getCurrentBillAmount.call(),
|
||||
onError: (_) {},
|
||||
),
|
||||
handleErrorWithResult<int>(
|
||||
action: () => _getSavingsAmount.call(),
|
||||
onError: (_) {},
|
||||
),
|
||||
handleErrorWithResult<List<Invoice>>(
|
||||
action: () => _getPendingInvoices.call(),
|
||||
onError: (_) {},
|
||||
),
|
||||
handleErrorWithResult<List<Invoice>>(
|
||||
action: () => _getInvoiceHistory.call(),
|
||||
onError: (_) {},
|
||||
),
|
||||
handleErrorWithResult<List<SpendItem>>(
|
||||
action: () => _getSpendBreakdown.call(spendParams),
|
||||
onError: (_) {},
|
||||
),
|
||||
handleErrorWithResult<List<BillingAccount>>(
|
||||
action: () => _getBankAccounts.call(),
|
||||
onError: (_) {},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final int? currentBillCents = results[0] as int?;
|
||||
final int? savingsCents = results[1] as int?;
|
||||
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?;
|
||||
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?;
|
||||
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?;
|
||||
final List<BillingAccount>? bankAccounts =
|
||||
results[5] as List<BillingAccount>?;
|
||||
final int? currentBillCents = results[0] as int?;
|
||||
final int? savingsCents = results[1] as int?;
|
||||
final List<Invoice>? pendingInvoices =
|
||||
results[2] as List<Invoice>?;
|
||||
final List<Invoice>? invoiceHistory =
|
||||
results[3] as List<Invoice>?;
|
||||
final List<SpendItem>? spendBreakdown =
|
||||
results[4] as List<SpendItem>?;
|
||||
final List<BillingAccount>? bankAccounts =
|
||||
results[5] as List<BillingAccount>?;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: BillingStatus.success,
|
||||
currentBillCents: currentBillCents ?? state.currentBillCents,
|
||||
savingsCents: savingsCents ?? state.savingsCents,
|
||||
pendingInvoices: pendingInvoices ?? state.pendingInvoices,
|
||||
invoiceHistory: invoiceHistory ?? state.invoiceHistory,
|
||||
spendBreakdown: spendBreakdown ?? state.spendBreakdown,
|
||||
bankAccounts: bankAccounts ?? state.bankAccounts,
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: BillingStatus.success,
|
||||
currentBillCents: currentBillCents ?? state.currentBillCents,
|
||||
savingsCents: savingsCents ?? state.savingsCents,
|
||||
pendingInvoices: pendingInvoices ?? state.pendingInvoices,
|
||||
invoiceHistory: invoiceHistory ?? state.invoiceHistory,
|
||||
spendBreakdown: spendBreakdown ?? state.spendBreakdown,
|
||||
bankAccounts: bankAccounts ?? state.bankAccounts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: BillingStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = resolvedInvoice.dueDate != null
|
||||
? formatter.format(resolvedInvoice.dueDate!)
|
||||
: 'N/A';
|
||||
: 'N/A'; // TODO: localize
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
@@ -85,7 +85,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
bottomNavigationBar: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: UiColors.primaryForeground,
|
||||
border: Border(
|
||||
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
|
||||
@@ -19,7 +19,7 @@ class BillingPageSkeleton extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
// Pending invoices section header
|
||||
const UiShimmerSectionHeader(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
@@ -39,7 +39,7 @@ class BillingPageSkeleton extends StatelessWidget {
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 160, height: 16),
|
||||
SizedBox(height: UiConstants.space4),
|
||||
// Breakdown rows
|
||||
|
||||
@@ -10,7 +10,7 @@ class BreakdownRowSkeleton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 100, height: 14),
|
||||
UiShimmerLine(width: 60, height: 14),
|
||||
],
|
||||
|
||||
@@ -16,10 +16,10 @@ class InvoiceCardSkeleton extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerBox(
|
||||
width: 72,
|
||||
height: 24,
|
||||
@@ -35,10 +35,10 @@ class InvoiceCardSkeleton extends StatelessWidget {
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 18),
|
||||
|
||||
@@ -95,8 +95,8 @@ class CompletionReviewActions extends StatelessWidget {
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) => AlertDialog(
|
||||
title: Text(t.client_billing.flag_dialog.title),
|
||||
surfaceTintColor: Colors.white,
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: UiColors.primaryForeground,
|
||||
backgroundColor: UiColors.primaryForeground,
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -23,7 +23,7 @@ class CompletionReviewSearchAndTabs extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F5F9),
|
||||
color: UiColors.muted,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: TextField(
|
||||
@@ -69,17 +69,17 @@ class CompletionReviewSearchAndTabs extends StatelessWidget {
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(
|
||||
color: isSelected ? const Color(0xFF2563EB) : UiColors.border,
|
||||
color: isSelected ? UiColors.primary : UiColors.border,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: isSelected ? Colors.white : UiColors.textSecondary,
|
||||
color: isSelected ? UiColors.primaryForeground : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ class InvoicesListSkeleton extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
children: List.generate(4, (int index) {
|
||||
children: List<Widget>.generate(4, (int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: Container(
|
||||
@@ -26,10 +26,10 @@ class InvoicesListSkeleton extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerBox(
|
||||
width: 64,
|
||||
height: 22,
|
||||
@@ -47,10 +47,10 @@ class InvoicesListSkeleton extends StatelessWidget {
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 20),
|
||||
|
||||
@@ -33,7 +33,7 @@ class PendingInvoicesSection extends StatelessWidget {
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.orange,
|
||||
color: UiColors.textWarning,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
@@ -101,7 +101,7 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
: 'N/A'; // TODO: localize
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
|
||||
@@ -21,8 +21,8 @@ class CoverageModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<CoverageRepository>(
|
||||
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
i.addLazySingleton<CoverageRepositoryInterface>(
|
||||
() => CoverageRepositoryInterfaceImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// V2 API implementation of [CoverageRepository].
|
||||
/// V2 API implementation of [CoverageRepositoryInterface].
|
||||
///
|
||||
/// Uses [BaseApiService] with [ClientEndpoints] for all backend access.
|
||||
class CoverageRepositoryImpl implements CoverageRepository {
|
||||
/// Creates a [CoverageRepositoryImpl].
|
||||
CoverageRepositoryImpl({required BaseApiService apiService})
|
||||
class CoverageRepositoryInterfaceImpl implements CoverageRepositoryInterface {
|
||||
/// Creates a [CoverageRepositoryInterfaceImpl].
|
||||
CoverageRepositoryInterfaceImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
///
|
||||
/// Defines the contract for accessing coverage data via the V2 REST API,
|
||||
/// acting as a boundary between the Domain and Data layers.
|
||||
abstract interface class CoverageRepository {
|
||||
abstract interface class CoverageRepositoryInterface {
|
||||
/// Fetches shifts with assigned workers for a specific [date].
|
||||
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// Use case for cancelling a late worker's assignment.
|
||||
///
|
||||
/// Delegates to [CoverageRepository] to cancel the assignment via V2 API.
|
||||
/// Delegates to [CoverageRepositoryInterface] to cancel the assignment via V2 API.
|
||||
class CancelLateWorkerUseCase
|
||||
implements UseCase<CancelLateWorkerArguments, void> {
|
||||
/// Creates a [CancelLateWorkerUseCase].
|
||||
CancelLateWorkerUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
final CoverageRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(CancelLateWorkerArguments arguments) {
|
||||
|
||||
@@ -2,17 +2,17 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching aggregated coverage statistics for a specific date.
|
||||
///
|
||||
/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity.
|
||||
/// Delegates to [CoverageRepositoryInterface] and returns a [CoverageStats] entity.
|
||||
class GetCoverageStatsUseCase
|
||||
implements UseCase<GetCoverageStatsArguments, CoverageStats> {
|
||||
/// Creates a [GetCoverageStatsUseCase].
|
||||
GetCoverageStatsUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
final CoverageRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<CoverageStats> call(GetCoverageStatsArguments arguments) {
|
||||
|
||||
@@ -2,17 +2,17 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching shifts with workers for a specific date.
|
||||
///
|
||||
/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities.
|
||||
/// Delegates to [CoverageRepositoryInterface] and returns V2 [ShiftWithWorkers] entities.
|
||||
class GetShiftsForDateUseCase
|
||||
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
|
||||
/// Creates a [GetShiftsForDateUseCase].
|
||||
GetShiftsForDateUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
final CoverageRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
|
||||
|
||||
/// Use case for submitting a worker review from the coverage page.
|
||||
///
|
||||
/// Validates the rating range and delegates to [CoverageRepository].
|
||||
/// Validates the rating range and delegates to [CoverageRepositoryInterface].
|
||||
class SubmitWorkerReviewUseCase
|
||||
implements UseCase<SubmitWorkerReviewArguments, void> {
|
||||
/// Creates a [SubmitWorkerReviewUseCase].
|
||||
SubmitWorkerReviewUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
final CoverageRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(SubmitWorkerReviewArguments arguments) async {
|
||||
|
||||
@@ -10,14 +10,14 @@ import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_state.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_calendar_selector.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_quick_stats.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_shift_list.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart';
|
||||
|
||||
/// Page for displaying daily coverage information.
|
||||
///
|
||||
/// Shows shifts, worker statuses, and coverage statistics for a selected date.
|
||||
/// Shows shifts, worker statuses, and coverage statistics for a selected date
|
||||
/// using a collapsible SliverAppBar with gradient header and live activity feed.
|
||||
class CoveragePage extends StatefulWidget {
|
||||
/// Creates a [CoveragePage].
|
||||
const CoveragePage({super.key});
|
||||
@@ -27,14 +27,13 @@ class CoveragePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CoveragePageState extends State<CoveragePage> {
|
||||
/// Controller for the [CustomScrollView].
|
||||
late ScrollController _scrollController;
|
||||
bool _isScrolled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -43,16 +42,6 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.hasClients) {
|
||||
if (_scrollController.offset > 180 && !_isScrolled) {
|
||||
setState(() => _isScrolled = true);
|
||||
} else if (_scrollController.offset <= 180 && _isScrolled) {
|
||||
setState(() => _isScrolled = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CoverageBloc>(
|
||||
@@ -69,6 +58,21 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
if (state.writeStatus == CoverageWriteStatus.submitted) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context.t.client_coverage.review.success,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
}
|
||||
if (state.writeStatus == CoverageWriteStatus.submitFailure &&
|
||||
state.writeErrorMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.writeErrorMessage!),
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, CoverageState state) {
|
||||
final DateTime selectedDate = state.selectedDate ?? DateTime.now();
|
||||
@@ -78,19 +82,26 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 300.0,
|
||||
expandedHeight: 316.0,
|
||||
backgroundColor: UiColors.primary,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(
|
||||
_isScrolled
|
||||
? DateFormat('MMMM d').format(selectedDate)
|
||||
: context.t.client_coverage.page.daily_coverage,
|
||||
key: ValueKey<bool>(_isScrolled),
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.daily_coverage,
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('EEEE, MMMM d').format(selectedDate),
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.primaryForeground
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
@@ -117,10 +128,13 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
],
|
||||
flexibleSpace: Container(
|
||||
decoration: const BoxDecoration(
|
||||
// Intentional gradient: the second stop is a darker
|
||||
// variant of UiColors.primary used only for the
|
||||
// coverage header visual effect.
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
UiColors.primary,
|
||||
UiColors.primary,
|
||||
Color(0xFF0626A8),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
@@ -154,6 +168,12 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
state.stats?.totalPositionsConfirmed ?? 0,
|
||||
totalNeeded:
|
||||
state.stats?.totalPositionsNeeded ?? 0,
|
||||
totalCheckedIn:
|
||||
state.stats?.totalWorkersCheckedIn ?? 0,
|
||||
totalEnRoute:
|
||||
state.stats?.totalWorkersEnRoute ?? 0,
|
||||
totalLate:
|
||||
state.stats?.totalWorkersLate ?? 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -176,7 +196,10 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the main body content based on the current state.
|
||||
/// Builds the main body content based on the current [CoverageState].
|
||||
///
|
||||
/// Displays a skeleton loader, error state, or the live activity feed
|
||||
/// with late worker alerts and shift list.
|
||||
Widget _buildBody({
|
||||
required BuildContext context,
|
||||
required CoverageState state,
|
||||
@@ -227,24 +250,19 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space6,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
if (state.stats != null &&
|
||||
state.stats!.totalWorkersLate > 0) ...<Widget>[
|
||||
LateWorkersAlert(
|
||||
lateCount: state.stats!.totalWorkersLate,
|
||||
),
|
||||
],
|
||||
if (state.stats != null) ...<Widget>[
|
||||
CoverageQuickStats(stats: state.stats!),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (state.stats != null &&
|
||||
state.stats!.totalWorkersLate > 0) ...<Widget>[
|
||||
LateWorkersAlert(
|
||||
lateCount: state.stats!.totalWorkersLate,
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'${context.t.client_coverage.page.shifts} (${state.shifts.length})',
|
||||
style: UiTypography.title2b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
context.t.client_coverage.page.live_activity,
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 2.0,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
CoverageShiftList(shifts: state.shifts),
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
|
||||
|
||||
/// Bottom sheet modal for cancelling a late worker's assignment.
|
||||
///
|
||||
/// Collects an optional cancellation reason and dispatches a
|
||||
/// [CoverageCancelLateWorkerRequested] event to the [CoverageBloc].
|
||||
class CancelLateWorkerSheet extends StatefulWidget {
|
||||
/// Creates a [CancelLateWorkerSheet].
|
||||
const CancelLateWorkerSheet({
|
||||
required this.worker,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The assigned worker to cancel.
|
||||
final AssignedWorker worker;
|
||||
|
||||
/// Shows the cancel-late-worker bottom sheet.
|
||||
///
|
||||
/// Captures [CoverageBloc] from [context] before opening so the sheet
|
||||
/// can dispatch events without relying on an ancestor that may be
|
||||
/// deactivated.
|
||||
static void show(BuildContext context, {required AssignedWorker worker}) {
|
||||
final CoverageBloc bloc = ReadContext(context).read<CoverageBloc>();
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(UiConstants.space4),
|
||||
),
|
||||
),
|
||||
builder: (_) => BlocProvider<CoverageBloc>.value(
|
||||
value: bloc,
|
||||
child: CancelLateWorkerSheet(worker: worker),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<CancelLateWorkerSheet> createState() => _CancelLateWorkerSheetState();
|
||||
}
|
||||
|
||||
class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
|
||||
/// Controller for the optional cancellation reason text field.
|
||||
final TextEditingController _reasonController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reasonController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCoverageCancelEn l10n =
|
||||
context.t.client_coverage.cancel;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
top: UiConstants.space3,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
// Drag handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.border,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
spacing: UiConstants.space3,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.warning,
|
||||
color: UiColors.destructive,
|
||||
size: 28,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(l10n.title, style: UiTypography.title1b.textError),
|
||||
Text(
|
||||
l10n.subtitle,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: const Icon(
|
||||
UiIcons.close,
|
||||
color: UiColors.textSecondary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Body
|
||||
Text(
|
||||
l10n.confirm_message(name: widget.worker.fullName),
|
||||
style: UiTypography.body1r,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
l10n.helper_text,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Reason field
|
||||
UiTextField(
|
||||
hintText: l10n.reason_placeholder,
|
||||
maxLines: 2,
|
||||
controller: _reasonController,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
text: l10n.keep_worker,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: l10n.confirm,
|
||||
onPressed: () => _onConfirm(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.destructive,
|
||||
foregroundColor: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatches the cancel event and closes the sheet.
|
||||
void _onConfirm(BuildContext context) {
|
||||
final String reason = _reasonController.text.trim();
|
||||
|
||||
ReadContext(context).read<CoverageBloc>().add(
|
||||
CoverageCancelLateWorkerRequested(
|
||||
assignmentId: widget.worker.assignmentId,
|
||||
reason: reason.isNotEmpty ? reason : null,
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? UiColors.primaryForeground
|
||||
: UiColors.primaryForeground.withOpacity(0.1),
|
||||
: UiColors.primaryForeground.withAlpha(25),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: isToday && !isSelected
|
||||
? Border.all(
|
||||
@@ -122,6 +122,14 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.primary
|
||||
: UiColors.primaryForeground.withAlpha(179),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
date.day.toString().padLeft(2, '0'),
|
||||
style: UiTypography.body1b.copyWith(
|
||||
@@ -130,14 +138,6 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.mutedForeground
|
||||
: UiColors.primaryForeground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,40 +5,30 @@ import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/
|
||||
|
||||
/// Shimmer loading skeleton that mimics the coverage page loaded layout.
|
||||
///
|
||||
/// Shows placeholder shapes for the quick stats row, shift section header,
|
||||
/// and a list of shift cards with worker rows.
|
||||
/// Shows placeholder shapes for the live activity section label and a list
|
||||
/// of shift cards with worker rows.
|
||||
class CoveragePageSkeleton extends StatelessWidget {
|
||||
/// Creates a [CoveragePageSkeleton].
|
||||
const CoveragePageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
return const UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
padding: EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Quick stats row (2 stat cards)
|
||||
const Row(
|
||||
children: [
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shifts section header
|
||||
const UiShimmerLine(width: 140, height: 18),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
children: <Widget>[
|
||||
// "LIVE ACTIVITY" section label placeholder
|
||||
UiShimmerLine(width: 100, height: 10),
|
||||
SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shift cards with worker rows
|
||||
const ShiftCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const ShiftCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const ShiftCardSkeleton(),
|
||||
ShiftCardSkeleton(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
ShiftCardSkeleton(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
ShiftCardSkeleton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -14,20 +14,20 @@ class ShiftCardSkeleton extends StatelessWidget {
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
// Shift header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const UiShimmerLine(width: 180, height: 16),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const UiShimmerLine(width: 120, height: 12),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const UiShimmerLine(width: 80, height: 12),
|
||||
const Spacer(),
|
||||
UiShimmerBox(
|
||||
@@ -47,7 +47,7 @@ class ShiftCardSkeleton extends StatelessWidget {
|
||||
horizontal: UiConstants.space3,
|
||||
).copyWith(bottom: UiConstants.space3),
|
||||
child: const Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerListItem(),
|
||||
UiShimmerListItem(),
|
||||
],
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_stat_card.dart';
|
||||
|
||||
/// Quick statistics cards showing coverage metrics.
|
||||
///
|
||||
/// Displays checked-in and en-route worker counts.
|
||||
class CoverageQuickStats extends StatelessWidget {
|
||||
/// Creates a [CoverageQuickStats].
|
||||
const CoverageQuickStats({
|
||||
required this.stats,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The coverage statistics to display.
|
||||
final CoverageStats stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: CoverageStatCard(
|
||||
icon: UiIcons.success,
|
||||
label: context.t.client_coverage.stats.checked_in,
|
||||
value: stats.totalWorkersCheckedIn.toString(),
|
||||
color: UiColors.iconSuccess,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CoverageStatCard(
|
||||
icon: UiIcons.clock,
|
||||
label: context.t.client_coverage.stats.en_route,
|
||||
value: stats.totalWorkersEnRoute.toString(),
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/presentation/widgets/cancel_late_worker_sheet.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/shift_header.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/worker_row.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/worker_review_sheet.dart';
|
||||
|
||||
/// List of shifts with their workers.
|
||||
/// Displays a list of shifts as collapsible cards with worker details.
|
||||
///
|
||||
/// Displays all shifts for the selected date, or an empty state if none exist.
|
||||
class CoverageShiftList extends StatelessWidget {
|
||||
/// Each shift is rendered as a card with a tappable [ShiftHeader] that toggles
|
||||
/// visibility of the worker rows beneath it. All cards start expanded.
|
||||
/// Shows an empty state when [shifts] is empty.
|
||||
class CoverageShiftList extends StatefulWidget {
|
||||
/// Creates a [CoverageShiftList].
|
||||
const CoverageShiftList({
|
||||
required this.shifts,
|
||||
@@ -20,17 +24,73 @@ class CoverageShiftList extends StatelessWidget {
|
||||
/// The list of shifts to display.
|
||||
final List<ShiftWithWorkers> shifts;
|
||||
|
||||
@override
|
||||
State<CoverageShiftList> createState() => _CoverageShiftListState();
|
||||
}
|
||||
|
||||
/// State for [CoverageShiftList] managing which shift cards are expanded.
|
||||
class _CoverageShiftListState extends State<CoverageShiftList> {
|
||||
/// Set of shift IDs whose cards are currently expanded.
|
||||
final Set<String> _expandedShiftIds = <String>{};
|
||||
|
||||
/// Whether the expanded set has been initialised from the first build.
|
||||
bool _initialised = false;
|
||||
|
||||
/// Formats a [DateTime] to a readable time string (h:mm a).
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return '';
|
||||
return DateFormat('h:mm a').format(time);
|
||||
}
|
||||
|
||||
/// Toggles the expanded / collapsed state for the shift with [shiftId].
|
||||
void _toggleShift(String shiftId) {
|
||||
setState(() {
|
||||
if (_expandedShiftIds.contains(shiftId)) {
|
||||
_expandedShiftIds.remove(shiftId);
|
||||
} else {
|
||||
_expandedShiftIds.add(shiftId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Seeds [_expandedShiftIds] with all current shift IDs on first build,
|
||||
/// and adds any new shift IDs when the widget is rebuilt with new data.
|
||||
void _ensureInitialised() {
|
||||
if (!_initialised) {
|
||||
_expandedShiftIds.addAll(
|
||||
widget.shifts.map((ShiftWithWorkers s) => s.shiftId),
|
||||
);
|
||||
_initialised = true;
|
||||
return;
|
||||
}
|
||||
// Add any new shift IDs that arrived after initial build.
|
||||
for (final ShiftWithWorkers shift in widget.shifts) {
|
||||
if (!_expandedShiftIds.contains(shift.shiftId)) {
|
||||
_expandedShiftIds.add(shift.shiftId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant CoverageShiftList oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Add newly-appeared shift IDs so they start expanded.
|
||||
for (final ShiftWithWorkers shift in widget.shifts) {
|
||||
if (!oldWidget.shifts.any(
|
||||
(ShiftWithWorkers old) => old.shiftId == shift.shiftId,
|
||||
)) {
|
||||
_expandedShiftIds.add(shift.shiftId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_ensureInitialised();
|
||||
|
||||
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
|
||||
|
||||
if (shifts.isEmpty) {
|
||||
if (widget.shifts.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space8),
|
||||
width: double.infinity,
|
||||
@@ -57,66 +117,137 @@ class CoverageShiftList extends StatelessWidget {
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: shifts.map((ShiftWithWorkers shift) {
|
||||
children: widget.shifts.map((ShiftWithWorkers shift) {
|
||||
final int coveragePercent = shift.requiredWorkerCount > 0
|
||||
? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100)
|
||||
.round()
|
||||
: 0;
|
||||
|
||||
// Per-shift worker status counts.
|
||||
final int onSite = shift.assignedWorkers
|
||||
.where(
|
||||
(AssignedWorker w) => w.status == AssignmentStatus.checkedIn,
|
||||
)
|
||||
.length;
|
||||
final int enRoute = shift.assignedWorkers
|
||||
.where(
|
||||
(AssignedWorker w) =>
|
||||
w.status == AssignmentStatus.accepted && w.checkInAt == null,
|
||||
)
|
||||
.length;
|
||||
final int lateCount = shift.assignedWorkers
|
||||
.where(
|
||||
(AssignedWorker w) => w.status == AssignmentStatus.noShow,
|
||||
)
|
||||
.length;
|
||||
|
||||
final bool isExpanded = _expandedShiftIds.contains(shift.shiftId);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radius2xl,
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ShiftHeader(
|
||||
title: shift.roleName,
|
||||
location: '', // V2 API does not return location on coverage
|
||||
startTime: _formatTime(shift.timeRange.startsAt),
|
||||
current: shift.assignedWorkerCount,
|
||||
total: shift.requiredWorkerCount,
|
||||
coveragePercent: coveragePercent,
|
||||
shiftId: shift.shiftId,
|
||||
onSiteCount: onSite,
|
||||
enRouteCount: enRoute,
|
||||
lateCount: lateCount,
|
||||
isExpanded: isExpanded,
|
||||
onToggle: () => _toggleShift(shift.shiftId),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: const SizedBox.shrink(),
|
||||
secondChild: _buildWorkerSection(shift, l10n),
|
||||
crossFadeState: isExpanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
if (shift.assignedWorkers.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
child: Column(
|
||||
children: shift.assignedWorkers
|
||||
.map<Widget>((AssignedWorker worker) {
|
||||
final bool isLast =
|
||||
worker == shift.assignedWorkers.last;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isLast ? 0 : UiConstants.space2,
|
||||
),
|
||||
child: WorkerRow(
|
||||
worker: worker,
|
||||
shiftStartTime:
|
||||
_formatTime(shift.timeRange.startsAt),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Text(
|
||||
l10n.no_workers_assigned,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the expanded worker section for a shift including divider.
|
||||
Widget _buildWorkerSection(
|
||||
ShiftWithWorkers shift,
|
||||
TranslationsClientCoverageEn l10n,
|
||||
) {
|
||||
if (shift.assignedWorkers.isEmpty) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Text(
|
||||
l10n.no_workers_assigned,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
child: Column(
|
||||
children:
|
||||
shift.assignedWorkers.map<Widget>((AssignedWorker worker) {
|
||||
final bool isLast = worker == shift.assignedWorkers.last;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isLast ? 0 : UiConstants.space2,
|
||||
),
|
||||
child: WorkerRow(
|
||||
worker: worker,
|
||||
shiftStartTime: _formatTime(shift.timeRange.startsAt),
|
||||
showRateButton:
|
||||
worker.status == AssignmentStatus.checkedIn ||
|
||||
worker.status == AssignmentStatus.checkedOut ||
|
||||
worker.status == AssignmentStatus.completed,
|
||||
showCancelButton:
|
||||
DateTime.now().isAfter(shift.timeRange.startsAt) &&
|
||||
(worker.status == AssignmentStatus.noShow ||
|
||||
worker.status == AssignmentStatus.assigned ||
|
||||
worker.status == AssignmentStatus.accepted),
|
||||
onRate: () => WorkerReviewSheet.show(
|
||||
context,
|
||||
worker: worker,
|
||||
),
|
||||
onCancel: () => CancelLateWorkerSheet.show(
|
||||
context,
|
||||
worker: worker,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Stat card displaying an icon, value, and label with an accent color.
|
||||
class CoverageStatCard extends StatelessWidget {
|
||||
/// Creates a [CoverageStatCard].
|
||||
const CoverageStatCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The icon to display.
|
||||
final IconData icon;
|
||||
|
||||
/// The label text describing the stat.
|
||||
final String label;
|
||||
|
||||
/// The numeric value to display.
|
||||
final String value;
|
||||
|
||||
/// The accent color for the card border, icon, and text.
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(10),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space2,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: UiConstants.space6,
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: UiTypography.title1b.copyWith(
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,72 +2,176 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Displays coverage percentage and worker ratio in the app bar header.
|
||||
/// Displays overall coverage statistics in the SliverAppBar expanded header.
|
||||
///
|
||||
/// Shows the coverage percentage, a progress bar, and real-time worker
|
||||
/// status counts (on site, en route, late) on a primary blue gradient
|
||||
/// background with a semi-transparent white container.
|
||||
class CoverageStatsHeader extends StatelessWidget {
|
||||
/// Creates a [CoverageStatsHeader].
|
||||
/// Creates a [CoverageStatsHeader] with coverage and worker status data.
|
||||
const CoverageStatsHeader({
|
||||
required this.coveragePercent,
|
||||
required this.totalConfirmed,
|
||||
required this.totalNeeded,
|
||||
required this.totalCheckedIn,
|
||||
required this.totalEnRoute,
|
||||
required this.totalLate,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current coverage percentage.
|
||||
/// The current overall coverage percentage (0-100).
|
||||
final double coveragePercent;
|
||||
|
||||
/// The number of confirmed workers.
|
||||
final int totalConfirmed;
|
||||
|
||||
/// The total number of workers needed.
|
||||
/// The total number of workers needed for full coverage.
|
||||
final int totalNeeded;
|
||||
|
||||
/// The number of workers currently checked in and on site.
|
||||
final int totalCheckedIn;
|
||||
|
||||
/// The number of workers currently en route.
|
||||
final int totalEnRoute;
|
||||
|
||||
/// The number of workers currently marked as late.
|
||||
final int totalLate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withOpacity(0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.12),
|
||||
borderRadius: UiConstants.radiusXl,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.coverage_status,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${coveragePercent.toStringAsFixed(0)}%',
|
||||
style: UiTypography.display1b.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.workers,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$totalConfirmed/$totalNeeded',
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
Expanded(
|
||||
child: _buildCoverageColumn(context),
|
||||
),
|
||||
_buildStatusColumn(context),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
_buildProgressBar(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the left column with the "Overall Coverage" label and percentage.
|
||||
Widget _buildCoverageColumn(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.overall_coverage,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${coveragePercent.toStringAsFixed(0)}%',
|
||||
style: UiTypography.display1b.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the right column with on-site, en-route, and late stat items.
|
||||
Widget _buildStatusColumn(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
_buildStatRow(
|
||||
context: context,
|
||||
value: totalCheckedIn,
|
||||
label: context.t.client_coverage.stats.on_site,
|
||||
valueColor: UiColors.primaryForeground,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
_buildStatRow(
|
||||
context: context,
|
||||
value: totalEnRoute,
|
||||
label: context.t.client_coverage.stats.en_route,
|
||||
valueColor: UiColors.accent,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
_buildStatRow(
|
||||
context: context,
|
||||
value: totalLate,
|
||||
label: context.t.client_coverage.stats.late,
|
||||
valueColor: UiColors.tagError,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a single stat row with a colored number and a muted label.
|
||||
Widget _buildStatRow({
|
||||
required BuildContext context,
|
||||
required int value,
|
||||
required String label,
|
||||
required Color valueColor,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
value.toString(),
|
||||
style: UiTypography.title2b.copyWith(
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the horizontal progress bar indicating coverage fill.
|
||||
Widget _buildProgressBar() {
|
||||
final double clampedFraction =
|
||||
(coveragePercent / 100).clamp(0.0, 1.0);
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
child: SizedBox(
|
||||
height: 8,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: clampedFraction,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,38 +2,54 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Alert widget for displaying late workers warning.
|
||||
/// Alert banner displayed when workers are running late.
|
||||
///
|
||||
/// Shows a warning banner when workers are running late.
|
||||
/// Renders a solid red container with a warning icon, late worker count,
|
||||
/// and auto-backup status message in white text.
|
||||
class LateWorkersAlert extends StatelessWidget {
|
||||
/// Creates a [LateWorkersAlert].
|
||||
/// Creates a [LateWorkersAlert] with the given [lateCount].
|
||||
const LateWorkersAlert({
|
||||
required this.lateCount,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The number of late workers.
|
||||
/// The number of workers currently marked as late.
|
||||
final int lateCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.destructive.withValues(alpha: 0.1),
|
||||
color: UiColors.destructive,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: UiColors.destructive,
|
||||
width: 0.5,
|
||||
),
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.destructive.withValues(alpha: 0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.warning,
|
||||
color: UiColors.destructive,
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.warning,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -41,12 +57,14 @@ class LateWorkersAlert extends StatelessWidget {
|
||||
Text(
|
||||
context.t.client_coverage.alert
|
||||
.workers_running_late(n: lateCount, count: lateCount),
|
||||
style: UiTypography.body1b.textError,
|
||||
style: UiTypography.body1b.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.t.client_coverage.alert.auto_backup_searching,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.textError.withValues(alpha: 0.7),
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,124 +1,198 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart';
|
||||
|
||||
/// Header section for a shift card showing title, location, time, and coverage.
|
||||
/// Tappable header for a collapsible shift card.
|
||||
///
|
||||
/// Displays a status dot colour-coded by coverage, the shift title and time,
|
||||
/// a filled/total badge, a linear progress bar, and per-shift worker summary
|
||||
/// counts (on site, en route, late). Tapping anywhere triggers [onToggle].
|
||||
class ShiftHeader extends StatelessWidget {
|
||||
/// Creates a [ShiftHeader].
|
||||
const ShiftHeader({
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.startTime,
|
||||
required this.current,
|
||||
required this.total,
|
||||
required this.coveragePercent,
|
||||
required this.shiftId,
|
||||
required this.onSiteCount,
|
||||
required this.enRouteCount,
|
||||
required this.lateCount,
|
||||
required this.isExpanded,
|
||||
required this.onToggle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The shift title.
|
||||
/// The shift role or title.
|
||||
final String title;
|
||||
|
||||
/// The shift location.
|
||||
final String location;
|
||||
|
||||
/// The formatted shift start time.
|
||||
/// Formatted shift start time (e.g. "8:00 AM").
|
||||
final String startTime;
|
||||
|
||||
/// Current number of assigned workers.
|
||||
final int current;
|
||||
|
||||
/// Total workers needed for the shift.
|
||||
/// Total workers required for the shift.
|
||||
final int total;
|
||||
|
||||
/// Coverage percentage (0-100+).
|
||||
final int coveragePercent;
|
||||
|
||||
/// The shift identifier.
|
||||
/// Unique shift identifier.
|
||||
final String shiftId;
|
||||
|
||||
/// Number of workers currently on site (checked in).
|
||||
final int onSiteCount;
|
||||
|
||||
/// Number of workers en route (accepted but not checked in).
|
||||
final int enRouteCount;
|
||||
|
||||
/// Number of workers marked as late / no-show.
|
||||
final int lateCount;
|
||||
|
||||
/// Whether the shift card is currently expanded to show workers.
|
||||
final bool isExpanded;
|
||||
|
||||
/// Callback invoked when the header is tapped to expand or collapse.
|
||||
final VoidCallback onToggle;
|
||||
|
||||
/// Returns the status colour based on [coveragePercent].
|
||||
///
|
||||
/// Green for >= 100 %, yellow for >= 80 %, red otherwise.
|
||||
Color _statusColor() {
|
||||
if (coveragePercent >= 100) {
|
||||
return UiColors.textSuccess;
|
||||
} else if (coveragePercent >= 80) {
|
||||
return UiColors.textWarning;
|
||||
}
|
||||
return UiColors.destructive;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.muted,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: UiColors.border,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space2,
|
||||
final Color statusColor = _statusColor();
|
||||
final TranslationsClientCoverageStatsEn stats =
|
||||
context.t.client_coverage.stats;
|
||||
final double fillFraction =
|
||||
total > 0 ? (current / total).clamp(0.0, 1.0) : 0.0;
|
||||
|
||||
return InkWell(
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Row 1: status dot, title + time, badge, chevron.
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space2,
|
||||
height: UiConstants.space2,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
// Status dot.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
Text(
|
||||
startTime,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
// Title and start time.
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: 10,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
startTime,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Coverage badge.
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withAlpha(26),
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
child: Text(
|
||||
'$current/$total',
|
||||
style: UiTypography.body3b.copyWith(color: statusColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
// Expand / collapse chevron.
|
||||
Icon(
|
||||
isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown,
|
||||
size: 16,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CoverageBadge(
|
||||
current: current,
|
||||
total: total,
|
||||
coveragePercent: coveragePercent,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
// Progress bar.
|
||||
ClipRRect(
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
child: SizedBox(
|
||||
height: 8,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.muted,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: fillFraction,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
// Summary text: on site / en route / late.
|
||||
Text(
|
||||
'$onSiteCount ${stats.on_site} · '
|
||||
'$enRouteCount ${stats.en_route} · '
|
||||
'$lateCount ${stats.late}',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
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:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_state.dart';
|
||||
|
||||
/// Semantic color for the "favorite" toggle, representing a pink/heart accent.
|
||||
/// No matching token in [UiColors] — kept as a local constant intentionally.
|
||||
const Color _kFavoriteColor = Color(0xFFE91E63);
|
||||
|
||||
/// Bottom sheet for submitting a worker review with rating, feedback, and flags.
|
||||
class WorkerReviewSheet extends StatefulWidget {
|
||||
const WorkerReviewSheet({required this.worker, super.key});
|
||||
|
||||
final AssignedWorker worker;
|
||||
|
||||
static void show(BuildContext context, {required AssignedWorker worker}) {
|
||||
final CoverageBloc bloc = ReadContext(context).read<CoverageBloc>();
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(UiConstants.space4),
|
||||
),
|
||||
),
|
||||
builder: (_) => BlocProvider<CoverageBloc>.value(
|
||||
value: bloc,
|
||||
child: WorkerReviewSheet(worker: worker),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<WorkerReviewSheet> createState() => _WorkerReviewSheetState();
|
||||
}
|
||||
|
||||
class _WorkerReviewSheetState extends State<WorkerReviewSheet> {
|
||||
int _rating = 0;
|
||||
bool _isFavorite = false;
|
||||
bool _isBlocked = false;
|
||||
final Set<ReviewIssueFlag> _selectedFlags = <ReviewIssueFlag>{};
|
||||
final TextEditingController _feedbackController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_feedbackController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCoverageReviewEn l10n =
|
||||
context.t.client_coverage.review;
|
||||
|
||||
final List<String> ratingLabels = <String>[
|
||||
l10n.rating_labels.poor,
|
||||
l10n.rating_labels.fair,
|
||||
l10n.rating_labels.good,
|
||||
l10n.rating_labels.great,
|
||||
l10n.rating_labels.excellent,
|
||||
];
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
top: UiConstants.space3,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
_buildDragHandle(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildHeader(context, l10n),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
_buildStarRating(ratingLabels),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
_buildToggles(l10n),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
_buildIssueFlags(l10n),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
UiTextField(
|
||||
hintText: l10n.feedback_placeholder,
|
||||
maxLines: 3,
|
||||
controller: _feedbackController,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
BlocBuilder<CoverageBloc, CoverageState>(
|
||||
buildWhen: (CoverageState previous, CoverageState current) =>
|
||||
previous.writeStatus != current.writeStatus,
|
||||
builder: (BuildContext context, CoverageState state) {
|
||||
return UiButton.primary(
|
||||
text: l10n.submit,
|
||||
fullWidth: true,
|
||||
isLoading:
|
||||
state.writeStatus == CoverageWriteStatus.submitting,
|
||||
onPressed: _rating > 0 ? () => _onSubmit(context) : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDragHandle() {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.textDisabled,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(
|
||||
BuildContext context,
|
||||
TranslationsClientCoverageReviewEn l10n,
|
||||
) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(widget.worker.fullName, style: UiTypography.title1b),
|
||||
Text(l10n.title, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(UiIcons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStarRating(List<String> ratingLabels) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List<Widget>.generate(5, (int index) {
|
||||
final bool isFilled = index < _rating;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _rating = index + 1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space1,
|
||||
),
|
||||
child: Icon(
|
||||
UiIcons.star,
|
||||
size: UiConstants.space8,
|
||||
color: isFilled
|
||||
? UiColors.textWarning
|
||||
: UiColors.textDisabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (_rating > 0) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
ratingLabels[_rating - 1],
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToggles(TranslationsClientCoverageReviewEn l10n) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _buildToggleButton(
|
||||
icon: Icons.favorite,
|
||||
label: l10n.favorite_label,
|
||||
isActive: _isFavorite,
|
||||
activeColor: _kFavoriteColor,
|
||||
onTap: () => setState(() => _isFavorite = !_isFavorite),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: _buildToggleButton(
|
||||
icon: UiIcons.ban,
|
||||
label: l10n.block_label,
|
||||
isActive: _isBlocked,
|
||||
activeColor: UiColors.destructive,
|
||||
onTap: () => setState(() => _isBlocked = !_isBlocked),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToggleButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required bool isActive,
|
||||
required Color activeColor,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final Color bgColor =
|
||||
isActive ? activeColor.withAlpha(26) : UiColors.muted;
|
||||
final Color fgColor =
|
||||
isActive ? activeColor : UiColors.textDisabled;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: isActive ? Border.all(color: activeColor, width: 0.5) : null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(icon, size: UiConstants.space5, color: fgColor),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body2r.copyWith(color: fgColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIssueFlags(TranslationsClientCoverageReviewEn l10n) {
|
||||
final Map<ReviewIssueFlag, String> flagLabels =
|
||||
<ReviewIssueFlag, String>{
|
||||
ReviewIssueFlag.late: l10n.issue_flags.late,
|
||||
ReviewIssueFlag.uniform: l10n.issue_flags.uniform,
|
||||
ReviewIssueFlag.misconduct: l10n.issue_flags.misconduct,
|
||||
ReviewIssueFlag.noShow: l10n.issue_flags.no_show,
|
||||
ReviewIssueFlag.attitude: l10n.issue_flags.attitude,
|
||||
ReviewIssueFlag.performance: l10n.issue_flags.performance,
|
||||
ReviewIssueFlag.leftEarly: l10n.issue_flags.left_early,
|
||||
};
|
||||
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: ReviewIssueFlag.values.map((ReviewIssueFlag flag) {
|
||||
final bool isSelected = _selectedFlags.contains(flag);
|
||||
final String label = flagLabels[flag] ?? flag.value;
|
||||
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedFlags.add(flag);
|
||||
} else {
|
||||
_selectedFlags.remove(flag);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: UiColors.primary,
|
||||
labelStyle: isSelected
|
||||
? UiTypography.body3r.copyWith(color: UiColors.primaryForeground)
|
||||
: UiTypography.body3r.textSecondary,
|
||||
backgroundColor: UiColors.muted,
|
||||
checkmarkColor: UiColors.primaryForeground,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.space5),
|
||||
),
|
||||
side: isSelected
|
||||
? const BorderSide(color: UiColors.primary)
|
||||
: BorderSide.none,
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void _onSubmit(BuildContext context) {
|
||||
ReadContext(context).read<CoverageBloc>().add(
|
||||
CoverageSubmitReviewRequested(
|
||||
staffId: widget.worker.staffId,
|
||||
rating: _rating,
|
||||
assignmentId: widget.worker.assignmentId,
|
||||
feedback: _feedbackController.text.trim().isNotEmpty
|
||||
? _feedbackController.text.trim()
|
||||
: null,
|
||||
issueFlags: _selectedFlags.isNotEmpty
|
||||
? _selectedFlags
|
||||
.map((ReviewIssueFlag f) => f.value)
|
||||
.toList()
|
||||
: null,
|
||||
markAsFavorite: _isFavorite ? true : null,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ class WorkerRow extends StatelessWidget {
|
||||
const WorkerRow({
|
||||
required this.worker,
|
||||
required this.shiftStartTime,
|
||||
this.showRateButton = false,
|
||||
this.showCancelButton = false,
|
||||
this.onRate,
|
||||
this.onCancel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -19,6 +23,18 @@ class WorkerRow extends StatelessWidget {
|
||||
/// The formatted shift start time.
|
||||
final String shiftStartTime;
|
||||
|
||||
/// Whether to show the rate action button.
|
||||
final bool showRateButton;
|
||||
|
||||
/// Whether to show the cancel action button.
|
||||
final bool showCancelButton;
|
||||
|
||||
/// Callback invoked when the rate button is tapped.
|
||||
final VoidCallback? onRate;
|
||||
|
||||
/// Callback invoked when the cancel button is tapped.
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
/// Formats a [DateTime] to a readable time string (h:mm a).
|
||||
String _formatCheckInTime(DateTime? time) {
|
||||
if (time == null) return '';
|
||||
@@ -35,10 +51,6 @@ class WorkerRow extends StatelessWidget {
|
||||
Color textColor;
|
||||
IconData icon;
|
||||
String statusText;
|
||||
Color badgeBg;
|
||||
Color badgeText;
|
||||
Color badgeBorder;
|
||||
String badgeLabel;
|
||||
|
||||
switch (worker.status) {
|
||||
case AssignmentStatus.checkedIn:
|
||||
@@ -50,10 +62,6 @@ class WorkerRow extends StatelessWidget {
|
||||
statusText = l10n.status_checked_in_at(
|
||||
time: _formatCheckInTime(worker.checkInAt),
|
||||
);
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_on_site;
|
||||
case AssignmentStatus.accepted:
|
||||
if (worker.checkInAt == null) {
|
||||
bg = UiColors.textWarning.withAlpha(26);
|
||||
@@ -62,10 +70,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textWarning;
|
||||
icon = UiIcons.clock;
|
||||
statusText = l10n.status_en_route_expected(time: shiftStartTime);
|
||||
badgeBg = UiColors.textWarning.withAlpha(40);
|
||||
badgeText = UiColors.textWarning;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_en_route;
|
||||
} else {
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
@@ -73,10 +77,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_confirmed;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_confirmed;
|
||||
}
|
||||
case AssignmentStatus.noShow:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
@@ -85,10 +85,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = l10n.status_no_show;
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_no_show;
|
||||
case AssignmentStatus.checkedOut:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
@@ -96,10 +92,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_checked_out;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_done;
|
||||
case AssignmentStatus.completed:
|
||||
bg = UiColors.iconSuccess.withAlpha(26);
|
||||
border = UiColors.iconSuccess;
|
||||
@@ -107,10 +99,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_completed;
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_completed;
|
||||
case AssignmentStatus.assigned:
|
||||
case AssignmentStatus.swapRequested:
|
||||
case AssignmentStatus.cancelled:
|
||||
@@ -121,10 +109,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.clock;
|
||||
statusText = worker.status.value;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = worker.status.value;
|
||||
}
|
||||
|
||||
return Container(
|
||||
@@ -197,23 +181,25 @@ class WorkerRow extends StatelessWidget {
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: badgeBorder, width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: badgeText,
|
||||
if (showRateButton && onRate != null)
|
||||
GestureDetector(
|
||||
onTap: onRate,
|
||||
child: UiChip(
|
||||
label: l10n.actions.rate,
|
||||
size: UiChipSize.small,
|
||||
leadingIcon: UiIcons.star,
|
||||
),
|
||||
),
|
||||
if (showCancelButton && onCancel != null)
|
||||
GestureDetector(
|
||||
onTap: onCancel,
|
||||
child: UiChip(
|
||||
label: l10n.actions.cancel,
|
||||
size: UiChipSize.small,
|
||||
leadingIcon: UiIcons.close,
|
||||
variant: UiChipVariant.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'client_main_state.dart';
|
||||
import 'package:client_main/src/presentation/blocs/client_main_state.dart';
|
||||
|
||||
class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
|
||||
/// Cubit that manages the client app's main navigation state.
|
||||
///
|
||||
/// Tracks the active bottom bar tab and controls tab visibility
|
||||
/// based on the current route.
|
||||
class ClientMainCubit extends Cubit<ClientMainState>
|
||||
with BlocErrorHandler<ClientMainState>
|
||||
implements Disposable {
|
||||
/// Creates a [ClientMainCubit] and starts listening for route changes.
|
||||
ClientMainCubit() : super(const ClientMainState()) {
|
||||
Modular.to.addListener(_onRouteChanged);
|
||||
_onRouteChanged();
|
||||
}
|
||||
|
||||
/// Routes that should hide the bottom navigation bar.
|
||||
static const List<String> _hideBottomBarPaths = <String>[
|
||||
ClientPaths.completionReview,
|
||||
ClientPaths.awaitingApproval,
|
||||
];
|
||||
|
||||
/// Updates state when the current route changes.
|
||||
///
|
||||
/// Detects the active tab from the route path and determines
|
||||
/// whether the bottom bar should be visible.
|
||||
void _onRouteChanged() {
|
||||
if (isClosed) return;
|
||||
|
||||
final String path = Modular.to.path;
|
||||
int newIndex = state.currentIndex;
|
||||
|
||||
@@ -41,6 +55,9 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigates to the tab at [index] via Modular safe navigation.
|
||||
///
|
||||
/// State update happens automatically via [_onRouteChanged].
|
||||
void navigateToTab(int index) {
|
||||
if (index == state.currentIndex) return;
|
||||
|
||||
@@ -61,7 +78,6 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
|
||||
Modular.to.toClientReports();
|
||||
break;
|
||||
}
|
||||
// State update will happen via _onRouteChanged
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// State for [ClientMainCubit] representing bottom navigation status.
|
||||
class ClientMainState extends Equatable {
|
||||
/// Creates a [ClientMainState] with the given tab index and bar visibility.
|
||||
const ClientMainState({
|
||||
this.currentIndex = 2, // Default to Home
|
||||
this.showBottomBar = true,
|
||||
});
|
||||
|
||||
/// Index of the currently active bottom navigation tab.
|
||||
final int currentIndex;
|
||||
|
||||
/// Whether the bottom navigation bar should be visible.
|
||||
final bool showBottomBar;
|
||||
|
||||
/// Creates a copy of this state with updated fields.
|
||||
ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) {
|
||||
return ClientMainState(
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
|
||||
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
|
||||
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
|
||||
import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Widget that displays the home dashboard in edit mode with drag-and-drop support.
|
||||
///
|
||||
/// Allows users to reorder and rearrange dashboard widgets.
|
||||
class ClientHomeEditModeBody extends StatelessWidget {
|
||||
/// Creates a [ClientHomeEditModeBody].
|
||||
const ClientHomeEditModeBody({required this.state, super.key});
|
||||
|
||||
/// The current home state.
|
||||
final ClientHomeState state;
|
||||
|
||||
/// Creates a [ClientHomeEditModeBody].
|
||||
const ClientHomeEditModeBody({
|
||||
required this.state,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReorderableListView(
|
||||
@@ -30,18 +26,15 @@ class ClientHomeEditModeBody extends StatelessWidget {
|
||||
100,
|
||||
),
|
||||
onReorder: (int oldIndex, int newIndex) {
|
||||
BlocProvider.of<ClientHomeBloc>(context)
|
||||
.add(ClientHomeWidgetReordered(oldIndex, newIndex));
|
||||
BlocProvider.of<ClientHomeBloc>(
|
||||
context,
|
||||
).add(ClientHomeWidgetReordered(oldIndex, newIndex));
|
||||
},
|
||||
children: state.widgetOrder.map((String id) {
|
||||
return Container(
|
||||
key: ValueKey<String>(id),
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: DashboardWidgetBuilder(
|
||||
id: id,
|
||||
state: state,
|
||||
isEditMode: true,
|
||||
),
|
||||
child: DashboardWidgetBuilder(id: id, state: state, isEditMode: true),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
@@ -10,9 +10,9 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
return const UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
@@ -23,11 +23,11 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const UiShimmerCircle(size: UiConstants.space10),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
UiShimmerCircle(size: UiConstants.space10),
|
||||
SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 80, height: 12),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 120, height: 16),
|
||||
@@ -37,7 +37,7 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
|
||||
),
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: const <Widget>[
|
||||
children: <Widget>[
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
],
|
||||
|
||||
@@ -10,9 +10,9 @@ class ReorderSectionSkeleton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
SizedBox(
|
||||
|
||||
@@ -20,7 +20,8 @@ dependencies:
|
||||
path: ../../../design_system
|
||||
core_localization:
|
||||
path: ../../../core_localization
|
||||
krow_domain: ^0.0.1
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
|
||||
@@ -38,7 +39,7 @@ class EditHubPage extends StatelessWidget {
|
||||
message: message,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Modular.to.pop(true);
|
||||
Modular.to.popSafe(true);
|
||||
}
|
||||
if (state.status == EditHubStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
@@ -65,7 +66,7 @@ class EditHubPage extends StatelessWidget {
|
||||
child: HubForm(
|
||||
hub: hub,
|
||||
costCenters: state.costCenters,
|
||||
onCancel: () => Modular.to.pop(),
|
||||
onCancel: () => Modular.to.popSafe(),
|
||||
onSave: ({
|
||||
required String name,
|
||||
required String fullAddress,
|
||||
|
||||
@@ -38,7 +38,7 @@ class HubDetailsPage extends StatelessWidget {
|
||||
message: message,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Modular.to.pop(true); // Return true to indicate change
|
||||
Modular.to.popSafe(true); // Return true to indicate change
|
||||
}
|
||||
if (state.status == HubDetailsStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
@@ -117,7 +117,7 @@ class HubDetailsPage extends StatelessWidget {
|
||||
Future<void> _navigateToEditPage(BuildContext context) async {
|
||||
final bool? saved = await Modular.to.toEditHub(hub: hub);
|
||||
if (saved == true && context.mounted) {
|
||||
Modular.to.pop(true); // Return true to indicate change
|
||||
Modular.to.popSafe(true); // Return true to indicate change
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ class _HubFormState extends State<HubForm> {
|
||||
vertical: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFD),
|
||||
color: UiColors.muted,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase * 1.5,
|
||||
),
|
||||
@@ -225,7 +225,7 @@ class _HubFormState extends State<HubForm> {
|
||||
color: UiColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8FAFD),
|
||||
fillColor: UiColors.muted,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: 16,
|
||||
|
||||
@@ -13,7 +13,7 @@ class HubsPageSkeleton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Column(
|
||||
children: List.generate(5, (int index) {
|
||||
children: List<Widget>.generate(5, (int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: Container(
|
||||
@@ -23,7 +23,7 @@ class HubsPageSkeleton extends StatelessWidget {
|
||||
),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
// Leading icon placeholder
|
||||
UiShimmerBox(
|
||||
width: 52,
|
||||
@@ -35,7 +35,7 @@ class HubsPageSkeleton extends StatelessWidget {
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 160, height: 16),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 200, height: 12),
|
||||
|
||||
@@ -11,7 +11,11 @@ import 'domain/usecases/create_one_time_order_usecase.dart';
|
||||
import 'domain/usecases/create_permanent_order_usecase.dart';
|
||||
import 'domain/usecases/create_rapid_order_usecase.dart';
|
||||
import 'domain/usecases/create_recurring_order_usecase.dart';
|
||||
import 'domain/usecases/get_hubs_usecase.dart';
|
||||
import 'domain/usecases/get_managers_by_hub_usecase.dart';
|
||||
import 'domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'domain/usecases/get_roles_by_vendor_usecase.dart';
|
||||
import 'domain/usecases/get_vendors_usecase.dart';
|
||||
import 'domain/usecases/parse_rapid_order_usecase.dart';
|
||||
import 'domain/usecases/transcribe_rapid_order_usecase.dart';
|
||||
import 'presentation/blocs/index.dart';
|
||||
@@ -46,7 +50,7 @@ class ClientCreateOrderModule extends Module {
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
// Command UseCases (order creation)
|
||||
i.addLazySingleton(CreateOneTimeOrderUseCase.new);
|
||||
i.addLazySingleton(CreatePermanentOrderUseCase.new);
|
||||
i.addLazySingleton(CreateRecurringOrderUseCase.new);
|
||||
@@ -55,6 +59,12 @@ class ClientCreateOrderModule extends Module {
|
||||
i.addLazySingleton(ParseRapidOrderTextToOrderUseCase.new);
|
||||
i.addLazySingleton(GetOrderDetailsForReorderUseCase.new);
|
||||
|
||||
// Query UseCases (reference data loading)
|
||||
i.addLazySingleton(GetVendorsUseCase.new);
|
||||
i.addLazySingleton(GetRolesByVendorUseCase.new);
|
||||
i.addLazySingleton(GetHubsUseCase.new);
|
||||
i.addLazySingleton(GetManagersByHubUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.add<RapidOrderBloc>(
|
||||
() => RapidOrderBloc(
|
||||
@@ -63,15 +73,36 @@ class ClientCreateOrderModule extends Module {
|
||||
i.get<AudioRecorderService>(),
|
||||
),
|
||||
);
|
||||
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new);
|
||||
i.add<OneTimeOrderBloc>(
|
||||
() => OneTimeOrderBloc(
|
||||
i.get<CreateOneTimeOrderUseCase>(),
|
||||
i.get<GetOrderDetailsForReorderUseCase>(),
|
||||
i.get<GetVendorsUseCase>(),
|
||||
i.get<GetRolesByVendorUseCase>(),
|
||||
i.get<GetHubsUseCase>(),
|
||||
i.get<GetManagersByHubUseCase>(),
|
||||
),
|
||||
);
|
||||
i.add<PermanentOrderBloc>(
|
||||
() => PermanentOrderBloc(
|
||||
i.get<CreatePermanentOrderUseCase>(),
|
||||
i.get<GetOrderDetailsForReorderUseCase>(),
|
||||
i.get<ClientOrderQueryRepositoryInterface>(),
|
||||
i.get<GetVendorsUseCase>(),
|
||||
i.get<GetRolesByVendorUseCase>(),
|
||||
i.get<GetHubsUseCase>(),
|
||||
i.get<GetManagersByHubUseCase>(),
|
||||
),
|
||||
);
|
||||
i.add<RecurringOrderBloc>(
|
||||
() => RecurringOrderBloc(
|
||||
i.get<CreateRecurringOrderUseCase>(),
|
||||
i.get<GetOrderDetailsForReorderUseCase>(),
|
||||
i.get<GetVendorsUseCase>(),
|
||||
i.get<GetRolesByVendorUseCase>(),
|
||||
i.get<GetHubsUseCase>(),
|
||||
i.get<GetManagersByHubUseCase>(),
|
||||
),
|
||||
);
|
||||
i.add<RecurringOrderBloc>(RecurringOrderBloc.new);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,15 +1,69 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for the [CreateOneTimeOrderUseCase].
|
||||
///
|
||||
/// Wraps the V2 API payload map for a one-time order.
|
||||
class OneTimeOrderArguments extends UseCaseArgument {
|
||||
/// Creates a [OneTimeOrderArguments] with the given [payload].
|
||||
const OneTimeOrderArguments({required this.payload});
|
||||
/// A single position entry for a one-time order submission.
|
||||
class OneTimeOrderPositionArgument extends UseCaseArgument {
|
||||
/// Creates a [OneTimeOrderPositionArgument].
|
||||
const OneTimeOrderPositionArgument({
|
||||
required this.roleId,
|
||||
required this.workerCount,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.roleName,
|
||||
this.lunchBreak,
|
||||
});
|
||||
|
||||
/// The V2 API payload map.
|
||||
final Map<String, dynamic> payload;
|
||||
/// The role ID for this position.
|
||||
final String roleId;
|
||||
|
||||
/// Human-readable role name, if available.
|
||||
final String? roleName;
|
||||
|
||||
/// Number of workers needed for this position.
|
||||
final int workerCount;
|
||||
|
||||
/// Shift start time in HH:mm format.
|
||||
final String startTime;
|
||||
|
||||
/// Shift end time in HH:mm format.
|
||||
final String endTime;
|
||||
|
||||
/// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set.
|
||||
final String? lunchBreak;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[payload];
|
||||
List<Object?> get props =>
|
||||
<Object?>[roleId, roleName, workerCount, startTime, endTime, lunchBreak];
|
||||
}
|
||||
|
||||
/// Typed arguments for [CreateOneTimeOrderUseCase].
|
||||
///
|
||||
/// Carries structured form data so the use case can build the V2 API payload.
|
||||
class OneTimeOrderArguments extends UseCaseArgument {
|
||||
/// Creates a [OneTimeOrderArguments] with the given structured fields.
|
||||
const OneTimeOrderArguments({
|
||||
required this.hubId,
|
||||
required this.eventName,
|
||||
required this.orderDate,
|
||||
required this.positions,
|
||||
this.vendorId,
|
||||
});
|
||||
|
||||
/// The selected hub ID.
|
||||
final String hubId;
|
||||
|
||||
/// The order event name / title.
|
||||
final String eventName;
|
||||
|
||||
/// The order date.
|
||||
final DateTime orderDate;
|
||||
|
||||
/// The list of position entries.
|
||||
final List<OneTimeOrderPositionArgument> positions;
|
||||
|
||||
/// The selected vendor ID, if applicable.
|
||||
final String? vendorId;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
<Object?>[hubId, eventName, orderDate, positions, vendorId];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,75 @@
|
||||
/// Arguments for the [CreatePermanentOrderUseCase].
|
||||
///
|
||||
/// Wraps the V2 API payload map for a permanent order.
|
||||
class PermanentOrderArguments {
|
||||
/// Creates a [PermanentOrderArguments] with the given [payload].
|
||||
const PermanentOrderArguments({required this.payload});
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// The V2 API payload map.
|
||||
final Map<String, dynamic> payload;
|
||||
/// A single position entry for a permanent order submission.
|
||||
class PermanentOrderPositionArgument extends UseCaseArgument {
|
||||
/// Creates a [PermanentOrderPositionArgument].
|
||||
const PermanentOrderPositionArgument({
|
||||
required this.roleId,
|
||||
required this.workerCount,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.roleName,
|
||||
});
|
||||
|
||||
/// The role ID for this position.
|
||||
final String roleId;
|
||||
|
||||
/// Human-readable role name, if available.
|
||||
final String? roleName;
|
||||
|
||||
/// Number of workers needed for this position.
|
||||
final int workerCount;
|
||||
|
||||
/// Shift start time in HH:mm format.
|
||||
final String startTime;
|
||||
|
||||
/// Shift end time in HH:mm format.
|
||||
final String endTime;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
<Object?>[roleId, roleName, workerCount, startTime, endTime];
|
||||
}
|
||||
|
||||
/// Typed arguments for [CreatePermanentOrderUseCase].
|
||||
///
|
||||
/// Carries structured form data so the use case can build the V2 API payload.
|
||||
class PermanentOrderArguments extends UseCaseArgument {
|
||||
/// Creates a [PermanentOrderArguments] with the given structured fields.
|
||||
const PermanentOrderArguments({
|
||||
required this.hubId,
|
||||
required this.eventName,
|
||||
required this.startDate,
|
||||
required this.daysOfWeek,
|
||||
required this.positions,
|
||||
this.vendorId,
|
||||
});
|
||||
|
||||
/// The selected hub ID.
|
||||
final String hubId;
|
||||
|
||||
/// The order event name / title.
|
||||
final String eventName;
|
||||
|
||||
/// The start date of the permanent order.
|
||||
final DateTime startDate;
|
||||
|
||||
/// Day-of-week labels (e.g. `['MON', 'WED', 'FRI']`).
|
||||
final List<String> daysOfWeek;
|
||||
|
||||
/// The list of position entries.
|
||||
final List<PermanentOrderPositionArgument> positions;
|
||||
|
||||
/// The selected vendor ID, if applicable.
|
||||
final String? vendorId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
hubId,
|
||||
eventName,
|
||||
startDate,
|
||||
daysOfWeek,
|
||||
positions,
|
||||
vendorId,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,80 @@
|
||||
/// Arguments for the [CreateRecurringOrderUseCase].
|
||||
///
|
||||
/// Wraps the V2 API payload map for a recurring order.
|
||||
class RecurringOrderArguments {
|
||||
/// Creates a [RecurringOrderArguments] with the given [payload].
|
||||
const RecurringOrderArguments({required this.payload});
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// The V2 API payload map.
|
||||
final Map<String, dynamic> payload;
|
||||
/// A single position entry for a recurring order submission.
|
||||
class RecurringOrderPositionArgument extends UseCaseArgument {
|
||||
/// Creates a [RecurringOrderPositionArgument].
|
||||
const RecurringOrderPositionArgument({
|
||||
required this.roleId,
|
||||
required this.workerCount,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.roleName,
|
||||
});
|
||||
|
||||
/// The role ID for this position.
|
||||
final String roleId;
|
||||
|
||||
/// Human-readable role name, if available.
|
||||
final String? roleName;
|
||||
|
||||
/// Number of workers needed for this position.
|
||||
final int workerCount;
|
||||
|
||||
/// Shift start time in HH:mm format.
|
||||
final String startTime;
|
||||
|
||||
/// Shift end time in HH:mm format.
|
||||
final String endTime;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
<Object?>[roleId, roleName, workerCount, startTime, endTime];
|
||||
}
|
||||
|
||||
/// Typed arguments for [CreateRecurringOrderUseCase].
|
||||
///
|
||||
/// Carries structured form data so the use case can build the V2 API payload.
|
||||
class RecurringOrderArguments extends UseCaseArgument {
|
||||
/// Creates a [RecurringOrderArguments] with the given structured fields.
|
||||
const RecurringOrderArguments({
|
||||
required this.hubId,
|
||||
required this.eventName,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.recurringDays,
|
||||
required this.positions,
|
||||
this.vendorId,
|
||||
});
|
||||
|
||||
/// The selected hub ID.
|
||||
final String hubId;
|
||||
|
||||
/// The order event name / title.
|
||||
final String eventName;
|
||||
|
||||
/// The start date of the recurring order period.
|
||||
final DateTime startDate;
|
||||
|
||||
/// The end date of the recurring order period.
|
||||
final DateTime endDate;
|
||||
|
||||
/// Day-of-week labels (e.g. `['MON', 'WED', 'FRI']`).
|
||||
final List<String> recurringDays;
|
||||
|
||||
/// The list of position entries.
|
||||
final List<RecurringOrderPositionArgument> positions;
|
||||
|
||||
/// The selected vendor ID, if applicable.
|
||||
final String? vendorId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
hubId,
|
||||
eventName,
|
||||
startDate,
|
||||
endDate,
|
||||
recurringDays,
|
||||
positions,
|
||||
vendorId,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,16 +5,45 @@ import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Use case for creating a one-time staffing order.
|
||||
///
|
||||
/// Delegates the V2 API payload to the repository.
|
||||
/// Builds the V2 API payload from typed [OneTimeOrderArguments] and
|
||||
/// delegates submission to the repository. Payload construction (date
|
||||
/// formatting, position mapping, break-minutes conversion) is business
|
||||
/// logic that belongs here, not in the BLoC.
|
||||
class CreateOneTimeOrderUseCase
|
||||
implements UseCase<OneTimeOrderArguments, void> {
|
||||
/// Creates a [CreateOneTimeOrderUseCase].
|
||||
const CreateOneTimeOrderUseCase(this._repository);
|
||||
|
||||
/// The create-order repository.
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(OneTimeOrderArguments input) {
|
||||
return _repository.createOneTimeOrder(input.payload);
|
||||
final String orderDate = formatDateToIso(input.orderDate);
|
||||
|
||||
final List<Map<String, dynamic>> positions =
|
||||
input.positions.map((OneTimeOrderPositionArgument p) {
|
||||
return <String, dynamic>{
|
||||
if (p.roleName != null) 'roleName': p.roleName,
|
||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||
'workerCount': p.workerCount,
|
||||
'startTime': p.startTime,
|
||||
'endTime': p.endTime,
|
||||
if (p.lunchBreak != null &&
|
||||
p.lunchBreak != 'NO_BREAK' &&
|
||||
p.lunchBreak!.isNotEmpty)
|
||||
'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final Map<String, dynamic> payload = <String, dynamic>{
|
||||
'hubId': input.hubId,
|
||||
'eventName': input.eventName,
|
||||
'orderDate': orderDate,
|
||||
'positions': positions,
|
||||
if (input.vendorId != null) 'vendorId': input.vendorId,
|
||||
};
|
||||
|
||||
return _repository.createOneTimeOrder(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,61 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../arguments/permanent_order_arguments.dart';
|
||||
import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||
const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Use case for creating a permanent staffing order.
|
||||
///
|
||||
/// Delegates the V2 API payload to the repository.
|
||||
class CreatePermanentOrderUseCase {
|
||||
/// Builds the V2 API payload from typed [PermanentOrderArguments] and
|
||||
/// delegates submission to the repository. Payload construction (date
|
||||
/// formatting, day-of-week mapping, position mapping) is business
|
||||
/// logic that belongs here, not in the BLoC.
|
||||
class CreatePermanentOrderUseCase
|
||||
implements UseCase<PermanentOrderArguments, void> {
|
||||
/// Creates a [CreatePermanentOrderUseCase].
|
||||
const CreatePermanentOrderUseCase(this._repository);
|
||||
|
||||
/// The create-order repository.
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
/// Executes the use case with the given [args].
|
||||
Future<void> call(PermanentOrderArguments args) {
|
||||
return _repository.createPermanentOrder(args.payload);
|
||||
@override
|
||||
Future<void> call(PermanentOrderArguments input) {
|
||||
final String startDate = formatDateToIso(input.startDate);
|
||||
|
||||
final List<int> daysOfWeek = input.daysOfWeek
|
||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||
.toList();
|
||||
|
||||
final List<Map<String, dynamic>> positions =
|
||||
input.positions.map((PermanentOrderPositionArgument p) {
|
||||
return <String, dynamic>{
|
||||
if (p.roleName != null) 'roleName': p.roleName,
|
||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||
'workerCount': p.workerCount,
|
||||
'startTime': p.startTime,
|
||||
'endTime': p.endTime,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final Map<String, dynamic> payload = <String, dynamic>{
|
||||
'hubId': input.hubId,
|
||||
'eventName': input.eventName,
|
||||
'startDate': startDate,
|
||||
'daysOfWeek': daysOfWeek,
|
||||
'positions': positions,
|
||||
if (input.vendorId != null) 'vendorId': input.vendorId,
|
||||
};
|
||||
|
||||
return _repository.createPermanentOrder(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../arguments/recurring_order_arguments.dart';
|
||||
import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||
const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Use case for creating a recurring staffing order.
|
||||
///
|
||||
/// Delegates the V2 API payload to the repository.
|
||||
class CreateRecurringOrderUseCase {
|
||||
/// Builds the V2 API payload from typed [RecurringOrderArguments] and
|
||||
/// delegates submission to the repository. Payload construction (date
|
||||
/// formatting, recurrence-day mapping, position mapping) is business
|
||||
/// logic that belongs here, not in the BLoC.
|
||||
class CreateRecurringOrderUseCase
|
||||
implements UseCase<RecurringOrderArguments, void> {
|
||||
/// Creates a [CreateRecurringOrderUseCase].
|
||||
const CreateRecurringOrderUseCase(this._repository);
|
||||
|
||||
/// The create-order repository.
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
/// Executes the use case with the given [args].
|
||||
Future<void> call(RecurringOrderArguments args) {
|
||||
return _repository.createRecurringOrder(args.payload);
|
||||
@override
|
||||
Future<void> call(RecurringOrderArguments input) {
|
||||
final String startDate = formatDateToIso(input.startDate);
|
||||
final String endDate = formatDateToIso(input.endDate);
|
||||
|
||||
final List<int> recurrenceDays = input.recurringDays
|
||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||
.toList();
|
||||
|
||||
final List<Map<String, dynamic>> positions =
|
||||
input.positions.map((RecurringOrderPositionArgument p) {
|
||||
return <String, dynamic>{
|
||||
if (p.roleName != null) 'roleName': p.roleName,
|
||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||
'workerCount': p.workerCount,
|
||||
'startTime': p.startTime,
|
||||
'endTime': p.endTime,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final Map<String, dynamic> payload = <String, dynamic>{
|
||||
'hubId': input.hubId,
|
||||
'eventName': input.eventName,
|
||||
'startDate': startDate,
|
||||
'endDate': endDate,
|
||||
'recurrenceDays': recurrenceDays,
|
||||
'positions': positions,
|
||||
if (input.vendorId != null) 'vendorId': input.vendorId,
|
||||
};
|
||||
|
||||
return _repository.createRecurringOrder(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../models/order_hub.dart';
|
||||
import '../repositories/client_order_query_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching team hubs for the current business.
|
||||
///
|
||||
/// Returns the list of [OrderHub] instances available for order assignment.
|
||||
class GetHubsUseCase implements NoInputUseCase<List<OrderHub>> {
|
||||
/// Creates a [GetHubsUseCase].
|
||||
const GetHubsUseCase(this._repository);
|
||||
|
||||
/// The query repository for order reference data.
|
||||
final ClientOrderQueryRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<OrderHub>> call() {
|
||||
return _repository.getHubs();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../models/order_manager.dart';
|
||||
import '../repositories/client_order_query_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching managers assigned to a specific hub.
|
||||
///
|
||||
/// Takes a hub ID and returns the list of [OrderManager] instances
|
||||
/// for that hub.
|
||||
class GetManagersByHubUseCase implements UseCase<String, List<OrderManager>> {
|
||||
/// Creates a [GetManagersByHubUseCase].
|
||||
const GetManagersByHubUseCase(this._repository);
|
||||
|
||||
/// The query repository for order reference data.
|
||||
final ClientOrderQueryRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<OrderManager>> call(String hubId) {
|
||||
return _repository.getManagersByHub(hubId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../models/order_role.dart';
|
||||
import '../repositories/client_order_query_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching roles offered by a specific vendor.
|
||||
///
|
||||
/// Takes a vendor ID and returns the list of [OrderRole] instances
|
||||
/// available from that vendor.
|
||||
class GetRolesByVendorUseCase implements UseCase<String, List<OrderRole>> {
|
||||
/// Creates a [GetRolesByVendorUseCase].
|
||||
const GetRolesByVendorUseCase(this._repository);
|
||||
|
||||
/// The query repository for order reference data.
|
||||
final ClientOrderQueryRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<OrderRole>> call(String vendorId) {
|
||||
return _repository.getRolesByVendor(vendorId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../repositories/client_order_query_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching the list of available vendors.
|
||||
///
|
||||
/// Wraps the query repository to enforce the use-case boundary between
|
||||
/// presentation and data layers.
|
||||
class GetVendorsUseCase implements NoInputUseCase<List<Vendor>> {
|
||||
/// Creates a [GetVendorsUseCase].
|
||||
const GetVendorsUseCase(this._repository);
|
||||
|
||||
/// The query repository for order reference data.
|
||||
final ClientOrderQueryRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<Vendor>> call() {
|
||||
return _repository.getVendors();
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import 'package:client_create_order/src/domain/arguments/one_time_order_argument
|
||||
import 'package:client_create_order/src/domain/models/order_hub.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_manager.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_role.dart';
|
||||
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/create_one_time_order_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
@@ -14,16 +17,20 @@ import 'one_time_order_state.dart';
|
||||
|
||||
/// BLoC for managing the multi-step one-time order creation form.
|
||||
///
|
||||
/// Builds V2 API payloads and uses [OrderPreview] for reorder.
|
||||
/// Delegates all data fetching to query use cases and order submission
|
||||
/// to [CreateOneTimeOrderUseCase]. Uses [OrderPreview] for reorder.
|
||||
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
with
|
||||
BlocErrorHandler<OneTimeOrderState>,
|
||||
SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
|
||||
/// Creates the BLoC with required dependencies.
|
||||
/// Creates the BLoC with required use case dependencies.
|
||||
OneTimeOrderBloc(
|
||||
this._createOneTimeOrderUseCase,
|
||||
this._getOrderDetailsForReorderUseCase,
|
||||
this._queryRepository,
|
||||
this._getVendorsUseCase,
|
||||
this._getRolesByVendorUseCase,
|
||||
this._getHubsUseCase,
|
||||
this._getManagersByHubUseCase,
|
||||
) : super(OneTimeOrderState.initial()) {
|
||||
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<OneTimeOrderVendorChanged>(_onVendorChanged);
|
||||
@@ -45,16 +52,21 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
|
||||
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
|
||||
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
|
||||
final ClientOrderQueryRepositoryInterface _queryRepository;
|
||||
final GetVendorsUseCase _getVendorsUseCase;
|
||||
final GetRolesByVendorUseCase _getRolesByVendorUseCase;
|
||||
final GetHubsUseCase _getHubsUseCase;
|
||||
final GetManagersByHubUseCase _getManagersByHubUseCase;
|
||||
|
||||
/// Loads available vendors via the use case.
|
||||
Future<void> _loadVendors() async {
|
||||
final List<Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () => _queryRepository.getVendors(),
|
||||
action: () => _getVendorsUseCase(),
|
||||
onError: (_) => add(const OneTimeOrderVendorsLoaded(<Vendor>[])),
|
||||
);
|
||||
if (vendors != null) add(OneTimeOrderVendorsLoaded(vendors));
|
||||
}
|
||||
|
||||
/// Loads roles for [vendorId] and maps them to presentation option models.
|
||||
Future<void> _loadRolesForVendor(
|
||||
String vendorId,
|
||||
Emitter<OneTimeOrderState> emit,
|
||||
@@ -62,7 +74,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final List<OrderRole> result =
|
||||
await _queryRepository.getRolesByVendor(vendorId);
|
||||
await _getRolesByVendorUseCase(vendorId);
|
||||
return result
|
||||
.map((OrderRole r) => OneTimeOrderRoleOption(
|
||||
id: r.id, name: r.name, costPerHour: r.costPerHour))
|
||||
@@ -74,10 +86,11 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
if (roles != null) emit(state.copyWith(roles: roles));
|
||||
}
|
||||
|
||||
/// Loads hubs via the use case and maps to presentation option models.
|
||||
Future<void> _loadHubs() async {
|
||||
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final List<OrderHub> result = await _queryRepository.getHubs();
|
||||
final List<OrderHub> result = await _getHubsUseCase();
|
||||
return result
|
||||
.map((OrderHub h) => OneTimeOrderHubOption(
|
||||
id: h.id,
|
||||
@@ -100,12 +113,13 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
if (hubs != null) add(OneTimeOrderHubsLoaded(hubs));
|
||||
}
|
||||
|
||||
/// Loads managers for [hubId] via the use case.
|
||||
Future<void> _loadManagersForHub(String hubId) async {
|
||||
final List<OneTimeOrderManagerOption>? managers =
|
||||
await handleErrorWithResult(
|
||||
action: () async {
|
||||
final List<OrderManager> result =
|
||||
await _queryRepository.getManagersByHub(hubId);
|
||||
await _getManagersByHubUseCase(hubId);
|
||||
return result
|
||||
.map((OrderManager m) =>
|
||||
OneTimeOrderManagerOption(id: m.id, name: m.name))
|
||||
@@ -224,7 +238,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
emit(state.copyWith(positions: newPositions));
|
||||
}
|
||||
|
||||
/// Builds a V2 API payload and submits the one-time order.
|
||||
/// Builds typed arguments from form state and submits via the use case.
|
||||
Future<void> _onSubmitted(
|
||||
OneTimeOrderSubmitted event,
|
||||
Emitter<OneTimeOrderState> emit,
|
||||
@@ -236,12 +250,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
final OneTimeOrderHubOption? selectedHub = state.selectedHub;
|
||||
if (selectedHub == null) throw const OrderMissingHubException();
|
||||
|
||||
final String orderDate =
|
||||
'${state.date.year.toString().padLeft(4, '0')}-'
|
||||
'${state.date.month.toString().padLeft(2, '0')}-'
|
||||
'${state.date.day.toString().padLeft(2, '0')}';
|
||||
|
||||
final List<Map<String, dynamic>> positions =
|
||||
final List<OneTimeOrderPositionArgument> positionArgs =
|
||||
state.positions.map((OneTimeOrderPosition p) {
|
||||
final OneTimeOrderRoleOption? role = state.roles
|
||||
.cast<OneTimeOrderRoleOption?>()
|
||||
@@ -249,28 +258,24 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
(OneTimeOrderRoleOption? r) => r != null && r.id == p.role,
|
||||
orElse: () => null,
|
||||
);
|
||||
return <String, dynamic>{
|
||||
if (role != null) 'roleName': role.name,
|
||||
if (p.role.isNotEmpty) 'roleId': p.role,
|
||||
'workerCount': p.count,
|
||||
'startTime': p.startTime,
|
||||
'endTime': p.endTime,
|
||||
if (p.lunchBreak != 'NO_BREAK' && p.lunchBreak.isNotEmpty)
|
||||
'lunchBreakMinutes': _breakMinutes(p.lunchBreak),
|
||||
};
|
||||
return OneTimeOrderPositionArgument(
|
||||
roleId: p.role,
|
||||
roleName: role?.name,
|
||||
workerCount: p.count,
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
lunchBreak: p.lunchBreak,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final Map<String, dynamic> payload = <String, dynamic>{
|
||||
'hubId': selectedHub.id,
|
||||
'eventName': state.eventName,
|
||||
'orderDate': orderDate,
|
||||
'positions': positions,
|
||||
if (state.selectedVendor != null)
|
||||
'vendorId': state.selectedVendor!.id,
|
||||
};
|
||||
|
||||
await _createOneTimeOrderUseCase(
|
||||
OneTimeOrderArguments(payload: payload),
|
||||
OneTimeOrderArguments(
|
||||
hubId: selectedHub.id,
|
||||
eventName: state.eventName,
|
||||
orderDate: state.date,
|
||||
positions: positionArgs,
|
||||
vendorId: state.selectedVendor?.id,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(status: OneTimeOrderStatus.success));
|
||||
},
|
||||
@@ -339,8 +344,8 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
positions.add(OneTimeOrderPosition(
|
||||
role: role.roleId,
|
||||
count: role.workersNeeded,
|
||||
startTime: _formatTime(shift.startsAt),
|
||||
endTime: _formatTime(shift.endsAt),
|
||||
startTime: formatTimeHHmm(shift.startsAt),
|
||||
endTime: formatTimeHHmm(shift.endsAt),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -357,29 +362,4 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] to HH:mm string.
|
||||
String _formatTime(DateTime dt) {
|
||||
final DateTime local = dt.toLocal();
|
||||
return '${local.hour.toString().padLeft(2, '0')}:'
|
||||
'${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Converts a break duration string to minutes.
|
||||
int _breakMinutes(String value) {
|
||||
switch (value) {
|
||||
case 'MIN_10':
|
||||
return 10;
|
||||
case 'MIN_15':
|
||||
return 15;
|
||||
case 'MIN_30':
|
||||
return 30;
|
||||
case 'MIN_45':
|
||||
return 45;
|
||||
case 'MIN_60':
|
||||
return 60;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import 'package:client_create_order/src/domain/arguments/permanent_order_arguments.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_hub.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_manager.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_role.dart';
|
||||
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/create_permanent_order_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:client_create_order/src/domain/arguments/permanent_order_arguments.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import 'permanent_order_event.dart';
|
||||
import 'permanent_order_state.dart';
|
||||
|
||||
/// BLoC for managing the permanent order creation form.
|
||||
///
|
||||
/// Delegates all data fetching to query use cases and order submission
|
||||
/// to [CreatePermanentOrderUseCase].
|
||||
class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
with
|
||||
BlocErrorHandler<PermanentOrderState>,
|
||||
SafeBloc<PermanentOrderEvent, PermanentOrderState> {
|
||||
/// Creates a BLoC with required use case dependencies.
|
||||
PermanentOrderBloc(
|
||||
this._createPermanentOrderUseCase,
|
||||
this._getOrderDetailsForReorderUseCase,
|
||||
this._queryRepository,
|
||||
this._getVendorsUseCase,
|
||||
this._getRolesByVendorUseCase,
|
||||
this._getHubsUseCase,
|
||||
this._getManagersByHubUseCase,
|
||||
) : super(PermanentOrderState.initial()) {
|
||||
on<PermanentOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<PermanentOrderVendorChanged>(_onVendorChanged);
|
||||
@@ -43,7 +53,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
|
||||
final CreatePermanentOrderUseCase _createPermanentOrderUseCase;
|
||||
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
|
||||
final ClientOrderQueryRepositoryInterface _queryRepository;
|
||||
final GetVendorsUseCase _getVendorsUseCase;
|
||||
final GetRolesByVendorUseCase _getRolesByVendorUseCase;
|
||||
final GetHubsUseCase _getHubsUseCase;
|
||||
final GetManagersByHubUseCase _getManagersByHubUseCase;
|
||||
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
@@ -55,9 +68,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Loads available vendors via the use case.
|
||||
Future<void> _loadVendors() async {
|
||||
final List<domain.Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () => _queryRepository.getVendors(),
|
||||
action: () => _getVendorsUseCase(),
|
||||
onError: (_) => add(const PermanentOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
);
|
||||
|
||||
@@ -66,6 +80,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads roles for [vendorId] via the use case and maps them to
|
||||
/// presentation option models.
|
||||
Future<void> _loadRolesForVendor(
|
||||
String vendorId,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
@@ -73,7 +89,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
final List<PermanentOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final List<OrderRole> orderRoles =
|
||||
await _queryRepository.getRolesByVendor(vendorId);
|
||||
await _getRolesByVendorUseCase(vendorId);
|
||||
return orderRoles
|
||||
.map(
|
||||
(OrderRole r) => PermanentOrderRoleOption(
|
||||
@@ -93,10 +109,11 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads hubs via the use case and maps them to presentation option models.
|
||||
Future<void> _loadHubs() async {
|
||||
final List<PermanentOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final List<OrderHub> orderHubs = await _queryRepository.getHubs();
|
||||
final List<OrderHub> orderHubs = await _getHubsUseCase();
|
||||
return orderHubs
|
||||
.map(
|
||||
(OrderHub hub) => PermanentOrderHubOption(
|
||||
@@ -193,6 +210,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
emit(state.copyWith(managers: event.managers));
|
||||
}
|
||||
|
||||
/// Loads managers for [hubId] via the use case.
|
||||
Future<void> _loadManagersForHub(
|
||||
String hubId,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
@@ -201,7 +219,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
await handleErrorWithResult(
|
||||
action: () async {
|
||||
final List<OrderManager> orderManagers =
|
||||
await _queryRepository.getManagersByHub(hubId);
|
||||
await _getManagersByHubUseCase(hubId);
|
||||
return orderManagers
|
||||
.map(
|
||||
(OrderManager m) => PermanentOrderManagerOption(
|
||||
@@ -221,7 +239,6 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _onEventNameChanged(
|
||||
PermanentOrderEventNameChanged event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
@@ -315,6 +332,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
emit(state.copyWith(positions: newPositions));
|
||||
}
|
||||
|
||||
/// Builds typed arguments from form state and submits via the use case.
|
||||
Future<void> _onSubmitted(
|
||||
PermanentOrderSubmitted event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
@@ -328,16 +346,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
throw const domain.OrderMissingHubException();
|
||||
}
|
||||
|
||||
final String startDate =
|
||||
'${state.startDate.year.toString().padLeft(4, '0')}-'
|
||||
'${state.startDate.month.toString().padLeft(2, '0')}-'
|
||||
'${state.startDate.day.toString().padLeft(2, '0')}';
|
||||
|
||||
final List<int> daysOfWeek = state.permanentDays
|
||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||
.toList();
|
||||
|
||||
final List<Map<String, dynamic>> positions =
|
||||
final List<PermanentOrderPositionArgument> positionArgs =
|
||||
state.positions.map((PermanentOrderPosition p) {
|
||||
final PermanentOrderRoleOption? role = state.roles
|
||||
.cast<PermanentOrderRoleOption?>()
|
||||
@@ -345,27 +354,24 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
(PermanentOrderRoleOption? r) => r != null && r.id == p.role,
|
||||
orElse: () => null,
|
||||
);
|
||||
return <String, dynamic>{
|
||||
if (role != null) 'roleName': role.name,
|
||||
if (p.role.isNotEmpty) 'roleId': p.role,
|
||||
'workerCount': p.count,
|
||||
'startTime': p.startTime,
|
||||
'endTime': p.endTime,
|
||||
};
|
||||
return PermanentOrderPositionArgument(
|
||||
roleId: p.role,
|
||||
roleName: role?.name,
|
||||
workerCount: p.count,
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final Map<String, dynamic> payload = <String, dynamic>{
|
||||
'hubId': selectedHub.id,
|
||||
'eventName': state.eventName,
|
||||
'startDate': startDate,
|
||||
'daysOfWeek': daysOfWeek,
|
||||
'positions': positions,
|
||||
if (state.selectedVendor != null)
|
||||
'vendorId': state.selectedVendor!.id,
|
||||
};
|
||||
|
||||
await _createPermanentOrderUseCase(
|
||||
PermanentOrderArguments(payload: payload),
|
||||
PermanentOrderArguments(
|
||||
hubId: selectedHub.id,
|
||||
eventName: state.eventName,
|
||||
startDate: state.startDate,
|
||||
daysOfWeek: state.permanentDays,
|
||||
positions: positionArgs,
|
||||
vendorId: state.selectedVendor?.id,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(status: PermanentOrderStatus.success));
|
||||
},
|
||||
@@ -376,6 +382,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Initializes the form from route arguments or reorder preview data.
|
||||
Future<void> _onInitialized(
|
||||
PermanentOrderInitialized event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
@@ -406,8 +413,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
positions.add(PermanentOrderPosition(
|
||||
role: role.roleId,
|
||||
count: role.workersNeeded,
|
||||
startTime: _formatTime(shift.startsAt),
|
||||
endTime: _formatTime(shift.endsAt),
|
||||
startTime: formatTimeHHmm(shift.startsAt),
|
||||
endTime: formatTimeHHmm(shift.endsAt),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -430,13 +437,6 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] to HH:mm string.
|
||||
String _formatTime(DateTime dt) {
|
||||
final DateTime local = dt.toLocal();
|
||||
return '${local.hour.toString().padLeft(2, '0')}:'
|
||||
'${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
static List<String> _sortDays(List<String> days) {
|
||||
days.sort(
|
||||
(String a, String b) =>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'package:client_create_order/src/domain/arguments/recurring_order_arguments.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_hub.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_manager.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_role.dart';
|
||||
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/create_recurring_order_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:client_create_order/src/domain/arguments/recurring_order_arguments.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import 'recurring_order_event.dart';
|
||||
@@ -14,19 +17,20 @@ import 'recurring_order_state.dart';
|
||||
|
||||
/// BLoC for managing the recurring order creation form.
|
||||
///
|
||||
/// Delegates all backend queries to [ClientOrderQueryRepositoryInterface]
|
||||
/// and order submission to [CreateRecurringOrderUseCase].
|
||||
/// Builds V2 API payloads from form state.
|
||||
/// Delegates all data fetching to query use cases and order submission
|
||||
/// to [CreateRecurringOrderUseCase]. Builds V2 API payloads from form state.
|
||||
class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
with
|
||||
BlocErrorHandler<RecurringOrderState>,
|
||||
SafeBloc<RecurringOrderEvent, RecurringOrderState> {
|
||||
/// Creates a [RecurringOrderBloc] with the required use cases and
|
||||
/// query repository.
|
||||
/// Creates a [RecurringOrderBloc] with the required use case dependencies.
|
||||
RecurringOrderBloc(
|
||||
this._createRecurringOrderUseCase,
|
||||
this._getOrderDetailsForReorderUseCase,
|
||||
this._queryRepository,
|
||||
this._getVendorsUseCase,
|
||||
this._getRolesByVendorUseCase,
|
||||
this._getHubsUseCase,
|
||||
this._getManagersByHubUseCase,
|
||||
) : super(RecurringOrderState.initial()) {
|
||||
on<RecurringOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<RecurringOrderVendorChanged>(_onVendorChanged);
|
||||
@@ -50,7 +54,10 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
|
||||
final CreateRecurringOrderUseCase _createRecurringOrderUseCase;
|
||||
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
|
||||
final ClientOrderQueryRepositoryInterface _queryRepository;
|
||||
final GetVendorsUseCase _getVendorsUseCase;
|
||||
final GetRolesByVendorUseCase _getRolesByVendorUseCase;
|
||||
final GetHubsUseCase _getHubsUseCase;
|
||||
final GetManagersByHubUseCase _getManagersByHubUseCase;
|
||||
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
@@ -62,12 +69,10 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Loads the list of available vendors from the query repository.
|
||||
/// Loads the list of available vendors via the use case.
|
||||
Future<void> _loadVendors() async {
|
||||
final List<domain.Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () async {
|
||||
return _queryRepository.getVendors();
|
||||
},
|
||||
action: () => _getVendorsUseCase(),
|
||||
onError: (_) =>
|
||||
add(const RecurringOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
);
|
||||
@@ -77,8 +82,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads roles for the given [vendorId] and maps them to presentation
|
||||
/// option models.
|
||||
/// Loads roles for [vendorId] via the use case and maps them to
|
||||
/// presentation option models.
|
||||
Future<void> _loadRolesForVendor(
|
||||
String vendorId,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
@@ -86,7 +91,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
final List<RecurringOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final List<OrderRole> orderRoles =
|
||||
await _queryRepository.getRolesByVendor(vendorId);
|
||||
await _getRolesByVendorUseCase(vendorId);
|
||||
return orderRoles
|
||||
.map(
|
||||
(OrderRole r) => RecurringOrderRoleOption(
|
||||
@@ -106,12 +111,12 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads team hubs for the current business owner and maps them to
|
||||
/// presentation option models.
|
||||
/// Loads team hubs via the use case and maps them to presentation
|
||||
/// option models.
|
||||
Future<void> _loadHubs() async {
|
||||
final List<RecurringOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final List<OrderHub> orderHubs = await _queryRepository.getHubs();
|
||||
final List<OrderHub> orderHubs = await _getHubsUseCase();
|
||||
return orderHubs
|
||||
.map(
|
||||
(OrderHub hub) => RecurringOrderHubOption(
|
||||
@@ -208,8 +213,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
emit(state.copyWith(managers: event.managers));
|
||||
}
|
||||
|
||||
/// Loads managers for the given [hubId] and maps them to presentation
|
||||
/// option models.
|
||||
/// Loads managers for [hubId] via the use case and maps them to
|
||||
/// presentation option models.
|
||||
Future<void> _loadManagersForHub(
|
||||
String hubId,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
@@ -218,7 +223,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
await handleErrorWithResult(
|
||||
action: () async {
|
||||
final List<OrderManager> orderManagers =
|
||||
await _queryRepository.getManagersByHub(hubId);
|
||||
await _getManagersByHubUseCase(hubId);
|
||||
return orderManagers
|
||||
.map(
|
||||
(OrderManager m) => RecurringOrderManagerOption(
|
||||
@@ -347,6 +352,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
emit(state.copyWith(positions: newPositions));
|
||||
}
|
||||
|
||||
/// Builds typed arguments from form state and submits via the use case.
|
||||
Future<void> _onSubmitted(
|
||||
RecurringOrderSubmitted event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
@@ -360,21 +366,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
throw const domain.OrderMissingHubException();
|
||||
}
|
||||
|
||||
final String startDate =
|
||||
'${state.startDate.year.toString().padLeft(4, '0')}-'
|
||||
'${state.startDate.month.toString().padLeft(2, '0')}-'
|
||||
'${state.startDate.day.toString().padLeft(2, '0')}';
|
||||
final String endDate =
|
||||
'${state.endDate.year.toString().padLeft(4, '0')}-'
|
||||
'${state.endDate.month.toString().padLeft(2, '0')}-'
|
||||
'${state.endDate.day.toString().padLeft(2, '0')}';
|
||||
|
||||
// Map day labels (MON=1, TUE=2, ..., SUN=0) to V2 int format
|
||||
final List<int> recurrenceDays = state.recurringDays
|
||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||
.toList();
|
||||
|
||||
final List<Map<String, dynamic>> positions =
|
||||
final List<RecurringOrderPositionArgument> positionArgs =
|
||||
state.positions.map((RecurringOrderPosition p) {
|
||||
final RecurringOrderRoleOption? role = state.roles
|
||||
.cast<RecurringOrderRoleOption?>()
|
||||
@@ -382,28 +374,25 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
(RecurringOrderRoleOption? r) => r != null && r.id == p.role,
|
||||
orElse: () => null,
|
||||
);
|
||||
return <String, dynamic>{
|
||||
if (role != null) 'roleName': role.name,
|
||||
if (p.role.isNotEmpty) 'roleId': p.role,
|
||||
'workerCount': p.count,
|
||||
'startTime': p.startTime,
|
||||
'endTime': p.endTime,
|
||||
};
|
||||
return RecurringOrderPositionArgument(
|
||||
roleId: p.role,
|
||||
roleName: role?.name,
|
||||
workerCount: p.count,
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final Map<String, dynamic> payload = <String, dynamic>{
|
||||
'hubId': selectedHub.id,
|
||||
'eventName': state.eventName,
|
||||
'startDate': startDate,
|
||||
'endDate': endDate,
|
||||
'recurrenceDays': recurrenceDays,
|
||||
'positions': positions,
|
||||
if (state.selectedVendor != null)
|
||||
'vendorId': state.selectedVendor!.id,
|
||||
};
|
||||
|
||||
await _createRecurringOrderUseCase(
|
||||
RecurringOrderArguments(payload: payload),
|
||||
RecurringOrderArguments(
|
||||
hubId: selectedHub.id,
|
||||
eventName: state.eventName,
|
||||
startDate: state.startDate,
|
||||
endDate: state.endDate,
|
||||
recurringDays: state.recurringDays,
|
||||
positions: positionArgs,
|
||||
vendorId: state.selectedVendor?.id,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(status: RecurringOrderStatus.success));
|
||||
},
|
||||
@@ -414,6 +403,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Initializes the form from route arguments or reorder preview data.
|
||||
Future<void> _onInitialized(
|
||||
RecurringOrderInitialized event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
@@ -445,8 +435,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
positions.add(RecurringOrderPosition(
|
||||
role: role.roleId,
|
||||
count: role.workersNeeded,
|
||||
startTime: _formatTime(shift.startsAt),
|
||||
endTime: _formatTime(shift.endsAt),
|
||||
startTime: formatTimeHHmm(shift.startsAt),
|
||||
endTime: formatTimeHHmm(shift.endsAt),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -470,13 +460,6 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] to HH:mm string.
|
||||
String _formatTime(DateTime dt) {
|
||||
final DateTime local = dt.toLocal();
|
||||
return '${local.hour.toString().padLeft(2, '0')}:'
|
||||
'${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
static List<String> _sortDays(List<String> days) {
|
||||
days.sort(
|
||||
(String a, String b) =>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/i_view_orders_repository.dart';
|
||||
import '../../domain/repositories/view_orders_repository_interface.dart';
|
||||
|
||||
/// V2 API implementation of [IViewOrdersRepository].
|
||||
/// V2 API implementation of [ViewOrdersRepositoryInterface].
|
||||
///
|
||||
/// Replaces the old Data Connect implementation with [BaseApiService] calls
|
||||
/// to the V2 query and command API endpoints.
|
||||
class ViewOrdersRepositoryImpl implements IViewOrdersRepository {
|
||||
class ViewOrdersRepositoryImpl implements ViewOrdersRepositoryInterface {
|
||||
/// Creates an instance backed by the given [apiService].
|
||||
ViewOrdersRepositoryImpl({required BaseApiService apiService})
|
||||
: _api = apiService;
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
///
|
||||
/// V2 API returns workers inline with order items, so the separate
|
||||
/// accepted-applications method is no longer needed.
|
||||
abstract class IViewOrdersRepository {
|
||||
abstract class ViewOrdersRepositoryInterface {
|
||||
/// Fetches [OrderItem] list for the given date range via the V2 API.
|
||||
Future<List<OrderItem>> getOrdersForRange({
|
||||
required DateTime start,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user