feat: Implement Staff Clock In feature with attendance tracking and commute management

- Added AppColors for consistent theming across the feature.
- Created AttendanceCard widget to display attendance information.
- Developed CommuteTracker widget to manage and display commute status.
- Implemented DateSelector for selecting shift dates.
- Added LunchBreakDialog for logging lunch breaks with multiple steps.
- Created SwipeToCheckIn widget for checking in and out via swipe or NFC.
- Established StaffClockInModule for dependency injection and routing.
- Updated pubspec.yaml with necessary dependencies for the feature.
This commit is contained in:
Achintha Isuru
2026-01-26 01:25:08 -05:00
parent 2a820b3e4f
commit 7bd276269b
27 changed files with 2752 additions and 5 deletions

View File

@@ -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

View File

@@ -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<Shift?> 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<Map<String, dynamic>> getAttendanceStatus() async {
await Future.delayed(const Duration(milliseconds: 300));
return _getCurrentStatusMap();
}
@override
Future<Map<String, dynamic>> 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<Map<String, dynamic>> clockOut({String? notes, int? breakTimeMinutes}) async {
await Future.delayed(const Duration(seconds: 1)); // Simulate network
_isCheckedIn = false;
_checkOutTime = DateTime.now();
return _getCurrentStatusMap();
}
@override
Future<List<Map<String, dynamic>>> 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<String, dynamic> _getCurrentStatusMap() {
return {
'isCheckedIn': _isCheckedIn,
'checkInTime': _checkInTime,
'checkOutTime': _checkOutTime,
'activeShiftId': _activeShiftId,
};
}
}

View File

@@ -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<Object?> get props => [shiftId, notes];
}

View File

@@ -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<Object?> get props => [notes, breakTimeMinutes];
}

View File

@@ -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<Shift?> 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<AttendanceStatus> getAttendanceStatus();
/// Checks the user in for the specified [shiftId].
/// Returns the updated [AttendanceStatus].
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
/// Checks the user out for the currently active shift.
/// Optionally accepts [breakTimeMinutes] if tracked.
Future<AttendanceStatus> clockOut({String? notes, int? breakTimeMinutes});
/// Retrieves a list of recent clock-in/out activities.
Future<List<Map<String, dynamic>>> 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,
});
}

View File

@@ -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<Shift?> getTodaysShift();
/// Retrieves the current attendance status (check-in time, check-out time, etc.).
///
/// Returns a Map containing:
/// - 'isCheckedIn': bool
/// - 'checkInTime': DateTime?
/// - 'checkOutTime': DateTime?
Future<Map<String, dynamic>> getAttendanceStatus();
/// Clocks the user in for a specific shift.
Future<Map<String, dynamic>> clockIn({
required String shiftId,
String? notes,
});
/// Clocks the user out of the current shift.
Future<Map<String, dynamic>> 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<List<Map<String, dynamic>>> getActivityLog();
}

View File

@@ -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<ClockInArguments, Map<String, dynamic>> {
final ClockInRepositoryInterface _repository;
ClockInUseCase(this._repository);
@override
Future<Map<String, dynamic>> call(ClockInArguments arguments) {
return _repository.clockIn(
shiftId: arguments.shiftId,
notes: arguments.notes,
);
}
}

View File

@@ -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<ClockOutArguments, Map<String, dynamic>> {
final ClockInRepositoryInterface _repository;
ClockOutUseCase(this._repository);
@override
Future<Map<String, dynamic>> call(ClockOutArguments arguments) {
return _repository.clockOut(
notes: arguments.notes,
breakTimeMinutes: arguments.breakTimeMinutes,
);
}
}

View File

@@ -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<List<Map<String, dynamic>>> {
final ClockInRepositoryInterface _repository;
GetActivityLogUseCase(this._repository);
@override
Future<List<Map<String, dynamic>>> call() {
return _repository.getActivityLog();
}
}

View File

@@ -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<Map<String, dynamic>> {
final ClockInRepositoryInterface _repository;
GetAttendanceStatusUseCase(this._repository);
@override
Future<Map<String, dynamic>> call() {
return _repository.getAttendanceStatus();
}
}

View File

@@ -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<Shift?> {
final ClockInRepositoryInterface _repository;
GetTodaysShiftUseCase(this._repository);
@override
Future<Shift?> call() {
return _repository.getTodaysShift();
}
}

View File

@@ -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<ClockInEvent, ClockInState> {
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<ClockInPageLoaded>(_onLoaded);
on<DateSelected>(_onDateSelected);
on<CheckInRequested>(_onCheckIn);
on<CheckOutRequested>(_onCheckOut);
on<CheckInModeChanged>(_onModeChanged);
}
AttendanceStatus _mapToStatus(Map<String, dynamic> 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<void> _onLoaded(
ClockInPageLoaded event,
Emitter<ClockInState> 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<ClockInState> emit,
) {
emit(state.copyWith(selectedDate: event.date));
}
void _onModeChanged(
CheckInModeChanged event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(checkInMode: event.mode));
}
Future<void> _onCheckIn(
CheckInRequested event,
Emitter<ClockInState> 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<void> _onCheckOut(
CheckOutRequested event,
Emitter<ClockInState> 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(),
));
}
}
}

