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:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -1,26 +1,35 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'data/repositories_impl/coverage_repository_impl.dart';
import 'domain/repositories/coverage_repository.dart';
import 'domain/usecases/get_coverage_stats_usecase.dart';
import 'domain/usecases/get_shifts_for_date_usecase.dart';
import 'presentation/blocs/coverage_bloc.dart';
import 'presentation/pages/coverage_page.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
import 'package:client_coverage/src/domain/usecases/submit_worker_review_usecase.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
import 'package:client_coverage/src/presentation/pages/coverage_page.dart';
/// Modular module for the coverage feature.
///
/// Uses the V2 REST API via [BaseApiService] for all backend access.
class CoverageModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<CoverageRepository>(CoverageRepositoryImpl.new);
i.addLazySingleton<CoverageRepository>(
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// Use Cases
i.addLazySingleton(GetShiftsForDateUseCase.new);
i.addLazySingleton(GetCoverageStatsUseCase.new);
i.addLazySingleton(SubmitWorkerReviewUseCase.new);
i.addLazySingleton(CancelLateWorkerUseCase.new);
// BLoCs
i.addLazySingleton<CoverageBloc>(CoverageBloc.new);
@@ -28,7 +37,9 @@ class CoverageModule extends Module {
@override
void routes(RouteManager r) {
r.child(ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
child: (_) => const CoveragePage());
r.child(
ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
child: (_) => const CoveragePage(),
);
}
}

View File

@@ -1,62 +1,89 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
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/coverage_repository.dart';
/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository].
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// V2 API implementation of [CoverageRepository].
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
/// Uses [BaseApiService] with [V2ApiEndpoints] for all backend access.
class CoverageRepositoryImpl implements CoverageRepository {
/// Creates a [CoverageRepositoryImpl].
CoverageRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
CoverageRepositoryImpl({
dc.CoverageConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getCoverageRepository(),
_service = service ?? dc.DataConnectService.instance;
final dc.CoverageConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
final BaseApiService _apiService;
@override
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getShiftsForDate(
businessId: businessId,
date: date,
Future<List<ShiftWithWorkers>> getShiftsForDate({
required DateTime date,
}) async {
final String dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientCoverage,
params: <String, dynamic>{'date': dateStr},
);
final List<dynamic> items = response.data['items'] as List<dynamic>;
return items
.map((dynamic e) =>
ShiftWithWorkers.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<CoverageStats> getCoverageStats({required DateTime date}) async {
final List<CoverageShift> shifts = await getShiftsForDate(date: date);
final int totalNeeded = shifts.fold<int>(
0,
(int sum, CoverageShift shift) => sum + shift.workersNeeded,
final String dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientCoverageStats,
params: <String, dynamic>{'date': dateStr},
);
return CoverageStats.fromJson(response.data as Map<String, dynamic>);
}
final List<CoverageWorker> allWorkers =
shifts.expand((CoverageShift shift) => shift.workers).toList();
final int totalConfirmed = allWorkers.length;
final int checkedIn = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.checkedIn)
.length;
final int enRoute = allWorkers
.where((CoverageWorker w) =>
w.status == CoverageWorkerStatus.confirmed && w.checkInTime == null)
.length;
final int late = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.late)
.length;
@override
Future<void> submitWorkerReview({
required String staffId,
required int rating,
String? assignmentId,
String? feedback,
List<String>? issueFlags,
bool? markAsFavorite,
}) async {
final Map<String, dynamic> body = <String, dynamic>{
'staffId': staffId,
'rating': rating,
};
if (assignmentId != null) {
body['assignmentId'] = assignmentId;
}
if (feedback != null) {
body['feedback'] = feedback;
}
if (issueFlags != null && issueFlags.isNotEmpty) {
body['issueFlags'] = issueFlags;
}
if (markAsFavorite != null) {
body['markAsFavorite'] = markAsFavorite;
}
await _apiService.post(
V2ApiEndpoints.clientCoverageReviews,
data: body,
);
}
return CoverageStats(
totalNeeded: totalNeeded,
totalConfirmed: totalConfirmed,
checkedIn: checkedIn,
enRoute: enRoute,
late: late,
@override
Future<void> cancelLateWorker({
required String assignmentId,
String? reason,
}) async {
final Map<String, dynamic> body = <String, dynamic>{};
if (reason != null) {
body['reason'] = reason;
}
await _apiService.post(
V2ApiEndpoints.clientCoverageCancelLateWorker(assignmentId),
data: body,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_core/core.dart';
/// Arguments for cancelling a late worker's assignment.
class CancelLateWorkerArguments extends UseCaseArgument {
/// Creates [CancelLateWorkerArguments].
const CancelLateWorkerArguments({
required this.assignmentId,
this.reason,
});
/// The assignment ID to cancel.
final String assignmentId;
/// Optional cancellation reason.
final String? reason;
@override
List<Object?> get props => <Object?>[assignmentId, reason];
}

View File

@@ -1,9 +1,6 @@
import 'package:krow_core/core.dart';
/// Arguments for fetching coverage statistics for a specific date.
///
/// This argument class encapsulates the date parameter required by
/// the [GetCoverageStatsUseCase].
class GetCoverageStatsArguments extends UseCaseArgument {
/// Creates [GetCoverageStatsArguments].
const GetCoverageStatsArguments({required this.date});

View File

@@ -1,9 +1,6 @@
import 'package:krow_core/core.dart';
/// Arguments for fetching shifts for a specific date.
///
/// This argument class encapsulates the date parameter required by
/// the [GetShiftsForDateUseCase].
class GetShiftsForDateArguments extends UseCaseArgument {
/// Creates [GetShiftsForDateArguments].
const GetShiftsForDateArguments({required this.date});

View File

@@ -0,0 +1,42 @@
import 'package:krow_core/core.dart';
/// Arguments for submitting a worker review from the coverage page.
class SubmitWorkerReviewArguments extends UseCaseArgument {
/// Creates [SubmitWorkerReviewArguments].
const SubmitWorkerReviewArguments({
required this.staffId,
required this.rating,
this.assignmentId,
this.feedback,
this.issueFlags,
this.markAsFavorite,
});
/// The ID of the worker being reviewed.
final String staffId;
/// The rating value (1-5).
final int rating;
/// The assignment ID, if reviewing for a specific assignment.
final String? assignmentId;
/// Optional text feedback.
final String? feedback;
/// Optional list of issue flag labels.
final List<String>? issueFlags;
/// Whether to mark/unmark the worker as a favorite.
final bool? markAsFavorite;
@override
List<Object?> get props => <Object?>[
staffId,
rating,
assignmentId,
feedback,
issueFlags,
markAsFavorite,
];
}

View File

@@ -2,22 +2,35 @@ import 'package:krow_domain/krow_domain.dart';
/// Repository interface for coverage-related operations.
///
/// This interface defines the contract for accessing coverage data,
/// Defines the contract for accessing coverage data via the V2 REST API,
/// acting as a boundary between the Domain and Data layers.
/// It allows the Domain layer to remain independent of specific data sources.
///
/// Implementation of this interface must delegate all data access through
/// the `packages/data_connect` layer, ensuring compliance with Clean Architecture.
abstract interface class CoverageRepository {
/// Fetches shifts for a specific date.
///
/// Returns a list of [CoverageShift] entities representing all shifts
/// scheduled for the given [date].
Future<List<CoverageShift>> getShiftsForDate({required DateTime date});
/// Fetches shifts with assigned workers for a specific [date].
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});
/// Fetches coverage statistics for a specific date.
///
/// Returns [CoverageStats] containing aggregated metrics including
/// total workers needed, confirmed, checked in, en route, and late.
/// Fetches aggregated coverage statistics for a specific [date].
Future<CoverageStats> getCoverageStats({required DateTime date});
/// Submits a worker review from the coverage page.
///
/// [staffId] identifies the worker being reviewed.
/// [rating] is an integer from 1 to 5.
/// Optional fields: [assignmentId], [feedback], [issueFlags], [markAsFavorite].
Future<void> submitWorkerReview({
required String staffId,
required int rating,
String? assignmentId,
String? feedback,
List<String>? issueFlags,
bool? markAsFavorite,
});
/// Cancels a late worker's assignment.
///
/// [assignmentId] identifies the assignment to cancel.
/// [reason] is an optional cancellation reason.
Future<void> cancelLateWorker({
required String assignmentId,
String? reason,
});
}

View File

@@ -0,0 +1,23 @@
import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for cancelling a late worker's assignment.
///
/// Delegates to [CoverageRepository] to cancel the assignment via V2 API.
class CancelLateWorkerUseCase
implements UseCase<CancelLateWorkerArguments, void> {
/// Creates a [CancelLateWorkerUseCase].
CancelLateWorkerUseCase(this._repository);
final CoverageRepository _repository;
@override
Future<void> call(CancelLateWorkerArguments arguments) {
return _repository.cancelLateWorker(
assignmentId: arguments.assignmentId,
reason: arguments.reason,
);
}
}

View File

@@ -1,20 +1,12 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/get_coverage_stats_arguments.dart';
import '../repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for fetching coverage statistics for a specific date.
/// Use case for fetching aggregated coverage statistics for a specific date.
///
/// This use case encapsulates the logic for retrieving coverage metrics including
/// total workers needed, confirmed, checked in, en route, and late.
/// It delegates the data retrieval to the [CoverageRepository].
///
/// Follows the KROW Clean Architecture pattern by:
/// - Extending from [UseCase] base class
/// - Using [GetCoverageStatsArguments] for input
/// - Returning domain entities ([CoverageStats])
/// - Delegating to repository abstraction
/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity.
class GetCoverageStatsUseCase
implements UseCase<GetCoverageStatsArguments, CoverageStats> {
/// Creates a [GetCoverageStatsUseCase].

View File

@@ -1,27 +1,21 @@
import 'package:krow_core/core.dart';
import '../arguments/get_shifts_for_date_arguments.dart';
import '../repositories/coverage_repository.dart';
import 'package:krow_domain/krow_domain.dart';
/// Use case for fetching shifts for a specific date.
import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for fetching shifts with workers for a specific date.
///
/// This use case encapsulates the logic for retrieving all shifts scheduled for a given date.
/// It delegates the data retrieval to the [CoverageRepository].
///
/// Follows the KROW Clean Architecture pattern by:
/// - Extending from [UseCase] base class
/// - Using [GetShiftsForDateArguments] for input
/// - Returning domain entities ([CoverageShift])
/// - Delegating to repository abstraction
/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities.
class GetShiftsForDateUseCase
implements UseCase<GetShiftsForDateArguments, List<CoverageShift>> {
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
/// Creates a [GetShiftsForDateUseCase].
GetShiftsForDateUseCase(this._repository);
final CoverageRepository _repository;
@override
Future<List<CoverageShift>> call(GetShiftsForDateArguments arguments) {
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {
return _repository.getShiftsForDate(date: arguments.date);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for submitting a worker review from the coverage page.
///
/// Validates the rating range and delegates to [CoverageRepository].
class SubmitWorkerReviewUseCase
implements UseCase<SubmitWorkerReviewArguments, void> {
/// Creates a [SubmitWorkerReviewUseCase].
SubmitWorkerReviewUseCase(this._repository);
final CoverageRepository _repository;
@override
Future<void> call(SubmitWorkerReviewArguments arguments) async {
if (arguments.rating < 1 || arguments.rating > 5) {
throw ArgumentError('Rating must be between 1 and 5');
}
return _repository.submitWorkerReview(
staffId: arguments.staffId,
rating: arguments.rating,
assignmentId: arguments.assignmentId,
feedback: arguments.feedback,
issueFlags: arguments.issueFlags,
markAsFavorite: arguments.markAsFavorite,
);
}
}

View File

@@ -1,35 +1,46 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/arguments/get_coverage_stats_arguments.dart';
import '../../domain/arguments/get_shifts_for_date_arguments.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_coverage_stats_usecase.dart';
import '../../domain/usecases/get_shifts_for_date_usecase.dart';
import 'coverage_event.dart';
import 'coverage_state.dart';
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
import 'package:client_coverage/src/domain/usecases/submit_worker_review_usecase.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_state.dart';
/// BLoC for managing coverage feature state.
///
/// This BLoC handles:
/// - Loading shifts for a specific date
/// - Loading coverage statistics
/// - Refreshing coverage data
/// Handles loading shifts, coverage statistics, worker reviews,
/// and late-worker cancellation for a selected date.
class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
with BlocErrorHandler<CoverageState> {
/// Creates a [CoverageBloc].
CoverageBloc({
required GetShiftsForDateUseCase getShiftsForDate,
required GetCoverageStatsUseCase getCoverageStats,
}) : _getShiftsForDate = getShiftsForDate,
required SubmitWorkerReviewUseCase submitWorkerReview,
required CancelLateWorkerUseCase cancelLateWorker,
}) : _getShiftsForDate = getShiftsForDate,
_getCoverageStats = getCoverageStats,
_submitWorkerReview = submitWorkerReview,
_cancelLateWorker = cancelLateWorker,
super(const CoverageState()) {
on<CoverageLoadRequested>(_onLoadRequested);
on<CoverageRefreshRequested>(_onRefreshRequested);
on<CoverageRepostShiftRequested>(_onRepostShiftRequested);
on<CoverageSubmitReviewRequested>(_onSubmitReviewRequested);
on<CoverageCancelLateWorkerRequested>(_onCancelLateWorkerRequested);
}
final GetShiftsForDateUseCase _getShiftsForDate;
final GetCoverageStatsUseCase _getCoverageStats;
final SubmitWorkerReviewUseCase _submitWorkerReview;
final CancelLateWorkerUseCase _cancelLateWorker;
/// Handles the load requested event.
Future<void> _onLoadRequested(
@@ -47,12 +58,15 @@ class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
emit: emit.call,
action: () async {
// Fetch shifts and stats concurrently
final List<Object> results = await Future.wait<Object>(<Future<Object>>[
_getShiftsForDate(GetShiftsForDateArguments(date: event.date)),
_getCoverageStats(GetCoverageStatsArguments(date: event.date)),
]);
final List<Object> results = await Future.wait<Object>(
<Future<Object>>[
_getShiftsForDate(GetShiftsForDateArguments(date: event.date)),
_getCoverageStats(GetCoverageStatsArguments(date: event.date)),
],
);
final List<CoverageShift> shifts = results[0] as List<CoverageShift>;
final List<ShiftWithWorkers> shifts =
results[0] as List<ShiftWithWorkers>;
final CoverageStats stats = results[1] as CoverageStats;
emit(
@@ -86,17 +100,14 @@ class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
CoverageRepostShiftRequested event,
Emitter<CoverageState> emit,
) async {
// In a real implementation, this would call a repository method.
// For this audit completion, we simulate the action and refresh the state.
emit(state.copyWith(status: CoverageStatus.loading));
await handleError(
emit: emit.call,
action: () async {
// Simulating API call delay
// TODO: Implement re-post shift via V2 API when endpoint is available.
await Future<void>.delayed(const Duration(seconds: 1));
// Since we don't have a real re-post mutation yet, we just refresh
if (state.selectedDate != null) {
add(CoverageLoadRequested(date: state.selectedDate!));
}
@@ -107,5 +118,70 @@ class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
),
);
}
}
/// Handles the submit review requested event.
Future<void> _onSubmitReviewRequested(
CoverageSubmitReviewRequested event,
Emitter<CoverageState> emit,
) async {
emit(state.copyWith(writeStatus: CoverageWriteStatus.submitting));
await handleError(
emit: emit.call,
action: () async {
await _submitWorkerReview(
SubmitWorkerReviewArguments(
staffId: event.staffId,
rating: event.rating,
assignmentId: event.assignmentId,
feedback: event.feedback,
issueFlags: event.issueFlags,
markAsFavorite: event.markAsFavorite,
),
);
emit(state.copyWith(writeStatus: CoverageWriteStatus.submitted));
// Refresh coverage data after successful review.
if (state.selectedDate != null) {
add(CoverageLoadRequested(date: state.selectedDate!));
}
},
onError: (String errorKey) => state.copyWith(
writeStatus: CoverageWriteStatus.submitFailure,
writeErrorMessage: errorKey,
),
);
}
/// Handles the cancel late worker requested event.
Future<void> _onCancelLateWorkerRequested(
CoverageCancelLateWorkerRequested event,
Emitter<CoverageState> emit,
) async {
emit(state.copyWith(writeStatus: CoverageWriteStatus.submitting));
await handleError(
emit: emit.call,
action: () async {
await _cancelLateWorker(
CancelLateWorkerArguments(
assignmentId: event.assignmentId,
reason: event.reason,
),
);
emit(state.copyWith(writeStatus: CoverageWriteStatus.submitted));
// Refresh coverage data after cancellation.
if (state.selectedDate != null) {
add(CoverageLoadRequested(date: state.selectedDate!));
}
},
onError: (String errorKey) => state.copyWith(
writeStatus: CoverageWriteStatus.submitFailure,
writeErrorMessage: errorKey,
),
);
}
}

View File

@@ -38,3 +38,62 @@ final class CoverageRepostShiftRequested extends CoverageEvent {
@override
List<Object?> get props => <Object?>[shiftId];
}
/// Event to submit a worker review.
final class CoverageSubmitReviewRequested extends CoverageEvent {
/// Creates a [CoverageSubmitReviewRequested] event.
const CoverageSubmitReviewRequested({
required this.staffId,
required this.rating,
this.assignmentId,
this.feedback,
this.issueFlags,
this.markAsFavorite,
});
/// The worker ID to review.
final String staffId;
/// Rating from 1 to 5.
final int rating;
/// Optional assignment ID for context.
final String? assignmentId;
/// Optional text feedback.
final String? feedback;
/// Optional issue flag labels.
final List<String>? issueFlags;
/// Whether to mark/unmark as favorite.
final bool? markAsFavorite;
@override
List<Object?> get props => <Object?>[
staffId,
rating,
assignmentId,
feedback,
issueFlags,
markAsFavorite,
];
}
/// Event to cancel a late worker's assignment.
final class CoverageCancelLateWorkerRequested extends CoverageEvent {
/// Creates a [CoverageCancelLateWorkerRequested] event.
const CoverageCancelLateWorkerRequested({
required this.assignmentId,
this.reason,
});
/// The assignment ID to cancel.
final String assignmentId;
/// Optional reason for cancellation.
final String? reason;
@override
List<Object?> get props => <Object?>[assignmentId, reason];
}

View File

@@ -16,15 +16,32 @@ enum CoverageStatus {
failure,
}
/// Status of a write (review / cancel) operation.
enum CoverageWriteStatus {
/// No write operation in progress.
idle,
/// A write operation is in progress.
submitting,
/// The write operation succeeded.
submitted,
/// The write operation failed.
submitFailure,
}
/// State for the coverage feature.
final class CoverageState extends Equatable {
/// Creates a [CoverageState].
const CoverageState({
this.status = CoverageStatus.initial,
this.selectedDate,
this.shifts = const <CoverageShift>[],
this.shifts = const <ShiftWithWorkers>[],
this.stats,
this.errorMessage,
this.writeStatus = CoverageWriteStatus.idle,
this.writeErrorMessage,
});
/// The current status of data loading.
@@ -33,8 +50,8 @@ final class CoverageState extends Equatable {
/// The currently selected date.
final DateTime? selectedDate;
/// The list of shifts for the selected date.
final List<CoverageShift> shifts;
/// The list of shifts with assigned workers for the selected date.
final List<ShiftWithWorkers> shifts;
/// Coverage statistics for the selected date.
final CoverageStats? stats;
@@ -42,13 +59,21 @@ final class CoverageState extends Equatable {
/// Error message if status is failure.
final String? errorMessage;
/// Status of the current write operation (review or cancel).
final CoverageWriteStatus writeStatus;
/// Error message from a failed write operation.
final String? writeErrorMessage;
/// Creates a copy of this state with the given fields replaced.
CoverageState copyWith({
CoverageStatus? status,
DateTime? selectedDate,
List<CoverageShift>? shifts,
List<ShiftWithWorkers>? shifts,
CoverageStats? stats,
String? errorMessage,
CoverageWriteStatus? writeStatus,
String? writeErrorMessage,
}) {
return CoverageState(
status: status ?? this.status,
@@ -56,6 +81,8 @@ final class CoverageState extends Equatable {
shifts: shifts ?? this.shifts,
stats: stats ?? this.stats,
errorMessage: errorMessage ?? this.errorMessage,
writeStatus: writeStatus ?? this.writeStatus,
writeErrorMessage: writeErrorMessage ?? this.writeErrorMessage,
);
}
@@ -66,5 +93,7 @@ final class CoverageState extends Equatable {
shifts,
stats,
errorMessage,
writeStatus,
writeErrorMessage,
];
}

View File

@@ -5,15 +5,15 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import '../blocs/coverage_bloc.dart';
import '../blocs/coverage_event.dart';
import '../blocs/coverage_state.dart';
import '../widgets/coverage_calendar_selector.dart';
import '../widgets/coverage_page_skeleton.dart';
import '../widgets/coverage_quick_stats.dart';
import '../widgets/coverage_shift_list.dart';
import '../widgets/coverage_stats_header.dart';
import '../widgets/late_workers_alert.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_state.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_calendar_selector.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_quick_stats.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_shift_list.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart';
import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart';
/// Page for displaying daily coverage information.
///
@@ -102,7 +102,8 @@ class _CoveragePageState extends State<CoveragePage> {
icon: Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.primaryForeground.withValues(alpha: 0.2),
color: UiColors.primaryForeground
.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusMd,
),
child: const Icon(
@@ -147,11 +148,12 @@ class _CoveragePageState extends State<CoveragePage> {
const SizedBox(height: UiConstants.space4),
CoverageStatsHeader(
coveragePercent:
(state.stats?.coveragePercent ?? 0)
(state.stats?.totalCoveragePercentage ?? 0)
.toDouble(),
totalConfirmed:
state.stats?.totalConfirmed ?? 0,
totalNeeded: state.stats?.totalNeeded ?? 0,
state.stats?.totalPositionsConfirmed ?? 0,
totalNeeded:
state.stats?.totalPositionsNeeded ?? 0,
),
],
),
@@ -207,7 +209,8 @@ class _CoveragePageState extends State<CoveragePage> {
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: context.t.client_coverage.page.retry,
onPressed: () => BlocProvider.of<CoverageBloc>(context).add(
onPressed: () =>
BlocProvider.of<CoverageBloc>(context).add(
const CoverageRefreshRequested(),
),
),
@@ -227,8 +230,11 @@ class _CoveragePageState extends State<CoveragePage> {
Column(
spacing: UiConstants.space2,
children: <Widget>[
if (state.stats != null && state.stats!.late > 0) ...<Widget>[
LateWorkersAlert(lateCount: state.stats!.late),
if (state.stats != null &&
state.stats!.totalWorkersLate > 0) ...<Widget>[
LateWorkersAlert(
lateCount: state.stats!.totalWorkersLate,
),
],
if (state.stats != null) ...<Widget>[
CoverageQuickStats(stats: state.stats!),

View File

@@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'calendar_nav_button.dart';
import 'package:client_coverage/src/presentation/widgets/calendar_nav_button.dart';
/// Calendar selector widget for choosing dates.
///

View File

@@ -1,7 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'shift_card_skeleton.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/shift_card_skeleton.dart';
/// Shimmer loading skeleton that mimics the coverage page loaded layout.
///

View File

@@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'coverage_stat_card.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_stat_card.dart';
/// Quick statistics cards showing coverage metrics.
///
@@ -27,7 +27,7 @@ class CoverageQuickStats extends StatelessWidget {
child: CoverageStatCard(
icon: UiIcons.success,
label: context.t.client_coverage.stats.checked_in,
value: stats.checkedIn.toString(),
value: stats.totalWorkersCheckedIn.toString(),
color: UiColors.iconSuccess,
),
),
@@ -35,7 +35,7 @@ class CoverageQuickStats extends StatelessWidget {
child: CoverageStatCard(
icon: UiIcons.clock,
label: context.t.client_coverage.stats.en_route,
value: stats.enRoute.toString(),
value: stats.totalWorkersEnRoute.toString(),
color: UiColors.textWarning,
),
),

View File

@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'shift_header.dart';
import 'worker_row.dart';
import 'package:client_coverage/src/presentation/widgets/shift_header.dart';
import 'package:client_coverage/src/presentation/widgets/worker_row.dart';
/// List of shifts with their workers.
///
@@ -18,20 +18,12 @@ class CoverageShiftList extends StatelessWidget {
});
/// The list of shifts to display.
final List<CoverageShift> shifts;
final List<ShiftWithWorkers> shifts;
/// Formats a time string (HH:mm) to a readable format (h:mm a).
String _formatTime(String? time) {
/// Formats a [DateTime] to a readable time string (h:mm a).
String _formatTime(DateTime? time) {
if (time == null) return '';
final List<String> parts = time.split(':');
final DateTime dt = DateTime(
2022,
1,
1,
int.parse(parts[0]),
int.parse(parts[1]),
);
return DateFormat('h:mm a').format(dt);
return DateFormat('h:mm a').format(time);
}
@override
@@ -65,7 +57,12 @@ class CoverageShiftList extends StatelessWidget {
}
return Column(
children: shifts.map((CoverageShift shift) {
children: shifts.map((ShiftWithWorkers shift) {
final int coveragePercent = shift.requiredWorkerCount > 0
? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100)
.round()
: 0;
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
@@ -77,29 +74,30 @@ class CoverageShiftList extends StatelessWidget {
child: Column(
children: <Widget>[
ShiftHeader(
title: shift.title,
location: shift.location,
startTime: _formatTime(shift.startTime),
current: shift.workers.length,
total: shift.workersNeeded,
coveragePercent: shift.coveragePercent,
shiftId: shift.id,
title: shift.roleName,
location: '', // V2 API does not return location on coverage
startTime: _formatTime(shift.timeRange.startsAt),
current: shift.assignedWorkerCount,
total: shift.requiredWorkerCount,
coveragePercent: coveragePercent,
shiftId: shift.shiftId,
),
if (shift.workers.isNotEmpty)
if (shift.assignedWorkers.isNotEmpty)
Padding(
padding: const EdgeInsets.all(UiConstants.space3),
child: Column(
children:
shift.workers.map<Widget>((CoverageWorker worker) {
final bool isLast = worker == shift.workers.last;
children: shift.assignedWorkers
.map<Widget>((AssignedWorker worker) {
final bool isLast =
worker == shift.assignedWorkers.last;
return Padding(
padding: EdgeInsets.only(
bottom: isLast ? 0 : UiConstants.space2,
),
child: WorkerRow(
worker: worker,
shiftStartTime: _formatTime(shift.startTime),
formatTime: _formatTime,
shiftStartTime:
_formatTime(shift.timeRange.startsAt),
),
);
}).toList(),

View File

@@ -1,7 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'coverage_badge.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart';
/// Header section for a shift card showing title, location, time, and coverage.
class ShiftHeader extends StatelessWidget {

View File

@@ -1,6 +1,7 @@
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';
/// Row displaying a single worker's avatar, name, status, and badge.
@@ -9,18 +10,20 @@ class WorkerRow extends StatelessWidget {
const WorkerRow({
required this.worker,
required this.shiftStartTime,
required this.formatTime,
super.key,
});
/// The worker data to display.
final CoverageWorker worker;
/// The assigned worker data to display.
final AssignedWorker worker;
/// The formatted shift start time.
final String shiftStartTime;
/// Callback to format a raw time string into a readable format.
final String Function(String?) formatTime;
/// Formats a [DateTime] to a readable time string (h:mm a).
String _formatCheckInTime(DateTime? time) {
if (time == null) return '';
return DateFormat('h:mm a').format(time);
}
@override
Widget build(BuildContext context) {
@@ -38,21 +41,21 @@ class WorkerRow extends StatelessWidget {
String badgeLabel;
switch (worker.status) {
case CoverageWorkerStatus.checkedIn:
case AssignmentStatus.checkedIn:
bg = UiColors.textSuccess.withAlpha(26);
border = UiColors.textSuccess;
textBg = UiColors.textSuccess.withAlpha(51);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = l10n.status_checked_in_at(
time: formatTime(worker.checkInTime),
time: _formatCheckInTime(worker.checkInAt),
);
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_on_site;
case CoverageWorkerStatus.confirmed:
if (worker.checkInTime == null) {
case AssignmentStatus.accepted:
if (worker.checkInAt == null) {
bg = UiColors.textWarning.withAlpha(26);
border = UiColors.textWarning;
textBg = UiColors.textWarning.withAlpha(51);
@@ -75,29 +78,7 @@ class WorkerRow extends StatelessWidget {
badgeBorder = badgeText;
badgeLabel = l10n.status_confirmed;
}
case CoverageWorkerStatus.late:
bg = UiColors.destructive.withAlpha(26);
border = UiColors.destructive;
textBg = UiColors.destructive.withAlpha(51);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = l10n.status_running_late;
badgeBg = UiColors.destructive.withAlpha(40);
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = l10n.status_late;
case CoverageWorkerStatus.checkedOut:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = l10n.status_checked_out;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = l10n.status_done;
case CoverageWorkerStatus.noShow:
case AssignmentStatus.noShow:
bg = UiColors.destructive.withAlpha(26);
border = UiColors.destructive;
textBg = UiColors.destructive.withAlpha(51);
@@ -108,7 +89,18 @@ class WorkerRow extends StatelessWidget {
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = l10n.status_no_show;
case CoverageWorkerStatus.completed:
case AssignmentStatus.checkedOut:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = l10n.status_checked_out;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = l10n.status_done;
case AssignmentStatus.completed:
bg = UiColors.iconSuccess.withAlpha(26);
border = UiColors.iconSuccess;
textBg = UiColors.iconSuccess.withAlpha(51);
@@ -119,20 +111,20 @@ class WorkerRow extends StatelessWidget {
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_completed;
case CoverageWorkerStatus.pending:
case CoverageWorkerStatus.accepted:
case CoverageWorkerStatus.rejected:
case AssignmentStatus.assigned:
case AssignmentStatus.swapRequested:
case AssignmentStatus.cancelled:
case AssignmentStatus.unknown:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.clock;
statusText = worker.status.name.toUpperCase();
statusText = worker.status.value;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = worker.status.name[0].toUpperCase() +
worker.status.name.substring(1);
badgeLabel = worker.status.value;
}
return Container(
@@ -156,7 +148,7 @@ class WorkerRow extends StatelessWidget {
child: CircleAvatar(
backgroundColor: textBg,
child: Text(
worker.name.isNotEmpty ? worker.name[0] : 'W',
worker.fullName.isNotEmpty ? worker.fullName[0] : 'W',
style: UiTypography.body1b.copyWith(
color: textColor,
),
@@ -188,7 +180,7 @@ class WorkerRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
worker.name,
worker.fullName,
style: UiTypography.body2b.copyWith(
color: UiColors.textPrimary,
),