feat: integrate Clock In functionality with Firebase support and refactor attendance management

This commit is contained in:
Achintha Isuru
2026-01-30 17:22:51 -05:00
parent 9038d6533e
commit 1268da45b0
16 changed files with 267 additions and 295 deletions

View File

@@ -6,7 +6,7 @@
/// Locales: 2
/// Strings: 1044 (522 per locale)
///
/// Built on 2026-01-30 at 19:58 UTC
/// Built on 2026-01-30 at 22:11 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View File

@@ -80,6 +80,8 @@ export 'src/entities/home/reorder_item.dart';
// Availability
export 'src/adapters/availability/availability_adapter.dart';
export 'src/entities/clock_in/attendance_status.dart';
export 'src/adapters/clock_in/clock_in_adapter.dart';
export 'src/entities/availability/availability_slot.dart';
export 'src/entities/availability/day_availability.dart';

View File

@@ -0,0 +1,26 @@
import '../../entities/shifts/shift.dart';
import '../../entities/clock_in/attendance_status.dart';
/// Adapter for Clock In related data.
class ClockInAdapter {
/// Converts primitive attendance data to [AttendanceStatus].
static AttendanceStatus toAttendanceStatus({
required String status,
DateTime? checkInTime,
DateTime? checkOutTime,
String? activeShiftId,
}) {
final bool isCheckedIn = status == 'CHECKED_IN' || status == 'LATE'; // Assuming LATE is also checked in?
// Statuses that imply active attendance: CHECKED_IN, LATE.
// Statuses that imply completed: CHECKED_OUT.
return AttendanceStatus(
isCheckedIn: isCheckedIn,
checkInTime: checkInTime,
checkOutTime: checkOutTime,
activeShiftId: activeShiftId,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:equatable/equatable.dart';
/// Simple entity to hold 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];
}

View File

@@ -1,95 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
import 'package:intl/intl.dart';
import '../../domain/repositories/clock_in_repository_interface.dart';
/// Implementation of [ClockInRepositoryInterface] using Mock Data.
///
/// This implementation uses hardcoded data to match the prototype UI.
class ClockInRepositoryImpl implements ClockInRepositoryInterface {
ClockInRepositoryImpl();
// Local state for the mock implementation
bool _isCheckedIn = false;
DateTime? _checkInTime;
DateTime? _checkOutTime;
@override
Future<Shift?> getTodaysShift() async {
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 500));
// 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 {
'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));
_isCheckedIn = true;
_checkInTime = DateTime.now();
return getAttendanceStatus();
}
@override
Future<Map<String, dynamic>> clockOut({String? notes, int? breakTimeMinutes}) async {
await Future.delayed(const Duration(seconds: 1));
_isCheckedIn = false;
_checkOutTime = DateTime.now();
return getAttendanceStatus();
}
@override
Future<List<Map<String, dynamic>>> getActivityLog() async {
await Future.delayed(const Duration(milliseconds: 300));
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',
},
{
'date': DateTime.now().subtract(const Duration(days: 3)),
'start': '09:00 AM',
'end': '05:00 PM',
'hours': '8h',
},
];
}
}

View File

