From 2b331e356bf9b595bc5b9a2432696885678ac7b2 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 23 Jan 2026 16:25:01 -0500 Subject: [PATCH] feat(client_coverage): add client coverage feature with user session data use case and dashboard widgets - Created `client_coverage` feature with necessary dependencies in `pubspec.yaml`. - Implemented `GetUserSessionDataUseCase` for retrieving user session data. - Developed `ClientHomeEditBanner` for edit mode instructions and reset functionality. - Added `ClientHomeHeader` to display user information and action buttons. - Built `DashboardWidgetBuilder` to render various dashboard widgets based on state. - Introduced `DraggableWidgetWrapper` for managing widget visibility and drag handles in edit mode. - Created `HeaderIconButton` for interactive header actions with optional badge support. --- apps/mobile/apps/client/pubspec.yaml | 2 + .../lib/src/mocks/home_repository_mock.dart | 11 + .../client_coverage/lib/client_coverage.dart | 2 + .../lib/src/coverage_module.dart | 33 + .../coverage_repository_impl.dart | 123 ++++ .../get_coverage_stats_arguments.dart | 16 + .../get_shifts_for_date_arguments.dart | 16 + .../repositories/coverage_repository.dart | 23 + .../domain/ui_entities/coverage_entities.dart | 133 ++++ .../usecases/get_coverage_stats_usecase.dart | 28 + .../usecases/get_shifts_for_date_usecase.dart | 27 + .../src/presentation/blocs/coverage_bloc.dart | 80 +++ .../presentation/blocs/coverage_event.dart | 28 + .../presentation/blocs/coverage_state.dart | 70 ++ .../src/presentation/pages/coverage_page.dart | 128 ++++ .../widgets/coverage_calendar_selector.dart | 185 +++++ .../presentation/widgets/coverage_header.dart | 176 +++++ .../widgets/coverage_quick_stats.dart | 112 +++ .../widgets/coverage_shift_list.dart | 433 ++++++++++++ .../widgets/late_workers_alert.dart | 60 ++ .../client/client_coverage/pubspec.lock | 650 ++++++++++++++++++ .../client/client_coverage/pubspec.yaml | 34 + .../lib/src/client_main_module.dart | 8 +- .../features/client/client_main/pubspec.yaml | 2 + .../blocs/client_main_cubit_test.dart | 3 +- .../features/client/home/lib/client_home.dart | 3 + .../home_repository_impl.dart | 9 + .../home_repository_interface.dart | 18 + .../get_user_session_data_usecase.dart | 16 + .../presentation/blocs/client_home_bloc.dart | 23 +- .../presentation/blocs/client_home_state.dart | 10 + .../navigation/client_home_navigator.dart | 30 + .../presentation/pages/client_home_page.dart | 470 ++----------- .../widgets/client_home_edit_banner.dart | 79 +++ .../widgets/client_home_header.dart | 114 +++ .../widgets/dashboard_widget_builder.dart | 119 ++++ .../widgets/draggable_widget_wrapper.dart | 95 +++ .../widgets/header_icon_button.dart | 82 +++ apps/mobile/pubspec.lock | 7 + 39 files changed, 3032 insertions(+), 426 deletions(-) create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/client_coverage.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_coverage_stats_arguments.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_shifts_for_date_arguments.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/ui_entities/coverage_entities.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_header.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/pubspec.lock create mode 100644 apps/mobile/packages/features/client/client_coverage/pubspec.yaml create mode 100644 apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart create mode 100644 apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart create mode 100644 apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart create mode 100644 apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart create mode 100644 apps/mobile/packages/features/client/home/lib/src/presentation/widgets/draggable_widget_wrapper.dart create mode 100644 apps/mobile/packages/features/client/home/lib/src/presentation/widgets/header_icon_button.dart diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index e62cb00c..f40ce950 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -24,6 +24,8 @@ dependencies: path: ../../packages/features/client/client_main client_home: path: ../../packages/features/client/home + client_coverage: + path: ../../packages/features/client/client_coverage client_settings: path: ../../packages/features/client/settings client_hubs: diff --git a/apps/mobile/packages/data_connect/lib/src/mocks/home_repository_mock.dart b/apps/mobile/packages/data_connect/lib/src/mocks/home_repository_mock.dart index 46817026..626c9fee 100644 --- a/apps/mobile/packages/data_connect/lib/src/mocks/home_repository_mock.dart +++ b/apps/mobile/packages/data_connect/lib/src/mocks/home_repository_mock.dart @@ -1,4 +1,5 @@ import 'package:krow_domain/krow_domain.dart'; +import '../session/client_session_store.dart'; /// Mock implementation of data source for Home dashboard data. /// @@ -18,4 +19,14 @@ class HomeRepositoryMock { totalFilled: 8, ); } + + /// Returns the current user's session data. + /// + /// Returns a tuple of (businessName, photoUrl). + (String, String?) getUserSession() { + final session = ClientSessionStore.instance.session; + final businessName = session?.business?.businessName ?? 'Your Company'; + final photoUrl = session?.userPhotoUrl; + return (businessName, photoUrl); + } } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/client_coverage.dart b/apps/mobile/packages/features/client/client_coverage/lib/client_coverage.dart new file mode 100644 index 00000000..65b8ce5a --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/client_coverage.dart @@ -0,0 +1,2 @@ +export 'src/coverage_module.dart'; +export 'src/presentation/pages/coverage_page.dart'; diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart new file mode 100644 index 00000000..5a155704 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart @@ -0,0 +1,33 @@ +import 'package:flutter_modular/flutter_modular.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'; + +/// Modular module for the coverage feature. +class CoverageModule extends Module { + @override + void binds(Injector i) { + // Repositories + i.addSingleton(CoverageRepositoryImpl.new); + + // Use Cases + i.addSingleton(GetShiftsForDateUseCase.new); + i.addSingleton(GetCoverageStatsUseCase.new); + + // BLoCs + i.addSingleton( + () => CoverageBloc( + getShiftsForDate: i.get(), + getCoverageStats: i.get(), + ), + ); + } + + @override + void routes(RouteManager r) { + r.child('/', child: (_) => const CoveragePage()); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart new file mode 100644 index 00000000..fdee9b3f --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart @@ -0,0 +1,123 @@ +import '../../domain/repositories/coverage_repository.dart'; +import '../../domain/ui_entities/coverage_entities.dart'; + +/// Implementation of [CoverageRepository] in the Data layer. +/// +/// This class provides mock data for the coverage feature. +/// In a production environment, this would delegate to `packages/data_connect` +/// for real data access (e.g., Firebase Data Connect, REST API). +/// +/// It strictly adheres to the Clean Architecture data layer responsibilities: +/// - No business logic (except necessary data transformation). +/// - Delegates to data sources (currently mock data, will be `data_connect`). +/// - Returns domain entities from `domain/ui_entities`. +class CoverageRepositoryImpl implements CoverageRepository { + /// Creates a [CoverageRepositoryImpl]. + CoverageRepositoryImpl(); + + /// Fetches shifts for a specific date. + @override + Future> getShiftsForDate({required DateTime date}) async { + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 500)); + + // Mock data - in production, this would come from data_connect + final DateTime today = DateTime.now(); + final bool isToday = date.year == today.year && + date.month == today.month && + date.day == today.day; + + if (!isToday) { + // Return empty list for non-today dates + return []; + } + + return [ + CoverageShift( + id: '1', + title: 'Banquet Server', + location: 'Grand Ballroom', + startTime: '16:00', + workersNeeded: 10, + date: date, + workers: const [ + CoverageWorker( + name: 'Sarah Wilson', + status: 'confirmed', + checkInTime: '15:55', + ), + CoverageWorker( + name: 'Mike Ross', + status: 'confirmed', + checkInTime: '16:00', + ), + CoverageWorker( + name: 'Jane Doe', + status: 'confirmed', + checkInTime: null, + ), + CoverageWorker( + name: 'John Smith', + status: 'late', + checkInTime: null, + ), + ], + ), + CoverageShift( + id: '2', + title: 'Bartender', + location: 'Lobby Bar', + startTime: '17:00', + workersNeeded: 4, + date: date, + workers: const [ + CoverageWorker( + name: 'Emily Blunt', + status: 'confirmed', + checkInTime: '16:45', + ), + CoverageWorker( + name: 'Chris Evans', + status: 'confirmed', + checkInTime: '16:50', + ), + CoverageWorker( + name: 'Tom Holland', + status: 'confirmed', + checkInTime: null, + ), + ], + ), + ]; + } + + /// Fetches coverage statistics for a specific date. + @override + Future getCoverageStats({required DateTime date}) async { + // Get shifts for the date + final List shifts = await getShiftsForDate(date: date); + + // Calculate statistics + final int totalNeeded = shifts.fold( + 0, + (int sum, CoverageShift shift) => sum + shift.workersNeeded, + ); + + final List allWorkers = + shifts.expand((CoverageShift shift) => shift.workers).toList(); + final int totalConfirmed = allWorkers.length; + final int checkedIn = + allWorkers.where((CoverageWorker w) => w.isCheckedIn).length; + final int enRoute = + allWorkers.where((CoverageWorker w) => w.isEnRoute).length; + final int late = allWorkers.where((CoverageWorker w) => w.isLate).length; + + return CoverageStats( + totalNeeded: totalNeeded, + totalConfirmed: totalConfirmed, + checkedIn: checkedIn, + enRoute: enRoute, + late: late, + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_coverage_stats_arguments.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_coverage_stats_arguments.dart new file mode 100644 index 00000000..105733c3 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_coverage_stats_arguments.dart @@ -0,0 +1,16 @@ +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}); + + /// The date to fetch coverage statistics for. + final DateTime date; + + @override + List get props => [date]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_shifts_for_date_arguments.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_shifts_for_date_arguments.dart new file mode 100644 index 00000000..ad71b56e --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_shifts_for_date_arguments.dart @@ -0,0 +1,16 @@ +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}); + + /// The date to fetch shifts for. + final DateTime date; + + @override + List get props => [date]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart new file mode 100644 index 00000000..6d7de8ba --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart @@ -0,0 +1,23 @@ +import '../ui_entities/coverage_entities.dart'; + +/// Repository interface for coverage-related operations. +/// +/// This interface defines the contract for accessing coverage data, +/// 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> 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. + Future getCoverageStats({required DateTime date}); +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/ui_entities/coverage_entities.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/ui_entities/coverage_entities.dart new file mode 100644 index 00000000..50758e8c --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/ui_entities/coverage_entities.dart @@ -0,0 +1,133 @@ +import 'package:equatable/equatable.dart'; + +/// Domain entity representing a shift in the coverage view. +/// +/// This is a feature-specific domain entity that encapsulates shift information +/// including scheduling details and assigned workers. +class CoverageShift extends Equatable { + /// Creates a [CoverageShift]. + const CoverageShift({ + required this.id, + required this.title, + required this.location, + required this.startTime, + required this.workersNeeded, + required this.date, + required this.workers, + }); + + /// The unique identifier for the shift. + final String id; + + /// The title or role of the shift. + final String title; + + /// The location where the shift takes place. + final String location; + + /// The start time of the shift (e.g., "16:00"). + final String startTime; + + /// The number of workers needed for this shift. + final int workersNeeded; + + /// The date of the shift. + final DateTime date; + + /// The list of workers assigned to this shift. + final List workers; + + /// Calculates the coverage percentage for this shift. + int get coveragePercent { + if (workersNeeded == 0) return 100; + return ((workers.length / workersNeeded) * 100).round(); + } + + @override + List get props => [ + id, + title, + location, + startTime, + workersNeeded, + date, + workers, + ]; +} + +/// Domain entity representing a worker in the coverage view. +/// +/// This entity tracks worker status including check-in information. +class CoverageWorker extends Equatable { + /// Creates a [CoverageWorker]. + const CoverageWorker({ + required this.name, + required this.status, + this.checkInTime, + }); + + /// The name of the worker. + final String name; + + /// The status of the worker ('confirmed', 'late', etc.). + final String status; + + /// The time the worker checked in, if applicable. + final String? checkInTime; + + /// Returns true if the worker is checked in. + bool get isCheckedIn => status == 'confirmed' && checkInTime != null; + + /// Returns true if the worker is en route. + bool get isEnRoute => status == 'confirmed' && checkInTime == null; + + /// Returns true if the worker is late. + bool get isLate => status == 'late'; + + @override + List get props => [name, status, checkInTime]; +} + +/// Domain entity representing coverage statistics. +/// +/// Aggregates coverage metrics for a specific date. +class CoverageStats extends Equatable { + /// Creates a [CoverageStats]. + const CoverageStats({ + required this.totalNeeded, + required this.totalConfirmed, + required this.checkedIn, + required this.enRoute, + required this.late, + }); + + /// The total number of workers needed. + final int totalNeeded; + + /// The total number of confirmed workers. + final int totalConfirmed; + + /// The number of workers who have checked in. + final int checkedIn; + + /// The number of workers en route. + final int enRoute; + + /// The number of late workers. + final int late; + + /// Calculates the overall coverage percentage. + int get coveragePercent { + if (totalNeeded == 0) return 100; + return ((totalConfirmed / totalNeeded) * 100).round(); + } + + @override + List get props => [ + totalNeeded, + totalConfirmed, + checkedIn, + enRoute, + late, + ]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart new file mode 100644 index 00000000..00cb7c1d --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart @@ -0,0 +1,28 @@ +import 'package:krow_core/core.dart'; +import '../arguments/get_coverage_stats_arguments.dart'; +import '../repositories/coverage_repository.dart'; +import '../ui_entities/coverage_entities.dart'; + +/// Use case for fetching 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 +class GetCoverageStatsUseCase + implements UseCase { + /// Creates a [GetCoverageStatsUseCase]. + GetCoverageStatsUseCase(this._repository); + + final CoverageRepository _repository; + + @override + Future call(GetCoverageStatsArguments arguments) { + return _repository.getCoverageStats(date: arguments.date); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart new file mode 100644 index 00000000..da84506b --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; +import '../arguments/get_shifts_for_date_arguments.dart'; +import '../repositories/coverage_repository.dart'; +import '../ui_entities/coverage_entities.dart'; + +/// Use case for fetching shifts 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 +class GetShiftsForDateUseCase + implements UseCase> { + /// Creates a [GetShiftsForDateUseCase]. + GetShiftsForDateUseCase(this._repository); + + final CoverageRepository _repository; + + @override + Future> call(GetShiftsForDateArguments arguments) { + return _repository.getShiftsForDate(date: arguments.date); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart new file mode 100644 index 00000000..d8a0a8c3 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart @@ -0,0 +1,80 @@ +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 '../../domain/ui_entities/coverage_entities.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'; + +/// BLoC for managing coverage feature state. +/// +/// This BLoC handles: +/// - Loading shifts for a specific date +/// - Loading coverage statistics +/// - Refreshing coverage data +class CoverageBloc extends Bloc { + /// Creates a [CoverageBloc]. + CoverageBloc({ + required GetShiftsForDateUseCase getShiftsForDate, + required GetCoverageStatsUseCase getCoverageStats, + }) : _getShiftsForDate = getShiftsForDate, + _getCoverageStats = getCoverageStats, + super(const CoverageState()) { + on(_onLoadRequested); + on(_onRefreshRequested); + } + + final GetShiftsForDateUseCase _getShiftsForDate; + final GetCoverageStatsUseCase _getCoverageStats; + + /// Handles the load requested event. + Future _onLoadRequested( + CoverageLoadRequested event, + Emitter emit, + ) async { + emit( + state.copyWith( + status: CoverageStatus.loading, + selectedDate: event.date, + ), + ); + + try { + // Fetch shifts and stats concurrently + final List results = await Future.wait(>[ + _getShiftsForDate(GetShiftsForDateArguments(date: event.date)), + _getCoverageStats(GetCoverageStatsArguments(date: event.date)), + ]); + + final List shifts = results[0] as List; + final CoverageStats stats = results[1] as CoverageStats; + + emit( + state.copyWith( + status: CoverageStatus.success, + shifts: shifts, + stats: stats, + ), + ); + } catch (error) { + emit( + state.copyWith( + status: CoverageStatus.failure, + errorMessage: error.toString(), + ), + ); + } + } + + /// Handles the refresh requested event. + Future _onRefreshRequested( + CoverageRefreshRequested event, + Emitter emit, + ) async { + if (state.selectedDate == null) return; + + // Reload data for the current selected date + add(CoverageLoadRequested(date: state.selectedDate!)); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart new file mode 100644 index 00000000..8df53eed --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; + +/// Base class for all coverage events. +sealed class CoverageEvent extends Equatable { + /// Creates a [CoverageEvent]. + const CoverageEvent(); + + @override + List get props => []; +} + +/// Event to load coverage data for a specific date. +final class CoverageLoadRequested extends CoverageEvent { + /// Creates a [CoverageLoadRequested] event. + const CoverageLoadRequested({required this.date}); + + /// The date to load coverage data for. + final DateTime date; + + @override + List get props => [date]; +} + +/// Event to refresh coverage data. +final class CoverageRefreshRequested extends CoverageEvent { + /// Creates a [CoverageRefreshRequested] event. + const CoverageRefreshRequested(); +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart new file mode 100644 index 00000000..9ca35dad --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart @@ -0,0 +1,70 @@ +import 'package:equatable/equatable.dart'; +import '../../domain/ui_entities/coverage_entities.dart'; + +/// Enum representing the status of coverage data loading. +enum CoverageStatus { + /// Initial state before any data is loaded. + initial, + + /// Data is currently being loaded. + loading, + + /// Data has been successfully loaded. + success, + + /// An error occurred while loading data. + failure, +} + +/// State for the coverage feature. +final class CoverageState extends Equatable { + /// Creates a [CoverageState]. + const CoverageState({ + this.status = CoverageStatus.initial, + this.selectedDate, + this.shifts = const [], + this.stats, + this.errorMessage, + }); + + /// The current status of data loading. + final CoverageStatus status; + + /// The currently selected date. + final DateTime? selectedDate; + + /// The list of shifts for the selected date. + final List shifts; + + /// Coverage statistics for the selected date. + final CoverageStats? stats; + + /// Error message if status is failure. + final String? errorMessage; + + /// Creates a copy of this state with the given fields replaced. + CoverageState copyWith({ + CoverageStatus? status, + DateTime? selectedDate, + List? shifts, + CoverageStats? stats, + String? errorMessage, + }) { + return CoverageState( + status: status ?? this.status, + selectedDate: selectedDate ?? this.selectedDate, + shifts: shifts ?? this.shifts, + stats: stats ?? this.stats, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + selectedDate, + shifts, + stats, + errorMessage, + ]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart new file mode 100644 index 00000000..441c6040 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart @@ -0,0 +1,128 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import '../blocs/coverage_bloc.dart'; +import '../blocs/coverage_event.dart'; +import '../blocs/coverage_state.dart'; + +import '../widgets/coverage_header.dart'; +import '../widgets/coverage_quick_stats.dart'; +import '../widgets/coverage_shift_list.dart'; +import '../widgets/late_workers_alert.dart'; + +/// Page for displaying daily coverage information. +/// +/// Shows shifts, worker statuses, and coverage statistics for a selected date. +class CoveragePage extends StatelessWidget { + /// Creates a [CoveragePage]. + const CoveragePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => Modular.get() + ..add(CoverageLoadRequested(date: DateTime.now())), + child: Scaffold( + backgroundColor: UiColors.background, + body: BlocBuilder( + builder: (BuildContext context, CoverageState state) { + return Column( + children: [ + CoverageHeader( + selectedDate: state.selectedDate ?? DateTime.now(), + coveragePercent: state.stats?.coveragePercent ?? 0, + totalConfirmed: state.stats?.totalConfirmed ?? 0, + totalNeeded: state.stats?.totalNeeded ?? 0, + onDateSelected: (DateTime date) { + BlocProvider.of(context).add( + CoverageLoadRequested(date: date), + ); + }, + onRefresh: () { + BlocProvider.of(context).add( + const CoverageRefreshRequested(), + ); + }, + ), + Expanded( + child: _buildBody(context: context, state: state), + ), + ], + ); + }, + ), + ), + ); + } + + /// Builds the main body content based on the current state. + Widget _buildBody({ + required BuildContext context, + required CoverageState state, + }) { + if (state.status == CoverageStatus.loading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state.status == CoverageStatus.failure) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.warning, + size: UiConstants.space12, + color: UiColors.destructive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'Failed to load coverage data', + style: UiTypography.title2m.copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: UiConstants.space2), + Text( + state.errorMessage ?? 'An unknown error occurred', + style: UiTypography.body2r.copyWith( + color: UiColors.mutedForeground, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (state.stats != null) ...[ + CoverageQuickStats(stats: state.stats!), + const SizedBox(height: UiConstants.space5), + ], + if (state.stats != null && state.stats!.late > 0) ...[ + LateWorkersAlert(lateCount: state.stats!.late), + const SizedBox(height: UiConstants.space5), + ], + Text( + 'Shifts', + style: UiTypography.title2b.copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: UiConstants.space3), + CoverageShiftList(shifts: state.shifts), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart new file mode 100644 index 00000000..a5e7787e --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart @@ -0,0 +1,185 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// Calendar selector widget for choosing dates. +/// +/// Displays a week view with navigation buttons and date selection. +class CoverageCalendarSelector extends StatefulWidget { + /// Creates a [CoverageCalendarSelector]. + const CoverageCalendarSelector({ + required this.selectedDate, + required this.onDateSelected, + super.key, + }); + + /// The currently selected date. + final DateTime selectedDate; + + /// Callback when a date is selected. + final ValueChanged onDateSelected; + + @override + State createState() => + _CoverageCalendarSelectorState(); +} + +class _CoverageCalendarSelectorState extends State { + late DateTime _today; + + @override + void initState() { + super.initState(); + _today = DateTime.now(); + _today = DateTime(_today.year, _today.month, _today.day); + } + + /// Gets the list of calendar days to display (7 days centered on selected date). + List _getCalendarDays() { + final List days = []; + final DateTime startDate = + widget.selectedDate.subtract(const Duration(days: 3)); + for (int i = 0; i < 7; i++) { + days.add(startDate.add(Duration(days: i))); + } + return days; + } + + /// Navigates to the previous week. + void _navigatePrevWeek() { + widget.onDateSelected( + widget.selectedDate.subtract(const Duration(days: 7)), + ); + } + + /// Navigates to today's date. + void _navigateToday() { + final DateTime now = DateTime.now(); + widget.onDateSelected(DateTime(now.year, now.month, now.day)); + } + + /// Navigates to the next week. + void _navigateNextWeek() { + widget.onDateSelected( + widget.selectedDate.add(const Duration(days: 7)), + ); + } + + @override + Widget build(BuildContext context) { + final List calendarDays = _getCalendarDays(); + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _NavButton( + text: '← Prev Week', + onTap: _navigatePrevWeek, + ), + _NavButton( + text: 'Today', + onTap: _navigateToday, + ), + _NavButton( + text: 'Next Week →', + onTap: _navigateNextWeek, + ), + ], + ), + const SizedBox(height: UiConstants.space2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: calendarDays.map((DateTime date) { + final bool isSelected = date.year == widget.selectedDate.year && + date.month == widget.selectedDate.month && + date.day == widget.selectedDate.day; + final bool isToday = date.year == _today.year && + date.month == _today.month && + date.day == _today.day; + + return GestureDetector( + onTap: () => widget.onDateSelected(date), + child: Container( + width: UiConstants.space10 + UiConstants.space1, + height: UiConstants.space14, + decoration: BoxDecoration( + color: isSelected + ? UiColors.primaryForeground + : UiColors.primaryForeground.withOpacity(0.1), + borderRadius: UiConstants.radiusLg, + border: isToday && !isSelected + ? Border.all( + color: UiColors.primaryForeground, + width: 2, + ) + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + date.day.toString().padLeft(2, '0'), + style: UiTypography.body1b.copyWith( + color: isSelected + ? UiColors.primary + : UiColors.primaryForeground, + ), + ), + Text( + DateFormat('E').format(date), + style: UiTypography.body4m.copyWith( + color: isSelected + ? UiColors.mutedForeground + : UiColors.primaryForeground.withOpacity(0.7), + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ); + } +} + +/// Navigation button for calendar navigation. +class _NavButton extends StatelessWidget { + /// Creates a [_NavButton]. + const _NavButton({ + required this.text, + required this.onTap, + }); + + /// The button text. + final String text; + + /// Callback when tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: UiColors.primaryForeground.withOpacity(0.2), + borderRadius: UiConstants.radiusMd, + ), + child: Text( + text, + style: UiTypography.body3r.copyWith( + color: UiColors.primaryForeground, + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_header.dart new file mode 100644 index 00000000..f1f1f5cb --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_header.dart @@ -0,0 +1,176 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'coverage_calendar_selector.dart'; + +/// Header widget for the coverage page. +/// +/// Displays: +/// - Back button and title +/// - Refresh button +/// - Calendar date selector +/// - Coverage summary statistics +class CoverageHeader extends StatelessWidget { + /// Creates a [CoverageHeader]. + const CoverageHeader({ + required this.selectedDate, + required this.coveragePercent, + required this.totalConfirmed, + required this.totalNeeded, + required this.onDateSelected, + required this.onRefresh, + super.key, + }); + + /// The currently selected date. + final DateTime selectedDate; + + /// The coverage percentage. + final int coveragePercent; + + /// The total number of confirmed workers. + final int totalConfirmed; + + /// The total number of workers needed. + final int totalNeeded; + + /// Callback when a date is selected. + final ValueChanged onDateSelected; + + /// Callback when refresh is requested. + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only( + top: UiConstants.space14, + left: UiConstants.space5, + right: UiConstants.space5, + bottom: UiConstants.space6, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.accent, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container( + width: UiConstants.space10, + height: UiConstants.space10, + decoration: BoxDecoration( + color: UiColors.primaryForeground.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.primaryForeground, + size: UiConstants.space5, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Text( + 'Daily Coverage', + style: UiTypography.title1m.copyWith( + color: UiColors.primaryForeground, + ), + ), + ], + ), + Container( + width: UiConstants.space8, + height: UiConstants.space8, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: UiConstants.radiusMd, + ), + child: IconButton( + onPressed: onRefresh, + icon: const Icon( + UiIcons.rotateCcw, + color: UiColors.primaryForeground, + size: UiConstants.space4, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: IconButton.styleFrom( + hoverColor: UiColors.primaryForeground.withOpacity(0.2), + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusMd, + ), + ), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + CoverageCalendarSelector( + selectedDate: selectedDate, + onDateSelected: onDateSelected, + ), + const SizedBox(height: UiConstants.space4), + Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.primaryForeground.withOpacity(0.1), + borderRadius: UiConstants.radiusLg, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Coverage Status', + style: UiTypography.body2r.copyWith( + color: UiColors.primaryForeground.withOpacity(0.7), + ), + ), + Text( + '$coveragePercent%', + style: UiTypography.display1b.copyWith( + color: UiColors.primaryForeground, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Workers', + style: UiTypography.body2r.copyWith( + color: UiColors.primaryForeground.withOpacity(0.7), + ), + ), + Text( + '$totalConfirmed/$totalNeeded', + style: UiTypography.title2m.copyWith( + color: UiColors.primaryForeground, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart new file mode 100644 index 00000000..56f87c69 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart @@ -0,0 +1,112 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import '../../domain/ui_entities/coverage_entities.dart'; + +/// Quick statistics cards showing coverage metrics. +/// +/// Displays checked-in, en-route, and late worker counts. +class CoverageQuickStats extends StatelessWidget { + /// Creates a [CoverageQuickStats]. + const CoverageQuickStats({ + required this.stats, + super.key, + }); + + /// The coverage statistics to display. + final CoverageStats stats; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _StatCard( + icon: UiIcons.success, + label: 'Checked In', + value: stats.checkedIn.toString(), + color: UiColors.iconSuccess, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _StatCard( + icon: UiIcons.clock, + label: 'En Route', + value: stats.enRoute.toString(), + color: UiColors.textWarning, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _StatCard( + icon: UiIcons.warning, + label: 'Late', + value: stats.late.toString(), + color: UiColors.destructive, + ), + ), + ], + ); + } +} + +/// Individual stat card widget. +class _StatCard extends StatelessWidget { + /// Creates a [_StatCard]. + const _StatCard({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + /// The icon to display. + final IconData icon; + + /// The label text. + final String label; + + /// The value to display. + final String value; + + /// The accent color for the card. + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgMenu, + borderRadius: UiConstants.radiusLg, + border: Border.all( + color: UiColors.border, + ), + ), + child: Column( + children: [ + Icon( + icon, + color: color, + size: UiConstants.space6, + ), + const SizedBox(height: UiConstants.space2), + Text( + value, + style: UiTypography.title1m.copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: UiConstants.space1), + Text( + label, + style: UiTypography.body3r.copyWith( + color: UiColors.mutedForeground, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart new file mode 100644 index 00000000..0732c389 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -0,0 +1,433 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../domain/ui_entities/coverage_entities.dart'; + +/// List of shifts with their workers. +/// +/// Displays all shifts for the selected date, or an empty state if none exist. +class CoverageShiftList extends StatelessWidget { + /// Creates a [CoverageShiftList]. + const CoverageShiftList({ + required this.shifts, + super.key, + }); + + /// The list of shifts to display. + final List shifts; + + /// Formats a time string (HH:mm) to a readable format (h:mm a). + String _formatTime(String? time) { + if (time == null) return ''; + final List 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); + } + + @override + Widget build(BuildContext context) { + if (shifts.isEmpty) { + return Container( + padding: const EdgeInsets.all(UiConstants.space8), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Column( + children: [ + const Icon( + UiIcons.users, + size: UiConstants.space12, + color: UiColors.mutedForeground, + ), + const SizedBox(height: UiConstants.space3), + Text( + 'No shifts scheduled for this day', + style: UiTypography.body2r.copyWith( + color: UiColors.mutedForeground, + ), + ), + ], + ), + ); + } + + return Column( + children: shifts.map((CoverageShift shift) { + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _ShiftHeader( + title: shift.title, + location: shift.location, + startTime: _formatTime(shift.startTime), + current: shift.workers.length, + total: shift.workersNeeded, + coveragePercent: shift.coveragePercent, + ), + if (shift.workers.isNotEmpty) + Padding( + padding: const EdgeInsets.all(UiConstants.space3), + child: Column( + children: + shift.workers.map((CoverageWorker worker) { + final bool isLast = worker == shift.workers.last; + return Padding( + padding: EdgeInsets.only( + bottom: isLast ? 0 : UiConstants.space2, + ), + child: _WorkerRow( + worker: worker, + shiftStartTime: _formatTime(shift.startTime), + formatTime: _formatTime, + ), + ); + }).toList(), + ), + ) + else + Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Text( + 'No workers assigned yet', + style: UiTypography.body3r.copyWith( + color: UiColors.mutedForeground, + ), + ), + ), + ], + ), + ); + }).toList(), + ); + } +} + +/// Header for a shift card. +class _ShiftHeader extends StatelessWidget { + /// Creates a [_ShiftHeader]. + const _ShiftHeader({ + required this.title, + required this.location, + required this.startTime, + required this.current, + required this.total, + required this.coveragePercent, + }); + + /// The shift title. + final String title; + + /// The shift location. + final String location; + + /// The shift start time. + final String startTime; + + /// Current number of workers. + final int current; + + /// Total workers needed. + final int total; + + /// Coverage percentage. + final int coveragePercent; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: const BoxDecoration( + color: UiColors.muted, + border: Border( + bottom: BorderSide( + color: UiColors.border, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: UiConstants.space2, + height: UiConstants.space2, + decoration: const BoxDecoration( + color: UiColors.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: UiConstants.space2), + Text( + title, + style: UiTypography.body1b.copyWith( + color: UiColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Text( + location, + style: UiTypography.body3r.copyWith( + color: UiColors.mutedForeground, + ), + ), + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Text( + startTime, + style: UiTypography.body3r.copyWith( + color: UiColors.mutedForeground, + ), + ), + ], + ), + ], + ), + ), + _CoverageBadge( + current: current, + total: total, + coveragePercent: coveragePercent, + ), + ], + ), + ); + } +} + +/// Coverage badge showing worker count and status. +class _CoverageBadge extends StatelessWidget { + /// Creates a [_CoverageBadge]. + const _CoverageBadge({ + required this.current, + required this.total, + required this.coveragePercent, + }); + + /// Current number of workers. + final int current; + + /// Total workers needed. + final int total; + + /// Coverage percentage. + final int coveragePercent; + + @override + Widget build(BuildContext context) { + Color bg; + Color text; + + if (coveragePercent >= 100) { + bg = UiColors.textSuccess; + text = UiColors.primaryForeground; + } else if (coveragePercent >= 80) { + bg = UiColors.textWarning; + text = UiColors.primaryForeground; + } else { + bg = UiColors.destructive; + text = UiColors.destructiveForeground; + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2 + UiConstants.space1, + vertical: UiConstants.space1 / 2, + ), + decoration: BoxDecoration( + color: bg, + borderRadius: UiConstants.radiusFull, + ), + child: Text( + '$current/$total', + style: UiTypography.body3m.copyWith( + color: text, + ), + ), + ); + } +} + +/// Row displaying a single worker's status. +class _WorkerRow extends StatelessWidget { + /// Creates a [_WorkerRow]. + const _WorkerRow({ + required this.worker, + required this.shiftStartTime, + required this.formatTime, + }); + + /// The worker to display. + final CoverageWorker worker; + + /// The shift start time. + final String shiftStartTime; + + /// Function to format time strings. + final String Function(String?) formatTime; + + @override + Widget build(BuildContext context) { + Color bg; + Color border; + Color textBg; + Color textColor; + IconData icon; + String statusText; + Color badgeBg; + Color badgeText; + String badgeLabel; + + if (worker.isCheckedIn) { + bg = UiColors.textSuccess.withOpacity(0.1); + border = UiColors.textSuccess; + textBg = UiColors.textSuccess.withOpacity(0.2); + textColor = UiColors.textSuccess; + icon = UiIcons.success; + statusText = '✓ Checked In at ${formatTime(worker.checkInTime)}'; + badgeBg = UiColors.textSuccess; + badgeText = UiColors.primaryForeground; + badgeLabel = 'On Site'; + } else if (worker.isEnRoute) { + bg = UiColors.textWarning.withOpacity(0.1); + border = UiColors.textWarning; + textBg = UiColors.textWarning.withOpacity(0.2); + textColor = UiColors.textWarning; + icon = UiIcons.clock; + statusText = 'En Route - Expected $shiftStartTime'; + badgeBg = UiColors.textWarning; + badgeText = UiColors.primaryForeground; + badgeLabel = 'En Route'; + } else { + bg = UiColors.destructive.withOpacity(0.1); + border = UiColors.destructive; + textBg = UiColors.destructive.withOpacity(0.2); + textColor = UiColors.destructive; + icon = UiIcons.warning; + statusText = '⚠ Running Late'; + badgeBg = UiColors.destructive; + badgeText = UiColors.destructiveForeground; + badgeLabel = 'Late'; + } + + return Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: BoxDecoration( + color: bg, + borderRadius: UiConstants.radiusMd, + ), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: UiConstants.space10, + height: UiConstants.space10, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: border, width: 2), + ), + child: CircleAvatar( + backgroundColor: textBg, + child: Text( + worker.name.isNotEmpty ? worker.name[0] : 'W', + style: UiTypography.body1b.copyWith( + color: textColor, + ), + ), + ), + ), + Positioned( + bottom: -2, + right: -2, + child: Container( + width: UiConstants.space4, + height: UiConstants.space4, + decoration: BoxDecoration( + color: border, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: UiConstants.space2 + UiConstants.space1, + color: UiColors.primaryForeground, + ), + ), + ), + ], + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + worker.name, + style: UiTypography.body2b.copyWith( + color: UiColors.textPrimary, + ), + ), + Text( + statusText, + style: UiTypography.body3m.copyWith( + color: textColor, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1 / 2, + ), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: UiConstants.radiusFull, + ), + child: Text( + badgeLabel, + style: UiTypography.footnote2b.copyWith( + color: badgeText, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart new file mode 100644 index 00000000..8d5f8c0a --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart @@ -0,0 +1,60 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Alert widget for displaying late workers warning. +/// +/// Shows a warning banner when there are late workers. +class LateWorkersAlert extends StatelessWidget { + /// Creates a [LateWorkersAlert]. + const LateWorkersAlert({ + required this.lateCount, + super.key, + }); + + /// The number of late workers. + final int lateCount; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.destructive.withOpacity(0.1), + borderRadius: UiConstants.radiusLg, + border: Border.all( + color: UiColors.destructive.withOpacity(0.3), + ), + ), + child: Row( + children: [ + const Icon( + UiIcons.warning, + color: UiColors.destructive, + size: UiConstants.space5, + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Late Workers Alert', + style: UiTypography.body1b.copyWith( + color: UiColors.destructive, + ), + ), + const SizedBox(height: UiConstants.space1), + Text( + '$lateCount ${lateCount == 1 ? 'worker is' : 'workers are'} running late', + style: UiTypography.body3r.copyWith( + color: UiColors.destructiveForeground, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/pubspec.lock b/apps/mobile/packages/features/client/client_coverage/pubspec.lock new file mode 100644 index 00000000..6dd6fbaf --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/pubspec.lock @@ -0,0 +1,650 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + auto_injector: + dependency: transitive + description: + name: auto_injector + sha256: "1fc2624898e92485122eb2b1698dd42511d7ff6574f84a3a8606fc4549a1e8f8" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + bloc: + dependency: transitive + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + core_localization: + dependency: "direct main" + description: + path: "../../../core_localization" + relative: true + source: path + version: "0.0.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csv: + dependency: transitive + description: + name: csv + sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c + url: "https://pub.dev" + source: hosted + version: "6.0.0" + design_system: + dependency: "direct main" + description: + path: "../../../design_system" + relative: true + source: path + version: "0.0.1" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a + url: "https://pub.dev" + source: hosted + version: "8.1.6" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_modular: + dependency: "direct main" + description: + name: flutter_modular + sha256: "33a63d9fe61429d12b3dfa04795ed890f17d179d3d38e988ba7969651fcd5586" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + font_awesome_flutter: + dependency: transitive + description: + name: font_awesome_flutter + sha256: b9011df3a1fa02993630b8fb83526368cf2206a711259830325bab2f1d2a4eb0 + url: "https://pub.dev" + source: hosted + version: "10.12.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: transitive + description: + name: google_fonts + sha256: "6996212014b996eaa17074e02b1b925b212f5e053832d9048970dc27255a8fb3" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + krow_core: + dependency: "direct main" + description: + path: "../../../core" + relative: true + source: path + version: "0.0.1" + krow_data_connect: + dependency: "direct main" + description: + path: "../../../data_connect" + relative: true + source: path + version: "0.0.1" + krow_domain: + dependency: "direct main" + description: + path: "../../../domain" + relative: true + source: path + version: "0.0.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lucide_icons: + dependency: transitive + description: + name: lucide_icons + sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 + url: "https://pub.dev" + source: hosted + version: "0.257.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + modular_core: + dependency: transitive + description: + name: modular_core + sha256: "1db0420a0dfb8a2c6dca846e7cbaa4ffeb778e247916dbcb27fb25aa566e5436" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "7fd0c4d8ac8980011753b9bdaed2bf15111365924cdeeeaeb596214ea2b03537" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + result_dart: + dependency: transitive + description: + name: result_dart + sha256: "0666b21fbdf697b3bdd9986348a380aa204b3ebe7c146d8e4cdaa7ce735e6054" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + slang: + dependency: transitive + description: + name: slang + sha256: "13e3b6f07adc51ab751e7889647774d294cbce7a3382f81d9e5029acfe9c37b2" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + slang_flutter: + dependency: transitive + description: + name: slang_flutter + sha256: "0a4545cca5404d6b7487cf61cf1fe56c52daeb08de56a7574ee8381fbad035a0" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.7 <4.0.0" + flutter: ">=3.38.4" diff --git a/apps/mobile/packages/features/client/client_coverage/pubspec.yaml b/apps/mobile/packages/features/client/client_coverage/pubspec.yaml new file mode 100644 index 00000000..35422870 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/pubspec.yaml @@ -0,0 +1,34 @@ +name: client_coverage +description: Client coverage feature for tracking daily shift coverage and worker status +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + + # Internal packages + design_system: + path: ../../../design_system + krow_domain: + path: ../../../domain + krow_core: + path: ../../../core + krow_data_connect: + path: ../../../data_connect + core_localization: + path: ../../../core_localization + + # External packages + flutter_modular: ^6.3.4 + flutter_bloc: ^8.1.6 + equatable: ^2.0.7 + intl: ^0.20.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 diff --git a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart index 8759d57c..9b4d7a67 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart @@ -1,5 +1,6 @@ import 'package:billing/billing.dart'; import 'package:client_home/client_home.dart'; +import 'package:client_coverage/client_coverage.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:view_orders/view_orders.dart'; @@ -21,12 +22,7 @@ class ClientMainModule extends Module { child: (BuildContext context) => const ClientMainPage(), children: >[ ModuleRoute('/home', module: ClientHomeModule()), - // Placeholders for other tabs - ChildRoute( - '/coverage', - child: (BuildContext context) => - const PlaceholderPage(title: 'Coverage'), - ), + ModuleRoute('/coverage', module: CoverageModule()), ModuleRoute('/billing', module: BillingModule()), ModuleRoute('/orders', module: ViewOrdersModule()), ChildRoute( diff --git a/apps/mobile/packages/features/client/client_main/pubspec.yaml b/apps/mobile/packages/features/client/client_main/pubspec.yaml index cbaaf1e9..7e1545f1 100644 --- a/apps/mobile/packages/features/client/client_main/pubspec.yaml +++ b/apps/mobile/packages/features/client/client_main/pubspec.yaml @@ -23,6 +23,8 @@ dependencies: path: ../../../core_localization client_home: path: ../home + client_coverage: + path: ../client_coverage view_orders: path: ../view_orders billing: diff --git a/apps/mobile/packages/features/client/client_main/test/presentation/blocs/client_main_cubit_test.dart b/apps/mobile/packages/features/client/client_main/test/presentation/blocs/client_main_cubit_test.dart index 6b6ecee7..1ef6ab40 100644 --- a/apps/mobile/packages/features/client/client_main/test/presentation/blocs/client_main_cubit_test.dart +++ b/apps/mobile/packages/features/client/client_main/test/presentation/blocs/client_main_cubit_test.dart @@ -1,4 +1,3 @@ -import 'package:bloc_test/bloc_test.dart'; import 'package:client_main/src/presentation/blocs/client_main_cubit.dart'; import 'package:client_main/src/presentation/blocs/client_main_state.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -27,7 +26,7 @@ void main() { }); test('initial state is correct', () { - final cubit = ClientMainCubit(); + final ClientMainCubit cubit = ClientMainCubit(); expect(cubit.state, const ClientMainState(currentIndex: 2)); cubit.close(); }); diff --git a/apps/mobile/packages/features/client/home/lib/client_home.dart b/apps/mobile/packages/features/client/home/lib/client_home.dart index f5b0364f..b6ca088d 100644 --- a/apps/mobile/packages/features/client/home/lib/client_home.dart +++ b/apps/mobile/packages/features/client/home/lib/client_home.dart @@ -5,6 +5,7 @@ import 'package:krow_data_connect/krow_data_connect.dart'; import 'src/data/repositories_impl/home_repository_impl.dart'; import 'src/domain/repositories/home_repository_interface.dart'; import 'src/domain/usecases/get_dashboard_data_usecase.dart'; +import 'src/domain/usecases/get_user_session_data_usecase.dart'; import 'src/presentation/blocs/client_home_bloc.dart'; import 'src/presentation/pages/client_home_page.dart'; @@ -28,11 +29,13 @@ class ClientHomeModule extends Module { // UseCases i.addLazySingleton(GetDashboardDataUseCase.new); + i.addLazySingleton(GetUserSessionDataUseCase.new); // BLoCs i.add( () => ClientHomeBloc( getDashboardDataUseCase: i.get(), + getUserSessionDataUseCase: i.get(), ), ); } diff --git a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart index c68a09de..23c5ff01 100644 --- a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart +++ b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart @@ -18,4 +18,13 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { Future getDashboardData() { return _mock.getDashboardData(); } + + @override + UserSessionData getUserSessionData() { + final (businessName, photoUrl) = _mock.getUserSession(); + return UserSessionData( + businessName: businessName, + photoUrl: photoUrl, + ); + } } diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart b/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart index ad8b1ff3..2c8fe27a 100644 --- a/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart +++ b/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart @@ -1,5 +1,20 @@ import 'package:krow_domain/krow_domain.dart'; +/// User session data for the home page. +class UserSessionData { + /// The business name of the logged-in user. + final String businessName; + + /// The photo URL of the logged-in user (optional). + final String? photoUrl; + + /// Creates a [UserSessionData]. + const UserSessionData({ + required this.businessName, + this.photoUrl, + }); +} + /// Interface for the Client Home repository. /// /// This repository is responsible for providing data required for the @@ -7,4 +22,7 @@ import 'package:krow_domain/krow_domain.dart'; abstract interface class HomeRepositoryInterface { /// Fetches the [HomeDashboardData] containing aggregated dashboard metrics. Future getDashboardData(); + + /// Fetches the user's session data (business name and photo). + UserSessionData getUserSessionData(); } diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart new file mode 100644 index 00000000..9710b727 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart @@ -0,0 +1,16 @@ +import '../repositories/home_repository_interface.dart'; + +/// Use case for retrieving user session data. +/// +/// Returns the user's business name and photo URL for display in the header. +class GetUserSessionDataUseCase { + final HomeRepositoryInterface _repository; + + /// Creates a [GetUserSessionDataUseCase]. + GetUserSessionDataUseCase(this._repository); + + /// Executes the use case to get session data. + UserSessionData call() { + return _repository.getUserSessionData(); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart index fe34944e..3fff1716 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart @@ -1,15 +1,20 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../domain/usecases/get_dashboard_data_usecase.dart'; +import '../../domain/usecases/get_user_session_data_usecase.dart'; import 'client_home_event.dart'; import 'client_home_state.dart'; /// BLoC responsible for managing the state and business logic of the client home dashboard. class ClientHomeBloc extends Bloc { final GetDashboardDataUseCase _getDashboardDataUseCase; + final GetUserSessionDataUseCase _getUserSessionDataUseCase; - ClientHomeBloc({required GetDashboardDataUseCase getDashboardDataUseCase}) - : _getDashboardDataUseCase = getDashboardDataUseCase, - super(const ClientHomeState()) { + ClientHomeBloc({ + required GetDashboardDataUseCase getDashboardDataUseCase, + required GetUserSessionDataUseCase getUserSessionDataUseCase, + }) : _getDashboardDataUseCase = getDashboardDataUseCase, + _getUserSessionDataUseCase = getUserSessionDataUseCase, + super(const ClientHomeState()) { on(_onStarted); on(_onEditModeToggled); on(_onWidgetVisibilityToggled); @@ -23,9 +28,19 @@ class ClientHomeBloc extends Bloc { ) async { emit(state.copyWith(status: ClientHomeStatus.loading)); try { + // Get session data + final sessionData = _getUserSessionDataUseCase(); + + // Get dashboard data final data = await _getDashboardDataUseCase(); + emit( - state.copyWith(status: ClientHomeStatus.success, dashboardData: data), + state.copyWith( + status: ClientHomeStatus.success, + dashboardData: data, + businessName: sessionData.businessName, + photoUrl: sessionData.photoUrl, + ), ); } catch (e) { emit( diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_state.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_state.dart index fb79c18d..a73f7966 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_state.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_state.dart @@ -12,6 +12,8 @@ class ClientHomeState extends Equatable { final bool isEditMode; final String? errorMessage; final HomeDashboardData dashboardData; + final String businessName; + final String? photoUrl; const ClientHomeState({ this.status = ClientHomeStatus.initial, @@ -39,6 +41,8 @@ class ClientHomeState extends Equatable { totalNeeded: 10, totalFilled: 8, ), + this.businessName = 'Your Company', + this.photoUrl, }); ClientHomeState copyWith({ @@ -48,6 +52,8 @@ class ClientHomeState extends Equatable { bool? isEditMode, String? errorMessage, HomeDashboardData? dashboardData, + String? businessName, + String? photoUrl, }) { return ClientHomeState( status: status ?? this.status, @@ -56,6 +62,8 @@ class ClientHomeState extends Equatable { isEditMode: isEditMode ?? this.isEditMode, errorMessage: errorMessage ?? this.errorMessage, dashboardData: dashboardData ?? this.dashboardData, + businessName: businessName ?? this.businessName, + photoUrl: photoUrl ?? this.photoUrl, ); } @@ -67,5 +75,7 @@ class ClientHomeState extends Equatable { isEditMode, errorMessage, dashboardData, + businessName, + photoUrl, ]; } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/navigation/client_home_navigator.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/navigation/client_home_navigator.dart index 682ec32a..97ab786e 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/navigation/client_home_navigator.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/navigation/client_home_navigator.dart @@ -1,4 +1,6 @@ +import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import '../widgets/shift_order_form_sheet.dart'; extension ClientHomeNavigator on IModularNavigator { void pushSettings() { @@ -13,3 +15,31 @@ extension ClientHomeNavigator on IModularNavigator { pushNamed('/client/create-order/rapid'); } } + +/// Helper class for showing modal sheets in the client home feature. +class ClientHomeSheets { + /// Shows the shift order form bottom sheet. + /// + /// Optionally accepts [initialData] to pre-populate the form for reordering. + /// Calls [onSubmit] when the user submits the form successfully. + static void showOrderFormSheet( + BuildContext context, + Map? initialData, { + required void Function(Map) onSubmit, + }) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return ShiftOrderFormSheet( + initialData: initialData, + onSubmit: (data) { + Navigator.pop(context); + onSubmit(data); + }, + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart index 32c698d8..78189e06 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart @@ -3,43 +3,22 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; import '../blocs/client_home_bloc.dart'; import '../blocs/client_home_event.dart'; import '../blocs/client_home_state.dart'; -import '../navigation/client_home_navigator.dart'; -import '../widgets/actions_widget.dart'; -import '../widgets/coverage_widget.dart'; -import '../widgets/live_activity_widget.dart'; -import '../widgets/reorder_widget.dart'; -import '../widgets/shift_order_form_sheet.dart'; -import '../widgets/spending_widget.dart'; +import '../widgets/client_home_edit_banner.dart'; +import '../widgets/client_home_header.dart'; +import '../widgets/dashboard_widget_builder.dart'; /// The main Home page for client users. +/// +/// This page displays a customizable dashboard with various widgets that can be +/// reordered and toggled on/off through edit mode. class ClientHomePage extends StatelessWidget { /// Creates a [ClientHomePage]. const ClientHomePage({super.key}); - void _openOrderFormSheet( - BuildContext context, - Map? shiftData, - ) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) { - return ShiftOrderFormSheet( - initialData: shiftData, - onSubmit: (data) { - Navigator.pop(context); - }, - ); - }, - ); - } - @override Widget build(BuildContext context) { final i18n = t.client_home; @@ -51,59 +30,15 @@ class ClientHomePage extends StatelessWidget { body: SafeArea( child: Column( children: [ - _buildHeader(context, i18n), - _buildEditModeBanner(i18n), + ClientHomeHeader(i18n: i18n), + ClientHomeEditBanner(i18n: i18n), Flexible( child: BlocBuilder( builder: (context, state) { if (state.isEditMode) { - return ReorderableListView( - padding: const EdgeInsets.fromLTRB( - UiConstants.space4, - 0, - UiConstants.space4, - 100, - ), - onReorder: (oldIndex, newIndex) { - BlocProvider.of( - context, - ).add(ClientHomeWidgetReordered(oldIndex, newIndex)); - }, - children: state.widgetOrder.map((id) { - return Container( - key: ValueKey(id), - margin: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - child: _buildDraggableWidgetWrapper( - context, - id, - state, - ), - ); - }).toList(), - ); + return _buildEditModeList(context, state); } - - return ListView( - padding: const EdgeInsets.fromLTRB( - UiConstants.space4, - 0, - UiConstants.space4, - 100, - ), - children: state.widgetOrder.map((id) { - if (!(state.widgetVisibility[id] ?? true)) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - child: _buildWidgetContent(context, id, state), - ); - }).toList(), - ); + return _buildNormalModeList(state); }, ), ), @@ -114,346 +49,53 @@ class ClientHomePage extends StatelessWidget { ); } - Widget _buildHeader(BuildContext context, dynamic i18n) { - return BlocBuilder( - builder: (context, state) { - final session = dc.ClientSessionStore.instance.session; - final businessName = - session?.business?.businessName ?? 'Your Company'; - final photoUrl = session?.userPhotoUrl; - final avatarLetter = businessName.trim().isNotEmpty - ? businessName.trim()[0].toUpperCase() - : 'C'; - - return Padding( - padding: const EdgeInsets.fromLTRB( - UiConstants.space4, - UiConstants.space4, - UiConstants.space4, - UiConstants.space3, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: UiColors.primary.withValues(alpha: 0.2), - width: 2, - ), - ), - child: CircleAvatar( - backgroundColor: UiColors.primary.withValues(alpha: 0.1), - backgroundImage: - photoUrl != null && photoUrl.isNotEmpty - ? NetworkImage(photoUrl) - : null, - child: - photoUrl != null && photoUrl.isNotEmpty - ? null - : Text( - avatarLetter, - style: UiTypography.body2b.copyWith( - color: UiColors.primary, - ), - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.dashboard.welcome_back, - style: UiTypography.footnote2r.textSecondary, - ), - Text(businessName, style: UiTypography.body1b), - ], - ), - ], - ), - Row( - children: [ - _HeaderIconButton( - icon: UiIcons.edit, - isActive: state.isEditMode, - onTap: () => BlocProvider.of( - context, - ).add(ClientHomeEditModeToggled()), - ), - const SizedBox(width: UiConstants.space2), - _HeaderIconButton( - icon: UiIcons.bell, - badgeText: '3', - onTap: () {}, - ), - const SizedBox(width: UiConstants.space2), - _HeaderIconButton( - icon: UiIcons.settings, - onTap: () => Modular.to.pushSettings(), - ), - ], - ), - ], - ), - ); - }, - ); - } - - Widget _buildEditModeBanner(dynamic i18n) { - return BlocBuilder( - buildWhen: (prev, curr) => prev.isEditMode != curr.isEditMode, - builder: (context, state) { - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: state.isEditMode ? 76 : 0, - clipBehavior: Clip.antiAlias, - margin: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: UiConstants.space2, - ), - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.1), - border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)), - borderRadius: UiConstants.radiusLg, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Icon(UiIcons.edit, size: 16, color: UiColors.primary), - const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - i18n.dashboard.edit_mode_active, - style: UiTypography.footnote1b.copyWith( - color: UiColors.primary, - ), - ), - Text( - i18n.dashboard.drag_instruction, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - UiButton.secondary( - text: i18n.dashboard.reset, - onPressed: () => BlocProvider.of( - context, - ).add(ClientHomeLayoutReset()), - size: UiButtonSize.small, - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, 48), - maximumSize: const Size(double.infinity, 48), - ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildDraggableWidgetWrapper( - BuildContext context, - String id, - ClientHomeState state, - ) { - final isVisible = state.widgetVisibility[id] ?? true; - final title = _getWidgetTitle(id); - - return Column( - spacing: UiConstants.space2, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - const Icon( - UiIcons.gripVertical, - size: 14, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space2), - Text(title, style: UiTypography.footnote1m), - ], - ), - ), - const SizedBox(width: UiConstants.space2), - GestureDetector( - onTap: () => BlocProvider.of( - context, - ).add(ClientHomeWidgetVisibilityToggled(id)), - child: Container( - padding: const EdgeInsets.all(UiConstants.space1), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: Icon( - isVisible ? UiIcons.success : UiIcons.error, - size: 14, - color: isVisible ? UiColors.primary : UiColors.iconSecondary, - ), - ), - ), - ], - ), - - // Widget content - Opacity( - opacity: isVisible ? 1.0 : 0.4, - child: IgnorePointer( - ignoring: !isVisible, - child: _buildWidgetContent(context, id, state), - ), - ), - ], - ); - } - - Widget _buildWidgetContent( - BuildContext context, - String id, - ClientHomeState state, - ) { - switch (id) { - case 'actions': - return ActionsWidget( - onRapidPressed: () => Modular.to.pushRapidOrder(), - onCreateOrderPressed: () => Modular.to.pushCreateOrder(), - ); - case 'reorder': - return ReorderWidget( - onReorderPressed: (data) => _openOrderFormSheet(context, data), - ); - case 'spending': - return SpendingWidget( - weeklySpending: state.dashboardData.weeklySpending, - next7DaysSpending: state.dashboardData.next7DaysSpending, - weeklyShifts: state.dashboardData.weeklyShifts, - next7DaysScheduled: state.dashboardData.next7DaysScheduled, - ); - case 'coverage': - return CoverageWidget( - totalNeeded: state.dashboardData.totalNeeded, - totalConfirmed: state.dashboardData.totalFilled, - coveragePercent: state.dashboardData.totalNeeded > 0 - ? ((state.dashboardData.totalFilled / - state.dashboardData.totalNeeded) * - 100) - .toInt() - : 0, - ); - case 'liveActivity': - return LiveActivityWidget(onViewAllPressed: () {}); - default: - return const SizedBox.shrink(); - } - } - - String _getWidgetTitle(String id) { - final i18n = t.client_home.widgets; - switch (id) { - case 'actions': - return i18n.actions; - case 'reorder': - return i18n.reorder; - case 'coverage': - return i18n.coverage; - case 'spending': - return i18n.spending; - case 'liveActivity': - return i18n.live_activity; - default: - return ''; - } - } -} - -class _HeaderIconButton extends StatelessWidget { - final IconData icon; - final String? badgeText; - final bool isActive; - final VoidCallback onTap; - - const _HeaderIconButton({ - required this.icon, - this.badgeText, - this.isActive = false, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: isActive ? UiColors.primary : UiColors.white, - borderRadius: UiConstants.radiusMd, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 2, - ), - ], - ), - child: Icon( - icon, - color: isActive ? UiColors.white : UiColors.iconSecondary, - size: 16, - ), - ), - if (badgeText != null) - Positioned( - top: -4, - right: -4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: UiColors.iconError, - shape: BoxShape.circle, - ), - constraints: const BoxConstraints(minWidth: 16, minHeight: 16), - child: Center( - child: Text( - badgeText!, - style: UiTypography.footnote2b.copyWith( - color: UiColors.white, - fontSize: 8, - ), - ), - ), - ), - ), - ], + /// Builds the widget list in edit mode with drag-and-drop support. + Widget _buildEditModeList(BuildContext context, ClientHomeState state) { + return ReorderableListView( + padding: const EdgeInsets.fromLTRB( + UiConstants.space4, + 0, + UiConstants.space4, + 100, ), + onReorder: (oldIndex, newIndex) { + BlocProvider.of(context).add( + ClientHomeWidgetReordered(oldIndex, newIndex), + ); + }, + children: state.widgetOrder.map((id) { + return Container( + key: ValueKey(id), + margin: const EdgeInsets.only(bottom: UiConstants.space4), + child: DashboardWidgetBuilder( + id: id, + state: state, + isEditMode: true, + ), + ); + }).toList(), + ); + } + + /// Builds the widget list in normal mode with visibility filters. + Widget _buildNormalModeList(ClientHomeState state) { + return ListView( + padding: const EdgeInsets.fromLTRB( + UiConstants.space4, + 0, + UiConstants.space4, + 100, + ), + children: state.widgetOrder.map((id) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: DashboardWidgetBuilder( + id: id, + state: state, + isEditMode: false, + ), + ); + }).toList(), ); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart new file mode 100644 index 00000000..d9437a3d --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart @@ -0,0 +1,79 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../blocs/client_home_bloc.dart'; +import '../blocs/client_home_event.dart'; +import '../blocs/client_home_state.dart'; + +/// A banner displayed when edit mode is active. +/// +/// Shows instructions for reordering widgets and provides a reset button +/// to restore the default layout. +class ClientHomeEditBanner extends StatelessWidget { + /// The internationalization object for localized strings. + final dynamic i18n; + + /// Creates a [ClientHomeEditBanner]. + const ClientHomeEditBanner({ + required this.i18n, + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, curr) => prev.isEditMode != curr.isEditMode, + builder: (context, state) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: state.isEditMode ? 76 : 0, + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space2, + ), + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.1), + border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)), + borderRadius: UiConstants.radiusLg, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Icon(UiIcons.edit, size: 16, color: UiColors.primary), + const SizedBox(width: UiConstants.space2), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + i18n.dashboard.edit_mode_active, + style: UiTypography.footnote1b.copyWith( + color: UiColors.primary, + ), + ), + Text( + i18n.dashboard.drag_instruction, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + UiButton.secondary( + text: i18n.dashboard.reset, + onPressed: () => BlocProvider.of( + context, + ).add(ClientHomeLayoutReset()), + size: UiButtonSize.small, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 48), + maximumSize: const Size(double.infinity, 48), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart new file mode 100644 index 00000000..864257d4 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart @@ -0,0 +1,114 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import '../blocs/client_home_bloc.dart'; +import '../blocs/client_home_event.dart'; +import '../blocs/client_home_state.dart'; +import '../navigation/client_home_navigator.dart'; +import 'header_icon_button.dart'; + +/// The header section of the client home page. +/// +/// Displays the user's business name, avatar, and action buttons +/// (edit mode, notifications, settings). +class ClientHomeHeader extends StatelessWidget { + /// The internationalization object for localized strings. + final dynamic i18n; + + /// Creates a [ClientHomeHeader]. + const ClientHomeHeader({ + required this.i18n, + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final businessName = state.businessName; + final photoUrl = state.photoUrl; + final avatarLetter = businessName.trim().isNotEmpty + ? businessName.trim()[0].toUpperCase() + : 'C'; + + return Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space4, + UiConstants.space4, + UiConstants.space4, + UiConstants.space3, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: UiColors.primary.withValues(alpha: 0.2), + width: 2, + ), + ), + child: CircleAvatar( + backgroundColor: UiColors.primary.withValues(alpha: 0.1), + backgroundImage: + photoUrl != null && photoUrl.isNotEmpty + ? NetworkImage(photoUrl) + : null, + child: photoUrl != null && photoUrl.isNotEmpty + ? null + : Text( + avatarLetter, + style: UiTypography.body2b.copyWith( + color: UiColors.primary, + ), + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + i18n.dashboard.welcome_back, + style: UiTypography.footnote2r.textSecondary, + ), + Text(businessName, style: UiTypography.body1b), + ], + ), + ], + ), + Row( + children: [ + HeaderIconButton( + icon: UiIcons.edit, + isActive: state.isEditMode, + onTap: () => BlocProvider.of( + context, + ).add(ClientHomeEditModeToggled()), + ), + const SizedBox(width: UiConstants.space2), + HeaderIconButton( + icon: UiIcons.bell, + badgeText: '3', + onTap: () {}, + ), + const SizedBox(width: UiConstants.space2), + HeaderIconButton( + icon: UiIcons.settings, + onTap: () => Modular.to.pushSettings(), + ), + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart new file mode 100644 index 00000000..488d0464 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart @@ -0,0 +1,119 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import '../blocs/client_home_state.dart'; +import '../navigation/client_home_navigator.dart'; +import '../widgets/actions_widget.dart'; +import '../widgets/coverage_widget.dart'; +import '../widgets/draggable_widget_wrapper.dart'; +import '../widgets/live_activity_widget.dart'; +import '../widgets/reorder_widget.dart'; +import '../widgets/spending_widget.dart'; + +/// A widget that builds dashboard content based on widget ID. +/// +/// This widget encapsulates the logic for rendering different dashboard +/// widgets based on their unique identifiers and current state. +class DashboardWidgetBuilder extends StatelessWidget { + /// The unique identifier for the widget to build. + final String id; + + /// The current dashboard state. + final ClientHomeState state; + + /// Whether the widget is in edit mode. + final bool isEditMode; + + /// Creates a [DashboardWidgetBuilder]. + const DashboardWidgetBuilder({ + required this.id, + required this.state, + required this.isEditMode, + super.key, + }); + + @override + Widget build(BuildContext context) { + final i18n = t.client_home.widgets; + final widgetContent = _buildWidgetContent(context); + + if (isEditMode) { + return DraggableWidgetWrapper( + id: id, + title: _getWidgetTitle(i18n), + isVisible: state.widgetVisibility[id] ?? true, + child: widgetContent, + ); + } + + // Hide widget if not visible in normal mode + if (!(state.widgetVisibility[id] ?? true)) { + return const SizedBox.shrink(); + } + + return widgetContent; + } + + /// Builds the actual widget content based on the widget ID. + Widget _buildWidgetContent(BuildContext context) { + switch (id) { + case 'actions': + return ActionsWidget( + onRapidPressed: () => Modular.to.pushRapidOrder(), + onCreateOrderPressed: () => Modular.to.pushCreateOrder(), + ); + case 'reorder': + return ReorderWidget( + onReorderPressed: (data) { + ClientHomeSheets.showOrderFormSheet( + context, + data, + onSubmit: (submittedData) { + // Handle form submission if needed + }, + ); + }, + ); + case 'spending': + return SpendingWidget( + weeklySpending: state.dashboardData.weeklySpending, + next7DaysSpending: state.dashboardData.next7DaysSpending, + weeklyShifts: state.dashboardData.weeklyShifts, + next7DaysScheduled: state.dashboardData.next7DaysScheduled, + ); + case 'coverage': + return CoverageWidget( + totalNeeded: state.dashboardData.totalNeeded, + totalConfirmed: state.dashboardData.totalFilled, + coveragePercent: state.dashboardData.totalNeeded > 0 + ? ((state.dashboardData.totalFilled / + state.dashboardData.totalNeeded) * + 100) + .toInt() + : 0, + ); + case 'liveActivity': + return LiveActivityWidget(onViewAllPressed: () {}); + default: + return const SizedBox.shrink(); + } + } + + /// Returns the display title for the widget based on its ID. + String _getWidgetTitle(dynamic i18n) { + switch (id) { + case 'actions': + return i18n.actions; + case 'reorder': + return i18n.reorder; + case 'coverage': + return i18n.coverage; + case 'spending': + return i18n.spending; + case 'liveActivity': + return i18n.live_activity; + default: + return ''; + } + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/draggable_widget_wrapper.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/draggable_widget_wrapper.dart new file mode 100644 index 00000000..57131f9e --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/draggable_widget_wrapper.dart @@ -0,0 +1,95 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../blocs/client_home_bloc.dart'; +import '../blocs/client_home_event.dart'; + +/// A wrapper for dashboard widgets in edit mode. +/// +/// Displays drag handles, visibility toggles, and wraps the actual widget +/// content with appropriate styling for the edit state. +class DraggableWidgetWrapper extends StatelessWidget { + /// The unique identifier for this widget. + final String id; + + /// The display title for this widget. + final String title; + + /// The actual widget content to wrap. + final Widget child; + + /// Whether this widget is currently visible. + final bool isVisible; + + /// Creates a [DraggableWidgetWrapper]. + const DraggableWidgetWrapper({ + required this.id, + required this.title, + required this.child, + required this.isVisible, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + spacing: UiConstants.space2, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + const Icon( + UiIcons.gripVertical, + size: 14, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space2), + Text(title, style: UiTypography.footnote1m), + ], + ), + ), + const SizedBox(width: UiConstants.space2), + GestureDetector( + onTap: () => BlocProvider.of( + context, + ).add(ClientHomeWidgetVisibilityToggled(id)), + child: Container( + padding: const EdgeInsets.all(UiConstants.space1), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: Icon( + isVisible ? UiIcons.success : UiIcons.error, + size: 14, + color: isVisible ? UiColors.primary : UiColors.iconSecondary, + ), + ), + ), + ], + ), + + // Widget content + Opacity( + opacity: isVisible ? 1.0 : 0.4, + child: IgnorePointer( + ignoring: !isVisible, + child: child, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/header_icon_button.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/header_icon_button.dart new file mode 100644 index 00000000..41f42615 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/header_icon_button.dart @@ -0,0 +1,82 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A circular icon button used in the header section. +/// +/// Supports an optional badge for notification counts and an active state +/// for toggled actions. +class HeaderIconButton extends StatelessWidget { + /// The icon to display. + final IconData icon; + + /// Optional badge text (e.g., notification count). + final String? badgeText; + + /// Whether this button is in an active/selected state. + final bool isActive; + + /// Callback invoked when the button is tapped. + final VoidCallback onTap; + + /// Creates a [HeaderIconButton]. + const HeaderIconButton({ + required this.icon, + this.badgeText, + this.isActive = false, + required this.onTap, + super.key, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: isActive ? UiColors.primary : UiColors.white, + borderRadius: UiConstants.radiusMd, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 2, + ), + ], + ), + child: Icon( + icon, + color: isActive ? UiColors.white : UiColors.iconSecondary, + size: 16, + ), + ), + if (badgeText != null) + Positioned( + top: -4, + right: -4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: UiColors.iconError, + shape: BoxShape.circle, + ), + constraints: const BoxConstraints(minWidth: 16, minHeight: 16), + child: Center( + child: Text( + badgeText!, + style: UiTypography.footnote2b.copyWith( + color: UiColors.white, + fontSize: 8, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 96681ac2..21ca16ea 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -192,6 +192,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.2" + client_coverage: + dependency: transitive + description: + path: "packages/features/client/client_coverage" + relative: true + source: path + version: "1.0.0" clock: dependency: transitive description: