feat(clock-in): add adaptive launcher icons and implement clock-in features

- Added adaptive launcher icons for both dev and stage environments in mobile apps.
- Introduced CheckInModeTab widget for selecting check-in methods.
- Created CheckedInBanner to display current check-in status with time.
- Implemented ClockInActionSection to manage check-in/out actions based on shift status.
- Developed ClockInBody to compose the main content of the clock-in page.
- Added utility functions in ClockInHelpers for time formatting and check-in availability.
- Created EarlyCheckInBanner to notify users arriving too early to check in.
- Implemented NFC scan dialog for NFC-based check-ins.
- Added NoShiftsBanner to inform users when no shifts are scheduled.
- Developed ShiftCard and ShiftCardList for displaying shifts in a selectable format.
- Created ShiftCompletedBanner to show success message after completing a shift.
This commit is contained in:
Achintha Isuru
2026-03-13 11:55:59 -04:00
parent 13bcfc9d40
commit ec880007d0
16 changed files with 899 additions and 717 deletions

View File

@@ -1,745 +1,60 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../bloc/clock_in_bloc.dart';
import '../bloc/clock_in_event.dart';
import '../bloc/clock_in_state.dart';
import '../widgets/clock_in_body.dart';
import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart';
import '../widgets/commute_tracker.dart';
import '../widgets/date_selector.dart';
import '../widgets/lunch_break_modal.dart';
import '../widgets/swipe_to_check_in.dart';
class ClockInPage extends StatefulWidget {
/// Top-level page for the staff clock-in feature.
///
/// Acts as a thin shell that provides the [ClockInBloc] and delegates
/// rendering to [ClockInBody] (loaded state) or [ClockInPageSkeleton]
/// (loading state). Error snackbars are handled via [BlocListener].
class ClockInPage extends StatelessWidget {
/// Creates the clock-in page.
const ClockInPage({super.key});
@override
State<ClockInPage> createState() => _ClockInPageState();
}
class _ClockInPageState extends State<ClockInPage> {
late final ClockInBloc _bloc;
@override
void initState() {
super.initState();
_bloc = Modular.get<ClockInBloc>();
}
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return BlocProvider<ClockInBloc>.value(
value: _bloc,
child: BlocConsumer<ClockInBloc, ClockInState>(
value: Modular.get<ClockInBloc>(),
child: BlocListener<ClockInBloc, ClockInState>(
listenWhen: (ClockInState previous, ClockInState current) =>
current.status == ClockInStatus.failure &&
current.errorMessage != null,
listener: (BuildContext context, ClockInState state) {
if (state.status == ClockInStatus.failure &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
child: Scaffold(
appBar: UiAppBar(title: i18n.title, showBackButton: false),
body: BlocBuilder<ClockInBloc, ClockInState>(
buildWhen: (ClockInState previous, ClockInState current) =>
previous.status != current.status ||
previous.todayShifts != current.todayShifts,
builder: (BuildContext context, ClockInState state) {
if (state.status == ClockInStatus.loading &&
state.todayShifts.isEmpty) {
return Scaffold(
appBar: UiAppBar(title: i18n.title, showBackButton: false),
body: const SafeArea(child: ClockInPageSkeleton()),
);
}
final bool isInitialLoading =
state.status == ClockInStatus.loading &&
state.todayShifts.isEmpty;
final List<Shift> todayShifts = state.todayShifts;
final Shift? selectedShift = state.selectedShift;
final String? activeShiftId = state.attendance.activeShiftId;
final bool isActiveSelected =
selectedShift != null && selectedShift.id == activeShiftId;
final DateTime? checkInTime = isActiveSelected
? state.attendance.checkInTime
: null;
final DateTime? checkOutTime = isActiveSelected
? state.attendance.checkOutTime
: null;
final bool isCheckedIn =
state.attendance.isCheckedIn && isActiveSelected;
return Scaffold(
appBar: UiAppBar(title: i18n.title, showBackButton: false),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.only(
bottom: UiConstants.space24,
top: UiConstants.space6,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// // Commute Tracker (shows before date selector when applicable)
// if (selectedShift != null)
// CommuteTracker(
// shift: selectedShift,
// hasLocationConsent: state.hasLocationConsent,
// isCommuteModeOn: state.isCommuteModeOn,
// distanceMeters: state.distanceFromVenue,
// etaMinutes: state.etaMinutes,
// onCommuteToggled: (bool value) {
// _bloc.add(CommuteModeToggled(value));
// },
// ),
// Date Selector
DateSelector(
selectedDate: state.selectedDate,
onSelect: (DateTime date) =>
_bloc.add(DateSelected(date)),
shiftDates: <String>[
DateFormat('yyyy-MM-dd').format(DateTime.now()),
],
),
const SizedBox(height: UiConstants.space5),
// Your Activity Header
Text(
i18n.your_activity,
textAlign: TextAlign.start,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space4),
// Selected Shift Info Card
if (todayShifts.isNotEmpty)
Column(
children: todayShifts
.map(
(Shift shift) => GestureDetector(
onTap: () =>
_bloc.add(ShiftSelected(shift)),
child: Container(
padding: const EdgeInsets.all(
UiConstants.space3,
),
margin: const EdgeInsets.only(
bottom: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: shift.id == selectedShift?.id
? UiColors.primary
: UiColors.border,
width: shift.id == selectedShift?.id
? 2
: 1,
),
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
shift.id ==
selectedShift?.id
? i18n.selected_shift_badge
: i18n.today_shift_badge,
style: UiTypography
.titleUppercase4b
.copyWith(
color:
shift.id ==
selectedShift
?.id
? UiColors.primary
: UiColors
.textSecondary,
),
),
const SizedBox(height: 2),
Text(
shift.title,
style: UiTypography.body2b,
),
Text(
"${shift.clientName} ${shift.location}",
style: UiTypography
.body3r
.textSecondary,
),
],
),
),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: <Widget>[
Text(
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
style: UiTypography
.body3m
.textSecondary,
),
Text(
"\$${shift.hourlyRate}/hr",
style: UiTypography.body3m
.copyWith(
color: UiColors.primary,
),
),
],
),
],
),
),
),
)
.toList(),
),
// Swipe To Check In / Checked Out State / No Shift State
if (selectedShift != null &&
checkOutTime == null) ...<Widget>[
if (!isCheckedIn &&
!_isCheckInAllowed(selectedShift))
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_title,
style: UiTypography.body1m.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.check_in_at(
time: _getCheckInAvailabilityTime(
selectedShift,
),
),
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
)
else ...<Widget>[
// Attire Photo Section
// if (!isCheckedIn) ...<Widget>[
// Container(
// padding: const EdgeInsets.all(
// UiConstants.space4,
// ),
// margin: const EdgeInsets.only(
// bottom: UiConstants.space4,
// ),
// decoration: BoxDecoration(
// color: UiColors.white,
// borderRadius: UiConstants.radiusLg,
// border: Border.all(color: UiColors.border),
// ),
// child: Row(
// children: <Widget>[
// Container(
// width: 48,
// height: 48,
// decoration: BoxDecoration(
// color: UiColors.bgSecondary,
// borderRadius: UiConstants.radiusMd,
// ),
// child: const Icon(
// UiIcons.camera,
// color: UiColors.primary,
// ),
// ),
// const SizedBox(width: UiConstants.space3),
// Expanded(
// child: Column(
// crossAxisAlignment:
// CrossAxisAlignment.start,
// children: <Widget>[
// Text(
// i18n.attire_photo_label,
// style: UiTypography.body2b,
// ),
// Text(
// i18n.attire_photo_desc,
// style: UiTypography
// .body3r
// .textSecondary,
// ),
// ],
// ),
// ),
// UiButton.secondary(
// text: i18n.take_attire_photo,
// onPressed: () {
// UiSnackbar.show(
// context,
// message: i18n.attire_captured,
// type: UiSnackbarType.success,
// );
// },
// ),
// ],
// ),
// ),
// ],
// if (!isCheckedIn &&
// (!state.isLocationVerified ||
// state.currentLocation ==
// null)) ...<Widget>[
// Container(
// width: double.infinity,
// padding: const EdgeInsets.all(
// UiConstants.space4,
// ),
// margin: const EdgeInsets.only(
// bottom: UiConstants.space4,
// ),
// decoration: BoxDecoration(
// color: UiColors.tagError,
// borderRadius: UiConstants.radiusLg,
// ),
// child: Row(
// children: [
// const Icon(
// UiIcons.error,
// color: UiColors.textError,
// size: 20,
// ),
// const SizedBox(width: UiConstants.space3),
// Expanded(
// child: Text(
// state.currentLocation == null
// ? i18n.location_verifying
// : i18n.not_in_range(
// distance: '500',
// ),
// style: UiTypography.body3m.textError,
// ),
// ),
// ],
// ),
// ),
// ],
SwipeToCheckIn(
isCheckedIn: isCheckedIn,
mode: state.checkInMode,
isDisabled: isCheckedIn,
isLoading:
state.status ==
ClockInStatus.actionInProgress,
onCheckIn: () async {
// Show NFC dialog if mode is 'nfc'
if (state.checkInMode == 'nfc') {
await _showNFCDialog(context);
} else {
_bloc.add(
CheckInRequested(
shiftId: selectedShift.id,
),
);
}
},
onCheckOut: () {
showDialog(
context: context,
builder: (BuildContext context) =>
LunchBreakDialog(
onComplete: () {
Navigator.of(
context,
).pop(); // Close dialog first
_bloc.add(
const CheckOutRequested(),
);
return isInitialLoading
? const ClockInPageSkeleton()
: const ClockInBody();
},
),
);
},
),
],
] else if (selectedShift != null &&
checkOutTime != null) ...<Widget>[
// Shift Completed State
Container(
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.success.withValues(
alpha: 0.3,
),
),
),
child: Column(
children: <Widget>[
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: UiColors.tagActive,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.check,
color: UiColors.textSuccess,
size: 24,
),
),
const SizedBox(height: UiConstants.space3),
Text(
i18n.shift_completed,
style: UiTypography.body1b.textSuccess,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.great_work,
style: UiTypography.body2r.textSuccess,
),
],
),
),
] else ...<Widget>[
// No Shift State
Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusLg,
),
child: Column(
children: <Widget>[
Text(
i18n.no_shifts_today,
style: UiTypography.body1m.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.accept_shift_cta,
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
),
],
// Checked In Banner
if (isCheckedIn && checkInTime != null) ...<Widget>[
const SizedBox(height: UiConstants.space3),
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.success.withValues(
alpha: 0.3,
),
),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
i18n.checked_in_at_label,
style: UiTypography.body3m.textSuccess,
),
Text(
DateFormat(
'h:mm a',
).format(checkInTime),
style: UiTypography.body1b.textSuccess,
),
],
),
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: UiColors.tagActive,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.check,
color: UiColors.textSuccess,
),
),
],
),
),
],
const SizedBox(height: 16),
// Recent Activity List (Temporarily removed)
const SizedBox(height: 16),
],
),
),
],
),
),
),
);
},
),
);
}
Widget _buildModeTab(
String label,
IconData icon,
String value,
String currentMode,
) {
final bool isSelected = currentMode == value;
return Expanded(
child: GestureDetector(
onTap: () => _bloc.add(CheckInModeChanged(value)),
child: Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
decoration: BoxDecoration(
color: isSelected ? UiColors.white : UiColors.transparent,
borderRadius: UiConstants.radiusMd,
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: <BoxShadow>[],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
icon,
size: 16,
color: isSelected ? UiColors.foreground : UiColors.iconThird,
),
const SizedBox(width: 6),
Text(
label,
style: UiTypography.body2m.copyWith(
color: isSelected
? UiColors.foreground
: UiColors.textSecondary,
),
),
],
),
),
),
);
}
Future<void> _showNFCDialog(BuildContext context) async {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
bool scanned = false;
// Using a local navigator context since we are in a dialog
await showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (BuildContext context, setState) {
return AlertDialog(
title: Text(
scanned
? i18n.nfc_dialog.scanned_title
: i18n.nfc_dialog.scan_title,
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: scanned
? UiColors.tagSuccess
: UiColors.tagInProgress,
shape: BoxShape.circle,
),
child: Icon(
scanned ? UiIcons.check : UiIcons.nfc,
size: 48,
color: scanned ? UiColors.textSuccess : UiColors.primary,
),
),
const SizedBox(height: UiConstants.space6),
Text(
scanned
? i18n.nfc_dialog.processing
: i18n.nfc_dialog.ready_to_scan,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space2),
Text(
scanned
? i18n.nfc_dialog.please_wait
: i18n.nfc_dialog.scan_instruction,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
if (!scanned) ...<Widget>[
const SizedBox(height: UiConstants.space6),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () async {
setState(() {
scanned = true;
});
// Simulate NFC scan delay
await Future.delayed(
const Duration(milliseconds: 1000),
);
if (!context.mounted) return;
Navigator.of(dialogContext).pop();
// Trigger BLoC event
// Need to access the bloc from the outer context or via passed reference
// Since _bloc is a field of the page state, we can use it if we are inside the page class
// But this dialog is just a function call.
// It's safer to just return a result
},
icon: const Icon(UiIcons.nfc, size: 24),
label: Text(
i18n.nfc_dialog.tap_to_scan,
style: UiTypography.headline4m.white,
),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
),
],
],
),
);
},
);
},
);
// After dialog closes, trigger the event if scan was successful (simulated)
// In real app, we would check the dialog result
if (scanned && _bloc.state.selectedShift != null) {
_bloc.add(CheckInRequested(shiftId: _bloc.state.selectedShift!.id));
}
}
// --- Helper Methods ---
String _formatTime(String timeStr) {
if (timeStr.isEmpty) return '';
try {
// Try parsing as ISO string first (which contains date)
final DateTime dt = DateTime.parse(timeStr);
return DateFormat('h:mm a').format(dt);
} catch (_) {
// Fallback for strict "HH:mm" or "HH:mm:ss" strings
try {
final List<String> parts = timeStr.split(':');
if (parts.length >= 2) {
final DateTime dt = DateTime(
2022,
1,
1,
int.parse(parts[0]),
int.parse(parts[1]),
);
return DateFormat('h:mm a').format(dt);
}
return timeStr;
} catch (e) {
return timeStr;
}
}
}
bool _isCheckInAllowed(Shift shift) {
try {
// Parse shift date (e.g. 2024-01-31T09:00:00)
// The Shift entity has 'date' which is the start DateTime string
final DateTime shiftStart = DateTime.parse(shift.startTime);
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateTime.now().isAfter(windowStart);
} catch (e) {
// Fallback: If parsing fails, allow check in to avoid blocking.
return true;
}
}
String _getCheckInAvailabilityTime(Shift shift) {
try {
final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateFormat('h:mm a').format(windowStart);
} catch (e) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return i18n.soon;
}
}
}

