diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart b/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart index aa09b232..1bc75bd0 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart +++ b/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart @@ -6,7 +6,7 @@ /// Locales: 2 /// Strings: 1026 (513 per locale) /// -/// Built on 2026-01-26 at 03:26 UTC +/// Built on 2026-01-26 at 05:52 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories/clock_in_repository_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories/clock_in_repository_impl.dart new file mode 100644 index 00000000..ef8e4211 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories/clock_in_repository_impl.dart @@ -0,0 +1,115 @@ +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:intl/intl.dart'; +import '../../domain/repositories/clock_in_repository_interface.dart'; + +/// Implementation of [ClockInRepositoryInterface]. +/// +/// Delegates shift data retrieval to [ShiftsRepositoryMock] and manages purely +/// local state for attendance (check-in/out) for the prototype phase. +class ClockInRepositoryImpl implements ClockInRepositoryInterface { + final ShiftsRepositoryMock _shiftsMock; + + // Local state for the session (mocking backend state) + bool _isCheckedIn = false; + DateTime? _checkInTime; + DateTime? _checkOutTime; + String? _activeShiftId; + + ClockInRepositoryImpl({ShiftsRepositoryMock? shiftsMock}) + : _shiftsMock = shiftsMock ?? ShiftsRepositoryMock(); + + @override + Future getTodaysShift() async { + final shifts = await _shiftsMock.getMyShifts(); + + if (shifts.isEmpty) return null; + + final now = DateTime.now(); + final todayStr = DateFormat('yyyy-MM-dd').format(now); + + // Find a shift effectively for today, or mock one + try { + return shifts.firstWhere((s) => s.date == todayStr); + } catch (_) { + final original = shifts.first; + // Mock "today's" shift based on the first available shift + return Shift( + id: original.id, + title: original.title, + clientName: original.clientName, + logoUrl: original.logoUrl, + hourlyRate: original.hourlyRate, + location: original.location, + locationAddress: original.locationAddress, + date: todayStr, + startTime: original.startTime, // Use original times or calculate + endTime: original.endTime, + createdDate: original.createdDate, + status: 'assigned', + latitude: original.latitude, + longitude: original.longitude, + description: original.description, + managers: original.managers, + ); + } + } + + @override + Future> getAttendanceStatus() async { + await Future.delayed(const Duration(milliseconds: 300)); + return _getCurrentStatusMap(); + } + + @override + Future> clockIn({required String shiftId, String? notes}) async { + await Future.delayed(const Duration(seconds: 1)); // Simulate network + + _isCheckedIn = true; + _checkInTime = DateTime.now(); + _activeShiftId = shiftId; + _checkOutTime = null; // Reset for new check-in? Or keep for history? + // Simple mock logic: reset check-out on new check-in. + + return _getCurrentStatusMap(); + } + + @override + Future> clockOut({String? notes, int? breakTimeMinutes}) async { + await Future.delayed(const Duration(seconds: 1)); // Simulate network + + _isCheckedIn = false; + _checkOutTime = DateTime.now(); + + return _getCurrentStatusMap(); + } + + @override + Future>> getActivityLog() async { + await Future.delayed(const Duration(milliseconds: 500)); + // Mock data + return [ + { + 'date': DateTime.now().subtract(const Duration(days: 1)), + 'start': '09:00 AM', + 'end': '05:00 PM', + 'hours': '8h', + }, + { + 'date': DateTime.now().subtract(const Duration(days: 2)), + 'start': '09:00 AM', + 'end': '05:00 PM', + 'hours': '8h', + }, + ]; + } + + Map _getCurrentStatusMap() { + return { + 'isCheckedIn': _isCheckedIn, + 'checkInTime': _checkInTime, + 'checkOutTime': _checkOutTime, + 'activeShiftId': _activeShiftId, + }; + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart new file mode 100644 index 00000000..bf7cb9d2 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart @@ -0,0 +1,19 @@ +import 'package:krow_core/core.dart'; + +/// Represents the arguments required for the [ClockInUseCase]. +class ClockInArguments extends UseCaseArgument { + /// The ID of the shift to clock in to. + final String shiftId; + + /// Optional notes provided by the user during clock-in. + final String? notes; + + /// Creates a [ClockInArguments] instance. + const ClockInArguments({ + required this.shiftId, + this.notes, + }); + + @override + List get props => [shiftId, notes]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart new file mode 100644 index 00000000..9b7fd324 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart @@ -0,0 +1,19 @@ +import 'package:krow_core/core.dart'; + +/// Represents the arguments required for the [ClockOutUseCase]. +class ClockOutArguments extends UseCaseArgument { + /// Optional notes provided by the user during clock-out. + final String? notes; + + /// Optional break time in minutes. + final int? breakTimeMinutes; + + /// Creates a [ClockOutArguments] instance. + const ClockOutArguments({ + this.notes, + this.breakTimeMinutes, + }); + + @override + List get props => [notes, breakTimeMinutes]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository.dart new file mode 100644 index 00000000..c27c665f --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository.dart @@ -0,0 +1,39 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Repository interface for Clock In/Out functionality +abstract class ClockInRepository { + + /// Retrieves the shift assigned to the user for the current day. + /// Returns null if no shift is assigned for today. + Future getTodaysShift(); + + /// Gets the current attendance status (e.g., checked in or not, times). + /// This helps in restoring the UI state if the app was killed. + Future getAttendanceStatus(); + + /// Checks the user in for the specified [shiftId]. + /// Returns the updated [AttendanceStatus]. + Future clockIn({required String shiftId, String? notes}); + + /// Checks the user out for the currently active shift. + /// Optionally accepts [breakTimeMinutes] if tracked. + Future clockOut({String? notes, int? breakTimeMinutes}); + + /// Retrieves a list of recent clock-in/out activities. + Future>> getActivityLog(); +} + +/// Simple entity to hold attendance state +class AttendanceStatus { + final bool isCheckedIn; + final DateTime? checkInTime; + final DateTime? checkOutTime; + final String? activeShiftId; + + const AttendanceStatus({ + this.isCheckedIn = false, + this.checkInTime, + this.checkOutTime, + this.activeShiftId, + }); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart new file mode 100644 index 00000000..5049987e --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart @@ -0,0 +1,35 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Interface for the Clock In feature repository. +/// +/// Defines the methods for managing clock-in/out operations and retrieving +/// related shift and attendance data. +abstract interface class ClockInRepositoryInterface { + /// Retrieves the shift scheduled for today. + Future getTodaysShift(); + + /// Retrieves the current attendance status (check-in time, check-out time, etc.). + /// + /// Returns a Map containing: + /// - 'isCheckedIn': bool + /// - 'checkInTime': DateTime? + /// - 'checkOutTime': DateTime? + Future> getAttendanceStatus(); + + /// Clocks the user in for a specific shift. + Future> clockIn({ + required String shiftId, + String? notes, + }); + + /// Clocks the user out of the current shift. + Future> clockOut({ + String? notes, + int? breakTimeMinutes, + }); + + /// Retrieves the history of clock-in/out activity. + /// + /// Returns a list of maps, where each map represents an activity entry. + Future>> getActivityLog(); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart new file mode 100644 index 00000000..a99ae43e --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart @@ -0,0 +1,18 @@ +import 'package:krow_core/core.dart'; +import '../repositories/clock_in_repository_interface.dart'; +import '../arguments/clock_in_arguments.dart'; + +/// Use case for clocking in a user. +class ClockInUseCase implements UseCase> { + final ClockInRepositoryInterface _repository; + + ClockInUseCase(this._repository); + + @override + Future> call(ClockInArguments arguments) { + return _repository.clockIn( + shiftId: arguments.shiftId, + notes: arguments.notes, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart new file mode 100644 index 00000000..dbea2b26 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart @@ -0,0 +1,18 @@ +import 'package:krow_core/core.dart'; +import '../repositories/clock_in_repository_interface.dart'; +import '../arguments/clock_out_arguments.dart'; + +/// Use case for clocking out a user. +class ClockOutUseCase implements UseCase> { + final ClockInRepositoryInterface _repository; + + ClockOutUseCase(this._repository); + + @override + Future> call(ClockOutArguments arguments) { + return _repository.clockOut( + notes: arguments.notes, + breakTimeMinutes: arguments.breakTimeMinutes, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/get_activity_log_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/get_activity_log_usecase.dart new file mode 100644 index 00000000..04b908dc --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/get_activity_log_usecase.dart @@ -0,0 +1,14 @@ +import 'package:krow_core/core.dart'; +import '../repositories/clock_in_repository_interface.dart'; + +/// Use case for retrieving the activity log. +class GetActivityLogUseCase implements NoInputUseCase>> { + final ClockInRepositoryInterface _repository; + + GetActivityLogUseCase(this._repository); + + @override + Future>> call() { + return _repository.getActivityLog(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/get_attendance_status_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/get_attendance_status_usecase.dart new file mode 100644 index 00000000..e0722339 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/get_attendance_status_usecase.dart @@ -0,0 +1,14 @@ +import 'package:krow_core/core.dart'; +import '../repositories/clock_in_repository_interface.dart'; + +/// Use case for getting the current attendance status (check-in/out times). +class GetAttendanceStatusUseCase implements NoInputUseCase> { + final ClockInRepositoryInterface _repository; + + GetAttendanceStatusUseCase(this._repository); + + @override + Future> call() { + return _repository.getAttendanceStatus(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/get_todays_shift_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/get_todays_shift_usecase.dart new file mode 100644 index 00000000..54477dc0 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/get_todays_shift_usecase.dart @@ -0,0 +1,15 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/clock_in_repository_interface.dart'; + +/// Use case for retrieving the user's scheduled shift for today. +class GetTodaysShiftUseCase implements NoInputUseCase { + final ClockInRepositoryInterface _repository; + + GetTodaysShiftUseCase(this._repository); + + @override + Future call() { + return _repository.getTodaysShift(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart new file mode 100644 index 00000000..e934e636 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart @@ -0,0 +1,129 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/usecases/get_todays_shift_usecase.dart'; +import '../../domain/usecases/get_attendance_status_usecase.dart'; +import '../../domain/usecases/clock_in_usecase.dart'; +import '../../domain/usecases/clock_out_usecase.dart'; +import '../../domain/usecases/get_activity_log_usecase.dart'; +import '../../domain/arguments/clock_in_arguments.dart'; +import '../../domain/arguments/clock_out_arguments.dart'; +import 'clock_in_event.dart'; +import 'clock_in_state.dart'; + +class ClockInBloc extends Bloc { + final GetTodaysShiftUseCase _getTodaysShift; + final GetAttendanceStatusUseCase _getAttendanceStatus; + final ClockInUseCase _clockIn; + final ClockOutUseCase _clockOut; + final GetActivityLogUseCase _getActivityLog; + + ClockInBloc({ + required GetTodaysShiftUseCase getTodaysShift, + required GetAttendanceStatusUseCase getAttendanceStatus, + required ClockInUseCase clockIn, + required ClockOutUseCase clockOut, + required GetActivityLogUseCase getActivityLog, + }) : _getTodaysShift = getTodaysShift, + _getAttendanceStatus = getAttendanceStatus, + _clockIn = clockIn, + _clockOut = clockOut, + _getActivityLog = getActivityLog, + super(ClockInState(selectedDate: DateTime.now())) { + on(_onLoaded); + on(_onDateSelected); + on(_onCheckIn); + on(_onCheckOut); + on(_onModeChanged); + } + + AttendanceStatus _mapToStatus(Map map) { + return AttendanceStatus( + isCheckedIn: map['isCheckedIn'] as bool? ?? false, + checkInTime: map['checkInTime'] as DateTime?, + checkOutTime: map['checkOutTime'] as DateTime?, + activeShiftId: map['activeShiftId'] as String?, + ); + } + + Future _onLoaded( + ClockInPageLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClockInStatus.loading)); + try { + final shift = await _getTodaysShift(); + final statusMap = await _getAttendanceStatus(); + final activity = await _getActivityLog(); + + emit(state.copyWith( + status: ClockInStatus.success, + todayShift: shift, + attendance: _mapToStatus(statusMap), + activityLog: activity, + )); + } catch (e) { + emit(state.copyWith( + status: ClockInStatus.failure, + errorMessage: e.toString(), + )); + } + } + + void _onDateSelected( + DateSelected event, + Emitter emit, + ) { + emit(state.copyWith(selectedDate: event.date)); + } + + void _onModeChanged( + CheckInModeChanged event, + Emitter emit, + ) { + emit(state.copyWith(checkInMode: event.mode)); + } + + Future _onCheckIn( + CheckInRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClockInStatus.actionInProgress)); + try { + final newStatusMap = await _clockIn( + ClockInArguments(shiftId: event.shiftId, notes: event.notes), + ); + emit(state.copyWith( + status: ClockInStatus.success, + attendance: _mapToStatus(newStatusMap), + )); + } catch (e) { + emit(state.copyWith( + status: ClockInStatus.failure, + errorMessage: e.toString(), + )); + } + } + + Future _onCheckOut( + CheckOutRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClockInStatus.actionInProgress)); + try { + final newStatusMap = await _clockOut( + ClockOutArguments( + notes: event.notes, + breakTimeMinutes: 0, // Should be passed from event if supported + ), + ); + emit(state.copyWith( + status: ClockInStatus.success, + attendance: _mapToStatus(newStatusMap), + )); + } catch (e) { + emit(state.copyWith( + status: ClockInStatus.failure, + errorMessage: e.toString(), + )); + } + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart new file mode 100644 index 00000000..d35647fb --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart @@ -0,0 +1,48 @@ +import 'package:equatable/equatable.dart'; + +abstract class ClockInEvent extends Equatable { + const ClockInEvent(); + + @override + List get props => []; +} + +class ClockInPageLoaded extends ClockInEvent {} + +class DateSelected extends ClockInEvent { + final DateTime date; + + const DateSelected(this.date); + + @override + List get props => [date]; +} + +class CheckInRequested extends ClockInEvent { + final String shiftId; + final String? notes; + + const CheckInRequested({required this.shiftId, this.notes}); + + @override + List get props => [shiftId, notes]; +} + +class CheckOutRequested extends ClockInEvent { + final String? notes; + final int? breakTimeMinutes; + + const CheckOutRequested({this.notes, this.breakTimeMinutes}); + + @override + List get props => [notes, breakTimeMinutes]; +} + +class CheckInModeChanged extends ClockInEvent { + final String mode; + + const CheckInModeChanged(this.mode); + + @override + List get props => [mode]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart new file mode 100644 index 00000000..8e6fe30c --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart @@ -0,0 +1,73 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum ClockInStatus { initial, loading, success, failure, actionInProgress } + +/// View model representing the user's current attendance state. +class AttendanceStatus extends Equatable { + final bool isCheckedIn; + final DateTime? checkInTime; + final DateTime? checkOutTime; + final String? activeShiftId; + + const AttendanceStatus({ + this.isCheckedIn = false, + this.checkInTime, + this.checkOutTime, + this.activeShiftId, + }); + + @override + List get props => [isCheckedIn, checkInTime, checkOutTime, activeShiftId]; +} + +class ClockInState extends Equatable { + final ClockInStatus status; + final Shift? todayShift; + final AttendanceStatus attendance; + final List> activityLog; + final DateTime selectedDate; + final String checkInMode; + final String? errorMessage; + + const ClockInState({ + this.status = ClockInStatus.initial, + this.todayShift, + this.attendance = const AttendanceStatus(), + this.activityLog = const [], + required this.selectedDate, + this.checkInMode = 'swipe', + this.errorMessage, + }); + + ClockInState copyWith({ + ClockInStatus? status, + Shift? todayShift, + AttendanceStatus? attendance, + List>? activityLog, + DateTime? selectedDate, + String? checkInMode, + String? errorMessage, + }) { + return ClockInState( + status: status ?? this.status, + todayShift: todayShift ?? this.todayShift, + attendance: attendance ?? this.attendance, + activityLog: activityLog ?? this.activityLog, + selectedDate: selectedDate ?? this.selectedDate, + checkInMode: checkInMode ?? this.checkInMode, + errorMessage: errorMessage, + ); + } + + @override + List get props => [ + status, + todayShift, + attendance, + activityLog, + selectedDate, + checkInMode, + errorMessage, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart new file mode 100644 index 00000000..1ca438d7 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -0,0 +1,774 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../theme/app_colors.dart'; +import '../bloc/clock_in_bloc.dart'; +import '../bloc/clock_in_event.dart'; +import '../bloc/clock_in_state.dart'; +import '../widgets/attendance_card.dart'; +import '../widgets/date_selector.dart'; +import '../widgets/swipe_to_check_in.dart'; +import '../widgets/lunch_break_modal.dart'; +import '../widgets/commute_tracker.dart'; + +class ClockInPage extends StatefulWidget { + const ClockInPage({super.key}); + + @override + State createState() => _ClockInPageState(); +} + +class _ClockInPageState extends State { + late final ClockInBloc _bloc; + + @override + void initState() { + super.initState(); + _bloc = Modular.get(); + _bloc.add(ClockInPageLoaded()); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _bloc, + child: BlocConsumer( + listener: (context, state) { + if (state.status == ClockInStatus.failure && state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.errorMessage!)), + ); + } + }, + builder: (context, state) { + if (state.status == ClockInStatus.loading && state.todayShift == null) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + final todayShift = state.todayShift; + final checkInTime = state.attendance.checkInTime; + final checkOutTime = state.attendance.checkOutTime; + final isCheckedIn = state.attendance.isCheckedIn; + + // Format times for display + final checkInStr = checkInTime != null + ? DateFormat('h:mm a').format(checkInTime) + : '--:-- --'; + final checkOutStr = checkOutTime != null + ? DateFormat('h:mm a').format(checkOutTime) + : '--:-- --'; + + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFF8FAFC), // slate-50 + Colors.white, + ], + ), + ), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // Commute Tracker (shows before date selector when applicable) + if (todayShift != null) + CommuteTracker( + shift: todayShift, + hasLocationConsent: false, // Mock value + isCommuteModeOn: false, // Mock value + distanceMeters: 500, // Mock value for demo + etaMinutes: 8, // Mock value for demo + ), + + // Date Selector + DateSelector( + selectedDate: state.selectedDate, + onSelect: (date) => _bloc.add(DateSelected(date)), + shiftDates: [ + DateFormat('yyyy-MM-dd').format(DateTime.now()), + ], + ), + const SizedBox(height: 20), + + // Today Attendance Section + const Align( + alignment: Alignment.centerLeft, + child: Text( + "Today Attendance", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.krowCharcoal, + ), + ), + ), + const SizedBox(height: 12), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.0, + children: [ + AttendanceCard( + type: AttendanceType.checkin, + title: "Check In", + value: checkInStr, + subtitle: checkInTime != null + ? "On Time" + : "Pending", + scheduledTime: "09:00 AM", + ), + AttendanceCard( + type: AttendanceType.checkout, + title: "Check Out", + value: checkOutStr, + subtitle: checkOutTime != null + ? "Go Home" + : "Pending", + scheduledTime: "05:00 PM", + ), + AttendanceCard( + type: AttendanceType.breaks, + title: "Break Time", + value: "00:30 min", + subtitle: "Scheduled 00:30 min", + ), + const AttendanceCard( + type: AttendanceType.days, + title: "Total Days", + value: "28", + subtitle: "Working Days", + ), + ], + ), + const SizedBox(height: 24), + + // Your Activity Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Your Activity", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.krowCharcoal, + ), + ), + GestureDetector( + onTap: () { + debugPrint('Navigating to shifts...'); + }, + child: const Row( + children: [ + Text( + "View all", + style: TextStyle( + color: AppColors.krowBlue, + fontWeight: FontWeight.w500, + ), + ), + Icon( + LucideIcons.chevronRight, + size: 16, + color: AppColors.krowBlue, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Check-in Mode Toggle + const Align( + alignment: Alignment.centerLeft, + child: Text( + "Check-in Method", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF334155), // slate-700 + ), + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), // slate-100 + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _buildModeTab("Swipe", LucideIcons.mapPin, 'swipe', state.checkInMode), + _buildModeTab("NFC Tap", LucideIcons.wifi, 'nfc', state.checkInMode), + ], + ), + ), + const SizedBox(height: 16), + + // Selected Shift Info Card + if (todayShift != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFE2E8F0), + ), // slate-200 + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "TODAY'S SHIFT", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.krowBlue, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 2), + Text( + todayShift.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), // slate-800 + ), + ), + Text( + "${todayShift.clientName} • ${todayShift.location}", + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), // slate-500 + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text( + "9:00 AM - 5:00 PM", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF475569), // slate-600 + ), + ), + Text( + "\$${todayShift.hourlyRate}/hr", + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.krowBlue, + ), + ), + ], + ), + ], + ), + ), + + // Swipe To Check In / Checked Out State / No Shift State + if (todayShift != null && checkOutTime == null) ...[ + SwipeToCheckIn( + isCheckedIn: isCheckedIn, + mode: state.checkInMode, + 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: todayShift.id)); + } + }, + onCheckOut: () { + showDialog( + context: context, + builder: (context) => LunchBreakDialog( + onComplete: () { + Navigator.of(context).pop(); // Close dialog first + _bloc.add(const CheckOutRequested()); + }, + ), + ); + }, + ), + ] else if (todayShift != null && checkOutTime != null) ...[ + // Shift Completed State + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFFECFDF5), // emerald-50 + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFA7F3D0), + ), // emerald-200 + ), + child: Column( + children: [ + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Color(0xFFD1FAE5), // emerald-100 + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.check, + color: Color(0xFF059669), // emerald-600 + size: 24, + ), + ), + const SizedBox(height: 12), + const Text( + "Shift Completed!", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF065F46), // emerald-800 + ), + ), + const SizedBox(height: 4), + const Text( + "Great work today", + style: TextStyle( + fontSize: 14, + color: Color(0xFF059669), // emerald-600 + ), + ), + ], + ), + ), + ] else ...[ + // No Shift State + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), // slate-100 + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + const Text( + "No confirmed shifts for today", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF475569), // slate-600 + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + const Text( + "Accept a shift to clock in", + style: TextStyle( + fontSize: 14, + color: Color(0xFF64748B), // slate-500 + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + + // Checked In Banner + if (isCheckedIn && checkInTime != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFECFDF5), // emerald-50 + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFA7F3D0), + ), // emerald-200 + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Checked in at", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF059669), + ), + ), + Text( + DateFormat('h:mm a').format(checkInTime), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF065F46), + ), + ), + ], + ), + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: Color(0xFFD1FAE5), + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.check, + color: Color(0xFF059669), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 16), + + // Recent Activity List + ...state.activityLog.map( + (activity) => Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFF1F5F9), + ), // slate-100 + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.krowBlue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + LucideIcons.mapPin, + color: AppColors.krowBlue, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat( + 'MMM d', + ).format(activity['date'] as DateTime), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF0F172A), // slate-900 + ), + ), + Text( + "${activity['start']} - ${activity['end']}", + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), // slate-500 + ), + ), + ], + ), + ), + Text( + activity['hours'] as String, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.krowBlue, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildModeTab(String label, IconData icon, String value, String currentMode) { + final isSelected = currentMode == value; + return Expanded( + child: GestureDetector( + onTap: () => _bloc.add(CheckInModeChanged(value)), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isSelected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(8), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ] + : [], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 16, + color: isSelected ? Colors.black : Colors.grey, + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.black : Colors.grey, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppColors.krowBlue.withOpacity(0.2), + width: 2, + ), + ), + child: CircleAvatar( + backgroundColor: AppColors.krowBlue.withOpacity(0.1), + child: const Text( + 'K', + style: TextStyle( + color: AppColors.krowBlue, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Good Morning', + style: TextStyle(color: AppColors.krowMuted, fontSize: 12), + ), + Text( + 'Krower', + style: TextStyle( + color: AppColors.krowCharcoal, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Warehouse Assistant', + style: TextStyle(color: AppColors.krowMuted, fontSize: 12), + ), + ], + ), + ], + ), + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular( + 20, + ), // Rounded full for this page per design + border: Border.all(color: Colors.grey.shade100), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: const Icon( + LucideIcons.bell, + color: AppColors.krowMuted, + size: 20, + ), + ), + ], + ), + ); + } + + Future _showNFCDialog(BuildContext context) async { + 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: (context, setState) { + return AlertDialog( + title: Text(scanned ? 'Tag Scanned!' : 'Scan NFC Tag'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: scanned + ? Colors.green.shade50 + : Colors.blue.shade50, + shape: BoxShape.circle, + ), + child: Icon( + scanned ? LucideIcons.check : LucideIcons.nfc, + size: 48, + color: scanned + ? Colors.green.shade600 + : Colors.blue.shade600, + ), + ), + const SizedBox(height: 24), + Text( + scanned ? 'Processing check-in...' : 'Ready to scan', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + scanned + ? 'Please wait...' + : 'Hold your phone near the NFC tag at the clock-in station', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + if (!scanned) ...[ + const SizedBox(height: 24), + 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(LucideIcons.nfc, size: 24), + label: const Text( + 'Tap to Scan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0047FF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ], + ), + ); + }, + ); + }, + ); + + // After dialog closes, trigger the event if scan was successful (simulated) + // In real app, we would check the dialog result + if (scanned && _bloc.state.todayShift != null) { + _bloc.add(CheckInRequested(shiftId: _bloc.state.todayShift!.id)); + } + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/theme/app_colors.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/theme/app_colors.dart new file mode 100644 index 00000000..a41fe11f --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/theme/app_colors.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const Color krowBlue = Color(0xFF0A39DF); + static const Color krowYellow = Color(0xFFFFED4A); + static const Color krowCharcoal = Color(0xFF121826); + static const Color krowMuted = Color(0xFF6A7382); + static const Color krowBorder = Color(0xFFE3E6E9); + static const Color krowBackground = Color(0xFFFAFBFC); + + static const Color white = Colors.white; + static const Color black = Colors.black; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart new file mode 100644 index 00000000..4efc0f62 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +enum AttendanceType { checkin, checkout, breaks, days } + +class AttendanceCard extends StatelessWidget { + final AttendanceType type; + final String title; + final String value; + final String subtitle; + final String? scheduledTime; + + const AttendanceCard({ + super.key, + required this.type, + required this.title, + required this.value, + required this.subtitle, + this.scheduledTime, + }); + + @override + Widget build(BuildContext context) { + final styles = _getStyles(type); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade100), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: styles.bgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(styles.icon, size: 16, color: styles.iconColor), + ), + const SizedBox(height: 12), + Text( + title, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), // slate-500 + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), // slate-900 + ), + ), + if (scheduledTime != null) ...[ + const SizedBox(height: 2), + Text( + "Scheduled: $scheduledTime", + style: const TextStyle( + fontSize: 10, + color: Color(0xFF94A3B8), // slate-400 + ), + ), + ], + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle(fontSize: 12, color: Color(0xFF0032A0)), + ), + ], + ), + ); + } + + _AttendanceStyle _getStyles(AttendanceType type) { + switch (type) { + case AttendanceType.checkin: + return _AttendanceStyle( + icon: LucideIcons.logIn, + bgColor: const Color(0xFF0032A0).withOpacity(0.1), + iconColor: const Color(0xFF0032A0), + ); + case AttendanceType.checkout: + return _AttendanceStyle( + icon: LucideIcons.logOut, + bgColor: const Color(0xFF333F48).withOpacity(0.1), + iconColor: const Color(0xFF333F48), + ); + case AttendanceType.breaks: + return _AttendanceStyle( + icon: LucideIcons.coffee, + bgColor: const Color(0xFFF9E547).withOpacity(0.2), + iconColor: const Color(0xFF4C460D), + ); + case AttendanceType.days: + return _AttendanceStyle( + icon: LucideIcons.calendar, + bgColor: Colors.green.withOpacity(0.1), + iconColor: Colors.green, + ); + } + } +} + +class _AttendanceStyle { + final IconData icon; + final Color bgColor; + final Color iconColor; + + _AttendanceStyle({ + required this.icon, + required this.bgColor, + required this.iconColor, + }); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart new file mode 100644 index 00000000..e9701fe0 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart @@ -0,0 +1,542 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../theme/app_colors.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum CommuteMode { + lockedNoShift, + needsConsent, + preShiftCommuteAllowed, + commuteModeActive, + arrivedCanClockIn, +} + +class CommuteTracker extends StatefulWidget { + final Shift? shift; + final Function(CommuteMode)? onModeChange; + final bool hasLocationConsent; + final bool isCommuteModeOn; + final double? distanceMeters; + final int? etaMinutes; + + const CommuteTracker({ + super.key, + this.shift, + this.onModeChange, + this.hasLocationConsent = false, + this.isCommuteModeOn = false, + this.distanceMeters, + this.etaMinutes, + }); + + @override + State createState() => _CommuteTrackerState(); +} + +class _CommuteTrackerState extends State { + bool _localHasConsent = false; + bool _localIsCommuteOn = false; + + @override + void initState() { + super.initState(); + _localHasConsent = widget.hasLocationConsent; + _localIsCommuteOn = widget.isCommuteModeOn; + } + + CommuteMode _getAppMode() { + if (widget.shift == null) return CommuteMode.lockedNoShift; + + // For demo purposes, check if we're within 24 hours of shift + final now = DateTime.now(); + final shiftStart = DateTime.parse( + '${widget.shift!.date} ${widget.shift!.startTime}', + ); + final hoursUntilShift = shiftStart.difference(now).inHours; + final inCommuteWindow = hoursUntilShift <= 24 && hoursUntilShift >= 0; + + if (_localIsCommuteOn) { + // Check if arrived (mock: if distance < 200m) + if (widget.distanceMeters != null && widget.distanceMeters! <= 200) { + return CommuteMode.arrivedCanClockIn; + } + return CommuteMode.commuteModeActive; + } + + if (inCommuteWindow) { + return _localHasConsent + ? CommuteMode.preShiftCommuteAllowed + : CommuteMode.needsConsent; + } + + return CommuteMode.lockedNoShift; + } + + String _formatDistance(double meters) { + final miles = meters / 1609.34; + return miles < 0.1 + ? '${meters.round()} m' + : '${miles.toStringAsFixed(1)} mi'; + } + + int _getMinutesUntilShift() { + if (widget.shift == null) return 0; + final now = DateTime.now(); + final shiftStart = DateTime.parse( + '${widget.shift!.date} ${widget.shift!.startTime}', + ); + return shiftStart.difference(now).inMinutes; + } + + @override + Widget build(BuildContext context) { + final mode = _getAppMode(); + + // Notify parent of mode change + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onModeChange?.call(mode); + }); + + switch (mode) { + case CommuteMode.lockedNoShift: + return const SizedBox.shrink(); + + case CommuteMode.needsConsent: + return _buildConsentCard(); + + case CommuteMode.preShiftCommuteAllowed: + return _buildPreShiftCard(); + + case CommuteMode.commuteModeActive: + return _buildActiveCommuteScreen(); + + case CommuteMode.arrivedCanClockIn: + return _buildArrivedCard(); + } + } + + Widget _buildConsentCard() { + return Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFEFF6FF), // blue-50 + Color(0xFFECFEFF), // cyan-50 + ], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + color: Color(0xFF2563EB), // blue-600 + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.mapPin, + size: 16, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enable Commute Tracking?', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF0F172A), // slate-900 + ), + ), + const SizedBox(height: 4), + Text( + 'Share location 1hr before shift so your manager can see you\'re on the way.', + style: TextStyle( + fontSize: 12, + color: Color(0xFF475569), // slate-600 + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + setState(() => _localHasConsent = false); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 8), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + child: const Text('Not Now', style: TextStyle(fontSize: 12)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: () { + setState(() => _localHasConsent = true); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), // blue-600 + padding: const EdgeInsets.symmetric(vertical: 8), + ), + child: const Text( + 'Enable', + style: TextStyle(fontSize: 12, color: Colors.white), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildPreShiftCard() { + return Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + color: Color(0xFFF1F5F9), // slate-100 + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.navigation, + size: 16, + color: Color(0xFF475569), // slate-600 + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'On My Way', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF0F172A), // slate-900 + ), + ), + const SizedBox(width: 8), + Row( + children: [ + const Icon( + LucideIcons.clock, + size: 12, + color: Color(0xFF64748B), // slate-500 + ), + const SizedBox(width: 2), + Text( + 'Shift starts in ${_getMinutesUntilShift()} min', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF64748B), // slate-500 + ), + ), + ], + ), + ], + ), + const Text( + 'Track arrival', + style: TextStyle( + fontSize: 10, + color: Color(0xFF64748B), // slate-500 + ), + ), + ], + ), + ), + Switch( + value: _localIsCommuteOn, + onChanged: (value) { + setState(() => _localIsCommuteOn = value); + }, + activeColor: AppColors.krowBlue, + ), + ], + ), + ); + } + + Widget _buildActiveCommuteScreen() { + return Container( + height: MediaQuery.of(context).size.height, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF2563EB), // blue-600 + Color(0xFF0891B2), // cyan-600 + ], + ), + ), + child: SafeArea( + child: Column( + children: [ + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 1.0, end: 1.1), + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + builder: (context, double scale, child) { + return Transform.scale( + scale: scale, + child: Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.navigation, + size: 48, + color: Colors.white, + ), + ), + ); + }, + onEnd: () { + // Restart animation + setState(() {}); + }, + ), + const SizedBox(height: 24), + const Text( + 'On My Way', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Text( + 'Your manager can see you\'re heading to the site', + style: TextStyle( + fontSize: 14, + color: Colors.blue.shade100, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (widget.distanceMeters != null) ...[ + Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 300), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withOpacity(0.2), + ), + ), + child: Column( + children: [ + Text( + 'Distance to Site', + style: TextStyle( + fontSize: 14, + color: Colors.blue.shade100, + ), + ), + const SizedBox(height: 4), + Text( + _formatDistance(widget.distanceMeters!), + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + if (widget.etaMinutes != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 300), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withOpacity(0.2), + ), + ), + child: Column( + children: [ + Text( + 'Estimated Arrival', + style: TextStyle( + fontSize: 14, + color: Colors.blue.shade100, + ), + ), + const SizedBox(height: 4), + Text( + '${widget.etaMinutes} min', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + ], + ], + const SizedBox(height: 32), + Text( + 'Most app features are locked while commute mode is on. You\'ll be able to clock in once you arrive.', + style: TextStyle( + fontSize: 12, + color: Colors.blue.shade100, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: OutlinedButton( + onPressed: () { + setState(() => _localIsCommuteOn = false); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withOpacity(0.3)), + padding: const EdgeInsets.symmetric(vertical: 16), + minimumSize: const Size(double.infinity, 48), + ), + child: const Text('Turn Off Commute Mode'), + ), + ), + ], + ), + ), + ); + } + + Widget _buildArrivedCard() { + return Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFECFDF5), // emerald-50 + Color(0xFFD1FAE5), // green-50 + ], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + color: Color(0xFF10B981), // emerald-500 + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.checkCircle, + size: 32, + color: Colors.white, + ), + ), + const SizedBox(height: 16), + const Text( + 'You\'ve Arrived! 🎉', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), // slate-900 + ), + ), + const SizedBox(height: 8), + const Text( + 'You\'re at the shift location. Ready to clock in?', + style: TextStyle( + fontSize: 14, + color: Color(0xFF475569), // slate-600 + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart new file mode 100644 index 00000000..320ba176 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class DateSelector extends StatelessWidget { + final DateTime selectedDate; + final ValueChanged onSelect; + final List shiftDates; + + const DateSelector({ + super.key, + required this.selectedDate, + required this.onSelect, + this.shiftDates = const [], + }); + + @override + Widget build(BuildContext context) { + final today = DateTime.now(); + final dates = List.generate(7, (index) { + return today.add(Duration(days: index - 3)); + }); + + return SizedBox( + height: 80, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: dates.map((date) { + final isSelected = _isSameDay(date, selectedDate); + final isToday = _isSameDay(date, today); + final hasShift = shiftDates.contains(_formatDateIso(date)); + + return Expanded( + child: GestureDetector( + onTap: () => onSelect(date), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF0032A0) : Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: isSelected + ? [ + BoxShadow( + color: const Color(0xFF0032A0).withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ] + : [], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat('d').format(date), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isSelected + ? Colors.white + : const Color(0xFF0F172A), + ), + ), + const SizedBox(height: 2), + Text( + DateFormat('E').format(date), + style: TextStyle( + fontSize: 12, + color: isSelected + ? Colors.white.withOpacity(0.8) + : const Color(0xFF94A3B8), + ), + ), + const SizedBox(height: 4), + if (hasShift) + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: isSelected + ? Colors.white + : const Color(0xFF0032A0), + shape: BoxShape.circle, + ), + ) + else if (isToday && !isSelected) + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ) + else + const SizedBox(height: 6), + ], + ), + ), + ), + ); + }).toList(), + ), + ); + } + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + String _formatDateIso(DateTime date) { + return DateFormat('yyyy-MM-dd').format(date); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart new file mode 100644 index 00000000..3061de20 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +class LunchBreakDialog extends StatefulWidget { + final VoidCallback onComplete; + + const LunchBreakDialog({super.key, required this.onComplete}); + + @override + State createState() => _LunchBreakDialogState(); +} + +class _LunchBreakDialogState extends State { + int _step = 1; + bool? _tookLunch; + // ignore: unused_field + String? _breakStart = '12:00pm'; + // ignore: unused_field + String? _breakEnd = '12:30pm'; + // ignore: unused_field + String? _noLunchReason; + // ignore: unused_field + String _additionalNotes = ''; + + final List _timeOptions = _generateTimeOptions(); + final List _noLunchReasons = [ + 'Unpredictable Workflows', + 'Poor Time Management', + 'Lack of coverage or short Staff', + 'No Lunch Area', + 'Other (Please specify)', + ]; + + static List _generateTimeOptions() { + List options = []; + for (int h = 0; h < 24; h++) { + for (int m = 0; m < 60; m += 15) { + final hour = h % 12 == 0 ? 12 : h % 12; + final ampm = h < 12 ? 'am' : 'pm'; + final timeStr = '$hour:${m.toString().padLeft(2, '0')}$ampm'; + options.add(timeStr); + } + } + return options; + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: _buildCurrentStep(), + ), + ); + } + + Widget _buildCurrentStep() { + switch (_step) { + case 1: + return _buildStep1(); + case 2: + return _buildStep2(); + case 102: // 2b: No lunch reason + return _buildStep2b(); + case 3: + return _buildStep3(); + case 4: + return _buildStep4(); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildStep1() { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey.shade100, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.coffee, + size: 40, + color: Color(0xFF6A7382), + ), + ), + const SizedBox(height: 24), + const Text( + "Did You Take\na Lunch?", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Color(0xFF121826), + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + setState(() { + _tookLunch = false; + _step = 102; // Go to No Lunch Reason + }); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: Colors.grey.shade300), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + "No", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF121826), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + setState(() { + _tookLunch = true; + _step = 2; // Go to Time Input + }); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0032A0), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + "Yes", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildStep2() { + // Time input + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "When did you take lunch?", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + // Mock Inputs + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: _breakStart, + items: _timeOptions.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), + onChanged: (v) => setState(() => _breakStart = v), + decoration: const InputDecoration(labelText: 'Start'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + value: _breakEnd, + items: _timeOptions.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), + onChanged: (v) => setState(() => _breakEnd = v), + decoration: const InputDecoration(labelText: 'End'), + ), + ), + ], + ), + + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + setState(() => _step = 3); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0032A0), + minimumSize: const Size(double.infinity, 48), + ), + child: const Text("Next", style: TextStyle(color: Colors.white)), + ), + ], + )); + } + + Widget _buildStep2b() { + // No lunch reason + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + "Why didn't you take lunch?", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + ..._noLunchReasons.map((reason) => RadioListTile( + title: Text(reason), + value: reason, + groupValue: _noLunchReason, + onChanged: (val) => setState(() => _noLunchReason = val), + )), + + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + setState(() => _step = 3); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0032A0), + minimumSize: const Size(double.infinity, 48), + ), + child: const Text("Next", style: TextStyle(color: Colors.white)), + ), + ], + )); + } + + Widget _buildStep3() { + // Additional Notes + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Additional Notes", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + TextField( + onChanged: (v) => _additionalNotes = v, + decoration: const InputDecoration( + hintText: 'Add any details...', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + setState(() => _step = 4); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0032A0), + minimumSize: const Size(double.infinity, 48), + ), + child: const Text("Submit", style: TextStyle(color: Colors.white)), + ), + ], + )); + } + + Widget _buildStep4() { + // Success + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.checkCircle, size: 64, color: Colors.green), + const SizedBox(height: 24), + const Text( + "Break Logged!", + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: widget.onComplete, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0032A0), + minimumSize: const Size(double.infinity, 48), + ), + child: const Text("Close", style: TextStyle(color: Colors.white)), + ), + ], + )); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart new file mode 100644 index 00000000..fef53472 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +class SwipeToCheckIn extends StatefulWidget { + final VoidCallback? onCheckIn; + final VoidCallback? onCheckOut; + final bool isLoading; + final String mode; // 'swipe' or 'nfc' + final bool isCheckedIn; + + const SwipeToCheckIn({ + super.key, + this.onCheckIn, + this.onCheckOut, + this.isLoading = false, + this.mode = 'swipe', + this.isCheckedIn = false, + }); + + @override + State createState() => _SwipeToCheckInState(); +} + +class _SwipeToCheckInState extends State + with SingleTickerProviderStateMixin { + double _dragValue = 0.0; + final double _handleSize = 48.0; + bool _isComplete = false; + + @override + void didUpdateWidget(SwipeToCheckIn oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isCheckedIn != oldWidget.isCheckedIn) { + setState(() { + _isComplete = false; + _dragValue = 0.0; + }); + } + } + + void _onDragUpdate(DragUpdateDetails details, double maxWidth) { + if (_isComplete || widget.isLoading) return; + setState(() { + _dragValue = (_dragValue + details.delta.dx).clamp( + 0.0, + maxWidth - _handleSize - 8, + ); + }); + } + + void _onDragEnd(DragEndDetails details, double maxWidth) { + if (_isComplete || widget.isLoading) return; + final threshold = (maxWidth - _handleSize - 8) * 0.8; + if (_dragValue > threshold) { + setState(() { + _dragValue = maxWidth - _handleSize - 8; + _isComplete = true; + }); + Future.delayed(const Duration(milliseconds: 300), () { + if (widget.isCheckedIn) { + widget.onCheckOut?.call(); + } else { + widget.onCheckIn?.call(); + } + }); + } else { + setState(() { + _dragValue = 0.0; + }); + } + } + + @override + Widget build(BuildContext context) { + final baseColor = widget.isCheckedIn + ? const Color(0xFF10B981) + : const Color(0xFF0032A0); + + if (widget.mode == 'nfc') { + return GestureDetector( + onTap: () { + if (widget.isLoading) return; + // Simulate completion for NFC tap + Future.delayed(const Duration(milliseconds: 300), () { + if (widget.isCheckedIn) { + widget.onCheckOut?.call(); + } else { + widget.onCheckIn?.call(); + } + }); + }, + child: Container( + height: 56, + decoration: BoxDecoration( + color: baseColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: baseColor.withOpacity(0.4), + blurRadius: 25, + offset: const Offset(0, 10), + spreadRadius: -5, + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(LucideIcons.wifi, color: Colors.white), + const SizedBox(width: 12), + Text( + widget.isLoading + ? (widget.isCheckedIn + ? "Checking out..." + : "Checking in...") + : (widget.isCheckedIn ? "NFC Check Out" : "NFC Check In"), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ], + ), + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final maxDrag = maxWidth - _handleSize - 8; + + // Calculate background color based on drag + final progress = _dragValue / maxDrag; + final startColor = widget.isCheckedIn + ? const Color(0xFF10B981) + : const Color(0xFF0032A0); + final endColor = widget.isCheckedIn + ? const Color(0xFF0032A0) + : const Color(0xFF10B981); + final currentColor = + Color.lerp(startColor, endColor, progress) ?? startColor; + + return Container( + height: 56, + decoration: BoxDecoration( + color: currentColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Stack( + children: [ + Center( + child: Opacity( + opacity: 1.0 - progress, + child: Text( + widget.isCheckedIn + ? "Swipe to Check Out" + : "Swipe to Check In", + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + ), + ), + if (_isComplete) + Center( + child: Text( + widget.isCheckedIn ? "Check Out!" : "Check In!", + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + ), + Positioned( + left: 4 + _dragValue, + top: 4, + child: GestureDetector( + onHorizontalDragUpdate: (d) => _onDragUpdate(d, maxWidth), + onHorizontalDragEnd: (d) => _onDragEnd(d, maxWidth), + child: Container( + width: _handleSize, + height: _handleSize, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Center( + child: Icon( + _isComplete + ? LucideIcons.check + : LucideIcons.arrowRight, + color: startColor, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart new file mode 100644 index 00000000..f7062597 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -0,0 +1,37 @@ +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'data/repositories/clock_in_repository_impl.dart'; +import 'domain/repositories/clock_in_repository_interface.dart'; +import 'domain/usecases/clock_in_usecase.dart'; +import 'domain/usecases/clock_out_usecase.dart'; +import 'domain/usecases/get_activity_log_usecase.dart'; +import 'domain/usecases/get_attendance_status_usecase.dart'; +import 'domain/usecases/get_todays_shift_usecase.dart'; +import 'presentation/bloc/clock_in_bloc.dart'; +import 'presentation/pages/clock_in_page.dart'; + +class StaffClockInModule extends Module { + @override + void binds(Injector i) { + // Data Sources (Mocks from data_connect) + i.add(ShiftsRepositoryMock.new); + + // Repositories + i.add(ClockInRepositoryImpl.new); + + // Use Cases + i.add(GetTodaysShiftUseCase.new); + i.add(GetAttendanceStatusUseCase.new); + i.add(ClockInUseCase.new); + i.add(ClockOutUseCase.new); + i.add(GetActivityLogUseCase.new); + + // BLoC + i.add(ClockInBloc.new); + } + + @override + void routes(r) { + r.child('/', child: (context) => const ClockInPage()); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart new file mode 100644 index 00000000..d9d93a80 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart @@ -0,0 +1,4 @@ +library staff_clock_in; + +export 'src/staff_clock_in_module.dart'; +export 'src/presentation/pages/clock_in_page.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/pubspec.yaml b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml new file mode 100644 index 00000000..47781140 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml @@ -0,0 +1,30 @@ +name: staff_clock_in +description: Staff Clock In Feature +version: 0.0.1 +publish_to: 'none' +resolution: workspace + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^8.1.3 + equatable: ^2.0.5 + intl: ^0.20.2 + lucide_icons: ^0.257.0 + flutter_modular: ^6.3.2 + + # Internal packages + core_localization: + path: ../../../core_localization + design_system: + path: ../../../design_system + krow_domain: + path: ../../../domain + krow_data_connect: + path: ../../../data_connect + krow_core: + path: ../../../core diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index 5472cb35..1e154963 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -14,6 +14,7 @@ import 'package:staff_shifts/staff_shifts.dart'; import 'package:staff_payments/staff_payements.dart'; import 'package:staff_time_card/staff_time_card.dart'; import 'package:staff_availability/staff_availability.dart'; +import 'package:staff_clock_in/staff_clock_in.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart'; import 'package:staff_main/src/presentation/constants/staff_main_routes.dart'; @@ -44,10 +45,9 @@ class StaffMainModule extends Module { StaffMainRoutes.home, module: StaffHomeModule(), ), - ChildRoute( + ModuleRoute( StaffMainRoutes.clockIn, - child: (BuildContext context) => - const PlaceholderPage(title: 'Clock In'), + module: StaffClockInModule(), ), ModuleRoute( StaffMainRoutes.profile, @@ -74,5 +74,9 @@ class StaffMainModule extends Module { module: StaffTimeCardModule(), ); r.module('/availability', module: StaffAvailabilityModule()); + r.module( + '/clock-in', + module: StaffClockInModule(), + ); } } diff --git a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml index 362acace..441aea74 100644 --- a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml +++ b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml @@ -51,7 +51,9 @@ dependencies: path: ../profile_sections/finances/time_card staff_availability: path: ../availability - + staff_clock_in: + path: ../clock_in + dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 913fe41f..cbc370eb 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -1100,6 +1100,13 @@ packages: relative: true source: path version: "0.0.1" + staff_clock_in: + dependency: transitive + description: + path: "packages/features/staff/clock_in" + relative: true + source: path + version: "0.0.1" staff_documents: dependency: transitive description: