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:
Achintha Isuru
2026-03-16 01:27:15 -04:00
parent 86335dd177
commit 5fd2a44a8b
12 changed files with 203 additions and 73 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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