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

View File

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

View File

@@ -42,6 +42,9 @@ export 'src/services/session/client_session_store.dart';
export 'src/services/session/staff_session_store.dart';
export 'src/services/session/v2_session_service.dart';
// Auth
export 'src/services/auth/auth_token_provider.dart';
// Device Services
export 'src/services/device/camera/camera_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: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';
/// 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<NotificationService>(() => NotificationService());
i.addLazySingleton<StorageService>(() => StorageService());

View File

@@ -48,6 +48,26 @@ abstract final class ClientEndpoints {
static const ApiEndpoint coverageCoreTeam =
ApiEndpoint('/client/coverage/core-team');
/// Coverage incidents.
static const ApiEndpoint coverageIncidents =
ApiEndpoint('/client/coverage/incidents');
/// Blocked staff.
static const ApiEndpoint coverageBlockedStaff =
ApiEndpoint('/client/coverage/blocked-staff');
/// Coverage swap requests.
static const ApiEndpoint coverageSwapRequests =
ApiEndpoint('/client/coverage/swap-requests');
/// Dispatch teams.
static const ApiEndpoint coverageDispatchTeams =
ApiEndpoint('/client/coverage/dispatch-teams');
/// Dispatch candidates.
static const ApiEndpoint coverageDispatchCandidates =
ApiEndpoint('/client/coverage/dispatch-candidates');
/// Hubs list.
static const ApiEndpoint hubs = ApiEndpoint('/client/hubs');
@@ -162,4 +182,28 @@ abstract final class ClientEndpoints {
/// Cancel late worker assignment.
static ApiEndpoint coverageCancelLateWorker(String assignmentId) =>
ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel');
/// Register or delete device push token (POST to register, DELETE to remove).
static const ApiEndpoint devicesPushTokens =
ApiEndpoint('/client/devices/push-tokens');
/// Create shift manager.
static const ApiEndpoint shiftManagerCreate =
ApiEndpoint('/client/shift-managers');
/// Resolve coverage swap request by ID.
static ApiEndpoint coverageSwapRequestResolve(String id) =>
ApiEndpoint('/client/coverage/swap-requests/$id/resolve');
/// Cancel coverage swap request by ID.
static ApiEndpoint coverageSwapRequestCancel(String id) =>
ApiEndpoint('/client/coverage/swap-requests/$id/cancel');
/// Create dispatch team membership.
static const ApiEndpoint coverageDispatchTeamMembershipsCreate =
ApiEndpoint('/client/coverage/dispatch-teams/memberships');
/// Delete dispatch team membership by ID.
static ApiEndpoint coverageDispatchTeamMembershipsDelete(String id) =>
ApiEndpoint('/client/coverage/dispatch-teams/memberships/$id');
}

View File

@@ -2,39 +2,42 @@ import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
/// rapid orders).
///
/// Paths are at the unified API root level (not under `/core/`).
abstract final class CoreEndpoints {
/// Upload a file.
static const ApiEndpoint uploadFile =
ApiEndpoint('/core/upload-file');
static const ApiEndpoint uploadFile = ApiEndpoint('/upload-file');
/// Create a signed URL for a file.
static const ApiEndpoint createSignedUrl =
ApiEndpoint('/core/create-signed-url');
static const ApiEndpoint createSignedUrl = ApiEndpoint('/create-signed-url');
/// Invoke a Large Language Model.
static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm');
static const ApiEndpoint invokeLlm = ApiEndpoint('/invoke-llm');
/// Root for verification operations.
static const ApiEndpoint verifications =
ApiEndpoint('/core/verifications');
static const ApiEndpoint verifications = ApiEndpoint('/verifications');
/// Get status of a verification job.
static ApiEndpoint verificationStatus(String id) =>
ApiEndpoint('/core/verifications/$id');
ApiEndpoint('/verifications/$id');
/// Review a verification decision.
static ApiEndpoint verificationReview(String id) =>
ApiEndpoint('/core/verifications/$id/review');
ApiEndpoint('/verifications/$id/review');
/// Retry a verification job.
static ApiEndpoint verificationRetry(String id) =>
ApiEndpoint('/core/verifications/$id/retry');
ApiEndpoint('/verifications/$id/retry');
/// Transcribe audio to text for rapid orders.
static const ApiEndpoint transcribeRapidOrder =
ApiEndpoint('/core/rapid-orders/transcribe');
ApiEndpoint('/rapid-orders/transcribe');
/// Parse text to structured rapid order.
static const ApiEndpoint parseRapidOrder =
ApiEndpoint('/core/rapid-orders/parse');
ApiEndpoint('/rapid-orders/parse');
/// Combined transcribe + parse in a single call.
static const ApiEndpoint processRapidOrder =
ApiEndpoint('/rapid-orders/process');
}

View File

@@ -105,6 +105,10 @@ abstract final class StaffEndpoints {
/// Benefits.
static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits');
/// Benefits history.
static const ApiEndpoint benefitsHistory =
ApiEndpoint('/staff/profile/benefits/history');
/// Time card.
static const ApiEndpoint timeCard =
ApiEndpoint('/staff/profile/time-card');
@@ -112,6 +116,10 @@ abstract final class StaffEndpoints {
/// Privacy settings.
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
/// Preferred locations.
static const ApiEndpoint locations =
ApiEndpoint('/staff/profile/locations');
/// FAQs.
static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs');
@@ -177,4 +185,16 @@ abstract final class StaffEndpoints {
/// Delete certificate by ID.
static ApiEndpoint certificateDelete(String certificateId) =>
ApiEndpoint('/staff/profile/certificates/$certificateId');
/// Submit shift for approval.
static ApiEndpoint shiftSubmitForApproval(String shiftId) =>
ApiEndpoint('/staff/shifts/$shiftId/submit-for-approval');
/// Location streams.
static const ApiEndpoint locationStreams =
ApiEndpoint('/staff/location-streams');
/// Register or delete device push token (POST to register, DELETE to remove).
static const ApiEndpoint devicesPushTokens =
ApiEndpoint('/staff/devices/push-tokens');
}

View File

@@ -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",
"spanish": "Español"
},
"session": {
"expired_title": "Session Expired",
"expired_message": "Your session has expired. Please log in again to continue.",
"error_title": "Session Error",
"log_in": "Log In",
"log_out": "Log Out"
},
"settings": {
"language": "Language",
"change_language": "Change Language"
@@ -1337,7 +1344,14 @@
"applying_dialog": {
"title": "Applying"
},
"eligibility_requirements": "Eligibility Requirements"
"eligibility_requirements": "Eligibility Requirements",
"missing_certifications": "You are missing required certifications or documents to claim this shift. Please upload them to continue.",
"go_to_certificates": "Go to Certificates",
"shift_booked": "Shift successfully booked!",
"shift_not_found": "Shift not found",
"shift_declined_success": "Shift declined",
"complete_account_title": "Complete Your Account",
"complete_account_description": "Complete your account to book this shift and start earning"
},
"my_shift_card": {
"submit_for_approval": "Submit for Approval",
@@ -1457,7 +1471,8 @@
"shift": {
"no_open_roles": "There are no open positions available for this shift.",
"application_not_found": "Your application couldn't be found.",
"no_active_shift": "You don't have an active shift to clock out from."
"no_active_shift": "You don't have an active shift to clock out from.",
"not_found": "Shift not found. It may have been removed or is no longer available."
},
"clock_in": {
"location_verification_required": "Please wait for location verification before clocking in.",

View File

@@ -12,6 +12,13 @@
"english": "English",
"spanish": "Español"
},
"session": {
"expired_title": "Sesión Expirada",
"expired_message": "Tu sesión ha expirado. Por favor inicia sesión de nuevo para continuar.",
"error_title": "Error de Sesión",
"log_in": "Iniciar Sesión",
"log_out": "Cerrar Sesión"
},
"settings": {
"language": "Idioma",
"change_language": "Cambiar Idioma"
@@ -1332,7 +1339,14 @@
"applying_dialog": {
"title": "Solicitando"
},
"eligibility_requirements": "Requisitos de Elegibilidad"
"eligibility_requirements": "Requisitos de Elegibilidad",
"missing_certifications": "Te faltan certificaciones o documentos requeridos para reclamar este turno. Por favor, súbelos para continuar.",
"go_to_certificates": "Ir a Certificados",
"shift_booked": "¡Turno reservado con éxito!",
"shift_not_found": "Turno no encontrado",
"shift_declined_success": "Turno rechazado",
"complete_account_title": "Completa Tu Cuenta",
"complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar"
},
"my_shift_card": {
"submit_for_approval": "Enviar para Aprobación",
@@ -1452,7 +1466,8 @@
"shift": {
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
"application_not_found": "No se pudo encontrar tu solicitud.",
"no_active_shift": "No tienes un turno activo para registrar salida."
"no_active_shift": "No tienes un turno activo para registrar salida.",
"not_found": "Turno no encontrado. Puede haber sido eliminado o ya no está disponible."
},
"clock_in": {
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",

View File

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

View File

@@ -18,6 +18,10 @@ class AssignedShift extends Equatable {
required this.startTime,
required this.endTime,
required this.hourlyRateCents,
required this.hourlyRate,
required this.totalRateCents,
required this.totalRate,
required this.clientName,
required this.orderType,
required this.status,
});
@@ -33,6 +37,10 @@ class AssignedShift extends Equatable {
startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String),
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
totalRateCents: json['totalRateCents'] as int? ?? 0,
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
clientName: json['clientName'] as String? ?? '',
orderType: OrderType.fromJson(json['orderType'] as String?),
status: AssignmentStatus.fromJson(json['status'] as String?),
);
@@ -62,6 +70,18 @@ class AssignedShift extends Equatable {
/// Pay rate in cents per hour.
final int hourlyRateCents;
/// Pay rate in dollars per hour.
final double hourlyRate;
/// Total pay for this shift in cents.
final int totalRateCents;
/// Total pay for this shift in dollars.
final double totalRate;
/// Name of the client / business for this shift.
final String clientName;
/// Order type.
final OrderType orderType;
@@ -79,6 +99,10 @@ class AssignedShift extends Equatable {
'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(),
'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'totalRateCents': totalRateCents,
'totalRate': totalRate,
'clientName': clientName,
'orderType': orderType.toJson(),
'status': status.toJson(),
};
@@ -94,6 +118,10 @@ class AssignedShift extends Equatable {
startTime,
endTime,
hourlyRateCents,
hourlyRate,
totalRateCents,
totalRate,
clientName,
orderType,
status,
];

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ class Shift extends Equatable {
/// Creates a [Shift].
const Shift({
required this.id,
required this.orderId,
this.orderId,
required this.title,
required this.status,
required this.startsAt,
@@ -25,13 +25,16 @@ class Shift extends Equatable {
required this.requiredWorkers,
required this.assignedWorkers,
this.notes,
this.clockInMode,
this.allowClockInOverride,
this.nfcTagId,
});
/// Deserialises from the V2 API JSON response.
factory Shift.fromJson(Map<String, dynamic> json) {
return Shift(
id: json['id'] as String,
orderId: json['orderId'] as String,
orderId: json['orderId'] as String?,
title: json['title'] as String? ?? '',
status: ShiftStatus.fromJson(json['status'] as String?),
startsAt: DateTime.parse(json['startsAt'] as String),
@@ -45,14 +48,17 @@ class Shift extends Equatable {
requiredWorkers: json['requiredWorkers'] as int? ?? 1,
assignedWorkers: json['assignedWorkers'] as int? ?? 0,
notes: json['notes'] as String?,
clockInMode: json['clockInMode'] as String?,
allowClockInOverride: json['allowClockInOverride'] as bool?,
nfcTagId: json['nfcTagId'] as String?,
);
}
/// The shift row id.
final String id;
/// The parent order id.
final String orderId;
/// The parent order id (may be null for today-shifts endpoint).
final String? orderId;
/// Display title.
final String title;
@@ -93,6 +99,15 @@ class Shift extends Equatable {
/// Free-form notes for the shift.
final String? notes;
/// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`).
final String? clockInMode;
/// Whether the worker is allowed to override the clock-in method.
final bool? allowClockInOverride;
/// NFC tag identifier for NFC-based clock-in.
final String? nfcTagId;
/// Serialises to JSON.
Map<String, dynamic> toJson() {
return <String, dynamic>{
@@ -111,6 +126,9 @@ class Shift extends Equatable {
'requiredWorkers': requiredWorkers,
'assignedWorkers': assignedWorkers,
'notes': notes,
'clockInMode': clockInMode,
'allowClockInOverride': allowClockInOverride,
'nfcTagId': nfcTagId,
};
}
@@ -140,5 +158,8 @@ class Shift extends Equatable {
requiredWorkers,
assignedWorkers,
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/assignment_status.dart';
import 'package:krow_domain/src/entities/enums/order_type.dart';
import 'package:krow_domain/src/entities/shifts/shift.dart';
/// Full detail view of a shift for the staff member.
///
@@ -18,17 +19,27 @@ class ShiftDetail extends Equatable {
this.description,
required this.location,
this.address,
required this.clientName,
this.latitude,
this.longitude,
required this.date,
required this.startTime,
required this.endTime,
required this.roleId,
required this.roleName,
required this.hourlyRateCents,
required this.hourlyRate,
required this.totalRateCents,
required this.totalRate,
required this.orderType,
required this.requiredCount,
required this.confirmedCount,
this.assignmentStatus,
this.applicationStatus,
this.clockInMode,
required this.allowClockInOverride,
this.geofenceRadiusMeters,
this.nfcTagId,
});
/// Deserialises from the V2 API JSON response.
@@ -39,12 +50,18 @@ class ShiftDetail extends Equatable {
description: json['description'] as String?,
location: json['location'] as String? ?? '',
address: json['address'] as String?,
clientName: json['clientName'] as String? ?? '',
latitude: Shift.parseDouble(json['latitude']),
longitude: Shift.parseDouble(json['longitude']),
date: DateTime.parse(json['date'] as String),
startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String),
roleId: json['roleId'] as String,
roleName: json['roleName'] as String,
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
totalRateCents: json['totalRateCents'] as int? ?? 0,
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
orderType: OrderType.fromJson(json['orderType'] as String?),
requiredCount: json['requiredCount'] as int? ?? 1,
confirmedCount: json['confirmedCount'] as int? ?? 0,
@@ -54,6 +71,10 @@ class ShiftDetail extends Equatable {
applicationStatus: json['applicationStatus'] != null
? ApplicationStatus.fromJson(json['applicationStatus'] as String?)
: null,
clockInMode: json['clockInMode'] as String?,
allowClockInOverride: json['allowClockInOverride'] as bool? ?? false,
geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?,
nfcTagId: json['nfcTagId'] as String?,
);
}
@@ -72,6 +93,15 @@ class ShiftDetail extends Equatable {
/// Street address of the shift location.
final String? address;
/// Name of the client / business for this shift.
final String clientName;
/// Latitude for map display and geofence validation.
final double? latitude;
/// Longitude for map display and geofence validation.
final double? longitude;
/// Date of the shift (same as startTime, kept for display grouping).
final DateTime date;
@@ -90,6 +120,15 @@ class ShiftDetail extends Equatable {
/// Pay rate in cents per hour.
final int hourlyRateCents;
/// Pay rate in dollars per hour.
final double hourlyRate;
/// Total pay for this shift in cents.
final int totalRateCents;
/// Total pay for this shift in dollars.
final double totalRate;
/// Order type.
final OrderType orderType;
@@ -105,6 +144,26 @@ class ShiftDetail extends Equatable {
/// Current worker's application status, if applied.
final ApplicationStatus? applicationStatus;
/// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`).
final String? clockInMode;
/// Whether the worker is allowed to override the clock-in method.
final bool allowClockInOverride;
/// Geofence radius in meters for clock-in validation.
final int? geofenceRadiusMeters;
/// NFC tag identifier for NFC-based clock-in.
final String? nfcTagId;
/// Duration of the shift in hours.
double get durationHours {
return endTime.difference(startTime).inMinutes / 60;
}
/// Estimated total pay in dollars.
double get estimatedTotal => hourlyRate * durationHours;
/// Serialises to JSON.
Map<String, dynamic> toJson() {
return <String, dynamic>{
@@ -113,17 +172,27 @@ class ShiftDetail extends Equatable {
'description': description,
'location': location,
'address': address,
'clientName': clientName,
'latitude': latitude,
'longitude': longitude,
'date': date.toIso8601String(),
'startTime': startTime.toIso8601String(),
'endTime': endTime.toIso8601String(),
'roleId': roleId,
'roleName': roleName,
'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'totalRateCents': totalRateCents,
'totalRate': totalRate,
'orderType': orderType.toJson(),
'requiredCount': requiredCount,
'confirmedCount': confirmedCount,
'assignmentStatus': assignmentStatus?.toJson(),
'applicationStatus': applicationStatus?.toJson(),
'clockInMode': clockInMode,
'allowClockInOverride': allowClockInOverride,
'geofenceRadiusMeters': geofenceRadiusMeters,
'nfcTagId': nfcTagId,
};
}
@@ -134,16 +203,26 @@ class ShiftDetail extends Equatable {
description,
location,
address,
clientName,
latitude,
longitude,
date,
startTime,
endTime,
roleId,
roleName,
hourlyRateCents,
hourlyRate,
totalRateCents,
totalRate,
orderType,
requiredCount,
confirmedCount,
assignmentStatus,
applicationStatus,
clockInMode,
allowClockInOverride,
geofenceRadiusMeters,
nfcTagId,
];
}

View File

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

View File

@@ -1,7 +1,50 @@
// ignore_for_file: avoid_print
// Print statements are intentional — background isolates cannot use
// dart:developer or structured loggers from the DI container.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// Lightweight HTTP client for background isolate API calls.
///
/// Cannot use Dio or DI — uses [HttpClient] directly with auth tokens
/// from [StorageService] (SharedPreferences, works across isolates).
class BackgroundApiClient {
/// Creates a [BackgroundApiClient] with its own HTTP client and storage.
BackgroundApiClient() : _client = HttpClient(), _storage = StorageService();
final HttpClient _client;
final StorageService _storage;
/// POSTs JSON to [path] under the V2 API base URL.
///
/// Returns the HTTP status code, or null if no auth token is available.
Future<int?> post(String path, Map<String, dynamic> body) async {
final String? token = await _storage.getString(
BackgroundGeofenceService._keyAuthToken,
);
if (token == null || token.isEmpty) {
print('[BackgroundApiClient] No auth token stored, skipping POST');
return null;
}
final Uri uri = Uri.parse('${AppConfig.v2ApiBaseUrl}$path');
final HttpClientRequest request = await _client.postUrl(uri);
request.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
request.write(jsonEncode(body));
final HttpClientResponse response = await request.close();
await response.drain<void>();
return response.statusCode;
}
/// Closes the underlying [HttpClient].
void dispose() => _client.close(force: false);
}
/// Top-level callback dispatcher for background geofence tasks.
///
/// Must be a top-level function because workmanager executes it in a separate
@@ -13,8 +56,10 @@ import 'package:krow_domain/krow_domain.dart';
/// is retained solely for this entry-point pattern.
@pragma('vm:entry-point')
void backgroundGeofenceDispatcher() {
const BackgroundTaskService().executeTask(
(String task, Map<String, dynamic>? inputData) async {
const BackgroundTaskService().executeTask((
String task,
Map<String, dynamic>? inputData,
) async {
print('[BackgroundGeofence] Task triggered: $task');
print('[BackgroundGeofence] Input data: $inputData');
print(
@@ -24,22 +69,25 @@ void backgroundGeofenceDispatcher() {
final double? targetLat = inputData?['targetLat'] as double?;
final double? targetLng = inputData?['targetLng'] as double?;
final String? shiftId = inputData?['shiftId'] as String?;
final double geofenceRadius =
(inputData?['geofenceRadiusMeters'] as num?)?.toDouble() ??
BackgroundGeofenceService.defaultGeofenceRadiusMeters;
print(
'[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, '
'shiftId=$shiftId',
'shiftId=$shiftId, geofenceRadius=${geofenceRadius.round()}m',
);
if (targetLat == null || targetLng == null) {
print(
'[BackgroundGeofence] Missing target coordinates, skipping check',
);
print('[BackgroundGeofence] Missing target coordinates, skipping check');
return true;
}
final BackgroundApiClient client = BackgroundApiClient();
try {
const LocationService locationService = LocationService();
final DeviceLocation location = await locationService.getCurrentLocation();
final DeviceLocation location = await locationService
.getCurrentLocation();
print(
'[BackgroundGeofence] Current position: '
'lat=${location.latitude}, lng=${location.longitude}',
@@ -51,25 +99,36 @@ void backgroundGeofenceDispatcher() {
targetLat,
targetLng,
);
print(
'[BackgroundGeofence] Distance from target: ${distance.round()}m',
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 > BackgroundGeofenceService.geofenceRadiusMeters) {
if (distance > geofenceRadius) {
print(
'[BackgroundGeofence] Worker is outside geofence '
'(${distance.round()}m > '
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), '
'${geofenceRadius.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.';
// 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();
final NotificationService notificationService = NotificationService();
await notificationService.showNotification(
id: BackgroundGeofenceService.leftGeofenceNotificationId,
title: title,
@@ -79,17 +138,52 @@ void backgroundGeofenceDispatcher() {
print(
'[BackgroundGeofence] Worker is within geofence '
'(${distance.round()}m <= '
'${BackgroundGeofenceService.geofenceRadiusMeters.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.
@@ -98,7 +192,6 @@ void backgroundGeofenceDispatcher() {
/// delivery is handled by [ClockInNotificationService]. The background isolate
/// logic lives in the top-level [backgroundGeofenceDispatcher] function above.
class BackgroundGeofenceService {
/// Creates a [BackgroundGeofenceService] instance.
BackgroundGeofenceService({
required BackgroundTaskService backgroundTaskService,
@@ -124,6 +217,9 @@ class BackgroundGeofenceService {
/// Storage key for the active tracking flag.
static const String _keyTrackingActive = 'geofence_tracking_active';
/// Storage key for the Firebase auth token used in background isolate.
static const String _keyAuthToken = 'geofence_auth_token';
/// Unique task name for the periodic background check.
static const String taskUniqueName = 'geofence_background_check';
@@ -136,8 +232,12 @@ class BackgroundGeofenceService {
/// it directly (background isolate has no DI access).
static const int leftGeofenceNotificationId = 2;
/// Geofence radius in meters.
static const double geofenceRadiusMeters = 500;
/// Default geofence radius in meters, used as fallback when no per-shift
/// radius is provided.
static const double defaultGeofenceRadiusMeters = 500;
/// Storage key for the per-shift geofence radius.
static const String _keyGeofenceRadius = 'geofence_radius_meters';
/// Starts periodic 15-minute background geofence checks.
///
@@ -150,12 +250,17 @@ class BackgroundGeofenceService {
required String shiftId,
required String leftGeofenceTitle,
required String leftGeofenceBody,
double geofenceRadiusMeters = defaultGeofenceRadiusMeters,
String? authToken,
}) async {
await Future.wait(<Future<bool>>[
_storageService.setDouble(_keyTargetLat, targetLat),
_storageService.setDouble(_keyTargetLng, targetLng),
_storageService.setString(_keyShiftId, shiftId),
_storageService.setDouble(_keyGeofenceRadius, geofenceRadiusMeters),
_storageService.setBool(_keyTrackingActive, true),
if (authToken != null)
_storageService.setString(_keyAuthToken, authToken),
]);
await _backgroundTaskService.registerPeriodicTask(
@@ -166,6 +271,7 @@ class BackgroundGeofenceService {
'targetLat': targetLat,
'targetLng': targetLng,
'shiftId': shiftId,
'geofenceRadiusMeters': geofenceRadiusMeters,
'leftGeofenceTitle': leftGeofenceTitle,
'leftGeofenceBody': leftGeofenceBody,
},
@@ -182,10 +288,20 @@ class BackgroundGeofenceService {
_storageService.remove(_keyTargetLat),
_storageService.remove(_keyTargetLng),
_storageService.remove(_keyShiftId),
_storageService.remove(_keyGeofenceRadius),
_storageService.remove(_keyAuthToken),
_storageService.setBool(_keyTrackingActive, false),
]);
}
/// Stores a fresh auth token for background isolate API calls.
///
/// Called by the foreground [GeofenceBloc] both initially and
/// periodically (~45 min) to keep the token fresh across long shifts.
Future<void> storeAuthToken(String token) async {
await _storageService.setString(_keyAuthToken, token);
}
/// Whether background tracking is currently active.
Future<bool> get isTrackingActive async {
final bool? active = await _storageService.getBool(_keyTrackingActive);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,21 +4,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/arguments/clock_in_arguments.dart';
import '../../../domain/arguments/clock_out_arguments.dart';
import '../../../domain/usecases/clock_in_usecase.dart';
import '../../../domain/usecases/clock_out_usecase.dart';
import '../../../domain/usecases/get_attendance_status_usecase.dart';
import '../../../domain/usecases/get_todays_shift_usecase.dart';
import '../../../domain/validators/clock_in_validation_context.dart';
import '../../../domain/validators/clock_in_validation_result.dart';
import '../../../domain/validators/validators/composite_clock_in_validator.dart';
import '../../../domain/validators/validators/time_window_validator.dart';
import '../geofence/geofence_bloc.dart';
import '../geofence/geofence_event.dart';
import '../geofence/geofence_state.dart';
import 'clock_in_event.dart';
import 'clock_in_state.dart';
import 'package:staff_clock_in/src/data/services/background_geofence_service.dart';
import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart';
import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart';
import 'package:staff_clock_in/src/domain/usecases/clock_in_usecase.dart';
import 'package:staff_clock_in/src/domain/usecases/clock_out_usecase.dart';
import 'package:staff_clock_in/src/domain/usecases/get_attendance_status_usecase.dart';
import 'package:staff_clock_in/src/domain/usecases/get_todays_shift_usecase.dart';
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart';
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart';
import 'package:staff_clock_in/src/domain/utils/time_window_utils.dart';
import 'package:staff_clock_in/src/domain/validators/validators/composite_clock_in_validator.dart';
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_bloc.dart';
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_event.dart';
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_state.dart';
import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_event.dart';
import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_state.dart';
/// BLoC responsible for clock-in/clock-out operations and shift management.
///
@@ -92,7 +93,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
selectedShift ??= shifts.last;
}
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
final TimeWindowFlags timeFlags = computeTimeWindowFlags(
selectedShift,
);
@@ -122,7 +123,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
ShiftSelected event,
Emitter<ClockInState> emit,
) {
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift);
final TimeWindowFlags timeFlags = computeTimeWindowFlags(event.shift);
emit(state.copyWith(
selectedShift: event.shift,
isCheckInAllowed: timeFlags.isCheckInAllowed,
@@ -201,8 +202,20 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
await handleError(
emit: emit.call,
action: () async {
final DeviceLocation? location = geofenceState.currentLocation;
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(
status: ClockInStatus.success,
@@ -224,20 +237,39 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
/// Handles a clock-out request.
///
/// Emits a failure state and returns early when no active shift ID is
/// available — this prevents the API call from being made without a valid
/// shift reference.
/// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc].
Future<void> _onCheckOut(
CheckOutRequested event,
Emitter<ClockInState> emit,
) async {
final String? activeShiftId = state.attendance.activeShiftId;
if (activeShiftId == null) {
emit(state.copyWith(
status: ClockInStatus.failure,
errorMessage: 'errors.shift.no_active_shift',
));
return;
}
emit(state.copyWith(status: ClockInStatus.actionInProgress));
await handleError(
emit: emit.call,
action: () async {
final GeofenceState currentGeofence = _geofenceBloc.state;
final DeviceLocation? location = currentGeofence.currentLocation;
final AttendanceStatus newStatus = await _clockOut(
ClockOutArguments(
notes: event.notes,
breakTimeMinutes: event.breakTimeMinutes ?? 0,
shiftId: state.attendance.activeShiftId,
breakTimeMinutes: event.breakTimeMinutes,
shiftId: activeShiftId,
latitude: location?.latitude,
longitude: location?.longitude,
accuracyMeters: location?.accuracy,
capturedAt: location?.timestamp,
),
);
emit(state.copyWith(
@@ -269,7 +301,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
Emitter<ClockInState> emit,
) {
if (state.status != ClockInStatus.success) return;
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
final TimeWindowFlags timeFlags = computeTimeWindowFlags(
state.selectedShift,
);
emit(state.copyWith(
@@ -299,52 +331,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
return super.close();
}
/// Computes time-window check-in/check-out flags for the given [shift].
///
/// Uses [TimeWindowValidator] so this business logic stays out of widgets.
static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) {
if (shift == null) {
return const _TimeWindowFlags();
}
const TimeWindowValidator validator = TimeWindowValidator();
final DateTime shiftStart = shift.startsAt;
final DateTime shiftEnd = shift.endsAt;
// Check-in window.
bool isCheckInAllowed = true;
String? checkInAvailabilityTime;
final ClockInValidationContext checkInCtx = ClockInValidationContext(
isCheckingIn: true,
shiftStartTime: shiftStart,
);
isCheckInAllowed = validator.validate(checkInCtx).isValid;
if (!isCheckInAllowed) {
checkInAvailabilityTime =
TimeWindowValidator.getAvailabilityTime(shiftStart);
}
// Check-out window.
bool isCheckOutAllowed = true;
String? checkOutAvailabilityTime;
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
isCheckingIn: false,
shiftEndTime: shiftEnd,
);
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
if (!isCheckOutAllowed) {
checkOutAvailabilityTime =
TimeWindowValidator.getAvailabilityTime(shiftEnd);
}
return _TimeWindowFlags(
isCheckInAllowed: isCheckInAllowed,
isCheckOutAllowed: isCheckOutAllowed,
checkInAvailabilityTime: checkInAvailabilityTime,
checkOutAvailabilityTime: checkOutAvailabilityTime,
);
}
/// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the
/// geofence has target coordinates.
void _dispatchBackgroundTrackingStarted({
@@ -361,6 +347,9 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
shiftId: activeShiftId,
targetLat: geofenceState.targetLat!,
targetLng: geofenceState.targetLng!,
geofenceRadiusMeters:
state.selectedShift?.geofenceRadiusMeters?.toDouble() ??
BackgroundGeofenceService.defaultGeofenceRadiusMeters,
greetingTitle: event.clockInGreetingTitle,
greetingBody: event.clockInGreetingBody,
leftGeofenceTitle: event.leftGeofenceTitle,
@@ -370,26 +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:developer' as developer;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
@@ -25,9 +26,11 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
required GeofenceServiceInterface geofenceService,
required BackgroundGeofenceService backgroundGeofenceService,
required ClockInNotificationService notificationService,
required AuthTokenProvider authTokenProvider,
}) : _geofenceService = geofenceService,
_backgroundGeofenceService = backgroundGeofenceService,
_notificationService = notificationService,
_authTokenProvider = authTokenProvider,
super(const GeofenceState.initial()) {
on<GeofenceStarted>(_onStarted);
on<GeofenceResultUpdated>(_onResultUpdated);
@@ -52,6 +55,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
/// The notification service for clock-in related notifications.
final ClockInNotificationService _notificationService;
/// Provides fresh Firebase ID tokens for background isolate storage.
final AuthTokenProvider _authTokenProvider;
/// Periodic timer that refreshes the auth token in SharedPreferences
/// so the background isolate always has a valid token for API calls.
Timer? _tokenRefreshTimer;
/// How often to refresh the auth token for background use.
/// Set to 45 minutes — well before Firebase's 1-hour expiry.
static const Duration _tokenRefreshInterval = Duration(minutes: 45);
/// Active subscription to the foreground geofence location stream.
StreamSubscription<GeofenceResult>? _geofenceSubscription;
@@ -239,6 +253,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
shiftId: event.shiftId,
leftGeofenceTitle: event.leftGeofenceTitle,
leftGeofenceBody: event.leftGeofenceBody,
geofenceRadiusMeters: event.geofenceRadiusMeters,
);
// Get and store initial auth token for background location streaming.
await _refreshAndStoreToken();
// Start periodic token refresh to keep it valid across long shifts.
_tokenRefreshTimer?.cancel();
_tokenRefreshTimer = Timer.periodic(
_tokenRefreshInterval,
(_) => _refreshAndStoreToken(),
);
// Show greeting notification using localized strings from the UI.
@@ -261,6 +286,9 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
BackgroundTrackingStopped event,
Emitter<GeofenceState> emit,
) async {
_tokenRefreshTimer?.cancel();
_tokenRefreshTimer = null;
await handleError(
emit: emit.call,
action: () async {
@@ -298,6 +326,8 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
GeofenceStopped event,
Emitter<GeofenceState> emit,
) async {
_tokenRefreshTimer?.cancel();
_tokenRefreshTimer = null;
await _geofenceSubscription?.cancel();
_geofenceSubscription = null;
await _serviceStatusSubscription?.cancel();
@@ -305,8 +335,26 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
emit(const GeofenceState.initial());
}
/// Fetches a fresh Firebase ID token and stores it in SharedPreferences
/// for the background isolate to use.
Future<void> _refreshAndStoreToken() async {
try {
final String? token = await _authTokenProvider.getIdToken(
forceRefresh: true,
);
if (token != null) {
await _backgroundGeofenceService.storeAuthToken(token);
}
} catch (e) {
// Best-effort — if token refresh fails, the background isolate will
// skip the POST (it checks for null/empty token).
developer.log('Token refresh failed: $e', name: 'GeofenceBloc', error: e);
}
}
@override
Future<void> close() {
_tokenRefreshTimer?.cancel();
_geofenceSubscription?.cancel();
_serviceStatusSubscription?.cancel();
return super.close();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -107,7 +108,7 @@ class ShiftAssignmentCard extends StatelessWidget {
children: <Widget>[
const Icon(UiIcons.calendar,
size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
const SizedBox(width: UiConstants.space1),
Text(
_formatDate(assignment.startTime),
style: UiTypography.footnote1r.textSecondary,
@@ -115,19 +116,19 @@ class ShiftAssignmentCard extends StatelessWidget {
const SizedBox(width: UiConstants.space3),
const Icon(UiIcons.clock,
size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
const SizedBox(width: UiConstants.space1),
Text(
'${_formatTime(assignment.startTime)} - ${_formatTime(assignment.endTime)}',
style: UiTypography.footnote1r.textSecondary,
),
],
),
const SizedBox(height: 4),
const SizedBox(height: UiConstants.space1),
Row(
children: <Widget>[
const Icon(UiIcons.mapPin,
size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(
assignment.location,
@@ -160,7 +161,10 @@ class ShiftAssignmentCard extends StatelessWidget {
style: TextButton.styleFrom(
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),
@@ -178,14 +182,17 @@ class ShiftAssignmentCard extends StatelessWidget {
),
child: isConfirming
? const SizedBox(
height: 16,
width: 16,
height: UiConstants.space4,
width: UiConstants.space4,
child: CircularProgressIndicator(
strokeWidth: 2,
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:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext;
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.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/shift_card.dart';
/// Tab displaying completed shift history.
class HistoryShiftsTab extends StatelessWidget {
/// Creates a [HistoryShiftsTab].
const HistoryShiftsTab({super.key, required this.historyShifts});
const HistoryShiftsTab({
super.key,
required this.historyShifts,
this.submittedShiftIds = const <String>{},
});
/// Completed shifts.
final List<CompletedShift> historyShifts;
/// Set of shift IDs that have been successfully submitted for approval.
final Set<String> submittedShiftIds;
@override
Widget build(BuildContext context) {
if (historyShifts.isEmpty) {
@@ -32,14 +41,31 @@ class HistoryShiftsTab extends StatelessWidget {
children: <Widget>[
const SizedBox(height: UiConstants.space5),
...historyShifts.map(
(CompletedShift shift) => Padding(
(CompletedShift shift) {
final bool isSubmitted =
submittedShiftIds.contains(shift.shiftId);
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
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),
],

View File

@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.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/widgets/shared/empty_state_view.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
@@ -20,6 +21,7 @@ class MyShiftsTab extends StatefulWidget {
required this.pendingAssignments,
required this.cancelledShifts,
this.initialDate,
this.submittedShiftIds = const <String>{},
});
/// Assigned shifts for the current week.
@@ -34,6 +36,9 @@ class MyShiftsTab extends StatefulWidget {
/// Initial date to select in the calendar.
final DateTime? initialDate;
/// Set of shift IDs that have been successfully submitted for approval.
final Set<String> submittedShiftIds;
@override
State<MyShiftsTab> createState() => _MyShiftsTabState();
}
@@ -42,9 +47,6 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
DateTime _selectedDate = DateTime.now();
int _weekOffset = 0;
/// Tracks which completed-shift cards have been submitted locally.
final Set<String> _submittedShiftIds = <String>{};
@override
void initState() {
super.initState();
@@ -90,20 +92,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
});
}
List<DateTime> _getCalendarDays() {
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)),
);
}
List<DateTime> _getCalendarDays() => getCalendarDaysForOffset(_weekOffset);
void _loadShiftsForCurrentWeek() {
final List<DateTime> calendarDays = _getCalendarDays();
@@ -402,7 +391,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
final bool isCompleted =
shift.status == AssignmentStatus.completed;
final bool isSubmitted =
_submittedShiftIds.contains(shift.shiftId);
widget.submittedShiftIds.contains(shift.shiftId);
return Padding(
padding: const EdgeInsets.only(
@@ -415,9 +404,11 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
showApprovalAction: isCompleted,
isSubmitted: isSubmitted,
onSubmitForApproval: () {
setState(() {
_submittedShiftIds.add(shift.shiftId);
});
ReadContext(context).read<ShiftsBloc>().add(
SubmitForApprovalEvent(
shiftId: shift.shiftId,
),
);
UiSnackbar.show(
context,
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/accept_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/shift_details/shift_details_bloc.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(GetShiftDetailUseCase.new);
i.addLazySingleton(GetProfileCompletionUseCase.new);
i.addLazySingleton(
() => SubmitForApprovalUseCase(i.get<ShiftsRepositoryInterface>()),
);
// BLoC
i.add(
@@ -57,6 +61,7 @@ class StaffShiftsModule extends Module {
getProfileCompletion: i.get(),
acceptShift: i.get(),
declineShift: i.get(),
submitForApproval: i.get(),
),
);
i.add(