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.
This commit is contained in:
@@ -24,6 +24,8 @@ dependencies:
|
|||||||
path: ../../packages/features/client/client_main
|
path: ../../packages/features/client/client_main
|
||||||
client_home:
|
client_home:
|
||||||
path: ../../packages/features/client/home
|
path: ../../packages/features/client/home
|
||||||
|
client_coverage:
|
||||||
|
path: ../../packages/features/client/client_coverage
|
||||||
client_settings:
|
client_settings:
|
||||||
path: ../../packages/features/client/settings
|
path: ../../packages/features/client/settings
|
||||||
client_hubs:
|
client_hubs:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../session/client_session_store.dart';
|
||||||
|
|
||||||
/// Mock implementation of data source for Home dashboard data.
|
/// Mock implementation of data source for Home dashboard data.
|
||||||
///
|
///
|
||||||
@@ -18,4 +19,14 @@ class HomeRepositoryMock {
|
|||||||
totalFilled: 8,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export 'src/coverage_module.dart';
|
||||||
|
export 'src/presentation/pages/coverage_page.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<CoverageRepository>(CoverageRepositoryImpl.new);
|
||||||
|
|
||||||
|
// Use Cases
|
||||||
|
i.addSingleton(GetShiftsForDateUseCase.new);
|
||||||
|
i.addSingleton(GetCoverageStatsUseCase.new);
|
||||||
|
|
||||||
|
// BLoCs
|
||||||
|
i.addSingleton<CoverageBloc>(
|
||||||
|
() => CoverageBloc(
|
||||||
|
getShiftsForDate: i.get<GetShiftsForDateUseCase>(),
|
||||||
|
getCoverageStats: i.get<GetCoverageStatsUseCase>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void routes(RouteManager r) {
|
||||||
|
r.child('/', child: (_) => const CoveragePage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
|
||||||
|
// Simulate network delay
|
||||||
|
await Future<void>.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 <CoverageShift>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CoverageShift>[
|
||||||
|
CoverageShift(
|
||||||
|
id: '1',
|
||||||
|
title: 'Banquet Server',
|
||||||
|
location: 'Grand Ballroom',
|
||||||
|
startTime: '16:00',
|
||||||
|
workersNeeded: 10,
|
||||||
|
date: date,
|
||||||
|
workers: const <CoverageWorker>[
|
||||||
|
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>[
|
||||||
|
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<CoverageStats> getCoverageStats({required DateTime date}) async {
|
||||||
|
// Get shifts for the date
|
||||||
|
final List<CoverageShift> shifts = await getShiftsForDate(date: date);
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
final int totalNeeded = shifts.fold<int>(
|
||||||
|
0,
|
||||||
|
(int sum, CoverageShift shift) => sum + shift.workersNeeded,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<CoverageWorker> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Object?> get props => <Object?>[date];
|
||||||
|
}
|
||||||
@@ -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<Object?> get props => <Object?>[date];
|
||||||
|
}
|
||||||
@@ -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<List<CoverageShift>> 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<CoverageStats> getCoverageStats({required DateTime date});
|
||||||
|
}
|
||||||
@@ -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<CoverageWorker> workers;
|
||||||
|
|
||||||
|
/// Calculates the coverage percentage for this shift.
|
||||||
|
int get coveragePercent {
|
||||||
|
if (workersNeeded == 0) return 100;
|
||||||
|
return ((workers.length / workersNeeded) * 100).round();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[
|
||||||
|
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<Object?> get props => <Object?>[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<Object?> get props => <Object?>[
|
||||||
|
totalNeeded,
|
||||||
|
totalConfirmed,
|
||||||
|
checkedIn,
|
||||||
|
enRoute,
|
||||||
|
late,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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<GetCoverageStatsArguments, CoverageStats> {
|
||||||
|
/// Creates a [GetCoverageStatsUseCase].
|
||||||
|
GetCoverageStatsUseCase(this._repository);
|
||||||
|
|
||||||
|
final CoverageRepository _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CoverageStats> call(GetCoverageStatsArguments arguments) {
|
||||||
|
return _repository.getCoverageStats(date: arguments.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetShiftsForDateArguments, List<CoverageShift>> {
|
||||||
|
/// Creates a [GetShiftsForDateUseCase].
|
||||||
|
GetShiftsForDateUseCase(this._repository);
|
||||||
|
|
||||||
|
final CoverageRepository _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CoverageShift>> call(GetShiftsForDateArguments arguments) {
|
||||||
|
return _repository.getShiftsForDate(date: arguments.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CoverageEvent, CoverageState> {
|
||||||
|
/// Creates a [CoverageBloc].
|
||||||
|
CoverageBloc({
|
||||||
|
required GetShiftsForDateUseCase getShiftsForDate,
|
||||||
|
required GetCoverageStatsUseCase getCoverageStats,
|
||||||
|
}) : _getShiftsForDate = getShiftsForDate,
|
||||||
|
_getCoverageStats = getCoverageStats,
|
||||||
|
super(const CoverageState()) {
|
||||||
|
on<CoverageLoadRequested>(_onLoadRequested);
|
||||||
|
on<CoverageRefreshRequested>(_onRefreshRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
final GetShiftsForDateUseCase _getShiftsForDate;
|
||||||
|
final GetCoverageStatsUseCase _getCoverageStats;
|
||||||
|
|
||||||
|
/// Handles the load requested event.
|
||||||
|
Future<void> _onLoadRequested(
|
||||||
|
CoverageLoadRequested event,
|
||||||
|
Emitter<CoverageState> emit,
|
||||||
|
) async {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: CoverageStatus.loading,
|
||||||
|
selectedDate: event.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch shifts and stats concurrently
|
||||||
|
final List<Object> results = await Future.wait<Object>(<Future<Object>>[
|
||||||
|
_getShiftsForDate(GetShiftsForDateArguments(date: event.date)),
|
||||||
|
_getCoverageStats(GetCoverageStatsArguments(date: event.date)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final List<CoverageShift> shifts = results[0] as List<CoverageShift>;
|
||||||
|
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<void> _onRefreshRequested(
|
||||||
|
CoverageRefreshRequested event,
|
||||||
|
Emitter<CoverageState> emit,
|
||||||
|
) async {
|
||||||
|
if (state.selectedDate == null) return;
|
||||||
|
|
||||||
|
// Reload data for the current selected date
|
||||||
|
add(CoverageLoadRequested(date: state.selectedDate!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Object?> get props => <Object?>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Object?> get props => <Object?>[date];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event to refresh coverage data.
|
||||||
|
final class CoverageRefreshRequested extends CoverageEvent {
|
||||||
|
/// Creates a [CoverageRefreshRequested] event.
|
||||||
|
const CoverageRefreshRequested();
|
||||||
|
}
|
||||||
@@ -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 <CoverageShift>[],
|
||||||
|
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<CoverageShift> 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<CoverageShift>? 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<Object?> get props => <Object?>[
|
||||||
|
status,
|
||||||
|
selectedDate,
|
||||||
|
shifts,
|
||||||
|
stats,
|
||||||
|
errorMessage,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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<CoverageBloc>(
|
||||||
|
create: (BuildContext context) => Modular.get<CoverageBloc>()
|
||||||
|
..add(CoverageLoadRequested(date: DateTime.now())),
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: UiColors.background,
|
||||||
|
body: BlocBuilder<CoverageBloc, CoverageState>(
|
||||||
|
builder: (BuildContext context, CoverageState state) {
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
|
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<CoverageBloc>(context).add(
|
||||||
|
CoverageLoadRequested(date: date),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onRefresh: () {
|
||||||
|
BlocProvider.of<CoverageBloc>(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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
if (state.stats != null) ...<Widget>[
|
||||||
|
CoverageQuickStats(stats: state.stats!),
|
||||||
|
const SizedBox(height: UiConstants.space5),
|
||||||
|
],
|
||||||
|
if (state.stats != null && state.stats!.late > 0) ...<Widget>[
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DateTime> onDateSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CoverageCalendarSelector> createState() =>
|
||||||
|
_CoverageCalendarSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||||
|
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<DateTime> _getCalendarDays() {
|
||||||
|
final List<DateTime> days = <DateTime>[];
|
||||||
|
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<DateTime> calendarDays = _getCalendarDays();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: <Widget>[
|
||||||
|
_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: <Widget>[
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DateTime> 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: <Color>[
|
||||||
|
UiColors.primary,
|
||||||
|
UiColors.accent,
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
Text(
|
||||||
|
'Workers',
|
||||||
|
style: UiTypography.body2r.copyWith(
|
||||||
|
color: UiColors.primaryForeground.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$totalConfirmed/$totalNeeded',
|
||||||
|
style: UiTypography.title2m.copyWith(
|
||||||
|
color: UiColors.primaryForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CoverageShift> shifts;
|
||||||
|
|
||||||
|
/// Formats a time string (HH:mm) to a readable format (h:mm a).
|
||||||
|
String _formatTime(String? time) {
|
||||||
|
if (time == null) return '';
|
||||||
|
final List<String> parts = time.split(':');
|
||||||
|
final DateTime dt = DateTime(
|
||||||
|
2022,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
int.parse(parts[0]),
|
||||||
|
int.parse(parts[1]),
|
||||||
|
);
|
||||||
|
return DateFormat('h:mm a').format(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
_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<Widget>((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: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.warning,
|
||||||
|
color: UiColors.destructive,
|
||||||
|
size: UiConstants.space5,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:billing/billing.dart';
|
import 'package:billing/billing.dart';
|
||||||
import 'package:client_home/client_home.dart';
|
import 'package:client_home/client_home.dart';
|
||||||
|
import 'package:client_coverage/client_coverage.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:view_orders/view_orders.dart';
|
import 'package:view_orders/view_orders.dart';
|
||||||
@@ -21,12 +22,7 @@ class ClientMainModule extends Module {
|
|||||||
child: (BuildContext context) => const ClientMainPage(),
|
child: (BuildContext context) => const ClientMainPage(),
|
||||||
children: <ParallelRoute<dynamic>>[
|
children: <ParallelRoute<dynamic>>[
|
||||||
ModuleRoute<dynamic>('/home', module: ClientHomeModule()),
|
ModuleRoute<dynamic>('/home', module: ClientHomeModule()),
|
||||||
// Placeholders for other tabs
|
ModuleRoute<dynamic>('/coverage', module: CoverageModule()),
|
||||||
ChildRoute<dynamic>(
|
|
||||||
'/coverage',
|
|
||||||
child: (BuildContext context) =>
|
|
||||||
const PlaceholderPage(title: 'Coverage'),
|
|
||||||
),
|
|
||||||
ModuleRoute<dynamic>('/billing', module: BillingModule()),
|
ModuleRoute<dynamic>('/billing', module: BillingModule()),
|
||||||
ModuleRoute<dynamic>('/orders', module: ViewOrdersModule()),
|
ModuleRoute<dynamic>('/orders', module: ViewOrdersModule()),
|
||||||
ChildRoute<dynamic>(
|
ChildRoute<dynamic>(
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ dependencies:
|
|||||||
path: ../../../core_localization
|
path: ../../../core_localization
|
||||||
client_home:
|
client_home:
|
||||||
path: ../home
|
path: ../home
|
||||||
|
client_coverage:
|
||||||
|
path: ../client_coverage
|
||||||
view_orders:
|
view_orders:
|
||||||
path: ../view_orders
|
path: ../view_orders
|
||||||
billing:
|
billing:
|
||||||
|
|||||||
@@ -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_cubit.dart';
|
||||||
import 'package:client_main/src/presentation/blocs/client_main_state.dart';
|
import 'package:client_main/src/presentation/blocs/client_main_state.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
@@ -27,7 +26,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('initial state is correct', () {
|
test('initial state is correct', () {
|
||||||
final cubit = ClientMainCubit();
|
final ClientMainCubit cubit = ClientMainCubit();
|
||||||
expect(cubit.state, const ClientMainState(currentIndex: 2));
|
expect(cubit.state, const ClientMainState(currentIndex: 2));
|
||||||
cubit.close();
|
cubit.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:krow_data_connect/krow_data_connect.dart';
|
|||||||
import 'src/data/repositories_impl/home_repository_impl.dart';
|
import 'src/data/repositories_impl/home_repository_impl.dart';
|
||||||
import 'src/domain/repositories/home_repository_interface.dart';
|
import 'src/domain/repositories/home_repository_interface.dart';
|
||||||
import 'src/domain/usecases/get_dashboard_data_usecase.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/blocs/client_home_bloc.dart';
|
||||||
import 'src/presentation/pages/client_home_page.dart';
|
import 'src/presentation/pages/client_home_page.dart';
|
||||||
|
|
||||||
@@ -28,11 +29,13 @@ class ClientHomeModule extends Module {
|
|||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.addLazySingleton(GetDashboardDataUseCase.new);
|
i.addLazySingleton(GetDashboardDataUseCase.new);
|
||||||
|
i.addLazySingleton(GetUserSessionDataUseCase.new);
|
||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.add<ClientHomeBloc>(
|
i.add<ClientHomeBloc>(
|
||||||
() => ClientHomeBloc(
|
() => ClientHomeBloc(
|
||||||
getDashboardDataUseCase: i.get<GetDashboardDataUseCase>(),
|
getDashboardDataUseCase: i.get<GetDashboardDataUseCase>(),
|
||||||
|
getUserSessionDataUseCase: i.get<GetUserSessionDataUseCase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,13 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
|
|||||||
Future<HomeDashboardData> getDashboardData() {
|
Future<HomeDashboardData> getDashboardData() {
|
||||||
return _mock.getDashboardData();
|
return _mock.getDashboardData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
UserSessionData getUserSessionData() {
|
||||||
|
final (businessName, photoUrl) = _mock.getUserSession();
|
||||||
|
return UserSessionData(
|
||||||
|
businessName: businessName,
|
||||||
|
photoUrl: photoUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
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.
|
/// Interface for the Client Home repository.
|
||||||
///
|
///
|
||||||
/// This repository is responsible for providing data required for the
|
/// 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 {
|
abstract interface class HomeRepositoryInterface {
|
||||||
/// Fetches the [HomeDashboardData] containing aggregated dashboard metrics.
|
/// Fetches the [HomeDashboardData] containing aggregated dashboard metrics.
|
||||||
Future<HomeDashboardData> getDashboardData();
|
Future<HomeDashboardData> getDashboardData();
|
||||||
|
|
||||||
|
/// Fetches the user's session data (business name and photo).
|
||||||
|
UserSessionData getUserSessionData();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../domain/usecases/get_dashboard_data_usecase.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_event.dart';
|
||||||
import 'client_home_state.dart';
|
import 'client_home_state.dart';
|
||||||
|
|
||||||
/// BLoC responsible for managing the state and business logic of the client home dashboard.
|
/// BLoC responsible for managing the state and business logic of the client home dashboard.
|
||||||
class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState> {
|
class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState> {
|
||||||
final GetDashboardDataUseCase _getDashboardDataUseCase;
|
final GetDashboardDataUseCase _getDashboardDataUseCase;
|
||||||
|
final GetUserSessionDataUseCase _getUserSessionDataUseCase;
|
||||||
|
|
||||||
ClientHomeBloc({required GetDashboardDataUseCase getDashboardDataUseCase})
|
ClientHomeBloc({
|
||||||
: _getDashboardDataUseCase = getDashboardDataUseCase,
|
required GetDashboardDataUseCase getDashboardDataUseCase,
|
||||||
|
required GetUserSessionDataUseCase getUserSessionDataUseCase,
|
||||||
|
}) : _getDashboardDataUseCase = getDashboardDataUseCase,
|
||||||
|
_getUserSessionDataUseCase = getUserSessionDataUseCase,
|
||||||
super(const ClientHomeState()) {
|
super(const ClientHomeState()) {
|
||||||
on<ClientHomeStarted>(_onStarted);
|
on<ClientHomeStarted>(_onStarted);
|
||||||
on<ClientHomeEditModeToggled>(_onEditModeToggled);
|
on<ClientHomeEditModeToggled>(_onEditModeToggled);
|
||||||
@@ -23,9 +28,19 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState> {
|
|||||||
) async {
|
) async {
|
||||||
emit(state.copyWith(status: ClientHomeStatus.loading));
|
emit(state.copyWith(status: ClientHomeStatus.loading));
|
||||||
try {
|
try {
|
||||||
|
// Get session data
|
||||||
|
final sessionData = _getUserSessionDataUseCase();
|
||||||
|
|
||||||
|
// Get dashboard data
|
||||||
final data = await _getDashboardDataUseCase();
|
final data = await _getDashboardDataUseCase();
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(status: ClientHomeStatus.success, dashboardData: data),
|
state.copyWith(
|
||||||
|
status: ClientHomeStatus.success,
|
||||||
|
dashboardData: data,
|
||||||
|
businessName: sessionData.businessName,
|
||||||
|
photoUrl: sessionData.photoUrl,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(
|
emit(
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class ClientHomeState extends Equatable {
|
|||||||
final bool isEditMode;
|
final bool isEditMode;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final HomeDashboardData dashboardData;
|
final HomeDashboardData dashboardData;
|
||||||
|
final String businessName;
|
||||||
|
final String? photoUrl;
|
||||||
|
|
||||||
const ClientHomeState({
|
const ClientHomeState({
|
||||||
this.status = ClientHomeStatus.initial,
|
this.status = ClientHomeStatus.initial,
|
||||||
@@ -39,6 +41,8 @@ class ClientHomeState extends Equatable {
|
|||||||
totalNeeded: 10,
|
totalNeeded: 10,
|
||||||
totalFilled: 8,
|
totalFilled: 8,
|
||||||
),
|
),
|
||||||
|
this.businessName = 'Your Company',
|
||||||
|
this.photoUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
ClientHomeState copyWith({
|
ClientHomeState copyWith({
|
||||||
@@ -48,6 +52,8 @@ class ClientHomeState extends Equatable {
|
|||||||
bool? isEditMode,
|
bool? isEditMode,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
HomeDashboardData? dashboardData,
|
HomeDashboardData? dashboardData,
|
||||||
|
String? businessName,
|
||||||
|
String? photoUrl,
|
||||||
}) {
|
}) {
|
||||||
return ClientHomeState(
|
return ClientHomeState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -56,6 +62,8 @@ class ClientHomeState extends Equatable {
|
|||||||
isEditMode: isEditMode ?? this.isEditMode,
|
isEditMode: isEditMode ?? this.isEditMode,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
dashboardData: dashboardData ?? this.dashboardData,
|
dashboardData: dashboardData ?? this.dashboardData,
|
||||||
|
businessName: businessName ?? this.businessName,
|
||||||
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,5 +75,7 @@ class ClientHomeState extends Equatable {
|
|||||||
isEditMode,
|
isEditMode,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
dashboardData,
|
dashboardData,
|
||||||
|
businessName,
|
||||||
|
photoUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import '../widgets/shift_order_form_sheet.dart';
|
||||||
|
|
||||||
extension ClientHomeNavigator on IModularNavigator {
|
extension ClientHomeNavigator on IModularNavigator {
|
||||||
void pushSettings() {
|
void pushSettings() {
|
||||||
@@ -13,3 +15,31 @@ extension ClientHomeNavigator on IModularNavigator {
|
|||||||
pushNamed('/client/create-order/rapid');
|
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<String, dynamic>? initialData, {
|
||||||
|
required void Function(Map<String, dynamic>) onSubmit,
|
||||||
|
}) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) {
|
||||||
|
return ShiftOrderFormSheet(
|
||||||
|
initialData: initialData,
|
||||||
|
onSubmit: (data) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
onSubmit(data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,43 +3,22 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.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_bloc.dart';
|
||||||
import '../blocs/client_home_event.dart';
|
import '../blocs/client_home_event.dart';
|
||||||
import '../blocs/client_home_state.dart';
|
import '../blocs/client_home_state.dart';
|
||||||
import '../navigation/client_home_navigator.dart';
|
import '../widgets/client_home_edit_banner.dart';
|
||||||
import '../widgets/actions_widget.dart';
|
import '../widgets/client_home_header.dart';
|
||||||
import '../widgets/coverage_widget.dart';
|
import '../widgets/dashboard_widget_builder.dart';
|
||||||
import '../widgets/live_activity_widget.dart';
|
|
||||||
import '../widgets/reorder_widget.dart';
|
|
||||||
import '../widgets/shift_order_form_sheet.dart';
|
|
||||||
import '../widgets/spending_widget.dart';
|
|
||||||
|
|
||||||
/// The main Home page for client users.
|
/// 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 {
|
class ClientHomePage extends StatelessWidget {
|
||||||
/// Creates a [ClientHomePage].
|
/// Creates a [ClientHomePage].
|
||||||
const ClientHomePage({super.key});
|
const ClientHomePage({super.key});
|
||||||
|
|
||||||
void _openOrderFormSheet(
|
|
||||||
BuildContext context,
|
|
||||||
Map<String, dynamic>? shiftData,
|
|
||||||
) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) {
|
|
||||||
return ShiftOrderFormSheet(
|
|
||||||
initialData: shiftData,
|
|
||||||
onSubmit: (data) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final i18n = t.client_home;
|
final i18n = t.client_home;
|
||||||
@@ -51,12 +30,27 @@ class ClientHomePage extends StatelessWidget {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(context, i18n),
|
ClientHomeHeader(i18n: i18n),
|
||||||
_buildEditModeBanner(i18n),
|
ClientHomeEditBanner(i18n: i18n),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
child: BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.isEditMode) {
|
if (state.isEditMode) {
|
||||||
|
return _buildEditModeList(context, state);
|
||||||
|
}
|
||||||
|
return _buildNormalModeList(state);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the widget list in edit mode with drag-and-drop support.
|
||||||
|
Widget _buildEditModeList(BuildContext context, ClientHomeState state) {
|
||||||
return ReorderableListView(
|
return ReorderableListView(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
UiConstants.space4,
|
UiConstants.space4,
|
||||||
@@ -65,26 +59,26 @@ class ClientHomePage extends StatelessWidget {
|
|||||||
100,
|
100,
|
||||||
),
|
),
|
||||||
onReorder: (oldIndex, newIndex) {
|
onReorder: (oldIndex, newIndex) {
|
||||||
BlocProvider.of<ClientHomeBloc>(
|
BlocProvider.of<ClientHomeBloc>(context).add(
|
||||||
context,
|
ClientHomeWidgetReordered(oldIndex, newIndex),
|
||||||
).add(ClientHomeWidgetReordered(oldIndex, newIndex));
|
);
|
||||||
},
|
},
|
||||||
children: state.widgetOrder.map((id) {
|
children: state.widgetOrder.map((id) {
|
||||||
return Container(
|
return Container(
|
||||||
key: ValueKey(id),
|
key: ValueKey(id),
|
||||||
margin: const EdgeInsets.only(
|
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||||
bottom: UiConstants.space4,
|
child: DashboardWidgetBuilder(
|
||||||
),
|
id: id,
|
||||||
child: _buildDraggableWidgetWrapper(
|
state: state,
|
||||||
context,
|
isEditMode: true,
|
||||||
id,
|
|
||||||
state,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the widget list in normal mode with visibility filters.
|
||||||
|
Widget _buildNormalModeList(ClientHomeState state) {
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
UiConstants.space4,
|
UiConstants.space4,
|
||||||
@@ -93,367 +87,15 @@ class ClientHomePage extends StatelessWidget {
|
|||||||
100,
|
100,
|
||||||
),
|
),
|
||||||
children: state.widgetOrder.map((id) {
|
children: state.widgetOrder.map((id) {
|
||||||
if (!(state.widgetVisibility[id] ?? true)) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||||
bottom: UiConstants.space4,
|
child: DashboardWidgetBuilder(
|
||||||
|
id: id,
|
||||||
|
state: state,
|
||||||
|
isEditMode: false,
|
||||||
),
|
),
|
||||||
child: _buildWidgetContent(context, id, state),
|
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context, dynamic i18n) {
|
|
||||||
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
|
||||||
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<ClientHomeBloc>(
|
|
||||||
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<ClientHomeBloc, ClientHomeState>(
|
|
||||||
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<ClientHomeBloc>(
|
|
||||||
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<ClientHomeBloc>(
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ClientHomeBloc, ClientHomeState>(
|
||||||
|
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<ClientHomeBloc>(
|
||||||
|
context,
|
||||||
|
).add(ClientHomeLayoutReset()),
|
||||||
|
size: UiButtonSize.small,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
maximumSize: const Size(double.infinity, 48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ClientHomeBloc, ClientHomeState>(
|
||||||
|
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<ClientHomeBloc>(
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ClientHomeBloc>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,6 +192,13 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.2"
|
version: "0.4.2"
|
||||||
|
client_coverage:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
path: "packages/features/client/client_coverage"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "1.0.0"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user