feat(shifts): implement submit for approval functionality

- Added `submitForApproval` method to `ShiftsRepositoryInterface` and its implementation in `ShiftsRepositoryImpl`.
- Created `SubmitForApprovalUseCase` to handle the submission logic.
- Updated `ShiftsBloc` to handle `SubmitForApprovalEvent` and manage submission state.
- Enhanced `HistoryShiftsTab` and `MyShiftsTab` to support submission actions and display appropriate UI feedback.
- Refactored date utilities for better calendar management and filtering of past shifts.
- Improved UI components for better spacing and alignment.
- Localized success messages for shift submission actions.
This commit is contained in:
Achintha Isuru
2026-03-18 14:37:55 -04:00
parent 3e5b6af8dc
commit 3a5f2cc9c6
50 changed files with 1269 additions and 408 deletions

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
@@ -84,7 +85,7 @@ class _SessionListenerState extends State<SessionListener> {
if (!_isInitialState) { if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}'); debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog( _showSessionErrorDialog(
state.errorMessage ?? 'Session error occurred', state.errorMessage ?? t.session.error_title,
); );
} else { } else {
_isInitialState = false; _isInitialState = false;
@@ -101,22 +102,21 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when the session expires. /// Shows a dialog when the session expires.
void _showSessionExpiredDialog() { void _showSessionExpiredDialog() {
final Translations translations = t;
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext context) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Session Expired'), title: Text(translations.session.expired_title),
content: const Text( content: Text(translations.session.expired_message),
'Your session has expired. Please log in again to continue.',
),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
Modular.to.popSafe(); Navigator.of(dialogContext).pop();
_proceedToLogin(); _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. /// Shows a dialog when a session error occurs, with retry option.
void _showSessionErrorDialog(String errorMessage) { void _showSessionErrorDialog(String errorMessage) {
final Translations translations = t;
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext context) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Session Error'), title: Text(translations.session.error_title),
content: Text(errorMessage), content: Text(errorMessage),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
// User can retry by dismissing and continuing // 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( TextButton(
onPressed: () { onPressed: () {
Modular.to.popSafe(); Navigator.of(dialogContext).pop();
_proceedToLogin(); _proceedToLogin();
}, },
child: const Text('Log Out'), child: Text(translations.session.log_out),
), ),
], ],
); );

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
@@ -97,7 +98,7 @@ class _SessionListenerState extends State<SessionListener> {
if (!_isInitialState) { if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}'); debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog( _showSessionErrorDialog(
state.errorMessage ?? 'Session error occurred', state.errorMessage ?? t.session.error_title,
); );
} else { } else {
_isInitialState = false; _isInitialState = false;
@@ -114,22 +115,21 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when the session expires. /// Shows a dialog when the session expires.
void _showSessionExpiredDialog() { void _showSessionExpiredDialog() {
final Translations translations = t;
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext context) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Session Expired'), title: Text(translations.session.expired_title),
content: const Text( content: Text(translations.session.expired_message),
'Your session has expired. Please log in again to continue.',
),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
Modular.to.popSafe(); Navigator.of(dialogContext).pop();
_proceedToLogin(); _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. /// Shows a dialog when a session error occurs, with retry option.
void _showSessionErrorDialog(String errorMessage) { void _showSessionErrorDialog(String errorMessage) {
final Translations translations = t;
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext context) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Session Error'), title: Text(translations.session.error_title),
content: Text(errorMessage), content: Text(errorMessage),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
// User can retry by dismissing and continuing // 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( TextButton(
onPressed: () { onPressed: () {
Modular.to.popSafe(); Navigator.of(dialogContext).pop();
_proceedToLogin(); _proceedToLogin();
}, },
child: const Text('Log Out'), child: Text(translations.session.log_out),
), ),
], ],
); );

View File

@@ -42,6 +42,9 @@ export 'src/services/session/client_session_store.dart';
export 'src/services/session/staff_session_store.dart'; export 'src/services/session/staff_session_store.dart';
export 'src/services/session/v2_session_service.dart'; export 'src/services/session/v2_session_service.dart';
// Auth
export 'src/services/auth/auth_token_provider.dart';
// Device Services // Device Services
export 'src/services/device/camera/camera_service.dart'; export 'src/services/device/camera/camera_service.dart';
export 'src/services/device/gallery/gallery_service.dart'; export 'src/services/device/gallery/gallery_service.dart';

View File

@@ -3,6 +3,9 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:krow_domain/krow_domain.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_token_provider.dart';
import '../core.dart'; import '../core.dart';
/// A module that provides core services and shared dependencies. /// A module that provides core services and shared dependencies.
@@ -57,7 +60,10 @@ class CoreModule extends Module {
), ),
); );
// 6. Register Geofence Device Services // 6. Auth Token Provider
i.addLazySingleton<AuthTokenProvider>(FirebaseAuthTokenProvider.new);
// 7. Register Geofence Device Services
i.addLazySingleton<LocationService>(() => const LocationService()); i.addLazySingleton<LocationService>(() => const LocationService());
i.addLazySingleton<NotificationService>(() => NotificationService()); i.addLazySingleton<NotificationService>(() => NotificationService());
i.addLazySingleton<StorageService>(() => StorageService()); i.addLazySingleton<StorageService>(() => StorageService());

View File

