feat: Add localization for hourly rate and enhance geofence notifications
- Added localization for hourly rate in English and Spanish. - Updated background geofence service to use localized notification titles and bodies. - Enhanced ClockInBloc to compute time window flags for check-in/check-out availability. - Updated ClockInState and ClockInEvent to include check-in/check-out availability flags and messages. - Refactored ClockInActionSection to display availability messages based on computed flags. - Ensured compliance with design system for user-facing strings and notification messages.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// ignore_for_file: avoid_print
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/src/core/models/device_location.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Top-level callback dispatcher for background geofence tasks.
|
||||
///
|
||||
@@ -63,13 +63,17 @@ void backgroundGeofenceDispatcher() {
|
||||
'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: "You've Left the Workplace",
|
||||
body:
|
||||
'You appear to be more than 500m from your shift location.',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
} else {
|
||||
print(
|
||||
@@ -138,11 +142,14 @@ class BackgroundGeofenceService {
|
||||
/// Starts periodic 15-minute background geofence checks.
|
||||
///
|
||||
/// Called after a successful clock-in. Persists the target coordinates
|
||||
/// so the background isolate can access them.
|
||||
/// and passes localized notification strings via [inputData] so the
|
||||
/// background isolate can display them without DI.
|
||||
Future<void> startBackgroundTracking({
|
||||
required double targetLat,
|
||||
required double targetLng,
|
||||
required String shiftId,
|
||||
required String leftGeofenceTitle,
|
||||
required String leftGeofenceBody,
|
||||
}) async {
|
||||
await Future.wait(<Future<bool>>[
|
||||
_storageService.setDouble(_keyTargetLat, targetLat),
|
||||
@@ -159,6 +166,8 @@ class BackgroundGeofenceService {
|
||||
'targetLat': targetLat,
|
||||
'targetLng': targetLng,
|
||||
'shiftId': shiftId,
|
||||
'leftGeofenceTitle': leftGeofenceTitle,
|
||||
'leftGeofenceBody': leftGeofenceBody,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../domain/usecases/get_todays_shift_usecase.dart';
|
||||
import '../../../domain/validators/clock_in_validation_context.dart';
|
||||
import '../../../domain/validators/clock_in_validation_result.dart';
|
||||
import '../../../domain/validators/validators/composite_clock_in_validator.dart';
|
||||
import '../../../domain/validators/validators/time_window_validator.dart';
|
||||
import '../geofence/geofence_bloc.dart';
|
||||
import '../geofence/geofence_event.dart';
|
||||
import '../geofence/geofence_state.dart';
|
||||
@@ -86,11 +87,19 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
selectedShift ??= shifts.last;
|
||||
}
|
||||
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
|
||||
selectedShift,
|
||||
);
|
||||
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
todayShifts: shifts,
|
||||
selectedShift: selectedShift,
|
||||
attendance: status,
|
||||
isCheckInAllowed: timeFlags.isCheckInAllowed,
|
||||
isCheckOutAllowed: timeFlags.isCheckOutAllowed,
|
||||
checkInAvailabilityTime: timeFlags.checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime,
|
||||
));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
@@ -100,12 +109,19 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates the currently selected shift.
|
||||
/// Updates the currently selected shift and recomputes time window flags.
|
||||
void _onShiftSelected(
|
||||
ShiftSelected event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
emit(state.copyWith(selectedShift: event.shift));
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift);
|
||||
emit(state.copyWith(
|
||||
selectedShift: event.shift,
|
||||
isCheckInAllowed: timeFlags.isCheckInAllowed,
|
||||
isCheckOutAllowed: timeFlags.isCheckOutAllowed,
|
||||
checkInAvailabilityTime: timeFlags.checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime,
|
||||
));
|
||||
}
|
||||
|
||||
/// Updates the selected date for shift viewing.
|
||||
@@ -236,6 +252,56 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
|
||||
/// 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 = _tryParseDateTime(shift.startTime);
|
||||
final DateTime? shiftEnd = _tryParseDateTime(shift.endTime);
|
||||
|
||||
// Check-in window.
|
||||
bool isCheckInAllowed = true;
|
||||
String? checkInAvailabilityTime;
|
||||
if (shiftStart != null) {
|
||||
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;
|
||||
if (shiftEnd != null) {
|
||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||
if (!isCheckOutAllowed) {
|
||||
checkOutAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
}
|
||||
|
||||
return _TimeWindowFlags(
|
||||
isCheckInAllowed: isCheckInAllowed,
|
||||
isCheckOutAllowed: isCheckOutAllowed,
|
||||
checkInAvailabilityTime: checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: checkOutAvailabilityTime,
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the
|
||||
/// geofence has target coordinates.
|
||||
void _dispatchBackgroundTrackingStarted({
|
||||
@@ -254,8 +320,33 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
targetLng: geofenceState.targetLng!,
|
||||
greetingTitle: event.clockInGreetingTitle,
|
||||
greetingBody: event.clockInGreetingBody,
|
||||
leftGeofenceTitle: event.leftGeofenceTitle,
|
||||
leftGeofenceBody: event.leftGeofenceBody,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ class CheckInRequested extends ClockInEvent {
|
||||
this.notes,
|
||||
this.clockInGreetingTitle = '',
|
||||
this.clockInGreetingBody = '',
|
||||
this.leftGeofenceTitle = '',
|
||||
this.leftGeofenceBody = '',
|
||||
});
|
||||
|
||||
/// The ID of the shift to clock into.
|
||||
@@ -59,12 +61,20 @@ class CheckInRequested extends ClockInEvent {
|
||||
/// Localized body for the clock-in greeting notification.
|
||||
final String clockInGreetingBody;
|
||||
|
||||
/// Localized title for the left-geofence background notification.
|
||||
final String leftGeofenceTitle;
|
||||
|
||||
/// Localized body for the left-geofence background notification.
|
||||
final String leftGeofenceBody;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
shiftId,
|
||||
notes,
|
||||
clockInGreetingTitle,
|
||||
clockInGreetingBody,
|
||||
leftGeofenceTitle,
|
||||
leftGeofenceBody,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ class ClockInState extends Equatable {
|
||||
required this.selectedDate,
|
||||
this.checkInMode = 'swipe',
|
||||
this.errorMessage,
|
||||
this.isCheckInAllowed = true,
|
||||
this.isCheckOutAllowed = true,
|
||||
this.checkInAvailabilityTime,
|
||||
this.checkOutAvailabilityTime,
|
||||
});
|
||||
|
||||
/// Current page status.
|
||||
@@ -41,6 +45,18 @@ class ClockInState extends Equatable {
|
||||
/// Error message key for displaying failures.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Whether the time window allows the user to check in.
|
||||
final bool isCheckInAllowed;
|
||||
|
||||
/// Whether the time window allows the user to check out.
|
||||
final bool isCheckOutAllowed;
|
||||
|
||||
/// Formatted earliest time when check-in becomes available, or `null`.
|
||||
final String? checkInAvailabilityTime;
|
||||
|
||||
/// Formatted earliest time when check-out becomes available, or `null`.
|
||||
final String? checkOutAvailabilityTime;
|
||||
|
||||
/// Creates a copy of this state with the given fields replaced.
|
||||
ClockInState copyWith({
|
||||
ClockInStatus? status,
|
||||
@@ -50,6 +66,10 @@ class ClockInState extends Equatable {
|
||||
DateTime? selectedDate,
|
||||
String? checkInMode,
|
||||
String? errorMessage,
|
||||
bool? isCheckInAllowed,
|
||||
bool? isCheckOutAllowed,
|
||||
String? checkInAvailabilityTime,
|
||||
String? checkOutAvailabilityTime,
|
||||
}) {
|
||||
return ClockInState(
|
||||
status: status ?? this.status,
|
||||
@@ -59,6 +79,12 @@ class ClockInState extends Equatable {
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
checkInMode: checkInMode ?? this.checkInMode,
|
||||
errorMessage: errorMessage,
|
||||
isCheckInAllowed: isCheckInAllowed ?? this.isCheckInAllowed,
|
||||
isCheckOutAllowed: isCheckOutAllowed ?? this.isCheckOutAllowed,
|
||||
checkInAvailabilityTime:
|
||||
checkInAvailabilityTime ?? this.checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime:
|
||||
checkOutAvailabilityTime ?? this.checkOutAvailabilityTime,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,5 +97,9 @@ class ClockInState extends Equatable {
|
||||
selectedDate,
|
||||
checkInMode,
|
||||
errorMessage,
|
||||
isCheckInAllowed,
|
||||
isCheckOutAllowed,
|
||||
checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -213,6 +213,8 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||
targetLat: event.targetLat,
|
||||
targetLng: event.targetLng,
|
||||
shiftId: event.shiftId,
|
||||
leftGeofenceTitle: event.leftGeofenceTitle,
|
||||
leftGeofenceBody: event.leftGeofenceBody,
|
||||
);
|
||||
|
||||
// Show greeting notification using localized strings from the UI.
|
||||
|
||||
@@ -71,6 +71,8 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
||||
required this.targetLng,
|
||||
required this.greetingTitle,
|
||||
required this.greetingBody,
|
||||
required this.leftGeofenceTitle,
|
||||
required this.leftGeofenceBody,
|
||||
});
|
||||
|
||||
/// The shift ID being tracked.
|
||||
@@ -88,9 +90,24 @@ class BackgroundTrackingStarted extends GeofenceEvent {
|
||||
/// Localized greeting notification body passed from the UI layer.
|
||||
final String greetingBody;
|
||||
|
||||
/// Localized title for the left-geofence notification, persisted to storage
|
||||
/// for the background isolate.
|
||||
final String leftGeofenceTitle;
|
||||
|
||||
/// Localized body for the left-geofence notification, persisted to storage
|
||||
/// for the background isolate.
|
||||
final String leftGeofenceBody;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
<Object?>[shiftId, targetLat, targetLng, greetingTitle, greetingBody];
|
||||
List<Object?> get props => <Object?>[
|
||||
shiftId,
|
||||
targetLat,
|
||||
targetLng,
|
||||
greetingTitle,
|
||||
greetingBody,
|
||||
leftGeofenceTitle,
|
||||
leftGeofenceBody,
|
||||
];
|
||||
}
|
||||
|
||||
/// Stops background tracking after clock-out.
|
||||
|
||||
@@ -8,8 +8,6 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_clock_in/src/presentation/widgets/early_check_in_banner.dart';
|
||||
import 'package:staff_clock_in/src/presentation/widgets/early_check_out_banner.dart';
|
||||
|
||||
import '../../domain/validators/clock_in_validation_context.dart';
|
||||
import '../../domain/validators/validators/time_window_validator.dart';
|
||||
import '../bloc/clock_in/clock_in_bloc.dart';
|
||||
import '../bloc/clock_in/clock_in_event.dart';
|
||||
import '../bloc/geofence/geofence_bloc.dart';
|
||||
@@ -37,6 +35,10 @@ class ClockInActionSection extends StatelessWidget {
|
||||
required this.checkInMode,
|
||||
required this.isActionInProgress,
|
||||
this.hasClockinError = false,
|
||||
this.isCheckInAllowed = true,
|
||||
this.isCheckOutAllowed = true,
|
||||
this.checkInAvailabilityTime,
|
||||
this.checkOutAvailabilityTime,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -65,6 +67,18 @@ class ClockInActionSection extends StatelessWidget {
|
||||
/// Whether the last action attempt resulted in an error.
|
||||
final bool hasClockinError;
|
||||
|
||||
/// Whether the time window allows check-in, computed by the BLoC.
|
||||
final bool isCheckInAllowed;
|
||||
|
||||
/// Whether the time window allows check-out, computed by the BLoC.
|
||||
final bool isCheckOutAllowed;
|
||||
|
||||
/// Formatted earliest time when check-in becomes available, or `null`.
|
||||
final String? checkInAvailabilityTime;
|
||||
|
||||
/// Formatted earliest time when check-out becomes available, or `null`.
|
||||
final String? checkOutAvailabilityTime;
|
||||
|
||||
/// Resolves the [CheckInInteraction] for the current mode.
|
||||
///
|
||||
/// Falls back to [SwipeCheckInInteraction] if the mode is unrecognized.
|
||||
@@ -86,31 +100,30 @@ class ClockInActionSection extends StatelessWidget {
|
||||
|
||||
/// Builds the action widget for an active (not completed) shift.
|
||||
Widget _buildActiveShiftAction(BuildContext context) {
|
||||
final String soonLabel = Translations.of(context).staff.clock_in.soon;
|
||||
|
||||
// Show geofence status and time-based availability banners when relevant.
|
||||
if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) {
|
||||
if (!isCheckedIn && !isCheckInAllowed) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const GeofenceStatusBanner(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
EarlyCheckInBanner(
|
||||
availabilityTime: _getAvailabilityTimeText(selectedShift!, context),
|
||||
availabilityTime: checkInAvailabilityTime ?? soonLabel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (isCheckedIn && !_isCheckOutAllowed(selectedShift!)) {
|
||||
if (isCheckedIn && !isCheckOutAllowed) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const GeofenceStatusBanner(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
EarlyCheckOutBanner(
|
||||
availabilityTime: _getCheckOutAvailabilityTimeText(
|
||||
selectedShift!,
|
||||
context,
|
||||
),
|
||||
availabilityTime: checkOutAvailabilityTime ?? soonLabel,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -164,64 +177,12 @@ class ClockInActionSection extends StatelessWidget {
|
||||
notes: geofenceState.overrideNotes,
|
||||
clockInGreetingTitle: geofenceI18n.clock_in_greeting_title,
|
||||
clockInGreetingBody: geofenceI18n.clock_in_greeting_body,
|
||||
leftGeofenceTitle: geofenceI18n.background_left_title,
|
||||
leftGeofenceBody: geofenceI18n.background_left_body,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the user is allowed to check in for the given [shift].
|
||||
///
|
||||
/// Delegates to [TimeWindowValidator]; returns `true` if the start time
|
||||
/// cannot be parsed (don't block the user).
|
||||
bool _isCheckInAllowed(Shift shift) {
|
||||
final DateTime? shiftStart = DateTime.tryParse(shift.startTime);
|
||||
if (shiftStart == null) return true;
|
||||
|
||||
final ClockInValidationContext validationContext = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: shiftStart,
|
||||
);
|
||||
return const TimeWindowValidator().validate(validationContext).isValid;
|
||||
}
|
||||
|
||||
/// Whether the user is allowed to check out for the given [shift].
|
||||
///
|
||||
/// Delegates to [TimeWindowValidator]; returns `true` if the end time
|
||||
/// cannot be parsed (don't block the user).
|
||||
bool _isCheckOutAllowed(Shift shift) {
|
||||
final DateTime? shiftEnd = DateTime.tryParse(shift.endTime);
|
||||
if (shiftEnd == null) return true;
|
||||
|
||||
final ClockInValidationContext validationContext = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
return const TimeWindowValidator().validate(validationContext).isValid;
|
||||
}
|
||||
|
||||
/// Returns the formatted earliest check-out time for the given [shift].
|
||||
///
|
||||
/// Falls back to the localized "soon" label when the end time cannot
|
||||
/// be parsed.
|
||||
String _getCheckOutAvailabilityTimeText(Shift shift, BuildContext context) {
|
||||
final DateTime? shiftEnd = DateTime.tryParse(shift.endTime.trim());
|
||||
if (shiftEnd != null) {
|
||||
return TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
return Translations.of(context).staff.clock_in.soon;
|
||||
}
|
||||
|
||||
/// Returns the formatted earliest check-in time for the given [shift].
|
||||
///
|
||||
/// Falls back to the localized "soon" label when the start time cannot
|
||||
/// be parsed.
|
||||
String _getAvailabilityTimeText(Shift shift, BuildContext context) {
|
||||
final DateTime? shiftStart = DateTime.tryParse(shift.startTime.trim());
|
||||
if (shiftStart != null) {
|
||||
return TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||
}
|
||||
return Translations.of(context).staff.clock_in.soon;
|
||||
}
|
||||
|
||||
/// Triggers the check-out flow via the lunch-break confirmation dialog.
|
||||
void _handleCheckOut(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of(
|
||||
|
||||
@@ -112,6 +112,10 @@ class _ClockInBodyState extends State<ClockInBody> {
|
||||
isActionInProgress:
|
||||
state.status == ClockInStatus.actionInProgress,
|
||||
hasClockinError: state.status == ClockInStatus.failure,
|
||||
isCheckInAllowed: state.isCheckInAllowed,
|
||||
isCheckOutAllowed: state.isCheckOutAllowed,
|
||||
checkInAvailabilityTime: state.checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: state.checkOutAvailabilityTime,
|
||||
),
|
||||
|
||||
// checked-in banner (only when checked in to the selected shift)
|
||||
|
||||
@@ -106,6 +106,10 @@ class _ShiftTimeAndRate extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
@@ -114,7 +118,7 @@ class _ShiftTimeAndRate extends StatelessWidget {
|
||||
style: UiTypography.body3m.textSecondary,
|
||||
),
|
||||
Text(
|
||||
'\$${shift.hourlyRate}/hr',
|
||||
i18n.per_hr(amount: shift.hourlyRate),
|
||||
style: UiTypography.body3m.copyWith(color: UiColors.primary),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user