feat(clock_in): add early check-out banner and localization support
This commit is contained in:
@@ -856,6 +856,8 @@
|
|||||||
"today_shift_badge": "TODAY'S SHIFT",
|
"today_shift_badge": "TODAY'S SHIFT",
|
||||||
"early_title": "You're early!",
|
"early_title": "You're early!",
|
||||||
"check_in_at": "Check-in available at $time",
|
"check_in_at": "Check-in available at $time",
|
||||||
|
"early_checkout_title": "Too early to check out",
|
||||||
|
"check_out_at": "Check-out available at $time",
|
||||||
"shift_completed": "Shift Completed!",
|
"shift_completed": "Shift Completed!",
|
||||||
"great_work": "Great work today",
|
"great_work": "Great work today",
|
||||||
"no_shifts_today": "No confirmed shifts for today",
|
"no_shifts_today": "No confirmed shifts for today",
|
||||||
|
|||||||
@@ -851,6 +851,8 @@
|
|||||||
"today_shift_badge": "TURNO DE HOY",
|
"today_shift_badge": "TURNO DE HOY",
|
||||||
"early_title": "\u00a1Ha llegado temprano!",
|
"early_title": "\u00a1Ha llegado temprano!",
|
||||||
"check_in_at": "Entrada disponible a las $time",
|
"check_in_at": "Entrada disponible a las $time",
|
||||||
|
"early_checkout_title": "Muy temprano para salir",
|
||||||
|
"check_out_at": "Salida disponible a las $time",
|
||||||
"shift_completed": "\u00a1Turno completado!",
|
"shift_completed": "\u00a1Turno completado!",
|
||||||
"great_work": "Buen trabajo hoy",
|
"great_work": "Buen trabajo hoy",
|
||||||
"no_shifts_today": "No hay turnos confirmados para hoy",
|
"no_shifts_today": "No hay turnos confirmados para hoy",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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_clock_in/src/presentation/widgets/early_check_in_banner.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/clock_in_validation_context.dart';
|
||||||
import '../../domain/validators/validators/time_window_validator.dart';
|
import '../../domain/validators/validators/time_window_validator.dart';
|
||||||
@@ -42,9 +43,9 @@ class ClockInActionSection extends StatelessWidget {
|
|||||||
/// Available check-in interaction strategies keyed by mode identifier.
|
/// Available check-in interaction strategies keyed by mode identifier.
|
||||||
static const Map<String, CheckInInteraction> _interactions =
|
static const Map<String, CheckInInteraction> _interactions =
|
||||||
<String, CheckInInteraction>{
|
<String, CheckInInteraction>{
|
||||||
'swipe': SwipeCheckInInteraction(),
|
'swipe': SwipeCheckInInteraction(),
|
||||||
'nfc': NfcCheckInInteraction(),
|
'nfc': NfcCheckInInteraction(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The currently selected shift, or null if none is selected.
|
/// The currently selected shift, or null if none is selected.
|
||||||
final Shift? selectedShift;
|
final Shift? selectedShift;
|
||||||
@@ -85,6 +86,7 @@ class ClockInActionSection extends StatelessWidget {
|
|||||||
|
|
||||||
/// Builds the action widget for an active (not completed) shift.
|
/// Builds the action widget for an active (not completed) shift.
|
||||||
Widget _buildActiveShiftAction(BuildContext context) {
|
Widget _buildActiveShiftAction(BuildContext context) {
|
||||||
|
// Show geofence status and time-based availability banners when relevant.
|
||||||
if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) {
|
if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) {
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -92,7 +94,20 @@ class ClockInActionSection extends StatelessWidget {
|
|||||||
const GeofenceStatusBanner(),
|
const GeofenceStatusBanner(),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
EarlyCheckInBanner(
|
EarlyCheckInBanner(
|
||||||
availabilityTime: _getAvailabilityTimeText(
|
availabilityTime: _getAvailabilityTimeText(selectedShift!, context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCheckedIn && !_isCheckOutAllowed(selectedShift!)) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
const GeofenceStatusBanner(),
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
EarlyCheckOutBanner(
|
||||||
|
availabilityTime: _getCheckOutAvailabilityTimeText(
|
||||||
selectedShift!,
|
selectedShift!,
|
||||||
context,
|
context,
|
||||||
),
|
),
|
||||||
@@ -139,8 +154,9 @@ class ClockInActionSection extends StatelessWidget {
|
|||||||
final GeofenceState geofenceState = ReadContext(
|
final GeofenceState geofenceState = ReadContext(
|
||||||
context,
|
context,
|
||||||
).read<GeofenceBloc>().state;
|
).read<GeofenceBloc>().state;
|
||||||
final TranslationsStaffClockInGeofenceEn geofenceI18n =
|
final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of(
|
||||||
Translations.of(context).staff.clock_in.geofence;
|
context,
|
||||||
|
).staff.clock_in.geofence;
|
||||||
|
|
||||||
ReadContext(context).read<ClockInBloc>().add(
|
ReadContext(context).read<ClockInBloc>().add(
|
||||||
CheckInRequested(
|
CheckInRequested(
|
||||||
@@ -167,6 +183,33 @@ class ClockInActionSection extends StatelessWidget {
|
|||||||
return const TimeWindowValidator().validate(validationContext).isValid;
|
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].
|
/// Returns the formatted earliest check-in time for the given [shift].
|
||||||
///
|
///
|
||||||
/// Falls back to the localized "soon" label when the start time cannot
|
/// Falls back to the localized "soon" label when the start time cannot
|
||||||
@@ -181,8 +224,9 @@ class ClockInActionSection extends StatelessWidget {
|
|||||||
|
|
||||||
/// Triggers the check-out flow via the lunch-break confirmation dialog.
|
/// Triggers the check-out flow via the lunch-break confirmation dialog.
|
||||||
void _handleCheckOut(BuildContext context) {
|
void _handleCheckOut(BuildContext context) {
|
||||||
final TranslationsStaffClockInGeofenceEn geofenceI18n =
|
final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of(
|
||||||
Translations.of(context).staff.clock_in.geofence;
|
context,
|
||||||
|
).staff.clock_in.geofence;
|
||||||
|
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Banner shown when the user tries to check out too early.
|
||||||
|
///
|
||||||
|
/// Displays a clock icon and a message indicating when check-out
|
||||||
|
/// will become available.
|
||||||
|
class EarlyCheckOutBanner extends StatelessWidget {
|
||||||
|
/// Creates an early check-out banner.
|
||||||
|
const EarlyCheckOutBanner({
|
||||||
|
required this.availabilityTime,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Formatted time string when check-out becomes available (e.g. "4:45 PM").
|
||||||
|
final String availabilityTime;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff.clock_in;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgSecondary,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(UiIcons.clock, size: 48, color: UiColors.iconThird),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Text(
|
||||||
|
i18n.early_checkout_title,
|
||||||
|
style: UiTypography.body1m.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
i18n.check_out_at(time: availabilityTime),
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user