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:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user