@@ -48,6 +48,26 @@ abstract final class ClientEndpoints {
static const ApiEndpoint coverageCoreTeam = static const ApiEndpoint coverageCoreTeam =
ApiEndpoint('/client/coverage/core-team'); 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. /// Hubs list.
static const ApiEndpoint hubs = ApiEndpoint('/client/hubs'); static const ApiEndpoint hubs = ApiEndpoint('/client/hubs');
@@ -162,4 +182,28 @@ abstract final class ClientEndpoints {
/// Cancel late worker assignment. /// Cancel late worker assignment.
static ApiEndpoint coverageCancelLateWorker(String assignmentId) => static ApiEndpoint coverageCancelLateWorker(String assignmentId) =>
ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel'); 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, /// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
/// rapid orders). /// rapid orders).
///
/// Paths are at the unified API root level (not under `/core/`).
abstract final class CoreEndpoints { abstract final class CoreEndpoints {
/// Upload a file. /// Upload a file.
static const ApiEndpoint uploadFile = static const ApiEndpoint uploadFile = ApiEndpoint('/upload-file');
ApiEndpoint('/core/upload-file');
/// Create a signed URL for a file. /// Create a signed URL for a file.
static const ApiEndpoint createSignedUrl = static const ApiEndpoint createSignedUrl = ApiEndpoint('/create-signed-url');
ApiEndpoint('/core/create-signed-url');
/// Invoke a Large Language Model. /// Invoke a Large Language Model.
static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm'); static const ApiEndpoint invokeLlm = ApiEndpoint('/invoke-llm');
/// Root for verification operations. /// Root for verification operations.
static const ApiEndpoint verifications = static const ApiEndpoint verifications = ApiEndpoint('/verifications');
ApiEndpoint('/core/verifications');
/// Get status of a verification job. /// Get status of a verification job.
static ApiEndpoint verificationStatus(String id) => static ApiEndpoint verificationStatus(String id) =>
ApiEndpoint('/core/verifications/$id'); ApiEndpoint('/verifications/$id');
/// Review a verification decision. /// Review a verification decision.
static ApiEndpoint verificationReview(String id) => static ApiEndpoint verificationReview(String id) =>
ApiEndpoint('/core/verifications/$id/review'); ApiEndpoint('/verifications/$id/review');
/// Retry a verification job. /// Retry a verification job.
static ApiEndpoint verificationRetry(String id) => static ApiEndpoint verificationRetry(String id) =>
ApiEndpoint('/core/verifications/$id/retry'); ApiEndpoint('/verifications/$id/retry');
/// Transcribe audio to text for rapid orders. /// Transcribe audio to text for rapid orders.
static const ApiEndpoint transcribeRapidOrder = static const ApiEndpoint transcribeRapidOrder =
ApiEndpoint('/core/rapid-orders/transcribe'); ApiEndpoint('/rapid-orders/transcribe');
/// Parse text to structured rapid order. /// Parse text to structured rapid order.
static const ApiEndpoint parseRapidOrder = 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. /// Benefits.
static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits'); static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits');
/// Benefits history.
static const ApiEndpoint benefitsHistory =
ApiEndpoint('/staff/profile/benefits/history');
/// Time card. /// Time card.
static const ApiEndpoint timeCard = static const ApiEndpoint timeCard =
ApiEndpoint('/staff/profile/time-card'); ApiEndpoint('/staff/profile/time-card');
@@ -112,6 +116,10 @@ abstract final class StaffEndpoints {
/// Privacy settings. /// Privacy settings.
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy'); static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
/// Preferred locations.
static const ApiEndpoint locations =
ApiEndpoint('/staff/profile/locations');
/// FAQs. /// FAQs.
static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs'); static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs');
@@ -177,4 +185,16 @@ abstract final class StaffEndpoints {
/// Delete certificate by ID. /// Delete certificate by ID.
static ApiEndpoint certificateDelete(String certificateId) => static ApiEndpoint certificateDelete(String certificateId) =>
ApiEndpoint('/staff/profile/certificates/$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

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

@@ -12,6 +12,13 @@
"english": "English", "english": "English",
"spanish": "Español" "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": { "settings": {
"language": "Language", "language": "Language",
"change_language": "Change Language" "change_language": "Change Language"
@@ -1337,7 +1344,14 @@
"applying_dialog": { "applying_dialog": {
"title": "Applying" "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": { "my_shift_card": {
"submit_for_approval": "Submit for Approval", "submit_for_approval": "Submit for Approval",
@@ -1457,7 +1471,8 @@
"shift": { "shift": {
"no_open_roles": "There are no open positions available for this shift.", "no_open_roles": "There are no open positions available for this shift.",
"application_not_found": "Your application couldn't be found.", "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": { "clock_in": {
"location_verification_required": "Please wait for location verification before clocking in.", "location_verification_required": "Please wait for location verification before clocking in.",

View File

@@ -12,6 +12,13 @@
"english": "English", "english": "English",
"spanish": "Español" "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": { "settings": {
"language": "Idioma", "language": "Idioma",
"change_language": "Cambiar Idioma" "change_language": "Cambiar Idioma"
@@ -1332,7 +1339,14 @@
"applying_dialog": { "applying_dialog": {
"title": "Solicitando" "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": { "my_shift_card": {
"submit_for_approval": "Enviar para Aprobación", "submit_for_approval": "Enviar para Aprobación",
@@ -1452,7 +1466,8 @@
"shift": { "shift": {
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.", "no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
"application_not_found": "No se pudo encontrar tu solicitud.", "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": { "clock_in": {
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.", "location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",

View File

@@ -124,6 +124,8 @@ String _translateShiftError(String errorType) {
return t.errors.shift.application_not_found; return t.errors.shift.application_not_found;
case 'no_active_shift': case 'no_active_shift':
return t.errors.shift.no_active_shift; return t.errors.shift.no_active_shift;
case 'not_found':
return t.errors.shift.not_found;
default: default:
return t.errors.generic.unknown; return t.errors.generic.unknown;
} }

View File

@@ -18,6 +18,10 @@ class AssignedShift extends Equatable {
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
required this.hourlyRateCents, required this.hourlyRateCents,
required this.hourlyRate,
required this.totalRateCents,
required this.totalRate,
required this.clientName,
required this.orderType, required this.orderType,
required this.status, required this.status,
}); });
@@ -33,6 +37,10 @@ class AssignedShift extends Equatable {
startTime: DateTime.parse(json['startTime'] as String), startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String), endTime: DateTime.parse(json['endTime'] as String),
hourlyRateCents: json['hourlyRateCents'] 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,
clientName: json['clientName'] as String? ?? '',
orderType: OrderType.fromJson(json['orderType'] as String?), orderType: OrderType.fromJson(json['orderType'] as String?),
status: AssignmentStatus.fromJson(json['status'] as String?), status: AssignmentStatus.fromJson(json['status'] as String?),
); );
@@ -62,6 +70,18 @@ class AssignedShift extends Equatable {
/// Pay rate in cents per hour. /// Pay rate in cents per hour.
final int hourlyRateCents; 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. /// Order type.
final OrderType orderType; final OrderType orderType;
@@ -79,6 +99,10 @@ class AssignedShift extends Equatable {
'startTime': startTime.toIso8601String(), 'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(), 'endTime': endTime.toIso8601String(),
'hourlyRateCents': hourlyRateCents, 'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'totalRateCents': totalRateCents,
'totalRate': totalRate,
'clientName': clientName,
'orderType': orderType.toJson(), 'orderType': orderType.toJson(),
'status': status.toJson(), 'status': status.toJson(),
}; };
@@ -94,6 +118,10 @@ class AssignedShift extends Equatable {
startTime, startTime,
endTime, endTime,
hourlyRateCents, hourlyRateCents,
hourlyRate,
totalRateCents,
totalRate,
clientName,
orderType, orderType,
status, status,
]; ];

View File

@@ -12,10 +12,18 @@ class CompletedShift extends Equatable {
required this.shiftId, required this.shiftId,
required this.title, required this.title,
required this.location, required this.location,
required this.clientName,
required this.date, required this.date,
required this.startTime,
required this.endTime,
required this.minutesWorked, required this.minutesWorked,
required this.hourlyRateCents,
required this.hourlyRate,
required this.totalRateCents,
required this.totalRate,
required this.paymentStatus, required this.paymentStatus,
required this.status, required this.status,
this.timesheetStatus,
}); });
/// Deserialises from the V2 API JSON response. /// Deserialises from the V2 API JSON response.
@@ -25,10 +33,22 @@ class CompletedShift extends Equatable {
shiftId: json['shiftId'] as String, shiftId: json['shiftId'] as String,
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
location: json['location'] as String? ?? '', location: json['location'] as String? ?? '',
clientName: json['clientName'] as String? ?? '',
date: DateTime.parse(json['date'] 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, 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?), paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?),
status: AssignmentStatus.completed, status: AssignmentStatus.completed,
timesheetStatus: json['timesheetStatus'] as String?,
); );
} }
@@ -44,18 +64,42 @@ class CompletedShift extends Equatable {
/// Human-readable location label. /// Human-readable location label.
final String location; final String location;
/// Name of the client / business for this shift.
final String clientName;
/// The date the shift was worked. /// The date the shift was worked.
final DateTime date; final DateTime date;
/// Scheduled start time.
final DateTime startTime;
/// Scheduled end time.
final DateTime endTime;
/// Total minutes worked (regular + overtime). /// Total minutes worked (regular + overtime).
final int minutesWorked; 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. /// Payment processing status.
final PaymentStatus paymentStatus; final PaymentStatus paymentStatus;
/// Assignment status (should always be `completed` for this class). /// Assignment status (should always be `completed` for this class).
final AssignmentStatus status; final AssignmentStatus status;
/// Timesheet status (e.g. `SUBMITTED`, `APPROVED`, `PAID`, or null).
final String? timesheetStatus;
/// Serialises to JSON. /// Serialises to JSON.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
@@ -63,9 +107,17 @@ class CompletedShift extends Equatable {
'shiftId': shiftId, 'shiftId': shiftId,
'title': title, 'title': title,
'location': location, 'location': location,
'clientName': clientName,
'date': date.toIso8601String(), 'date': date.toIso8601String(),
'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(),
'minutesWorked': minutesWorked, 'minutesWorked': minutesWorked,
'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'totalRateCents': totalRateCents,
'totalRate': totalRate,
'paymentStatus': paymentStatus.toJson(), 'paymentStatus': paymentStatus.toJson(),
'timesheetStatus': timesheetStatus,
}; };
} }
@@ -75,8 +127,17 @@ class CompletedShift extends Equatable {
shiftId, shiftId,
title, title,
location, location,
clientName,
date, date,
startTime,
endTime,
minutesWorked, minutesWorked,
hourlyRateCents,
hourlyRate,
totalRateCents,
totalRate,
paymentStatus, paymentStatus,
timesheetStatus,
status,
]; ];
} }

View File

@@ -17,6 +17,7 @@ class OpenShift extends Equatable {
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
required this.hourlyRateCents, required this.hourlyRateCents,
required this.hourlyRate,
required this.orderType, required this.orderType,
required this.instantBook, required this.instantBook,
required this.requiredWorkerCount, required this.requiredWorkerCount,
@@ -33,6 +34,7 @@ class OpenShift extends Equatable {
startTime: DateTime.parse(json['startTime'] as String), startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String), endTime: DateTime.parse(json['endTime'] as String),
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
orderType: OrderType.fromJson(json['orderType'] as String?), orderType: OrderType.fromJson(json['orderType'] as String?),
instantBook: json['instantBook'] as bool? ?? false, instantBook: json['instantBook'] as bool? ?? false,
requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1, requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1,
@@ -63,6 +65,9 @@ class OpenShift extends Equatable {
/// Pay rate in cents per hour. /// Pay rate in cents per hour.
final int hourlyRateCents; final int hourlyRateCents;
/// Pay rate in dollars per hour.
final double hourlyRate;
/// Order type. /// Order type.
final OrderType orderType; final OrderType orderType;
@@ -83,6 +88,7 @@ class OpenShift extends Equatable {
'startTime': startTime.toIso8601String(), 'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(), 'endTime': endTime.toIso8601String(),
'hourlyRateCents': hourlyRateCents, 'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'orderType': orderType.toJson(), 'orderType': orderType.toJson(),
'instantBook': instantBook, 'instantBook': instantBook,
'requiredWorkerCount': requiredWorkerCount, 'requiredWorkerCount': requiredWorkerCount,
@@ -99,6 +105,7 @@ class OpenShift extends Equatable {
startTime, startTime,
endTime, endTime,
hourlyRateCents, hourlyRateCents,
hourlyRate,
orderType, orderType,
instantBook, instantBook,
requiredWorkerCount, requiredWorkerCount,

View File

@@ -11,7 +11,7 @@ class Shift extends Equatable {
/// Creates a [Shift]. /// Creates a [Shift].
const Shift({ const Shift({
required this.id, required this.id,
required this.orderId, this.orderId,
required this.title, required this.title,
required this.status, required this.status,
required this.startsAt, required this.startsAt,
@@ -25,13 +25,16 @@ class Shift extends Equatable {
required this.requiredWorkers, required this.requiredWorkers,
required this.assignedWorkers, required this.assignedWorkers,
this.notes, this.notes,
this.clockInMode,
this.allowClockInOverride,
this.nfcTagId,
}); });
/// Deserialises from the V2 API JSON response. /// Deserialises from the V2 API JSON response.
factory Shift.fromJson(Map<String, dynamic> json) { factory Shift.fromJson(Map<String, dynamic> json) {
return Shift( return Shift(
id: json['id'] as String, id: json['id'] as String,
orderId: json['orderId'] as String, orderId: json['orderId'] as String?,
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
status: ShiftStatus.fromJson(json['status'] as String?), status: ShiftStatus.fromJson(json['status'] as String?),
startsAt: DateTime.parse(json['startsAt'] as String), startsAt: DateTime.parse(json['startsAt'] as String),
@@ -45,14 +48,17 @@ class Shift extends Equatable {
requiredWorkers: json['requiredWorkers'] as int? ?? 1, requiredWorkers: json['requiredWorkers'] as int? ?? 1,
assignedWorkers: json['assignedWorkers'] as int? ?? 0, assignedWorkers: json['assignedWorkers'] as int? ?? 0,
notes: json['notes'] as String?, notes: json['notes'] as String?,
clockInMode: json['clockInMode'] as String?,
allowClockInOverride: json['allowClockInOverride'] as bool?,
nfcTagId: json['nfcTagId'] as String?,
); );
} }
/// The shift row id. /// The shift row id.
final String id; final String id;
/// The parent order id. /// The parent order id (may be null for today-shifts endpoint).
final String orderId; final String? orderId;
/// Display title. /// Display title.
final String title; final String title;
@@ -93,6 +99,15 @@ class Shift extends Equatable {
/// Free-form notes for the shift. /// Free-form notes for the shift.
final String? notes; 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;
/// Serialises to JSON. /// Serialises to JSON.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
@@ -111,6 +126,9 @@ class Shift extends Equatable {
'requiredWorkers': requiredWorkers, 'requiredWorkers': requiredWorkers,
'assignedWorkers': assignedWorkers, 'assignedWorkers': assignedWorkers,
'notes': notes, 'notes': notes,
'clockInMode': clockInMode,
'allowClockInOverride': allowClockInOverride,
'nfcTagId': nfcTagId,
}; };
} }
@@ -140,5 +158,8 @@ class Shift extends Equatable {
requiredWorkers, requiredWorkers,
assignedWorkers, assignedWorkers,
notes, notes,
clockInMode,
allowClockInOverride,
nfcTagId,
]; ];
} }

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/application_status.dart';
import 'package:krow_domain/src/entities/enums/assignment_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/enums/order_type.dart';
import 'package:krow_domain/src/entities/shifts/shift.dart';
/// Full detail view of a shift for the staff member. /// Full detail view of a shift for the staff member.
/// ///
@@ -18,17 +19,27 @@ class ShiftDetail extends Equatable {
this.description, this.description,
required this.location, required this.location,
this.address, this.address,
required this.clientName,
this.latitude,
this.longitude,
required this.date, required this.date,
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
required this.roleId, required this.roleId,
required this.roleName, required this.roleName,
required this.hourlyRateCents, required this.hourlyRateCents,
required this.hourlyRate,
required this.totalRateCents,
required this.totalRate,
required this.orderType, required this.orderType,
required this.requiredCount, required this.requiredCount,
required this.confirmedCount, required this.confirmedCount,
this.assignmentStatus, this.assignmentStatus,
this.applicationStatus, this.applicationStatus,
this.clockInMode,
required this.allowClockInOverride,
this.geofenceRadiusMeters,
this.nfcTagId,
}); });
/// Deserialises from the V2 API JSON response. /// Deserialises from the V2 API JSON response.
@@ -39,12 +50,18 @@ class ShiftDetail extends Equatable {
description: json['description'] as String?, description: json['description'] as String?,
location: json['location'] as String? ?? '', location: json['location'] as String? ?? '',
address: json['address'] 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), date: DateTime.parse(json['date'] as String),
startTime: DateTime.parse(json['startTime'] as String), startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String), endTime: DateTime.parse(json['endTime'] as String),
roleId: json['roleId'] as String, roleId: json['roleId'] as String,
roleName: json['roleName'] as String, roleName: json['roleName'] as String,
hourlyRateCents: json['hourlyRateCents'] 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,
orderType: OrderType.fromJson(json['orderType'] as String?), orderType: OrderType.fromJson(json['orderType'] as String?),
requiredCount: json['requiredCount'] as int? ?? 1, requiredCount: json['requiredCount'] as int? ?? 1,
confirmedCount: json['confirmedCount'] as int? ?? 0, confirmedCount: json['confirmedCount'] as int? ?? 0,
@@ -54,6 +71,10 @@ class ShiftDetail extends Equatable {
applicationStatus: json['applicationStatus'] != null applicationStatus: json['applicationStatus'] != null
? ApplicationStatus.fromJson(json['applicationStatus'] as String?) ? ApplicationStatus.fromJson(json['applicationStatus'] as String?)
: null, : 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. /// Street address of the shift location.
final String? address; 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). /// Date of the shift (same as startTime, kept for display grouping).
final DateTime date; final DateTime date;
@@ -90,6 +120,15 @@ class ShiftDetail extends Equatable {
/// Pay rate in cents per hour. /// Pay rate in cents per hour.
final int hourlyRateCents; 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. /// Order type.
final OrderType orderType; final OrderType orderType;
@@ -105,6 +144,26 @@ class ShiftDetail extends Equatable {
/// Current worker's application status, if applied. /// Current worker's application status, if applied.
final ApplicationStatus? applicationStatus; 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. /// Serialises to JSON.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
@@ -113,17 +172,27 @@ class ShiftDetail extends Equatable {
'description': description, 'description': description,
'location': location, 'location': location,
'address': address, 'address': address,
'clientName': clientName,
'latitude': latitude,
'longitude': longitude,
'date': date.toIso8601String(), 'date': date.toIso8601String(),
'startTime': startTime.toIso8601String(), 'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(), 'endTime': endTime.toIso8601String(),
'roleId': roleId, 'roleId': roleId,
'roleName': roleName, 'roleName': roleName,
'hourlyRateCents': hourlyRateCents, 'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'totalRateCents': totalRateCents,
'totalRate': totalRate,
'orderType': orderType.toJson(), 'orderType': orderType.toJson(),
'requiredCount': requiredCount, 'requiredCount': requiredCount,
'confirmedCount': confirmedCount, 'confirmedCount': confirmedCount,
'assignmentStatus': assignmentStatus?.toJson(), 'assignmentStatus': assignmentStatus?.toJson(),
'applicationStatus': applicationStatus?.toJson(), 'applicationStatus': applicationStatus?.toJson(),
'clockInMode': clockInMode,
'allowClockInOverride': allowClockInOverride,
'geofenceRadiusMeters': geofenceRadiusMeters,
'nfcTagId': nfcTagId,
}; };
} }
@@ -134,16 +203,26 @@ class ShiftDetail extends Equatable {
description, description,
location, location,
address, address,
clientName,
latitude,
longitude,
date, date,
startTime, startTime,
endTime, endTime,
roleId, roleId,
roleName, roleName,
hourlyRateCents, hourlyRateCents,
hourlyRate,
totalRateCents,
totalRate,
orderType, orderType,
requiredCount, requiredCount,
confirmedCount, confirmedCount,
assignmentStatus, assignmentStatus,
applicationStatus, applicationStatus,
clockInMode,
allowClockInOverride,
geofenceRadiusMeters,
nfcTagId,
]; ];
} }

