Refactor coverage-related widgets and improve UI consistency
- Updated CancelLateWorkerSheet to enhance layout and information display. - Modified CoverageCalendarSelector to adjust color opacity for better visibility. - Simplified CoveragePageSkeleton by removing unused quick stats row. - Removed CoverageQuickStats widget as it was redundant. - Enhanced CoverageShiftList to manage expanded state and improve worker display. - Deleted CoverageStatCard as its functionality is no longer needed. - Revamped CoverageStatsHeader to provide a clearer overview of coverage statistics. - Improved LateWorkersAlert for better visual feedback on late workers. - Redesigned ShiftHeader for a more interactive and informative shift card. - Updated WorkerRow to streamline status display and action buttons.
This commit is contained in:
@@ -1793,7 +1793,9 @@
|
||||
"workers": "Workers",
|
||||
"error_occurred": "An error occurred",
|
||||
"retry": "Retry",
|
||||
"shifts": "Shifts"
|
||||
"shifts": "Shifts",
|
||||
"overall_coverage": "Overall Coverage",
|
||||
"live_activity": "LIVE ACTIVITY"
|
||||
},
|
||||
"calendar": {
|
||||
"prev_week": "\u2190 Prev Week",
|
||||
@@ -1802,7 +1804,9 @@
|
||||
},
|
||||
"stats": {
|
||||
"checked_in": "Checked In",
|
||||
"en_route": "En Route"
|
||||
"en_route": "En Route",
|
||||
"on_site": "On Site",
|
||||
"late": "Late"
|
||||
},
|
||||
"alert": {
|
||||
"workers_running_late(count)": {
|
||||
|
||||
@@ -1793,7 +1793,9 @@
|
||||
"workers": "Trabajadores",
|
||||
"error_occurred": "Ocurri\u00f3 un error",
|
||||
"retry": "Reintentar",
|
||||
"shifts": "Turnos"
|
||||
"shifts": "Turnos",
|
||||
"overall_coverage": "Cobertura General",
|
||||
"live_activity": "ACTIVIDAD EN VIVO"
|
||||
},
|
||||
"calendar": {
|
||||
"prev_week": "\u2190 Semana Anterior",
|
||||
@@ -1802,7 +1804,9 @@
|
||||
},
|
||||
"stats": {
|
||||
"checked_in": "Registrado",
|
||||
"en_route": "En Camino"
|
||||
"en_route": "En Camino",
|
||||
"on_site": "En Sitio",
|
||||
"late": "Tarde"
|
||||
},
|
||||
"alert": {
|
||||
"workers_running_late(count)": {
|
||||
|
||||
@@ -82,6 +82,7 @@ class UiChip extends StatelessWidget {
|
||||
|
||||
final Row content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
if (leadingIcon != null) ...<Widget>[
|
||||
Icon(leadingIcon, size: iconSize, color: contentColor),
|
||||
|
||||
@@ -10,14 +10,14 @@ import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_state.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_calendar_selector.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_quick_stats.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_shift_list.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart';
|
||||
|
||||
/// Page for displaying daily coverage information.
|
||||
///
|
||||
/// Shows shifts, worker statuses, and coverage statistics for a selected date.
|
||||
/// Shows shifts, worker statuses, and coverage statistics for a selected date
|
||||
/// using a collapsible SliverAppBar with gradient header and live activity feed.
|
||||
class CoveragePage extends StatefulWidget {
|
||||
/// Creates a [CoveragePage].
|
||||
const CoveragePage({super.key});
|
||||
@@ -27,14 +27,13 @@ class CoveragePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CoveragePageState extends State<CoveragePage> {
|
||||
/// Controller for the [CustomScrollView].
|
||||
late ScrollController _scrollController;
|
||||
bool _isScrolled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -43,16 +42,6 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.hasClients) {
|
||||
if (_scrollController.offset > 180 && !_isScrolled) {
|
||||
setState(() => _isScrolled = true);
|
||||
} else if (_scrollController.offset <= 180 && _isScrolled) {
|
||||
setState(() => _isScrolled = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CoverageBloc>(
|
||||
@@ -93,19 +82,26 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 300.0,
|
||||
expandedHeight: 316.0,
|
||||
backgroundColor: UiColors.primary,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(
|
||||
_isScrolled
|
||||
? DateFormat('MMMM d').format(selectedDate)
|
||||
: context.t.client_coverage.page.daily_coverage,
|
||||
key: ValueKey<bool>(_isScrolled),
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.daily_coverage,
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('EEEE, MMMM d').format(selectedDate),
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.primaryForeground
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
@@ -135,7 +131,7 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
UiColors.primary,
|
||||
UiColors.primary,
|
||||
Color(0xFF0626A8),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
@@ -169,6 +165,12 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
state.stats?.totalPositionsConfirmed ?? 0,
|
||||
totalNeeded:
|
||||
state.stats?.totalPositionsNeeded ?? 0,
|
||||
totalCheckedIn:
|
||||
state.stats?.totalWorkersCheckedIn ?? 0,
|
||||
totalEnRoute:
|
||||
state.stats?.totalWorkersEnRoute ?? 0,
|
||||
totalLate:
|
||||
state.stats?.totalWorkersLate ?? 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -191,7 +193,10 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the main body content based on the current state.
|
||||
/// Builds the main body content based on the current [CoverageState].
|
||||
///
|
||||
/// Displays a skeleton loader, error state, or the live activity feed
|
||||
/// with late worker alerts and shift list.
|
||||
Widget _buildBody({
|
||||
required BuildContext context,
|
||||
required CoverageState state,
|
||||
@@ -242,24 +247,19 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space6,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
if (state.stats != null &&
|
||||
state.stats!.totalWorkersLate > 0) ...<Widget>[
|
||||
LateWorkersAlert(
|
||||
lateCount: state.stats!.totalWorkersLate,
|
||||
),
|
||||
],
|
||||
if (state.stats != null) ...<Widget>[
|
||||
CoverageQuickStats(stats: state.stats!),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (state.stats != null &&
|
||||
state.stats!.totalWorkersLate > 0) ...<Widget>[
|
||||
LateWorkersAlert(
|
||||
lateCount: state.stats!.totalWorkersLate,
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'${context.t.client_coverage.page.shifts} (${state.shifts.length})',
|
||||
style: UiTypography.title2b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
context.t.client_coverage.page.live_activity,
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 2.0,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
CoverageShiftList(shifts: state.shifts),
|
||||
|
||||
@@ -91,14 +91,29 @@ class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
UiIcons.warning,
|
||||
color: UiColors.destructive,
|
||||
size: 28,
|
||||
Row(
|
||||
spacing: UiConstants.space3,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.warning,
|
||||
color: UiColors.destructive,
|
||||
size: 28,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(l10n.title, style: UiTypography.title1b.textError),
|
||||
Text(
|
||||
l10n.subtitle,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Icon(
|
||||
child: const Icon(
|
||||
UiIcons.close,
|
||||
color: UiColors.textSecondary,
|
||||
size: 24,
|
||||
@@ -106,21 +121,14 @@ class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(l10n.title, style: UiTypography.title1b),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
l10n.subtitle,
|
||||
style: UiTypography.body2r.copyWith(color: UiColors.destructive),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Body
|
||||
Text(
|
||||
l10n.confirm_message(name: widget.worker.fullName),
|
||||
style: UiTypography.body1m,
|
||||
style: UiTypography.body1r,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
l10n.helper_text,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
@@ -146,28 +154,19 @@ class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
child: UiButton.primary(
|
||||
text: l10n.confirm,
|
||||
onPressed: () => _onConfirm(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.destructive,
|
||||
foregroundColor: UiColors.primaryForeground,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
l10n.confirm,
|
||||
style: UiTypography.body1b.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space24),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -110,7 +110,7 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? UiColors.primaryForeground
|
||||
: UiColors.primaryForeground.withOpacity(0.1),
|
||||
: UiColors.primaryForeground.withAlpha(25),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: isToday && !isSelected
|
||||
? Border.all(
|
||||
@@ -122,6 +122,14 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.primary
|
||||
: UiColors.primaryForeground.withAlpha(179),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
date.day.toString().padLeft(2, '0'),
|
||||
style: UiTypography.body1b.copyWith(
|
||||
@@ -130,14 +138,6 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.mutedForeground
|
||||
: UiColors.primaryForeground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/
|
||||
|
||||
/// Shimmer loading skeleton that mimics the coverage page loaded layout.
|
||||
///
|
||||
/// Shows placeholder shapes for the quick stats row, shift section header,
|
||||
/// and a list of shift cards with worker rows.
|
||||
/// Shows placeholder shapes for the live activity section label and a list
|
||||
/// of shift cards with worker rows.
|
||||
class CoveragePageSkeleton extends StatelessWidget {
|
||||
/// Creates a [CoveragePageSkeleton].
|
||||
const CoveragePageSkeleton({super.key});
|
||||
@@ -19,18 +19,8 @@ class CoveragePageSkeleton extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Quick stats row (2 stat cards)
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
],
|
||||
),
|
||||
SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shifts section header
|
||||
UiShimmerLine(width: 140, height: 18),
|
||||
// "LIVE ACTIVITY" section label placeholder
|
||||
UiShimmerLine(width: 100, height: 10),
|
||||
SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shift cards with worker rows
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_stat_card.dart';
|
||||
|
||||
/// Quick statistics cards showing coverage metrics.
|
||||
///
|
||||
/// Displays checked-in and en-route 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(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: CoverageStatCard(
|
||||
icon: UiIcons.success,
|
||||
label: context.t.client_coverage.stats.checked_in,
|
||||
value: stats.totalWorkersCheckedIn.toString(),
|
||||
color: UiColors.iconSuccess,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CoverageStatCard(
|
||||
icon: UiIcons.clock,
|
||||
label: context.t.client_coverage.stats.en_route,
|
||||
value: stats.totalWorkersEnRoute.toString(),
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@ import 'package:client_coverage/src/presentation/widgets/shift_header.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/worker_row.dart';
|
||||
import 'package:client_coverage/src/presentation/widgets/worker_review_sheet.dart';
|
||||
|
||||
/// List of shifts with their workers.
|
||||
/// Displays a list of shifts as collapsible cards with worker details.
|
||||
///
|
||||
/// Displays all shifts for the selected date, or an empty state if none exist.
|
||||
class CoverageShiftList extends StatelessWidget {
|
||||
/// Each shift is rendered as a card with a tappable [ShiftHeader] that toggles
|
||||
/// visibility of the worker rows beneath it. All cards start expanded.
|
||||
/// Shows an empty state when [shifts] is empty.
|
||||
class CoverageShiftList extends StatefulWidget {
|
||||
/// Creates a [CoverageShiftList].
|
||||
const CoverageShiftList({
|
||||
required this.shifts,
|
||||
@@ -22,17 +24,73 @@ class CoverageShiftList extends StatelessWidget {
|
||||
/// The list of shifts to display.
|
||||
final List<ShiftWithWorkers> shifts;
|
||||
|
||||
@override
|
||||
State<CoverageShiftList> createState() => _CoverageShiftListState();
|
||||
}
|
||||
|
||||
/// State for [CoverageShiftList] managing which shift cards are expanded.
|
||||
class _CoverageShiftListState extends State<CoverageShiftList> {
|
||||
/// Set of shift IDs whose cards are currently expanded.
|
||||
final Set<String> _expandedShiftIds = <String>{};
|
||||
|
||||
/// Whether the expanded set has been initialised from the first build.
|
||||
bool _initialised = false;
|
||||
|
||||
/// Formats a [DateTime] to a readable time string (h:mm a).
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return '';
|
||||
return DateFormat('h:mm a').format(time);
|
||||
}
|
||||
|
||||
/// Toggles the expanded / collapsed state for the shift with [shiftId].
|
||||
void _toggleShift(String shiftId) {
|
||||
setState(() {
|
||||
if (_expandedShiftIds.contains(shiftId)) {
|
||||
_expandedShiftIds.remove(shiftId);
|
||||
} else {
|
||||
_expandedShiftIds.add(shiftId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Seeds [_expandedShiftIds] with all current shift IDs on first build,
|
||||
/// and adds any new shift IDs when the widget is rebuilt with new data.
|
||||
void _ensureInitialised() {
|
||||
if (!_initialised) {
|
||||
_expandedShiftIds.addAll(
|
||||
widget.shifts.map((ShiftWithWorkers s) => s.shiftId),
|
||||
);
|
||||
_initialised = true;
|
||||
return;
|
||||
}
|
||||
// Add any new shift IDs that arrived after initial build.
|
||||
for (final ShiftWithWorkers shift in widget.shifts) {
|
||||
if (!_expandedShiftIds.contains(shift.shiftId)) {
|
||||
_expandedShiftIds.add(shift.shiftId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant CoverageShiftList oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Add newly-appeared shift IDs so they start expanded.
|
||||
for (final ShiftWithWorkers shift in widget.shifts) {
|
||||
if (!oldWidget.shifts.any(
|
||||
(ShiftWithWorkers old) => old.shiftId == shift.shiftId,
|
||||
)) {
|
||||
_expandedShiftIds.add(shift.shiftId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_ensureInitialised();
|
||||
|
||||
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
|
||||
|
||||
if (shifts.isEmpty) {
|
||||
if (widget.shifts.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space8),
|
||||
width: double.infinity,
|
||||
@@ -59,86 +117,136 @@ class CoverageShiftList extends StatelessWidget {
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: shifts.map((ShiftWithWorkers shift) {
|
||||
children: widget.shifts.map((ShiftWithWorkers shift) {
|
||||
final int coveragePercent = shift.requiredWorkerCount > 0
|
||||
? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100)
|
||||
.round()
|
||||
: 0;
|
||||
|
||||
// Per-shift worker status counts.
|
||||
final int onSite = shift.assignedWorkers
|
||||
.where(
|
||||
(AssignedWorker w) => w.status == AssignmentStatus.checkedIn,
|
||||
)
|
||||
.length;
|
||||
final int enRoute = shift.assignedWorkers
|
||||
.where(
|
||||
(AssignedWorker w) =>
|
||||
w.status == AssignmentStatus.accepted && w.checkInAt == null,
|
||||
)
|
||||
.length;
|
||||
final int lateCount = shift.assignedWorkers
|
||||
.where(
|
||||
(AssignedWorker w) => w.status == AssignmentStatus.noShow,
|
||||
)
|
||||
.length;
|
||||
|
||||
final bool isExpanded = _expandedShiftIds.contains(shift.shiftId);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radius2xl,
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ShiftHeader(
|
||||
title: shift.roleName,
|
||||
location: '', // V2 API does not return location on coverage
|
||||
startTime: _formatTime(shift.timeRange.startsAt),
|
||||
current: shift.assignedWorkerCount,
|
||||
total: shift.requiredWorkerCount,
|
||||
coveragePercent: coveragePercent,
|
||||
shiftId: shift.shiftId,
|
||||
onSiteCount: onSite,
|
||||
enRouteCount: enRoute,
|
||||
lateCount: lateCount,
|
||||
isExpanded: isExpanded,
|
||||
onToggle: () => _toggleShift(shift.shiftId),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: const SizedBox.shrink(),
|
||||
secondChild: _buildWorkerSection(shift, l10n),
|
||||
crossFadeState: isExpanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
if (shift.assignedWorkers.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
child: Column(
|
||||
children: shift.assignedWorkers
|
||||
.map<Widget>((AssignedWorker worker) {
|
||||
final bool isLast =
|
||||
worker == shift.assignedWorkers.last;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isLast ? 0 : UiConstants.space2,
|
||||
),
|
||||
child: WorkerRow(
|
||||
worker: worker,
|
||||
shiftStartTime:
|
||||
_formatTime(shift.timeRange.startsAt),
|
||||
showRateButton:
|
||||
worker.status == AssignmentStatus.checkedIn ||
|
||||
worker.status ==
|
||||
AssignmentStatus.checkedOut ||
|
||||
worker.status ==
|
||||
AssignmentStatus.completed,
|
||||
showCancelButton:
|
||||
worker.status == AssignmentStatus.noShow ||
|
||||
worker.status ==
|
||||
AssignmentStatus.assigned ||
|
||||
worker.status ==
|
||||
AssignmentStatus.accepted,
|
||||
onRate: () => WorkerReviewSheet.show(
|
||||
context,
|
||||
worker: worker,
|
||||
),
|
||||
onCancel: () => CancelLateWorkerSheet.show(
|
||||
context,
|
||||
worker: worker,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Text(
|
||||
l10n.no_workers_assigned,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the expanded worker section for a shift including divider.
|
||||
Widget _buildWorkerSection(
|
||||
ShiftWithWorkers shift,
|
||||
TranslationsClientCoverageEn l10n,
|
||||
) {
|
||||
if (shift.assignedWorkers.isEmpty) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Text(
|
||||
l10n.no_workers_assigned,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
child: Column(
|
||||
children:
|
||||
shift.assignedWorkers.map<Widget>((AssignedWorker worker) {
|
||||
final bool isLast = worker == shift.assignedWorkers.last;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isLast ? 0 : UiConstants.space2,
|
||||
),
|
||||
child: WorkerRow(
|
||||
worker: worker,
|
||||
shiftStartTime: _formatTime(shift.timeRange.startsAt),
|
||||
showRateButton:
|
||||
worker.status == AssignmentStatus.checkedIn ||
|
||||
worker.status == AssignmentStatus.checkedOut ||
|
||||
worker.status == AssignmentStatus.completed,
|
||||
showCancelButton:
|
||||
worker.status == AssignmentStatus.noShow ||
|
||||
worker.status == AssignmentStatus.assigned ||
|
||||
worker.status == AssignmentStatus.accepted,
|
||||
onRate: () => WorkerReviewSheet.show(
|
||||
context,
|
||||
worker: worker,
|
||||
),
|
||||
onCancel: () => CancelLateWorkerSheet.show(
|
||||
context,
|
||||
worker: worker,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Stat card displaying an icon, value, and label with an accent color.
|
||||
class CoverageStatCard extends StatelessWidget {
|
||||
/// Creates a [CoverageStatCard].
|
||||
const CoverageStatCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The icon to display.
|
||||
final IconData icon;
|
||||
|
||||
/// The label text describing the stat.
|
||||
final String label;
|
||||
|
||||
/// The numeric value to display.
|
||||
final String value;
|
||||
|
||||
/// The accent color for the card border, icon, and text.
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(10),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space2,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: UiConstants.space6,
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: UiTypography.title1b.copyWith(
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,72 +2,176 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Displays coverage percentage and worker ratio in the app bar header.
|
||||
/// Displays overall coverage statistics in the SliverAppBar expanded header.
|
||||
///
|
||||
/// Shows the coverage percentage, a progress bar, and real-time worker
|
||||
/// status counts (on site, en route, late) on a primary blue gradient
|
||||
/// background with a semi-transparent white container.
|
||||
class CoverageStatsHeader extends StatelessWidget {
|
||||
/// Creates a [CoverageStatsHeader].
|
||||
/// Creates a [CoverageStatsHeader] with coverage and worker status data.
|
||||
const CoverageStatsHeader({
|
||||
required this.coveragePercent,
|
||||
required this.totalConfirmed,
|
||||
required this.totalNeeded,
|
||||
required this.totalCheckedIn,
|
||||
required this.totalEnRoute,
|
||||
required this.totalLate,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current coverage percentage.
|
||||
/// The current overall coverage percentage (0-100).
|
||||
final double coveragePercent;
|
||||
|
||||
/// The number of confirmed workers.
|
||||
final int totalConfirmed;
|
||||
|
||||
/// The total number of workers needed.
|
||||
/// The total number of workers needed for full coverage.
|
||||
final int totalNeeded;
|
||||
|
||||
/// The number of workers currently checked in and on site.
|
||||
final int totalCheckedIn;
|
||||
|
||||
/// The number of workers currently en route.
|
||||
final int totalEnRoute;
|
||||
|
||||
/// The number of workers currently marked as late.
|
||||
final int totalLate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withOpacity(0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.12),
|
||||
borderRadius: UiConstants.radiusXl,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.coverage_status,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${coveragePercent.toStringAsFixed(0)}%',
|
||||
style: UiTypography.display1b.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.workers,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$totalConfirmed/$totalNeeded',
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
Expanded(
|
||||
child: _buildCoverageColumn(context),
|
||||
),
|
||||
_buildStatusColumn(context),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
_buildProgressBar(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the left column with the "Overall Coverage" label and percentage.
|
||||
Widget _buildCoverageColumn(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.overall_coverage,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${coveragePercent.toStringAsFixed(0)}%',
|
||||
style: UiTypography.display1b.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the right column with on-site, en-route, and late stat items.
|
||||
Widget _buildStatusColumn(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
_buildStatRow(
|
||||
context: context,
|
||||
value: totalCheckedIn,
|
||||
label: context.t.client_coverage.stats.on_site,
|
||||
valueColor: UiColors.primaryForeground,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
_buildStatRow(
|
||||
context: context,
|
||||
value: totalEnRoute,
|
||||
label: context.t.client_coverage.stats.en_route,
|
||||
valueColor: UiColors.accent,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
_buildStatRow(
|
||||
context: context,
|
||||
value: totalLate,
|
||||
label: context.t.client_coverage.stats.late,
|
||||
valueColor: UiColors.tagError,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a single stat row with a colored number and a muted label.
|
||||
Widget _buildStatRow({
|
||||
required BuildContext context,
|
||||
required int value,
|
||||
required String label,
|
||||
required Color valueColor,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
value.toString(),
|
||||
style: UiTypography.title2b.copyWith(
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body4m.copyWith(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the horizontal progress bar indicating coverage fill.
|
||||
Widget _buildProgressBar() {
|
||||
final double clampedFraction =
|
||||
(coveragePercent / 100).clamp(0.0, 1.0);
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
child: SizedBox(
|
||||
height: 8,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: clampedFraction,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,70 +2,75 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Alert widget for displaying late workers warning.
|
||||
/// Alert banner displayed when workers are running late.
|
||||
///
|
||||
/// Shows a warning banner when workers are running late.
|
||||
/// Renders a solid red container with a warning icon, late worker count,
|
||||
/// and auto-backup status message in white text.
|
||||
class LateWorkersAlert extends StatelessWidget {
|
||||
/// Creates a [LateWorkersAlert].
|
||||
/// Creates a [LateWorkersAlert] with the given [lateCount].
|
||||
const LateWorkersAlert({
|
||||
required this.lateCount,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The number of late workers.
|
||||
/// The number of workers currently marked as late.
|
||||
final int lateCount;
|
||||
|
||||
/// Optional callback invoked when the alert is tapped.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.destructive.withValues(alpha: 0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: UiColors.destructive,
|
||||
width: 0.5,
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.destructive,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.destructive.withValues(alpha: 0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.warning,
|
||||
color: UiColors.destructive,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.alert
|
||||
.workers_running_late(n: lateCount, count: lateCount),
|
||||
style: UiTypography.body1b.textError,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.alert
|
||||
.workers_running_late(n: lateCount, count: lateCount),
|
||||
style: UiTypography.body1b.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
Text(
|
||||
context.t.client_coverage.alert.auto_backup_searching,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.textError.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.t.client_coverage.alert.auto_backup_searching,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (onTap != null)
|
||||
const Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: UiConstants.space4,
|
||||
color: UiColors.destructive,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +1,192 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart';
|
||||
|
||||
/// Header section for a shift card showing title, location, time, and coverage.
|
||||
/// Tappable header for a collapsible shift card.
|
||||
///
|
||||
/// Displays a status dot colour-coded by coverage, the shift title and time,
|
||||
/// a filled/total badge, a linear progress bar, and per-shift worker summary
|
||||
/// counts (on site, en route, late). Tapping anywhere triggers [onToggle].
|
||||
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,
|
||||
required this.shiftId,
|
||||
required this.onSiteCount,
|
||||
required this.enRouteCount,
|
||||
required this.lateCount,
|
||||
required this.isExpanded,
|
||||
required this.onToggle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The shift title.
|
||||
/// The shift role or title.
|
||||
final String title;
|
||||
|
||||
/// The shift location.
|
||||
final String location;
|
||||
|
||||
/// The formatted shift start time.
|
||||
/// Formatted shift start time (e.g. "8:00 AM").
|
||||
final String startTime;
|
||||
|
||||
/// Current number of assigned workers.
|
||||
final int current;
|
||||
|
||||
/// Total workers needed for the shift.
|
||||
/// Total workers required for the shift.
|
||||
final int total;
|
||||
|
||||
/// Coverage percentage (0-100+).
|
||||
final int coveragePercent;
|
||||
|
||||
/// The shift identifier.
|
||||
/// Unique shift identifier.
|
||||
final String shiftId;
|
||||
|
||||
/// Number of workers currently on site (checked in).
|
||||
final int onSiteCount;
|
||||
|
||||
/// Number of workers en route (accepted but not checked in).
|
||||
final int enRouteCount;
|
||||
|
||||
/// Number of workers marked as late / no-show.
|
||||
final int lateCount;
|
||||
|
||||
/// Whether the shift card is currently expanded to show workers.
|
||||
final bool isExpanded;
|
||||
|
||||
/// Callback invoked when the header is tapped to expand or collapse.
|
||||
final VoidCallback onToggle;
|
||||
|
||||
/// Returns the status colour based on [coveragePercent].
|
||||
///
|
||||
/// Green for >= 100 %, yellow for >= 80 %, red otherwise.
|
||||
Color _statusColor() {
|
||||
if (coveragePercent >= 100) {
|
||||
return UiColors.textSuccess;
|
||||
} else if (coveragePercent >= 80) {
|
||||
return UiColors.textWarning;
|
||||
}
|
||||
return UiColors.destructive;
|
||||
}
|
||||
|
||||
@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(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
final Color statusColor = _statusColor();
|
||||
final TranslationsClientCoverageStatsEn stats =
|
||||
context.t.client_coverage.stats;
|
||||
final double fillFraction =
|
||||
total > 0 ? (current / total).clamp(0.0, 1.0) : 0.0;
|
||||
|
||||
return InkWell(
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Row 1: status dot, title + time, badge, chevron.
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space2,
|
||||
height: UiConstants.space2,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
// Status dot.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
Text(
|
||||
startTime,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
// Title and start time.
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: 10,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
startTime,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Coverage badge.
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withAlpha(26),
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
child: Text(
|
||||
'$current/$total',
|
||||
style: UiTypography.body3b.copyWith(color: statusColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
// Expand / collapse chevron.
|
||||
Icon(
|
||||
isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown,
|
||||
size: 16,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CoverageBadge(
|
||||
current: current,
|
||||
total: total,
|
||||
coveragePercent: coveragePercent,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
// Progress bar.
|
||||
ClipRRect(
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
child: Container(
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.muted,
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: fillFraction,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
// Summary text: on site / en route / late.
|
||||
Text(
|
||||
'$onSiteCount ${stats.on_site} · '
|
||||
'$enRouteCount ${stats.en_route} · '
|
||||
'$lateCount ${stats.late}',
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,10 +51,6 @@ class WorkerRow extends StatelessWidget {
|
||||
Color textColor;
|
||||
IconData icon;
|
||||
String statusText;
|
||||
Color badgeBg;
|
||||
Color badgeText;
|
||||
Color badgeBorder;
|
||||
String badgeLabel;
|
||||
|
||||
switch (worker.status) {
|
||||
case AssignmentStatus.checkedIn:
|
||||
@@ -66,10 +62,6 @@ class WorkerRow extends StatelessWidget {
|
||||
statusText = l10n.status_checked_in_at(
|
||||
time: _formatCheckInTime(worker.checkInAt),
|
||||
);
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_on_site;
|
||||
case AssignmentStatus.accepted:
|
||||
if (worker.checkInAt == null) {
|
||||
bg = UiColors.textWarning.withAlpha(26);
|
||||
@@ -78,10 +70,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textWarning;
|
||||
icon = UiIcons.clock;
|
||||
statusText = l10n.status_en_route_expected(time: shiftStartTime);
|
||||
badgeBg = UiColors.textWarning.withAlpha(40);
|
||||
badgeText = UiColors.textWarning;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_en_route;
|
||||
} else {
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
@@ -89,10 +77,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_confirmed;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_confirmed;
|
||||
}
|
||||
case AssignmentStatus.noShow:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
@@ -101,10 +85,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = l10n.status_no_show;
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_no_show;
|
||||
case AssignmentStatus.checkedOut:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
@@ -112,10 +92,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_checked_out;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_done;
|
||||
case AssignmentStatus.completed:
|
||||
bg = UiColors.iconSuccess.withAlpha(26);
|
||||
border = UiColors.iconSuccess;
|
||||
@@ -123,10 +99,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_completed;
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_completed;
|
||||
case AssignmentStatus.assigned:
|
||||
case AssignmentStatus.swapRequested:
|
||||
case AssignmentStatus.cancelled:
|
||||
@@ -137,10 +109,6 @@ class WorkerRow extends StatelessWidget {
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.clock;
|
||||
statusText = worker.status.value;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = worker.status.value;
|
||||
}
|
||||
|
||||
return Container(
|
||||
@@ -213,88 +181,23 @@ class WorkerRow extends StatelessWidget {
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: badgeBorder, width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: badgeText,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showRateButton && onRate != null)
|
||||
GestureDetector(
|
||||
onTap: onRate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withAlpha(26),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.primary, width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.star,
|
||||
size: 12,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
Text(
|
||||
l10n.actions.rate,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: UiChip(
|
||||
label: l10n.actions.rate,
|
||||
size: UiChipSize.small,
|
||||
leadingIcon: UiIcons.star,
|
||||
),
|
||||
),
|
||||
if (showCancelButton && onCancel != null)
|
||||
if (!showCancelButton && onCancel != null)
|
||||
GestureDetector(
|
||||
onTap: onCancel,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.destructive.withAlpha(26),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(
|
||||
color: UiColors.destructive,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.close,
|
||||
size: 12,
|
||||
color: UiColors.destructive,
|
||||
),
|
||||
Text(
|
||||
l10n.actions.cancel,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: UiChip(
|
||||
label: l10n.actions.cancel,
|
||||
size: UiChipSize.small,
|
||||
leadingIcon: UiIcons.close,
|
||||
variant: UiChipVariant.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user