@@ -0,0 +1,177 @@
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/clock_in_repository_interface.dart';
/// Implementation of [ClockInRepositoryInterface] using Firebase Data Connect.
class ClockInRepositoryImpl implements ClockInRepositoryInterface {
final dc.ExampleConnector _dataConnect;
final firebase.FirebaseAuth _firebaseAuth;
ClockInRepositoryImpl({
required dc.ExampleConnector dataConnect,
required firebase.FirebaseAuth firebaseAuth,
}) : _dataConnect = dataConnect,
_firebaseAuth = firebaseAuth;
Future<String> _getStaffId() async {
final firebase.User? user = _firebaseAuth.currentUser;
if (user == null) throw Exception('User not authenticated');
final QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (result.data.staffs.isEmpty) {
throw Exception('Staff profile not found');
}
return result.data.staffs.first.id;
}
/// Helper to convert Data Connect Timestamp to DateTime
DateTime? _toDateTime(dynamic t) {
if (t == null) return null;
// Attempt to use toJson assuming it matches the generated code's expectation of String
try {
// If t has toDate (e.g. cloud_firestore), usage would be t.toDate()
// But here we rely on toJson or toString
return DateTime.tryParse(t.toJson() as String);
} catch (_) {
try {
return DateTime.tryParse(t.toString());
} catch (e) {
return null;
}
}
}
/// Helper to create Timestamp from DateTime
Timestamp _fromDateTime(DateTime d) {
// Assuming Timestamp.fromJson takes an ISO string
return Timestamp.fromJson(d.toIso8601String());
}
/// Helper to find today's active application
Future<dc.GetApplicationsByStaffIdApplications?> _getTodaysApplication(String staffId) async {
final DateTime now = DateTime.now();
// Fetch recent applications (assuming meaningful limit)
final QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> result =
await _dataConnect.getApplicationsByStaffId(
staffId: staffId,
).limit(20).execute();
try {
return result.data.applications.firstWhere((dc.GetApplicationsByStaffIdApplications app) {
final DateTime? shiftTime = _toDateTime(app.shift.startTime);
if (shiftTime == null) return false;
final bool isSameDay = shiftTime.year == now.year &&
shiftTime.month == now.month &&
shiftTime.day == now.day;
if (!isSameDay) return false;
// Check Status
final dynamic status = app.status.stringValue;
return status != 'PENDING' && status != 'REJECTED' && status != 'NO_SHOW' && status != 'CANCELED';
});
} catch (e) {
return null;
}
}
@override
Future<Shift?> getTodaysShift() async {
final String staffId = await _getStaffId();
final dc.GetApplicationsByStaffIdApplications? app = await _getTodaysApplication(staffId);
if (app == null) return null;
final dc.GetApplicationsByStaffIdApplicationsShift shift = app.shift;
final QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables> shiftResult =
await _dataConnect.getShiftById(id: shift.id).execute();
if (shiftResult.data.shift == null) return null;
final dc.GetShiftByIdShift fullShift = shiftResult.data.shift!;
return Shift(
id: fullShift.id,
title: fullShift.title,
clientName: fullShift.order.business.businessName,
logoUrl: '', // Not available in GetShiftById
hourlyRate: 0.0,
location: fullShift.location ?? '',
locationAddress: fullShift.locationAddress ?? '',
date: _toDateTime(fullShift.startTime)?.toIso8601String() ?? '',
startTime: _toDateTime(fullShift.startTime)?.toIso8601String() ?? '',
endTime: _toDateTime(fullShift.endTime)?.toIso8601String() ?? '',
createdDate: _toDateTime(fullShift.createdAt)?.toIso8601String() ?? '',
status: fullShift.status?.stringValue,
description: fullShift.description,
);
}
@override
Future<AttendanceStatus> getAttendanceStatus() async {
final String staffId = await _getStaffId();
final dc.GetApplicationsByStaffIdApplications? app = await _getTodaysApplication(staffId);
if (app == null) {
return const AttendanceStatus(isCheckedIn: false);
}
return ClockInAdapter.toAttendanceStatus(
status: app.status.stringValue,
checkInTime: _toDateTime(app.checkInTime),
checkOutTime: _toDateTime(app.checkOutTime),
activeShiftId: app.shiftId,
);
}
@override
Future<AttendanceStatus> clockIn({required String shiftId, String? notes}) async {
final String staffId = await _getStaffId();
final QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> appsResult =
await _dataConnect.getApplicationsByStaffId(staffId: staffId).execute();
final dc.GetApplicationsByStaffIdApplications app = appsResult.data.applications.firstWhere((dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId);
await _dataConnect.updateApplicationStatus(
id: app.id,
roleId: app.shiftRole.id,
)
.status(dc.ApplicationStatus.CHECKED_IN)
.checkInTime(_fromDateTime(DateTime.now()))
.execute();
return getAttendanceStatus();
}
@override
Future<AttendanceStatus> clockOut({String? notes, int? breakTimeMinutes}) async {
final String staffId = await _getStaffId();
final dc.GetApplicationsByStaffIdApplications? app = await _getTodaysApplication(staffId);
if (app == null) throw Exception('No active shift found to clock out');
await _dataConnect.updateApplicationStatus(
id: app.id,
roleId: app.shiftRole.id,
)
.status(dc.ApplicationStatus.CHECKED_OUT)
.checkOutTime(_fromDateTime(DateTime.now()))
.execute();
return getAttendanceStatus();
}
@override
Future<List<Map<String, dynamic>>> getActivityLog() async {
// Placeholder as this wasn't main focus and returns raw maps
return <Map<String, dynamic>>[];
}
}

View File

@@ -1,39 +0,0 @@
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

@@ -1,35 +1,24 @@
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.
/// Repository interface for Clock In/Out functionality
abstract class ClockInRepositoryInterface {
/// Retrieves the shift assigned to the user for the current day.
/// Returns null if no shift is assigned 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();
/// 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();
/// Clocks the user in for a specific shift.
Future<Map<String, dynamic>> clockIn({
required String shiftId,
String? notes,
});
/// Checks the user in for the specified [shiftId].
/// Returns the updated [AttendanceStatus].
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
/// Clocks the user out of the current shift.
Future<Map<String, dynamic>> clockOut({
String? notes,
int? breakTimeMinutes,
});
/// Checks the user out for the currently active shift.
/// Optionally accepts [breakTimeMinutes] if tracked.
Future<AttendanceStatus> 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.
/// Retrieves a list of recent clock-in/out activities.
Future<List<Map<String, dynamic>>> getActivityLog();
}

View File

@@ -1,15 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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>> {
class ClockInUseCase implements UseCase<ClockInArguments, AttendanceStatus> {
final ClockInRepositoryInterface _repository;
ClockInUseCase(this._repository);
@override
Future<Map<String, dynamic>> call(ClockInArguments arguments) {
Future<AttendanceStatus> call(ClockInArguments arguments) {
return _repository.clockIn(
shiftId: arguments.shiftId,
notes: arguments.notes,

View File

@@ -1,15 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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>> {
class ClockOutUseCase implements UseCase<ClockOutArguments, AttendanceStatus> {
final ClockInRepositoryInterface _repository;
ClockOutUseCase(this._repository);
@override
Future<Map<String, dynamic>> call(ClockOutArguments arguments) {
Future<AttendanceStatus> call(ClockOutArguments arguments) {
return _repository.clockOut(
notes: arguments.notes,
breakTimeMinutes: arguments.breakTimeMinutes,

View File

@@ -1,14 +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 getting the current attendance status (check-in/out times).
class GetAttendanceStatusUseCase implements NoInputUseCase<Map<String, dynamic>> {
class GetAttendanceStatusUseCase implements NoInputUseCase<AttendanceStatus> {
final ClockInRepositoryInterface _repository;
GetAttendanceStatusUseCase(this._repository);
@override
Future<Map<String, dynamic>> call() {
Future<AttendanceStatus> call() {
return _repository.getAttendanceStatus();
}
}

View File

@@ -37,15 +37,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
add(ClockInPageLoaded());
}
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,
@@ -53,13 +44,13 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
emit(state.copyWith(status: ClockInStatus.loading));
try {
final shift = await _getTodaysShift();
final statusMap = await _getAttendanceStatus();
final status = await _getAttendanceStatus();
final activity = await _getActivityLog();
emit(state.copyWith(
status: ClockInStatus.success,
todayShift: shift,
attendance: _mapToStatus(statusMap),
attendance: status,
activityLog: activity,
));
} catch (e) {
@@ -90,12 +81,12 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
) async {
emit(state.copyWith(status: ClockInStatus.actionInProgress));
try {
final newStatusMap = await _clockIn(
final newStatus = await _clockIn(
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
);
emit(state.copyWith(
status: ClockInStatus.success,
attendance: _mapToStatus(newStatusMap),
attendance: newStatus,
));
} catch (e) {
emit(state.copyWith(
@@ -111,7 +102,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
) async {
emit(state.copyWith(status: ClockInStatus.actionInProgress));
try {
final newStatusMap = await _clockOut(
final newStatus = await _clockOut(
ClockOutArguments(
notes: event.notes,
breakTimeMinutes: 0, // Should be passed from event if supported
@@ -119,7 +110,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
);
emit(state.copyWith(
status: ClockInStatus.success,
attendance: _mapToStatus(newStatusMap),
attendance: newStatus,
));
} catch (e) {
emit(state.copyWith(

View File

@@ -3,24 +3,6 @@ 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;

View File

@@ -107,63 +107,6 @@ class _ClockInPageState extends State<ClockInPage> {
),
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",
// 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",
),
],
),
const SizedBox(height: 24),
// Your Activity Header
const Text(
@@ -175,39 +118,7 @@ class _ClockInPageState extends State<ClockInPage> {
color: AppColors.krowCharcoal,
),
),
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
@@ -376,12 +287,13 @@ class _ClockInPageState extends State<ClockInPage> {
] else ...[
// No Shift State
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9), // slate-100
borderRadius: BorderRadius.circular(16),
),
child: Column(
child: const Column(
children: [
const Text(
"No confirmed shifts for today",

View File

@@ -1,6 +1,8 @@
import 'package:firebase_auth/firebase_auth.dart';
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 'data/repositories_impl/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';
@@ -13,11 +15,13 @@ 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);
i.add<ClockInRepositoryInterface>(
() => ClockInRepositoryImpl(
dataConnect: ExampleConnector.instance,
firebaseAuth: FirebaseAuth.instance,
),
);
// Use Cases
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);

View File

@@ -31,3 +31,4 @@ dependencies:
firebase_data_connect: ^0.2.2+2
geolocator: ^10.1.0
permission_handler: ^11.0.1
firebase_auth: ^6.1.4