View File

@@ -1,6 +1,8 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart';
import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart';
import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart'; import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart';
/// Implementation of [ClockInRepositoryInterface] using the V2 REST API. /// Implementation of [ClockInRepositoryInterface] using the V2 REST API.
@@ -19,7 +21,8 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
final ApiResponse response = await _apiService.get( final ApiResponse response = await _apiService.get(
StaffEndpoints.clockInShiftsToday, StaffEndpoints.clockInShiftsToday,
); );
final List<dynamic> items = response.data['items'] as List<dynamic>; final List<dynamic> items =
response.data['items'] as List<dynamic>? ?? <dynamic>[];
return items return items
.map( .map(
(dynamic json) => (dynamic json) =>
@@ -37,36 +40,20 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
} }
@override @override
Future<AttendanceStatus> clockIn({ Future<AttendanceStatus> clockIn(ClockInArguments arguments) async {
required String shiftId,
String? notes,
}) async {
await _apiService.post( await _apiService.post(
StaffEndpoints.clockIn, StaffEndpoints.clockIn,
data: <String, dynamic>{ data: arguments.toJson(),
'shiftId': shiftId,
'sourceType': 'GEO',
if (notes != null && notes.isNotEmpty) 'notes': notes,
},
); );
// Re-fetch the attendance status to get the canonical state after clock-in. // Re-fetch the attendance status to get the canonical state after clock-in.
return getAttendanceStatus(); return getAttendanceStatus();
} }
@override @override
Future<AttendanceStatus> clockOut({ Future<AttendanceStatus> clockOut(ClockOutArguments arguments) async {
String? notes,
int? breakTimeMinutes,
String? shiftId,
}) async {
await _apiService.post( await _apiService.post(
StaffEndpoints.clockOut, StaffEndpoints.clockOut,
data: <String, dynamic>{ data: arguments.toJson(),
if (shiftId != null) 'shiftId': shiftId,
'sourceType': 'GEO',
if (notes != null && notes.isNotEmpty) 'notes': notes,
if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes,
},
); );
// Re-fetch the attendance status to get the canonical state after clock-out. // Re-fetch the attendance status to get the canonical state after clock-out.
return getAttendanceStatus(); return getAttendanceStatus();
@@ -76,14 +63,19 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
static Shift _mapTodayShiftJsonToShift(Map<String, dynamic> json) { static Shift _mapTodayShiftJsonToShift(Map<String, dynamic> json) {
return Shift( return Shift(
id: json['shiftId'] as String, id: json['shiftId'] as String,
orderId: json['orderId'] as String? ?? '', orderId: null,
title: json['clientName'] as String? ?? json['roleName'] as String? ?? '', title: json['clientName'] as String? ?? json['roleName'] as String? ?? '',
status: ShiftStatus.assigned, status: ShiftStatus.assigned,
startsAt: DateTime.parse(json['startTime'] as String), startsAt: DateTime.parse(json['startTime'] as String),
endsAt: DateTime.parse(json['endTime'] as String), endsAt: DateTime.parse(json['endTime'] as String),
locationName: json['location'] as String?, locationName: json['locationAddress'] as String? ??
json['location'] as String?,
latitude: Shift.parseDouble(json['latitude']), latitude: Shift.parseDouble(json['latitude']),
longitude: Shift.parseDouble(json['longitude']), longitude: Shift.parseDouble(json['longitude']),
geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?,
clockInMode: json['clockInMode'] as String?,
allowClockInOverride: json['allowClockInOverride'] as bool?,
nfcTagId: json['nfcTagId'] as String?,
requiredWorkers: 0, requiredWorkers: 0,
assignedWorkers: 0, assignedWorkers: 0,
); );

View File

@@ -1,7 +1,50 @@
// ignore_for_file: avoid_print // ignore_for_file: avoid_print
// Print statements are intentional — background isolates cannot use
// dart:developer or structured loggers from the DI container.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// Lightweight HTTP client for background isolate API calls.
///
/// Cannot use Dio or DI — uses [HttpClient] directly with auth tokens
/// from [StorageService] (SharedPreferences, works across isolates).
class BackgroundApiClient {
/// Creates a [BackgroundApiClient] with its own HTTP client and storage.
BackgroundApiClient() : _client = HttpClient(), _storage = StorageService();
final HttpClient _client;
final StorageService _storage;
/// POSTs JSON to [path] under the V2 API base URL.
///
/// Returns the HTTP status code, or null if no auth token is available.
Future<int?> post(String path, Map<String, dynamic> body) async {
final String? token = await _storage.getString(
BackgroundGeofenceService._keyAuthToken,
);
if (token == null || token.isEmpty) {
print('[BackgroundApiClient] No auth token stored, skipping POST');
return null;
}
final Uri uri = Uri.parse('${AppConfig.v2ApiBaseUrl}$path');
final HttpClientRequest request = await _client.postUrl(uri);
request.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
request.write(jsonEncode(body));
final HttpClientResponse response = await request.close();
await response.drain<void>();
return response.statusCode;
}
/// Closes the underlying [HttpClient].
void dispose() => _client.close(force: false);
}
/// Top-level callback dispatcher for background geofence tasks. /// Top-level callback dispatcher for background geofence tasks.
/// ///
/// Must be a top-level function because workmanager executes it in a separate /// Must be a top-level function because workmanager executes it in a separate
@@ -13,83 +56,134 @@ import 'package:krow_domain/krow_domain.dart';
/// is retained solely for this entry-point pattern. /// is retained solely for this entry-point pattern.
@pragma('vm:entry-point') @pragma('vm:entry-point')
void backgroundGeofenceDispatcher() { void backgroundGeofenceDispatcher() {
const BackgroundTaskService().executeTask( const BackgroundTaskService().executeTask((
(String task, Map<String, dynamic>? inputData) async { String task,
print('[BackgroundGeofence] Task triggered: $task'); Map<String, dynamic>? inputData,
print('[BackgroundGeofence] Input data: $inputData'); ) async {
print( print('[BackgroundGeofence] Task triggered: $task');
'[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}', print('[BackgroundGeofence] Input data: $inputData');
); print(
'[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}',
);
final double? targetLat = inputData?['targetLat'] as double?; final double? targetLat = inputData?['targetLat'] as double?;
final double? targetLng = inputData?['targetLng'] as double?; final double? targetLng = inputData?['targetLng'] as double?;
final String? shiftId = inputData?['shiftId'] as String?; final String? shiftId = inputData?['shiftId'] as String?;
final double geofenceRadius =
(inputData?['geofenceRadiusMeters'] as num?)?.toDouble() ??
BackgroundGeofenceService.defaultGeofenceRadiusMeters;
print( print(
'[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, ' '[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, '
'shiftId=$shiftId', 'shiftId=$shiftId, geofenceRadius=${geofenceRadius.round()}m',
); );
if (targetLat == null || targetLng == null) { if (targetLat == null || targetLng == null) {
print( print('[BackgroundGeofence] Missing target coordinates, skipping check');
'[BackgroundGeofence] Missing target coordinates, skipping check',
);
return true;
}
try {
const LocationService locationService = LocationService();
final DeviceLocation location = await locationService.getCurrentLocation();
print(
'[BackgroundGeofence] Current position: '
'lat=${location.latitude}, lng=${location.longitude}',
);
final double distance = calculateDistance(
location.latitude,
location.longitude,
targetLat,
targetLng,
);
print(
'[BackgroundGeofence] Distance from target: ${distance.round()}m',
);
if (distance > BackgroundGeofenceService.geofenceRadiusMeters) {
print(
'[BackgroundGeofence] Worker is outside geofence '
'(${distance.round()}m > '
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), '
'showing notification',
);
final String title = inputData?['leftGeofenceTitle'] as String? ??
"You've Left the Workplace";
final String body = inputData?['leftGeofenceBody'] as String? ??
'You appear to be more than 500m from your shift location.';
final NotificationService notificationService =
NotificationService();
await notificationService.showNotification(
id: BackgroundGeofenceService.leftGeofenceNotificationId,
title: title,
body: body,
);
} else {
print(
'[BackgroundGeofence] Worker is within geofence '
'(${distance.round()}m <= '
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)',
);
}
} catch (e) {
print('[BackgroundGeofence] Error during background check: $e');
}
print('[BackgroundGeofence] Background check completed');
return true; return true;
}, }
);
final BackgroundApiClient client = BackgroundApiClient();
try {
const LocationService locationService = LocationService();
final DeviceLocation location = await locationService
.getCurrentLocation();
print(
'[BackgroundGeofence] Current position: '
'lat=${location.latitude}, lng=${location.longitude}',
);
final double distance = calculateDistance(
location.latitude,
location.longitude,
targetLat,
targetLng,
);
print('[BackgroundGeofence] Distance from target: ${distance.round()}m');
// POST location stream to the V2 API before geofence check.
unawaited(
_postLocationStream(
client: client,
shiftId: shiftId,
location: location,
),
);
if (distance > geofenceRadius) {
print(
'[BackgroundGeofence] Worker is outside geofence '
'(${distance.round()}m > '
'${geofenceRadius.round()}m), '
'showing notification',
);
// Fallback for when localized strings are not available in the
// background isolate. The primary path passes localized strings
// via inputData from the UI layer.
final String title =
inputData?['leftGeofenceTitle'] as String? ??
'You have left the work area';
final String body =
inputData?['leftGeofenceBody'] as String? ??
'You appear to have moved outside your shift location.';
final NotificationService notificationService = NotificationService();
await notificationService.showNotification(
id: BackgroundGeofenceService.leftGeofenceNotificationId,
title: title,
body: body,
);
} else {
print(
'[BackgroundGeofence] Worker is within geofence '
'(${distance.round()}m <= '
'${geofenceRadius.round()}m)',
);
}
} catch (e) {
print('[BackgroundGeofence] Error during background check: $e');
} finally {
client.dispose();
}
print('[BackgroundGeofence] Background check completed');
return true;
});
}
/// Posts a location data point to the V2 location-streams endpoint.
///
/// Uses [BackgroundApiClient] for isolate-safe HTTP access.
/// Failures are silently caught — location streaming is best-effort.
Future<void> _postLocationStream({
required BackgroundApiClient client,
required String? shiftId,
required DeviceLocation location,
}) async {
if (shiftId == null) return;
try {
final int? status = await client.post(
StaffEndpoints.locationStreams.path,
<String, dynamic>{
'shiftId': shiftId,
'sourceType': 'GEO',
'points': <Map<String, dynamic>>[
<String, dynamic>{
'capturedAt': location.timestamp.toUtc().toIso8601String(),
'latitude': location.latitude,
'longitude': location.longitude,
'accuracyMeters': location.accuracy,
},
],
'metadata': <String, String>{'source': 'background-workmanager'},
},
);
print('[BackgroundGeofence] Location stream POST status: $status');
} catch (e) {
print('[BackgroundGeofence] Location stream POST failed: $e');
}
} }
/// Service that manages periodic background geofence checks while clocked in. /// Service that manages periodic background geofence checks while clocked in.
@@ -98,13 +192,12 @@ void backgroundGeofenceDispatcher() {
/// delivery is handled by [ClockInNotificationService]. The background isolate /// delivery is handled by [ClockInNotificationService]. The background isolate
/// logic lives in the top-level [backgroundGeofenceDispatcher] function above. /// logic lives in the top-level [backgroundGeofenceDispatcher] function above.
class BackgroundGeofenceService { class BackgroundGeofenceService {
/// Creates a [BackgroundGeofenceService] instance. /// Creates a [BackgroundGeofenceService] instance.
BackgroundGeofenceService({ BackgroundGeofenceService({
required BackgroundTaskService backgroundTaskService, required BackgroundTaskService backgroundTaskService,
required StorageService storageService, required StorageService storageService,
}) : _backgroundTaskService = backgroundTaskService, }) : _backgroundTaskService = backgroundTaskService,
_storageService = storageService; _storageService = storageService;
/// The core background task service for scheduling periodic work. /// The core background task service for scheduling periodic work.
final BackgroundTaskService _backgroundTaskService; final BackgroundTaskService _backgroundTaskService;
@@ -124,6 +217,9 @@ class BackgroundGeofenceService {
/// Storage key for the active tracking flag. /// Storage key for the active tracking flag.
static const String _keyTrackingActive = 'geofence_tracking_active'; static const String _keyTrackingActive = 'geofence_tracking_active';
/// Storage key for the Firebase auth token used in background isolate.
static const String _keyAuthToken = 'geofence_auth_token';
/// Unique task name for the periodic background check. /// Unique task name for the periodic background check.
static const String taskUniqueName = 'geofence_background_check'; static const String taskUniqueName = 'geofence_background_check';
@@ -136,8 +232,12 @@ class BackgroundGeofenceService {
/// it directly (background isolate has no DI access). /// it directly (background isolate has no DI access).
static const int leftGeofenceNotificationId = 2; static const int leftGeofenceNotificationId = 2;
/// Geofence radius in meters. /// Default geofence radius in meters, used as fallback when no per-shift
static const double geofenceRadiusMeters = 500; /// radius is provided.
static const double defaultGeofenceRadiusMeters = 500;
/// Storage key for the per-shift geofence radius.
static const String _keyGeofenceRadius = 'geofence_radius_meters';
/// Starts periodic 15-minute background geofence checks. /// Starts periodic 15-minute background geofence checks.
/// ///
@@ -150,12 +250,17 @@ class BackgroundGeofenceService {
required String shiftId, required String shiftId,
required String leftGeofenceTitle, required String leftGeofenceTitle,
required String leftGeofenceBody, required String leftGeofenceBody,
double geofenceRadiusMeters = defaultGeofenceRadiusMeters,
String? authToken,
}) async { }) async {
await Future.wait(<Future<bool>>[ await Future.wait(<Future<bool>>[
_storageService.setDouble(_keyTargetLat, targetLat), _storageService.setDouble(_keyTargetLat, targetLat),
_storageService.setDouble(_keyTargetLng, targetLng), _storageService.setDouble(_keyTargetLng, targetLng),
_storageService.setString(_keyShiftId, shiftId), _storageService.setString(_keyShiftId, shiftId),
_storageService.setDouble(_keyGeofenceRadius, geofenceRadiusMeters),
_storageService.setBool(_keyTrackingActive, true), _storageService.setBool(_keyTrackingActive, true),
if (authToken != null)
_storageService.setString(_keyAuthToken, authToken),
]); ]);
await _backgroundTaskService.registerPeriodicTask( await _backgroundTaskService.registerPeriodicTask(
@@ -166,6 +271,7 @@ class BackgroundGeofenceService {
'targetLat': targetLat, 'targetLat': targetLat,
'targetLng': targetLng, 'targetLng': targetLng,
'shiftId': shiftId, 'shiftId': shiftId,
'geofenceRadiusMeters': geofenceRadiusMeters,
'leftGeofenceTitle': leftGeofenceTitle, 'leftGeofenceTitle': leftGeofenceTitle,
'leftGeofenceBody': leftGeofenceBody, 'leftGeofenceBody': leftGeofenceBody,
}, },
@@ -182,10 +288,20 @@ class BackgroundGeofenceService {
_storageService.remove(_keyTargetLat), _storageService.remove(_keyTargetLat),
_storageService.remove(_keyTargetLng), _storageService.remove(_keyTargetLng),
_storageService.remove(_keyShiftId), _storageService.remove(_keyShiftId),
_storageService.remove(_keyGeofenceRadius),
_storageService.remove(_keyAuthToken),
_storageService.setBool(_keyTrackingActive, false), _storageService.setBool(_keyTrackingActive, false),
]); ]);
} }
/// Stores a fresh auth token for background isolate API calls.
///
/// Called by the foreground [GeofenceBloc] both initially and
/// periodically (~45 min) to keep the token fresh across long shifts.
Future<void> storeAuthToken(String token) async {
await _storageService.setString(_keyAuthToken, token);
}
/// Whether background tracking is currently active. /// Whether background tracking is currently active.
Future<bool> get isTrackingActive async { Future<bool> get isTrackingActive async {
final bool? active = await _storageService.getBool(_keyTrackingActive); final bool? active = await _storageService.getBool(_keyTrackingActive);

View File

@@ -7,13 +7,99 @@ class ClockInArguments extends UseCaseArgument {
const ClockInArguments({ const ClockInArguments({
required this.shiftId, required this.shiftId,
this.notes, this.notes,
this.deviceId,
this.latitude,
this.longitude,
this.accuracyMeters,
this.capturedAt,
this.overrideReason,
this.nfcTagId,
this.proofNonce,
this.proofTimestamp,
this.attestationProvider,
this.attestationToken,
}); });
/// The ID of the shift to clock in to. /// The ID of the shift to clock in to.
final String shiftId; final String shiftId;
/// Optional notes provided by the user during clock-in. /// Optional notes provided by the user during clock-in.
final String? notes; final String? notes;
/// Device identifier for audit trail.
final String? deviceId;
/// Latitude of the device at clock-in time.
final double? latitude;
/// Longitude of the device at clock-in time.
final double? longitude;
/// Horizontal accuracy of the GPS fix in meters.
final double? accuracyMeters;
/// Timestamp when the location was captured on-device.
final DateTime? capturedAt;
/// Justification when the worker overrides a geofence check.
final String? overrideReason;
/// NFC tag identifier when clocking in via NFC tap.
final String? nfcTagId;
/// Server-generated nonce for proof-of-presence validation.
final String? proofNonce;
/// Device-local timestamp when the proof was captured.
final DateTime? proofTimestamp;
/// Name of the attestation provider (e.g. `'apple'`, `'android'`).
final String? attestationProvider;
/// Signed attestation token from the device integrity API.
final String? attestationToken;
/// Serializes the arguments to a JSON map for the V2 API request body.
///
/// Only includes non-null fields. The `sourceType` is inferred from
/// whether [nfcTagId] is present.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'shiftId': shiftId,
'sourceType': nfcTagId != null ? 'NFC' : 'GEO',
if (notes != null && notes!.isNotEmpty) 'notes': notes,
if (deviceId != null) 'deviceId': deviceId,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (accuracyMeters != null) 'accuracyMeters': accuracyMeters,
if (capturedAt != null)
'capturedAt': capturedAt!.toUtc().toIso8601String(),
if (overrideReason != null && overrideReason!.isNotEmpty)
'overrideReason': overrideReason,
if (nfcTagId != null) 'nfcTagId': nfcTagId,
if (proofNonce != null) 'proofNonce': proofNonce,
if (proofTimestamp != null)
'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(),
if (attestationProvider != null)
'attestationProvider': attestationProvider,
if (attestationToken != null) 'attestationToken': attestationToken,
};
}
@override @override
List<Object?> get props => <Object?>[shiftId, notes]; List<Object?> get props => <Object?>[
shiftId,
notes,
deviceId,
latitude,
longitude,
accuracyMeters,
capturedAt,
overrideReason,
nfcTagId,
proofNonce,
proofTimestamp,
attestationProvider,
attestationToken,
];
} }

View File

@@ -7,6 +7,17 @@ class ClockOutArguments extends UseCaseArgument {
this.notes, this.notes,
this.breakTimeMinutes, this.breakTimeMinutes,
this.shiftId, this.shiftId,
this.deviceId,
this.latitude,
this.longitude,
this.accuracyMeters,
this.capturedAt,
this.overrideReason,
this.nfcTagId,
this.proofNonce,
this.proofTimestamp,
this.attestationProvider,
this.attestationToken,
}); });
/// Optional notes provided by the user during clock-out. /// Optional notes provided by the user during clock-out.
@@ -18,6 +29,82 @@ class ClockOutArguments extends UseCaseArgument {
/// The shift id used by the V2 API to resolve the assignment. /// The shift id used by the V2 API to resolve the assignment.
final String? shiftId; final String? shiftId;
/// Device identifier for audit trail.
final String? deviceId;
/// Latitude of the device at clock-out time.
final double? latitude;
/// Longitude of the device at clock-out time.
final double? longitude;
/// Horizontal accuracy of the GPS fix in meters.
final double? accuracyMeters;
/// Timestamp when the location was captured on-device.
final DateTime? capturedAt;
/// Justification when the worker overrides a geofence check.
final String? overrideReason;
/// NFC tag identifier when clocking out via NFC tap.
final String? nfcTagId;
/// Server-generated nonce for proof-of-presence validation.
final String? proofNonce;
/// Device-local timestamp when the proof was captured.
final DateTime? proofTimestamp;
/// Name of the attestation provider (e.g. `'apple'`, `'android'`).
final String? attestationProvider;
/// Signed attestation token from the device integrity API.
final String? attestationToken;
/// Serializes the arguments to a JSON map for the V2 API request body.
///
/// Only includes non-null fields. The `sourceType` is inferred from
/// whether [nfcTagId] is present.
Map<String, dynamic> toJson() {
return <String, dynamic>{
if (shiftId != null) 'shiftId': shiftId,
'sourceType': nfcTagId != null ? 'NFC' : 'GEO',
if (notes != null && notes!.isNotEmpty) 'notes': notes,
if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes,
if (deviceId != null) 'deviceId': deviceId,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (accuracyMeters != null) 'accuracyMeters': accuracyMeters,
if (capturedAt != null)
'capturedAt': capturedAt!.toUtc().toIso8601String(),
if (overrideReason != null && overrideReason!.isNotEmpty)
'overrideReason': overrideReason,
if (nfcTagId != null) 'nfcTagId': nfcTagId,
if (proofNonce != null) 'proofNonce': proofNonce,
if (proofTimestamp != null)
'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(),
if (attestationProvider != null)
'attestationProvider': attestationProvider,
if (attestationToken != null) 'attestationToken': attestationToken,
};
}
@override @override
List<Object?> get props => <Object?>[notes, breakTimeMinutes, shiftId]; List<Object?> get props => <Object?>[
notes,
breakTimeMinutes,
shiftId,
deviceId,
latitude,
longitude,
accuracyMeters,
capturedAt,
overrideReason,
nfcTagId,
proofNonce,
proofTimestamp,
attestationProvider,
attestationToken,
];
} }

