Merge branch '312-feature-integrate-google-maps-places-autocomplete-for-hub-address-validation' into fix_staff_app_bugs

This commit is contained in:
José Salazar
2026-02-02 22:33:10 +09:00
50 changed files with 2315 additions and 884 deletions

View File

@@ -200,6 +200,8 @@ class _BillingViewState extends State<BillingView> {
const SpendingBreakdownCard(),
if (state.invoiceHistory.isEmpty) _buildEmptyState(context)
else InvoiceHistorySection(invoices: state.invoiceHistory),
const SizedBox(height: UiConstants.space32),
],
),
);

View File

@@ -1,7 +1,7 @@
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/coverage_repository.dart';
import '../../domain/ui_entities/coverage_entities.dart';
/// Implementation of [CoverageRepository] in the Data layer.
///
@@ -25,18 +25,14 @@ class CoverageRepositoryImpl implements CoverageRepository {
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
print('Coverage: now=${DateTime.now().toIso8601String()}');
if (businessId == null || businessId.isEmpty) {
print('Coverage: missing businessId for date=${date.toIso8601String()}');
return <CoverageShift>[];
}
final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end =
DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
print(
'Coverage: request businessId=$businessId dayStart=${start.toIso8601String()} dayEnd=${end.toIso8601String()}',
);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
@@ -58,9 +54,6 @@ class CoverageRepositoryImpl implements CoverageRepository {
dayEnd: _toTimestamp(end),
)
.execute();
print(
'Coverage: ${date.toIso8601String()} staffsApplications=${applicationsResult.data.applications.length}',
);
return _mapCoverageShifts(
shiftRolesResult.data.shiftRoles,
@@ -84,11 +77,16 @@ class CoverageRepositoryImpl implements CoverageRepository {
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;
final int checkedIn = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.checkedIn)
.length;
final int enRoute = allWorkers
.where((CoverageWorker w) =>
w.status == CoverageWorkerStatus.confirmed && w.checkInTime == null)
.length;
final int late = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.late)
.length;
return CoverageStats(
totalNeeded: totalNeeded,
@@ -172,25 +170,32 @@ class CoverageRepositoryImpl implements CoverageRepository {
.toList();
}
String _mapWorkerStatus(
CoverageWorkerStatus _mapWorkerStatus(
dc.EnumValue<dc.ApplicationStatus> status,
) {
if (status is dc.Known<dc.ApplicationStatus>) {
switch (status.value) {
case dc.ApplicationStatus.LATE:
return 'late';
case dc.ApplicationStatus.CHECKED_IN:
case dc.ApplicationStatus.CHECKED_OUT:
case dc.ApplicationStatus.ACCEPTED:
case dc.ApplicationStatus.CONFIRMED:
case dc.ApplicationStatus.PENDING:
return CoverageWorkerStatus.pending;
case dc.ApplicationStatus.ACCEPTED:
return CoverageWorkerStatus.confirmed;
case dc.ApplicationStatus.REJECTED:
return CoverageWorkerStatus.rejected;
case dc.ApplicationStatus.CONFIRMED:
return CoverageWorkerStatus.confirmed;
case dc.ApplicationStatus.CHECKED_IN:
return CoverageWorkerStatus.checkedIn;
case dc.ApplicationStatus.CHECKED_OUT:
return CoverageWorkerStatus.checkedOut;
case dc.ApplicationStatus.LATE:
return CoverageWorkerStatus.late;
case dc.ApplicationStatus.NO_SHOW:
return CoverageWorkerStatus.noShow;
case dc.ApplicationStatus.COMPLETED:
return 'confirmed';
return CoverageWorkerStatus.completed;
}
}
return 'confirmed';
return CoverageWorkerStatus.pending;
}
String? _formatTime(fdc.Timestamp? timestamp) {

View File

@@ -1,4 +1,4 @@
import '../ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for coverage-related operations.
///

View File

@@ -1,133 +0,0 @@
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 0;
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 0;
return ((totalConfirmed / totalNeeded) * 100).round();
}
@override
List<Object?> get props => <Object?>[
totalNeeded,
totalConfirmed,
checkedIn,
enRoute,
late,
];
}

View File

@@ -1,7 +1,8 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/get_coverage_stats_arguments.dart';
import '../repositories/coverage_repository.dart';
import '../ui_entities/coverage_entities.dart';
/// Use case for fetching coverage statistics for a specific date.
///

View File

@@ -1,7 +1,7 @@
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';
import 'package:krow_domain/krow_domain.dart';
/// Use case for fetching shifts for a specific date.
///

View File

@@ -1,7 +1,7 @@
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 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_coverage_stats_usecase.dart';
import '../../domain/usecases/get_shifts_for_date_usecase.dart';
import 'coverage_event.dart';

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../domain/ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
/// Enum representing the status of coverage data loading.
enum CoverageStatus {

View File

@@ -1,6 +1,6 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
/// Quick statistics cards showing coverage metrics.
///

View File

@@ -1,7 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../domain/ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
/// List of shifts with their workers.
///
@@ -194,7 +194,8 @@ class _ShiftHeader extends StatelessWidget {
size: UiConstants.space3,
color: UiColors.iconSecondary,
),
Expanded(child: Text(
Expanded(
child: Text(
location,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
@@ -314,36 +315,92 @@ class _WorkerRow extends StatelessWidget {
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';
switch (worker.status) {
case CoverageWorkerStatus.checkedIn:
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';
case CoverageWorkerStatus.confirmed:
if (worker.checkInTime == null) {
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.muted.withOpacity(0.1);
border = UiColors.border;
textBg = UiColors.muted.withOpacity(0.2);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = 'Confirmed';
badgeBg = UiColors.muted;
badgeText = UiColors.textPrimary;
badgeLabel = 'Confirmed';
}
case CoverageWorkerStatus.late:
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';
case CoverageWorkerStatus.checkedOut:
bg = UiColors.muted.withOpacity(0.1);
border = UiColors.border;
textBg = UiColors.muted.withOpacity(0.2);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = 'Checked Out';
badgeBg = UiColors.muted;
badgeText = UiColors.textPrimary;
badgeLabel = 'Done';
case CoverageWorkerStatus.noShow:
bg = UiColors.destructive.withOpacity(0.1);
border = UiColors.destructive;
textBg = UiColors.destructive.withOpacity(0.2);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = 'No Show';
badgeBg = UiColors.destructive;
badgeText = UiColors.destructiveForeground;
badgeLabel = 'No Show';
case CoverageWorkerStatus.completed:
bg = UiColors.textSuccess.withOpacity(0.1);
border = UiColors.textSuccess;
textBg = UiColors.textSuccess.withOpacity(0.2);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = 'Completed';
badgeBg = UiColors.textSuccess;
badgeText = UiColors.primaryForeground;
badgeLabel = 'Completed';
case CoverageWorkerStatus.pending:
case CoverageWorkerStatus.accepted:
case CoverageWorkerStatus.rejected:
bg = UiColors.muted.withOpacity(0.1);
border = UiColors.border;
textBg = UiColors.muted.withOpacity(0.2);
textColor = UiColors.textSecondary;
icon = UiIcons.clock;
statusText = worker.status.name.toUpperCase();
badgeBg = UiColors.muted;
badgeText = UiColors.textPrimary;
badgeLabel = worker.status.name[0].toUpperCase() +
worker.status.name.substring(1);
}
return Container(

View File

@@ -43,14 +43,11 @@ class ReorderWidget extends StatelessWidget {
),
if (subtitle != null) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Text(
subtitle!,
style: UiTypography.body2r.textSecondary,
),
Text(subtitle!, style: UiTypography.body2r.textSecondary),
],
const SizedBox(height: UiConstants.space2),
SizedBox(
height: 140,
height: 164,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recentOrders.length,
@@ -67,13 +64,7 @@ class ReorderWidget extends StatelessWidget {
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.02),
blurRadius: 4,
),
],
border: Border.all(color: UiColors.border, width: 0.6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -129,10 +120,7 @@ class ReorderWidget extends StatelessWidget {
style: UiTypography.body1b,
),
Text(
i18n.per_hr(
amount: order.hourlyRate.toString(),
) +
' · ${order.hours}h',
'${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h',
style: UiTypography.footnote2r.textSecondary,
),
],
@@ -145,49 +133,37 @@ class ReorderWidget extends StatelessWidget {
_Badge(
icon: UiIcons.success,
text: order.type,
color: const Color(0xFF2563EB),
bg: const Color(0xFF2563EB),
textColor: UiColors.white,
color: UiColors.primary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.primary,
),
const SizedBox(width: UiConstants.space2),
_Badge(
icon: UiIcons.building,
text: '${order.workers}',
color: const Color(0xFF334155),
bg: const Color(0xFFF1F5F9),
textColor: const Color(0xFF334155),
color: UiColors.textSecondary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.textSecondary,
),
],
),
const Spacer(),
SizedBox(
height: 28,
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => onReorderPressed(<String, dynamic>{
'orderId': order.orderId,
'title': order.title,
'location': order.location,
'hourlyRate': order.hourlyRate,
'hours': order.hours,
'workers': order.workers,
'type': order.type,
}),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusMd,
),
elevation: 0,
),
icon: const Icon(UiIcons.zap, size: 12),
label: Text(
i18n.reorder_button,
style: UiTypography.footnote1m,
),
),
UiButton.secondary(
size: UiButtonSize.small,
text: i18n.reorder_button,
leadingIcon: UiIcons.zap,
iconSize: 12,
fullWidth: true,
onPressed: () => onReorderPressed(<String, dynamic>{
'orderId': order.orderId,
'title': order.title,
'location': order.location,
'hourlyRate': order.hourlyRate,
'hours': order.hours,
'workers': order.workers,
'type': order.type,
}),
),
],
),

View File

@@ -35,7 +35,7 @@ class SettingsProfileHeader extends StatelessWidget {
flexibleSpace: FlexibleSpaceBar(
background: Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space8),
margin: const EdgeInsets.only(top: UiConstants.space16),
margin: const EdgeInsets.only(top: UiConstants.space24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,

View File

@@ -1,4 +1,3 @@
import 'dart:ui';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -10,6 +9,7 @@ import '../blocs/view_orders_cubit.dart';
import '../blocs/view_orders_state.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/view_order_card.dart';
import '../widgets/view_orders_header.dart';
import '../navigation/view_orders_navigator.dart';
/// The main page for viewing client orders.
@@ -22,6 +22,7 @@ class ViewOrdersPage extends StatelessWidget {
/// Creates a [ViewOrdersPage].
const ViewOrdersPage({super.key, this.initialDate});
/// The initial date to display orders for.
final DateTime? initialDate;
@override
@@ -37,7 +38,8 @@ class ViewOrdersPage extends StatelessWidget {
class ViewOrdersView extends StatefulWidget {
/// Creates a [ViewOrdersView].
const ViewOrdersView({super.key, this.initialDate});
/// The initial date to display orders for.
final DateTime? initialDate;
@override
@@ -88,376 +90,84 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
}
return Scaffold(
body: Stack(
children: <Widget>[
// Background Gradient
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[UiColors.bgSecondary, UiColors.white],
stops: <double>[0.0, 0.3],
),
body: SafeArea(
child: Column(
children: <Widget>[
// Header + Filter + Calendar (Sticky behavior)
ViewOrdersHeader(
state: state,
calendarDays: calendarDays,
),
),
SafeArea(
child: Column(
children: <Widget>[
// Header + Filter + Calendar (Sticky behavior)
_buildHeader(
context: context,
state: state,
calendarDays: calendarDays,
),
// Content List
Expanded(
child: filteredOrders.isEmpty
? _buildEmptyState(context: context, state: state)
: ListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space4,
UiConstants.space5,
100,
),
children: <Widget>[
if (filteredOrders.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: Row(
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: dotColor,
shape: BoxShape.circle,
),
),
const SizedBox(
width: UiConstants.space2,
),
Text(
sectionTitle.toUpperCase(),
style: UiTypography.titleUppercase2m
.copyWith(
color: UiColors.textPrimary,
),
),
const SizedBox(
width: UiConstants.space1,
),
Text(
'(${filteredOrders.length})',
style: UiTypography.footnote1r
.copyWith(
color: UiColors.textSecondary,
),
),
],
),
),
...filteredOrders.map(
(OrderItem order) => Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: ViewOrderCard(order: order),
),
// Content List
Expanded(
child: filteredOrders.isEmpty
? _buildEmptyState(context: context, state: state)
: ListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space4,
UiConstants.space5,
100,
),
children: <Widget>[
if (filteredOrders.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
],
child: Row(
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: dotColor,
shape: BoxShape.circle,
),
),
const SizedBox(
width: UiConstants.space2,
),
Text(
sectionTitle.toUpperCase(),
style: UiTypography.titleUppercase2m
.copyWith(
color: UiColors.textPrimary,
),
),
const SizedBox(
width: UiConstants.space1,
),
Text(
'(${filteredOrders.length})',
style: UiTypography.footnote1r
.copyWith(
color: UiColors.textSecondary,
),
),
],
),
),
...filteredOrders.map(
(OrderItem order) => Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: ViewOrderCard(order: order),
),
),
),
],
],
),
),
),
],
],
),
),
);
},
);
}
/// Builds the sticky header section.
Widget _buildHeader({
required BuildContext context,
required ViewOrdersState state,
required List<DateTime> calendarDays,
}) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: const BoxDecoration(
color: Color(0xCCFFFFFF), // White with 0.8 alpha
border: Border(
bottom: BorderSide(color: UiColors.separatorSecondary),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// Top Bar
Padding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
UiConstants.space3,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_view_orders.title,
style: UiTypography.headline3m.copyWith(
color: UiColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
if (state.filteredOrders.isNotEmpty)
UiButton.primary(
text: t.client_view_orders.post_button,
leadingIcon: UiIcons.add,
onPressed: () => Modular.to.navigateToCreateOrder(),
size: UiButtonSize.small,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 48),
maximumSize: const Size(0, 48),
),
),
],
),
),
// Filter Tabs
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_buildFilterTab(
context,
label: t.client_view_orders.tabs.up_next,
isSelected: state.filterTab == 'all',
tabId: 'all',
count: state.upNextCount,
),
const SizedBox(width: UiConstants.space6),
_buildFilterTab(
context,
label: t.client_view_orders.tabs.active,
isSelected: state.filterTab == 'active',
tabId: 'active',
count: state.activeCount,
),
const SizedBox(width: UiConstants.space6),
_buildFilterTab(
context,
label: t.client_view_orders.tabs.completed,
isSelected: state.filterTab == 'completed',
tabId: 'completed',
count: state.completedCount,
),
],
),
),
// Calendar Header controls
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
vertical: UiConstants.space2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
IconButton(
icon: const Icon(
UiIcons.chevronLeft,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(-1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
Text(
DateFormat('MMMM yyyy').format(calendarDays.first),
style: UiTypography.body2m.copyWith(
color: UiColors.textSecondary,
),
),
IconButton(
icon: const Icon(
UiIcons.chevronRight,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
],
),
),
// Calendar Grid
SizedBox(
height: 72,
child: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
scrollDirection: Axis.horizontal,
itemCount: 7,
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: UiConstants.space2),
itemBuilder: (BuildContext context, int index) {
final DateTime date = calendarDays[index];
final bool isSelected =
state.selectedDate != null &&
date.year == state.selectedDate!.year &&
date.month == state.selectedDate!.month &&
date.day == state.selectedDate!.day;
// Check if this date has any shifts
final String dateStr = DateFormat(
'yyyy-MM-dd',
).format(date);
final bool hasShifts = state.orders.any(
(OrderItem s) => s.date == dateStr,
);
return GestureDetector(
onTap: () => BlocProvider.of<ViewOrdersCubit>(
context,
).selectDate(date),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 48,
decoration: BoxDecoration(
color: isSelected ? UiColors.primary : UiColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? UiColors.primary
: UiColors.separatorPrimary,
),
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.primary.withValues(
alpha: 0.25,
),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('dd').format(date),
style: UiTypography.title2b.copyWith(
fontSize: 18,
color: isSelected
? UiColors.white
: UiColors.textPrimary,
),
),
Text(
DateFormat('E').format(date),
style: UiTypography.footnote2m.copyWith(
color: isSelected
? UiColors.white.withValues(alpha: 0.8)
: UiColors.textSecondary,
),
),
if (hasShifts) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected
? UiColors.white
: UiColors.primary,
shape: BoxShape.circle,
),
),
],
],
),
),
);
},
),
),
const SizedBox(height: UiConstants.space4),
],
),
),
),
);
}
/// Builds a single filter tab.
Widget _buildFilterTab(
BuildContext context, {
required String label,
required bool isSelected,
required String tabId,
int? count,
}) {
String text = label;
if (count != null) {
text = '$label ($count)';
}
return GestureDetector(
onTap: () =>
BlocProvider.of<ViewOrdersCubit>(context).selectFilterTab(tabId),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(
text,
style: UiTypography.body2m.copyWith(
color: isSelected ? UiColors.primary : UiColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 2,
width: isSelected ? 40 : 0,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
if (!isSelected) const SizedBox(height: 2),
],
),
);
}
/// Builds the empty state view.
Widget _buildEmptyState({
required BuildContext context,

View File

@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import 'package:url_launcher/url_launcher.dart';
import '../blocs/view_orders_cubit.dart';
/// A rich card displaying details of a client order/shift.
@@ -325,15 +326,23 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
children: <Widget>[
Row(
children: <Widget>[
if (order.workersNeeded != 0)
if (coveragePercent != 100)
const Icon(
UiIcons.error,
size: 16,
color: UiColors.textError,
),
if (coveragePercent == 100)
const Icon(
UiIcons.checkCircle,
size: 16,
color: UiColors.textSuccess,
),
const SizedBox(width: 8),
Text(
'${order.workersNeeded} Workers Needed',
coveragePercent == 100
? 'All Workers Confirmed'
: '${order.workersNeeded} Workers Needed',
style: UiTypography.body2m.textPrimary,
),
],

View File

@@ -0,0 +1,68 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/view_orders_cubit.dart';
/// A single filter tab for the View Orders page.
///
/// Displays a label with an optional count and shows a selection indicator
/// when the tab is active.
class ViewOrdersFilterTab extends StatelessWidget {
/// Creates a [ViewOrdersFilterTab].
const ViewOrdersFilterTab({
required this.label,
required this.isSelected,
required this.tabId,
this.count,
super.key,
});
/// The label text to display.
final String label;
/// Whether this tab is currently selected.
final bool isSelected;
/// The unique identifier for this tab.
final String tabId;
/// Optional count to display next to the label.
final int? count;
@override
Widget build(BuildContext context) {
String text = label;
if (count != null) {
text = '$label ($count)';
}
return GestureDetector(
onTap: () =>
BlocProvider.of<ViewOrdersCubit>(context).selectFilterTab(tabId),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(
text,
style: UiTypography.body2m.copyWith(
color: isSelected ? UiColors.primary : UiColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 2,
width: isSelected ? 40 : 0,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
if (!isSelected) const SizedBox(height: 2),
],
),
);
}
}

View File

@@ -0,0 +1,268 @@
import 'dart:ui';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/view_orders_cubit.dart';
import '../blocs/view_orders_state.dart';
import '../navigation/view_orders_navigator.dart';
import 'view_orders_filter_tab.dart';
/// The sticky header section for the View Orders page.
///
/// This widget contains:
/// - Top bar with title and post button
/// - Filter tabs (Up Next, Active, Completed)
/// - Calendar navigation controls
/// - Horizontal calendar grid
class ViewOrdersHeader extends StatelessWidget {
/// Creates a [ViewOrdersHeader].
const ViewOrdersHeader({
required this.state,
required this.calendarDays,
super.key,
});
/// The current state of the view orders feature.
final ViewOrdersState state;
/// The list of calendar days to display.
final List<DateTime> calendarDays;
@override
Widget build(BuildContext context) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: const BoxDecoration(
color: Color(0xCCFFFFFF), // White with 0.8 alpha
border: Border(
bottom: BorderSide(color: UiColors.separatorSecondary),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// Top Bar
Padding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
UiConstants.space3,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_view_orders.title,
style: UiTypography.headline3m.copyWith(
color: UiColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
if (state.filteredOrders.isNotEmpty)
UiButton.primary(
text: t.client_view_orders.post_button,
leadingIcon: UiIcons.add,
onPressed: () => Modular.to.navigateToCreateOrder(),
size: UiButtonSize.small,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 48),
maximumSize: const Size(0, 48),
),
),
],
),
),
// Filter Tabs
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ViewOrdersFilterTab(
label: t.client_view_orders.tabs.up_next,
isSelected: state.filterTab == 'all',
tabId: 'all',
count: state.upNextCount,
),
const SizedBox(width: UiConstants.space6),
ViewOrdersFilterTab(
label: t.client_view_orders.tabs.active,
isSelected: state.filterTab == 'active',
tabId: 'active',
count: state.activeCount,
),
const SizedBox(width: UiConstants.space6),
ViewOrdersFilterTab(
label: t.client_view_orders.tabs.completed,
isSelected: state.filterTab == 'completed',
tabId: 'completed',
count: state.completedCount,
),
],
),
),
// Calendar Header controls
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
vertical: UiConstants.space2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
IconButton(
icon: const Icon(
UiIcons.chevronLeft,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(-1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
Text(
DateFormat('MMMM yyyy').format(calendarDays.first),
style: UiTypography.body2m.copyWith(
color: UiColors.textSecondary,
),
),
IconButton(
icon: const Icon(
UiIcons.chevronRight,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
],
),
),
// Calendar Grid
SizedBox(
height: 72,
child: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
scrollDirection: Axis.horizontal,
itemCount: 7,
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: UiConstants.space2),
itemBuilder: (BuildContext context, int index) {
final DateTime date = calendarDays[index];
final bool isSelected =
state.selectedDate != null &&
date.year == state.selectedDate!.year &&
date.month == state.selectedDate!.month &&
date.day == state.selectedDate!.day;
// Check if this date has any shifts
final String dateStr = DateFormat(
'yyyy-MM-dd',
).format(date);
final bool hasShifts = state.orders.any(
(OrderItem s) => s.date == dateStr,
);
// Check if date is in the past
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime checkDate = DateTime(date.year, date.month, date.day);
final bool isPast = checkDate.isBefore(today);
return Opacity(
opacity: isPast && !isSelected ? 0.5 : 1.0,
child: GestureDetector(
onTap: () => BlocProvider.of<ViewOrdersCubit>(
context,
).selectDate(date),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 48,
decoration: BoxDecoration(
color: isSelected ? UiColors.primary : UiColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? UiColors.primary
: UiColors.separatorPrimary,
),
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.primary.withValues(
alpha: 0.25,
),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('dd').format(date),
style: UiTypography.title2b.copyWith(
fontSize: 18,
color: isSelected
? UiColors.white
: UiColors.textPrimary,
),
),
Text(
DateFormat('E').format(date),
style: UiTypography.footnote2m.copyWith(
color: isSelected
? UiColors.white.withValues(alpha: 0.8)
: UiColors.textSecondary,
),
),
if (hasShifts) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected
? UiColors.white
: UiColors.primary,
shape: BoxShape.circle,
),
),
],
],
),
),
),
);
},
),
),
const SizedBox(height: UiConstants.space4),
],
),
),
),
);
}
}

View File

@@ -25,10 +25,14 @@ dependencies:
path: ../../../domain
krow_core:
path: ../../../core
krow_data_connect:
path: ../../../data_connect
# UI
lucide_icons: ^0.257.0
intl: ^0.20.1
url_launcher: ^6.3.1
firebase_data_connect: ^0.2.2+2
firebase_auth: ^6.1.4
dev_dependencies:
flutter_test: