feat: implement staff availability, clock-in, payments and fix UI navigation

This commit is contained in:
Suriya
2026-01-30 21:46:44 +05:30
parent 56aab9e1f6
commit ac7874c634
55 changed files with 1373 additions and 463 deletions

View File

@@ -1,93 +1,75 @@
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].
/// Implementation of [ClockInRepositoryInterface] using Mock Data.
///
/// Delegates shift data retrieval to [ShiftsRepositoryMock] and manages purely
/// local state for attendance (check-in/out) for the prototype phase.
/// This implementation uses hardcoded data to match the prototype UI.
class ClockInRepositoryImpl implements ClockInRepositoryInterface {
final ShiftsRepositoryMock _shiftsMock;
// Local state for the session (mocking backend state)
ClockInRepositoryImpl();
// Local state for the mock implementation
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;
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 500));
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,
);
}
// Mock Shift matching the prototype
return Shift(
id: '1',
title: 'Warehouse Assistant',
clientName: 'Amazon Warehouse',
logoUrl:
'https://upload.wikimedia.org/wikipedia/commons/thumb/0/06/Amazon_2024.svg/500px-Amazon_2024.svg.png',
hourlyRate: 22.50,
location: 'San Francisco, CA',
locationAddress: '123 Market St, San Francisco, CA 94105',
date: DateFormat('yyyy-MM-dd').format(DateTime.now()),
startTime: '09:00',
endTime: '17:00',
createdDate: DateTime.now().subtract(const Duration(days: 2)).toIso8601String(),
status: 'assigned',
description: 'General warehouse duties including packing and sorting.',
);
}
@override
Future<Map<String, dynamic>> getAttendanceStatus() async {
await Future.delayed(const Duration(milliseconds: 300));
return _getCurrentStatusMap();
return {
'isCheckedIn': _isCheckedIn,
'checkInTime': _checkInTime,
'checkOutTime': _checkOutTime,
'activeShiftId': '1',
};
}
@override
Future<Map<String, dynamic>> clockIn({required String shiftId, String? notes}) async {
await Future.delayed(const Duration(seconds: 1)); // Simulate network
await Future.delayed(const Duration(seconds: 1));
_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();
return getAttendanceStatus();
}
@override
Future<Map<String, dynamic>> clockOut({String? notes, int? breakTimeMinutes}) async {
await Future.delayed(const Duration(seconds: 1)); // Simulate network
await Future.delayed(const Duration(seconds: 1));
_isCheckedIn = false;
_checkOutTime = DateTime.now();
return _getCurrentStatusMap();
return getAttendanceStatus();
}
@override
Future<List<Map<String, dynamic>>> getActivityLog() async {
await Future.delayed(const Duration(milliseconds: 500));
// Mock data
await Future.delayed(const Duration(milliseconds: 300));
return [
{
'date': DateTime.now().subtract(const Duration(days: 1)),
@@ -101,15 +83,13 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
'end': '05:00 PM',
'hours': '8h',
},
{
'date': DateTime.now().subtract(const Duration(days: 3)),
'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,156 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
// --- State ---
class ClockInState extends Equatable {
final bool isLoading;
final bool isLocationVerified;
final String? error;
final Position? currentLocation;
final double? distanceFromVenue;
final bool isClockedIn;
final DateTime? clockInTime;
const ClockInState({
this.isLoading = false,
this.isLocationVerified = false,
this.error,
this.currentLocation,
this.distanceFromVenue,
this.isClockedIn = false,
this.clockInTime,
});
ClockInState copyWith({
bool? isLoading,
bool? isLocationVerified,
String? error,
Position? currentLocation,
double? distanceFromVenue,
bool? isClockedIn,
DateTime? clockInTime,
}) {
return ClockInState(
isLoading: isLoading ?? this.isLoading,
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
error: error, // Clear error if not provided
currentLocation: currentLocation ?? this.currentLocation,
distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue,
isClockedIn: isClockedIn ?? this.isClockedIn,
clockInTime: clockInTime ?? this.clockInTime,
);
}
@override
List<Object?> get props => [
isLoading,
isLocationVerified,
error,
currentLocation,
distanceFromVenue,
isClockedIn,
clockInTime,
];
}
// --- Cubit ---
class ClockInCubit extends Cubit<ClockInState> {
// Mock Venue Location (e.g., Grand Hotel, NYC)
static const double venueLat = 40.7128;
static const double venueLng = -74.0060;
static const double allowedRadiusMeters = 500; // 500m radius
ClockInCubit() : super(const ClockInState());
Future<void> checkLocationPermission() async {
emit(state.copyWith(isLoading: true, error: null));
try {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
emit(state.copyWith(
isLoading: false,
error: 'Location permissions are denied',
));
return;
}
}
if (permission == LocationPermission.deniedForever) {
emit(state.copyWith(
isLoading: false,
error: 'Location permissions are permanently denied, we cannot request permissions.',
));
return;
}
_getCurrentLocation();
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<void> _getCurrentLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
final distance = Geolocator.distanceBetween(
position.latitude,
position.longitude,
venueLat,
venueLng,
);
final isWithinRadius = distance <= allowedRadiusMeters;
emit(state.copyWith(
isLoading: false,
currentLocation: position,
distanceFromVenue: distance,
isLocationVerified: isWithinRadius,
error: isWithinRadius ? null : 'You are ${distance.toStringAsFixed(0)}m away. You must be within ${allowedRadiusMeters}m.',
));
} catch (e) {
emit(state.copyWith(isLoading: false, error: 'Failed to get location: $e'));
}
}
Future<void> clockIn() async {
if (state.currentLocation == null) {
await checkLocationPermission();
if (state.currentLocation == null) return;
}
emit(state.copyWith(isLoading: true));
await Future.delayed(const Duration(seconds: 2));
emit(state.copyWith(
isLoading: false,
isClockedIn: true,
clockInTime: DateTime.now(),
));
}
Future<void> clockOut() async {
if (state.currentLocation == null) {
await checkLocationPermission();
if (state.currentLocation == null) return;
}
emit(state.copyWith(isLoading: true));
await Future.delayed(const Duration(seconds: 2));
emit(state.copyWith(
isLoading: false,
isClockedIn: false,
clockInTime: null,
));
}
}

View File

@@ -64,6 +64,7 @@ class _ClockInPageState extends State<ClockInPage> {
: '--:-- --';
return Scaffold(
backgroundColor: Colors.transparent,
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
@@ -96,7 +97,6 @@ class _ClockInPageState extends State<ClockInPage> {
distanceMeters: 500, // Mock value for demo
etaMinutes: 8, // Mock value for demo
),
// Date Selector
DateSelector(
selectedDate: state.selectedDate,
@@ -149,12 +149,15 @@ class _ClockInPageState extends State<ClockInPage> {
AttendanceCard(
type: AttendanceType.breaks,
title: "Break Time",
// TODO: Connect to Data Connect when 'breakDuration' field is added to Shift schema.
value: "00:30 min",
subtitle: "Scheduled 00:30 min",
),
const AttendanceCard(
type: AttendanceType.days,
title: "Total Days",
// TODO: Connect to Data Connect when 'staffStats' or similar aggregation API is available.
// Currently avoided to prevent fetching full shift history for a simple count.
value: "28",
subtitle: "Working Days",
),
@@ -162,6 +165,7 @@ class _ClockInPageState extends State<ClockInPage> {
),
const SizedBox(height: 24),
// Your Activity Header
// Your Activity Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -178,15 +182,17 @@ class _ClockInPageState extends State<ClockInPage> {
onTap: () {
debugPrint('Navigating to shifts...');
},
child: const Row(
children: [
child: Row(
children: const [
Text(
"View all",
style: TextStyle(
color: AppColors.krowBlue,
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
SizedBox(width: 4),
Icon(
LucideIcons.chevronRight,
size: 16,
@@ -221,7 +227,7 @@ class _ClockInPageState extends State<ClockInPage> {
child: Row(
children: [
_buildModeTab("Swipe", LucideIcons.mapPin, 'swipe', state.checkInMode),
_buildModeTab("NFC Tap", LucideIcons.wifi, 'nfc', state.checkInMode),
// _buildModeTab("NFC Tap", LucideIcons.wifi, 'nfc', state.checkInMode),
],
),
),
@@ -467,7 +473,7 @@ class _ClockInPageState extends State<ClockInPage> {
const SizedBox(height: 16),
// Recent Activity List
...state.activityLog.map(
if (state.activityLog.isNotEmpty) ...state.activityLog.map(
(activity) => Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
@@ -530,11 +536,12 @@ class _ClockInPageState extends State<ClockInPage> {
),
),
),
],
),
const SizedBox(height: 16),
],
),
],
),
),
],
),
),
),
),

View File

@@ -24,7 +24,7 @@ class AttendanceCard extends StatelessWidget {
final styles = _getStyles(type);
return Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
@@ -39,31 +39,37 @@ class AttendanceCard extends StatelessWidget {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
width: 32,
height: 32,
decoration: BoxDecoration(
color: styles.bgColor,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(styles.icon, size: 16, color: styles.iconColor),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
Text(
title,
style: const TextStyle(
fontSize: 12,
fontSize: 11,
color: Color(0xFF64748B), // slate-500
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A), // slate-900
const SizedBox(height: 2),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A), // slate-900
),
),
),
if (scheduledTime != null) ...[

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
import 'package:lucide_icons/lucide_icons.dart';
class LocationMapPlaceholder extends StatelessWidget {
final bool isVerified;
final double? distance;
const LocationMapPlaceholder({
super.key,
required this.isVerified,
this.distance,
});
@override
Widget build(BuildContext context) {
return Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFE2E8F0),
borderRadius: BorderRadius.circular(16),
image: DecorationImage(
image: const NetworkImage(
'https://maps.googleapis.com/maps/api/staticmap?center=40.7128,-74.0060&zoom=15&size=600x300&maptype=roadmap&markers=color:red%7C40.7128,-74.0060&key=YOUR_API_KEY',
),
// In a real app with keys, this would verify visually.
// For now we use a generic placeholder color/icon to avoid broken images.
fit: BoxFit.cover,
onError: (_, __) {},
),
),
child: Stack(
children: [
// Fallback UI if image fails (which it will without key)
const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.mapPin, size: 48, color: UiColors.iconSecondary),
SizedBox(height: 8),
Text('Map View (GPS)', style: TextStyle(color: UiColors.textSecondary)),
],
),
),
// Status Overlay
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Icon(
isVerified ? LucideIcons.checkCircle : LucideIcons.alertCircle,
color: isVerified ? UiColors.textSuccess : UiColors.destructive,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isVerified ? 'Location Verified' : 'Location Check',
style: UiTypography.body1b.copyWith(color: UiColors.textPrimary),
),
if (distance != null)
Text(
'${distance!.toStringAsFixed(0)}m from venue',
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
),
],
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -106,26 +106,28 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
child: GestureDetector(
onTap: () {
setState(() {
_tookLunch = false;
_step = 102; // Go to No Lunch Reason
});
},
style: OutlinedButton.styleFrom(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
color: Colors.transparent,
),
),
child: const Text(
"No",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF121826),
alignment: Alignment.center,
child: const Text(
"No",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF121826),
),
),
),
),
@@ -180,19 +182,27 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
children: [
Expanded(
child: DropdownButtonFormField<String>(
isExpanded: true,
value: _breakStart,
items: _timeOptions.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
items: _timeOptions.map((t) => DropdownMenuItem(value: t, child: Text(t, style: const TextStyle(fontSize: 13)))).toList(),
onChanged: (v) => setState(() => _breakStart = v),
decoration: const InputDecoration(labelText: 'Start'),
decoration: const InputDecoration(
labelText: 'Start',
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
),
),
const SizedBox(width: 16),
const SizedBox(width: 10),
Expanded(
child: DropdownButtonFormField<String>(
isExpanded: true,
value: _breakEnd,
items: _timeOptions.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
items: _timeOptions.map((t) => DropdownMenuItem(value: t, child: Text(t, style: const TextStyle(fontSize: 13)))).toList(),
onChanged: (v) => setState(() => _breakEnd = v),
decoration: const InputDecoration(labelText: 'End'),
decoration: const InputDecoration(
labelText: 'End',
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
),
),
],