View File

@@ -1,8 +1,11 @@
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// Repository interface for Clock In/Out functionality import '../arguments/clock_in_arguments.dart';
abstract class ClockInRepositoryInterface { import '../arguments/clock_out_arguments.dart';
/// Repository interface for Clock In/Out functionality.
abstract interface class ClockInRepositoryInterface {
/// Retrieves the shifts assigned to the user for the current day. /// Retrieves the shifts assigned to the user for the current day.
/// Returns empty list if no shift is assigned for today. /// Returns empty list if no shift is assigned for today.
Future<List<Shift>> getTodaysShifts(); Future<List<Shift>> getTodaysShifts();
@@ -11,17 +14,12 @@ abstract class ClockInRepositoryInterface {
/// This helps in restoring the UI state if the app was killed. /// This helps in restoring the UI state if the app was killed.
Future<AttendanceStatus> getAttendanceStatus(); Future<AttendanceStatus> getAttendanceStatus();
/// Checks the user in for the specified [shiftId]. /// Checks the user in using the fields from [arguments].
/// Returns the updated [AttendanceStatus]. /// Returns the updated [AttendanceStatus].
Future<AttendanceStatus> clockIn({required String shiftId, String? notes}); Future<AttendanceStatus> clockIn(ClockInArguments arguments);
/// Checks the user out for the currently active shift. /// Checks the user out using the fields from [arguments].
/// ///
/// The V2 API resolves the assignment from [shiftId]. Optionally accepts /// The V2 API resolves the assignment from the shift ID.
/// [breakTimeMinutes] if tracked. Future<AttendanceStatus> clockOut(ClockOutArguments arguments);
Future<AttendanceStatus> clockOut({
String? notes,
int? breakTimeMinutes,
String? shiftId,
});
} }

View File

@@ -11,9 +11,6 @@ class ClockInUseCase implements UseCase<ClockInArguments, AttendanceStatus> {
@override @override
Future<AttendanceStatus> call(ClockInArguments arguments) { Future<AttendanceStatus> call(ClockInArguments arguments) {
return _repository.clockIn( return _repository.clockIn(arguments);
shiftId: arguments.shiftId,
notes: arguments.notes,
);
} }
} }

