feat: Migrate staff profile features from Data Connect to V2 REST API
- Removed data_connect package from mobile pubspec.yaml. - Added documentation for V2 profile migration status and QA findings. - Implemented new session management with ClientSessionStore and StaffSessionStore. - Created V2SessionService for handling user sessions via the V2 API. - Developed use cases for cancelling late worker assignments and submitting worker reviews. - Added arguments and use cases for payment chart retrieval and profile completion checks. - Implemented repository interfaces and their implementations for staff main and profile features. - Ensured proper error handling and validation in use cases.
This commit is contained in:
@@ -1,235 +1,99 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/clock_in_repository_interface.dart';
|
||||
import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart';
|
||||
|
||||
/// Implementation of [ClockInRepositoryInterface] using Firebase Data Connect.
|
||||
/// Implementation of [ClockInRepositoryInterface] using the V2 REST API.
|
||||
///
|
||||
/// All backend calls go through [BaseApiService] with [V2ApiEndpoints].
|
||||
/// The old Data Connect implementation has been removed.
|
||||
class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
||||
ClockInRepositoryImpl() : _service = dc.DataConnectService.instance;
|
||||
/// Creates a [ClockInRepositoryImpl] backed by the V2 API.
|
||||
ClockInRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
final Map<String, String> _shiftToApplicationId = <String, String>{};
|
||||
String? _activeApplicationId;
|
||||
|
||||
({fdc.Timestamp start, fdc.Timestamp end}) _utcDayRange(DateTime localDay) {
|
||||
final DateTime dayStartUtc = DateTime.utc(
|
||||
localDay.year,
|
||||
localDay.month,
|
||||
localDay.day,
|
||||
);
|
||||
final DateTime dayEndUtc = DateTime.utc(
|
||||
localDay.year,
|
||||
localDay.month,
|
||||
localDay.day,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
999,
|
||||
);
|
||||
return (
|
||||
start: _service.toTimestamp(dayStartUtc),
|
||||
end: _service.toTimestamp(dayEndUtc),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to find today's applications ordered with the closest at the end.
|
||||
Future<List<dc.GetApplicationsByStaffIdApplications>> _getTodaysApplications(
|
||||
String staffId,
|
||||
) async {
|
||||
final DateTime now = DateTime.now();
|
||||
final ({fdc.Timestamp start, fdc.Timestamp end}) range = _utcDayRange(now);
|
||||
final fdc.QueryResult<dc.GetApplicationsByStaffIdData,
|
||||
dc.GetApplicationsByStaffIdVariables> result = await _service.run(
|
||||
() => _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.dayStart(range.start)
|
||||
.dayEnd(range.end)
|
||||
.execute(),
|
||||
);
|
||||
|
||||
final List<dc.GetApplicationsByStaffIdApplications> apps =
|
||||
result.data.applications;
|
||||
if (apps.isEmpty) return const <dc.GetApplicationsByStaffIdApplications>[];
|
||||
|
||||
_shiftToApplicationId
|
||||
..clear()
|
||||
..addEntries(apps.map((dc.GetApplicationsByStaffIdApplications app) =>
|
||||
MapEntry<String, String>(app.shiftId, app.id)));
|
||||
|
||||
apps.sort((dc.GetApplicationsByStaffIdApplications a,
|
||||
dc.GetApplicationsByStaffIdApplications b) {
|
||||
final DateTime? aTime =
|
||||
_service.toDateTime(a.shift.startTime) ?? _service.toDateTime(a.shift.date);
|
||||
final DateTime? bTime =
|
||||
_service.toDateTime(b.shift.startTime) ?? _service.toDateTime(b.shift.date);
|
||||
if (aTime == null && bTime == null) return 0;
|
||||
if (aTime == null) return -1;
|
||||
if (bTime == null) return 1;
|
||||
final Duration aDiff = aTime.difference(now).abs();
|
||||
final Duration bDiff = bTime.difference(now).abs();
|
||||
return bDiff.compareTo(aDiff); // closest at the end
|
||||
});
|
||||
|
||||
return apps;
|
||||
}
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getTodaysShifts() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
final List<dc.GetApplicationsByStaffIdApplications> apps =
|
||||
await _getTodaysApplications(staffId);
|
||||
if (apps.isEmpty) return const <Shift>[];
|
||||
|
||||
final List<Shift> shifts = <Shift>[];
|
||||
for (final dc.GetApplicationsByStaffIdApplications app in apps) {
|
||||
final dc.GetApplicationsByStaffIdApplicationsShift shift = app.shift;
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName =
|
||||
(shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? shift.order.eventName!
|
||||
: shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
shifts.add(
|
||||
Shift(
|
||||
id: shift.id,
|
||||
title: title,
|
||||
clientName: shift.order.business.businessName,
|
||||
logoUrl: shift.order.business.companyLogoUrl ?? '',
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: shift.location ?? '',
|
||||
locationAddress: shift.order.teamHub.hubName,
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt?.toIso8601String() ?? '',
|
||||
endTime: endDt?.toIso8601String() ?? '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: shift.status?.stringValue,
|
||||
description: shift.description,
|
||||
latitude: shift.latitude,
|
||||
longitude: shift.longitude,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return shifts;
|
||||
});
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.staffClockInShiftsToday,
|
||||
);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>;
|
||||
// TODO: Ask BE to add latitude, longitude, hourlyRate, and clientName
|
||||
// to the listTodayShifts query to avoid mapping gaps and extra API calls.
|
||||
return items
|
||||
.map(
|
||||
(dynamic json) =>
|
||||
_mapTodayShiftJsonToShift(json as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> getAttendanceStatus() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
final List<dc.GetApplicationsByStaffIdApplications> apps =
|
||||
await _getTodaysApplications(staffId);
|
||||
if (apps.isEmpty) {
|
||||
return const AttendanceStatus(isCheckedIn: false);
|
||||
}
|
||||
|
||||
dc.GetApplicationsByStaffIdApplications? activeApp;
|
||||
for (final dc.GetApplicationsByStaffIdApplications app in apps) {
|
||||
if (app.checkInTime != null && app.checkOutTime == null) {
|
||||
if (activeApp == null) {
|
||||
activeApp = app;
|
||||
} else {
|
||||
final DateTime? current = _service.toDateTime(activeApp.checkInTime);
|
||||
final DateTime? next = _service.toDateTime(app.checkInTime);
|
||||
if (current == null || (next != null && next.isAfter(current))) {
|
||||
activeApp = app;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeApp == null) {
|
||||
_activeApplicationId = null;
|
||||
return const AttendanceStatus(isCheckedIn: false);
|
||||
}
|
||||
|
||||
_activeApplicationId = activeApp.id;
|
||||
|
||||
return AttendanceStatus(
|
||||
isCheckedIn: true,
|
||||
checkInTime: _service.toDateTime(activeApp.checkInTime),
|
||||
checkOutTime: _service.toDateTime(activeApp.checkOutTime),
|
||||
activeShiftId: activeApp.shiftId,
|
||||
activeApplicationId: activeApp.id,
|
||||
);
|
||||
});
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.staffClockInStatus,
|
||||
);
|
||||
return AttendanceStatus.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> clockIn({required String shiftId, String? notes}) async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final String? cachedAppId = _shiftToApplicationId[shiftId];
|
||||
dc.GetApplicationsByStaffIdApplications? app;
|
||||
if (cachedAppId != null) {
|
||||
try {
|
||||
final List<dc.GetApplicationsByStaffIdApplications> apps =
|
||||
await _getTodaysApplications(staffId);
|
||||
app = apps.firstWhere(
|
||||
(dc.GetApplicationsByStaffIdApplications a) => a.id == cachedAppId);
|
||||
} catch (_) {}
|
||||
}
|
||||
app ??= (await _getTodaysApplications(staffId)).firstWhere(
|
||||
(dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId);
|
||||
|
||||
final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now());
|
||||
|
||||
await _service.connector
|
||||
.updateApplicationStatus(
|
||||
id: app.id,
|
||||
)
|
||||
.checkInTime(checkInTs)
|
||||
.execute();
|
||||
_activeApplicationId = app.id;
|
||||
|
||||
return getAttendanceStatus();
|
||||
});
|
||||
Future<AttendanceStatus> clockIn({
|
||||
required String shiftId,
|
||||
String? notes,
|
||||
}) async {
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.staffClockIn,
|
||||
data: <String, dynamic>{
|
||||
'shiftId': shiftId,
|
||||
'sourceType': 'GEO',
|
||||
if (notes != null && notes.isNotEmpty) 'notes': notes,
|
||||
},
|
||||
);
|
||||
// Re-fetch the attendance status to get the canonical state after clock-in.
|
||||
return getAttendanceStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> clockOut({
|
||||
String? notes,
|
||||
int? breakTimeMinutes,
|
||||
String? applicationId,
|
||||
String? shiftId,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
await _service.getStaffId(); // Validate session
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.staffClockOut,
|
||||
data: <String, dynamic>{
|
||||
if (shiftId != null) 'shiftId': shiftId,
|
||||
'sourceType': 'GEO',
|
||||
if (notes != null && notes.isNotEmpty) 'notes': notes,
|
||||
if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes,
|
||||
},
|
||||
);
|
||||
// Re-fetch the attendance status to get the canonical state after clock-out.
|
||||
return getAttendanceStatus();
|
||||
}
|
||||
|
||||
final String? targetAppId = applicationId ?? _activeApplicationId;
|
||||
if (targetAppId == null || targetAppId.isEmpty) {
|
||||
throw Exception('No active application id for checkout');
|
||||
}
|
||||
final fdc.QueryResult<dc.GetApplicationByIdData,
|
||||
dc.GetApplicationByIdVariables> appResult =
|
||||
await _service.connector
|
||||
.getApplicationById(id: targetAppId)
|
||||
.execute();
|
||||
final dc.GetApplicationByIdApplication? app = appResult.data.application;
|
||||
|
||||
if (app == null) {
|
||||
throw Exception('Application not found for checkout');
|
||||
}
|
||||
if (app.checkInTime == null || app.checkOutTime != null) {
|
||||
throw Exception('No active shift found to clock out');
|
||||
}
|
||||
|
||||
await _service.connector
|
||||
.updateApplicationStatus(
|
||||
id: targetAppId,
|
||||
)
|
||||
.checkOutTime(_service.toTimestamp(DateTime.now()))
|
||||
.execute();
|
||||
|
||||
return getAttendanceStatus();
|
||||
});
|
||||
/// Maps a V2 `listTodayShifts` JSON item to the domain [Shift] entity.
|
||||
///
|
||||
/// The today-shifts endpoint returns a lightweight shape that lacks some
|
||||
/// [Shift] fields. Missing fields are defaulted:
|
||||
/// - `orderId` defaults to empty string
|
||||
/// - `latitude` / `longitude` default to null (disables geofence)
|
||||
/// - `requiredWorkers` / `assignedWorkers` default to 0
|
||||
// TODO: Ask BE to add latitude/longitude to the listTodayShifts query
|
||||
// to avoid losing geofence validation.
|
||||
static Shift _mapTodayShiftJsonToShift(Map<String, dynamic> json) {
|
||||
return Shift(
|
||||
id: json['shiftId'] as String,
|
||||
orderId: '',
|
||||
title: json['roleName'] as String? ?? '',
|
||||
status: ShiftStatus.fromJson(json['attendanceStatus'] as String?),
|
||||
startsAt: DateTime.parse(json['startTime'] as String),
|
||||
endsAt: DateTime.parse(json['endTime'] as String),
|
||||
locationName: json['location'] as String?,
|
||||
requiredWorkers: 0,
|
||||
assignedWorkers: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Represents the arguments required for the [ClockOutUseCase].
|
||||
/// Arguments required for the [ClockOutUseCase].
|
||||
class ClockOutArguments extends UseCaseArgument {
|
||||
|
||||
/// Creates a [ClockOutArguments] instance.
|
||||
const ClockOutArguments({
|
||||
this.notes,
|
||||
this.breakTimeMinutes,
|
||||
this.applicationId,
|
||||
this.shiftId,
|
||||
});
|
||||
|
||||
/// Optional notes provided by the user during clock-out.
|
||||
final String? notes;
|
||||
|
||||
/// Optional break time in minutes.
|
||||
final int? breakTimeMinutes;
|
||||
|
||||
/// Optional application id for checkout.
|
||||
final String? applicationId;
|
||||
/// The shift id used by the V2 API to resolve the assignment.
|
||||
final String? shiftId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[notes, breakTimeMinutes, applicationId];
|
||||
List<Object?> get props => <Object?>[notes, breakTimeMinutes, shiftId];
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@ abstract class ClockInRepositoryInterface {
|
||||
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
|
||||
|
||||
/// Checks the user out for the currently active shift.
|
||||
/// Optionally accepts [breakTimeMinutes] if tracked.
|
||||
///
|
||||
/// The V2 API resolves the assignment from [shiftId]. Optionally accepts
|
||||
/// [breakTimeMinutes] if tracked.
|
||||
Future<AttendanceStatus> clockOut({
|
||||
String? notes,
|
||||
int? breakTimeMinutes,
|
||||
String? applicationId,
|
||||
String? shiftId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class ClockOutUseCase implements UseCase<ClockOutArguments, AttendanceStatus> {
|
||||
return _repository.clockOut(
|
||||
notes: arguments.notes,
|
||||
breakTimeMinutes: arguments.breakTimeMinutes,
|
||||
applicationId: arguments.applicationId,
|
||||
shiftId: arguments.shiftId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +177,8 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
// Build validation context from combined BLoC states.
|
||||
final ClockInValidationContext validationContext = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: _tryParseDateTime(shift?.startTime),
|
||||
shiftEndTime: _tryParseDateTime(shift?.endTime),
|
||||
shiftStartTime: shift?.startsAt,
|
||||
shiftEndTime: shift?.endsAt,
|
||||
hasCoordinates: hasCoordinates,
|
||||
isLocationVerified: geofenceState.isLocationVerified,
|
||||
isLocationTimedOut: geofenceState.isLocationTimedOut,
|
||||
@@ -237,7 +237,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
ClockOutArguments(
|
||||
notes: event.notes,
|
||||
breakTimeMinutes: event.breakTimeMinutes ?? 0,
|
||||
applicationId: state.attendance.activeApplicationId,
|
||||
shiftId: state.attendance.activeShiftId,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
@@ -299,12 +299,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
return super.close();
|
||||
}
|
||||
|
||||
/// Safely parses a time string into a [DateTime], returning `null` on failure.
|
||||
static DateTime? _tryParseDateTime(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
|
||||
/// Computes time-window check-in/check-out flags for the given [shift].
|
||||
///
|
||||
/// Uses [TimeWindowValidator] so this business logic stays out of widgets.
|
||||
@@ -314,37 +308,33 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
}
|
||||
|
||||
const TimeWindowValidator validator = TimeWindowValidator();
|
||||
final DateTime? shiftStart = _tryParseDateTime(shift.startTime);
|
||||
final DateTime? shiftEnd = _tryParseDateTime(shift.endTime);
|
||||
final DateTime shiftStart = shift.startsAt;
|
||||
final DateTime shiftEnd = shift.endsAt;
|
||||
|
||||
// Check-in window.
|
||||
bool isCheckInAllowed = true;
|
||||
String? checkInAvailabilityTime;
|
||||
if (shiftStart != null) {
|
||||
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: shiftStart,
|
||||
);
|
||||
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
||||
if (!isCheckInAllowed) {
|
||||
checkInAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||
}
|
||||
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: shiftStart,
|
||||
);
|
||||
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
||||
if (!isCheckInAllowed) {
|
||||
checkInAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||
}
|
||||
|
||||
// Check-out window.
|
||||
bool isCheckOutAllowed = true;
|
||||
String? checkOutAvailabilityTime;
|
||||
if (shiftEnd != null) {
|
||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||
if (!isCheckOutAllowed) {
|
||||
checkOutAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||
if (!isCheckOutAllowed) {
|
||||
checkOutAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
|
||||
return _TimeWindowFlags(
|
||||
|
||||
@@ -14,7 +14,9 @@ class ClockInState extends Equatable {
|
||||
this.status = ClockInStatus.initial,
|
||||
this.todayShifts = const <Shift>[],
|
||||
this.selectedShift,
|
||||
this.attendance = const AttendanceStatus(),
|
||||
this.attendance = const AttendanceStatus(
|
||||
attendanceStatus: AttendanceStatusType.notClockedIn,
|
||||
),
|
||||
required this.selectedDate,
|
||||
this.checkInMode = 'swipe',
|
||||
this.errorMessage,
|
||||
|
||||
@@ -31,7 +31,7 @@ class ClockInActionSection extends StatelessWidget {
|
||||
const ClockInActionSection({
|
||||
required this.selectedShift,
|
||||
required this.isCheckedIn,
|
||||
required this.checkOutTime,
|
||||
required this.hasCompletedShift,
|
||||
required this.checkInMode,
|
||||
required this.isActionInProgress,
|
||||
this.hasClockinError = false,
|
||||
@@ -55,8 +55,8 @@ class ClockInActionSection extends StatelessWidget {
|
||||
/// Whether the user is currently checked in for the active shift.
|
||||
final bool isCheckedIn;
|
||||
|
||||
/// The check-out time, or null if the user has not checked out.
|
||||
final DateTime? checkOutTime;
|
||||
/// Whether the shift has been completed (clocked out).
|
||||
final bool hasCompletedShift;
|
||||
|
||||
/// The current check-in mode (e.g. "swipe" or "nfc").
|
||||
final String checkInMode;
|
||||
@@ -87,15 +87,15 @@ class ClockInActionSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (selectedShift != null && checkOutTime == null) {
|
||||
return _buildActiveShiftAction(context);
|
||||
if (selectedShift == null) {
|
||||
return const NoShiftsBanner();
|
||||
}
|
||||
|
||||
if (selectedShift != null && checkOutTime != null) {
|
||||
if (hasCompletedShift) {
|
||||
return const ShiftCompletedBanner();
|
||||
}
|
||||
|
||||
return const NoShiftsBanner();
|
||||
return _buildActiveShiftAction(context);
|
||||
}
|
||||
|
||||
/// Builds the action widget for an active (not completed) shift.
|
||||
|
||||
@@ -66,12 +66,16 @@ class _ClockInBodyState extends State<ClockInBody> {
|
||||
final String? activeShiftId = state.attendance.activeShiftId;
|
||||
final bool isActiveSelected =
|
||||
selectedShift != null && selectedShift.id == activeShiftId;
|
||||
final DateTime? checkInTime =
|
||||
isActiveSelected ? state.attendance.checkInTime : null;
|
||||
final DateTime? checkOutTime =
|
||||
isActiveSelected ? state.attendance.checkOutTime : null;
|
||||
final bool isCheckedIn =
|
||||
state.attendance.isCheckedIn && isActiveSelected;
|
||||
final DateTime? clockInAt =
|
||||
isActiveSelected ? state.attendance.clockInAt : null;
|
||||
final bool isClockedIn =
|
||||
state.attendance.isClockedIn && isActiveSelected;
|
||||
// The V2 AttendanceStatus no longer carries checkOutTime.
|
||||
// A closed session means the worker already clocked out for
|
||||
// this shift, which the UI shows via ShiftCompletedBanner.
|
||||
final bool hasCompletedShift = isActiveSelected &&
|
||||
state.attendance.attendanceStatus ==
|
||||
AttendanceStatusType.closed;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -106,8 +110,8 @@ class _ClockInBodyState extends State<ClockInBody> {
|
||||
// action section (check-in/out buttons)
|
||||
ClockInActionSection(
|
||||
selectedShift: selectedShift,
|
||||
isCheckedIn: isCheckedIn,
|
||||
checkOutTime: checkOutTime,
|
||||
isCheckedIn: isClockedIn,
|
||||
hasCompletedShift: hasCompletedShift,
|
||||
checkInMode: state.checkInMode,
|
||||
isActionInProgress:
|
||||
state.status == ClockInStatus.actionInProgress,
|
||||
@@ -119,9 +123,9 @@ class _ClockInBodyState extends State<ClockInBody> {
|
||||
),
|
||||
|
||||
// checked-in banner (only when checked in to the selected shift)
|
||||
if (isCheckedIn && checkInTime != null) ...<Widget>[
|
||||
if (isClockedIn && clockInAt != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
CheckedInBanner(checkInTime: checkInTime),
|
||||
CheckedInBanner(checkInTime: clockInAt),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
|
||||
@@ -67,21 +67,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
|
||||
|
||||
// For demo purposes, check if we're within 24 hours of shift
|
||||
final DateTime now = DateTime.now();
|
||||
DateTime shiftStart;
|
||||
try {
|
||||
// Try parsing startTime as full datetime first
|
||||
shiftStart = DateTime.parse(widget.shift!.startTime);
|
||||
} catch (_) {
|
||||
try {
|
||||
// Try parsing date as full datetime
|
||||
shiftStart = DateTime.parse(widget.shift!.date);
|
||||
} catch (_) {
|
||||
// Fall back to combining date and time
|
||||
shiftStart = DateTime.parse(
|
||||
'${widget.shift!.date} ${widget.shift!.startTime}',
|
||||
);
|
||||
}
|
||||
}
|
||||
final DateTime shiftStart = widget.shift!.startsAt;
|
||||
final int hoursUntilShift = shiftStart.difference(now).inHours;
|
||||
final bool inCommuteWindow = hoursUntilShift <= 24 && hoursUntilShift >= 0;
|
||||
|
||||
@@ -112,21 +98,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
|
||||
int _getMinutesUntilShift() {
|
||||
if (widget.shift == null) return 0;
|
||||
final DateTime now = DateTime.now();
|
||||
DateTime shiftStart;
|
||||
try {
|
||||
// Try parsing startTime as full datetime first
|
||||
shiftStart = DateTime.parse(widget.shift!.startTime);
|
||||
} catch (_) {
|
||||
try {
|
||||
// Try parsing date as full datetime
|
||||
shiftStart = DateTime.parse(widget.shift!.date);
|
||||
} catch (_) {
|
||||
// Fall back to combining date and time
|
||||
shiftStart = DateTime.parse(
|
||||
'${widget.shift!.date} ${widget.shift!.startTime}',
|
||||
);
|
||||
}
|
||||
}
|
||||
final DateTime shiftStart = widget.shift!.startsAt;
|
||||
return shiftStart.difference(now).inMinutes;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:krow_core/core.dart' show formatTime;
|
||||
|
||||
/// A selectable card that displays a single shift's summary information.
|
||||
///
|
||||
/// Shows the shift title, client/location, time range, and hourly rate.
|
||||
/// Shows the shift title, location, and time range.
|
||||
/// Highlights with a primary border when [isSelected] is true.
|
||||
class ShiftCard extends StatelessWidget {
|
||||
/// Creates a shift card for the given [shift].
|
||||
@@ -50,7 +49,7 @@ class ShiftCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(child: _ShiftDetails(shift: shift, isSelected: isSelected, i18n: i18n)),
|
||||
_ShiftTimeAndRate(shift: shift),
|
||||
_ShiftTimeRange(shift: shift),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -58,7 +57,7 @@ class ShiftCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the shift title, client name, and location on the left side.
|
||||
/// Displays the shift title and location on the left side.
|
||||
class _ShiftDetails extends StatelessWidget {
|
||||
const _ShiftDetails({
|
||||
required this.shift,
|
||||
@@ -88,8 +87,10 @@ class _ShiftDetails extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(shift.title, style: UiTypography.body2b),
|
||||
// TODO: Ask BE to add clientName to the listTodayShifts response.
|
||||
// Currently showing locationName as subtitle fallback.
|
||||
Text(
|
||||
'${shift.clientName} ${shift.location}',
|
||||
shift.locationName ?? '',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -97,30 +98,26 @@ class _ShiftDetails extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the shift time range and hourly rate on the right side.
|
||||
class _ShiftTimeAndRate extends StatelessWidget {
|
||||
const _ShiftTimeAndRate({required this.shift});
|
||||
/// Displays the shift time range on the right side.
|
||||
class _ShiftTimeRange extends StatelessWidget {
|
||||
const _ShiftTimeRange({required this.shift});
|
||||
|
||||
/// The shift whose time and rate to display.
|
||||
/// The shift whose time to display.
|
||||
final Shift shift;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
final String startFormatted = DateFormat('h:mm a').format(shift.startsAt);
|
||||
final String endFormatted = DateFormat('h:mm a').format(shift.endsAt);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${formatTime(shift.startTime)} - ${formatTime(shift.endTime)}',
|
||||
'$startFormatted - $endFormatted',
|
||||
style: UiTypography.body3m.textSecondary,
|
||||
),
|
||||
Text(
|
||||
i18n.per_hr(amount: shift.hourlyRate),
|
||||
style: UiTypography.body3m.copyWith(color: UiColors.primary),
|
||||
),
|
||||
// TODO: Ask BE to add hourlyRate to the listTodayShifts response.
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories_impl/clock_in_repository_impl.dart';
|
||||
import 'data/services/background_geofence_service.dart';
|
||||
@@ -30,8 +31,10 @@ class StaffClockInModule extends Module {
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.add<ClockInRepositoryInterface>(ClockInRepositoryImpl.new);
|
||||
// Repositories (V2 API via BaseApiService from CoreModule)
|
||||
i.add<ClockInRepositoryInterface>(
|
||||
() => ClockInRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Geofence Services (resolve core singletons from DI)
|
||||
i.add<GeofenceServiceInterface>(
|
||||
|
||||
Reference in New Issue
Block a user