Merge dev into feature branch

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,7 +97,7 @@ mixin ApiErrorHandler {
);
case DioExceptionType.cancel:
return UnknownException(
return const UnknownException(
technicalMessage: 'Request cancelled',
);

View File

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

View File

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

View File

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

View File

@@ -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").
///

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

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

View File

@@ -84,6 +84,7 @@ class UiNoticeBanner extends StatelessWidget {
style: UiTypography.body2b.copyWith(
color: titleColor ?? UiColors.primary,
),
overflow: TextOverflow.ellipsis,
),
],
],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
firebase_auth: ^6.1.2
# Architecture Packages
design_system:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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