View File

@@ -0,0 +1,48 @@
import 'package:equatable/equatable.dart';
abstract class ClockInEvent extends Equatable {
const ClockInEvent();
@override
List<Object?> get props => [];
}
class ClockInPageLoaded extends ClockInEvent {}
class DateSelected extends ClockInEvent {
final DateTime date;
const DateSelected(this.date);
@override
List<Object?> get props => [date];
}
class CheckInRequested extends ClockInEvent {
final String shiftId;
final String? notes;
const CheckInRequested({required this.shiftId, this.notes});
@override
List<Object?> get props => [shiftId, notes];
}
class CheckOutRequested extends ClockInEvent {
final String? notes;
final int? breakTimeMinutes;
const CheckOutRequested({this.notes, this.breakTimeMinutes});
@override
List<Object?> get props => [notes, breakTimeMinutes];
}
class CheckInModeChanged extends ClockInEvent {
final String mode;
const CheckInModeChanged(this.mode);
@override
List<Object?> get props => [mode];
}

View File

@@ -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<Object?> get props => [isCheckedIn, checkInTime, checkOutTime, activeShiftId];
}
class ClockInState extends Equatable {
final ClockInStatus status;
final Shift? todayShift;
final AttendanceStatus attendance;
final List<Map<String, dynamic>> 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<Map<String, dynamic>>? 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<Object?> get props => [
status,
todayShift,
attendance,
activityLog,
selectedDate,
checkInMode,
errorMessage,
];
}

View File

@@ -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<ClockInPage> createState() => _ClockInPageState();
}
class _ClockInPageState extends State<ClockInPage> {
late final ClockInBloc _bloc;
@override
void initState() {
super.initState();
_bloc = Modular.get<ClockInBloc>();
_bloc.add(ClockInPageLoaded());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _bloc,
child: BlocConsumer<ClockInBloc, ClockInState>(
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<void> _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));
}
}
}

View File

@@ -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;
}

View File

@@ -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,
});
}

View File

@@ -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<CommuteTracker> createState() => _CommuteTrackerState();
}
class _CommuteTrackerState extends State<CommuteTracker> {
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<double>(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,
),
],
),
);
}
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class DateSelector extends StatelessWidget {
final DateTime selectedDate;
final ValueChanged<DateTime> onSelect;
final List<String> 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);
}
}

View File

@@ -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<LunchBreakDialog> createState() => _LunchBreakDialogState();
}
class _LunchBreakDialogState extends State<LunchBreakDialog> {
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<String> _timeOptions = _generateTimeOptions();
final List<String> _noLunchReasons = [
'Unpredictable Workflows',
'Poor Time Management',
'Lack of coverage or short Staff',
'No Lunch Area',
'Other (Please specify)',
];
static List<String> _generateTimeOptions() {
List<String> 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<String>(
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<String>(
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<String>(
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)),
),
],
));
}
}

View File

@@ -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<SwipeToCheckIn> createState() => _SwipeToCheckInState();
}
class _SwipeToCheckInState extends State<SwipeToCheckIn>
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,
),
),
),
),
),
],
),
);
},
);
}
}

View File

@@ -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>(ShiftsRepositoryMock.new);
// Repositories
i.add<ClockInRepositoryInterface>(ClockInRepositoryImpl.new);
// Use Cases
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
i.add<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new);
i.add<ClockInUseCase>(ClockInUseCase.new);
i.add<ClockOutUseCase>(ClockOutUseCase.new);
i.add<GetActivityLogUseCase>(GetActivityLogUseCase.new);
// BLoC
i.add<ClockInBloc>(ClockInBloc.new);
}
@override
void routes(r) {
r.child('/', child: (context) => const ClockInPage());
}
}

View File

@@ -0,0 +1,4 @@
library staff_clock_in;
export 'src/staff_clock_in_module.dart';
export 'src/presentation/pages/clock_in_page.dart';

View File

@@ -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

View File

@@ -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<dynamic>(
ModuleRoute<dynamic>(
StaffMainRoutes.clockIn,
child: (BuildContext context) =>
const PlaceholderPage(title: 'Clock In'),
module: StaffClockInModule(),
),
ModuleRoute<dynamic>(
StaffMainRoutes.profile,
@@ -74,5 +74,9 @@ class StaffMainModule extends Module {
module: StaffTimeCardModule(),
);
r.module('/availability', module: StaffAvailabilityModule());
r.module(
'/clock-in',
module: StaffClockInModule(),
);
}
}

View File

@@ -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

View File

@@ -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: