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:
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
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 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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
] 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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) {
|
||||
final bool isInitialLoading =
|
||||
state.status == ClockInStatus.loading &&
|
||||
state.todayShifts.isEmpty;
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
return isInitialLoading
|
||||
? const ClockInPageSkeleton()
|
||||
: const ClockInBody();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user