View File

@@ -11,10 +11,6 @@ class ClockOutUseCase implements UseCase<ClockOutArguments, AttendanceStatus> {
@override @override
Future<AttendanceStatus> call(ClockOutArguments arguments) { Future<AttendanceStatus> call(ClockOutArguments arguments) {
return _repository.clockOut( return _repository.clockOut(arguments);
notes: arguments.notes,
breakTimeMinutes: arguments.breakTimeMinutes,
shiftId: arguments.shiftId,
);
} }
} }

View File

@@ -0,0 +1,75 @@
import 'package:krow_domain/krow_domain.dart';
import '../validators/clock_in_validation_context.dart';
import '../validators/validators/time_window_validator.dart';
/// Holds the computed time-window check-in/check-out availability flags.
class TimeWindowFlags {
/// Creates a [TimeWindowFlags] with default allowed values.
const TimeWindowFlags({
this.isCheckInAllowed = true,
this.isCheckOutAllowed = true,
this.checkInAvailabilityTime,
this.checkOutAvailabilityTime,
});
/// Whether the time window currently allows check-in.
final bool isCheckInAllowed;
/// Whether the time window currently allows check-out.
final bool isCheckOutAllowed;
/// Formatted time when check-in becomes available, or `null`.
final String? checkInAvailabilityTime;
/// Formatted time when check-out becomes available, or `null`.
final String? checkOutAvailabilityTime;
}
/// Computes time-window check-in/check-out flags for the given [shift].
///
/// Returns a [TimeWindowFlags] indicating whether the current time falls
/// within the allowed clock-in and clock-out windows. Uses
/// [TimeWindowValidator] for the underlying validation logic.
TimeWindowFlags computeTimeWindowFlags(Shift? shift) {
if (shift == null) {
return const TimeWindowFlags();
}
const TimeWindowValidator validator = TimeWindowValidator();
final DateTime shiftStart = shift.startsAt;
final DateTime shiftEnd = shift.endsAt;
// Check-in window.
bool isCheckInAllowed = true;
String? checkInAvailabilityTime;
final ClockInValidationContext checkInCtx = ClockInValidationContext(
isCheckingIn: true,
shiftStartTime: shiftStart,
);
isCheckInAllowed = validator.validate(checkInCtx).isValid;
if (!isCheckInAllowed) {
checkInAvailabilityTime =
TimeWindowValidator.getAvailabilityTime(shiftStart);
}
// Check-out window.
bool isCheckOutAllowed = true;
String? checkOutAvailabilityTime;
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
isCheckingIn: false,
shiftEndTime: shiftEnd,
);
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
if (!isCheckOutAllowed) {
checkOutAvailabilityTime =
TimeWindowValidator.getAvailabilityTime(shiftEnd);
}
return TimeWindowFlags(
isCheckInAllowed: isCheckInAllowed,
isCheckOutAllowed: isCheckOutAllowed,
checkInAvailabilityTime: checkInAvailabilityTime,
checkOutAvailabilityTime: checkOutAvailabilityTime,
);
}