View File

@@ -0,0 +1,79 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/clock_in_bloc.dart';
import '../bloc/clock_in_event.dart';
/// A single selectable tab within a check-in mode toggle strip.
///
/// Used to switch between different check-in methods (e.g. swipe, NFC).
class CheckInModeTab extends StatelessWidget {
/// Creates a mode tab.
const CheckInModeTab({
required this.label,
required this.icon,
required this.value,
required this.currentMode,
super.key,
});
/// The display label for this mode.
final String label;
/// The icon shown next to the label.
final IconData icon;
/// The mode value this tab represents.
final String value;
/// The currently active mode, used to determine selection state.
final String currentMode;
@override
Widget build(BuildContext context) {
final bool isSelected = currentMode == value;
return Expanded(
child: GestureDetector(
onTap: () =>
context.read<ClockInBloc>().add(CheckInModeChanged(value)),
child: Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
decoration: BoxDecoration(
color: isSelected ? UiColors.white : UiColors.transparent,
borderRadius: UiConstants.radiusMd,
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: <BoxShadow>[],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
icon,
size: 16,
color: isSelected ? UiColors.foreground : UiColors.iconThird,
),
const SizedBox(width: UiConstants.space1),
Text(
label,
style: UiTypography.body2m.copyWith(
color: isSelected
? UiColors.foreground
: UiColors.textSecondary,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// A green-tinted banner confirming that the user is currently checked in.
///
/// Displays the exact check-in time alongside a check icon.
class CheckedInBanner extends StatelessWidget {
/// Creates a checked-in banner for the given [checkInTime].
const CheckedInBanner({required this.checkInTime, super.key});
/// The time the user checked in.
final DateTime checkInTime;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.success.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
i18n.checked_in_at_label,
style: UiTypography.body3m.textSuccess,
),
Text(
DateFormat('h:mm a').format(checkInTime),
style: UiTypography.body1b.textSuccess,
),
],
),
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: UiColors.tagActive,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.check,
color: UiColors.textSuccess,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../bloc/clock_in_bloc.dart';
import '../bloc/clock_in_event.dart';
import '../bloc/clock_in_state.dart';
import 'clock_in_helpers.dart';
import 'early_check_in_banner.dart';
import 'lunch_break_modal.dart';
import 'nfc_scan_dialog.dart';
import 'no_shifts_banner.dart';
import 'shift_completed_banner.dart';
import 'swipe_to_check_in.dart';
/// Orchestrates which action widget is displayed based on the current state.
///
/// Decides between the swipe-to-check-in slider, the early-arrival banner,
/// the shift-completed banner, or the no-shifts placeholder.
class ClockInActionSection extends StatelessWidget {
/// Creates the action section.
const ClockInActionSection({
required this.selectedShift,
required this.isCheckedIn,
required this.checkOutTime,
required this.checkInMode,
required this.isActionInProgress,
super.key,
});
/// The currently selected shift, or null if none is selected.
final Shift? selectedShift;
/// Whether the user is currently checked in for the active shift.
final bool isCheckedIn;
/// The check-out time, or null if the user has not checked out.
final DateTime? checkOutTime;
/// The current check-in mode (e.g. "swipe" or "nfc").
final String checkInMode;
/// Whether a check-in or check-out action is currently in progress.
final bool isActionInProgress;
@override
Widget build(BuildContext context) {
if (selectedShift != null && checkOutTime == null) {
return _buildActiveShiftAction(context);
}
if (selectedShift != null && checkOutTime != null) {
return const ShiftCompletedBanner();
}
return const NoShiftsBanner();
}
/// Builds the action widget for an active (not completed) shift.
Widget _buildActiveShiftAction(BuildContext context) {
if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) {
return EarlyCheckInBanner(
availabilityTime: ClockInHelpers.getCheckInAvailabilityTime(
selectedShift!,
context,
),
);
}
return SwipeToCheckIn(
isCheckedIn: isCheckedIn,
mode: checkInMode,
isDisabled: isCheckedIn,
isLoading: isActionInProgress,
onCheckIn: () => _handleCheckIn(context),
onCheckOut: () => _handleCheckOut(context),
);
}
/// Triggers the check-in flow, showing an NFC dialog when needed.
Future<void> _handleCheckIn(BuildContext context) async {
if (checkInMode == 'nfc') {
final bool scanned = await showNfcScanDialog(context);
if (scanned && context.mounted) {
context.read<ClockInBloc>().add(
CheckInRequested(shiftId: selectedShift!.id),
);
}
} else {
context.read<ClockInBloc>().add(
CheckInRequested(shiftId: selectedShift!.id),
);
}
}
/// Triggers the check-out flow via the lunch-break confirmation dialog.
void _handleCheckOut(BuildContext context) {
showDialog<void>(
context: context,
builder: (BuildContext dialogContext) => LunchBreakDialog(
onComplete: () {
Navigator.of(dialogContext).pop();
context.read<ClockInBloc>().add(const CheckOutRequested());
},
),
);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../bloc/clock_in_bloc.dart';
import '../bloc/clock_in_event.dart';
import '../bloc/clock_in_state.dart';
import 'checked_in_banner.dart';
import 'clock_in_action_section.dart';
import 'date_selector.dart';
import 'shift_card_list.dart';
/// The scrollable main content of the clock-in page.
///
/// Composes the date selector, activity header, shift cards, action section,
/// and the checked-in status banner into a single scrollable column.
class ClockInBody extends StatelessWidget {
/// Creates the clock-in body.
const ClockInBody({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return SingleChildScrollView(
padding: const EdgeInsets.only(
bottom: UiConstants.space24,
top: UiConstants.space6,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: BlocBuilder<ClockInBloc, ClockInState>(
builder: (BuildContext context, ClockInState state) {
final List<Shift> todayShifts = state.todayShifts;
final Shift? selectedShift = state.selectedShift;
final String? activeShiftId = state.attendance.activeShiftId;
final bool isActiveSelected =
selectedShift != null && selectedShift.id == activeShiftId;
final DateTime? checkInTime = isActiveSelected
? state.attendance.checkInTime
: null;
final DateTime? checkOutTime = isActiveSelected
? state.attendance.checkOutTime
: null;
final bool isCheckedIn =
state.attendance.isCheckedIn && isActiveSelected;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// date selector
DateSelector(
selectedDate: state.selectedDate,
onSelect: (DateTime date) =>
context.read<ClockInBloc>().add(DateSelected(date)),
shiftDates: <String>[
DateFormat('yyyy-MM-dd').format(DateTime.now()),
],
),
const SizedBox(height: UiConstants.space5),
Text(
i18n.your_activity,
textAlign: TextAlign.start,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space4),
// today's shifts and actions
if (todayShifts.isNotEmpty)
ShiftCardList(
shifts: todayShifts,
selectedShiftId: selectedShift?.id,
onShiftSelected: (Shift shift) =>
context.read<ClockInBloc>().add(ShiftSelected(shift)),
),
// action section (check-in/out buttons)
ClockInActionSection(
selectedShift: selectedShift,
isCheckedIn: isCheckedIn,
checkOutTime: checkOutTime,
checkInMode: state.checkInMode,
isActionInProgress:
state.status == ClockInStatus.actionInProgress,
),
// checked-in banner (only if currently checked in to the selected shift)
if (isCheckedIn && checkInTime != null) ...<Widget>[
const SizedBox(height: UiConstants.space3),
CheckedInBanner(checkInTime: checkInTime),
],
const SizedBox(height: UiConstants.space4),
],
);
},
),
),
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// Pure utility functions shared across clock-in widgets.
///
/// These are stateless helpers that handle time formatting and
/// shift check-in availability calculations.
class ClockInHelpers {
const ClockInHelpers._();
/// Formats a time string (ISO 8601 or HH:mm) into a human-readable
/// 12-hour format (e.g. "9:00 AM").
static String formatTime(String timeStr) {
if (timeStr.isEmpty) return '';
try {
final DateTime dt = DateTime.parse(timeStr);
return DateFormat('h:mm a').format(dt);
} catch (_) {
try {
final List<String> parts = timeStr.split(':');
if (parts.length >= 2) {
final DateTime dt = DateTime(
2022,
1,
1,
int.parse(parts[0]),
int.parse(parts[1]),
);
return DateFormat('h:mm a').format(dt);
}
return timeStr;
} catch (e) {
return timeStr;
}
}
}
/// Whether the user is allowed to check in for the given [shift].
///
/// Check-in is permitted 15 minutes before the shift start time.
/// Falls back to `true` if the start time cannot be parsed.
static bool isCheckInAllowed(Shift shift) {
try {
final DateTime shiftStart = DateTime.parse(shift.startTime);
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateTime.now().isAfter(windowStart);
} catch (e) {
return true;
}
}
/// Returns the earliest time the user may check in for the given [shift],
/// formatted as a 12-hour string (e.g. "8:45 AM").
///
/// Falls back to the localized "soon" label when the start time cannot
/// be parsed.
static String getCheckInAvailabilityTime(
Shift shift,
BuildContext context,
) {
try {
final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateFormat('h:mm a').format(windowStart);
} catch (e) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return i18n.soon;
}
}
}

View File

@@ -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 arrives too early to check in.
///
/// Displays a clock icon and a message indicating when check-in
/// will become available.
class EarlyCheckInBanner extends StatelessWidget {
/// Creates an early check-in banner.
const EarlyCheckInBanner({
required this.availabilityTime,
super.key,
});
/// Formatted time string when check-in becomes available (e.g. "8:45 AM").
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_title,
style: UiTypography.body1m.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.check_in_at(time: availabilityTime),
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,127 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Shows the NFC scanning dialog and returns `true` when a scan completes.
///
/// The dialog is non-dismissible and simulates an NFC tap with a short delay.
/// Returns `false` if the dialog is closed without a successful scan.
Future<bool> showNfcScanDialog(BuildContext context) async {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
bool scanned = false;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return AlertDialog(
title: Text(
scanned
? i18n.nfc_dialog.scanned_title
: i18n.nfc_dialog.scan_title,
),
content: _NfcDialogContent(
scanned: scanned,
i18n: i18n,
onTapToScan: () async {
setState(() {
scanned = true;
});
await Future<void>.delayed(
const Duration(milliseconds: 1000),
);
if (!context.mounted) return;
Navigator.of(dialogContext).pop();
},
),
);
},
);
},
);
return scanned;
}
/// Internal content widget for the NFC scan dialog.
///
/// Displays the scan icon/status and a tap-to-scan button.
class _NfcDialogContent extends StatelessWidget {
const _NfcDialogContent({
required this.scanned,
required this.i18n,
required this.onTapToScan,
});
/// Whether an NFC tag has been scanned.
final bool scanned;
/// Localization accessor for clock-in strings.
final TranslationsStaffClockInEn i18n;
/// Called when the user taps the scan button.
final VoidCallback onTapToScan;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: scanned ? UiColors.tagSuccess : UiColors.tagInProgress,
shape: BoxShape.circle,
),
child: Icon(
scanned ? UiIcons.check : UiIcons.nfc,
size: 48,
color: scanned ? UiColors.textSuccess : UiColors.primary,
),
),
const SizedBox(height: UiConstants.space6),
Text(
scanned
? i18n.nfc_dialog.processing
: i18n.nfc_dialog.ready_to_scan,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space2),
Text(
scanned
? i18n.nfc_dialog.please_wait
: i18n.nfc_dialog.scan_instruction,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
if (!scanned) ...<Widget>[
const SizedBox(height: UiConstants.space6),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: onTapToScan,
icon: const Icon(UiIcons.nfc, size: 24),
label: Text(
i18n.nfc_dialog.tap_to_scan,
style: UiTypography.headline4m.white,
),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Placeholder banner shown when there are no shifts scheduled for today.
///
/// Encourages the user to browse available shifts.
class NoShiftsBanner extends StatelessWidget {
/// Creates a no-shifts banner.
const NoShiftsBanner({super.key});
@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>[
Text(
i18n.no_shifts_today,
style: UiTypography.body1m.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space1),
Text(
i18n.accept_shift_cta,
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,123 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'clock_in_helpers.dart';
/// A selectable card that displays a single shift's summary information.
///
/// Shows the shift title, client/location, time range, and hourly rate.
/// Highlights with a primary border when [isSelected] is true.
class ShiftCard extends StatelessWidget {
/// Creates a shift card for the given [shift].
const ShiftCard({
required this.shift,
required this.isSelected,
required this.onTap,
super.key,
});
/// The shift to display.
final Shift shift;
/// Whether this card is currently selected.
final bool isSelected;
/// Called when the user taps this card.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space3),
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: isSelected ? UiColors.primary : UiColors.border,
width: isSelected ? 2 : 1,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(child: _ShiftDetails(shift: shift, isSelected: isSelected, i18n: i18n)),
_ShiftTimeAndRate(shift: shift),
],
),
),
);
}
}
/// Displays the shift title, client name, and location on the left side.
class _ShiftDetails extends StatelessWidget {
const _ShiftDetails({
required this.shift,
required this.isSelected,
required this.i18n,
});
/// The shift whose details to display.
final Shift shift;
/// Whether the parent card is selected.
final bool isSelected;
/// Localization accessor for clock-in strings.
final TranslationsStaffClockInEn i18n;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
isSelected ? i18n.selected_shift_badge : i18n.today_shift_badge,
style: UiTypography.titleUppercase4b.copyWith(
color: isSelected ? UiColors.primary : UiColors.textSecondary,
),
),
const SizedBox(height: 2),
Text(shift.title, style: UiTypography.body2b),
Text(
'${shift.clientName} ${shift.location}',
style: UiTypography.body3r.textSecondary,
),
],
);
}
}
/// Displays the shift time range and hourly rate on the right side.
class _ShiftTimeAndRate extends StatelessWidget {
const _ShiftTimeAndRate({required this.shift});
/// The shift whose time and rate to display.
final Shift shift;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'${ClockInHelpers.formatTime(shift.startTime)} - ${ClockInHelpers.formatTime(shift.endTime)}',
style: UiTypography.body3m.textSecondary,
),
Text(
'\$${shift.hourlyRate}/hr',
style: UiTypography.body3m.copyWith(color: UiColors.primary),
),
],
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'shift_card.dart';
/// Renders a vertical list of [ShiftCard] widgets for today's shifts.
///
/// Highlights the currently selected shift and notifies the parent
/// when a different shift is tapped.
class ShiftCardList extends StatelessWidget {
/// Creates a shift card list from [shifts].
const ShiftCardList({
required this.shifts,
required this.selectedShiftId,
required this.onShiftSelected,
super.key,
});
/// All shifts to display.
final List<Shift> shifts;
/// The ID of the currently selected shift, if any.
final String? selectedShiftId;
/// Called when the user taps a shift card.
final ValueChanged<Shift> onShiftSelected;
@override
Widget build(BuildContext context) {
return Column(
children: shifts
.map(
(Shift shift) => ShiftCard(
shift: shift,
isSelected: shift.id == selectedShiftId,
onTap: () => onShiftSelected(shift),
),
)
.toList(),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Success banner displayed after a shift has been completed.
///
/// Shows a check icon with congratulatory text in a green-tinted container.
class ShiftCompletedBanner extends StatelessWidget {
/// Creates a shift completed banner.
const ShiftCompletedBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return Container(
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.tagSuccess,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.success.withValues(alpha: 0.3),
),
),
child: Column(
children: <Widget>[
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: UiColors.tagActive,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.check,
color: UiColors.textSuccess,
size: 24,
),
),
const SizedBox(height: UiConstants.space3),
Text(i18n.shift_completed, style: UiTypography.body1b.textSuccess),
const SizedBox(height: UiConstants.space1),
Text(i18n.great_work, style: UiTypography.body2r.textSuccess),
],
),
);
}
}