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:
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
@@ -84,7 +85,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
if (!_isInitialState) {
|
if (!_isInitialState) {
|
||||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||||
_showSessionErrorDialog(
|
_showSessionErrorDialog(
|
||||||
state.errorMessage ?? 'Session error occurred',
|
state.errorMessage ?? t.session.error_title,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_isInitialState = false;
|
_isInitialState = false;
|
||||||
@@ -101,22 +102,21 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
|
|
||||||
/// Shows a dialog when the session expires.
|
/// Shows a dialog when the session expires.
|
||||||
void _showSessionExpiredDialog() {
|
void _showSessionExpiredDialog() {
|
||||||
|
final Translations translations = t;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Session Expired'),
|
title: Text(translations.session.expired_title),
|
||||||
content: const Text(
|
content: Text(translations.session.expired_message),
|
||||||
'Your session has expired. Please log in again to continue.',
|
|
||||||
),
|
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();
|
Navigator.of(dialogContext).pop();
|
||||||
_proceedToLogin();
|
_proceedToLogin();
|
||||||
},
|
},
|
||||||
child: const Text('Log In'),
|
child: Text(translations.session.log_in),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -126,27 +126,28 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
|
|
||||||
/// Shows a dialog when a session error occurs, with retry option.
|
/// Shows a dialog when a session error occurs, with retry option.
|
||||||
void _showSessionErrorDialog(String errorMessage) {
|
void _showSessionErrorDialog(String errorMessage) {
|
||||||
|
final Translations translations = t;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Session Error'),
|
title: Text(translations.session.error_title),
|
||||||
content: Text(errorMessage),
|
content: Text(errorMessage),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// User can retry by dismissing and continuing
|
// User can retry by dismissing and continuing
|
||||||
Modular.to.popSafe();
|
Navigator.of(dialogContext).pop();
|
||||||
},
|
},
|
||||||
child: const Text('Continue'),
|
child: Text(translations.common.continue_text),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();
|
Navigator.of(dialogContext).pop();
|
||||||
_proceedToLogin();
|
_proceedToLogin();
|
||||||
},
|
},
|
||||||
child: const Text('Log Out'),
|
child: Text(translations.session.log_out),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
@@ -97,7 +98,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
if (!_isInitialState) {
|
if (!_isInitialState) {
|
||||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||||
_showSessionErrorDialog(
|
_showSessionErrorDialog(
|
||||||
state.errorMessage ?? 'Session error occurred',
|
state.errorMessage ?? t.session.error_title,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_isInitialState = false;
|
_isInitialState = false;
|
||||||
@@ -114,22 +115,21 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
|
|
||||||
/// Shows a dialog when the session expires.
|
/// Shows a dialog when the session expires.
|
||||||
void _showSessionExpiredDialog() {
|
void _showSessionExpiredDialog() {
|
||||||
|
final Translations translations = t;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Session Expired'),
|
title: Text(translations.session.expired_title),
|
||||||
content: const Text(
|
content: Text(translations.session.expired_message),
|
||||||
'Your session has expired. Please log in again to continue.',
|
|
||||||
),
|
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();
|
Navigator.of(dialogContext).pop();
|
||||||
_proceedToLogin();
|
_proceedToLogin();
|
||||||
},
|
},
|
||||||
child: const Text('Log In'),
|
child: Text(translations.session.log_in),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -139,27 +139,28 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
|
|
||||||
/// Shows a dialog when a session error occurs, with retry option.
|
/// Shows a dialog when a session error occurs, with retry option.
|
||||||
void _showSessionErrorDialog(String errorMessage) {
|
void _showSessionErrorDialog(String errorMessage) {
|
||||||
|
final Translations translations = t;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Session Error'),
|
title: Text(translations.session.error_title),
|
||||||
content: Text(errorMessage),
|
content: Text(errorMessage),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// User can retry by dismissing and continuing
|
// User can retry by dismissing and continuing
|
||||||
Modular.to.popSafe();
|
Navigator.of(dialogContext).pop();
|
||||||
},
|
},
|
||||||
child: const Text('Continue'),
|
child: Text(translations.common.continue_text),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();
|
Navigator.of(dialogContext).pop();
|
||||||
_proceedToLogin();
|
_proceedToLogin();
|
||||||
},
|
},
|
||||||
child: const Text('Log Out'),
|
child: Text(translations.session.log_out),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export 'src/services/session/client_session_store.dart';
|
|||||||
export 'src/services/session/staff_session_store.dart';
|
export 'src/services/session/staff_session_store.dart';
|
||||||
export 'src/services/session/v2_session_service.dart';
|
export 'src/services/session/v2_session_service.dart';
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
export 'src/services/auth/auth_token_provider.dart';
|
||||||
|
|
||||||
// Device Services
|
// Device Services
|
||||||
export 'src/services/device/camera/camera_service.dart';
|
export 'src/services/device/camera/camera_service.dart';
|
||||||
export 'src/services/device/gallery/gallery_service.dart';
|
export 'src/services/device/gallery/gallery_service.dart';
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import 'package:flutter_modular/flutter_modular.dart';
|
|||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'package:krow_core/src/services/auth/auth_token_provider.dart';
|
||||||
|
import 'package:krow_core/src/services/auth/firebase_auth_token_provider.dart';
|
||||||
|
|
||||||
import '../core.dart';
|
import '../core.dart';
|
||||||
|
|
||||||
/// A module that provides core services and shared dependencies.
|
/// A module that provides core services and shared dependencies.
|
||||||
@@ -57,7 +60,10 @@ class CoreModule extends Module {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. Register Geofence Device Services
|
// 6. Auth Token Provider
|
||||||
|
i.addLazySingleton<AuthTokenProvider>(FirebaseAuthTokenProvider.new);
|
||||||
|
|
||||||
|
// 7. Register Geofence Device Services
|
||||||
i.addLazySingleton<LocationService>(() => const LocationService());
|
i.addLazySingleton<LocationService>(() => const LocationService());
|
||||||
i.addLazySingleton<NotificationService>(() => NotificationService());
|
i.addLazySingleton<NotificationService>(() => NotificationService());
|
||||||
i.addLazySingleton<StorageService>(() => StorageService());
|
i.addLazySingleton<StorageService>(() => StorageService());
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ abstract final class ClientEndpoints {
|
|||||||
static const ApiEndpoint coverageCoreTeam =
|
static const ApiEndpoint coverageCoreTeam =
|
||||||
ApiEndpoint('/client/coverage/core-team');
|
ApiEndpoint('/client/coverage/core-team');
|
||||||
|
|
||||||
|
/// Coverage incidents.
|
||||||
|
static const ApiEndpoint coverageIncidents =
|
||||||
|
ApiEndpoint('/client/coverage/incidents');
|
||||||
|
|
||||||
|
/// Blocked staff.
|
||||||
|
static const ApiEndpoint coverageBlockedStaff =
|
||||||
|
ApiEndpoint('/client/coverage/blocked-staff');
|
||||||
|
|
||||||
|
/// Coverage swap requests.
|
||||||
|
static const ApiEndpoint coverageSwapRequests =
|
||||||
|
ApiEndpoint('/client/coverage/swap-requests');
|
||||||
|
|
||||||
|
/// Dispatch teams.
|
||||||
|
static const ApiEndpoint coverageDispatchTeams =
|
||||||
|
ApiEndpoint('/client/coverage/dispatch-teams');
|
||||||
|
|
||||||
|
/// Dispatch candidates.
|
||||||
|
static const ApiEndpoint coverageDispatchCandidates =
|
||||||
|
ApiEndpoint('/client/coverage/dispatch-candidates');
|
||||||
|
|
||||||
/// Hubs list.
|
/// Hubs list.
|
||||||
static const ApiEndpoint hubs = ApiEndpoint('/client/hubs');
|
static const ApiEndpoint hubs = ApiEndpoint('/client/hubs');
|
||||||
|
|
||||||
@@ -162,4 +182,28 @@ abstract final class ClientEndpoints {
|
|||||||
/// Cancel late worker assignment.
|
/// Cancel late worker assignment.
|
||||||
static ApiEndpoint coverageCancelLateWorker(String assignmentId) =>
|
static ApiEndpoint coverageCancelLateWorker(String assignmentId) =>
|
||||||
ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel');
|
ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel');
|
||||||
|
|
||||||
|
/// Register or delete device push token (POST to register, DELETE to remove).
|
||||||
|
static const ApiEndpoint devicesPushTokens =
|
||||||
|
ApiEndpoint('/client/devices/push-tokens');
|
||||||
|
|
||||||
|
/// Create shift manager.
|
||||||
|
static const ApiEndpoint shiftManagerCreate =
|
||||||
|
ApiEndpoint('/client/shift-managers');
|
||||||
|
|
||||||
|
/// Resolve coverage swap request by ID.
|
||||||
|
static ApiEndpoint coverageSwapRequestResolve(String id) =>
|
||||||
|
ApiEndpoint('/client/coverage/swap-requests/$id/resolve');
|
||||||
|
|
||||||
|
/// Cancel coverage swap request by ID.
|
||||||
|
static ApiEndpoint coverageSwapRequestCancel(String id) =>
|
||||||
|
ApiEndpoint('/client/coverage/swap-requests/$id/cancel');
|
||||||
|
|
||||||
|
/// Create dispatch team membership.
|
||||||
|
static const ApiEndpoint coverageDispatchTeamMembershipsCreate =
|
||||||
|
ApiEndpoint('/client/coverage/dispatch-teams/memberships');
|
||||||
|
|
||||||
|
/// Delete dispatch team membership by ID.
|
||||||
|
static ApiEndpoint coverageDispatchTeamMembershipsDelete(String id) =>
|
||||||
|
ApiEndpoint('/client/coverage/dispatch-teams/memberships/$id');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,42 @@ import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
|
|||||||
|
|
||||||
/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
|
/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
|
||||||
/// rapid orders).
|
/// rapid orders).
|
||||||
|
///
|
||||||
|
/// Paths are at the unified API root level (not under `/core/`).
|
||||||
abstract final class CoreEndpoints {
|
abstract final class CoreEndpoints {
|
||||||
/// Upload a file.
|
/// Upload a file.
|
||||||
static const ApiEndpoint uploadFile =
|
static const ApiEndpoint uploadFile = ApiEndpoint('/upload-file');
|
||||||
ApiEndpoint('/core/upload-file');
|
|
||||||
|
|
||||||
/// Create a signed URL for a file.
|
/// Create a signed URL for a file.
|
||||||
static const ApiEndpoint createSignedUrl =
|
static const ApiEndpoint createSignedUrl = ApiEndpoint('/create-signed-url');
|
||||||
ApiEndpoint('/core/create-signed-url');
|
|
||||||
|
|
||||||
/// Invoke a Large Language Model.
|
/// Invoke a Large Language Model.
|
||||||
static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm');
|
static const ApiEndpoint invokeLlm = ApiEndpoint('/invoke-llm');
|
||||||
|
|
||||||
/// Root for verification operations.
|
/// Root for verification operations.
|
||||||
static const ApiEndpoint verifications =
|
static const ApiEndpoint verifications = ApiEndpoint('/verifications');
|
||||||
ApiEndpoint('/core/verifications');
|
|
||||||
|
|
||||||
/// Get status of a verification job.
|
/// Get status of a verification job.
|
||||||
static ApiEndpoint verificationStatus(String id) =>
|
static ApiEndpoint verificationStatus(String id) =>
|
||||||
ApiEndpoint('/core/verifications/$id');
|
ApiEndpoint('/verifications/$id');
|
||||||
|
|
||||||
/// Review a verification decision.
|
/// Review a verification decision.
|
||||||
static ApiEndpoint verificationReview(String id) =>
|
static ApiEndpoint verificationReview(String id) =>
|
||||||
ApiEndpoint('/core/verifications/$id/review');
|
ApiEndpoint('/verifications/$id/review');
|
||||||
|
|
||||||
/// Retry a verification job.
|
/// Retry a verification job.
|
||||||
static ApiEndpoint verificationRetry(String id) =>
|
static ApiEndpoint verificationRetry(String id) =>
|
||||||
ApiEndpoint('/core/verifications/$id/retry');
|
ApiEndpoint('/verifications/$id/retry');
|
||||||
|
|
||||||
/// Transcribe audio to text for rapid orders.
|
/// Transcribe audio to text for rapid orders.
|
||||||
static const ApiEndpoint transcribeRapidOrder =
|
static const ApiEndpoint transcribeRapidOrder =
|
||||||
ApiEndpoint('/core/rapid-orders/transcribe');
|
ApiEndpoint('/rapid-orders/transcribe');
|
||||||
|
|
||||||
/// Parse text to structured rapid order.
|
/// Parse text to structured rapid order.
|
||||||
static const ApiEndpoint parseRapidOrder =
|
static const ApiEndpoint parseRapidOrder =
|
||||||
ApiEndpoint('/core/rapid-orders/parse');
|
ApiEndpoint('/rapid-orders/parse');
|
||||||
|
|
||||||
|
/// Combined transcribe + parse in a single call.
|
||||||
|
static const ApiEndpoint processRapidOrder =
|
||||||
|
ApiEndpoint('/rapid-orders/process');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ abstract final class StaffEndpoints {
|
|||||||
/// Benefits.
|
/// Benefits.
|
||||||
static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits');
|
static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits');
|
||||||
|
|
||||||
|
/// Benefits history.
|
||||||
|
static const ApiEndpoint benefitsHistory =
|
||||||
|
ApiEndpoint('/staff/profile/benefits/history');
|
||||||
|
|
||||||
/// Time card.
|
/// Time card.
|
||||||
static const ApiEndpoint timeCard =
|
static const ApiEndpoint timeCard =
|
||||||
ApiEndpoint('/staff/profile/time-card');
|
ApiEndpoint('/staff/profile/time-card');
|
||||||
@@ -112,6 +116,10 @@ abstract final class StaffEndpoints {
|
|||||||
/// Privacy settings.
|
/// Privacy settings.
|
||||||
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
|
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
|
||||||
|
|
||||||
|
/// Preferred locations.
|
||||||
|
static const ApiEndpoint locations =
|
||||||
|
ApiEndpoint('/staff/profile/locations');
|
||||||
|
|
||||||
/// FAQs.
|
/// FAQs.
|
||||||
static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs');
|
static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs');
|
||||||
|
|
||||||
@@ -177,4 +185,16 @@ abstract final class StaffEndpoints {
|
|||||||
/// Delete certificate by ID.
|
/// Delete certificate by ID.
|
||||||
static ApiEndpoint certificateDelete(String certificateId) =>
|
static ApiEndpoint certificateDelete(String certificateId) =>
|
||||||
ApiEndpoint('/staff/profile/certificates/$certificateId');
|
ApiEndpoint('/staff/profile/certificates/$certificateId');
|
||||||
|
|
||||||
|
/// Submit shift for approval.
|
||||||
|
static ApiEndpoint shiftSubmitForApproval(String shiftId) =>
|
||||||
|
ApiEndpoint('/staff/shifts/$shiftId/submit-for-approval');
|
||||||
|
|
||||||
|
/// Location streams.
|
||||||
|
static const ApiEndpoint locationStreams =
|
||||||
|
ApiEndpoint('/staff/location-streams');
|
||||||
|
|
||||||
|
/// Register or delete device push token (POST to register, DELETE to remove).
|
||||||
|
static const ApiEndpoint devicesPushTokens =
|
||||||
|
ApiEndpoint('/staff/devices/push-tokens');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/// Provides the current Firebase ID token for API authentication.
|
||||||
|
///
|
||||||
|
/// Lives in core so feature packages can access auth tokens
|
||||||
|
/// without importing firebase_auth directly.
|
||||||
|
abstract interface class AuthTokenProvider {
|
||||||
|
/// Returns the current ID token, refreshing if expired.
|
||||||
|
///
|
||||||
|
/// Pass [forceRefresh] to force a token refresh from Firebase.
|
||||||
|
/// Returns null if no user is signed in.
|
||||||
|
Future<String?> getIdToken({bool forceRefresh});
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@
|
|||||||
"english": "English",
|
"english": "English",
|
||||||
"spanish": "Español"
|
"spanish": "Español"
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"expired_title": "Session Expired",
|
||||||
|
"expired_message": "Your session has expired. Please log in again to continue.",
|
||||||
|
"error_title": "Session Error",
|
||||||
|
"log_in": "Log In",
|
||||||
|
"log_out": "Log Out"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"change_language": "Change Language"
|
"change_language": "Change Language"
|
||||||
@@ -1337,7 +1344,14 @@
|
|||||||
"applying_dialog": {
|
"applying_dialog": {
|
||||||
"title": "Applying"
|
"title": "Applying"
|
||||||
},
|
},
|
||||||
"eligibility_requirements": "Eligibility Requirements"
|
"eligibility_requirements": "Eligibility Requirements",
|
||||||
|
"missing_certifications": "You are missing required certifications or documents to claim this shift. Please upload them to continue.",
|
||||||
|
"go_to_certificates": "Go to Certificates",
|
||||||
|
"shift_booked": "Shift successfully booked!",
|
||||||
|
"shift_not_found": "Shift not found",
|
||||||
|
"shift_declined_success": "Shift declined",
|
||||||
|
"complete_account_title": "Complete Your Account",
|
||||||
|
"complete_account_description": "Complete your account to book this shift and start earning"
|
||||||
},
|
},
|
||||||
"my_shift_card": {
|
"my_shift_card": {
|
||||||
"submit_for_approval": "Submit for Approval",
|
"submit_for_approval": "Submit for Approval",
|
||||||
@@ -1457,7 +1471,8 @@
|
|||||||
"shift": {
|
"shift": {
|
||||||
"no_open_roles": "There are no open positions available for this shift.",
|
"no_open_roles": "There are no open positions available for this shift.",
|
||||||
"application_not_found": "Your application couldn't be found.",
|
"application_not_found": "Your application couldn't be found.",
|
||||||
"no_active_shift": "You don't have an active shift to clock out from."
|
"no_active_shift": "You don't have an active shift to clock out from.",
|
||||||
|
"not_found": "Shift not found. It may have been removed or is no longer available."
|
||||||
},
|
},
|
||||||
"clock_in": {
|
"clock_in": {
|
||||||
"location_verification_required": "Please wait for location verification before clocking in.",
|
"location_verification_required": "Please wait for location verification before clocking in.",
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
"english": "English",
|
"english": "English",
|
||||||
"spanish": "Español"
|
"spanish": "Español"
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"expired_title": "Sesión Expirada",
|
||||||
|
"expired_message": "Tu sesión ha expirado. Por favor inicia sesión de nuevo para continuar.",
|
||||||
|
"error_title": "Error de Sesión",
|
||||||
|
"log_in": "Iniciar Sesión",
|
||||||
|
"log_out": "Cerrar Sesión"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"change_language": "Cambiar Idioma"
|
"change_language": "Cambiar Idioma"
|
||||||
@@ -1332,7 +1339,14 @@
|
|||||||
"applying_dialog": {
|
"applying_dialog": {
|
||||||
"title": "Solicitando"
|
"title": "Solicitando"
|
||||||
},
|
},
|
||||||
"eligibility_requirements": "Requisitos de Elegibilidad"
|
"eligibility_requirements": "Requisitos de Elegibilidad",
|
||||||
|
"missing_certifications": "Te faltan certificaciones o documentos requeridos para reclamar este turno. Por favor, súbelos para continuar.",
|
||||||
|
"go_to_certificates": "Ir a Certificados",
|
||||||
|
"shift_booked": "¡Turno reservado con éxito!",
|
||||||
|
"shift_not_found": "Turno no encontrado",
|
||||||
|
"shift_declined_success": "Turno rechazado",
|
||||||
|
"complete_account_title": "Completa Tu Cuenta",
|
||||||
|
"complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar"
|
||||||
},
|
},
|
||||||
"my_shift_card": {
|
"my_shift_card": {
|
||||||
"submit_for_approval": "Enviar para Aprobación",
|
"submit_for_approval": "Enviar para Aprobación",
|
||||||
@@ -1452,7 +1466,8 @@
|
|||||||
"shift": {
|
"shift": {
|
||||||
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
|
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
|
||||||
"application_not_found": "No se pudo encontrar tu solicitud.",
|
"application_not_found": "No se pudo encontrar tu solicitud.",
|
||||||
"no_active_shift": "No tienes un turno activo para registrar salida."
|
"no_active_shift": "No tienes un turno activo para registrar salida.",
|
||||||
|
"not_found": "Turno no encontrado. Puede haber sido eliminado o ya no está disponible."
|
||||||
},
|
},
|
||||||
"clock_in": {
|
"clock_in": {
|
||||||
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",
|
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ String _translateShiftError(String errorType) {
|
|||||||
return t.errors.shift.application_not_found;
|
return t.errors.shift.application_not_found;
|
||||||
case 'no_active_shift':
|
case 'no_active_shift':
|
||||||
return t.errors.shift.no_active_shift;
|
return t.errors.shift.no_active_shift;
|
||||||
|
case 'not_found':
|
||||||
|
return t.errors.shift.not_found;
|
||||||
default:
|
default:
|
||||||
return t.errors.generic.unknown;
|
return t.errors.generic.unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class AssignedShift extends Equatable {
|
|||||||
required this.startTime,
|
required this.startTime,
|
||||||
required this.endTime,
|
required this.endTime,
|
||||||
required this.hourlyRateCents,
|
required this.hourlyRateCents,
|
||||||
|
required this.hourlyRate,
|
||||||
|
required this.totalRateCents,
|
||||||
|
required this.totalRate,
|
||||||
|
required this.clientName,
|
||||||
required this.orderType,
|
required this.orderType,
|
||||||
required this.status,
|
required this.status,
|
||||||
});
|
});
|
||||||
@@ -33,6 +37,10 @@ class AssignedShift extends Equatable {
|
|||||||
startTime: DateTime.parse(json['startTime'] as String),
|
startTime: DateTime.parse(json['startTime'] as String),
|
||||||
endTime: DateTime.parse(json['endTime'] as String),
|
endTime: DateTime.parse(json['endTime'] as String),
|
||||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||||
|
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
clientName: json['clientName'] as String? ?? '',
|
||||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||||
status: AssignmentStatus.fromJson(json['status'] as String?),
|
status: AssignmentStatus.fromJson(json['status'] as String?),
|
||||||
);
|
);
|
||||||
@@ -62,6 +70,18 @@ class AssignedShift extends Equatable {
|
|||||||
/// Pay rate in cents per hour.
|
/// Pay rate in cents per hour.
|
||||||
final int hourlyRateCents;
|
final int hourlyRateCents;
|
||||||
|
|
||||||
|
/// Pay rate in dollars per hour.
|
||||||
|
final double hourlyRate;
|
||||||
|
|
||||||
|
/// Total pay for this shift in cents.
|
||||||
|
final int totalRateCents;
|
||||||
|
|
||||||
|
/// Total pay for this shift in dollars.
|
||||||
|
final double totalRate;
|
||||||
|
|
||||||
|
/// Name of the client / business for this shift.
|
||||||
|
final String clientName;
|
||||||
|
|
||||||
/// Order type.
|
/// Order type.
|
||||||
final OrderType orderType;
|
final OrderType orderType;
|
||||||
|
|
||||||
@@ -79,6 +99,10 @@ class AssignedShift extends Equatable {
|
|||||||
'startTime': startTime.toIso8601String(),
|
'startTime': startTime.toIso8601String(),
|
||||||
'endTime': endTime.toIso8601String(),
|
'endTime': endTime.toIso8601String(),
|
||||||
'hourlyRateCents': hourlyRateCents,
|
'hourlyRateCents': hourlyRateCents,
|
||||||
|
'hourlyRate': hourlyRate,
|
||||||
|
'totalRateCents': totalRateCents,
|
||||||
|
'totalRate': totalRate,
|
||||||
|
'clientName': clientName,
|
||||||
'orderType': orderType.toJson(),
|
'orderType': orderType.toJson(),
|
||||||
'status': status.toJson(),
|
'status': status.toJson(),
|
||||||
};
|
};
|
||||||
@@ -94,6 +118,10 @@ class AssignedShift extends Equatable {
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
hourlyRateCents,
|
hourlyRateCents,
|
||||||
|
hourlyRate,
|
||||||
|
totalRateCents,
|
||||||
|
totalRate,
|
||||||
|
clientName,
|
||||||
orderType,
|
orderType,
|
||||||
status,
|
status,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,10 +12,18 @@ class CompletedShift extends Equatable {
|
|||||||
required this.shiftId,
|
required this.shiftId,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.location,
|
required this.location,
|
||||||
|
required this.clientName,
|
||||||
required this.date,
|
required this.date,
|
||||||
|
required this.startTime,
|
||||||
|
required this.endTime,
|
||||||
required this.minutesWorked,
|
required this.minutesWorked,
|
||||||
|
required this.hourlyRateCents,
|
||||||
|
required this.hourlyRate,
|
||||||
|
required this.totalRateCents,
|
||||||
|
required this.totalRate,
|
||||||
required this.paymentStatus,
|
required this.paymentStatus,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
this.timesheetStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises from the V2 API JSON response.
|
/// Deserialises from the V2 API JSON response.
|
||||||
@@ -25,10 +33,22 @@ class CompletedShift extends Equatable {
|
|||||||
shiftId: json['shiftId'] as String,
|
shiftId: json['shiftId'] as String,
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
location: json['location'] as String? ?? '',
|
location: json['location'] as String? ?? '',
|
||||||
|
clientName: json['clientName'] as String? ?? '',
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: DateTime.parse(json['date'] as String),
|
||||||
|
startTime: json['startTime'] != null
|
||||||
|
? DateTime.parse(json['startTime'] as String)
|
||||||
|
: DateTime.now(),
|
||||||
|
endTime: json['endTime'] != null
|
||||||
|
? DateTime.parse(json['endTime'] as String)
|
||||||
|
: DateTime.now(),
|
||||||
minutesWorked: json['minutesWorked'] as int? ?? 0,
|
minutesWorked: json['minutesWorked'] as int? ?? 0,
|
||||||
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||||
|
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?),
|
paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?),
|
||||||
status: AssignmentStatus.completed,
|
status: AssignmentStatus.completed,
|
||||||
|
timesheetStatus: json['timesheetStatus'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,18 +64,42 @@ class CompletedShift extends Equatable {
|
|||||||
/// Human-readable location label.
|
/// Human-readable location label.
|
||||||
final String location;
|
final String location;
|
||||||
|
|
||||||
|
/// Name of the client / business for this shift.
|
||||||
|
final String clientName;
|
||||||
|
|
||||||
/// The date the shift was worked.
|
/// The date the shift was worked.
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
|
||||||
|
/// Scheduled start time.
|
||||||
|
final DateTime startTime;
|
||||||
|
|
||||||
|
/// Scheduled end time.
|
||||||
|
final DateTime endTime;
|
||||||
|
|
||||||
/// Total minutes worked (regular + overtime).
|
/// Total minutes worked (regular + overtime).
|
||||||
final int minutesWorked;
|
final int minutesWorked;
|
||||||
|
|
||||||
|
/// Pay rate in cents per hour.
|
||||||
|
final int hourlyRateCents;
|
||||||
|
|
||||||
|
/// Pay rate in dollars per hour.
|
||||||
|
final double hourlyRate;
|
||||||
|
|
||||||
|
/// Total pay for this shift in cents.
|
||||||
|
final int totalRateCents;
|
||||||
|
|
||||||
|
/// Total pay for this shift in dollars.
|
||||||
|
final double totalRate;
|
||||||
|
|
||||||
/// Payment processing status.
|
/// Payment processing status.
|
||||||
final PaymentStatus paymentStatus;
|
final PaymentStatus paymentStatus;
|
||||||
|
|
||||||
/// Assignment status (should always be `completed` for this class).
|
/// Assignment status (should always be `completed` for this class).
|
||||||
final AssignmentStatus status;
|
final AssignmentStatus status;
|
||||||
|
|
||||||
|
/// Timesheet status (e.g. `SUBMITTED`, `APPROVED`, `PAID`, or null).
|
||||||
|
final String? timesheetStatus;
|
||||||
|
|
||||||
/// Serialises to JSON.
|
/// Serialises to JSON.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
@@ -63,9 +107,17 @@ class CompletedShift extends Equatable {
|
|||||||
'shiftId': shiftId,
|
'shiftId': shiftId,
|
||||||
'title': title,
|
'title': title,
|
||||||
'location': location,
|
'location': location,
|
||||||
|
'clientName': clientName,
|
||||||
'date': date.toIso8601String(),
|
'date': date.toIso8601String(),
|
||||||
|
'startTime': startTime.toIso8601String(),
|
||||||
|
'endTime': endTime.toIso8601String(),
|
||||||
'minutesWorked': minutesWorked,
|
'minutesWorked': minutesWorked,
|
||||||
|
'hourlyRateCents': hourlyRateCents,
|
||||||
|
'hourlyRate': hourlyRate,
|
||||||
|
'totalRateCents': totalRateCents,
|
||||||
|
'totalRate': totalRate,
|
||||||
'paymentStatus': paymentStatus.toJson(),
|
'paymentStatus': paymentStatus.toJson(),
|
||||||
|
'timesheetStatus': timesheetStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +127,17 @@ class CompletedShift extends Equatable {
|
|||||||
shiftId,
|
shiftId,
|
||||||
title,
|
title,
|
||||||
location,
|
location,
|
||||||
|
clientName,
|
||||||
date,
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
minutesWorked,
|
minutesWorked,
|
||||||
|
hourlyRateCents,
|
||||||
|
hourlyRate,
|
||||||
|
totalRateCents,
|
||||||
|
totalRate,
|
||||||
paymentStatus,
|
paymentStatus,
|
||||||
|
timesheetStatus,
|
||||||
|
status,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class OpenShift extends Equatable {
|
|||||||
required this.startTime,
|
required this.startTime,
|
||||||
required this.endTime,
|
required this.endTime,
|
||||||
required this.hourlyRateCents,
|
required this.hourlyRateCents,
|
||||||
|
required this.hourlyRate,
|
||||||
required this.orderType,
|
required this.orderType,
|
||||||
required this.instantBook,
|
required this.instantBook,
|
||||||
required this.requiredWorkerCount,
|
required this.requiredWorkerCount,
|
||||||
@@ -33,6 +34,7 @@ class OpenShift extends Equatable {
|
|||||||
startTime: DateTime.parse(json['startTime'] as String),
|
startTime: DateTime.parse(json['startTime'] as String),
|
||||||
endTime: DateTime.parse(json['endTime'] as String),
|
endTime: DateTime.parse(json['endTime'] as String),
|
||||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||||
instantBook: json['instantBook'] as bool? ?? false,
|
instantBook: json['instantBook'] as bool? ?? false,
|
||||||
requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1,
|
requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1,
|
||||||
@@ -63,6 +65,9 @@ class OpenShift extends Equatable {
|
|||||||
/// Pay rate in cents per hour.
|
/// Pay rate in cents per hour.
|
||||||
final int hourlyRateCents;
|
final int hourlyRateCents;
|
||||||
|
|
||||||
|
/// Pay rate in dollars per hour.
|
||||||
|
final double hourlyRate;
|
||||||
|
|
||||||
/// Order type.
|
/// Order type.
|
||||||
final OrderType orderType;
|
final OrderType orderType;
|
||||||
|
|
||||||
@@ -83,6 +88,7 @@ class OpenShift extends Equatable {
|
|||||||
'startTime': startTime.toIso8601String(),
|
'startTime': startTime.toIso8601String(),
|
||||||
'endTime': endTime.toIso8601String(),
|
'endTime': endTime.toIso8601String(),
|
||||||
'hourlyRateCents': hourlyRateCents,
|
'hourlyRateCents': hourlyRateCents,
|
||||||
|
'hourlyRate': hourlyRate,
|
||||||
'orderType': orderType.toJson(),
|
'orderType': orderType.toJson(),
|
||||||
'instantBook': instantBook,
|
'instantBook': instantBook,
|
||||||
'requiredWorkerCount': requiredWorkerCount,
|
'requiredWorkerCount': requiredWorkerCount,
|
||||||
@@ -99,6 +105,7 @@ class OpenShift extends Equatable {
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
hourlyRateCents,
|
hourlyRateCents,
|
||||||
|
hourlyRate,
|
||||||
orderType,
|
orderType,
|
||||||
instantBook,
|
instantBook,
|
||||||
requiredWorkerCount,
|
requiredWorkerCount,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Shift extends Equatable {
|
|||||||
/// Creates a [Shift].
|
/// Creates a [Shift].
|
||||||
const Shift({
|
const Shift({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.orderId,
|
this.orderId,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.startsAt,
|
required this.startsAt,
|
||||||
@@ -25,13 +25,16 @@ class Shift extends Equatable {
|
|||||||
required this.requiredWorkers,
|
required this.requiredWorkers,
|
||||||
required this.assignedWorkers,
|
required this.assignedWorkers,
|
||||||
this.notes,
|
this.notes,
|
||||||
|
this.clockInMode,
|
||||||
|
this.allowClockInOverride,
|
||||||
|
this.nfcTagId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises from the V2 API JSON response.
|
/// Deserialises from the V2 API JSON response.
|
||||||
factory Shift.fromJson(Map<String, dynamic> json) {
|
factory Shift.fromJson(Map<String, dynamic> json) {
|
||||||
return Shift(
|
return Shift(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
orderId: json['orderId'] as String,
|
orderId: json['orderId'] as String?,
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
status: ShiftStatus.fromJson(json['status'] as String?),
|
status: ShiftStatus.fromJson(json['status'] as String?),
|
||||||
startsAt: DateTime.parse(json['startsAt'] as String),
|
startsAt: DateTime.parse(json['startsAt'] as String),
|
||||||
@@ -45,14 +48,17 @@ class Shift extends Equatable {
|
|||||||
requiredWorkers: json['requiredWorkers'] as int? ?? 1,
|
requiredWorkers: json['requiredWorkers'] as int? ?? 1,
|
||||||
assignedWorkers: json['assignedWorkers'] as int? ?? 0,
|
assignedWorkers: json['assignedWorkers'] as int? ?? 0,
|
||||||
notes: json['notes'] as String?,
|
notes: json['notes'] as String?,
|
||||||
|
clockInMode: json['clockInMode'] as String?,
|
||||||
|
allowClockInOverride: json['allowClockInOverride'] as bool?,
|
||||||
|
nfcTagId: json['nfcTagId'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shift row id.
|
/// The shift row id.
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
/// The parent order id.
|
/// The parent order id (may be null for today-shifts endpoint).
|
||||||
final String orderId;
|
final String? orderId;
|
||||||
|
|
||||||
/// Display title.
|
/// Display title.
|
||||||
final String title;
|
final String title;
|
||||||
@@ -93,6 +99,15 @@ class Shift extends Equatable {
|
|||||||
/// Free-form notes for the shift.
|
/// Free-form notes for the shift.
|
||||||
final String? notes;
|
final String? notes;
|
||||||
|
|
||||||
|
/// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`).
|
||||||
|
final String? clockInMode;
|
||||||
|
|
||||||
|
/// Whether the worker is allowed to override the clock-in method.
|
||||||
|
final bool? allowClockInOverride;
|
||||||
|
|
||||||
|
/// NFC tag identifier for NFC-based clock-in.
|
||||||
|
final String? nfcTagId;
|
||||||
|
|
||||||
/// Serialises to JSON.
|
/// Serialises to JSON.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
@@ -111,6 +126,9 @@ class Shift extends Equatable {
|
|||||||
'requiredWorkers': requiredWorkers,
|
'requiredWorkers': requiredWorkers,
|
||||||
'assignedWorkers': assignedWorkers,
|
'assignedWorkers': assignedWorkers,
|
||||||
'notes': notes,
|
'notes': notes,
|
||||||
|
'clockInMode': clockInMode,
|
||||||
|
'allowClockInOverride': allowClockInOverride,
|
||||||
|
'nfcTagId': nfcTagId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,5 +158,8 @@ class Shift extends Equatable {
|
|||||||
requiredWorkers,
|
requiredWorkers,
|
||||||
assignedWorkers,
|
assignedWorkers,
|
||||||
notes,
|
notes,
|
||||||
|
clockInMode,
|
||||||
|
allowClockInOverride,
|
||||||
|
nfcTagId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
|
|||||||
import 'package:krow_domain/src/entities/enums/application_status.dart';
|
import 'package:krow_domain/src/entities/enums/application_status.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
||||||
|
import 'package:krow_domain/src/entities/shifts/shift.dart';
|
||||||
|
|
||||||
/// Full detail view of a shift for the staff member.
|
/// Full detail view of a shift for the staff member.
|
||||||
///
|
///
|
||||||
@@ -18,17 +19,27 @@ class ShiftDetail extends Equatable {
|
|||||||
this.description,
|
this.description,
|
||||||
required this.location,
|
required this.location,
|
||||||
this.address,
|
this.address,
|
||||||
|
required this.clientName,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
required this.date,
|
required this.date,
|
||||||
required this.startTime,
|
required this.startTime,
|
||||||
required this.endTime,
|
required this.endTime,
|
||||||
required this.roleId,
|
required this.roleId,
|
||||||
required this.roleName,
|
required this.roleName,
|
||||||
required this.hourlyRateCents,
|
required this.hourlyRateCents,
|
||||||
|
required this.hourlyRate,
|
||||||
|
required this.totalRateCents,
|
||||||
|
required this.totalRate,
|
||||||
required this.orderType,
|
required this.orderType,
|
||||||
required this.requiredCount,
|
required this.requiredCount,
|
||||||
required this.confirmedCount,
|
required this.confirmedCount,
|
||||||
this.assignmentStatus,
|
this.assignmentStatus,
|
||||||
this.applicationStatus,
|
this.applicationStatus,
|
||||||
|
this.clockInMode,
|
||||||
|
required this.allowClockInOverride,
|
||||||
|
this.geofenceRadiusMeters,
|
||||||
|
this.nfcTagId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises from the V2 API JSON response.
|
/// Deserialises from the V2 API JSON response.
|
||||||
@@ -39,12 +50,18 @@ class ShiftDetail extends Equatable {
|
|||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
location: json['location'] as String? ?? '',
|
location: json['location'] as String? ?? '',
|
||||||
address: json['address'] as String?,
|
address: json['address'] as String?,
|
||||||
|
clientName: json['clientName'] as String? ?? '',
|
||||||
|
latitude: Shift.parseDouble(json['latitude']),
|
||||||
|
longitude: Shift.parseDouble(json['longitude']),
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: DateTime.parse(json['date'] as String),
|
||||||
startTime: DateTime.parse(json['startTime'] as String),
|
startTime: DateTime.parse(json['startTime'] as String),
|
||||||
endTime: DateTime.parse(json['endTime'] as String),
|
endTime: DateTime.parse(json['endTime'] as String),
|
||||||
roleId: json['roleId'] as String,
|
roleId: json['roleId'] as String,
|
||||||
roleName: json['roleName'] as String,
|
roleName: json['roleName'] as String,
|
||||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||||
|
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||||
requiredCount: json['requiredCount'] as int? ?? 1,
|
requiredCount: json['requiredCount'] as int? ?? 1,
|
||||||
confirmedCount: json['confirmedCount'] as int? ?? 0,
|
confirmedCount: json['confirmedCount'] as int? ?? 0,
|
||||||
@@ -54,6 +71,10 @@ class ShiftDetail extends Equatable {
|
|||||||
applicationStatus: json['applicationStatus'] != null
|
applicationStatus: json['applicationStatus'] != null
|
||||||
? ApplicationStatus.fromJson(json['applicationStatus'] as String?)
|
? ApplicationStatus.fromJson(json['applicationStatus'] as String?)
|
||||||
: null,
|
: null,
|
||||||
|
clockInMode: json['clockInMode'] as String?,
|
||||||
|
allowClockInOverride: json['allowClockInOverride'] as bool? ?? false,
|
||||||
|
geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?,
|
||||||
|
nfcTagId: json['nfcTagId'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +93,15 @@ class ShiftDetail extends Equatable {
|
|||||||
/// Street address of the shift location.
|
/// Street address of the shift location.
|
||||||
final String? address;
|
final String? address;
|
||||||
|
|
||||||
|
/// Name of the client / business for this shift.
|
||||||
|
final String clientName;
|
||||||
|
|
||||||
|
/// Latitude for map display and geofence validation.
|
||||||
|
final double? latitude;
|
||||||
|
|
||||||
|
/// Longitude for map display and geofence validation.
|
||||||
|
final double? longitude;
|
||||||
|
|
||||||
/// Date of the shift (same as startTime, kept for display grouping).
|
/// Date of the shift (same as startTime, kept for display grouping).
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
|
||||||
@@ -90,6 +120,15 @@ class ShiftDetail extends Equatable {
|
|||||||
/// Pay rate in cents per hour.
|
/// Pay rate in cents per hour.
|
||||||
final int hourlyRateCents;
|
final int hourlyRateCents;
|
||||||
|
|
||||||
|
/// Pay rate in dollars per hour.
|
||||||
|
final double hourlyRate;
|
||||||
|
|
||||||
|
/// Total pay for this shift in cents.
|
||||||
|
final int totalRateCents;
|
||||||
|
|
||||||
|
/// Total pay for this shift in dollars.
|
||||||
|
final double totalRate;
|
||||||
|
|
||||||
/// Order type.
|
/// Order type.
|
||||||
final OrderType orderType;
|
final OrderType orderType;
|
||||||
|
|
||||||
@@ -105,6 +144,26 @@ class ShiftDetail extends Equatable {
|
|||||||
/// Current worker's application status, if applied.
|
/// Current worker's application status, if applied.
|
||||||
final ApplicationStatus? applicationStatus;
|
final ApplicationStatus? applicationStatus;
|
||||||
|
|
||||||
|
/// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`).
|
||||||
|
final String? clockInMode;
|
||||||
|
|
||||||
|
/// Whether the worker is allowed to override the clock-in method.
|
||||||
|
final bool allowClockInOverride;
|
||||||
|
|
||||||
|
/// Geofence radius in meters for clock-in validation.
|
||||||
|
final int? geofenceRadiusMeters;
|
||||||
|
|
||||||
|
/// NFC tag identifier for NFC-based clock-in.
|
||||||
|
final String? nfcTagId;
|
||||||
|
|
||||||
|
/// Duration of the shift in hours.
|
||||||
|
double get durationHours {
|
||||||
|
return endTime.difference(startTime).inMinutes / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimated total pay in dollars.
|
||||||
|
double get estimatedTotal => hourlyRate * durationHours;
|
||||||
|
|
||||||
/// Serialises to JSON.
|
/// Serialises to JSON.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
@@ -113,17 +172,27 @@ class ShiftDetail extends Equatable {
|
|||||||
'description': description,
|
'description': description,
|
||||||
'location': location,
|
'location': location,
|
||||||
'address': address,
|
'address': address,
|
||||||
|
'clientName': clientName,
|
||||||
|
'latitude': latitude,
|
||||||
|
'longitude': longitude,
|
||||||
'date': date.toIso8601String(),
|
'date': date.toIso8601String(),
|
||||||
'startTime': startTime.toIso8601String(),
|
'startTime': startTime.toIso8601String(),
|
||||||
'endTime': endTime.toIso8601String(),
|
'endTime': endTime.toIso8601String(),
|
||||||
'roleId': roleId,
|
'roleId': roleId,
|
||||||
'roleName': roleName,
|
'roleName': roleName,
|
||||||
'hourlyRateCents': hourlyRateCents,
|
'hourlyRateCents': hourlyRateCents,
|
||||||
|
'hourlyRate': hourlyRate,
|
||||||
|
'totalRateCents': totalRateCents,
|
||||||
|
'totalRate': totalRate,
|
||||||
'orderType': orderType.toJson(),
|
'orderType': orderType.toJson(),
|
||||||
'requiredCount': requiredCount,
|
'requiredCount': requiredCount,
|
||||||
'confirmedCount': confirmedCount,
|
'confirmedCount': confirmedCount,
|
||||||
'assignmentStatus': assignmentStatus?.toJson(),
|
'assignmentStatus': assignmentStatus?.toJson(),
|
||||||
'applicationStatus': applicationStatus?.toJson(),
|
'applicationStatus': applicationStatus?.toJson(),
|
||||||
|
'clockInMode': clockInMode,
|
||||||
|
'allowClockInOverride': allowClockInOverride,
|
||||||
|
'geofenceRadiusMeters': geofenceRadiusMeters,
|
||||||
|
'nfcTagId': nfcTagId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,16 +203,26 @@ class ShiftDetail extends Equatable {
|
|||||||
description,
|
description,
|
||||||
location,
|
location,
|
||||||
address,
|
address,
|
||||||
|
clientName,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
date,
|
date,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
roleId,
|
roleId,
|
||||||
roleName,
|
roleName,
|
||||||
hourlyRateCents,
|
hourlyRateCents,
|
||||||
|
hourlyRate,
|
||||||
|
totalRateCents,
|
||||||
|
totalRate,
|
||||||
orderType,
|
orderType,
|
||||||
requiredCount,
|
requiredCount,
|
||||||
confirmedCount,
|
confirmedCount,
|
||||||
assignmentStatus,
|
assignmentStatus,
|
||||||
applicationStatus,
|
applicationStatus,
|
||||||
|
clockInMode,
|
||||||
|
allowClockInOverride,
|
||||||
|
geofenceRadiusMeters,
|
||||||
|
nfcTagId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart';
|
||||||
|
import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart';
|
||||||
import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart';
|
import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart';
|
||||||
|
|
||||||
/// Implementation of [ClockInRepositoryInterface] using the V2 REST API.
|
/// Implementation of [ClockInRepositoryInterface] using the V2 REST API.
|
||||||
@@ -19,7 +21,8 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
|||||||
final ApiResponse response = await _apiService.get(
|
final ApiResponse response = await _apiService.get(
|
||||||
StaffEndpoints.clockInShiftsToday,
|
StaffEndpoints.clockInShiftsToday,
|
||||||
);
|
);
|
||||||
final List<dynamic> items = response.data['items'] as List<dynamic>;
|
final List<dynamic> items =
|
||||||
|
response.data['items'] as List<dynamic>? ?? <dynamic>[];
|
||||||
return items
|
return items
|
||||||
.map(
|
.map(
|
||||||
(dynamic json) =>
|
(dynamic json) =>
|
||||||
@@ -37,36 +40,20 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AttendanceStatus> clockIn({
|
Future<AttendanceStatus> clockIn(ClockInArguments arguments) async {
|
||||||
required String shiftId,
|
|
||||||
String? notes,
|
|
||||||
}) async {
|
|
||||||
await _apiService.post(
|
await _apiService.post(
|
||||||
StaffEndpoints.clockIn,
|
StaffEndpoints.clockIn,
|
||||||
data: <String, dynamic>{
|
data: arguments.toJson(),
|
||||||
'shiftId': shiftId,
|
|
||||||
'sourceType': 'GEO',
|
|
||||||
if (notes != null && notes.isNotEmpty) 'notes': notes,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
// Re-fetch the attendance status to get the canonical state after clock-in.
|
// Re-fetch the attendance status to get the canonical state after clock-in.
|
||||||
return getAttendanceStatus();
|
return getAttendanceStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AttendanceStatus> clockOut({
|
Future<AttendanceStatus> clockOut(ClockOutArguments arguments) async {
|
||||||
String? notes,
|
|
||||||
int? breakTimeMinutes,
|
|
||||||
String? shiftId,
|
|
||||||
}) async {
|
|
||||||
await _apiService.post(
|
await _apiService.post(
|
||||||
StaffEndpoints.clockOut,
|
StaffEndpoints.clockOut,
|
||||||
data: <String, dynamic>{
|
data: arguments.toJson(),
|
||||||
if (shiftId != null) 'shiftId': shiftId,
|
|
||||||
'sourceType': 'GEO',
|
|
||||||
if (notes != null && notes.isNotEmpty) 'notes': notes,
|
|
||||||
if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
// Re-fetch the attendance status to get the canonical state after clock-out.
|
// Re-fetch the attendance status to get the canonical state after clock-out.
|
||||||
return getAttendanceStatus();
|
return getAttendanceStatus();
|
||||||
@@ -76,14 +63,19 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
|||||||
static Shift _mapTodayShiftJsonToShift(Map<String, dynamic> json) {
|
static Shift _mapTodayShiftJsonToShift(Map<String, dynamic> json) {
|
||||||
return Shift(
|
return Shift(
|
||||||
id: json['shiftId'] as String,
|
id: json['shiftId'] as String,
|
||||||
orderId: json['orderId'] as String? ?? '',
|
orderId: null,
|
||||||
title: json['clientName'] as String? ?? json['roleName'] as String? ?? '',
|
title: json['clientName'] as String? ?? json['roleName'] as String? ?? '',
|
||||||
status: ShiftStatus.assigned,
|
status: ShiftStatus.assigned,
|
||||||
startsAt: DateTime.parse(json['startTime'] as String),
|
startsAt: DateTime.parse(json['startTime'] as String),
|
||||||
endsAt: DateTime.parse(json['endTime'] as String),
|
endsAt: DateTime.parse(json['endTime'] as String),
|
||||||
locationName: json['location'] as String?,
|
locationName: json['locationAddress'] as String? ??
|
||||||
|
json['location'] as String?,
|
||||||
latitude: Shift.parseDouble(json['latitude']),
|
latitude: Shift.parseDouble(json['latitude']),
|
||||||
longitude: Shift.parseDouble(json['longitude']),
|
longitude: Shift.parseDouble(json['longitude']),
|
||||||
|
geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?,
|
||||||
|
clockInMode: json['clockInMode'] as String?,
|
||||||
|
allowClockInOverride: json['allowClockInOverride'] as bool?,
|
||||||
|
nfcTagId: json['nfcTagId'] as String?,
|
||||||
requiredWorkers: 0,
|
requiredWorkers: 0,
|
||||||
assignedWorkers: 0,
|
assignedWorkers: 0,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,50 @@
|
|||||||
// ignore_for_file: avoid_print
|
// ignore_for_file: avoid_print
|
||||||
|
// Print statements are intentional — background isolates cannot use
|
||||||
|
// dart:developer or structured loggers from the DI container.
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Lightweight HTTP client for background isolate API calls.
|
||||||
|
///
|
||||||
|
/// Cannot use Dio or DI — uses [HttpClient] directly with auth tokens
|
||||||
|
/// from [StorageService] (SharedPreferences, works across isolates).
|
||||||
|
class BackgroundApiClient {
|
||||||
|
/// Creates a [BackgroundApiClient] with its own HTTP client and storage.
|
||||||
|
BackgroundApiClient() : _client = HttpClient(), _storage = StorageService();
|
||||||
|
|
||||||
|
final HttpClient _client;
|
||||||
|
final StorageService _storage;
|
||||||
|
|
||||||
|
/// POSTs JSON to [path] under the V2 API base URL.
|
||||||
|
///
|
||||||
|
/// Returns the HTTP status code, or null if no auth token is available.
|
||||||
|
Future<int?> post(String path, Map<String, dynamic> body) async {
|
||||||
|
final String? token = await _storage.getString(
|
||||||
|
BackgroundGeofenceService._keyAuthToken,
|
||||||
|
);
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
print('[BackgroundApiClient] No auth token stored, skipping POST');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Uri uri = Uri.parse('${AppConfig.v2ApiBaseUrl}$path');
|
||||||
|
final HttpClientRequest request = await _client.postUrl(uri);
|
||||||
|
request.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
|
||||||
|
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
|
||||||
|
request.write(jsonEncode(body));
|
||||||
|
final HttpClientResponse response = await request.close();
|
||||||
|
await response.drain<void>();
|
||||||
|
return response.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the underlying [HttpClient].
|
||||||
|
void dispose() => _client.close(force: false);
|
||||||
|
}
|
||||||
|
|
||||||
/// Top-level callback dispatcher for background geofence tasks.
|
/// Top-level callback dispatcher for background geofence tasks.
|
||||||
///
|
///
|
||||||
/// Must be a top-level function because workmanager executes it in a separate
|
/// Must be a top-level function because workmanager executes it in a separate
|
||||||
@@ -13,83 +56,134 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
/// is retained solely for this entry-point pattern.
|
/// is retained solely for this entry-point pattern.
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void backgroundGeofenceDispatcher() {
|
void backgroundGeofenceDispatcher() {
|
||||||
const BackgroundTaskService().executeTask(
|
const BackgroundTaskService().executeTask((
|
||||||
(String task, Map<String, dynamic>? inputData) async {
|
String task,
|
||||||
print('[BackgroundGeofence] Task triggered: $task');
|
Map<String, dynamic>? inputData,
|
||||||
print('[BackgroundGeofence] Input data: $inputData');
|
) async {
|
||||||
print(
|
print('[BackgroundGeofence] Task triggered: $task');
|
||||||
'[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}',
|
print('[BackgroundGeofence] Input data: $inputData');
|
||||||
);
|
print(
|
||||||
|
'[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}',
|
||||||
|
);
|
||||||
|
|
||||||
final double? targetLat = inputData?['targetLat'] as double?;
|
final double? targetLat = inputData?['targetLat'] as double?;
|
||||||
final double? targetLng = inputData?['targetLng'] as double?;
|
final double? targetLng = inputData?['targetLng'] as double?;
|
||||||
final String? shiftId = inputData?['shiftId'] as String?;
|
final String? shiftId = inputData?['shiftId'] as String?;
|
||||||
|
final double geofenceRadius =
|
||||||
|
(inputData?['geofenceRadiusMeters'] as num?)?.toDouble() ??
|
||||||
|
BackgroundGeofenceService.defaultGeofenceRadiusMeters;
|
||||||
|
|
||||||
print(
|
print(
|
||||||
'[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, '
|
'[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, '
|
||||||
'shiftId=$shiftId',
|
'shiftId=$shiftId, geofenceRadius=${geofenceRadius.round()}m',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (targetLat == null || targetLng == null) {
|
if (targetLat == null || targetLng == null) {
|
||||||
print(
|
print('[BackgroundGeofence] Missing target coordinates, skipping check');
|
||||||
'[BackgroundGeofence] Missing target coordinates, skipping check',
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const LocationService locationService = LocationService();
|
|
||||||
final DeviceLocation location = await locationService.getCurrentLocation();
|
|
||||||
print(
|
|
||||||
'[BackgroundGeofence] Current position: '
|
|
||||||
'lat=${location.latitude}, lng=${location.longitude}',
|
|
||||||
);
|
|
||||||
|
|
||||||
final double distance = calculateDistance(
|
|
||||||
location.latitude,
|
|
||||||
location.longitude,
|
|
||||||
targetLat,
|
|
||||||
targetLng,
|
|
||||||
);
|
|
||||||
print(
|
|
||||||
'[BackgroundGeofence] Distance from target: ${distance.round()}m',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (distance > BackgroundGeofenceService.geofenceRadiusMeters) {
|
|
||||||
print(
|
|
||||||
'[BackgroundGeofence] Worker is outside geofence '
|
|
||||||
'(${distance.round()}m > '
|
|
||||||
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), '
|
|
||||||
'showing notification',
|
|
||||||
);
|
|
||||||
|
|
||||||
final String title = inputData?['leftGeofenceTitle'] as String? ??
|
|
||||||
"You've Left the Workplace";
|
|
||||||
final String body = inputData?['leftGeofenceBody'] as String? ??
|
|
||||||
'You appear to be more than 500m from your shift location.';
|
|
||||||
|
|
||||||
final NotificationService notificationService =
|
|
||||||
NotificationService();
|
|
||||||
await notificationService.showNotification(
|
|
||||||
id: BackgroundGeofenceService.leftGeofenceNotificationId,
|
|
||||||
title: title,
|
|
||||||
body: body,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
print(
|
|
||||||
'[BackgroundGeofence] Worker is within geofence '
|
|
||||||
'(${distance.round()}m <= '
|
|
||||||
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('[BackgroundGeofence] Error during background check: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
print('[BackgroundGeofence] Background check completed');
|
|
||||||
return true;
|
return true;
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
final BackgroundApiClient client = BackgroundApiClient();
|
||||||
|
try {
|
||||||
|
const LocationService locationService = LocationService();
|
||||||
|
final DeviceLocation location = await locationService
|
||||||
|
.getCurrentLocation();
|
||||||
|
print(
|
||||||
|
'[BackgroundGeofence] Current position: '
|
||||||
|
'lat=${location.latitude}, lng=${location.longitude}',
|
||||||
|
);
|
||||||
|
|
||||||
|
final double distance = calculateDistance(
|
||||||
|
location.latitude,
|
||||||
|
location.longitude,
|
||||||
|
targetLat,
|
||||||
|
targetLng,
|
||||||
|
);
|
||||||
|
print('[BackgroundGeofence] Distance from target: ${distance.round()}m');
|
||||||
|
|
||||||
|
// POST location stream to the V2 API before geofence check.
|
||||||
|
unawaited(
|
||||||
|
_postLocationStream(
|
||||||
|
client: client,
|
||||||
|
shiftId: shiftId,
|
||||||
|
location: location,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance > geofenceRadius) {
|
||||||
|
print(
|
||||||
|
'[BackgroundGeofence] Worker is outside geofence '
|
||||||
|
'(${distance.round()}m > '
|
||||||
|
'${geofenceRadius.round()}m), '
|
||||||
|
'showing notification',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback for when localized strings are not available in the
|
||||||
|
// background isolate. The primary path passes localized strings
|
||||||
|
// via inputData from the UI layer.
|
||||||
|
final String title =
|
||||||
|
inputData?['leftGeofenceTitle'] as String? ??
|
||||||
|
'You have left the work area';
|
||||||
|
final String body =
|
||||||
|
inputData?['leftGeofenceBody'] as String? ??
|
||||||
|
'You appear to have moved outside your shift location.';
|
||||||
|
|
||||||
|
final NotificationService notificationService = NotificationService();
|
||||||
|
await notificationService.showNotification(
|
||||||
|
id: BackgroundGeofenceService.leftGeofenceNotificationId,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
print(
|
||||||
|
'[BackgroundGeofence] Worker is within geofence '
|
||||||
|
'(${distance.round()}m <= '
|
||||||
|
'${geofenceRadius.round()}m)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('[BackgroundGeofence] Error during background check: $e');
|
||||||
|
} finally {
|
||||||
|
client.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[BackgroundGeofence] Background check completed');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts a location data point to the V2 location-streams endpoint.
|
||||||
|
///
|
||||||
|
/// Uses [BackgroundApiClient] for isolate-safe HTTP access.
|
||||||
|
/// Failures are silently caught — location streaming is best-effort.
|
||||||
|
Future<void> _postLocationStream({
|
||||||
|
required BackgroundApiClient client,
|
||||||
|
required String? shiftId,
|
||||||
|
required DeviceLocation location,
|
||||||
|
}) async {
|
||||||
|
if (shiftId == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final int? status = await client.post(
|
||||||
|
StaffEndpoints.locationStreams.path,
|
||||||
|
<String, dynamic>{
|
||||||
|
'shiftId': shiftId,
|
||||||
|
'sourceType': 'GEO',
|
||||||
|
'points': <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{
|
||||||
|
'capturedAt': location.timestamp.toUtc().toIso8601String(),
|
||||||
|
'latitude': location.latitude,
|
||||||
|
'longitude': location.longitude,
|
||||||
|
'accuracyMeters': location.accuracy,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'metadata': <String, String>{'source': 'background-workmanager'},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
print('[BackgroundGeofence] Location stream POST status: $status');
|
||||||
|
} catch (e) {
|
||||||
|
print('[BackgroundGeofence] Location stream POST failed: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service that manages periodic background geofence checks while clocked in.
|
/// Service that manages periodic background geofence checks while clocked in.
|
||||||
@@ -98,13 +192,12 @@ void backgroundGeofenceDispatcher() {
|
|||||||
/// delivery is handled by [ClockInNotificationService]. The background isolate
|
/// delivery is handled by [ClockInNotificationService]. The background isolate
|
||||||
/// logic lives in the top-level [backgroundGeofenceDispatcher] function above.
|
/// logic lives in the top-level [backgroundGeofenceDispatcher] function above.
|
||||||
class BackgroundGeofenceService {
|
class BackgroundGeofenceService {
|
||||||
|
|
||||||
/// Creates a [BackgroundGeofenceService] instance.
|
/// Creates a [BackgroundGeofenceService] instance.
|
||||||
BackgroundGeofenceService({
|
BackgroundGeofenceService({
|
||||||
required BackgroundTaskService backgroundTaskService,
|
required BackgroundTaskService backgroundTaskService,
|
||||||
required StorageService storageService,
|
required StorageService storageService,
|
||||||
}) : _backgroundTaskService = backgroundTaskService,
|
}) : _backgroundTaskService = backgroundTaskService,
|
||||||
_storageService = storageService;
|
_storageService = storageService;
|
||||||
|
|
||||||
/// The core background task service for scheduling periodic work.
|
/// The core background task service for scheduling periodic work.
|
||||||
final BackgroundTaskService _backgroundTaskService;
|
final BackgroundTaskService _backgroundTaskService;
|
||||||
@@ -124,6 +217,9 @@ class BackgroundGeofenceService {
|
|||||||
/// Storage key for the active tracking flag.
|
/// Storage key for the active tracking flag.
|
||||||
static const String _keyTrackingActive = 'geofence_tracking_active';
|
static const String _keyTrackingActive = 'geofence_tracking_active';
|
||||||
|
|
||||||
|
/// Storage key for the Firebase auth token used in background isolate.
|
||||||
|
static const String _keyAuthToken = 'geofence_auth_token';
|
||||||
|
|
||||||
/// Unique task name for the periodic background check.
|
/// Unique task name for the periodic background check.
|
||||||
static const String taskUniqueName = 'geofence_background_check';
|
static const String taskUniqueName = 'geofence_background_check';
|
||||||
|
|
||||||
@@ -136,8 +232,12 @@ class BackgroundGeofenceService {
|
|||||||
/// it directly (background isolate has no DI access).
|
/// it directly (background isolate has no DI access).
|
||||||
static const int leftGeofenceNotificationId = 2;
|
static const int leftGeofenceNotificationId = 2;
|
||||||
|
|
||||||
/// Geofence radius in meters.
|
/// Default geofence radius in meters, used as fallback when no per-shift
|
||||||
static const double geofenceRadiusMeters = 500;
|
/// radius is provided.
|
||||||
|
static const double defaultGeofenceRadiusMeters = 500;
|
||||||
|
|
||||||
|
/// Storage key for the per-shift geofence radius.
|
||||||
|
static const String _keyGeofenceRadius = 'geofence_radius_meters';
|
||||||
|
|
||||||
/// Starts periodic 15-minute background geofence checks.
|
/// Starts periodic 15-minute background geofence checks.
|
||||||
///
|
///
|
||||||
@@ -150,12 +250,17 @@ class BackgroundGeofenceService {
|
|||||||
required String shiftId,
|
required String shiftId,
|
||||||
required String leftGeofenceTitle,
|
required String leftGeofenceTitle,
|
||||||
required String leftGeofenceBody,
|
required String leftGeofenceBody,
|
||||||
|
double geofenceRadiusMeters = defaultGeofenceRadiusMeters,
|
||||||
|
String? authToken,
|
||||||
}) async {
|
}) async {
|
||||||
await Future.wait(<Future<bool>>[
|
await Future.wait(<Future<bool>>[
|
||||||
_storageService.setDouble(_keyTargetLat, targetLat),
|
_storageService.setDouble(_keyTargetLat, targetLat),
|
||||||
_storageService.setDouble(_keyTargetLng, targetLng),
|
_storageService.setDouble(_keyTargetLng, targetLng),
|
||||||
_storageService.setString(_keyShiftId, shiftId),
|
_storageService.setString(_keyShiftId, shiftId),
|
||||||
|
_storageService.setDouble(_keyGeofenceRadius, geofenceRadiusMeters),
|
||||||
_storageService.setBool(_keyTrackingActive, true),
|
_storageService.setBool(_keyTrackingActive, true),
|
||||||
|
if (authToken != null)
|
||||||
|
_storageService.setString(_keyAuthToken, authToken),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await _backgroundTaskService.registerPeriodicTask(
|
await _backgroundTaskService.registerPeriodicTask(
|
||||||
@@ -166,6 +271,7 @@ class BackgroundGeofenceService {
|
|||||||
'targetLat': targetLat,
|
'targetLat': targetLat,
|
||||||
'targetLng': targetLng,
|
'targetLng': targetLng,
|
||||||
'shiftId': shiftId,
|
'shiftId': shiftId,
|
||||||
|
'geofenceRadiusMeters': geofenceRadiusMeters,
|
||||||
'leftGeofenceTitle': leftGeofenceTitle,
|
'leftGeofenceTitle': leftGeofenceTitle,
|
||||||
'leftGeofenceBody': leftGeofenceBody,
|
'leftGeofenceBody': leftGeofenceBody,
|
||||||
},
|
},
|
||||||
@@ -182,10 +288,20 @@ class BackgroundGeofenceService {
|
|||||||
_storageService.remove(_keyTargetLat),
|
_storageService.remove(_keyTargetLat),
|
||||||
_storageService.remove(_keyTargetLng),
|
_storageService.remove(_keyTargetLng),
|
||||||
_storageService.remove(_keyShiftId),
|
_storageService.remove(_keyShiftId),
|
||||||
|
_storageService.remove(_keyGeofenceRadius),
|
||||||
|
_storageService.remove(_keyAuthToken),
|
||||||
_storageService.setBool(_keyTrackingActive, false),
|
_storageService.setBool(_keyTrackingActive, false),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores a fresh auth token for background isolate API calls.
|
||||||
|
///
|
||||||
|
/// Called by the foreground [GeofenceBloc] both initially and
|
||||||
|
/// periodically (~45 min) to keep the token fresh across long shifts.
|
||||||
|
Future<void> storeAuthToken(String token) async {
|
||||||
|
await _storageService.setString(_keyAuthToken, token);
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether background tracking is currently active.
|
/// Whether background tracking is currently active.
|
||||||
Future<bool> get isTrackingActive async {
|
Future<bool> get isTrackingActive async {
|
||||||
final bool? active = await _storageService.getBool(_keyTrackingActive);
|
final bool? active = await _storageService.getBool(_keyTrackingActive);
|
||||||
|
|||||||
@@ -7,13 +7,99 @@ class ClockInArguments extends UseCaseArgument {
|
|||||||
const ClockInArguments({
|
const ClockInArguments({
|
||||||
required this.shiftId,
|
required this.shiftId,
|
||||||
this.notes,
|
this.notes,
|
||||||
|
this.deviceId,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
|
this.accuracyMeters,
|
||||||
|
this.capturedAt,
|
||||||
|
this.overrideReason,
|
||||||
|
this.nfcTagId,
|
||||||
|
this.proofNonce,
|
||||||
|
this.proofTimestamp,
|
||||||
|
this.attestationProvider,
|
||||||
|
this.attestationToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The ID of the shift to clock in to.
|
/// The ID of the shift to clock in to.
|
||||||
final String shiftId;
|
final String shiftId;
|
||||||
|
|
||||||
/// Optional notes provided by the user during clock-in.
|
/// Optional notes provided by the user during clock-in.
|
||||||
final String? notes;
|
final String? notes;
|
||||||
|
|
||||||
|
/// Device identifier for audit trail.
|
||||||
|
final String? deviceId;
|
||||||
|
|
||||||
|
/// Latitude of the device at clock-in time.
|
||||||
|
final double? latitude;
|
||||||
|
|
||||||
|
/// Longitude of the device at clock-in time.
|
||||||
|
final double? longitude;
|
||||||
|
|
||||||
|
/// Horizontal accuracy of the GPS fix in meters.
|
||||||
|
final double? accuracyMeters;
|
||||||
|
|
||||||
|
/// Timestamp when the location was captured on-device.
|
||||||
|
final DateTime? capturedAt;
|
||||||
|
|
||||||
|
/// Justification when the worker overrides a geofence check.
|
||||||
|
final String? overrideReason;
|
||||||
|
|
||||||
|
/// NFC tag identifier when clocking in via NFC tap.
|
||||||
|
final String? nfcTagId;
|
||||||
|
|
||||||
|
/// Server-generated nonce for proof-of-presence validation.
|
||||||
|
final String? proofNonce;
|
||||||
|
|
||||||
|
/// Device-local timestamp when the proof was captured.
|
||||||
|
final DateTime? proofTimestamp;
|
||||||
|
|
||||||
|
/// Name of the attestation provider (e.g. `'apple'`, `'android'`).
|
||||||
|
final String? attestationProvider;
|
||||||
|
|
||||||
|
/// Signed attestation token from the device integrity API.
|
||||||
|
final String? attestationToken;
|
||||||
|
|
||||||
|
/// Serializes the arguments to a JSON map for the V2 API request body.
|
||||||
|
///
|
||||||
|
/// Only includes non-null fields. The `sourceType` is inferred from
|
||||||
|
/// whether [nfcTagId] is present.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'shiftId': shiftId,
|
||||||
|
'sourceType': nfcTagId != null ? 'NFC' : 'GEO',
|
||||||
|
if (notes != null && notes!.isNotEmpty) 'notes': notes,
|
||||||
|
if (deviceId != null) 'deviceId': deviceId,
|
||||||
|
if (latitude != null) 'latitude': latitude,
|
||||||
|
if (longitude != null) 'longitude': longitude,
|
||||||
|
if (accuracyMeters != null) 'accuracyMeters': accuracyMeters,
|
||||||
|
if (capturedAt != null)
|
||||||
|
'capturedAt': capturedAt!.toUtc().toIso8601String(),
|
||||||
|
if (overrideReason != null && overrideReason!.isNotEmpty)
|
||||||
|
'overrideReason': overrideReason,
|
||||||
|
if (nfcTagId != null) 'nfcTagId': nfcTagId,
|
||||||
|
if (proofNonce != null) 'proofNonce': proofNonce,
|
||||||
|
if (proofTimestamp != null)
|
||||||
|
'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(),
|
||||||
|
if (attestationProvider != null)
|
||||||
|
'attestationProvider': attestationProvider,
|
||||||
|
if (attestationToken != null) 'attestationToken': attestationToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[shiftId, notes];
|
List<Object?> get props => <Object?>[
|
||||||
|
shiftId,
|
||||||
|
notes,
|
||||||
|
deviceId,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracyMeters,
|
||||||
|
capturedAt,
|
||||||
|
overrideReason,
|
||||||
|
nfcTagId,
|
||||||
|
proofNonce,
|
||||||
|
proofTimestamp,
|
||||||
|
attestationProvider,
|
||||||
|
attestationToken,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ class ClockOutArguments extends UseCaseArgument {
|
|||||||
this.notes,
|
this.notes,
|
||||||
this.breakTimeMinutes,
|
this.breakTimeMinutes,
|
||||||
this.shiftId,
|
this.shiftId,
|
||||||
|
this.deviceId,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
|
this.accuracyMeters,
|
||||||
|
this.capturedAt,
|
||||||
|
this.overrideReason,
|
||||||
|
this.nfcTagId,
|
||||||
|
this.proofNonce,
|
||||||
|
this.proofTimestamp,
|
||||||
|
this.attestationProvider,
|
||||||
|
this.attestationToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Optional notes provided by the user during clock-out.
|
/// Optional notes provided by the user during clock-out.
|
||||||
@@ -18,6 +29,82 @@ class ClockOutArguments extends UseCaseArgument {
|
|||||||
/// The shift id used by the V2 API to resolve the assignment.
|
/// The shift id used by the V2 API to resolve the assignment.
|
||||||
final String? shiftId;
|
final String? shiftId;
|
||||||
|
|
||||||
|
/// Device identifier for audit trail.
|
||||||
|
final String? deviceId;
|
||||||
|
|
||||||
|
/// Latitude of the device at clock-out time.
|
||||||
|
final double? latitude;
|
||||||
|
|
||||||
|
/// Longitude of the device at clock-out time.
|
||||||
|
final double? longitude;
|
||||||
|
|
||||||
|
/// Horizontal accuracy of the GPS fix in meters.
|
||||||
|
final double? accuracyMeters;
|
||||||
|
|
||||||
|
/// Timestamp when the location was captured on-device.
|
||||||
|
final DateTime? capturedAt;
|
||||||
|
|
||||||
|
/// Justification when the worker overrides a geofence check.
|
||||||
|
final String? overrideReason;
|
||||||
|
|
||||||
|
/// NFC tag identifier when clocking out via NFC tap.
|
||||||
|
final String? nfcTagId;
|
||||||
|
|
||||||
|
/// Server-generated nonce for proof-of-presence validation.
|
||||||
|
final String? proofNonce;
|
||||||
|
|
||||||
|
/// Device-local timestamp when the proof was captured.
|
||||||
|
final DateTime? proofTimestamp;
|
||||||
|
|
||||||
|
/// Name of the attestation provider (e.g. `'apple'`, `'android'`).
|
||||||
|
final String? attestationProvider;
|
||||||
|
|
||||||
|
/// Signed attestation token from the device integrity API.
|
||||||
|
final String? attestationToken;
|
||||||
|
|
||||||
|
/// Serializes the arguments to a JSON map for the V2 API request body.
|
||||||
|
///
|
||||||
|
/// Only includes non-null fields. The `sourceType` is inferred from
|
||||||
|
/// whether [nfcTagId] is present.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
if (shiftId != null) 'shiftId': shiftId,
|
||||||
|
'sourceType': nfcTagId != null ? 'NFC' : 'GEO',
|
||||||
|
if (notes != null && notes!.isNotEmpty) 'notes': notes,
|
||||||
|
if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes,
|
||||||
|
if (deviceId != null) 'deviceId': deviceId,
|
||||||
|
if (latitude != null) 'latitude': latitude,
|
||||||
|
if (longitude != null) 'longitude': longitude,
|
||||||
|
if (accuracyMeters != null) 'accuracyMeters': accuracyMeters,
|
||||||
|
if (capturedAt != null)
|
||||||
|
'capturedAt': capturedAt!.toUtc().toIso8601String(),
|
||||||
|
if (overrideReason != null && overrideReason!.isNotEmpty)
|
||||||
|
'overrideReason': overrideReason,
|
||||||
|
if (nfcTagId != null) 'nfcTagId': nfcTagId,
|
||||||
|
if (proofNonce != null) 'proofNonce': proofNonce,
|
||||||
|
if (proofTimestamp != null)
|
||||||
|
'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(),
|
||||||
|
if (attestationProvider != null)
|
||||||
|
'attestationProvider': attestationProvider,
|
||||||
|
if (attestationToken != null) 'attestationToken': attestationToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[notes, breakTimeMinutes, shiftId];
|
List<Object?> get props => <Object?>[
|
||||||
|
notes,
|
||||||
|
breakTimeMinutes,
|
||||||
|
shiftId,
|
||||||
|
deviceId,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracyMeters,
|
||||||
|
capturedAt,
|
||||||
|
overrideReason,
|
||||||
|
nfcTagId,
|
||||||
|
proofNonce,
|
||||||
|
proofTimestamp,
|
||||||
|
attestationProvider,
|
||||||
|
attestationToken,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
/// Repository interface for Clock In/Out functionality
|
import '../arguments/clock_in_arguments.dart';
|
||||||
abstract class ClockInRepositoryInterface {
|
import '../arguments/clock_out_arguments.dart';
|
||||||
|
|
||||||
|
/// Repository interface for Clock In/Out functionality.
|
||||||
|
abstract interface class ClockInRepositoryInterface {
|
||||||
|
|
||||||
/// Retrieves the shifts assigned to the user for the current day.
|
/// Retrieves the shifts assigned to the user for the current day.
|
||||||
/// Returns empty list if no shift is assigned for today.
|
/// Returns empty list if no shift is assigned for today.
|
||||||
@@ -11,17 +14,12 @@ abstract class ClockInRepositoryInterface {
|
|||||||
/// This helps in restoring the UI state if the app was killed.
|
/// This helps in restoring the UI state if the app was killed.
|
||||||
Future<AttendanceStatus> getAttendanceStatus();
|
Future<AttendanceStatus> getAttendanceStatus();
|
||||||
|
|
||||||
/// Checks the user in for the specified [shiftId].
|
/// Checks the user in using the fields from [arguments].
|
||||||
/// Returns the updated [AttendanceStatus].
|
/// Returns the updated [AttendanceStatus].
|
||||||
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
|
Future<AttendanceStatus> clockIn(ClockInArguments arguments);
|
||||||
|
|
||||||
/// Checks the user out for the currently active shift.
|
/// Checks the user out using the fields from [arguments].
|
||||||
///
|
///
|
||||||
/// The V2 API resolves the assignment from [shiftId]. Optionally accepts
|
/// The V2 API resolves the assignment from the shift ID.
|
||||||
/// [breakTimeMinutes] if tracked.
|
Future<AttendanceStatus> clockOut(ClockOutArguments arguments);
|
||||||
Future<AttendanceStatus> clockOut({
|
|
||||||
String? notes,
|
|
||||||
int? breakTimeMinutes,
|
|
||||||
String? shiftId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ class ClockInUseCase implements UseCase<ClockInArguments, AttendanceStatus> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AttendanceStatus> call(ClockInArguments arguments) {
|
Future<AttendanceStatus> call(ClockInArguments arguments) {
|
||||||
return _repository.clockIn(
|
return _repository.clockIn(arguments);
|
||||||
shiftId: arguments.shiftId,
|
|
||||||
notes: arguments.notes,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ class ClockOutUseCase implements UseCase<ClockOutArguments, AttendanceStatus> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AttendanceStatus> call(ClockOutArguments arguments) {
|
Future<AttendanceStatus> call(ClockOutArguments arguments) {
|
||||||
return _repository.clockOut(
|
return _repository.clockOut(arguments);
|
||||||
notes: arguments.notes,
|
|
||||||
breakTimeMinutes: arguments.breakTimeMinutes,
|
|
||||||
shiftId: arguments.shiftId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../validators/clock_in_validation_context.dart';
|
||||||
|
import '../validators/validators/time_window_validator.dart';
|
||||||
|
|
||||||
|
/// Holds the computed time-window check-in/check-out availability flags.
|
||||||
|
class TimeWindowFlags {
|
||||||
|
/// Creates a [TimeWindowFlags] with default allowed values.
|
||||||
|
const TimeWindowFlags({
|
||||||
|
this.isCheckInAllowed = true,
|
||||||
|
this.isCheckOutAllowed = true,
|
||||||
|
this.checkInAvailabilityTime,
|
||||||
|
this.checkOutAvailabilityTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether the time window currently allows check-in.
|
||||||
|
final bool isCheckInAllowed;
|
||||||
|
|
||||||
|
/// Whether the time window currently allows check-out.
|
||||||
|
final bool isCheckOutAllowed;
|
||||||
|
|
||||||
|
/// Formatted time when check-in becomes available, or `null`.
|
||||||
|
final String? checkInAvailabilityTime;
|
||||||
|
|
||||||
|
/// Formatted time when check-out becomes available, or `null`.
|
||||||
|
final String? checkOutAvailabilityTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes time-window check-in/check-out flags for the given [shift].
|
||||||
|
///
|
||||||
|
/// Returns a [TimeWindowFlags] indicating whether the current time falls
|
||||||
|
/// within the allowed clock-in and clock-out windows. Uses
|
||||||
|
/// [TimeWindowValidator] for the underlying validation logic.
|
||||||
|
TimeWindowFlags computeTimeWindowFlags(Shift? shift) {
|
||||||
|
if (shift == null) {
|
||||||
|
return const TimeWindowFlags();
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimeWindowValidator validator = TimeWindowValidator();
|
||||||
|
final DateTime shiftStart = shift.startsAt;
|
||||||
|
final DateTime shiftEnd = shift.endsAt;
|
||||||
|
|
||||||
|
// Check-in window.
|
||||||
|
bool isCheckInAllowed = true;
|
||||||
|
String? checkInAvailabilityTime;
|
||||||
|
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
||||||
|
isCheckingIn: true,
|
||||||
|
shiftStartTime: shiftStart,
|
||||||
|
);
|
||||||
|
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
||||||
|
if (!isCheckInAllowed) {
|
||||||
|
checkInAvailabilityTime =
|
||||||
|
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check-out window.
|
||||||
|
bool isCheckOutAllowed = true;
|
||||||
|
String? checkOutAvailabilityTime;
|
||||||
|
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||||
|
isCheckingIn: false,
|
||||||
|
shiftEndTime: shiftEnd,
|
||||||
|
);
|
||||||
|
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||||
|
if (!isCheckOutAllowed) {
|
||||||
|
checkOutAvailabilityTime =
|
||||||
|
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeWindowFlags(
|
||||||
|
isCheckInAllowed: isCheckInAllowed,
|
||||||
|
isCheckOutAllowed: isCheckOutAllowed,
|
||||||
|
checkInAvailabilityTime: checkInAvailabilityTime,
|
||||||
|
checkOutAvailabilityTime: checkOutAvailabilityTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,21 +4,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import '../../../domain/arguments/clock_in_arguments.dart';
|
import 'package:staff_clock_in/src/data/services/background_geofence_service.dart';
|
||||||
import '../../../domain/arguments/clock_out_arguments.dart';
|
import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart';
|
||||||
import '../../../domain/usecases/clock_in_usecase.dart';
|
import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart';
|
||||||
import '../../../domain/usecases/clock_out_usecase.dart';
|
import 'package:staff_clock_in/src/domain/usecases/clock_in_usecase.dart';
|
||||||
import '../../../domain/usecases/get_attendance_status_usecase.dart';
|
import 'package:staff_clock_in/src/domain/usecases/clock_out_usecase.dart';
|
||||||
import '../../../domain/usecases/get_todays_shift_usecase.dart';
|
import 'package:staff_clock_in/src/domain/usecases/get_attendance_status_usecase.dart';
|
||||||
import '../../../domain/validators/clock_in_validation_context.dart';
|
import 'package:staff_clock_in/src/domain/usecases/get_todays_shift_usecase.dart';
|
||||||
import '../../../domain/validators/clock_in_validation_result.dart';
|
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart';
|
||||||
import '../../../domain/validators/validators/composite_clock_in_validator.dart';
|
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart';
|
||||||
import '../../../domain/validators/validators/time_window_validator.dart';
|
import 'package:staff_clock_in/src/domain/utils/time_window_utils.dart';
|
||||||
import '../geofence/geofence_bloc.dart';
|
import 'package:staff_clock_in/src/domain/validators/validators/composite_clock_in_validator.dart';
|
||||||
import '../geofence/geofence_event.dart';
|
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_bloc.dart';
|
||||||
import '../geofence/geofence_state.dart';
|
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_event.dart';
|
||||||
import 'clock_in_event.dart';
|
import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_state.dart';
|
||||||
import 'clock_in_state.dart';
|
import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_event.dart';
|
||||||
|
import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_state.dart';
|
||||||
|
|
||||||
/// BLoC responsible for clock-in/clock-out operations and shift management.
|
/// BLoC responsible for clock-in/clock-out operations and shift management.
|
||||||
///
|
///
|
||||||
@@ -92,7 +93,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
selectedShift ??= shifts.last;
|
selectedShift ??= shifts.last;
|
||||||
}
|
}
|
||||||
|
|
||||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
|
final TimeWindowFlags timeFlags = computeTimeWindowFlags(
|
||||||
selectedShift,
|
selectedShift,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
ShiftSelected event,
|
ShiftSelected event,
|
||||||
Emitter<ClockInState> emit,
|
Emitter<ClockInState> emit,
|
||||||
) {
|
) {
|
||||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift);
|
final TimeWindowFlags timeFlags = computeTimeWindowFlags(event.shift);
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
selectedShift: event.shift,
|
selectedShift: event.shift,
|
||||||
isCheckInAllowed: timeFlags.isCheckInAllowed,
|
isCheckInAllowed: timeFlags.isCheckInAllowed,
|
||||||
@@ -201,8 +202,20 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
|
final DeviceLocation? location = geofenceState.currentLocation;
|
||||||
|
|
||||||
final AttendanceStatus newStatus = await _clockIn(
|
final AttendanceStatus newStatus = await _clockIn(
|
||||||
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
|
ClockInArguments(
|
||||||
|
shiftId: event.shiftId,
|
||||||
|
notes: event.notes,
|
||||||
|
latitude: location?.latitude,
|
||||||
|
longitude: location?.longitude,
|
||||||
|
accuracyMeters: location?.accuracy,
|
||||||
|
capturedAt: location?.timestamp,
|
||||||
|
overrideReason: geofenceState.isGeofenceOverridden
|
||||||
|
? geofenceState.overrideNotes
|
||||||
|
: null,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
status: ClockInStatus.success,
|
status: ClockInStatus.success,
|
||||||
@@ -224,20 +237,39 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
|
|
||||||
/// Handles a clock-out request.
|
/// Handles a clock-out request.
|
||||||
///
|
///
|
||||||
|
/// Emits a failure state and returns early when no active shift ID is
|
||||||
|
/// available — this prevents the API call from being made without a valid
|
||||||
|
/// shift reference.
|
||||||
/// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc].
|
/// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc].
|
||||||
Future<void> _onCheckOut(
|
Future<void> _onCheckOut(
|
||||||
CheckOutRequested event,
|
CheckOutRequested event,
|
||||||
Emitter<ClockInState> emit,
|
Emitter<ClockInState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
final String? activeShiftId = state.attendance.activeShiftId;
|
||||||
|
if (activeShiftId == null) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: ClockInStatus.failure,
|
||||||
|
errorMessage: 'errors.shift.no_active_shift',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
|
final GeofenceState currentGeofence = _geofenceBloc.state;
|
||||||
|
final DeviceLocation? location = currentGeofence.currentLocation;
|
||||||
|
|
||||||
final AttendanceStatus newStatus = await _clockOut(
|
final AttendanceStatus newStatus = await _clockOut(
|
||||||
ClockOutArguments(
|
ClockOutArguments(
|
||||||
notes: event.notes,
|
notes: event.notes,
|
||||||
breakTimeMinutes: event.breakTimeMinutes ?? 0,
|
breakTimeMinutes: event.breakTimeMinutes,
|
||||||
shiftId: state.attendance.activeShiftId,
|
shiftId: activeShiftId,
|
||||||
|
latitude: location?.latitude,
|
||||||
|
longitude: location?.longitude,
|
||||||
|
accuracyMeters: location?.accuracy,
|
||||||
|
capturedAt: location?.timestamp,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
@@ -269,7 +301,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
Emitter<ClockInState> emit,
|
Emitter<ClockInState> emit,
|
||||||
) {
|
) {
|
||||||
if (state.status != ClockInStatus.success) return;
|
if (state.status != ClockInStatus.success) return;
|
||||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
|
final TimeWindowFlags timeFlags = computeTimeWindowFlags(
|
||||||
state.selectedShift,
|
state.selectedShift,
|
||||||
);
|
);
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
@@ -299,52 +331,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes time-window check-in/check-out flags for the given [shift].
|
|
||||||
///
|
|
||||||
/// Uses [TimeWindowValidator] so this business logic stays out of widgets.
|
|
||||||
static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) {
|
|
||||||
if (shift == null) {
|
|
||||||
return const _TimeWindowFlags();
|
|
||||||
}
|
|
||||||
|
|
||||||
const TimeWindowValidator validator = TimeWindowValidator();
|
|
||||||
final DateTime shiftStart = shift.startsAt;
|
|
||||||
final DateTime shiftEnd = shift.endsAt;
|
|
||||||
|
|
||||||
// Check-in window.
|
|
||||||
bool isCheckInAllowed = true;
|
|
||||||
String? checkInAvailabilityTime;
|
|
||||||
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
|
||||||
isCheckingIn: true,
|
|
||||||
shiftStartTime: shiftStart,
|
|
||||||
);
|
|
||||||
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
|
||||||
if (!isCheckInAllowed) {
|
|
||||||
checkInAvailabilityTime =
|
|
||||||
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check-out window.
|
|
||||||
bool isCheckOutAllowed = true;
|
|
||||||
String? checkOutAvailabilityTime;
|
|
||||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
|
||||||
isCheckingIn: false,
|
|
||||||
shiftEndTime: shiftEnd,
|
|
||||||
);
|
|
||||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
|
||||||
if (!isCheckOutAllowed) {
|
|
||||||
checkOutAvailabilityTime =
|
|
||||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _TimeWindowFlags(
|
|
||||||
isCheckInAllowed: isCheckInAllowed,
|
|
||||||
isCheckOutAllowed: isCheckOutAllowed,
|
|
||||||
checkInAvailabilityTime: checkInAvailabilityTime,
|
|
||||||
checkOutAvailabilityTime: checkOutAvailabilityTime,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the
|
/// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the
|
||||||
/// geofence has target coordinates.
|
/// geofence has target coordinates.
|
||||||
void _dispatchBackgroundTrackingStarted({
|
void _dispatchBackgroundTrackingStarted({
|
||||||
@@ -361,6 +347,9 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
shiftId: activeShiftId,
|
shiftId: activeShiftId,
|
||||||
targetLat: geofenceState.targetLat!,
|
targetLat: geofenceState.targetLat!,
|
||||||
targetLng: geofenceState.targetLng!,
|
targetLng: geofenceState.targetLng!,
|
||||||
|
geofenceRadiusMeters:
|
||||||
|
state.selectedShift?.geofenceRadiusMeters?.toDouble() ??
|
||||||
|
BackgroundGeofenceService.defaultGeofenceRadiusMeters,
|
||||||
greetingTitle: event.clockInGreetingTitle,
|
greetingTitle: event.clockInGreetingTitle,
|
||||||
greetingBody: event.clockInGreetingBody,
|
greetingBody: event.clockInGreetingBody,
|
||||||
leftGeofenceTitle: event.leftGeofenceTitle,
|
leftGeofenceTitle: event.leftGeofenceTitle,
|
||||||
@@ -370,26 +359,3 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal value holder for time-window computation results.
|
|
||||||
class _TimeWindowFlags {
|
|
||||||
/// Creates a [_TimeWindowFlags] with default allowed values.
|
|
||||||
const _TimeWindowFlags({
|
|
||||||
this.isCheckInAllowed = true,
|
|
||||||
this.isCheckOutAllowed = true,
|
|
||||||
this.checkInAvailabilityTime,
|
|
||||||
this.checkOutAvailabilityTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Whether the time window currently allows check-in.
|
|
||||||
final bool isCheckInAllowed;
|
|
||||||
|
|
||||||
/// Whether the time window currently allows check-out.
|
|
||||||
final bool isCheckOutAllowed;
|
|
||||||
|
|
||||||
/// Formatted time when check-in becomes available, or `null`.
|
|
||||||
final String? checkInAvailabilityTime;
|
|
||||||
|
|
||||||
/// Formatted time when check-out becomes available, or `null`.
|
|
||||||
final String? checkOutAvailabilityTime;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
@@ -25,9 +26,11 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
|||||||
required GeofenceServiceInterface geofenceService,
|
required GeofenceServiceInterface geofenceService,
|
||||||
required BackgroundGeofenceService backgroundGeofenceService,
|
required BackgroundGeofenceService backgroundGeofenceService,
|
||||||
required ClockInNotificationService notificationService,
|
required ClockInNotificationService notificationService,
|
||||||
|
required AuthTokenProvider authTokenProvider,
|
||||||
}) : _geofenceService = geofenceService,
|
}) : _geofenceService = geofenceService,
|
||||||
_backgroundGeofenceService = backgroundGeofenceService,
|
_backgroundGeofenceService = backgroundGeofenceService,
|
||||||
_notificationService = notificationService,
|
_notificationService = notificationService,
|
||||||
|
_authTokenProvider = authTokenProvider,
|
||||||
super(const GeofenceState.initial()) {
|
super(const GeofenceState.initial()) {
|
||||||
on<GeofenceStarted>(_onStarted);
|
on<GeofenceStarted>(_onStarted);
|
||||||
on<GeofenceResultUpdated>(_onResultUpdated);
|
on<GeofenceResultUpdated>(_onResultUpdated);
|
||||||
@@ -52,6 +55,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
|||||||
/// The notification service for clock-in related notifications.
|
/// The notification service for clock-in related notifications.
|
||||||
final ClockInNotificationService _notificationService;
|
final ClockInNotificationService _notificationService;
|
||||||
|
|
||||||
|
/// Provides fresh Firebase ID tokens for background isolate storage.
|
||||||
|
final AuthTokenProvider _authTokenProvider;
|
||||||
|
|
||||||
|
/// Periodic timer that refreshes the auth token in SharedPreferences
|
||||||
|
/// so the background isolate always has a valid token for API calls.
|
||||||
|
Timer? _tokenRefreshTimer;
|
||||||
|
|
||||||
|
/// How often to refresh the auth token for background use.
|
||||||
|
/// Set to 45 minutes — well before Firebase's 1-hour expiry.
|
||||||
|
static const Duration _tokenRefreshInterval = Duration(minutes: 45);
|
||||||
|
|
||||||
/// Active subscription to the foreground geofence location stream.
|
/// Active subscription to the foreground geofence location stream.
|
||||||
StreamSubscription<GeofenceResult>? _geofenceSubscription;
|
StreamSubscription<GeofenceResult>? _geofenceSubscription;
|
||||||
|
|
||||||
@@ -239,6 +253,17 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
|||||||
shiftId: event.shiftId,
|
shiftId: event.shiftId,
|
||||||
leftGeofenceTitle: event.leftGeofenceTitle,
|
leftGeofenceTitle: event.leftGeofenceTitle,
|
||||||
leftGeofenceBody: event.leftGeofenceBody,
|
leftGeofenceBody: event.leftGeofenceBody,
|
||||||
|
geofenceRadiusMeters: event.geofenceRadiusMeters,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get and store initial auth token for background location streaming.
|
||||||
|
await _refreshAndStoreToken();
|
||||||
|
|
||||||
|
// Start periodic token refresh to keep it valid across long shifts.
|
||||||
|
_tokenRefreshTimer?.cancel();
|
||||||
|
_tokenRefreshTimer = Timer.periodic(
|
||||||
|
_tokenRefreshInterval,
|
||||||
|
(_) => _refreshAndStoreToken(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show greeting notification using localized strings from the UI.
|
// Show greeting notification using localized strings from the UI.
|
||||||
@@ -261,6 +286,9 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
|||||||
BackgroundTrackingStopped event,
|
BackgroundTrackingStopped event,
|
||||||
Emitter<GeofenceState> emit,
|
Emitter<GeofenceState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
_tokenRefreshTimer?.cancel();
|
||||||
|
_tokenRefreshTimer = null;
|
||||||
|
|
||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
@@ -298,6 +326,8 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
|||||||
GeofenceStopped event,
|
GeofenceStopped event,
|
||||||
Emitter<GeofenceState> emit,
|
Emitter<GeofenceState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
_tokenRefreshTimer?.cancel();
|
||||||
|
_tokenRefreshTimer = null;
|
||||||
await _geofenceSubscription?.cancel();
|
await _geofenceSubscription?.cancel();
|
||||||
_geofenceSubscription = null;
|
_geofenceSubscription = null;
|
||||||
await _serviceStatusSubscription?.cancel();
|
await _serviceStatusSubscription?.cancel();
|
||||||
@@ -305,8 +335,26 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
|||||||
emit(const GeofenceState.initial());
|
emit(const GeofenceState.initial());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches a fresh Firebase ID token and stores it in SharedPreferences
|
||||||
|
/// for the background isolate to use.
|
||||||
|
Future<void> _refreshAndStoreToken() async {
|
||||||
|
try {
|
||||||
|
final String? token = await _authTokenProvider.getIdToken(
|
||||||
|
forceRefresh: true,
|
||||||
|
);
|
||||||
|
if (token != null) {
|
||||||
|
await _backgroundGeofenceService.storeAuthToken(token);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Best-effort — if token refresh fails, the background isolate will
|
||||||
|
// skip the POST (it checks for null/empty token).
|
||||||
|
developer.log('Token refresh failed: $e', name: 'GeofenceBloc', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
|
_tokenRefreshTimer?.cancel();
|
||||||
_geofenceSubscription?.cancel();
|
_geofenceSubscription?.cancel();
|
||||||
_serviceStatusSubscription?.cancel();
|
_serviceStatusSubscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
|||||||
required this.greetingBody,
|
required this.greetingBody,
|
||||||
required this.leftGeofenceTitle,
|
required this.leftGeofenceTitle,
|
||||||
required this.leftGeofenceBody,
|
required this.leftGeofenceBody,
|
||||||
|
this.geofenceRadiusMeters = 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The shift ID being tracked.
|
/// The shift ID being tracked.
|
||||||
@@ -84,6 +85,9 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
|||||||
/// Target longitude of the shift location.
|
/// Target longitude of the shift location.
|
||||||
final double targetLng;
|
final double targetLng;
|
||||||
|
|
||||||
|
/// Geofence radius in meters for this shift. Defaults to 500m.
|
||||||
|
final double geofenceRadiusMeters;
|
||||||
|
|
||||||
/// Localized greeting notification title passed from the UI layer.
|
/// Localized greeting notification title passed from the UI layer.
|
||||||
final String greetingTitle;
|
final String greetingTitle;
|
||||||
|
|
||||||
@@ -103,6 +107,7 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
|||||||
shiftId,
|
shiftId,
|
||||||
targetLat,
|
targetLat,
|
||||||
targetLng,
|
targetLng,
|
||||||
|
geofenceRadiusMeters,
|
||||||
greetingTitle,
|
greetingTitle,
|
||||||
greetingBody,
|
greetingBody,
|
||||||
leftGeofenceTitle,
|
leftGeofenceTitle,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class AttendanceCard extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: UiConstants.space1),
|
||||||
FittedBox(
|
FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -65,13 +65,13 @@ class AttendanceCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (scheduledTime != null) ...<Widget>[
|
if (scheduledTime != null) ...<Widget>[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
"Scheduled: $scheduledTime",
|
"Scheduled: $scheduledTime",
|
||||||
style: UiTypography.footnote2r.textInactive,
|
style: UiTypography.footnote2r.textInactive,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: UiTypography.footnote1r.copyWith(color: UiColors.primary),
|
style: UiTypography.footnote1r.copyWith(color: UiColors.primary),
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
|
|||||||
size: 12,
|
size: 12,
|
||||||
color: UiColors.textInactive,
|
color: UiColors.textInactive,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 2),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
i18n.starts_in(min: _getMinutesUntilShift().toString()),
|
i18n.starts_in(min: _getMinutesUntilShift().toString()),
|
||||||
style: UiTypography.titleUppercase4m.textSecondary,
|
style: UiTypography.titleUppercase4m.textSecondary,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class DateSelector extends StatelessWidget {
|
|||||||
: UiColors.foreground,
|
: UiColors.foreground,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
DateFormat('E').format(date),
|
DateFormat('E').format(date),
|
||||||
style: UiTypography.footnote2r.copyWith(
|
style: UiTypography.footnote2r.copyWith(
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class StaffClockInModule extends Module {
|
|||||||
geofenceService: i.get<GeofenceServiceInterface>(),
|
geofenceService: i.get<GeofenceServiceInterface>(),
|
||||||
backgroundGeofenceService: i.get<BackgroundGeofenceService>(),
|
backgroundGeofenceService: i.get<BackgroundGeofenceService>(),
|
||||||
notificationService: i.get<ClockInNotificationService>(),
|
notificationService: i.get<ClockInNotificationService>(),
|
||||||
|
authTokenProvider: i.get<AuthTokenProvider>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
i.add<ClockInBloc>(
|
i.add<ClockInBloc>(
|
||||||
|
|||||||
@@ -32,14 +32,11 @@ class _TimeCardPageState extends State<TimeCardPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final Translations t = Translations.of(context);
|
final Translations t = Translations.of(context);
|
||||||
return BlocProvider.value(
|
return Scaffold(
|
||||||
value: _bloc,
|
appBar: UiAppBar(title: t.staff_time_card.title, showBackButton: true),
|
||||||
child: Scaffold(
|
body: BlocProvider.value(
|
||||||
appBar: UiAppBar(
|
value: _bloc,
|
||||||
title: t.staff_time_card.title,
|
child: BlocConsumer<TimeCardBloc, TimeCardState>(
|
||||||
showBackButton: true,
|
|
||||||
),
|
|
||||||
body: BlocConsumer<TimeCardBloc, TimeCardState>(
|
|
||||||
listener: (BuildContext context, TimeCardState state) {
|
listener: (BuildContext context, TimeCardState state) {
|
||||||
if (state is TimeCardError) {
|
if (state is TimeCardError) {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ class ShiftHistoryList extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
t.staff_time_card.shift_history,
|
t.staff_time_card.shift_history,
|
||||||
style: UiTypography.title2b.copyWith(
|
style: UiTypography.title2b,
|
||||||
color: UiColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
if (timesheets.isEmpty)
|
if (timesheets.isEmpty)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
|
|
||||||
/// A card widget displaying details of a single shift/timecard.
|
/// A card widget displaying details of a single shift/timecard.
|
||||||
class TimesheetCard extends StatelessWidget {
|
class TimesheetCard extends StatelessWidget {
|
||||||
|
|
||||||
const TimesheetCard({super.key, required this.timesheet});
|
const TimesheetCard({super.key, required this.timesheet});
|
||||||
final TimeCardEntry timesheet;
|
final TimeCardEntry timesheet;
|
||||||
|
|
||||||
@@ -25,9 +24,10 @@ class TimesheetCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.bgPopup,
|
color: UiColors.bgPopup,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
border: Border.all(color: UiColors.border),
|
border: Border.all(color: UiColors.border, width: 0.5),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -60,20 +60,22 @@ class TimesheetCard extends StatelessWidget {
|
|||||||
if (timesheet.clockInAt != null && timesheet.clockOutAt != null)
|
if (timesheet.clockInAt != null && timesheet.clockOutAt != null)
|
||||||
_IconText(
|
_IconText(
|
||||||
icon: UiIcons.clock,
|
icon: UiIcons.clock,
|
||||||
text: '${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}',
|
text:
|
||||||
|
'${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}',
|
||||||
),
|
),
|
||||||
if (timesheet.location != null)
|
if (timesheet.location != null)
|
||||||
_IconText(icon: UiIcons.mapPin, text: timesheet.location!),
|
_IconText(icon: UiIcons.mapPin, text: timesheet.location!),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space5),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
padding: const EdgeInsets.only(top: UiConstants.space4),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
border: Border(top: BorderSide(color: UiColors.border)),
|
border: Border(top: BorderSide(color: UiColors.border, width: 0.5)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
'${totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}',
|
'${totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}',
|
||||||
@@ -81,7 +83,7 @@ class TimesheetCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'\$${totalPay.toStringAsFixed(2)}',
|
'\$${totalPay.toStringAsFixed(2)}',
|
||||||
style: UiTypography.title2b.primary,
|
style: UiTypography.title1b,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -93,7 +95,6 @@ class TimesheetCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _IconText extends StatelessWidget {
|
class _IconText extends StatelessWidget {
|
||||||
|
|
||||||
const _IconText({required this.icon, required this.text});
|
const _IconText({required this.icon, required this.text});
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String text;
|
final String text;
|
||||||
@@ -105,10 +106,7 @@ class _IconText extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Icon(icon, size: 14, color: UiColors.iconSecondary),
|
Icon(icon, size: 14, color: UiColors.iconSecondary),
|
||||||
const SizedBox(width: UiConstants.space1),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Text(
|
Text(text, style: UiTypography.body2r.textSecondary),
|
||||||
text,
|
|
||||||
style: UiTypography.body2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> submitForApproval(String shiftId, {String? note}) async {
|
||||||
|
await _apiService.post(
|
||||||
|
StaffEndpoints.shiftSubmitForApproval(shiftId),
|
||||||
|
data: <String, dynamic>{
|
||||||
|
if (note != null) 'note': note,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> getProfileCompletion() async {
|
Future<bool> getProfileCompletion() async {
|
||||||
final ApiResponse response =
|
final ApiResponse response =
|
||||||
|
|||||||
@@ -47,4 +47,9 @@ abstract interface class ShiftsRepositoryInterface {
|
|||||||
|
|
||||||
/// Returns whether the staff profile is complete.
|
/// Returns whether the staff profile is complete.
|
||||||
Future<bool> getProfileCompletion();
|
Future<bool> getProfileCompletion();
|
||||||
|
|
||||||
|
/// Submits a completed shift for timesheet approval.
|
||||||
|
///
|
||||||
|
/// Only allowed for shifts in CHECKED_OUT or COMPLETED status.
|
||||||
|
Future<void> submitForApproval(String shiftId, {String? note});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
|
||||||
|
|
||||||
|
/// Submits a completed shift for timesheet approval.
|
||||||
|
///
|
||||||
|
/// Delegates to [ShiftsRepositoryInterface.submitForApproval] which calls
|
||||||
|
/// `POST /staff/shifts/:shiftId/submit-for-approval`.
|
||||||
|
class SubmitForApprovalUseCase {
|
||||||
|
/// Creates a [SubmitForApprovalUseCase].
|
||||||
|
SubmitForApprovalUseCase(this.repository);
|
||||||
|
|
||||||
|
/// The shifts repository.
|
||||||
|
final ShiftsRepositoryInterface repository;
|
||||||
|
|
||||||
|
/// Executes the use case.
|
||||||
|
Future<void> call(String shiftId, {String? note}) async {
|
||||||
|
return repository.submitForApproval(shiftId, note: note);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Computes a Friday-based week calendar for the given [weekOffset].
|
||||||
|
///
|
||||||
|
/// Returns a list of 7 [DateTime] values starting from the Friday of the
|
||||||
|
/// week identified by [weekOffset] (0 = current week, negative = past,
|
||||||
|
/// positive = future). Each date is midnight-normalised.
|
||||||
|
List<DateTime> getCalendarDaysForOffset(int weekOffset) {
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||||
|
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||||
|
final DateTime start = now
|
||||||
|
.subtract(Duration(days: daysSinceFriday))
|
||||||
|
.add(Duration(days: weekOffset * 7));
|
||||||
|
final DateTime startDate = DateTime(start.year, start.month, start.day);
|
||||||
|
return List<DateTime>.generate(
|
||||||
|
7,
|
||||||
|
(int index) => startDate.add(Duration(days: index)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters out [OpenShift] entries whose date is strictly before today.
|
||||||
|
///
|
||||||
|
/// Comparison is done at midnight granularity so shifts scheduled for
|
||||||
|
/// today are always included.
|
||||||
|
List<OpenShift> filterPastOpenShifts(List<OpenShift> shifts) {
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||||
|
return shifts.where((OpenShift shift) {
|
||||||
|
final DateTime dateOnly = DateTime(
|
||||||
|
shift.date.year,
|
||||||
|
shift.date.month,
|
||||||
|
shift.date.day,
|
||||||
|
);
|
||||||
|
return !dateOnly.isBefore(today);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
|||||||
isProfileComplete: isProfileComplete,
|
isProfileComplete: isProfileComplete,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
emit(const ShiftDetailsError('Shift not found'));
|
emit(const ShiftDetailsError('errors.shift.not_found'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (String errorKey) => ShiftDetailsError(errorKey),
|
onError: (String errorKey) => ShiftDetailsError(errorKey),
|
||||||
@@ -74,7 +74,7 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
|||||||
);
|
);
|
||||||
emit(
|
emit(
|
||||||
ShiftActionSuccess(
|
ShiftActionSuccess(
|
||||||
'Shift successfully booked!',
|
'shift_booked',
|
||||||
shiftDate: event.date,
|
shiftDate: event.date,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -91,7 +91,7 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
|||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
await declineShift(event.shiftId);
|
await declineShift(event.shiftId);
|
||||||
emit(const ShiftActionSuccess('Shift declined'));
|
emit(const ShiftActionSuccess('shift_declined_success'));
|
||||||
},
|
},
|
||||||
onError: (String errorKey) => ShiftDetailsError(errorKey),
|
onError: (String errorKey) => ShiftDetailsError(errorKey),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart
|
|||||||
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
|
||||||
|
import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart';
|
||||||
|
import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart';
|
||||||
|
|
||||||
part 'shifts_event.dart';
|
part 'shifts_event.dart';
|
||||||
part 'shifts_state.dart';
|
part 'shifts_state.dart';
|
||||||
@@ -31,6 +33,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
required this.getProfileCompletion,
|
required this.getProfileCompletion,
|
||||||
required this.acceptShift,
|
required this.acceptShift,
|
||||||
required this.declineShift,
|
required this.declineShift,
|
||||||
|
required this.submitForApproval,
|
||||||
}) : super(const ShiftsState()) {
|
}) : super(const ShiftsState()) {
|
||||||
on<LoadShiftsEvent>(_onLoadShifts);
|
on<LoadShiftsEvent>(_onLoadShifts);
|
||||||
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
|
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
|
||||||
@@ -41,6 +44,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
on<CheckProfileCompletionEvent>(_onCheckProfileCompletion);
|
on<CheckProfileCompletionEvent>(_onCheckProfileCompletion);
|
||||||
on<AcceptShiftEvent>(_onAcceptShift);
|
on<AcceptShiftEvent>(_onAcceptShift);
|
||||||
on<DeclineShiftEvent>(_onDeclineShift);
|
on<DeclineShiftEvent>(_onDeclineShift);
|
||||||
|
on<SubmitForApprovalEvent>(_onSubmitForApproval);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Use case for assigned shifts.
|
/// Use case for assigned shifts.
|
||||||
@@ -67,6 +71,9 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
/// Use case for declining a shift.
|
/// Use case for declining a shift.
|
||||||
final DeclineShiftUseCase declineShift;
|
final DeclineShiftUseCase declineShift;
|
||||||
|
|
||||||
|
/// Use case for submitting a shift for timesheet approval.
|
||||||
|
final SubmitForApprovalUseCase submitForApproval;
|
||||||
|
|
||||||
Future<void> _onLoadShifts(
|
Future<void> _onLoadShifts(
|
||||||
LoadShiftsEvent event,
|
LoadShiftsEvent event,
|
||||||
Emitter<ShiftsState> emit,
|
Emitter<ShiftsState> emit,
|
||||||
@@ -78,7 +85,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
final List<DateTime> days = _getCalendarDaysForOffset(0);
|
final List<DateTime> days = getCalendarDaysForOffset(0);
|
||||||
|
|
||||||
// Load assigned, pending, and cancelled shifts in parallel.
|
// Load assigned, pending, and cancelled shifts in parallel.
|
||||||
final List<Object> results = await Future.wait(<Future<Object>>[
|
final List<Object> results = await Future.wait(<Future<Object>>[
|
||||||
@@ -110,6 +117,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
historyLoaded: false,
|
historyLoaded: false,
|
||||||
myShiftsLoaded: true,
|
myShiftsLoaded: true,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
|
clearErrorMessage: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -136,6 +144,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
historyShifts: historyResult,
|
historyShifts: historyResult,
|
||||||
historyLoading: false,
|
historyLoading: false,
|
||||||
historyLoaded: true,
|
historyLoaded: true,
|
||||||
|
clearErrorMessage: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -167,9 +176,10 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
);
|
);
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
availableShifts: _filterPastOpenShifts(availableResult),
|
availableShifts: filterPastOpenShifts(availableResult),
|
||||||
availableLoading: false,
|
availableLoading: false,
|
||||||
availableLoaded: true,
|
availableLoaded: true,
|
||||||
|
clearErrorMessage: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -219,9 +229,10 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: ShiftsStatus.loaded,
|
status: ShiftsStatus.loaded,
|
||||||
availableShifts: _filterPastOpenShifts(availableResult),
|
availableShifts: filterPastOpenShifts(availableResult),
|
||||||
availableLoading: false,
|
availableLoading: false,
|
||||||
availableLoaded: true,
|
availableLoaded: true,
|
||||||
|
clearErrorMessage: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -239,6 +250,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
LoadShiftsForRangeEvent event,
|
LoadShiftsForRangeEvent event,
|
||||||
Emitter<ShiftsState> emit,
|
Emitter<ShiftsState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
emit(state.copyWith(myShifts: const <AssignedShift>[], myShiftsLoaded: false));
|
||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
@@ -251,6 +263,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
status: ShiftsStatus.loaded,
|
status: ShiftsStatus.loaded,
|
||||||
myShifts: myShiftsResult,
|
myShifts: myShiftsResult,
|
||||||
myShiftsLoaded: true,
|
myShiftsLoaded: true,
|
||||||
|
clearErrorMessage: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -281,7 +294,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
availableShifts: _filterPastOpenShifts(result),
|
availableShifts: filterPastOpenShifts(result),
|
||||||
searchQuery: search,
|
searchQuery: search,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -342,30 +355,37 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets calendar days for the given week offset (Friday-based week).
|
Future<void> _onSubmitForApproval(
|
||||||
List<DateTime> _getCalendarDaysForOffset(int weekOffset) {
|
SubmitForApprovalEvent event,
|
||||||
final DateTime now = DateTime.now();
|
Emitter<ShiftsState> emit,
|
||||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
) async {
|
||||||
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
// Guard: another submission is already in progress.
|
||||||
final DateTime start = now
|
if (state.submittingShiftId != null) return;
|
||||||
.subtract(Duration(days: daysSinceFriday))
|
// Guard: this shift was already submitted.
|
||||||
.add(Duration(days: weekOffset * 7));
|
if (state.submittedShiftIds.contains(event.shiftId)) return;
|
||||||
final DateTime startDate = DateTime(start.year, start.month, start.day);
|
|
||||||
return List<DateTime>.generate(
|
emit(state.copyWith(submittingShiftId: event.shiftId));
|
||||||
7, (int index) => startDate.add(Duration(days: index)));
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
|
action: () async {
|
||||||
|
await submitForApproval(event.shiftId, note: event.note);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
clearSubmittingShiftId: true,
|
||||||
|
clearErrorMessage: true,
|
||||||
|
submittedShiftIds: <String>{
|
||||||
|
...state.submittedShiftIds,
|
||||||
|
event.shiftId,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
clearSubmittingShiftId: true,
|
||||||
|
status: ShiftsStatus.error,
|
||||||
|
errorMessage: errorKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filters out open shifts whose date is in the past.
|
|
||||||
List<OpenShift> _filterPastOpenShifts(List<OpenShift> shifts) {
|
|
||||||
final DateTime now = DateTime.now();
|
|
||||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
|
||||||
return shifts.where((OpenShift shift) {
|
|
||||||
final DateTime dateOnly = DateTime(
|
|
||||||
shift.date.year,
|
|
||||||
shift.date.month,
|
|
||||||
shift.date.day,
|
|
||||||
);
|
|
||||||
return !dateOnly.isBefore(today);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,3 +93,18 @@ class CheckProfileCompletionEvent extends ShiftsEvent {
|
|||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[];
|
List<Object?> get props => <Object?>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Submits a completed shift for timesheet approval.
|
||||||
|
class SubmitForApprovalEvent extends ShiftsEvent {
|
||||||
|
/// Creates a [SubmitForApprovalEvent].
|
||||||
|
const SubmitForApprovalEvent({required this.shiftId, this.note});
|
||||||
|
|
||||||
|
/// The shift row id to submit.
|
||||||
|
final String shiftId;
|
||||||
|
|
||||||
|
/// Optional note to include with the submission.
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[shiftId, note];
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class ShiftsState extends Equatable {
|
|||||||
this.searchQuery = '',
|
this.searchQuery = '',
|
||||||
this.profileComplete,
|
this.profileComplete,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
this.submittingShiftId,
|
||||||
|
this.submittedShiftIds = const <String>{},
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Current lifecycle status.
|
/// Current lifecycle status.
|
||||||
@@ -65,6 +67,12 @@ class ShiftsState extends Equatable {
|
|||||||
/// Error message key for display.
|
/// Error message key for display.
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// The shift ID currently being submitted for approval (null when idle).
|
||||||
|
final String? submittingShiftId;
|
||||||
|
|
||||||
|
/// Set of shift IDs that have been successfully submitted for approval.
|
||||||
|
final Set<String> submittedShiftIds;
|
||||||
|
|
||||||
/// Creates a copy with the given fields replaced.
|
/// Creates a copy with the given fields replaced.
|
||||||
ShiftsState copyWith({
|
ShiftsState copyWith({
|
||||||
ShiftsStatus? status,
|
ShiftsStatus? status,
|
||||||
@@ -81,6 +89,10 @@ class ShiftsState extends Equatable {
|
|||||||
String? searchQuery,
|
String? searchQuery,
|
||||||
bool? profileComplete,
|
bool? profileComplete,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
bool clearErrorMessage = false,
|
||||||
|
String? submittingShiftId,
|
||||||
|
bool clearSubmittingShiftId = false,
|
||||||
|
Set<String>? submittedShiftIds,
|
||||||
}) {
|
}) {
|
||||||
return ShiftsState(
|
return ShiftsState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -96,7 +108,13 @@ class ShiftsState extends Equatable {
|
|||||||
myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded,
|
myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded,
|
||||||
searchQuery: searchQuery ?? this.searchQuery,
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
profileComplete: profileComplete ?? this.profileComplete,
|
profileComplete: profileComplete ?? this.profileComplete,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: clearErrorMessage
|
||||||
|
? null
|
||||||
|
: (errorMessage ?? this.errorMessage),
|
||||||
|
submittingShiftId: clearSubmittingShiftId
|
||||||
|
? null
|
||||||
|
: (submittingShiftId ?? this.submittingShiftId),
|
||||||
|
submittedShiftIds: submittedShiftIds ?? this.submittedShiftIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,5 +134,7 @@ class ShiftsState extends Equatable {
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
profileComplete,
|
profileComplete,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
submittingShiftId,
|
||||||
|
submittedShiftIds,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,6 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
return DateFormat('EEEE, MMMM d, y').format(dt);
|
return DateFormat('EEEE, MMMM d, y').format(dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
double _calculateDuration(ShiftDetail detail) {
|
|
||||||
final int minutes = detail.endTime.difference(detail.startTime).inMinutes;
|
|
||||||
final double hours = minutes / 60;
|
|
||||||
return hours < 0 ? hours + 24 : hours;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<ShiftDetailsBloc>(
|
return BlocProvider<ShiftDetailsBloc>(
|
||||||
@@ -67,7 +61,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
_isApplying = false;
|
_isApplying = false;
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: state.message,
|
message: _translateSuccessKey(context, state.message),
|
||||||
type: UiSnackbarType.success,
|
type: UiSnackbarType.success,
|
||||||
);
|
);
|
||||||
Modular.to.toShifts(
|
Modular.to.toShifts(
|
||||||
@@ -98,14 +92,8 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final ShiftDetail detail = state.detail;
|
final ShiftDetail detail = state.detail;
|
||||||
final dynamic i18n =
|
|
||||||
Translations.of(context).staff_shifts.shift_details;
|
|
||||||
final bool isProfileComplete = state.isProfileComplete;
|
final bool isProfileComplete = state.isProfileComplete;
|
||||||
|
|
||||||
final double duration = _calculateDuration(detail);
|
|
||||||
final double hourlyRate = detail.hourlyRateCents / 100;
|
|
||||||
final double estimatedTotal = hourlyRate * duration;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: UiAppBar(
|
appBar: UiAppBar(
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
@@ -122,45 +110,46 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space6),
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
child: UiNoticeBanner(
|
child: UiNoticeBanner(
|
||||||
title: 'Complete Your Account',
|
title: context.t.staff_shifts.shift_details
|
||||||
description:
|
.complete_account_title,
|
||||||
'Complete your account to book this shift and start earning',
|
description: context.t.staff_shifts.shift_details
|
||||||
|
.complete_account_description,
|
||||||
icon: UiIcons.sparkles,
|
icon: UiIcons.sparkles,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ShiftDetailsHeader(detail: detail),
|
ShiftDetailsHeader(detail: detail),
|
||||||
const Divider(height: 1, thickness: 0.5),
|
const Divider(height: 1, thickness: 0.5),
|
||||||
ShiftStatsRow(
|
ShiftStatsRow(
|
||||||
estimatedTotal: estimatedTotal,
|
estimatedTotal: detail.estimatedTotal,
|
||||||
hourlyRate: hourlyRate,
|
hourlyRate: detail.hourlyRate,
|
||||||
duration: duration,
|
duration: detail.durationHours,
|
||||||
totalLabel: i18n.est_total,
|
totalLabel: context.t.staff_shifts.shift_details.est_total,
|
||||||
hourlyRateLabel: i18n.hourly_rate,
|
hourlyRateLabel: context.t.staff_shifts.shift_details.hourly_rate,
|
||||||
hoursLabel: i18n.hours,
|
hoursLabel: context.t.staff_shifts.shift_details.hours,
|
||||||
),
|
),
|
||||||
const Divider(height: 1, thickness: 0.5),
|
const Divider(height: 1, thickness: 0.5),
|
||||||
ShiftDateTimeSection(
|
ShiftDateTimeSection(
|
||||||
date: detail.date,
|
date: detail.date,
|
||||||
startTime: detail.startTime,
|
startTime: detail.startTime,
|
||||||
endTime: detail.endTime,
|
endTime: detail.endTime,
|
||||||
shiftDateLabel: i18n.shift_date,
|
shiftDateLabel: context.t.staff_shifts.shift_details.shift_date,
|
||||||
clockInLabel: i18n.start_time,
|
clockInLabel: context.t.staff_shifts.shift_details.start_time,
|
||||||
clockOutLabel: i18n.end_time,
|
clockOutLabel: context.t.staff_shifts.shift_details.end_time,
|
||||||
),
|
),
|
||||||
const Divider(height: 1, thickness: 0.5),
|
const Divider(height: 1, thickness: 0.5),
|
||||||
ShiftLocationSection(
|
ShiftLocationSection(
|
||||||
location: detail.location,
|
location: detail.location,
|
||||||
address: detail.address ?? '',
|
address: detail.address ?? '',
|
||||||
locationLabel: i18n.location,
|
locationLabel: context.t.staff_shifts.shift_details.location,
|
||||||
tbdLabel: i18n.tbd,
|
tbdLabel: context.t.staff_shifts.shift_details.tbd,
|
||||||
getDirectionLabel: i18n.get_direction,
|
getDirectionLabel: context.t.staff_shifts.shift_details.get_direction,
|
||||||
),
|
),
|
||||||
const Divider(height: 1, thickness: 0.5),
|
const Divider(height: 1, thickness: 0.5),
|
||||||
if (detail.description != null &&
|
if (detail.description != null &&
|
||||||
detail.description!.isNotEmpty)
|
detail.description!.isNotEmpty)
|
||||||
ShiftDescriptionSection(
|
ShiftDescriptionSection(
|
||||||
description: detail.description!,
|
description: detail.description!,
|
||||||
descriptionLabel: i18n.job_description,
|
descriptionLabel: context.t.staff_shifts.shift_details.job_description,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -190,13 +179,11 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _bookShift(BuildContext context, ShiftDetail detail) {
|
void _bookShift(BuildContext context, ShiftDetail detail) {
|
||||||
final dynamic i18n =
|
|
||||||
Translations.of(context).staff_shifts.shift_details.book_dialog;
|
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) => AlertDialog(
|
builder: (BuildContext ctx) => AlertDialog(
|
||||||
title: Text(i18n.title as String),
|
title: Text(context.t.staff_shifts.shift_details.book_dialog.title),
|
||||||
content: Text(i18n.message as String),
|
content: Text(context.t.staff_shifts.shift_details.book_dialog.message),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Modular.to.popSafe(),
|
onPressed: () => Modular.to.popSafe(),
|
||||||
@@ -228,14 +215,12 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
if (_actionDialogOpen) return;
|
if (_actionDialogOpen) return;
|
||||||
_actionDialogOpen = true;
|
_actionDialogOpen = true;
|
||||||
_isApplying = true;
|
_isApplying = true;
|
||||||
final dynamic i18n =
|
|
||||||
Translations.of(context).staff_shifts.shift_details.applying_dialog;
|
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext ctx) => AlertDialog(
|
builder: (BuildContext ctx) => AlertDialog(
|
||||||
title: Text(i18n.title as String),
|
title: Text(context.t.staff_shifts.shift_details.applying_dialog.title),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
@@ -250,7 +235,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
style: UiTypography.body2b.textPrimary,
|
style: UiTypography.body2b.textPrimary,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
'${_formatDate(detail.date)} \u2022 ${_formatTime(detail.startTime)} - ${_formatTime(detail.endTime)}',
|
'${_formatDate(detail.date)} \u2022 ${_formatTime(detail.startTime)} - ${_formatTime(detail.endTime)}',
|
||||||
style: UiTypography.body3r.textSecondary,
|
style: UiTypography.body3r.textSecondary,
|
||||||
@@ -270,6 +255,18 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
_actionDialogOpen = false;
|
_actionDialogOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Translates a success message key to a localized string.
|
||||||
|
String _translateSuccessKey(BuildContext context, String key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'shift_booked':
|
||||||
|
return context.t.staff_shifts.shift_details.shift_booked;
|
||||||
|
case 'shift_declined_success':
|
||||||
|
return context.t.staff_shifts.shift_details.shift_declined_success;
|
||||||
|
default:
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showEligibilityErrorDialog(BuildContext context) {
|
void _showEligibilityErrorDialog(BuildContext context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -288,16 +285,16 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: Text(
|
content: Text(
|
||||||
'You are missing required certifications or documents to claim this shift. Please upload them to continue.',
|
context.t.staff_shifts.shift_details.missing_certifications,
|
||||||
style: UiTypography.body2r.textSecondary,
|
style: UiTypography.body2r.textSecondary,
|
||||||
),
|
),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
UiButton.secondary(
|
UiButton.secondary(
|
||||||
text: 'Cancel',
|
text: Translations.of(context).common.cancel,
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
),
|
),
|
||||||
UiButton.primary(
|
UiButton.primary(
|
||||||
text: 'Go to Certificates',
|
text: context.t.staff_shifts.shift_details.go_to_certificates,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();
|
Modular.to.popSafe();
|
||||||
Modular.to.toCertificates();
|
Modular.to.toCertificates();
|
||||||
|
|||||||
@@ -12,10 +12,15 @@ import 'package:staff_shifts/src/presentation/widgets/tabs/my_shifts_tab.dart';
|
|||||||
import 'package:staff_shifts/src/presentation/widgets/tabs/find_shifts_tab.dart';
|
import 'package:staff_shifts/src/presentation/widgets/tabs/find_shifts_tab.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.dart';
|
import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.dart';
|
||||||
|
|
||||||
|
/// Tabbed page for browsing staff shifts (My Shifts, Find Work, History).
|
||||||
|
///
|
||||||
|
/// Manages tab state locally and delegates data loading to [ShiftsBloc].
|
||||||
class ShiftsPage extends StatefulWidget {
|
class ShiftsPage extends StatefulWidget {
|
||||||
final ShiftTabType? initialTab;
|
/// Creates a [ShiftsPage].
|
||||||
final DateTime? selectedDate;
|
///
|
||||||
final bool refreshAvailable;
|
/// [initialTab] selects the active tab on first render.
|
||||||
|
/// [selectedDate] pre-selects a calendar date in the My Shifts tab.
|
||||||
|
/// [refreshAvailable] triggers a forced reload of available shifts.
|
||||||
const ShiftsPage({
|
const ShiftsPage({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialTab,
|
this.initialTab,
|
||||||
@@ -23,6 +28,15 @@ class ShiftsPage extends StatefulWidget {
|
|||||||
this.refreshAvailable = false,
|
this.refreshAvailable = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The tab to display on initial render. Defaults to [ShiftTabType.find].
|
||||||
|
final ShiftTabType? initialTab;
|
||||||
|
|
||||||
|
/// Optional date to pre-select in the My Shifts calendar.
|
||||||
|
final DateTime? selectedDate;
|
||||||
|
|
||||||
|
/// When true, forces a refresh of available shifts on load.
|
||||||
|
final bool refreshAvailable;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ShiftsPage> createState() => _ShiftsPageState();
|
State<ShiftsPage> createState() => _ShiftsPageState();
|
||||||
}
|
}
|
||||||
@@ -251,6 +265,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
pendingAssignments: pendingAssignments,
|
pendingAssignments: pendingAssignments,
|
||||||
cancelledShifts: cancelledShifts,
|
cancelledShifts: cancelledShifts,
|
||||||
initialDate: _selectedDate,
|
initialDate: _selectedDate,
|
||||||
|
submittedShiftIds: state.submittedShiftIds,
|
||||||
);
|
);
|
||||||
case ShiftTabType.find:
|
case ShiftTabType.find:
|
||||||
if (availableLoading) {
|
if (availableLoading) {
|
||||||
@@ -264,7 +279,10 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
if (historyLoading) {
|
if (historyLoading) {
|
||||||
return const ShiftsPageSkeleton();
|
return const ShiftsPageSkeleton();
|
||||||
}
|
}
|
||||||
return HistoryShiftsTab(historyShifts: historyShifts);
|
return HistoryShiftsTab(
|
||||||
|
historyShifts: historyShifts,
|
||||||
|
submittedShiftIds: state.submittedShiftIds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +351,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showCount) ...[
|
if (showCount) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: UiConstants.space1,
|
horizontal: UiConstants.space1,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
@@ -107,7 +108,7 @@ class ShiftAssignmentCard extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(UiIcons.calendar,
|
const Icon(UiIcons.calendar,
|
||||||
size: 12, color: UiColors.iconSecondary),
|
size: 12, color: UiColors.iconSecondary),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
_formatDate(assignment.startTime),
|
_formatDate(assignment.startTime),
|
||||||
style: UiTypography.footnote1r.textSecondary,
|
style: UiTypography.footnote1r.textSecondary,
|
||||||
@@ -115,19 +116,19 @@ class ShiftAssignmentCard extends StatelessWidget {
|
|||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox(width: UiConstants.space3),
|
||||||
const Icon(UiIcons.clock,
|
const Icon(UiIcons.clock,
|
||||||
size: 12, color: UiColors.iconSecondary),
|
size: 12, color: UiColors.iconSecondary),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
'${_formatTime(assignment.startTime)} - ${_formatTime(assignment.endTime)}',
|
'${_formatTime(assignment.startTime)} - ${_formatTime(assignment.endTime)}',
|
||||||
style: UiTypography.footnote1r.textSecondary,
|
style: UiTypography.footnote1r.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(UiIcons.mapPin,
|
const Icon(UiIcons.mapPin,
|
||||||
size: 12, color: UiColors.iconSecondary),
|
size: 12, color: UiColors.iconSecondary),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
assignment.location,
|
assignment.location,
|
||||||
@@ -160,7 +161,10 @@ class ShiftAssignmentCard extends StatelessWidget {
|
|||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: UiColors.destructive,
|
foregroundColor: UiColors.destructive,
|
||||||
),
|
),
|
||||||
child: Text('Decline', style: UiTypography.body2m.textError),
|
child: Text(
|
||||||
|
context.t.staff_shifts.shift_details.decline,
|
||||||
|
style: UiTypography.body2m.textError,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
@@ -178,14 +182,17 @@ class ShiftAssignmentCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: isConfirming
|
child: isConfirming
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 16,
|
height: UiConstants.space4,
|
||||||
width: 16,
|
width: UiConstants.space4,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text('Accept', style: UiTypography.body2m.white),
|
: Text(
|
||||||
|
context.t.staff_shifts.shift_details.accept_shift,
|
||||||
|
style: UiTypography.body2m.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext;
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
|
||||||
|
|
||||||
/// Tab displaying completed shift history.
|
/// Tab displaying completed shift history.
|
||||||
class HistoryShiftsTab extends StatelessWidget {
|
class HistoryShiftsTab extends StatelessWidget {
|
||||||
/// Creates a [HistoryShiftsTab].
|
/// Creates a [HistoryShiftsTab].
|
||||||
const HistoryShiftsTab({super.key, required this.historyShifts});
|
const HistoryShiftsTab({
|
||||||
|
super.key,
|
||||||
|
required this.historyShifts,
|
||||||
|
this.submittedShiftIds = const <String>{},
|
||||||
|
});
|
||||||
|
|
||||||
/// Completed shifts.
|
/// Completed shifts.
|
||||||
final List<CompletedShift> historyShifts;
|
final List<CompletedShift> historyShifts;
|
||||||
|
|
||||||
|
/// Set of shift IDs that have been successfully submitted for approval.
|
||||||
|
final Set<String> submittedShiftIds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (historyShifts.isEmpty) {
|
if (historyShifts.isEmpty) {
|
||||||
@@ -32,14 +41,31 @@ class HistoryShiftsTab extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const SizedBox(height: UiConstants.space5),
|
const SizedBox(height: UiConstants.space5),
|
||||||
...historyShifts.map(
|
...historyShifts.map(
|
||||||
(CompletedShift shift) => Padding(
|
(CompletedShift shift) {
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
final bool isSubmitted =
|
||||||
child: ShiftCard(
|
submittedShiftIds.contains(shift.shiftId);
|
||||||
data: ShiftCardData.fromCompleted(shift),
|
return Padding(
|
||||||
onTap: () =>
|
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||||
Modular.to.toShiftDetailsById(shift.shiftId),
|
child: ShiftCard(
|
||||||
),
|
data: ShiftCardData.fromCompleted(shift),
|
||||||
),
|
onTap: () =>
|
||||||
|
Modular.to.toShiftDetailsById(shift.shiftId),
|
||||||
|
showApprovalAction: !isSubmitted,
|
||||||
|
isSubmitted: isSubmitted,
|
||||||
|
onSubmitForApproval: () {
|
||||||
|
ReadContext(context).read<ShiftsBloc>().add(
|
||||||
|
SubmitForApprovalEvent(shiftId: shift.shiftId),
|
||||||
|
);
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: context.t.staff_shifts
|
||||||
|
.my_shift_card.timesheet_submitted,
|
||||||
|
type: UiSnackbarType.success,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space32),
|
const SizedBox(height: UiConstants.space32),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart';
|
||||||
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
|
||||||
@@ -20,6 +21,7 @@ class MyShiftsTab extends StatefulWidget {
|
|||||||
required this.pendingAssignments,
|
required this.pendingAssignments,
|
||||||
required this.cancelledShifts,
|
required this.cancelledShifts,
|
||||||
this.initialDate,
|
this.initialDate,
|
||||||
|
this.submittedShiftIds = const <String>{},
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Assigned shifts for the current week.
|
/// Assigned shifts for the current week.
|
||||||
@@ -34,6 +36,9 @@ class MyShiftsTab extends StatefulWidget {
|
|||||||
/// Initial date to select in the calendar.
|
/// Initial date to select in the calendar.
|
||||||
final DateTime? initialDate;
|
final DateTime? initialDate;
|
||||||
|
|
||||||
|
/// Set of shift IDs that have been successfully submitted for approval.
|
||||||
|
final Set<String> submittedShiftIds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MyShiftsTab> createState() => _MyShiftsTabState();
|
State<MyShiftsTab> createState() => _MyShiftsTabState();
|
||||||
}
|
}
|
||||||
@@ -42,9 +47,6 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
DateTime _selectedDate = DateTime.now();
|
DateTime _selectedDate = DateTime.now();
|
||||||
int _weekOffset = 0;
|
int _weekOffset = 0;
|
||||||
|
|
||||||
/// Tracks which completed-shift cards have been submitted locally.
|
|
||||||
final Set<String> _submittedShiftIds = <String>{};
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -90,20 +92,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
List<DateTime> _getCalendarDays() {
|
List<DateTime> _getCalendarDays() => getCalendarDaysForOffset(_weekOffset);
|
||||||
final DateTime now = DateTime.now();
|
|
||||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
|
||||||
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
|
||||||
final DateTime start = now
|
|
||||||
.subtract(Duration(days: daysSinceFriday))
|
|
||||||
.add(Duration(days: _weekOffset * 7));
|
|
||||||
final DateTime startDate =
|
|
||||||
DateTime(start.year, start.month, start.day);
|
|
||||||
return List<DateTime>.generate(
|
|
||||||
7,
|
|
||||||
(int index) => startDate.add(Duration(days: index)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _loadShiftsForCurrentWeek() {
|
void _loadShiftsForCurrentWeek() {
|
||||||
final List<DateTime> calendarDays = _getCalendarDays();
|
final List<DateTime> calendarDays = _getCalendarDays();
|
||||||
@@ -402,7 +391,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
final bool isCompleted =
|
final bool isCompleted =
|
||||||
shift.status == AssignmentStatus.completed;
|
shift.status == AssignmentStatus.completed;
|
||||||
final bool isSubmitted =
|
final bool isSubmitted =
|
||||||
_submittedShiftIds.contains(shift.shiftId);
|
widget.submittedShiftIds.contains(shift.shiftId);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
@@ -415,9 +404,11 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
showApprovalAction: isCompleted,
|
showApprovalAction: isCompleted,
|
||||||
isSubmitted: isSubmitted,
|
isSubmitted: isSubmitted,
|
||||||
onSubmitForApproval: () {
|
onSubmitForApproval: () {
|
||||||
setState(() {
|
ReadContext(context).read<ShiftsBloc>().add(
|
||||||
_submittedShiftIds.add(shift.shiftId);
|
SubmitForApprovalEvent(
|
||||||
});
|
shiftId: shift.shiftId,
|
||||||
|
),
|
||||||
|
);
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: context.t.staff_shifts
|
message: context.t.staff_shifts
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.
|
|||||||
import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart';
|
||||||
|
import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart';
|
||||||
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
||||||
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart';
|
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart';
|
||||||
import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart';
|
import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart';
|
||||||
@@ -45,6 +46,9 @@ class StaffShiftsModule extends Module {
|
|||||||
i.addLazySingleton(ApplyForShiftUseCase.new);
|
i.addLazySingleton(ApplyForShiftUseCase.new);
|
||||||
i.addLazySingleton(GetShiftDetailUseCase.new);
|
i.addLazySingleton(GetShiftDetailUseCase.new);
|
||||||
i.addLazySingleton(GetProfileCompletionUseCase.new);
|
i.addLazySingleton(GetProfileCompletionUseCase.new);
|
||||||
|
i.addLazySingleton(
|
||||||
|
() => SubmitForApprovalUseCase(i.get<ShiftsRepositoryInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
// BLoC
|
// BLoC
|
||||||
i.add(
|
i.add(
|
||||||
@@ -57,6 +61,7 @@ class StaffShiftsModule extends Module {
|
|||||||
getProfileCompletion: i.get(),
|
getProfileCompletion: i.get(),
|
||||||
acceptShift: i.get(),
|
acceptShift: i.get(),
|
||||||
declineShift: i.get(),
|
declineShift: i.get(),
|
||||||
|
submitForApproval: i.get(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
i.add(
|
i.add(
|
||||||
|
|||||||
Reference in New Issue
Block a user