View File

@@ -4,21 +4,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../../../domain/arguments/clock_in_arguments.dart'; import 'package:staff_clock_in/src/data/services/background_geofence_service.dart';
import '../../../domain/arguments/clock_out_arguments.dart'; import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart';
import '../../../domain/usecases/clock_in_usecase.dart'; import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart';
import '../../../domain/usecases/clock_out_usecase.dart'; import 'package:staff_clock_in/src/domain/usecases/clock_in_usecase.dart';
import '../../../domain/usecases/get_attendance_status_usecase.dart'; import 'package:staff_clock_in/src/domain/usecases/clock_out_usecase.dart';
import '../../../domain/usecases/get_todays_shift_usecase.dart'; import 'package:staff_clock_in/src/domain/usecases/get_attendance_status_usecase.dart';
import '../../../domain/validators/clock_in_validation_context.dart'; import 'package:staff_clock_in/src/domain/usecases/get_todays_shift_usecase.dart';
import '../../../domain/validators/clock_in_validation_result.dart'; import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart';
import '../../../domain/validators/validators/composite_clock_in_validator.dart'; import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart';
import '../../../domain/validators/validators/time_window_validator.dart'; import 'package:staff_clock_in/src/domain/utils/time_window_utils.dart';
import '../geofence/geofence_bloc.dart'; import 'package:staff_clock_in/src/domain/validators/validators/composite_clock_in_validator.dart';
import '../geofence/geofence_event.dart'; import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_bloc.dart';
import '../geofence/geofence_state.dart'; import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_event.dart';
import 'clock_in_event.dart'; import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_state.dart';
import 'clock_in_state.dart'; import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_event.dart';
import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_state.dart';
/// BLoC responsible for clock-in/clock-out operations and shift management. /// BLoC responsible for clock-in/clock-out operations and shift management.
/// ///
@@ -92,7 +93,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
selectedShift ??= shifts.last; selectedShift ??= shifts.last;
} }
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( final TimeWindowFlags timeFlags = computeTimeWindowFlags(
selectedShift, selectedShift,
); );
@@ -122,7 +123,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
ShiftSelected event, ShiftSelected event,
Emitter<ClockInState> emit, Emitter<ClockInState> emit,
) { ) {
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift); final TimeWindowFlags timeFlags = computeTimeWindowFlags(event.shift);
emit(state.copyWith( emit(state.copyWith(
selectedShift: event.shift, selectedShift: event.shift,
isCheckInAllowed: timeFlags.isCheckInAllowed, isCheckInAllowed: timeFlags.isCheckInAllowed,
@@ -201,8 +202,20 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
final DeviceLocation? location = geofenceState.currentLocation;
final AttendanceStatus newStatus = await _clockIn( final AttendanceStatus newStatus = await _clockIn(
ClockInArguments(shiftId: event.shiftId, notes: event.notes), ClockInArguments(
shiftId: event.shiftId,
notes: event.notes,
latitude: location?.latitude,
longitude: location?.longitude,
accuracyMeters: location?.accuracy,
capturedAt: location?.timestamp,
overrideReason: geofenceState.isGeofenceOverridden
? geofenceState.overrideNotes
: null,
),
); );
emit(state.copyWith( emit(state.copyWith(
status: ClockInStatus.success, status: ClockInStatus.success,
@@ -224,20 +237,39 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
/// Handles a clock-out request. /// Handles a clock-out request.
/// ///
/// Emits a failure state and returns early when no active shift ID is
/// available — this prevents the API call from being made without a valid
/// shift reference.
/// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc]. /// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc].
Future<void> _onCheckOut( Future<void> _onCheckOut(
CheckOutRequested event, CheckOutRequested event,
Emitter<ClockInState> emit, Emitter<ClockInState> emit,
) async { ) async {
final String? activeShiftId = state.attendance.activeShiftId;
if (activeShiftId == null) {
emit(state.copyWith(
status: ClockInStatus.failure,
errorMessage: 'errors.shift.no_active_shift',
));
return;
}
emit(state.copyWith(status: ClockInStatus.actionInProgress)); emit(state.copyWith(status: ClockInStatus.actionInProgress));
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
final GeofenceState currentGeofence = _geofenceBloc.state;
final DeviceLocation? location = currentGeofence.currentLocation;
final AttendanceStatus newStatus = await _clockOut( final AttendanceStatus newStatus = await _clockOut(
ClockOutArguments( ClockOutArguments(
notes: event.notes, notes: event.notes,
breakTimeMinutes: event.breakTimeMinutes ?? 0, breakTimeMinutes: event.breakTimeMinutes,
shiftId: state.attendance.activeShiftId, shiftId: activeShiftId,
latitude: location?.latitude,
longitude: location?.longitude,
accuracyMeters: location?.accuracy,
capturedAt: location?.timestamp,
), ),
); );
emit(state.copyWith( emit(state.copyWith(
@@ -269,7 +301,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
Emitter<ClockInState> emit, Emitter<ClockInState> emit,
) { ) {
if (state.status != ClockInStatus.success) return; if (state.status != ClockInStatus.success) return;
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( final TimeWindowFlags timeFlags = computeTimeWindowFlags(
state.selectedShift, state.selectedShift,
); );
emit(state.copyWith( emit(state.copyWith(
@@ -299,52 +331,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
return super.close(); return super.close();
} }
/// Computes time-window check-in/check-out flags for the given [shift].
///
/// Uses [TimeWindowValidator] so this business logic stays out of widgets.
static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) {
if (shift == null) {
return const _TimeWindowFlags();
}
const TimeWindowValidator validator = TimeWindowValidator();
final DateTime shiftStart = shift.startsAt;
final DateTime shiftEnd = shift.endsAt;
// Check-in window.
bool isCheckInAllowed = true;
String? checkInAvailabilityTime;
final ClockInValidationContext checkInCtx = ClockInValidationContext(
isCheckingIn: true,
shiftStartTime: shiftStart,
);
isCheckInAllowed = validator.validate(checkInCtx).isValid;
if (!isCheckInAllowed) {
checkInAvailabilityTime =
TimeWindowValidator.getAvailabilityTime(shiftStart);
}
// Check-out window.
bool isCheckOutAllowed = true;
String? checkOutAvailabilityTime;
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
isCheckingIn: false,
shiftEndTime: shiftEnd,
);
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
if (!isCheckOutAllowed) {
checkOutAvailabilityTime =
TimeWindowValidator.getAvailabilityTime(shiftEnd);
}
return _TimeWindowFlags(
isCheckInAllowed: isCheckInAllowed,
isCheckOutAllowed: isCheckOutAllowed,
checkInAvailabilityTime: checkInAvailabilityTime,
checkOutAvailabilityTime: checkOutAvailabilityTime,
);
}
/// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the /// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the
/// geofence has target coordinates. /// geofence has target coordinates.
void _dispatchBackgroundTrackingStarted({ void _dispatchBackgroundTrackingStarted({
@@ -361,6 +347,9 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
shiftId: activeShiftId, shiftId: activeShiftId,
targetLat: geofenceState.targetLat!, targetLat: geofenceState.targetLat!,
targetLng: geofenceState.targetLng!, targetLng: geofenceState.targetLng!,
geofenceRadiusMeters:
state.selectedShift?.geofenceRadiusMeters?.toDouble() ??
BackgroundGeofenceService.defaultGeofenceRadiusMeters,
greetingTitle: event.clockInGreetingTitle, greetingTitle: event.clockInGreetingTitle,
greetingBody: event.clockInGreetingBody, greetingBody: event.clockInGreetingBody,
leftGeofenceTitle: event.leftGeofenceTitle, leftGeofenceTitle: event.leftGeofenceTitle,
@@ -370,26 +359,3 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
} }
} }
} }
/// Internal value holder for time-window computation results.
class _TimeWindowFlags {
/// Creates a [_TimeWindowFlags] with default allowed values.
const _TimeWindowFlags({
this.isCheckInAllowed = true,
this.isCheckOutAllowed = true,
this.checkInAvailabilityTime,
this.checkOutAvailabilityTime,
});
/// Whether the time window currently allows check-in.
final bool isCheckInAllowed;
/// Whether the time window currently allows check-out.
final bool isCheckOutAllowed;
/// Formatted time when check-in becomes available, or `null`.
final String? checkInAvailabilityTime;
/// Formatted time when check-out becomes available, or `null`.
final String? checkOutAvailabilityTime;
}

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
@@ -25,9 +26,11 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
required GeofenceServiceInterface geofenceService, required GeofenceServiceInterface geofenceService,
required BackgroundGeofenceService backgroundGeofenceService, required BackgroundGeofenceService backgroundGeofenceService,
required ClockInNotificationService notificationService, required ClockInNotificationService notificationService,
required AuthTokenProvider authTokenProvider,
}) : _geofenceService = geofenceService, }) : _geofenceService = geofenceService,
_backgroundGeofenceService = backgroundGeofenceService, _backgroundGeofenceService = backgroundGeofenceService,
_notificationService = notificationService, _notificationService = notificationService,
_authTokenProvider = authTokenProvider,
super(const GeofenceState.initial()) { super(const GeofenceState.initial()) {
on<GeofenceStarted>(_onStarted); on<GeofenceStarted>(_onStarted);
on<GeofenceResultUpdated>(_onResultUpdated); on<GeofenceResultUpdated>(_onResultUpdated);
@@ -52,6 +55,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
/// The notification service for clock-in related notifications. /// The notification service for clock-in related notifications.
final ClockInNotificationService _notificationService; final ClockInNotificationService _notificationService;
/// Provides fresh Firebase ID tokens for background isolate storage.
final AuthTokenProvider _authTokenProvider;
/// Periodic timer that refreshes the auth token in SharedPreferences
/// so the background isolate always has a valid token for API calls.
Timer? _tokenRefreshTimer;
/// How often to refresh the auth token for background use.
/// Set to 45 minutes — well before Firebase's 1-hour expiry.
static const Duration _tokenRefreshInterval = Duration(minutes: 45);
/// Active subscription to the foreground geofence location stream. /// Active subscription to the foreground geofence location stream.
StreamSubscription<GeofenceResult>? _geofenceSubscription; StreamSubscription<GeofenceResult>? _geofenceSubscription;
@@ -239,6 +253,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
shiftId: event.shiftId, shiftId: event.shiftId,
leftGeofenceTitle: event.leftGeofenceTitle, leftGeofenceTitle: event.leftGeofenceTitle,
leftGeofenceBody: event.leftGeofenceBody, leftGeofenceBody: event.leftGeofenceBody,
geofenceRadiusMeters: event.geofenceRadiusMeters,
);
// Get and store initial auth token for background location streaming.
await _refreshAndStoreToken();
// Start periodic token refresh to keep it valid across long shifts.
_tokenRefreshTimer?.cancel();
_tokenRefreshTimer = Timer.periodic(
_tokenRefreshInterval,
(_) => _refreshAndStoreToken(),
); );
// Show greeting notification using localized strings from the UI. // Show greeting notification using localized strings from the UI.
@@ -261,6 +286,9 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
BackgroundTrackingStopped event, BackgroundTrackingStopped event,
Emitter<GeofenceState> emit, Emitter<GeofenceState> emit,
) async { ) async {
_tokenRefreshTimer?.cancel();
_tokenRefreshTimer = null;
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
@@ -298,6 +326,8 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
GeofenceStopped event, GeofenceStopped event,
Emitter<GeofenceState> emit, Emitter<GeofenceState> emit,
) async { ) async {
_tokenRefreshTimer?.cancel();
_tokenRefreshTimer = null;
await _geofenceSubscription?.cancel(); await _geofenceSubscription?.cancel();
_geofenceSubscription = null; _geofenceSubscription = null;
await _serviceStatusSubscription?.cancel(); await _serviceStatusSubscription?.cancel();
@@ -305,8 +335,26 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
emit(const GeofenceState.initial()); emit(const GeofenceState.initial());
} }
/// Fetches a fresh Firebase ID token and stores it in SharedPreferences
/// for the background isolate to use.
Future<void> _refreshAndStoreToken() async {
try {
final String? token = await _authTokenProvider.getIdToken(
forceRefresh: true,
);
if (token != null) {
await _backgroundGeofenceService.storeAuthToken(token);
}
} catch (e) {
// Best-effort — if token refresh fails, the background isolate will
// skip the POST (it checks for null/empty token).
developer.log('Token refresh failed: $e', name: 'GeofenceBloc', error: e);
}
}
@override @override
Future<void> close() { Future<void> close() {
_tokenRefreshTimer?.cancel();
_geofenceSubscription?.cancel(); _geofenceSubscription?.cancel();
_serviceStatusSubscription?.cancel(); _serviceStatusSubscription?.cancel();
return super.close(); return super.close();

View File

@@ -73,6 +73,7 @@ class BackgroundTrackingStarted extends GeofenceEvent {
required this.greetingBody, required this.greetingBody,
required this.leftGeofenceTitle, required this.leftGeofenceTitle,
required this.leftGeofenceBody, required this.leftGeofenceBody,
this.geofenceRadiusMeters = 500,
}); });
/// The shift ID being tracked. /// The shift ID being tracked.
@@ -84,6 +85,9 @@ class BackgroundTrackingStarted extends GeofenceEvent {
/// Target longitude of the shift location. /// Target longitude of the shift location.
final double targetLng; final double targetLng;
/// Geofence radius in meters for this shift. Defaults to 500m.
final double geofenceRadiusMeters;
/// Localized greeting notification title passed from the UI layer. /// Localized greeting notification title passed from the UI layer.
final String greetingTitle; final String greetingTitle;
@@ -103,6 +107,7 @@ class BackgroundTrackingStarted extends GeofenceEvent {
shiftId, shiftId,
targetLat, targetLat,
targetLng, targetLng,
geofenceRadiusMeters,
greetingTitle, greetingTitle,
greetingBody, greetingBody,
leftGeofenceTitle, leftGeofenceTitle,

View File

@@ -56,7 +56,7 @@ class AttendanceCard extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: UiConstants.space1),
FittedBox( FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Text( child: Text(
@@ -65,13 +65,13 @@ class AttendanceCard extends StatelessWidget {
), ),
), ),
if (scheduledTime != null) ...<Widget>[ if (scheduledTime != null) ...<Widget>[
const SizedBox(height: 2), const SizedBox(height: UiConstants.space1),
Text( Text(
"Scheduled: $scheduledTime", "Scheduled: $scheduledTime",
style: UiTypography.footnote2r.textInactive, style: UiTypography.footnote2r.textInactive,
), ),
], ],
const SizedBox(height: 2), const SizedBox(height: UiConstants.space1),
Text( Text(
subtitle, subtitle,
style: UiTypography.footnote1r.copyWith(color: UiColors.primary), style: UiTypography.footnote1r.copyWith(color: UiColors.primary),

View File

@@ -281,7 +281,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
size: 12, size: 12,
color: UiColors.textInactive, color: UiColors.textInactive,
), ),
const SizedBox(width: 2), const SizedBox(width: UiConstants.space1),
Text( Text(
i18n.starts_in(min: _getMinutesUntilShift().toString()), i18n.starts_in(min: _getMinutesUntilShift().toString()),
style: UiTypography.titleUppercase4m.textSecondary, style: UiTypography.titleUppercase4m.textSecondary,

View File

@@ -55,7 +55,7 @@ class DateSelector extends StatelessWidget {
: UiColors.foreground, : UiColors.foreground,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: UiConstants.space1),
Text( Text(
DateFormat('E').format(date), DateFormat('E').format(date),
style: UiTypography.footnote2r.copyWith( style: UiTypography.footnote2r.copyWith(

View File

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

View File

@@ -79,6 +79,7 @@ class StaffClockInModule extends Module {
geofenceService: i.get<GeofenceServiceInterface>(), geofenceService: i.get<GeofenceServiceInterface>(),
backgroundGeofenceService: i.get<BackgroundGeofenceService>(), backgroundGeofenceService: i.get<BackgroundGeofenceService>(),
notificationService: i.get<ClockInNotificationService>(), notificationService: i.get<ClockInNotificationService>(),
authTokenProvider: i.get<AuthTokenProvider>(),
), ),
); );
i.add<ClockInBloc>( i.add<ClockInBloc>(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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