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:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.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_bloc.dart';
|
||||||
import '../bloc/clock_in_event.dart';
|
|
||||||
import '../bloc/clock_in_state.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/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});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||||
context,
|
context,
|
||||||
).staff.clock_in;
|
).staff.clock_in;
|
||||||
|
|
||||||
return BlocProvider<ClockInBloc>.value(
|
return BlocProvider<ClockInBloc>.value(
|
||||||
value: _bloc,
|
value: Modular.get<ClockInBloc>(),
|
||||||
child: BlocConsumer<ClockInBloc, ClockInState>(
|
child: BlocListener<ClockInBloc, ClockInState>(
|
||||||
|
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||||
|
current.status == ClockInStatus.failure &&
|
||||||
|
current.errorMessage != null,
|
||||||
listener: (BuildContext context, ClockInState state) {
|
listener: (BuildContext context, ClockInState state) {
|
||||||
if (state.status == ClockInStatus.failure &&
|
UiSnackbar.show(
|
||||||
state.errorMessage != null) {
|
context,
|
||||||
UiSnackbar.show(
|
message: translateErrorKey(state.errorMessage!),
|
||||||
context,
|
type: UiSnackbarType.error,
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
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(
|
return isInitialLoading
|
||||||
String label,
|
? const ClockInPageSkeleton()
|
||||||
IconData icon,
|
: const ClockInBody();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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