Merge branch 'dev' into feature/session-persistence-new
This commit is contained in:
@@ -24,20 +24,20 @@ class BillingModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addSingleton<BillingRepository>(BillingRepositoryImpl.new);
|
||||
i.addLazySingleton<BillingRepository>(BillingRepositoryImpl.new);
|
||||
|
||||
// Use Cases
|
||||
i.addSingleton(GetBankAccountsUseCase.new);
|
||||
i.addSingleton(GetCurrentBillAmountUseCase.new);
|
||||
i.addSingleton(GetSavingsAmountUseCase.new);
|
||||
i.addSingleton(GetPendingInvoicesUseCase.new);
|
||||
i.addSingleton(GetInvoiceHistoryUseCase.new);
|
||||
i.addSingleton(GetSpendingBreakdownUseCase.new);
|
||||
i.addSingleton(ApproveInvoiceUseCase.new);
|
||||
i.addSingleton(DisputeInvoiceUseCase.new);
|
||||
i.addLazySingleton(GetBankAccountsUseCase.new);
|
||||
i.addLazySingleton(GetCurrentBillAmountUseCase.new);
|
||||
i.addLazySingleton(GetSavingsAmountUseCase.new);
|
||||
i.addLazySingleton(GetPendingInvoicesUseCase.new);
|
||||
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
|
||||
i.addLazySingleton(GetSpendingBreakdownUseCase.new);
|
||||
i.addLazySingleton(ApproveInvoiceUseCase.new);
|
||||
i.addLazySingleton(DisputeInvoiceUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.addSingleton<BillingBloc>(
|
||||
i.addLazySingleton<BillingBloc>(
|
||||
() => BillingBloc(
|
||||
getBankAccounts: i.get<GetBankAccountsUseCase>(),
|
||||
getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:krow_core/core.dart';
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../widgets/billing_page_skeleton.dart';
|
||||
import '../widgets/invoice_history_section.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
import '../widgets/spending_breakdown_card.dart';
|
||||
@@ -179,10 +180,7 @@ class _BillingViewState extends State<BillingView> {
|
||||
|
||||
Widget _buildContent(BuildContext context, BillingState state) {
|
||||
if (state.status == BillingStatus.loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(UiConstants.space10),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
return const BillingPageSkeleton();
|
||||
}
|
||||
|
||||
if (state.status == BillingStatus.failure) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../widgets/invoices_list_skeleton.dart';
|
||||
|
||||
class InvoiceReadyPage extends StatelessWidget {
|
||||
const InvoiceReadyPage({super.key});
|
||||
@@ -30,7 +31,7 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
body: BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
if (state.status == BillingStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const InvoicesListSkeleton();
|
||||
}
|
||||
|
||||
if (state.invoiceHistory.isEmpty) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:krow_core/core.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../widgets/invoices_list_skeleton.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
|
||||
class PendingInvoicesPage extends StatelessWidget {
|
||||
@@ -31,7 +32,7 @@ class PendingInvoicesPage extends StatelessWidget {
|
||||
|
||||
Widget _buildBody(BuildContext context, BillingState state) {
|
||||
if (state.status == BillingStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const InvoicesListSkeleton();
|
||||
}
|
||||
|
||||
if (state.pendingInvoices.isEmpty) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export 'billing_page_skeleton/index.dart';
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'breakdown_row_skeleton.dart';
|
||||
import 'invoice_card_skeleton.dart';
|
||||
|
||||
/// Shimmer loading skeleton for the billing page content area.
|
||||
///
|
||||
/// Mimics the loaded layout with a pending invoices section,
|
||||
/// a spending breakdown card, and an invoice history list.
|
||||
class BillingPageSkeleton extends StatelessWidget {
|
||||
/// Creates a [BillingPageSkeleton].
|
||||
const BillingPageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Pending invoices section header
|
||||
const UiShimmerSectionHeader(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Pending invoice cards
|
||||
const InvoiceCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const InvoiceCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Spending breakdown card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UiShimmerLine(width: 160, height: 16),
|
||||
SizedBox(height: UiConstants.space4),
|
||||
// Breakdown rows
|
||||
BreakdownRowSkeleton(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
BreakdownRowSkeleton(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
BreakdownRowSkeleton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Invoice history section header
|
||||
const UiShimmerSectionHeader(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const UiShimmerListItem(),
|
||||
const UiShimmerListItem(),
|
||||
const UiShimmerListItem(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for a spending breakdown row.
|
||||
class BreakdownRowSkeleton extends StatelessWidget {
|
||||
/// Creates a [BreakdownRowSkeleton].
|
||||
const BreakdownRowSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
UiShimmerLine(width: 100, height: 14),
|
||||
UiShimmerLine(width: 60, height: 14),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'billing_page_skeleton.dart';
|
||||
export 'breakdown_row_skeleton.dart';
|
||||
export 'invoice_card_skeleton.dart';
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for a single pending invoice card.
|
||||
class InvoiceCardSkeleton extends StatelessWidget {
|
||||
/// Creates an [InvoiceCardSkeleton].
|
||||
const InvoiceCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
UiShimmerBox(
|
||||
width: 72,
|
||||
height: 24,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
const UiShimmerLine(width: 80, height: 12),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const UiShimmerLine(width: 200, height: 16),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const UiShimmerLine(width: 160, height: 12),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 18),
|
||||
],
|
||||
),
|
||||
UiShimmerBox(
|
||||
width: 100,
|
||||
height: 36,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer loading skeleton for invoice list pages.
|
||||
///
|
||||
/// Used by both [PendingInvoicesPage] and [InvoiceReadyPage] to show
|
||||
/// placeholder cards while data loads.
|
||||
class InvoicesListSkeleton extends StatelessWidget {
|
||||
/// Creates an [InvoicesListSkeleton].
|
||||
const InvoicesListSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
children: List.generate(4, (int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
UiShimmerBox(
|
||||
width: 64,
|
||||
height: 22,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
const UiShimmerLine(width: 80, height: 12),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const UiShimmerLine(width: 180, height: 16),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const UiShimmerLine(width: 140, height: 12),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const Divider(color: UiColors.border),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 20),
|
||||
],
|
||||
),
|
||||
UiShimmerBox(
|
||||
width: 100,
|
||||
height: 36,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,14 @@ class CoverageModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addSingleton<CoverageRepository>(CoverageRepositoryImpl.new);
|
||||
i.addLazySingleton<CoverageRepository>(CoverageRepositoryImpl.new);
|
||||
|
||||
// Use Cases
|
||||
i.addSingleton(GetShiftsForDateUseCase.new);
|
||||
i.addSingleton(GetCoverageStatsUseCase.new);
|
||||
i.addLazySingleton(GetShiftsForDateUseCase.new);
|
||||
i.addLazySingleton(GetCoverageStatsUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.addSingleton<CoverageBloc>(CoverageBloc.new);
|
||||
i.addLazySingleton<CoverageBloc>(CoverageBloc.new);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
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:krow_core/core.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
|
||||
import '../blocs/coverage_bloc.dart';
|
||||
import '../blocs/coverage_event.dart';
|
||||
import '../blocs/coverage_state.dart';
|
||||
|
||||
import '../widgets/coverage_calendar_selector.dart';
|
||||
import '../widgets/coverage_page_skeleton.dart';
|
||||
import '../widgets/coverage_quick_stats.dart';
|
||||
import '../widgets/coverage_shift_list.dart';
|
||||
import '../widgets/coverage_stats_header.dart';
|
||||
import '../widgets/late_workers_alert.dart';
|
||||
|
||||
/// Page for displaying daily coverage information.
|
||||
@@ -60,7 +61,8 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
child: Scaffold(
|
||||
body: BlocConsumer<CoverageBloc, CoverageState>(
|
||||
listener: (BuildContext context, CoverageState state) {
|
||||
if (state.status == CoverageStatus.failure && state.errorMessage != null) {
|
||||
if (state.status == CoverageStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.errorMessage!),
|
||||
@@ -78,27 +80,12 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
pinned: true,
|
||||
expandedHeight: 300.0,
|
||||
backgroundColor: UiColors.primary,
|
||||
leading: IconButton(
|
||||
onPressed: () => Modular.to.toClientHome(),
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.primaryForeground,
|
||||
size: UiConstants.space4,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(
|
||||
_isScrolled
|
||||
? DateFormat('MMMM d').format(selectedDate)
|
||||
: 'Daily Coverage',
|
||||
: context.t.client_coverage.page.daily_coverage,
|
||||
key: ValueKey<bool>(_isScrolled),
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
@@ -115,7 +102,7 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withOpacity(0.2),
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: const Icon(
|
||||
@@ -158,57 +145,13 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
// Coverage Stats Container
|
||||
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(
|
||||
'${state.stats?.coveragePercent ?? 0}%',
|
||||
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(
|
||||
'${state.stats?.totalConfirmed ?? 0}/${state.stats?.totalNeeded ?? 0}',
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
CoverageStatsHeader(
|
||||
coveragePercent:
|
||||
(state.stats?.coveragePercent ?? 0)
|
||||
.toDouble(),
|
||||
totalConfirmed:
|
||||
state.stats?.totalConfirmed ?? 0,
|
||||
totalNeeded: state.stats?.totalNeeded ?? 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -238,9 +181,7 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
}) {
|
||||
if (state.shifts.isEmpty) {
|
||||
if (state.status == CoverageStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
return const CoveragePageSkeleton();
|
||||
}
|
||||
|
||||
if (state.status == CoverageStatus.failure) {
|
||||
@@ -259,16 +200,16 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
Text(
|
||||
state.errorMessage != null
|
||||
? translateErrorKey(state.errorMessage!)
|
||||
: 'An error occurred',
|
||||
: context.t.client_coverage.page.error_occurred,
|
||||
style: UiTypography.body1m.textError,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
UiButton.secondary(
|
||||
text: 'Retry',
|
||||
text: context.t.client_coverage.page.retry,
|
||||
onPressed: () => BlocProvider.of<CoverageBloc>(context).add(
|
||||
const CoverageRefreshRequested(),
|
||||
),
|
||||
const CoverageRefreshRequested(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -281,22 +222,25 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space6,
|
||||
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),
|
||||
],
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
if (state.stats != null && state.stats!.late > 0) ...<Widget>[
|
||||
LateWorkersAlert(lateCount: state.stats!.late),
|
||||
],
|
||||
if (state.stats != null) ...<Widget>[
|
||||
CoverageQuickStats(stats: state.stats!),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Shifts (${state.shifts.length})',
|
||||
'${context.t.client_coverage.page.shifts} (${state.shifts.length})',
|
||||
style: UiTypography.title2b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
CoverageShiftList(shifts: state.shifts),
|
||||
const SizedBox(
|
||||
height: UiConstants.space24,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Navigation button used in the calendar selector for week navigation.
|
||||
class CalendarNavButton extends StatelessWidget {
|
||||
/// Creates a [CalendarNavButton].
|
||||
const CalendarNavButton({
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The button label text.
|
||||
final String text;
|
||||
|
||||
/// Callback when the button is 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.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Badge showing worker count ratio with color-coded coverage status.
|
||||
///
|
||||
/// Green for 100%+, yellow for 80%+, red below 80%.
|
||||
class CoverageBadge extends StatelessWidget {
|
||||
/// Creates a [CoverageBadge].
|
||||
const CoverageBadge({
|
||||
required this.current,
|
||||
required this.total,
|
||||
required this.coveragePercent,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Current number of assigned workers.
|
||||
final int current;
|
||||
|
||||
/// Total workers needed.
|
||||
final int total;
|
||||
|
||||
/// Coverage percentage used to determine badge color.
|
||||
final int coveragePercent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color bg;
|
||||
Color text;
|
||||
|
||||
if (coveragePercent >= 100) {
|
||||
bg = UiColors.textSuccess.withAlpha(40);
|
||||
text = UiColors.textSuccess;
|
||||
} else if (coveragePercent >= 80) {
|
||||
bg = UiColors.textWarning.withAlpha(40);
|
||||
text = UiColors.textWarning;
|
||||
} else {
|
||||
bg = UiColors.destructive.withAlpha(40);
|
||||
text = UiColors.destructive;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2 + UiConstants.space1,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border.all(color: text, width: 0.75),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Text(
|
||||
'$current/$total',
|
||||
style: UiTypography.body3b.copyWith(
|
||||
color: text,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'calendar_nav_button.dart';
|
||||
|
||||
/// Calendar selector widget for choosing dates.
|
||||
///
|
||||
/// Displays a week view with navigation buttons and date selection.
|
||||
@@ -74,16 +77,16 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
_NavButton(
|
||||
text: '← Prev Week',
|
||||
CalendarNavButton(
|
||||
text: context.t.client_coverage.calendar.prev_week,
|
||||
onTap: _navigatePrevWeek,
|
||||
),
|
||||
_NavButton(
|
||||
text: 'Today',
|
||||
CalendarNavButton(
|
||||
text: context.t.client_coverage.calendar.today,
|
||||
onTap: _navigateToday,
|
||||
),
|
||||
_NavButton(
|
||||
text: 'Next Week →',
|
||||
CalendarNavButton(
|
||||
text: context.t.client_coverage.calendar.next_week,
|
||||
onTap: _navigateNextWeek,
|
||||
),
|
||||
],
|
||||
@@ -145,41 +148,3 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.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.primary,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () => Modular.to.toClientHome(),
|
||||
child: Container(
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 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: UiColors.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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$totalConfirmed/$totalNeeded',
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'coverage_page_skeleton/index.dart';
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'shift_card_skeleton.dart';
|
||||
|
||||
/// 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.
|
||||
class CoveragePageSkeleton extends StatelessWidget {
|
||||
/// Creates a [CoveragePageSkeleton].
|
||||
const CoveragePageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Quick stats row (2 stat cards)
|
||||
const Row(
|
||||
children: [
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shifts section header
|
||||
const UiShimmerLine(width: 140, height: 18),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shift cards with worker rows
|
||||
const ShiftCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const ShiftCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const ShiftCardSkeleton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'coverage_page_skeleton.dart';
|
||||
export 'shift_card_skeleton.dart';
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for a single shift card with header and worker rows.
|
||||
class ShiftCardSkeleton extends StatelessWidget {
|
||||
/// Creates a [ShiftCardSkeleton].
|
||||
const ShiftCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
// Shift header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const UiShimmerLine(width: 180, height: 16),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const UiShimmerLine(width: 120, height: 12),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: [
|
||||
const UiShimmerLine(width: 80, height: 12),
|
||||
const Spacer(),
|
||||
UiShimmerBox(
|
||||
width: 60,
|
||||
height: 24,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Worker rows
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
).copyWith(bottom: UiConstants.space3),
|
||||
child: const Column(
|
||||
children: [
|
||||
UiShimmerListItem(),
|
||||
UiShimmerListItem(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
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 'coverage_stat_card.dart';
|
||||
|
||||
/// Quick statistics cards showing coverage metrics.
|
||||
///
|
||||
/// Displays checked-in, en-route, and late worker counts.
|
||||
/// Displays checked-in and en-route worker counts.
|
||||
class CoverageQuickStats extends StatelessWidget {
|
||||
/// Creates a [CoverageQuickStats].
|
||||
const CoverageQuickStats({
|
||||
@@ -18,96 +21,25 @@ class CoverageQuickStats extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
child: CoverageStatCard(
|
||||
icon: UiIcons.success,
|
||||
label: 'Checked In',
|
||||
label: context.t.client_coverage.stats.checked_in,
|
||||
value: stats.checkedIn.toString(),
|
||||
color: UiColors.iconSuccess,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
child: CoverageStatCard(
|
||||
icon: UiIcons.clock,
|
||||
label: 'En Route',
|
||||
label: context.t.client_coverage.stats.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: color.withAlpha(10),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: 0.75,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'shift_header.dart';
|
||||
import 'worker_row.dart';
|
||||
|
||||
/// List of shifts with their workers.
|
||||
///
|
||||
/// Displays all shifts for the selected date, or an empty state if none exist.
|
||||
@@ -33,6 +36,8 @@ class CoverageShiftList extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
|
||||
|
||||
if (shifts.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space8),
|
||||
@@ -51,7 +56,7 @@ class CoverageShiftList extends StatelessWidget {
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
Text(
|
||||
'No shifts scheduled for this day',
|
||||
l10n.no_shifts_day,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -71,7 +76,7 @@ class CoverageShiftList extends StatelessWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
_ShiftHeader(
|
||||
ShiftHeader(
|
||||
title: shift.title,
|
||||
location: shift.location,
|
||||
startTime: _formatTime(shift.startTime),
|
||||
@@ -91,7 +96,7 @@ class CoverageShiftList extends StatelessWidget {
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isLast ? 0 : UiConstants.space2,
|
||||
),
|
||||
child: _WorkerRow(
|
||||
child: WorkerRow(
|
||||
worker: worker,
|
||||
shiftStartTime: _formatTime(shift.startTime),
|
||||
formatTime: _formatTime,
|
||||
@@ -104,7 +109,7 @@ class CoverageShiftList extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Text(
|
||||
'No workers assigned yet',
|
||||
l10n.no_workers_assigned,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
@@ -117,414 +122,3 @@ class CoverageShiftList extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
required this.shiftId,
|
||||
});
|
||||
|
||||
/// 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;
|
||||
|
||||
/// The shift ID.
|
||||
final String shiftId;
|
||||
|
||||
@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(
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_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.withAlpha(40);
|
||||
text = UiColors.textSuccess;
|
||||
} else if (coveragePercent >= 80) {
|
||||
bg = UiColors.textWarning.withAlpha(40);
|
||||
text = UiColors.textWarning;
|
||||
} else {
|
||||
bg = UiColors.destructive.withAlpha(40);
|
||||
text = UiColors.destructive;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2 + UiConstants.space1,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border.all(color: text, width: 0.75),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Text(
|
||||
'$current/$total',
|
||||
style: UiTypography.body3b.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;
|
||||
Color badgeBorder;
|
||||
String badgeLabel;
|
||||
|
||||
switch (worker.status) {
|
||||
case CoverageWorkerStatus.checkedIn:
|
||||
bg = UiColors.textSuccess.withAlpha(26);
|
||||
border = UiColors.textSuccess;
|
||||
textBg = UiColors.textSuccess.withAlpha(51);
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = '✓ Checked In at ${formatTime(worker.checkInTime)}';
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'On Site';
|
||||
case CoverageWorkerStatus.confirmed:
|
||||
if (worker.checkInTime == null) {
|
||||
bg = UiColors.textWarning.withAlpha(26);
|
||||
border = UiColors.textWarning;
|
||||
textBg = UiColors.textWarning.withAlpha(51);
|
||||
textColor = UiColors.textWarning;
|
||||
icon = UiIcons.clock;
|
||||
statusText = 'En Route - Expected $shiftStartTime';
|
||||
badgeBg = UiColors.textWarning.withAlpha(40);
|
||||
badgeText = UiColors.textWarning;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'En Route';
|
||||
} else {
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = 'Confirmed';
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'Confirmed';
|
||||
}
|
||||
case CoverageWorkerStatus.late:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
border = UiColors.destructive;
|
||||
textBg = UiColors.destructive.withAlpha(51);
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = '⚠ Running Late';
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'Late';
|
||||
case CoverageWorkerStatus.checkedOut:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = 'Checked Out';
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'Done';
|
||||
case CoverageWorkerStatus.noShow:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
border = UiColors.destructive;
|
||||
textBg = UiColors.destructive.withAlpha(51);
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = 'No Show';
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'No Show';
|
||||
case CoverageWorkerStatus.completed:
|
||||
bg = UiColors.iconSuccess.withAlpha(26);
|
||||
border = UiColors.iconSuccess;
|
||||
textBg = UiColors.iconSuccess.withAlpha(51);
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = 'Completed';
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'Completed';
|
||||
case CoverageWorkerStatus.pending:
|
||||
case CoverageWorkerStatus.accepted:
|
||||
case CoverageWorkerStatus.rejected:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.clock;
|
||||
statusText = worker.status.name.toUpperCase();
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = worker.status.name[0].toUpperCase() +
|
||||
worker.status.name.substring(1);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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 (worker.status == CoverageWorkerStatus.checkedIn)
|
||||
UiButton.primary(
|
||||
text: context.t.client_coverage.worker_row.verify,
|
||||
size: UiButtonSize.small,
|
||||
onPressed: () {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message:
|
||||
context.t.client_coverage.worker_row.verified_message(
|
||||
name: worker.name,
|
||||
),
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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.
|
||||
class CoverageStatsHeader extends StatelessWidget {
|
||||
/// Creates a [CoverageStatsHeader].
|
||||
const CoverageStatsHeader({
|
||||
required this.coveragePercent,
|
||||
required this.totalConfirmed,
|
||||
required this.totalNeeded,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current coverage percentage.
|
||||
final double coveragePercent;
|
||||
|
||||
/// The number of confirmed workers.
|
||||
final int totalConfirmed;
|
||||
|
||||
/// The total number of workers needed.
|
||||
final int totalNeeded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
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.
|
||||
///
|
||||
/// Shows a warning banner when there are late workers.
|
||||
/// Shows a warning banner when workers are running late.
|
||||
class LateWorkersAlert extends StatelessWidget {
|
||||
/// Creates a [LateWorkersAlert].
|
||||
const LateWorkersAlert({
|
||||
@@ -22,32 +23,30 @@ class LateWorkersAlert extends StatelessWidget {
|
||||
color: UiColors.destructive.withValues(alpha: 0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: UiColors.destructive.withValues(alpha: 0.3),
|
||||
color: UiColors.destructive,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space4,
|
||||
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,
|
||||
),
|
||||
context.t.client_coverage.alert
|
||||
.workers_running_late(n: lateCount, count: lateCount),
|
||||
style: UiTypography.body1b.textError,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'$lateCount ${lateCount == 1 ? 'worker is' : 'workers are'} running late',
|
||||
context.t.client_coverage.alert.auto_backup_searching,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.destructiveForeground,
|
||||
color: UiColors.textError.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'coverage_badge.dart';
|
||||
|
||||
/// Header section for a shift card showing title, location, time, and coverage.
|
||||
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,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The shift title.
|
||||
final String title;
|
||||
|
||||
/// The shift location.
|
||||
final String location;
|
||||
|
||||
/// The formatted shift start time.
|
||||
final String startTime;
|
||||
|
||||
/// Current number of assigned workers.
|
||||
final int current;
|
||||
|
||||
/// Total workers needed for the shift.
|
||||
final int total;
|
||||
|
||||
/// Coverage percentage (0-100+).
|
||||
final int coveragePercent;
|
||||
|
||||
/// The shift identifier.
|
||||
final String shiftId;
|
||||
|
||||
@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(
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CoverageBadge(
|
||||
current: current,
|
||||
total: total,
|
||||
coveragePercent: coveragePercent,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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';
|
||||
|
||||
/// Row displaying a single worker's avatar, name, status, and badge.
|
||||
class WorkerRow extends StatelessWidget {
|
||||
/// Creates a [WorkerRow].
|
||||
const WorkerRow({
|
||||
required this.worker,
|
||||
required this.shiftStartTime,
|
||||
required this.formatTime,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The worker data to display.
|
||||
final CoverageWorker worker;
|
||||
|
||||
/// The formatted shift start time.
|
||||
final String shiftStartTime;
|
||||
|
||||
/// Callback to format a raw time string into a readable format.
|
||||
final String Function(String?) formatTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
|
||||
|
||||
Color bg;
|
||||
Color border;
|
||||
Color textBg;
|
||||
Color textColor;
|
||||
IconData icon;
|
||||
String statusText;
|
||||
Color badgeBg;
|
||||
Color badgeText;
|
||||
Color badgeBorder;
|
||||
String badgeLabel;
|
||||
|
||||
switch (worker.status) {
|
||||
case CoverageWorkerStatus.checkedIn:
|
||||
bg = UiColors.textSuccess.withAlpha(26);
|
||||
border = UiColors.textSuccess;
|
||||
textBg = UiColors.textSuccess.withAlpha(51);
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_checked_in_at(
|
||||
time: formatTime(worker.checkInTime),
|
||||
);
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_on_site;
|
||||
case CoverageWorkerStatus.confirmed:
|
||||
if (worker.checkInTime == null) {
|
||||
bg = UiColors.textWarning.withAlpha(26);
|
||||
border = UiColors.textWarning;
|
||||
textBg = UiColors.textWarning.withAlpha(51);
|
||||
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;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
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 CoverageWorkerStatus.late:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
border = UiColors.destructive;
|
||||
textBg = UiColors.destructive.withAlpha(51);
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = l10n.status_running_late;
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_late;
|
||||
case CoverageWorkerStatus.checkedOut:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
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 CoverageWorkerStatus.noShow:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
border = UiColors.destructive;
|
||||
textBg = UiColors.destructive.withAlpha(51);
|
||||
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 CoverageWorkerStatus.completed:
|
||||
bg = UiColors.iconSuccess.withAlpha(26);
|
||||
border = UiColors.iconSuccess;
|
||||
textBg = UiColors.iconSuccess.withAlpha(51);
|
||||
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 CoverageWorkerStatus.pending:
|
||||
case CoverageWorkerStatus.accepted:
|
||||
case CoverageWorkerStatus.rejected:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.clock;
|
||||
statusText = worker.status.name.toUpperCase();
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = worker.status.name[0].toUpperCase() +
|
||||
worker.status.name.substring(1);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import 'presentation/pages/client_main_page.dart';
|
||||
class ClientMainModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
i.addSingleton(ClientMainCubit.new);
|
||||
i.addLazySingleton(ClientMainCubit.new);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -8,10 +8,12 @@ import '../blocs/client_home_state.dart';
|
||||
import 'client_home_edit_mode_body.dart';
|
||||
import 'client_home_error_state.dart';
|
||||
import 'client_home_normal_mode_body.dart';
|
||||
import 'client_home_page_skeleton.dart';
|
||||
|
||||
/// Main body widget for the client home page.
|
||||
///
|
||||
/// Manages the state transitions between error, edit mode, and normal mode views.
|
||||
/// Manages the state transitions between loading, error, edit mode,
|
||||
/// and normal mode views.
|
||||
class ClientHomeBody extends StatelessWidget {
|
||||
/// Creates a [ClientHomeBody].
|
||||
const ClientHomeBody({super.key});
|
||||
@@ -31,6 +33,10 @@ class ClientHomeBody extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ClientHomeState state) {
|
||||
if (state.status == ClientHomeStatus.initial ||
|
||||
state.status == ClientHomeStatus.loading) {
|
||||
return const ClientHomePageSkeleton();
|
||||
}
|
||||
if (state.status == ClientHomeStatus.error) {
|
||||
return ClientHomeErrorState(state: state);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,15 @@ class ClientHomeEditBanner extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
||||
buildWhen: (ClientHomeState prev, ClientHomeState curr) => prev.isEditMode != curr.isEditMode,
|
||||
buildWhen: (ClientHomeState prev, ClientHomeState curr) =>
|
||||
prev.isEditMode != curr.isEditMode ||
|
||||
prev.status != curr.status,
|
||||
builder: (BuildContext context, ClientHomeState state) {
|
||||
if (state.status == ClientHomeStatus.initial ||
|
||||
state.status == ClientHomeStatus.loading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: state.isEditMode ? 80 : 0,
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../blocs/client_home_bloc.dart';
|
||||
import '../blocs/client_home_event.dart';
|
||||
import '../blocs/client_home_state.dart';
|
||||
import 'header_icon_button.dart';
|
||||
import 'client_home_header_skeleton.dart';
|
||||
|
||||
/// The header section of the client home page.
|
||||
///
|
||||
@@ -26,6 +27,11 @@ class ClientHomeHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
||||
builder: (BuildContext context, ClientHomeState state) {
|
||||
if (state.status == ClientHomeStatus.initial ||
|
||||
state.status == ClientHomeStatus.loading) {
|
||||
return const ClientHomeHeaderSkeleton();
|
||||
}
|
||||
|
||||
final String businessName = state.businessName;
|
||||
final String? photoUrl = state.photoUrl;
|
||||
final String avatarLetter = businessName.trim().isNotEmpty
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for the client home header during loading.
|
||||
///
|
||||
/// Mimics the avatar, welcome text, business name, and action buttons.
|
||||
class ClientHomeHeaderSkeleton extends StatelessWidget {
|
||||
/// Creates a [ClientHomeHeaderSkeleton].
|
||||
const ClientHomeHeaderSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
UiConstants.space3,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const UiShimmerCircle(size: UiConstants.space10),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
UiShimmerLine(width: 80, height: 12),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 120, height: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: const <Widget>[
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export 'client_home_page_skeleton/action_card_skeleton.dart';
|
||||
export 'client_home_page_skeleton/actions_section_skeleton.dart';
|
||||
export 'client_home_page_skeleton/client_home_page_skeleton.dart';
|
||||
export 'client_home_page_skeleton/coverage_section_skeleton.dart';
|
||||
export 'client_home_page_skeleton/live_activity_section_skeleton.dart';
|
||||
export 'client_home_page_skeleton/metric_card_skeleton.dart';
|
||||
export 'client_home_page_skeleton/reorder_card_skeleton.dart';
|
||||
export 'client_home_page_skeleton/reorder_section_skeleton.dart';
|
||||
export 'client_home_page_skeleton/spending_card_skeleton.dart';
|
||||
export 'client_home_page_skeleton/spending_section_skeleton.dart';
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton for a single action card with icon, title, and subtitle.
|
||||
class ActionCardSkeleton extends StatelessWidget {
|
||||
/// Creates an [ActionCardSkeleton].
|
||||
const ActionCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Column(
|
||||
children: <Widget>[
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 60, height: 14),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'action_card_skeleton.dart';
|
||||
|
||||
/// Skeleton for the two side-by-side action cards.
|
||||
class ActionsSectionSkeleton extends StatelessWidget {
|
||||
/// Creates an [ActionsSectionSkeleton].
|
||||
const ActionsSectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(child: ActionCardSkeleton()),
|
||||
SizedBox(width: UiConstants.space4),
|
||||
Expanded(child: ActionCardSkeleton()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'actions_section_skeleton.dart';
|
||||
import 'coverage_section_skeleton.dart';
|
||||
import 'live_activity_section_skeleton.dart';
|
||||
import 'reorder_section_skeleton.dart';
|
||||
import 'spending_section_skeleton.dart';
|
||||
|
||||
/// Shimmer loading skeleton for the client home page.
|
||||
///
|
||||
/// Mimics the loaded dashboard layout with action cards, reorder cards,
|
||||
/// coverage metrics, spending card, and live activity sections.
|
||||
class ClientHomePageSkeleton extends StatelessWidget {
|
||||
/// Creates a [ClientHomePageSkeleton].
|
||||
const ClientHomePageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: ListView(
|
||||
children: const <Widget>[
|
||||
// Actions section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: ActionsSectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
Divider(color: UiColors.border, height: 0.1),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Reorder section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: ReorderSectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
Divider(color: UiColors.border, height: 0.1),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Coverage section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: CoverageSectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
Divider(color: UiColors.border, height: 0.1),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Spending section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: SpendingSectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
Divider(color: UiColors.border, height: 0.1),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Live activity section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: LiveActivitySectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'metric_card_skeleton.dart';
|
||||
|
||||
/// Skeleton for the coverage metric cards row.
|
||||
class CoverageSectionSkeleton extends StatelessWidget {
|
||||
/// Creates a [CoverageSectionSkeleton].
|
||||
const CoverageSectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(child: MetricCardSkeleton()),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(child: MetricCardSkeleton()),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(child: MetricCardSkeleton()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton for the live activity section.
|
||||
class LiveActivitySectionSkeleton extends StatelessWidget {
|
||||
/// Creates a [LiveActivitySectionSkeleton].
|
||||
const LiveActivitySectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerStatsCard(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
UiShimmerListItem(),
|
||||
UiShimmerListItem(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton for a single coverage metric card.
|
||||
class MetricCardSkeleton extends StatelessWidget {
|
||||
/// Creates a [MetricCardSkeleton].
|
||||
const MetricCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
UiShimmerCircle(size: 14),
|
||||
SizedBox(width: UiConstants.space1),
|
||||
UiShimmerLine(width: 40, height: 10),
|
||||
],
|
||||
),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 32, height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton for a single reorder card.
|
||||
class ReorderCardSkeleton extends StatelessWidget {
|
||||
/// Creates a [ReorderCardSkeleton].
|
||||
const ReorderCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border, width: 0.6),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Row(
|
||||
children: <Widget>[
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 100, height: 14),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 40, height: 14),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 60, height: 10),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const Row(
|
||||
children: <Widget>[
|
||||
UiShimmerBox(width: 60, height: 22),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
UiShimmerBox(width: 36, height: 22),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
UiShimmerBox(
|
||||
width: double.infinity,
|
||||
height: 32,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'reorder_card_skeleton.dart';
|
||||
|
||||
/// Skeleton for the horizontal reorder cards list.
|
||||
class ReorderSectionSkeleton extends StatelessWidget {
|
||||
/// Creates a [ReorderSectionSkeleton].
|
||||
const ReorderSectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
SizedBox(
|
||||
height: 164,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(child: ReorderCardSkeleton()),
|
||||
SizedBox(width: UiConstants.space3),
|
||||
Expanded(child: ReorderCardSkeleton()),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton mimicking the spending card layout.
|
||||
class SpendingCardSkeleton extends StatelessWidget {
|
||||
/// Creates a [SpendingCardSkeleton].
|
||||
const SpendingCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 60, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 80, height: 22),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 50, height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 60, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 70, height: 18),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 50, height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'spending_card_skeleton.dart';
|
||||
|
||||
/// Skeleton for the spending gradient card.
|
||||
class SpendingSectionSkeleton extends StatelessWidget {
|
||||
/// Creates a [SpendingSectionSkeleton].
|
||||
const SpendingSectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
SpendingCardSkeleton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import '../blocs/client_hubs_state.dart';
|
||||
import '../widgets/hub_card.dart';
|
||||
import '../widgets/hub_empty_state.dart';
|
||||
import '../widgets/hub_info_card.dart';
|
||||
import '../widgets/hubs_page_skeleton.dart';
|
||||
|
||||
/// The main page for the client hubs feature.
|
||||
///
|
||||
@@ -94,7 +95,7 @@ class ClientHubsPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
if (state.status == ClientHubsStatus.loading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
const HubsPageSkeleton()
|
||||
else if (state.hubs.isEmpty)
|
||||
HubEmptyState(
|
||||
onAddPressed: () async {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer loading skeleton for the hubs list page.
|
||||
///
|
||||
/// Shows placeholder hub cards matching the [HubCard] layout with a
|
||||
/// leading icon box, title line, and address line.
|
||||
class HubsPageSkeleton extends StatelessWidget {
|
||||
/// Creates a [HubsPageSkeleton].
|
||||
const HubsPageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Column(
|
||||
children: List.generate(5, (int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
children: [
|
||||
// Leading icon placeholder
|
||||
UiShimmerBox(
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
// Title and address lines
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UiShimmerLine(width: 160, height: 16),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 200, height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
// Chevron placeholder
|
||||
const UiShimmerBox(width: 16, height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -4,7 +4,9 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'data/repositories_impl/client_create_order_repository_impl.dart';
|
||||
import 'data/repositories_impl/client_order_query_repository_impl.dart';
|
||||
import 'domain/repositories/client_create_order_repository_interface.dart';
|
||||
import 'domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'domain/usecases/create_one_time_order_usecase.dart';
|
||||
import 'domain/usecases/create_permanent_order_usecase.dart';
|
||||
import 'domain/usecases/create_recurring_order_usecase.dart';
|
||||
@@ -18,6 +20,7 @@ import 'presentation/pages/one_time_order_page.dart';
|
||||
import 'presentation/pages/permanent_order_page.dart';
|
||||
import 'presentation/pages/rapid_order_page.dart';
|
||||
import 'presentation/pages/recurring_order_page.dart';
|
||||
import 'presentation/pages/review_order_page.dart';
|
||||
|
||||
/// Module for the Client Create Order feature.
|
||||
///
|
||||
@@ -39,6 +42,12 @@ class ClientCreateOrderModule extends Module {
|
||||
),
|
||||
);
|
||||
|
||||
i.addLazySingleton<ClientOrderQueryRepositoryInterface>(
|
||||
() => ClientOrderQueryRepositoryImpl(
|
||||
service: i.get<dc.DataConnectService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(CreateOneTimeOrderUseCase.new);
|
||||
i.addLazySingleton(CreatePermanentOrderUseCase.new);
|
||||
@@ -57,14 +66,20 @@ class ClientCreateOrderModule extends Module {
|
||||
),
|
||||
);
|
||||
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new);
|
||||
i.add<PermanentOrderBloc>(PermanentOrderBloc.new);
|
||||
i.add<PermanentOrderBloc>(
|
||||
() => PermanentOrderBloc(
|
||||
i.get<CreatePermanentOrderUseCase>(),
|
||||
i.get<GetOrderDetailsForReorderUseCase>(),
|
||||
i.get<ClientOrderQueryRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
i.add<RecurringOrderBloc>(RecurringOrderBloc.new);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child(
|
||||
'/',
|
||||
ClientPaths.childRoute(ClientPaths.createOrder, ClientPaths.createOrder),
|
||||
child: (BuildContext context) => const ClientCreateOrderPage(),
|
||||
);
|
||||
r.child(
|
||||
@@ -95,5 +110,12 @@ class ClientCreateOrderModule extends Module {
|
||||
),
|
||||
child: (BuildContext context) => const PermanentOrderPage(),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(
|
||||
ClientPaths.createOrder,
|
||||
ClientPaths.createOrderReview,
|
||||
),
|
||||
child: (BuildContext context) => const ReviewOrderPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/models/order_hub.dart';
|
||||
import '../../domain/models/order_manager.dart';
|
||||
import '../../domain/models/order_role.dart';
|
||||
import '../../domain/repositories/client_order_query_repository_interface.dart';
|
||||
|
||||
/// Data layer implementation of [ClientOrderQueryRepositoryInterface].
|
||||
///
|
||||
/// Delegates all backend calls to [dc.DataConnectService] using the
|
||||
/// `_service.run()` pattern for automatic auth validation, token refresh,
|
||||
/// and retry logic. Each method maps Data Connect response types to the
|
||||
/// corresponding clean domain models.
|
||||
class ClientOrderQueryRepositoryImpl
|
||||
implements ClientOrderQueryRepositoryInterface {
|
||||
/// Creates an instance backed by the given [service].
|
||||
ClientOrderQueryRepositoryImpl({required dc.DataConnectService service})
|
||||
: _service = service;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<List<Vendor>> getVendors() async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector.listVendors().execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OrderRole>> getRolesByVendor(String vendorId) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => OrderRole(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OrderHub>> getHubsByOwner(String ownerId) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: ownerId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => OrderHub(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
longitude: hub.longitude,
|
||||
city: hub.city,
|
||||
state: hub.state,
|
||||
street: hub.street,
|
||||
country: hub.country,
|
||||
zipCode: hub.zipCode,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OrderManager>> getManagersByHub(String hubId) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector.listTeamMembers().execute();
|
||||
return result.data.teamMembers
|
||||
.where(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
member.teamHubId == hubId &&
|
||||
member.role is dc.Known<dc.TeamMemberRole> &&
|
||||
(member.role as dc.Known<dc.TeamMemberRole>).value ==
|
||||
dc.TeamMemberRole.MANAGER,
|
||||
)
|
||||
.map(
|
||||
(dc.ListTeamMembersTeamMembers member) => OrderManager(
|
||||
id: member.id,
|
||||
name: member.user.fullName ?? 'Unknown',
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getBusinessId() => _service.getBusinessId();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A team hub (location) available for order assignment.
|
||||
///
|
||||
/// This domain model represents a physical hub location owned by the business.
|
||||
/// It is used to populate hub selection dropdowns and to attach location
|
||||
/// details when creating shifts for an order.
|
||||
class OrderHub extends Equatable {
|
||||
/// Creates an [OrderHub] with the required [id], [name], and [address],
|
||||
/// plus optional geo-location and address component fields.
|
||||
const OrderHub({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
/// Unique identifier of the hub.
|
||||
final String id;
|
||||
|
||||
/// Human-readable display name of the hub.
|
||||
final String name;
|
||||
|
||||
/// Full street address of the hub.
|
||||
final String address;
|
||||
|
||||
/// Google Places ID, if available.
|
||||
final String? placeId;
|
||||
|
||||
/// Geographic latitude of the hub.
|
||||
final double? latitude;
|
||||
|
||||
/// Geographic longitude of the hub.
|
||||
final double? longitude;
|
||||
|
||||
/// City where the hub is located.
|
||||
final String? city;
|
||||
|
||||
/// State or province where the hub is located.
|
||||
final String? state;
|
||||
|
||||
/// Street name portion of the address.
|
||||
final String? street;
|
||||
|
||||
/// Country where the hub is located.
|
||||
final String? country;
|
||||
|
||||
/// Postal / ZIP code of the hub.
|
||||
final String? zipCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A hub manager available for assignment to an order.
|
||||
///
|
||||
/// This domain model represents a team member with a MANAGER role at a
|
||||
/// specific hub. It is used to populate the manager selection dropdown
|
||||
/// when creating or editing an order.
|
||||
class OrderManager extends Equatable {
|
||||
/// Creates an [OrderManager] with the given [id] and [name].
|
||||
const OrderManager({required this.id, required this.name});
|
||||
|
||||
/// Unique identifier of the manager (team member ID).
|
||||
final String id;
|
||||
|
||||
/// Full display name of the manager.
|
||||
final String name;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, name];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A role available for staffing positions within an order.
|
||||
///
|
||||
/// This domain model represents a staffing role fetched from the backend,
|
||||
/// decoupled from any data layer dependencies. It carries the role identity
|
||||
/// and its hourly cost so the presentation layer can populate dropdowns
|
||||
/// and calculate estimates.
|
||||
class OrderRole extends Equatable {
|
||||
/// Creates an [OrderRole] with the given [id], [name], and [costPerHour].
|
||||
const OrderRole({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.costPerHour,
|
||||
});
|
||||
|
||||
/// Unique identifier of the role.
|
||||
final String id;
|
||||
|
||||
/// Human-readable display name of the role.
|
||||
final String name;
|
||||
|
||||
/// Hourly cost rate for this role.
|
||||
final double costPerHour;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, name, costPerHour];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../models/order_hub.dart';
|
||||
import '../models/order_manager.dart';
|
||||
import '../models/order_role.dart';
|
||||
|
||||
/// Interface for querying order-related reference data.
|
||||
///
|
||||
/// This repository centralises the read-only queries that the order creation
|
||||
/// BLoCs need (vendors, roles, hubs, managers) so that they no longer depend
|
||||
/// directly on [DataConnectService] or the `krow_data_connect` package.
|
||||
///
|
||||
/// Implementations live in the data layer and translate backend responses
|
||||
/// into clean domain models.
|
||||
abstract interface class ClientOrderQueryRepositoryInterface {
|
||||
/// Returns the list of available vendors.
|
||||
///
|
||||
/// The returned [Vendor] objects come from the shared `krow_domain` package
|
||||
/// because `Vendor` is already a clean domain entity.
|
||||
Future<List<Vendor>> getVendors();
|
||||
|
||||
/// Returns the roles offered by the vendor identified by [vendorId].
|
||||
Future<List<OrderRole>> getRolesByVendor(String vendorId);
|
||||
|
||||
/// Returns the team hubs owned by the business identified by [ownerId].
|
||||
Future<List<OrderHub>> getHubsByOwner(String ownerId);
|
||||
|
||||
/// Returns the managers assigned to the hub identified by [hubId].
|
||||
///
|
||||
/// Only team members with the MANAGER role at the given hub are included.
|
||||
Future<List<OrderManager>> getManagersByHub(String hubId);
|
||||
|
||||
/// Returns the current business ID from the active client session.
|
||||
///
|
||||
/// This allows BLoCs to resolve the business ID without depending on
|
||||
/// the data layer's session store directly, keeping the presentation
|
||||
/// layer free from `krow_data_connect` imports.
|
||||
Future<String> getBusinessId();
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:client_create_order/src/domain/arguments/one_time_order_arguments.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_hub.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_manager.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_role.dart';
|
||||
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/create_one_time_order_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'one_time_order_event.dart';
|
||||
@@ -18,7 +20,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
OneTimeOrderBloc(
|
||||
this._createOneTimeOrderUseCase,
|
||||
this._getOrderDetailsForReorderUseCase,
|
||||
this._service,
|
||||
this._queryRepository,
|
||||
) : super(OneTimeOrderState.initial()) {
|
||||
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<OneTimeOrderVendorChanged>(_onVendorChanged);
|
||||
@@ -39,25 +41,11 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
}
|
||||
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
|
||||
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
|
||||
final dc.DataConnectService _service;
|
||||
final ClientOrderQueryRepositoryInterface _queryRepository;
|
||||
|
||||
Future<void> _loadVendors() async {
|
||||
final List<Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListVendorsData, void> result = await _service
|
||||
.connector
|
||||
.listVendors()
|
||||
.execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
action: () => _queryRepository.getVendors(),
|
||||
onError: (_) => add(const OneTimeOrderVendorsLoaded(<Vendor>[])),
|
||||
);
|
||||
|
||||
@@ -72,19 +60,14 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
) async {
|
||||
final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<
|
||||
dc.ListRolesByVendorIdData,
|
||||
dc.ListRolesByVendorIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
final List<OrderRole> result =
|
||||
await _queryRepository.getRolesByVendor(vendorId);
|
||||
return result
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
(OrderRole r) => OneTimeOrderRoleOption(
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
costPerHour: r.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -101,28 +84,23 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
Future<void> _loadHubs() async {
|
||||
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final fdc.QueryResult<
|
||||
dc.ListTeamHubsByOwnerIdData,
|
||||
dc.ListTeamHubsByOwnerIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
final String businessId = await _queryRepository.getBusinessId();
|
||||
final List<OrderHub> result =
|
||||
await _queryRepository.getHubsByOwner(businessId);
|
||||
return result
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => OneTimeOrderHubOption(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
longitude: hub.longitude,
|
||||
city: hub.city,
|
||||
state: hub.state,
|
||||
street: hub.street,
|
||||
country: hub.country,
|
||||
zipCode: hub.zipCode,
|
||||
(OrderHub h) => OneTimeOrderHubOption(
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
address: h.address,
|
||||
placeId: h.placeId,
|
||||
latitude: h.latitude,
|
||||
longitude: h.longitude,
|
||||
city: h.city,
|
||||
state: h.state,
|
||||
street: h.street,
|
||||
country: h.country,
|
||||
zipCode: h.zipCode,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -140,23 +118,14 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
final List<OneTimeOrderManagerOption>? managers =
|
||||
await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
|
||||
await _service.connector.listTeamMembers().execute();
|
||||
|
||||
return result.data.teamMembers
|
||||
.where(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
member.teamHubId == hubId &&
|
||||
member.role is dc.Known<dc.TeamMemberRole> &&
|
||||
(member.role as dc.Known<dc.TeamMemberRole>).value ==
|
||||
dc.TeamMemberRole.MANAGER,
|
||||
)
|
||||
final List<OrderManager> result =
|
||||
await _queryRepository.getManagersByHub(hubId);
|
||||
return result
|
||||
.map(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
OneTimeOrderManagerOption(
|
||||
id: member.id,
|
||||
name: member.user.fullName ?? 'Unknown',
|
||||
),
|
||||
(OrderManager m) => OneTimeOrderManagerOption(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
@@ -180,7 +149,11 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
? event.vendors.first
|
||||
: null;
|
||||
emit(
|
||||
state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor),
|
||||
state.copyWith(
|
||||
vendors: event.vendors,
|
||||
selectedVendor: selectedVendor,
|
||||
isDataLoaded: true,
|
||||
),
|
||||
);
|
||||
if (selectedVendor != null) {
|
||||
await _loadRolesForVendor(selectedVendor.id, emit);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../utils/time_parsing_utils.dart';
|
||||
|
||||
enum OneTimeOrderStatus { initial, loading, success, failure }
|
||||
|
||||
class OneTimeOrderState extends Equatable {
|
||||
@@ -19,6 +21,7 @@ class OneTimeOrderState extends Equatable {
|
||||
this.managers = const <OneTimeOrderManagerOption>[],
|
||||
this.selectedManager,
|
||||
this.isRapidDraft = false,
|
||||
this.isDataLoaded = false,
|
||||
});
|
||||
|
||||
factory OneTimeOrderState.initial() {
|
||||
@@ -50,6 +53,9 @@ class OneTimeOrderState extends Equatable {
|
||||
final OneTimeOrderManagerOption? selectedManager;
|
||||
final bool isRapidDraft;
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
|
||||
OneTimeOrderState copyWith({
|
||||
DateTime? date,
|
||||
String? location,
|
||||
@@ -65,6 +71,7 @@ class OneTimeOrderState extends Equatable {
|
||||
List<OneTimeOrderManagerOption>? managers,
|
||||
OneTimeOrderManagerOption? selectedManager,
|
||||
bool? isRapidDraft,
|
||||
bool? isDataLoaded,
|
||||
}) {
|
||||
return OneTimeOrderState(
|
||||
date: date ?? this.date,
|
||||
@@ -81,6 +88,7 @@ class OneTimeOrderState extends Equatable {
|
||||
managers: managers ?? this.managers,
|
||||
selectedManager: selectedManager ?? this.selectedManager,
|
||||
isRapidDraft: isRapidDraft ?? this.isRapidDraft,
|
||||
isDataLoaded: isDataLoaded ?? this.isDataLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,6 +106,77 @@ class OneTimeOrderState extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks up a role name by its ID, returns `null` if not found.
|
||||
String? roleNameById(String id) {
|
||||
for (final OneTimeOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Looks up a role cost-per-hour by its ID, returns `0` if not found.
|
||||
double roleCostById(String id) {
|
||||
for (final OneTimeOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.costPerHour;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Total number of workers across all positions.
|
||||
int get totalWorkers => positions.fold(
|
||||
0,
|
||||
(int sum, OneTimeOrderPosition p) => sum + p.count,
|
||||
);
|
||||
|
||||
/// Sum of (count * costPerHour) across all positions.
|
||||
double get totalCostPerHour => positions.fold(
|
||||
0,
|
||||
(double sum, OneTimeOrderPosition p) =>
|
||||
sum + (p.count * roleCostById(p.role)),
|
||||
);
|
||||
|
||||
/// Estimated total cost: sum of (count * costPerHour * hours) per position.
|
||||
double get estimatedTotal {
|
||||
double total = 0;
|
||||
for (final OneTimeOrderPosition p in positions) {
|
||||
final double hours = parseHoursFromTimes(p.startTime, p.endTime);
|
||||
total += p.count * roleCostById(p.role) * hours;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Time range string from the first position (e.g. "6:00 AM \u2013 2:00 PM").
|
||||
String get shiftTimeRange {
|
||||
if (positions.isEmpty) return '';
|
||||
final OneTimeOrderPosition first = positions.first;
|
||||
return '${first.startTime} \u2013 ${first.endTime}';
|
||||
}
|
||||
|
||||
/// Formatted shift duration from the first position (e.g. "8 hrs (30 min break)").
|
||||
String get shiftDuration {
|
||||
if (positions.isEmpty) return '';
|
||||
final OneTimeOrderPosition first = positions.first;
|
||||
final double hours = parseHoursFromTimes(first.startTime, first.endTime);
|
||||
if (hours <= 0) return '';
|
||||
|
||||
final int wholeHours = hours.floor();
|
||||
final int minutes = ((hours - wholeHours) * 60).round();
|
||||
final StringBuffer buffer = StringBuffer();
|
||||
|
||||
if (wholeHours > 0) buffer.write('$wholeHours hrs');
|
||||
if (minutes > 0) {
|
||||
if (wholeHours > 0) buffer.write(' ');
|
||||
buffer.write('$minutes min');
|
||||
}
|
||||
|
||||
if (first.lunchBreak != 'NO_BREAK' &&
|
||||
first.lunchBreak.isNotEmpty) {
|
||||
buffer.write(' (${first.lunchBreak} break)');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
date,
|
||||
@@ -114,6 +193,7 @@ class OneTimeOrderState extends Equatable {
|
||||
managers,
|
||||
selectedManager,
|
||||
isRapidDraft,
|
||||
isDataLoaded,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:client_create_order/src/domain/models/order_hub.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_manager.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_role.dart';
|
||||
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/create_permanent_order_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import 'permanent_order_event.dart';
|
||||
@@ -17,7 +19,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
PermanentOrderBloc(
|
||||
this._createPermanentOrderUseCase,
|
||||
this._getOrderDetailsForReorderUseCase,
|
||||
this._service,
|
||||
this._queryRepository,
|
||||
) : super(PermanentOrderState.initial()) {
|
||||
on<PermanentOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<PermanentOrderVendorChanged>(_onVendorChanged);
|
||||
@@ -40,7 +42,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
|
||||
final CreatePermanentOrderUseCase _createPermanentOrderUseCase;
|
||||
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
|
||||
final dc.DataConnectService _service;
|
||||
final ClientOrderQueryRepositoryInterface _queryRepository;
|
||||
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
@@ -54,21 +56,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
|
||||
Future<void> _loadVendors() async {
|
||||
final List<domain.Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListVendorsData, void> result = await _service
|
||||
.connector
|
||||
.listVendors()
|
||||
.execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => domain.Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
action: () => _queryRepository.getVendors(),
|
||||
onError: (_) => add(const PermanentOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
);
|
||||
|
||||
@@ -83,19 +71,14 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
) async {
|
||||
final List<PermanentOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<
|
||||
dc.ListRolesByVendorIdData,
|
||||
dc.ListRolesByVendorIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
final List<OrderRole> orderRoles =
|
||||
await _queryRepository.getRolesByVendor(vendorId);
|
||||
return orderRoles
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => PermanentOrderRoleOption(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
(OrderRole r) => PermanentOrderRoleOption(
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
costPerHour: r.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -112,19 +95,17 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
Future<void> _loadHubs() async {
|
||||
final List<PermanentOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final fdc.QueryResult<
|
||||
dc.ListTeamHubsByOwnerIdData,
|
||||
dc.ListTeamHubsByOwnerIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
final String? businessId = await _queryRepository.getBusinessId();
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
return <PermanentOrderHubOption>[];
|
||||
}
|
||||
final List<OrderHub> orderHubs =
|
||||
await _queryRepository.getHubsByOwner(businessId);
|
||||
return orderHubs
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => PermanentOrderHubOption(
|
||||
(OrderHub hub) => PermanentOrderHubOption(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
name: hub.name,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
@@ -155,7 +136,11 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
? event.vendors.first
|
||||
: null;
|
||||
emit(
|
||||
state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor),
|
||||
state.copyWith(
|
||||
vendors: event.vendors,
|
||||
selectedVendor: selectedVendor,
|
||||
isDataLoaded: true,
|
||||
),
|
||||
);
|
||||
if (selectedVendor != null) {
|
||||
await _loadRolesForVendor(selectedVendor.id, emit);
|
||||
@@ -170,10 +155,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
await _loadRolesForVendor(event.vendor.id, emit);
|
||||
}
|
||||
|
||||
void _onHubsLoaded(
|
||||
Future<void> _onHubsLoaded(
|
||||
PermanentOrderHubsLoaded event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
) async {
|
||||
final PermanentOrderHubOption? selectedHub = event.hubs.isNotEmpty
|
||||
? event.hubs.first
|
||||
: null;
|
||||
@@ -186,16 +171,16 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
);
|
||||
|
||||
if (selectedHub != null) {
|
||||
_loadManagersForHub(selectedHub.id, emit);
|
||||
await _loadManagersForHub(selectedHub.id, emit);
|
||||
}
|
||||
}
|
||||
|
||||
void _onHubChanged(
|
||||
Future<void> _onHubChanged(
|
||||
PermanentOrderHubChanged event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
) async {
|
||||
emit(state.copyWith(selectedHub: event.hub, location: event.hub.name));
|
||||
_loadManagersForHub(event.hub.id, emit);
|
||||
await _loadManagersForHub(event.hub.id, emit);
|
||||
}
|
||||
|
||||
void _onHubManagerChanged(
|
||||
@@ -219,22 +204,13 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
final List<PermanentOrderManagerOption>? managers =
|
||||
await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
|
||||
await _service.connector.listTeamMembers().execute();
|
||||
|
||||
return result.data.teamMembers
|
||||
.where(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
member.teamHubId == hubId &&
|
||||
member.role is dc.Known<dc.TeamMemberRole> &&
|
||||
(member.role as dc.Known<dc.TeamMemberRole>).value ==
|
||||
dc.TeamMemberRole.MANAGER,
|
||||
)
|
||||
final List<OrderManager> orderManagers =
|
||||
await _queryRepository.getManagersByHub(hubId);
|
||||
return orderManagers
|
||||
.map(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
PermanentOrderManagerOption(
|
||||
id: member.id,
|
||||
name: member.user.fullName ?? 'Unknown',
|
||||
(OrderManager m) => PermanentOrderManagerOption(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../utils/time_parsing_utils.dart';
|
||||
|
||||
enum PermanentOrderStatus { initial, loading, success, failure }
|
||||
|
||||
@@ -20,6 +21,7 @@ class PermanentOrderState extends Equatable {
|
||||
this.roles = const <PermanentOrderRoleOption>[],
|
||||
this.managers = const <PermanentOrderManagerOption>[],
|
||||
this.selectedManager,
|
||||
this.isDataLoaded = false,
|
||||
});
|
||||
|
||||
factory PermanentOrderState.initial() {
|
||||
@@ -67,6 +69,9 @@ class PermanentOrderState extends Equatable {
|
||||
final List<PermanentOrderManagerOption> managers;
|
||||
final PermanentOrderManagerOption? selectedManager;
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
|
||||
PermanentOrderState copyWith({
|
||||
DateTime? startDate,
|
||||
List<String>? permanentDays,
|
||||
@@ -83,6 +88,7 @@ class PermanentOrderState extends Equatable {
|
||||
List<PermanentOrderRoleOption>? roles,
|
||||
List<PermanentOrderManagerOption>? managers,
|
||||
PermanentOrderManagerOption? selectedManager,
|
||||
bool? isDataLoaded,
|
||||
}) {
|
||||
return PermanentOrderState(
|
||||
startDate: startDate ?? this.startDate,
|
||||
@@ -100,6 +106,7 @@ class PermanentOrderState extends Equatable {
|
||||
roles: roles ?? this.roles,
|
||||
managers: managers ?? this.managers,
|
||||
selectedManager: selectedManager ?? this.selectedManager,
|
||||
isDataLoaded: isDataLoaded ?? this.isDataLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +125,56 @@ class PermanentOrderState extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks up a role name by its ID, returns `null` if not found.
|
||||
String? roleNameById(String id) {
|
||||
for (final PermanentOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Looks up a role cost-per-hour by its ID, returns `0` if not found.
|
||||
double roleCostById(String id) {
|
||||
for (final PermanentOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.costPerHour;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Total number of workers across all positions.
|
||||
int get totalWorkers => positions.fold(
|
||||
0,
|
||||
(int sum, PermanentOrderPosition p) => sum + p.count,
|
||||
);
|
||||
|
||||
/// Sum of (count * costPerHour) across all positions.
|
||||
double get totalCostPerHour => positions.fold(
|
||||
0,
|
||||
(double sum, PermanentOrderPosition p) =>
|
||||
sum + (p.count * roleCostById(p.role)),
|
||||
);
|
||||
|
||||
/// Daily cost: sum of (count * costPerHour * hours) per position.
|
||||
double get dailyCost {
|
||||
double total = 0;
|
||||
for (final PermanentOrderPosition p in positions) {
|
||||
final double hours = parseHoursFromTimes(p.startTime, p.endTime);
|
||||
total += p.count * roleCostById(p.role) * hours;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Estimated weekly total cost for the permanent order.
|
||||
///
|
||||
/// Calculated as [dailyCost] multiplied by the number of selected
|
||||
/// [permanentDays] per week.
|
||||
double get estimatedTotal => dailyCost * permanentDays.length;
|
||||
|
||||
/// Formatted repeat days (e.g. "Mon, Tue, Wed").
|
||||
String get formattedRepeatDays => permanentDays.map(
|
||||
(String day) => day[0] + day.substring(1).toLowerCase(),
|
||||
).join(', ');
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
@@ -135,6 +192,7 @@ class PermanentOrderState extends Equatable {
|
||||
roles,
|
||||
managers,
|
||||
selectedManager,
|
||||
isDataLoaded,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import 'package:client_create_order/src/domain/models/order_hub.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_manager.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_role.dart';
|
||||
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/create_recurring_order_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import 'recurring_order_event.dart';
|
||||
import 'recurring_order_state.dart';
|
||||
|
||||
/// BLoC for managing the recurring order creation form.
|
||||
///
|
||||
/// This BLoC delegates all backend queries to
|
||||
/// [ClientOrderQueryRepositoryInterface] and order submission to
|
||||
/// [CreateRecurringOrderUseCase], keeping the presentation layer free
|
||||
/// from direct `krow_data_connect` imports.
|
||||
class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
with
|
||||
BlocErrorHandler<RecurringOrderState>,
|
||||
SafeBloc<RecurringOrderEvent, RecurringOrderState> {
|
||||
/// Creates a [RecurringOrderBloc] with the required use cases and
|
||||
/// query repository.
|
||||
RecurringOrderBloc(
|
||||
this._createRecurringOrderUseCase,
|
||||
this._getOrderDetailsForReorderUseCase,
|
||||
this._service,
|
||||
this._queryRepository,
|
||||
) : super(RecurringOrderState.initial()) {
|
||||
on<RecurringOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<RecurringOrderVendorChanged>(_onVendorChanged);
|
||||
@@ -41,7 +50,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
|
||||
final CreateRecurringOrderUseCase _createRecurringOrderUseCase;
|
||||
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
|
||||
final dc.DataConnectService _service;
|
||||
final ClientOrderQueryRepositoryInterface _queryRepository;
|
||||
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
@@ -53,24 +62,14 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Loads the list of available vendors from the query repository.
|
||||
Future<void> _loadVendors() async {
|
||||
final List<domain.Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListVendorsData, void> result = await _service
|
||||
.connector
|
||||
.listVendors()
|
||||
.execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => domain.Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
return _queryRepository.getVendors();
|
||||
},
|
||||
onError: (_) => add(const RecurringOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
onError: (_) =>
|
||||
add(const RecurringOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
);
|
||||
|
||||
if (vendors != null) {
|
||||
@@ -78,25 +77,22 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads roles for the given [vendorId] and maps them to presentation
|
||||
/// option models.
|
||||
Future<void> _loadRolesForVendor(
|
||||
String vendorId,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) async {
|
||||
final List<RecurringOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<
|
||||
dc.ListRolesByVendorIdData,
|
||||
dc.ListRolesByVendorIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
final List<OrderRole> orderRoles =
|
||||
await _queryRepository.getRolesByVendor(vendorId);
|
||||
return orderRoles
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => RecurringOrderRoleOption(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
(OrderRole r) => RecurringOrderRoleOption(
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
costPerHour: r.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -110,22 +106,19 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads team hubs for the current business owner and maps them to
|
||||
/// presentation option models.
|
||||
Future<void> _loadHubs() async {
|
||||
final List<RecurringOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final fdc.QueryResult<
|
||||
dc.ListTeamHubsByOwnerIdData,
|
||||
dc.ListTeamHubsByOwnerIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
final String businessId = await _queryRepository.getBusinessId();
|
||||
final List<OrderHub> orderHubs =
|
||||
await _queryRepository.getHubsByOwner(businessId);
|
||||
return orderHubs
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => RecurringOrderHubOption(
|
||||
(OrderHub hub) => RecurringOrderHubOption(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
name: hub.name,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
@@ -156,7 +149,11 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
? event.vendors.first
|
||||
: null;
|
||||
emit(
|
||||
state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor),
|
||||
state.copyWith(
|
||||
vendors: event.vendors,
|
||||
selectedVendor: selectedVendor,
|
||||
isDataLoaded: true,
|
||||
),
|
||||
);
|
||||
if (selectedVendor != null) {
|
||||
await _loadRolesForVendor(selectedVendor.id, emit);
|
||||
@@ -213,6 +210,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
emit(state.copyWith(managers: event.managers));
|
||||
}
|
||||
|
||||
/// Loads managers for the given [hubId] and maps them to presentation
|
||||
/// option models.
|
||||
Future<void> _loadManagersForHub(
|
||||
String hubId,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
@@ -220,22 +219,13 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
final List<RecurringOrderManagerOption>? managers =
|
||||
await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
|
||||
await _service.connector.listTeamMembers().execute();
|
||||
|
||||
return result.data.teamMembers
|
||||
.where(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
member.teamHubId == hubId &&
|
||||
member.role is dc.Known<dc.TeamMemberRole> &&
|
||||
(member.role as dc.Known<dc.TeamMemberRole>).value ==
|
||||
dc.TeamMemberRole.MANAGER,
|
||||
)
|
||||
final List<OrderManager> orderManagers =
|
||||
await _queryRepository.getManagersByHub(hubId);
|
||||
return orderManagers
|
||||
.map(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
RecurringOrderManagerOption(
|
||||
id: member.id,
|
||||
name: member.user.fullName ?? 'Unknown',
|
||||
(OrderManager m) => RecurringOrderManagerOption(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../utils/schedule_utils.dart';
|
||||
import '../../utils/time_parsing_utils.dart';
|
||||
|
||||
enum RecurringOrderStatus { initial, loading, success, failure }
|
||||
|
||||
@@ -21,6 +23,7 @@ class RecurringOrderState extends Equatable {
|
||||
this.roles = const <RecurringOrderRoleOption>[],
|
||||
this.managers = const <RecurringOrderManagerOption>[],
|
||||
this.selectedManager,
|
||||
this.isDataLoaded = false,
|
||||
});
|
||||
|
||||
factory RecurringOrderState.initial() {
|
||||
@@ -70,6 +73,9 @@ class RecurringOrderState extends Equatable {
|
||||
final List<RecurringOrderManagerOption> managers;
|
||||
final RecurringOrderManagerOption? selectedManager;
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
|
||||
RecurringOrderState copyWith({
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
@@ -87,6 +93,7 @@ class RecurringOrderState extends Equatable {
|
||||
List<RecurringOrderRoleOption>? roles,
|
||||
List<RecurringOrderManagerOption>? managers,
|
||||
RecurringOrderManagerOption? selectedManager,
|
||||
bool? isDataLoaded,
|
||||
}) {
|
||||
return RecurringOrderState(
|
||||
startDate: startDate ?? this.startDate,
|
||||
@@ -105,6 +112,7 @@ class RecurringOrderState extends Equatable {
|
||||
roles: roles ?? this.roles,
|
||||
managers: managers ?? this.managers,
|
||||
selectedManager: selectedManager ?? this.selectedManager,
|
||||
isDataLoaded: isDataLoaded ?? this.isDataLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +133,75 @@ class RecurringOrderState extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks up a role name by its ID, returns `null` if not found.
|
||||
String? roleNameById(String id) {
|
||||
for (final RecurringOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Looks up a role cost-per-hour by its ID, returns `0` if not found.
|
||||
double roleCostById(String id) {
|
||||
for (final RecurringOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.costPerHour;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Total number of workers across all positions.
|
||||
int get totalWorkers => positions.fold(
|
||||
0,
|
||||
(int sum, RecurringOrderPosition p) => sum + p.count,
|
||||
);
|
||||
|
||||
/// Sum of (count * costPerHour) across all positions.
|
||||
double get totalCostPerHour => positions.fold(
|
||||
0,
|
||||
(double sum, RecurringOrderPosition p) =>
|
||||
sum + (p.count * roleCostById(p.role)),
|
||||
);
|
||||
|
||||
/// Daily cost: sum of (count * costPerHour * hours) per position.
|
||||
double get dailyCost {
|
||||
double total = 0;
|
||||
for (final RecurringOrderPosition p in positions) {
|
||||
final double hours = parseHoursFromTimes(p.startTime, p.endTime);
|
||||
total += p.count * roleCostById(p.role) * hours;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Total number of working days between [startDate] and [endDate]
|
||||
/// (inclusive) that match the selected [recurringDays].
|
||||
///
|
||||
/// Iterates day-by-day and counts each date whose weekday label
|
||||
/// (e.g. "MON", "TUE") appears in [recurringDays].
|
||||
int get totalWorkingDays {
|
||||
final Set<String> selectedSet = recurringDays.toSet();
|
||||
int count = 0;
|
||||
for (
|
||||
DateTime day = startDate;
|
||||
!day.isAfter(endDate);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selectedSet.contains(weekdayLabel(day))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Estimated total cost for the entire recurring order period.
|
||||
///
|
||||
/// Calculated as [dailyCost] multiplied by [totalWorkingDays].
|
||||
double get estimatedTotal => dailyCost * totalWorkingDays;
|
||||
|
||||
/// Formatted repeat days (e.g. "Mon, Tue, Wed").
|
||||
String get formattedRepeatDays => recurringDays.map(
|
||||
(String day) => day[0] + day.substring(1).toLowerCase(),
|
||||
).join(', ');
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
@@ -143,6 +220,7 @@ class RecurringOrderState extends Equatable {
|
||||
roles,
|
||||
managers,
|
||||
selectedManager,
|
||||
isDataLoaded,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import '../widgets/review_order/review_order_positions_card.dart';
|
||||
|
||||
/// Identifies the order type for rendering the correct schedule layout
|
||||
/// on the review page.
|
||||
enum ReviewOrderType { oneTime, recurring, permanent }
|
||||
|
||||
/// Data transfer object passed as route arguments to the [ReviewOrderPage].
|
||||
///
|
||||
/// Contains pre-formatted display strings for every section of the review
|
||||
/// summary. The form page is responsible for converting BLoC state into
|
||||
/// these human-readable values before navigating.
|
||||
class ReviewOrderArguments {
|
||||
const ReviewOrderArguments({
|
||||
required this.orderType,
|
||||
required this.orderName,
|
||||
required this.hubName,
|
||||
required this.shiftContactName,
|
||||
required this.positions,
|
||||
required this.totalWorkers,
|
||||
required this.totalCostPerHour,
|
||||
required this.estimatedTotal,
|
||||
this.scheduleDate,
|
||||
this.scheduleTime,
|
||||
this.scheduleDuration,
|
||||
this.scheduleStartDate,
|
||||
this.scheduleEndDate,
|
||||
this.scheduleRepeatDays,
|
||||
this.totalLabel,
|
||||
});
|
||||
|
||||
final ReviewOrderType orderType;
|
||||
final String orderName;
|
||||
final String hubName;
|
||||
final String shiftContactName;
|
||||
final List<ReviewPositionItem> positions;
|
||||
final int totalWorkers;
|
||||
final double totalCostPerHour;
|
||||
final double estimatedTotal;
|
||||
|
||||
/// One-time order schedule fields.
|
||||
final String? scheduleDate;
|
||||
final String? scheduleTime;
|
||||
final String? scheduleDuration;
|
||||
|
||||
/// Recurring / permanent order schedule fields.
|
||||
final String? scheduleStartDate;
|
||||
final String? scheduleEndDate;
|
||||
final String? scheduleRepeatDays;
|
||||
|
||||
/// Optional label override for the total banner (e.g. "Estimated Weekly Total").
|
||||
final String? totalLabel;
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
import 'package:client_orders_common/client_orders_common.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:client_orders_common/client_orders_common.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/one_time_order/one_time_order_bloc.dart';
|
||||
import '../blocs/one_time_order/one_time_order_event.dart';
|
||||
import '../blocs/one_time_order/one_time_order_state.dart';
|
||||
import '../models/review_order_arguments.dart';
|
||||
import '../utils/time_parsing_utils.dart';
|
||||
import '../widgets/review_order/review_order_positions_card.dart';
|
||||
|
||||
/// Page for creating a one-time staffing order.
|
||||
/// Users can specify the date, location, and multiple staff positions required.
|
||||
///
|
||||
/// This page initializes the [OneTimeOrderBloc] and displays the [OneTimeOrderView]
|
||||
/// from the common orders package. It follows the KROW Clean Architecture by being
|
||||
/// a [StatelessWidget] and mapping local BLoC state to generic UI models.
|
||||
/// ## Submission Flow
|
||||
///
|
||||
/// When the user taps "Create Order", this page does NOT submit directly.
|
||||
/// Instead it navigates to [ReviewOrderPage] with a snapshot of the current
|
||||
/// BLoC state formatted as [ReviewOrderArguments]. If the user confirms on
|
||||
/// the review page (pops with `true`), this page then fires
|
||||
/// [OneTimeOrderSubmitted] on the BLoC to perform the actual API call.
|
||||
class OneTimeOrderPage extends StatelessWidget {
|
||||
/// Creates a [OneTimeOrderPage].
|
||||
const OneTimeOrderPage({super.key});
|
||||
@@ -36,6 +44,7 @@ class OneTimeOrderPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
return OneTimeOrderView(
|
||||
isDataLoaded: state.isDataLoaded,
|
||||
status: _mapStatus(state.status),
|
||||
errorMessage: state.errorMessage,
|
||||
eventName: state.eventName,
|
||||
@@ -53,8 +62,8 @@ class OneTimeOrderPage extends StatelessWidget {
|
||||
: null,
|
||||
hubManagers: state.managers.map(_mapManager).toList(),
|
||||
isValid: state.isValid,
|
||||
title: state.isRapidDraft ? 'Rapid Order' : null,
|
||||
subtitle: state.isRapidDraft ? 'Verify the order details' : null,
|
||||
title: state.isRapidDraft ? t.client_create_order.rapid_draft.title : null,
|
||||
subtitle: state.isRapidDraft ? t.client_create_order.rapid_draft.subtitle : null,
|
||||
onEventNameChanged: (String val) =>
|
||||
bloc.add(OneTimeOrderEventNameChanged(val)),
|
||||
onVendorChanged: (Vendor val) =>
|
||||
@@ -90,15 +99,53 @@ class OneTimeOrderPage extends StatelessWidget {
|
||||
},
|
||||
onPositionRemoved: (int index) =>
|
||||
bloc.add(OneTimeOrderPositionRemoved(index)),
|
||||
onSubmit: () => bloc.add(const OneTimeOrderSubmitted()),
|
||||
onSubmit: () => _navigateToReview(state, bloc),
|
||||
onDone: () => Modular.to.toOrdersSpecificDate(state.date),
|
||||
onBack: () => Modular.to.pop(),
|
||||
onBack: () => Modular.to.popSafe(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds [ReviewOrderArguments] from the current BLoC state and navigates
|
||||
/// to the review page. Submits the order only if the user confirms.
|
||||
Future<void> _navigateToReview(
|
||||
OneTimeOrderState state,
|
||||
OneTimeOrderBloc bloc,
|
||||
) async {
|
||||
final List<ReviewPositionItem> reviewPositions = state.positions.map(
|
||||
(OneTimeOrderPosition p) => ReviewPositionItem(
|
||||
roleName: state.roleNameById(p.role) ?? p.role,
|
||||
workerCount: p.count,
|
||||
costPerHour: state.roleCostById(p.role),
|
||||
hours: parseHoursFromTimes(p.startTime, p.endTime),
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
),
|
||||
).toList();
|
||||
|
||||
final bool? confirmed = await Modular.to.toCreateOrderReview(
|
||||
arguments: ReviewOrderArguments(
|
||||
orderType: ReviewOrderType.oneTime,
|
||||
orderName: state.eventName,
|
||||
hubName: state.selectedHub?.name ?? '',
|
||||
shiftContactName: state.selectedManager?.name ?? '',
|
||||
positions: reviewPositions,
|
||||
totalWorkers: state.totalWorkers,
|
||||
totalCostPerHour: state.totalCostPerHour,
|
||||
estimatedTotal: state.estimatedTotal,
|
||||
scheduleDate: DateFormat.yMMMEd().format(state.date),
|
||||
scheduleTime: state.shiftTimeRange,
|
||||
scheduleDuration: state.shiftDuration,
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
bloc.add(const OneTimeOrderSubmitted());
|
||||
}
|
||||
}
|
||||
|
||||
OrderFormStatus _mapStatus(OneTimeOrderStatus status) {
|
||||
switch (status) {
|
||||
case OneTimeOrderStatus.initial:
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:client_orders_common/client_orders_common.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' hide PermanentOrderPosition;
|
||||
import '../blocs/permanent_order/permanent_order_bloc.dart';
|
||||
import '../blocs/permanent_order/permanent_order_event.dart';
|
||||
import '../blocs/permanent_order/permanent_order_state.dart';
|
||||
import '../models/review_order_arguments.dart';
|
||||
import '../utils/schedule_utils.dart';
|
||||
import '../utils/time_parsing_utils.dart';
|
||||
import '../widgets/review_order/review_order_positions_card.dart';
|
||||
|
||||
/// Page for creating a permanent staffing order.
|
||||
///
|
||||
/// ## Submission Flow
|
||||
///
|
||||
/// When the user taps "Create Order", this page navigates to
|
||||
/// [ReviewOrderPage] with a snapshot of the current BLoC state formatted
|
||||
/// as [ReviewOrderArguments]. If the user confirms (pops with `true`),
|
||||
/// this page fires [PermanentOrderSubmitted] on the BLoC.
|
||||
class PermanentOrderPage extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderPage].
|
||||
const PermanentOrderPage({super.key});
|
||||
@@ -31,6 +44,7 @@ class PermanentOrderPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
return PermanentOrderView(
|
||||
isDataLoaded: state.isDataLoaded,
|
||||
status: _mapStatus(state.status),
|
||||
errorMessage: state.errorMessage,
|
||||
eventName: state.eventName,
|
||||
@@ -89,64 +103,58 @@ class PermanentOrderPage extends StatelessWidget {
|
||||
},
|
||||
onPositionRemoved: (int index) =>
|
||||
bloc.add(PermanentOrderPositionRemoved(index)),
|
||||
onSubmit: () => bloc.add(const PermanentOrderSubmitted()),
|
||||
onSubmit: () => _navigateToReview(state, bloc),
|
||||
onDone: () {
|
||||
final DateTime initialDate = _firstPermanentShiftDate(
|
||||
final DateTime initialDate = firstScheduledShiftDate(
|
||||
state.startDate,
|
||||
state.startDate.add(const Duration(days: 29)),
|
||||
state.permanentDays,
|
||||
);
|
||||
|
||||
// Navigate to orders page with the initial date set to the first recurring shift date
|
||||
Modular.to.toOrdersSpecificDate(initialDate);
|
||||
},
|
||||
onBack: () => Modular.to.pop(),
|
||||
onBack: () => Modular.to.popSafe(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _firstPermanentShiftDate(
|
||||
DateTime startDate,
|
||||
List<String> permanentDays,
|
||||
) {
|
||||
final DateTime start = DateTime(
|
||||
startDate.year,
|
||||
startDate.month,
|
||||
startDate.day,
|
||||
);
|
||||
final DateTime end = start.add(const Duration(days: 29));
|
||||
final Set<String> selected = permanentDays.toSet();
|
||||
for (
|
||||
DateTime day = start;
|
||||
!day.isAfter(end);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selected.contains(_weekdayLabel(day))) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
/// Builds [ReviewOrderArguments] from the current BLoC state and navigates
|
||||
/// to the review page. Submits the order only if the user confirms.
|
||||
Future<void> _navigateToReview(
|
||||
PermanentOrderState state,
|
||||
PermanentOrderBloc bloc,
|
||||
) async {
|
||||
final List<ReviewPositionItem> reviewPositions = state.positions.map(
|
||||
(PermanentOrderPosition p) => ReviewPositionItem(
|
||||
roleName: state.roleNameById(p.role) ?? p.role,
|
||||
workerCount: p.count,
|
||||
costPerHour: state.roleCostById(p.role),
|
||||
hours: parseHoursFromTimes(p.startTime, p.endTime),
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
),
|
||||
).toList();
|
||||
|
||||
String _weekdayLabel(DateTime date) {
|
||||
switch (date.weekday) {
|
||||
case DateTime.monday:
|
||||
return 'MON';
|
||||
case DateTime.tuesday:
|
||||
return 'TUE';
|
||||
case DateTime.wednesday:
|
||||
return 'WED';
|
||||
case DateTime.thursday:
|
||||
return 'THU';
|
||||
case DateTime.friday:
|
||||
return 'FRI';
|
||||
case DateTime.saturday:
|
||||
return 'SAT';
|
||||
case DateTime.sunday:
|
||||
return 'SUN';
|
||||
default:
|
||||
return 'SUN';
|
||||
final bool? confirmed = await Modular.to.toCreateOrderReview(
|
||||
arguments: ReviewOrderArguments(
|
||||
orderType: ReviewOrderType.permanent,
|
||||
orderName: state.eventName,
|
||||
hubName: state.selectedHub?.name ?? '',
|
||||
shiftContactName: state.selectedManager?.name ?? '',
|
||||
positions: reviewPositions,
|
||||
totalWorkers: state.totalWorkers,
|
||||
totalCostPerHour: state.totalCostPerHour,
|
||||
estimatedTotal: state.estimatedTotal,
|
||||
scheduleStartDate: DateFormat.yMMMd().format(state.startDate),
|
||||
scheduleRepeatDays: state.formattedRepeatDays,
|
||||
totalLabel: t.client_create_order.review.estimated_weekly_total,
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
bloc.add(const PermanentOrderSubmitted());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,25 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:client_orders_common/client_orders_common.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' hide RecurringOrderPosition;
|
||||
import '../blocs/recurring_order/recurring_order_bloc.dart';
|
||||
import '../blocs/recurring_order/recurring_order_event.dart';
|
||||
import '../blocs/recurring_order/recurring_order_state.dart';
|
||||
import '../models/review_order_arguments.dart';
|
||||
import '../utils/schedule_utils.dart';
|
||||
import '../utils/time_parsing_utils.dart';
|
||||
import '../widgets/review_order/review_order_positions_card.dart';
|
||||
|
||||
/// Page for creating a recurring staffing order.
|
||||
///
|
||||
/// ## Submission Flow
|
||||
///
|
||||
/// When the user taps "Create Order", this page navigates to
|
||||
/// [ReviewOrderPage] with a snapshot of the current BLoC state formatted
|
||||
/// as [ReviewOrderArguments]. If the user confirms (pops with `true`),
|
||||
/// this page fires [RecurringOrderSubmitted] on the BLoC.
|
||||
class RecurringOrderPage extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderPage].
|
||||
const RecurringOrderPage({super.key});
|
||||
@@ -31,6 +43,7 @@ class RecurringOrderPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
return RecurringOrderView(
|
||||
isDataLoaded: state.isDataLoaded,
|
||||
status: _mapStatus(state.status),
|
||||
errorMessage: state.errorMessage,
|
||||
eventName: state.eventName,
|
||||
@@ -92,7 +105,7 @@ class RecurringOrderPage extends StatelessWidget {
|
||||
},
|
||||
onPositionRemoved: (int index) =>
|
||||
bloc.add(RecurringOrderPositionRemoved(index)),
|
||||
onSubmit: () => bloc.add(const RecurringOrderSubmitted()),
|
||||
onSubmit: () => _navigateToReview(state, bloc),
|
||||
onDone: () {
|
||||
final DateTime maxEndDate = state.startDate.add(
|
||||
const Duration(days: 29),
|
||||
@@ -101,64 +114,56 @@ class RecurringOrderPage extends StatelessWidget {
|
||||
state.endDate.isAfter(maxEndDate)
|
||||
? maxEndDate
|
||||
: state.endDate;
|
||||
final DateTime initialDate = _firstRecurringShiftDate(
|
||||
final DateTime initialDate = firstScheduledShiftDate(
|
||||
state.startDate,
|
||||
effectiveEndDate,
|
||||
state.recurringDays,
|
||||
);
|
||||
|
||||
// Navigate to orders page with the initial date set to the first recurring shift date
|
||||
Modular.to.toOrdersSpecificDate(initialDate);
|
||||
},
|
||||
onBack: () => Modular.to.pop(),
|
||||
onBack: () => Modular.to.popSafe(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _firstRecurringShiftDate(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
List<String> recurringDays,
|
||||
) {
|
||||
final DateTime start = DateTime(
|
||||
startDate.year,
|
||||
startDate.month,
|
||||
startDate.day,
|
||||
);
|
||||
final DateTime end = DateTime(endDate.year, endDate.month, endDate.day);
|
||||
final Set<String> selected = recurringDays.toSet();
|
||||
for (
|
||||
DateTime day = start;
|
||||
!day.isAfter(end);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selected.contains(_weekdayLabel(day))) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
/// Builds [ReviewOrderArguments] from the current BLoC state and navigates
|
||||
/// to the review page. Submits the order only if the user confirms.
|
||||
Future<void> _navigateToReview(
|
||||
RecurringOrderState state,
|
||||
RecurringOrderBloc bloc,
|
||||
) async {
|
||||
final List<ReviewPositionItem> reviewPositions = state.positions.map(
|
||||
(RecurringOrderPosition p) => ReviewPositionItem(
|
||||
roleName: state.roleNameById(p.role) ?? p.role,
|
||||
workerCount: p.count,
|
||||
costPerHour: state.roleCostById(p.role),
|
||||
hours: parseHoursFromTimes(p.startTime, p.endTime),
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
),
|
||||
).toList();
|
||||
|
||||
String _weekdayLabel(DateTime date) {
|
||||
switch (date.weekday) {
|
||||
case DateTime.monday:
|
||||
return 'MON';
|
||||
case DateTime.tuesday:
|
||||
return 'TUE';
|
||||
case DateTime.wednesday:
|
||||
return 'WED';
|
||||
case DateTime.thursday:
|
||||
return 'THU';
|
||||
case DateTime.friday:
|
||||
return 'FRI';
|
||||
case DateTime.saturday:
|
||||
return 'SAT';
|
||||
case DateTime.sunday:
|
||||
return 'SUN';
|
||||
default:
|
||||
return 'SUN';
|
||||
final bool? confirmed = await Modular.to.toCreateOrderReview(
|
||||
arguments: ReviewOrderArguments(
|
||||
orderType: ReviewOrderType.recurring,
|
||||
orderName: state.eventName,
|
||||
hubName: state.selectedHub?.name ?? '',
|
||||
shiftContactName: state.selectedManager?.name ?? '',
|
||||
positions: reviewPositions,
|
||||
totalWorkers: state.totalWorkers,
|
||||
totalCostPerHour: state.totalCostPerHour,
|
||||
estimatedTotal: state.estimatedTotal,
|
||||
scheduleStartDate: DateFormat.yMMMd().format(state.startDate),
|
||||
scheduleEndDate: DateFormat.yMMMd().format(state.endDate),
|
||||
scheduleRepeatDays: state.formattedRepeatDays,
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
bloc.add(const RecurringOrderSubmitted());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../models/review_order_arguments.dart';
|
||||
import '../widgets/review_order/one_time_schedule_section.dart';
|
||||
import '../widgets/review_order/permanent_schedule_section.dart';
|
||||
import '../widgets/review_order/recurring_schedule_section.dart';
|
||||
import '../widgets/review_order/review_order_view.dart';
|
||||
|
||||
/// Review step in the order creation flow.
|
||||
///
|
||||
/// ## Navigation Flow
|
||||
///
|
||||
/// ```
|
||||
/// Form Page (one-time / recurring / permanent)
|
||||
/// -> user taps "Create Order"
|
||||
/// -> navigates here with [ReviewOrderArguments]
|
||||
/// -> user reviews summary
|
||||
/// -> "Post Order" => pops with `true` => form page submits via BLoC
|
||||
/// -> back / "Edit" => pops without result => form page resumes editing
|
||||
/// ```
|
||||
///
|
||||
/// This page is purely presentational. It receives all display data via
|
||||
/// [ReviewOrderArguments] and does not hold any BLoC. The calling form
|
||||
/// page owns the BLoC and only fires the submit event after this page
|
||||
/// confirms.
|
||||
class ReviewOrderPage extends StatelessWidget {
|
||||
/// Creates a [ReviewOrderPage].
|
||||
const ReviewOrderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Object? rawArgs = Modular.args.data;
|
||||
if (rawArgs is! ReviewOrderArguments) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Text(t.client_create_order.review.invalid_arguments),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final ReviewOrderArguments args = rawArgs;
|
||||
final bool showEdit = args.orderType != ReviewOrderType.oneTime;
|
||||
|
||||
return ReviewOrderView(
|
||||
orderName: args.orderName,
|
||||
hubName: args.hubName,
|
||||
shiftContactName: args.shiftContactName,
|
||||
scheduleSection: _buildScheduleSection(args, showEdit),
|
||||
positions: args.positions,
|
||||
totalWorkers: args.totalWorkers,
|
||||
totalCostPerHour: args.totalCostPerHour,
|
||||
estimatedTotal: args.estimatedTotal,
|
||||
totalLabel: args.totalLabel,
|
||||
showEditButtons: showEdit,
|
||||
onEditBasics: showEdit ? () => Modular.to.popSafe() : null,
|
||||
onEditSchedule: showEdit ? () => Modular.to.popSafe() : null,
|
||||
onEditPositions: showEdit ? () => Modular.to.popSafe() : null,
|
||||
onBack: () => Modular.to.popSafe(),
|
||||
onSubmit: () => Modular.to.popSafe<bool>(true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the schedule section widget matching the order type.
|
||||
Widget _buildScheduleSection(ReviewOrderArguments args, bool showEdit) {
|
||||
switch (args.orderType) {
|
||||
case ReviewOrderType.oneTime:
|
||||
return OneTimeScheduleSection(
|
||||
date: args.scheduleDate ?? '',
|
||||
time: args.scheduleTime ?? '',
|
||||
duration: args.scheduleDuration ?? '',
|
||||
);
|
||||
case ReviewOrderType.recurring:
|
||||
return RecurringScheduleSection(
|
||||
startDate: args.scheduleStartDate ?? '',
|
||||
endDate: args.scheduleEndDate ?? '',
|
||||
repeatDays: args.scheduleRepeatDays ?? '',
|
||||
onEdit: showEdit ? () => Modular.to.popSafe() : null,
|
||||
);
|
||||
case ReviewOrderType.permanent:
|
||||
return PermanentScheduleSection(
|
||||
startDate: args.scheduleStartDate ?? '',
|
||||
repeatDays: args.scheduleRepeatDays ?? '',
|
||||
onEdit: showEdit ? () => Modular.to.popSafe() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/// Returns the uppercase three-letter weekday label for [date].
|
||||
///
|
||||
/// Maps `DateTime.weekday` (1=Monday..7=Sunday) to labels like "MON", "TUE".
|
||||
String weekdayLabel(DateTime date) {
|
||||
switch (date.weekday) {
|
||||
case DateTime.monday:
|
||||
return 'MON';
|
||||
case DateTime.tuesday:
|
||||
return 'TUE';
|
||||
case DateTime.wednesday:
|
||||
return 'WED';
|
||||
case DateTime.thursday:
|
||||
return 'THU';
|
||||
case DateTime.friday:
|
||||
return 'FRI';
|
||||
case DateTime.saturday:
|
||||
return 'SAT';
|
||||
case DateTime.sunday:
|
||||
return 'SUN';
|
||||
default:
|
||||
return 'SUN';
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the first date within [startDate]..[endDate] whose weekday matches
|
||||
/// one of the [selectedDays] labels (e.g. "MON", "TUE").
|
||||
///
|
||||
/// Returns [startDate] if no match is found.
|
||||
DateTime firstScheduledShiftDate(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
List<String> selectedDays,
|
||||
) {
|
||||
final DateTime start = DateTime(startDate.year, startDate.month, startDate.day);
|
||||
final DateTime end = DateTime(endDate.year, endDate.month, endDate.day);
|
||||
final Set<String> selected = selectedDays.toSet();
|
||||
for (
|
||||
DateTime day = start;
|
||||
!day.isAfter(end);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selected.contains(weekdayLabel(day))) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Parses a time string in common formats ("6:00 PM", "18:00", "6:00PM").
|
||||
///
|
||||
/// Returns `null` if no format matches.
|
||||
DateTime? parseTime(String time) {
|
||||
for (final String format in <String>['h:mm a', 'HH:mm', 'h:mma']) {
|
||||
try {
|
||||
return DateFormat(format).parse(time.trim());
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Calculates the number of hours between [startTime] and [endTime].
|
||||
///
|
||||
/// Handles overnight shifts (negative difference wraps to 24h).
|
||||
/// Returns `0` if either time string cannot be parsed.
|
||||
double parseHoursFromTimes(String startTime, String endTime) {
|
||||
final DateTime? start = parseTime(startTime);
|
||||
final DateTime? end = parseTime(endTime);
|
||||
if (start == null || end == null) return 0;
|
||||
Duration diff = end.difference(start);
|
||||
if (diff.isNegative) diff += const Duration(hours: 24);
|
||||
return diff.inMinutes / 60;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_info_row.dart';
|
||||
import 'review_order_section_card.dart';
|
||||
|
||||
/// Schedule section for one-time orders.
|
||||
///
|
||||
/// Displays: Date, Time (start-end), Duration (with break info).
|
||||
class OneTimeScheduleSection extends StatelessWidget {
|
||||
const OneTimeScheduleSection({
|
||||
required this.date,
|
||||
required this.time,
|
||||
required this.duration,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String date;
|
||||
final String time;
|
||||
final String duration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReviewOrderSectionCard(
|
||||
title: t.client_create_order.review.schedule,
|
||||
children: <Widget>[
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.date, value: date),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.time, value: time),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.duration, value: duration),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_info_row.dart';
|
||||
import 'review_order_section_card.dart';
|
||||
|
||||
/// Schedule section for permanent orders.
|
||||
///
|
||||
/// Displays: Start Date, Repeat days (no end date).
|
||||
class PermanentScheduleSection extends StatelessWidget {
|
||||
const PermanentScheduleSection({
|
||||
required this.startDate,
|
||||
required this.repeatDays,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String startDate;
|
||||
final String repeatDays;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReviewOrderSectionCard(
|
||||
title: t.client_create_order.review.schedule,
|
||||
onEdit: onEdit,
|
||||
children: <Widget>[
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.start_date, value: startDate),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.repeat, value: repeatDays),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_info_row.dart';
|
||||
import 'review_order_section_card.dart';
|
||||
|
||||
/// Schedule section for recurring orders.
|
||||
///
|
||||
/// Displays: Start Date, End Date, Repeat days.
|
||||
class RecurringScheduleSection extends StatelessWidget {
|
||||
const RecurringScheduleSection({
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.repeatDays,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String startDate;
|
||||
final String endDate;
|
||||
final String repeatDays;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReviewOrderSectionCard(
|
||||
title: t.client_create_order.review.schedule,
|
||||
onEdit: onEdit,
|
||||
children: <Widget>[
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.start_date, value: startDate),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.end_date, value: endDate),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.repeat, value: repeatDays),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Bottom action bar with a back button and primary submit button.
|
||||
///
|
||||
/// The back button is a compact outlined button with a chevron icon.
|
||||
/// The submit button fills the remaining space.
|
||||
class ReviewOrderActionBar extends StatelessWidget {
|
||||
const ReviewOrderActionBar({
|
||||
required this.onBack,
|
||||
required this.onSubmit,
|
||||
this.submitLabel,
|
||||
this.isLoading = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback? onSubmit;
|
||||
final String? submitLabel;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: UiConstants.space6,
|
||||
right: UiConstants.space6,
|
||||
top: UiConstants.space3,
|
||||
bottom: UiConstants.space10,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
UiButton.secondary(
|
||||
leadingIcon: UiIcons.chevronLeft,
|
||||
onPressed: onBack,
|
||||
size: UiButtonSize.large,
|
||||
text: '',
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: submitLabel ?? t.client_create_order.review.post_order,
|
||||
onPressed: onSubmit,
|
||||
isLoading: isLoading,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_info_row.dart';
|
||||
import 'review_order_section_card.dart';
|
||||
|
||||
/// Displays the "Basics" section card showing order name, hub, and
|
||||
/// shift contact information.
|
||||
class ReviewOrderBasicsCard extends StatelessWidget {
|
||||
const ReviewOrderBasicsCard({
|
||||
required this.orderName,
|
||||
required this.hubName,
|
||||
required this.shiftContactName,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String orderName;
|
||||
final String hubName;
|
||||
final String shiftContactName;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReviewOrderSectionCard(
|
||||
title: t.client_create_order.review.basics,
|
||||
onEdit: onEdit,
|
||||
children: <Widget>[
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.order_name, value: orderName),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.hub, value: hubName),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.shift_contact, value: shiftContactName),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A single key-value row used inside review section cards.
|
||||
///
|
||||
/// Displays a label on the left and a value on the right in a
|
||||
/// space-between layout.
|
||||
class ReviewOrderInfoRow extends StatelessWidget {
|
||||
const ReviewOrderInfoRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: UiTypography.body2m,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Displays a summary of all positions with a divider and total row.
|
||||
///
|
||||
/// Each position is rendered as a two-line layout:
|
||||
/// - Line 1: role name (left) and worker count with cost/hr (right).
|
||||
/// - Line 2: time range and shift hours (right-aligned, muted style).
|
||||
///
|
||||
/// A divider separates the individual positions from the total.
|
||||
class ReviewOrderPositionsCard extends StatelessWidget {
|
||||
/// Creates a [ReviewOrderPositionsCard].
|
||||
const ReviewOrderPositionsCard({
|
||||
required this.positions,
|
||||
required this.totalWorkers,
|
||||
required this.totalCostPerHour,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The list of position items to display.
|
||||
final List<ReviewPositionItem> positions;
|
||||
|
||||
/// The total number of workers across all positions.
|
||||
final int totalWorkers;
|
||||
|
||||
/// The combined cost per hour across all positions.
|
||||
final double totalCostPerHour;
|
||||
|
||||
/// Optional callback invoked when the user taps "Edit".
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusXl,
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_create_order.review.positions,
|
||||
style: UiTypography.titleUppercase4b.textSecondary,
|
||||
),
|
||||
if (onEdit != null)
|
||||
GestureDetector(
|
||||
onTap: onEdit,
|
||||
child: Text(
|
||||
t.client_create_order.review.edit,
|
||||
style: UiTypography.body3m.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
...positions.map(_buildPositionItem),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: UiColors.bgSecondary,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_create_order.review.total,
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
Text(
|
||||
'$totalWorkers workers \u00B7 '
|
||||
'\$${totalCostPerHour.toStringAsFixed(0)}/hr',
|
||||
style: UiTypography.body2b.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a two-line widget for a single position.
|
||||
///
|
||||
/// Line 1 shows the role name on the left and worker count with cost on
|
||||
/// the right. Line 2 shows the time range and shift hours, right-aligned
|
||||
/// in a secondary/muted style.
|
||||
Widget _buildPositionItem(ReviewPositionItem position) {
|
||||
final String formattedHours = position.hours % 1 == 0
|
||||
? position.hours.toInt().toString()
|
||||
: position.hours.toStringAsFixed(1);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
position.roleName,
|
||||
style: UiTypography.body2m.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${position.workerCount} workers \u00B7 '
|
||||
'\$${position.costPerHour.toStringAsFixed(0)}/hr',
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'${position.startTime} - ${position.endTime} \u00B7 '
|
||||
'$formattedHours '
|
||||
'${t.client_create_order.review.hours_suffix}',
|
||||
style: UiTypography.body3r.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single position item for the positions card.
|
||||
///
|
||||
/// Contains the role name, worker count, shift hours, hourly cost,
|
||||
/// and the start/end times for one position in the review summary.
|
||||
class ReviewPositionItem {
|
||||
/// Creates a [ReviewPositionItem].
|
||||
const ReviewPositionItem({
|
||||
required this.roleName,
|
||||
required this.workerCount,
|
||||
required this.costPerHour,
|
||||
required this.hours,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
});
|
||||
|
||||
/// The display name of the role for this position.
|
||||
final String roleName;
|
||||
|
||||
/// The number of workers requested for this position.
|
||||
final int workerCount;
|
||||
|
||||
/// The cost per hour for this role.
|
||||
final double costPerHour;
|
||||
|
||||
/// The number of shift hours (derived from start/end time).
|
||||
final double hours;
|
||||
|
||||
/// The formatted start time of the shift (e.g. "08:00 AM").
|
||||
final String startTime;
|
||||
|
||||
/// The formatted end time of the shift (e.g. "04:00 PM").
|
||||
final String endTime;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A card that groups related review information with a section header.
|
||||
///
|
||||
/// Displays an uppercase section title with an optional "Edit" action
|
||||
/// and a list of child rows.
|
||||
class ReviewOrderSectionCard extends StatelessWidget {
|
||||
const ReviewOrderSectionCard({
|
||||
required this.title,
|
||||
required this.children,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusXl,
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: UiTypography.titleUppercase4b.textSecondary,
|
||||
),
|
||||
if (onEdit != null)
|
||||
GestureDetector(
|
||||
onTap: onEdit,
|
||||
child: Text(t.client_create_order.review.edit, style: UiTypography.body3m.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
...children.map(
|
||||
(Widget child) => Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A highlighted banner displaying the estimated total cost.
|
||||
///
|
||||
/// Uses the primary inverse background color with a bold price display.
|
||||
/// An optional [label] can override the default "Estimated Total" text.
|
||||
class ReviewOrderTotalBanner extends StatelessWidget {
|
||||
const ReviewOrderTotalBanner({
|
||||
required this.totalAmount,
|
||||
this.label,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The total monetary amount to display.
|
||||
final double totalAmount;
|
||||
|
||||
/// Optional label override. Defaults to the localized "Estimated Total".
|
||||
final String? label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryInverse,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label ?? t.client_create_order.review.estimated_total,
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
Text(
|
||||
'\$${totalAmount.toStringAsFixed(2)}',
|
||||
style: UiTypography.headline3b.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_action_bar.dart';
|
||||
import 'review_order_basics_card.dart';
|
||||
import 'review_order_positions_card.dart';
|
||||
import 'review_order_total_banner.dart';
|
||||
|
||||
/// The main review order view that displays a summary of the order
|
||||
/// before submission.
|
||||
///
|
||||
/// This is a "dumb" widget that receives all data via constructor parameters
|
||||
/// and exposes callbacks for user interactions. It does NOT interact with
|
||||
/// any BLoC directly.
|
||||
///
|
||||
/// The [scheduleSection] widget is injected to allow different schedule
|
||||
/// layouts per order type (one-time, recurring, permanent).
|
||||
class ReviewOrderView extends StatelessWidget {
|
||||
const ReviewOrderView({
|
||||
required this.orderName,
|
||||
required this.hubName,
|
||||
required this.shiftContactName,
|
||||
required this.scheduleSection,
|
||||
required this.positions,
|
||||
required this.totalWorkers,
|
||||
required this.totalCostPerHour,
|
||||
required this.estimatedTotal,
|
||||
required this.onBack,
|
||||
required this.onSubmit,
|
||||
this.showEditButtons = false,
|
||||
this.onEditBasics,
|
||||
this.onEditSchedule,
|
||||
this.onEditPositions,
|
||||
this.submitLabel,
|
||||
this.totalLabel,
|
||||
this.isLoading = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String orderName;
|
||||
final String hubName;
|
||||
final String shiftContactName;
|
||||
final Widget scheduleSection;
|
||||
final List<ReviewPositionItem> positions;
|
||||
final int totalWorkers;
|
||||
final double totalCostPerHour;
|
||||
final double estimatedTotal;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback? onSubmit;
|
||||
final bool showEditButtons;
|
||||
final VoidCallback? onEditBasics;
|
||||
final VoidCallback? onEditSchedule;
|
||||
final VoidCallback? onEditPositions;
|
||||
final String? submitLabel;
|
||||
|
||||
/// Optional label override for the total banner. When `null`, the default
|
||||
/// localized "Estimated Total" text is used.
|
||||
final String? totalLabel;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
showBackButton: true,
|
||||
onLeadingPressed: onBack,
|
||||
title: t.client_create_order.review.title,
|
||||
subtitle: t.client_create_order.review.subtitle,
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space6,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
ReviewOrderBasicsCard(
|
||||
orderName: orderName,
|
||||
hubName: hubName,
|
||||
shiftContactName: shiftContactName,
|
||||
onEdit: showEditButtons ? onEditBasics : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
scheduleSection,
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
ReviewOrderPositionsCard(
|
||||
positions: positions,
|
||||
totalWorkers: totalWorkers,
|
||||
totalCostPerHour: totalCostPerHour,
|
||||
onEdit: showEditButtons ? onEditPositions : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
ReviewOrderTotalBanner(
|
||||
totalAmount: estimatedTotal,
|
||||
label: totalLabel,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ReviewOrderActionBar(
|
||||
onBack: onBack,
|
||||
onSubmit: onSubmit,
|
||||
submitLabel: submitLabel ?? t.client_create_order.review.post_order,
|
||||
isLoading: isLoading,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
// UI Models
|
||||
export 'src/presentation/widgets/order_ui_models.dart';
|
||||
|
||||
// Shared Widgets
|
||||
export 'src/presentation/widgets/order_bottom_action_button.dart';
|
||||
export 'src/presentation/widgets/order_form_skeleton.dart';
|
||||
|
||||
// One Time Order Widgets
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_header.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_form.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_location_input.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_position_card.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_section_header.dart';
|
||||
@@ -13,8 +17,9 @@ export 'src/presentation/widgets/one_time_order/one_time_order_view.dart';
|
||||
|
||||
// Permanent Order Widgets
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_date_picker.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_days_selector.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_header.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_form.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_position_card.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_section_header.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_success_view.dart';
|
||||
@@ -22,8 +27,9 @@ export 'src/presentation/widgets/permanent_order/permanent_order_view.dart';
|
||||
|
||||
// Recurring Order Widgets
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_date_picker.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_days_selector.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_header.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_form.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_position_card.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_section_header.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_success_view.dart';
|
||||
|
||||
@@ -32,13 +32,12 @@ class HubManagerSelector extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
style: UiTypography.body1r,
|
||||
),
|
||||
if (description != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(description!, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
InkWell(
|
||||
onTap: () => _showSelector(context),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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 '../hub_manager_selector.dart';
|
||||
import '../order_ui_models.dart';
|
||||
import 'one_time_order_date_picker.dart';
|
||||
import 'one_time_order_event_name_input.dart';
|
||||
import 'one_time_order_position_card.dart';
|
||||
import 'one_time_order_section_header.dart';
|
||||
|
||||
/// The scrollable form body for the one-time order creation flow.
|
||||
///
|
||||
/// Displays fields for event name, vendor selection, date, hub, hub manager,
|
||||
/// and a dynamic list of position cards.
|
||||
class OneTimeOrderForm extends StatelessWidget {
|
||||
/// Creates a [OneTimeOrderForm].
|
||||
const OneTimeOrderForm({
|
||||
required this.eventName,
|
||||
required this.selectedVendor,
|
||||
required this.vendors,
|
||||
required this.date,
|
||||
required this.selectedHub,
|
||||
required this.hubs,
|
||||
required this.selectedHubManager,
|
||||
required this.hubManagers,
|
||||
required this.positions,
|
||||
required this.roles,
|
||||
required this.onEventNameChanged,
|
||||
required this.onVendorChanged,
|
||||
required this.onDateChanged,
|
||||
required this.onHubChanged,
|
||||
required this.onHubManagerChanged,
|
||||
required this.onPositionAdded,
|
||||
required this.onPositionUpdated,
|
||||
required this.onPositionRemoved,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current event name value.
|
||||
final String eventName;
|
||||
|
||||
/// The currently selected vendor, if any.
|
||||
final Vendor? selectedVendor;
|
||||
|
||||
/// The list of available vendors to choose from.
|
||||
final List<Vendor> vendors;
|
||||
|
||||
/// The selected date for the one-time order.
|
||||
final DateTime date;
|
||||
|
||||
/// The currently selected hub, if any.
|
||||
final OrderHubUiModel? selectedHub;
|
||||
|
||||
/// The list of available hubs to choose from.
|
||||
final List<OrderHubUiModel> hubs;
|
||||
|
||||
/// The currently selected hub manager, if any.
|
||||
final OrderManagerUiModel? selectedHubManager;
|
||||
|
||||
/// The list of available hub managers for the selected hub.
|
||||
final List<OrderManagerUiModel> hubManagers;
|
||||
|
||||
/// The list of position entries in the order.
|
||||
final List<OrderPositionUiModel> positions;
|
||||
|
||||
/// The list of available roles for position assignment.
|
||||
final List<OrderRoleUiModel> roles;
|
||||
|
||||
/// Called when the event name text changes.
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
|
||||
/// Called when a vendor is selected.
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
|
||||
/// Called when the date is changed.
|
||||
final ValueChanged<DateTime> onDateChanged;
|
||||
|
||||
/// Called when a hub is selected.
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
|
||||
/// Called when a hub manager is selected or cleared.
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
|
||||
/// Called when the user requests adding a new position.
|
||||
final VoidCallback onPositionAdded;
|
||||
|
||||
/// Called when a position at [index] is updated with new values.
|
||||
final void Function(int index, OrderPositionUiModel position)
|
||||
onPositionUpdated;
|
||||
|
||||
/// Called when a position at [index] is removed.
|
||||
final void Function(int index) onPositionRemoved;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderOneTimeEn labels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
children: <Widget>[
|
||||
OneTimeOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: eventName,
|
||||
onChanged: onEventNameChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
onVendorChanged(vendor);
|
||||
}
|
||||
},
|
||||
items: vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
OneTimeOrderDatePicker(
|
||||
label: labels.date_label,
|
||||
value: date,
|
||||
onChanged: onDateChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OrderHubUiModel>(
|
||||
isExpanded: true,
|
||||
value: selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (OrderHubUiModel? hub) {
|
||||
if (hub != null) {
|
||||
onHubChanged(hub);
|
||||
}
|
||||
},
|
||||
items: hubs.map((OrderHubUiModel hub) {
|
||||
return DropdownMenuItem<OrderHubUiModel>(
|
||||
value: hub,
|
||||
child: Text(hub.name, style: UiTypography.body2m.textPrimary),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
HubManagerSelector(
|
||||
label: labels.hub_manager_label,
|
||||
description: labels.hub_manager_desc,
|
||||
hintText: labels.hub_manager_hint,
|
||||
noManagersText: labels.hub_manager_empty,
|
||||
noneText: labels.hub_manager_none,
|
||||
managers: hubManagers,
|
||||
selectedManager: selectedHubManager,
|
||||
onChanged: onHubManagerChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
OneTimeOrderSectionHeader(
|
||||
title: labels.positions_title,
|
||||
actionLabel: labels.add_position,
|
||||
onAction: onPositionAdded,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...positions.asMap().entries.map((
|
||||
MapEntry<int, OrderPositionUiModel> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final OrderPositionUiModel position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: OneTimeOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: positions.length > 1,
|
||||
positionLabel: labels.positions_title,
|
||||
roleLabel: labels.select_role,
|
||||
workersLabel: labels.workers_label,
|
||||
startLabel: labels.start_label,
|
||||
endLabel: labels.end_label,
|
||||
lunchLabel: labels.lunch_break_label,
|
||||
roles: roles,
|
||||
onUpdated: (OrderPositionUiModel updated) {
|
||||
onPositionUpdated(index, updated);
|
||||
},
|
||||
onRemoved: () {
|
||||
onPositionRemoved(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A header widget for the one-time order flow with a colored background.
|
||||
class OneTimeOrderHeader extends StatelessWidget {
|
||||
/// Creates a [OneTimeOrderHeader].
|
||||
const OneTimeOrderHeader({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onBack,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title of the page.
|
||||
final String title;
|
||||
|
||||
/// The subtitle or description.
|
||||
final String subtitle;
|
||||
|
||||
/// Callback when the back button is pressed.
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + UiConstants.space5,
|
||||
bottom: UiConstants.space5,
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
),
|
||||
color: UiColors.primary,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: onBack,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
color: UiColors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.headline3m.copyWith(color: UiColors.white),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,11 @@ 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 '../order_bottom_action_button.dart';
|
||||
import '../order_form_skeleton.dart';
|
||||
import '../order_ui_models.dart';
|
||||
import '../hub_manager_selector.dart';
|
||||
import 'one_time_order_date_picker.dart';
|
||||
import 'one_time_order_event_name_input.dart';
|
||||
import 'one_time_order_header.dart';
|
||||
import 'one_time_order_position_card.dart';
|
||||
import 'one_time_order_section_header.dart';
|
||||
import 'one_time_order_form.dart';
|
||||
import 'one_time_order_success_view.dart';
|
||||
|
||||
/// The main content of the One-Time Order page as a dumb widget.
|
||||
@@ -40,6 +38,7 @@ class OneTimeOrderView extends StatelessWidget {
|
||||
required this.onBack,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.isDataLoaded = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -59,6 +58,9 @@ class OneTimeOrderView extends StatelessWidget {
|
||||
final String? title;
|
||||
final String? subtitle;
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
final ValueChanged<DateTime> onDateChanged;
|
||||
@@ -84,7 +86,12 @@ class OneTimeOrderView extends StatelessWidget {
|
||||
context,
|
||||
message: translateErrorKey(errorMessage!),
|
||||
type: UiSnackbarType.error,
|
||||
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
|
||||
// bottom: 140 clears the bottom navigation bar area
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 140,
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -98,322 +105,96 @@ class OneTimeOrderView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
showBackButton: true,
|
||||
onLeadingPressed: onBack,
|
||||
title: title ?? labels.title,
|
||||
subtitle: subtitle ?? labels.subtitle,
|
||||
),
|
||||
body: _buildBody(context, labels),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the main body of the One-Time Order page, showing either the form or a loading indicator.
|
||||
Widget _buildBody(
|
||||
BuildContext context,
|
||||
TranslationsClientCreateOrderOneTimeEn labels,
|
||||
) {
|
||||
if (!isDataLoaded) {
|
||||
return const OrderFormSkeleton();
|
||||
}
|
||||
|
||||
if (vendors.isEmpty && status != OrderFormStatus.loading) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
OneTimeOrderHeader(
|
||||
title: title ?? labels.title,
|
||||
subtitle: subtitle ?? labels.subtitle,
|
||||
onBack: onBack,
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
'No Vendors Available',
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
'There are no staffing vendors associated with your account.',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
t.client_create_order.no_vendors_title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
t.client_create_order.no_vendors_description,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
OneTimeOrderHeader(
|
||||
title: title ?? labels.title,
|
||||
subtitle: subtitle ?? labels.subtitle,
|
||||
onBack: onBack,
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
_OneTimeOrderForm(
|
||||
eventName: eventName,
|
||||
selectedVendor: selectedVendor,
|
||||
vendors: vendors,
|
||||
date: date,
|
||||
selectedHub: selectedHub,
|
||||
hubs: hubs,
|
||||
selectedHubManager: selectedHubManager,
|
||||
hubManagers: hubManagers,
|
||||
positions: positions,
|
||||
roles: roles,
|
||||
onEventNameChanged: onEventNameChanged,
|
||||
onVendorChanged: onVendorChanged,
|
||||
onDateChanged: onDateChanged,
|
||||
onHubChanged: onHubChanged,
|
||||
onHubManagerChanged: onHubManagerChanged,
|
||||
onPositionAdded: onPositionAdded,
|
||||
onPositionUpdated: onPositionUpdated,
|
||||
onPositionRemoved: onPositionRemoved,
|
||||
),
|
||||
if (status == OrderFormStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
_BottomActionButton(
|
||||
label: status == OrderFormStatus.loading
|
||||
? labels.creating
|
||||
: labels.create_order,
|
||||
isLoading: status == OrderFormStatus.loading,
|
||||
onPressed: isValid ? onSubmit : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OneTimeOrderForm extends StatelessWidget {
|
||||
const _OneTimeOrderForm({
|
||||
required this.eventName,
|
||||
required this.selectedVendor,
|
||||
required this.vendors,
|
||||
required this.date,
|
||||
required this.selectedHub,
|
||||
required this.hubs,
|
||||
required this.selectedHubManager,
|
||||
required this.hubManagers,
|
||||
required this.positions,
|
||||
required this.roles,
|
||||
required this.onEventNameChanged,
|
||||
required this.onVendorChanged,
|
||||
required this.onDateChanged,
|
||||
required this.onHubChanged,
|
||||
required this.onHubManagerChanged,
|
||||
required this.onPositionAdded,
|
||||
required this.onPositionUpdated,
|
||||
required this.onPositionRemoved,
|
||||
});
|
||||
|
||||
final String eventName;
|
||||
final Vendor? selectedVendor;
|
||||
final List<Vendor> vendors;
|
||||
final DateTime date;
|
||||
final OrderHubUiModel? selectedHub;
|
||||
final List<OrderHubUiModel> hubs;
|
||||
final OrderManagerUiModel? selectedHubManager;
|
||||
final List<OrderManagerUiModel> hubManagers;
|
||||
final List<OrderPositionUiModel> positions;
|
||||
final List<OrderRoleUiModel> roles;
|
||||
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
final ValueChanged<DateTime> onDateChanged;
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
final VoidCallback onPositionAdded;
|
||||
final void Function(int index, OrderPositionUiModel position)
|
||||
onPositionUpdated;
|
||||
final void Function(int index) onPositionRemoved;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderOneTimeEn labels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
labels.create_your_order,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
OneTimeOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: eventName,
|
||||
onChanged: onEventNameChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
OneTimeOrderForm(
|
||||
eventName: eventName,
|
||||
selectedVendor: selectedVendor,
|
||||
vendors: vendors,
|
||||
date: date,
|
||||
selectedHub: selectedHub,
|
||||
hubs: hubs,
|
||||
selectedHubManager: selectedHubManager,
|
||||
hubManagers: hubManagers,
|
||||
positions: positions,
|
||||
roles: roles,
|
||||
onEventNameChanged: onEventNameChanged,
|
||||
onVendorChanged: onVendorChanged,
|
||||
onDateChanged: onDateChanged,
|
||||
onHubChanged: onHubChanged,
|
||||
onHubManagerChanged: onHubManagerChanged,
|
||||
onPositionAdded: onPositionAdded,
|
||||
onPositionUpdated: onPositionUpdated,
|
||||
onPositionRemoved: onPositionRemoved,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
onVendorChanged(vendor);
|
||||
}
|
||||
},
|
||||
items: vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (status == OrderFormStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
OneTimeOrderDatePicker(
|
||||
label: labels.date_label,
|
||||
value: date,
|
||||
onChanged: onDateChanged,
|
||||
OrderBottomActionButton(
|
||||
label: status == OrderFormStatus.loading
|
||||
? labels.creating
|
||||
: labels.create_order,
|
||||
isLoading: status == OrderFormStatus.loading,
|
||||
onPressed: isValid ? onSubmit : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OrderHubUiModel>(
|
||||
isExpanded: true,
|
||||
value: selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (OrderHubUiModel? hub) {
|
||||
if (hub != null) {
|
||||
onHubChanged(hub);
|
||||
}
|
||||
},
|
||||
items: hubs.map((OrderHubUiModel hub) {
|
||||
return DropdownMenuItem<OrderHubUiModel>(
|
||||
value: hub,
|
||||
child: Text(hub.name, style: UiTypography.body2m.textPrimary),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
HubManagerSelector(
|
||||
label: labels.hub_manager_label,
|
||||
description: labels.hub_manager_desc,
|
||||
hintText: labels.hub_manager_hint,
|
||||
noManagersText: labels.hub_manager_empty,
|
||||
noneText: labels.hub_manager_none,
|
||||
managers: hubManagers,
|
||||
selectedManager: selectedHubManager,
|
||||
onChanged: onHubManagerChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
OneTimeOrderSectionHeader(
|
||||
title: labels.positions_title,
|
||||
actionLabel: labels.add_position,
|
||||
onAction: onPositionAdded,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...positions.asMap().entries.map((
|
||||
MapEntry<int, OrderPositionUiModel> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final OrderPositionUiModel position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: OneTimeOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: positions.length > 1,
|
||||
positionLabel: labels.positions_title,
|
||||
roleLabel: labels.select_role,
|
||||
workersLabel: labels.workers_label,
|
||||
startLabel: labels.start_label,
|
||||
endLabel: labels.end_label,
|
||||
lunchLabel: labels.lunch_break_label,
|
||||
roles: roles,
|
||||
onUpdated: (OrderPositionUiModel updated) {
|
||||
onPositionUpdated(index, updated);
|
||||
},
|
||||
onRemoved: () {
|
||||
onPositionRemoved(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomActionButton extends StatelessWidget {
|
||||
const _BottomActionButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
});
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
top: UiConstants.space5,
|
||||
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: label,
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A bottom-pinned action button used across all order type views.
|
||||
///
|
||||
/// Renders a full-width primary button with safe-area padding at the bottom
|
||||
/// and a top border separator. Disables the button while [isLoading] is true.
|
||||
class OrderBottomActionButton extends StatelessWidget {
|
||||
/// Creates an [OrderBottomActionButton].
|
||||
const OrderBottomActionButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The text displayed on the button.
|
||||
final String label;
|
||||
|
||||
/// Callback invoked when the button is pressed. Pass `null` to disable.
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// Whether the form is currently submitting. Disables the button when true.
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
top: UiConstants.space5,
|
||||
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border, width: 0.5)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: label,
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer skeleton that mimics the order creation form layout.
|
||||
///
|
||||
/// Displayed while initial data (vendors, hubs, roles) is being fetched.
|
||||
/// Renders placeholder shapes for the text input, dropdowns, date picker,
|
||||
/// hub manager section, and one position card.
|
||||
class OrderFormSkeleton extends StatelessWidget {
|
||||
/// Creates an [OrderFormSkeleton].
|
||||
const OrderFormSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: ListView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
children: <Widget>[
|
||||
_buildLabelPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_buildTextFieldPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildLabelPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_buildDropdownPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildLabelPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_buildDropdownPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildLabelPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_buildDropdownPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildHubManagerPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
_buildSectionHeaderPlaceholder(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
_buildPositionCardPlaceholder(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Small label placeholder above each field.
|
||||
Widget _buildLabelPlaceholder() {
|
||||
return const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: UiShimmerLine(width: 100, height: 12),
|
||||
);
|
||||
}
|
||||
|
||||
/// Full-width text input placeholder.
|
||||
Widget _buildTextFieldPlaceholder() {
|
||||
return const UiShimmerBox(width: double.infinity, height: 48);
|
||||
}
|
||||
|
||||
/// Full-width dropdown selector placeholder.
|
||||
Widget _buildDropdownPlaceholder() {
|
||||
return const UiShimmerBox(width: double.infinity, height: 48);
|
||||
}
|
||||
|
||||
/// Hub manager section with label and description lines.
|
||||
Widget _buildHubManagerPlaceholder() {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 140, height: 12),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 220, height: 10),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerBox(width: double.infinity, height: 48),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Section header placeholder with title and action button.
|
||||
Widget _buildSectionHeaderPlaceholder() {
|
||||
return const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 100, height: 16),
|
||||
UiShimmerBox(width: 90, height: 28),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Position card placeholder mimicking role, worker count, and time fields.
|
||||
Widget _buildPositionCardPlaceholder() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 80, height: 14),
|
||||
UiShimmerCircle(size: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const UiShimmerBox(width: double.infinity, height: 44),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const UiShimmerLine(width: 60, height: 12),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const UiShimmerBox(width: double.infinity, height: 44),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
UiShimmerLine(width: 50, height: 12),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerBox(width: double.infinity, height: 44),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
UiShimmerLine(width: 50, height: 12),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerBox(width: double.infinity, height: 44),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A horizontal row of circular day-of-week toggle buttons for permanent orders.
|
||||
///
|
||||
/// Displays seven circles labeled S, M, T, W, T, F, S representing the days
|
||||
/// of the week. Selected days are highlighted with the primary color.
|
||||
class PermanentOrderDaysSelector extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderDaysSelector].
|
||||
const PermanentOrderDaysSelector({
|
||||
required this.selectedDays,
|
||||
required this.onToggle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The list of currently selected day abbreviations (e.g. 'MON', 'TUE').
|
||||
final List<String> selectedDays;
|
||||
|
||||
/// Called when a day circle is tapped, with the day index (0 = Sunday).
|
||||
final ValueChanged<int> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const List<String> labelsShort = <String>[
|
||||
'S',
|
||||
'M',
|
||||
'T',
|
||||
'W',
|
||||
'T',
|
||||
'F',
|
||||
'S',
|
||||
];
|
||||
const List<String> labelsLong = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
children: List<Widget>.generate(labelsShort.length, (int index) {
|
||||
final bool isSelected = selectedDays.contains(labelsLong[index]);
|
||||
return GestureDetector(
|
||||
onTap: () => onToggle(index),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
labelsShort[index],
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: isSelected ? UiColors.white : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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' show Vendor;
|
||||
|
||||
import '../hub_manager_selector.dart';
|
||||
import '../order_ui_models.dart';
|
||||
import 'permanent_order_date_picker.dart';
|
||||
import 'permanent_order_days_selector.dart';
|
||||
import 'permanent_order_event_name_input.dart';
|
||||
import 'permanent_order_position_card.dart';
|
||||
import 'permanent_order_section_header.dart';
|
||||
|
||||
/// The scrollable form body for the permanent order creation flow.
|
||||
///
|
||||
/// Displays fields for event name, vendor selection, start date,
|
||||
/// permanent day toggles, hub, hub manager, and a dynamic list of
|
||||
/// position cards.
|
||||
class PermanentOrderForm extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderForm].
|
||||
const PermanentOrderForm({
|
||||
required this.eventName,
|
||||
required this.selectedVendor,
|
||||
required this.vendors,
|
||||
required this.startDate,
|
||||
required this.permanentDays,
|
||||
required this.selectedHub,
|
||||
required this.hubs,
|
||||
required this.positions,
|
||||
required this.roles,
|
||||
required this.onEventNameChanged,
|
||||
required this.onVendorChanged,
|
||||
required this.onStartDateChanged,
|
||||
required this.onDayToggled,
|
||||
required this.onHubChanged,
|
||||
required this.onHubManagerChanged,
|
||||
required this.onPositionAdded,
|
||||
required this.onPositionUpdated,
|
||||
required this.onPositionRemoved,
|
||||
required this.hubManagers,
|
||||
required this.selectedHubManager,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current event name value.
|
||||
final String eventName;
|
||||
|
||||
/// The currently selected vendor, if any.
|
||||
final Vendor? selectedVendor;
|
||||
|
||||
/// The list of available vendors to choose from.
|
||||
final List<Vendor> vendors;
|
||||
|
||||
/// The start date for the permanent order.
|
||||
final DateTime startDate;
|
||||
|
||||
/// The list of selected permanent day abbreviations (e.g. 'MON', 'TUE').
|
||||
final List<String> permanentDays;
|
||||
|
||||
/// The currently selected hub, if any.
|
||||
final OrderHubUiModel? selectedHub;
|
||||
|
||||
/// The list of available hubs to choose from.
|
||||
final List<OrderHubUiModel> hubs;
|
||||
|
||||
/// The list of position entries in the order.
|
||||
final List<OrderPositionUiModel> positions;
|
||||
|
||||
/// The list of available roles for position assignment.
|
||||
final List<OrderRoleUiModel> roles;
|
||||
|
||||
/// Called when the event name text changes.
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
|
||||
/// Called when a vendor is selected.
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
|
||||
/// Called when the start date is changed.
|
||||
final ValueChanged<DateTime> onStartDateChanged;
|
||||
|
||||
/// Called when a day-of-week toggle is tapped, with the day index (0=Sun).
|
||||
final ValueChanged<int> onDayToggled;
|
||||
|
||||
/// Called when a hub is selected.
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
|
||||
/// Called when a hub manager is selected or cleared.
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
|
||||
/// Called when the user requests adding a new position.
|
||||
final VoidCallback onPositionAdded;
|
||||
|
||||
/// Called when a position at [index] is updated with new values.
|
||||
final void Function(int index, OrderPositionUiModel position)
|
||||
onPositionUpdated;
|
||||
|
||||
/// Called when a position at [index] is removed.
|
||||
final void Function(int index) onPositionRemoved;
|
||||
|
||||
/// The list of available hub managers for the selected hub.
|
||||
final List<OrderManagerUiModel> hubManagers;
|
||||
|
||||
/// The currently selected hub manager, if any.
|
||||
final OrderManagerUiModel? selectedHubManager;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
children: <Widget>[
|
||||
PermanentOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: eventName,
|
||||
onChanged: onEventNameChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
onVendorChanged(vendor);
|
||||
}
|
||||
},
|
||||
items: vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
PermanentOrderDatePicker(
|
||||
label: 'Start Date',
|
||||
value: startDate,
|
||||
onChanged: onStartDateChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('Permanent Days', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
PermanentOrderDaysSelector(
|
||||
selectedDays: permanentDays,
|
||||
onToggle: onDayToggled,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OrderHubUiModel>(
|
||||
isExpanded: true,
|
||||
value: selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (OrderHubUiModel? hub) {
|
||||
if (hub != null) {
|
||||
onHubChanged(hub);
|
||||
}
|
||||
},
|
||||
items: hubs.map((OrderHubUiModel hub) {
|
||||
return DropdownMenuItem<OrderHubUiModel>(
|
||||
value: hub,
|
||||
child: Text(
|
||||
hub.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
HubManagerSelector(
|
||||
label: oneTimeLabels.hub_manager_label,
|
||||
description: oneTimeLabels.hub_manager_desc,
|
||||
hintText: oneTimeLabels.hub_manager_hint,
|
||||
noManagersText: oneTimeLabels.hub_manager_empty,
|
||||
noneText: oneTimeLabels.hub_manager_none,
|
||||
managers: hubManagers,
|
||||
selectedManager: selectedHubManager,
|
||||
onChanged: onHubManagerChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
PermanentOrderSectionHeader(
|
||||
title: oneTimeLabels.positions_title,
|
||||
actionLabel: oneTimeLabels.add_position,
|
||||
onAction: onPositionAdded,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...positions.asMap().entries.map((
|
||||
MapEntry<int, OrderPositionUiModel> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final OrderPositionUiModel position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: PermanentOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: positions.length > 1,
|
||||
positionLabel: oneTimeLabels.positions_title,
|
||||
roleLabel: oneTimeLabels.select_role,
|
||||
workersLabel: oneTimeLabels.workers_label,
|
||||
startLabel: oneTimeLabels.start_label,
|
||||
endLabel: oneTimeLabels.end_label,
|
||||
lunchLabel: oneTimeLabels.lunch_break_label,
|
||||
roles: roles,
|
||||
onUpdated: (OrderPositionUiModel updated) {
|
||||
onPositionUpdated(index, updated);
|
||||
},
|
||||
onRemoved: () {
|
||||
onPositionRemoved(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A header widget for the permanent order flow with a colored background.
|
||||
class PermanentOrderHeader extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderHeader].
|
||||
const PermanentOrderHeader({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onBack,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title of the page.
|
||||
final String title;
|
||||
|
||||
/// The subtitle or description.
|
||||
final String subtitle;
|
||||
|
||||
/// Callback when the back button is pressed.
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + UiConstants.space5,
|
||||
bottom: UiConstants.space5,
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
),
|
||||
color: UiColors.primary,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: onBack,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
color: UiColors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.headline3m.copyWith(color: UiColors.white),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,11 @@ 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' show Vendor;
|
||||
|
||||
import '../order_bottom_action_button.dart';
|
||||
import '../order_form_skeleton.dart';
|
||||
import '../order_ui_models.dart';
|
||||
import '../hub_manager_selector.dart';
|
||||
import 'permanent_order_date_picker.dart';
|
||||
import 'permanent_order_event_name_input.dart';
|
||||
import 'permanent_order_header.dart';
|
||||
import 'permanent_order_position_card.dart';
|
||||
import 'permanent_order_section_header.dart';
|
||||
import 'permanent_order_form.dart';
|
||||
import 'permanent_order_success_view.dart';
|
||||
|
||||
/// The main content of the Permanent Order page.
|
||||
@@ -40,9 +38,12 @@ class PermanentOrderView extends StatelessWidget {
|
||||
required this.onSubmit,
|
||||
required this.onDone,
|
||||
required this.onBack,
|
||||
this.isDataLoaded = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
final OrderFormStatus status;
|
||||
final String? errorMessage;
|
||||
final String eventName;
|
||||
@@ -65,7 +66,8 @@ class PermanentOrderView extends StatelessWidget {
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
final VoidCallback onPositionAdded;
|
||||
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
|
||||
final void Function(int index, OrderPositionUiModel position)
|
||||
onPositionUpdated;
|
||||
final void Function(int index) onPositionRemoved;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onDone;
|
||||
@@ -84,7 +86,12 @@ class PermanentOrderView extends StatelessWidget {
|
||||
context,
|
||||
message: translateErrorKey(errorMessage!),
|
||||
type: UiSnackbarType.error,
|
||||
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
|
||||
// bottom: 140 clears the bottom navigation bar area
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 140,
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -98,398 +105,99 @@ class PermanentOrderView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
showBackButton: true,
|
||||
onLeadingPressed: onBack,
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
),
|
||||
body: _buildBody(context, labels, oneTimeLabels),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the main body of the Permanent Order page based on the current state.
|
||||
Widget _buildBody(
|
||||
BuildContext context,
|
||||
TranslationsClientCreateOrderPermanentEn labels,
|
||||
TranslationsClientCreateOrderOneTimeEn oneTimeLabels,
|
||||
) {
|
||||
if (!isDataLoaded) {
|
||||
return const OrderFormSkeleton();
|
||||
}
|
||||
|
||||
if (vendors.isEmpty && status != OrderFormStatus.loading) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
PermanentOrderHeader(
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
onBack: onBack,
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
'No Vendors Available',
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
'There are no staffing vendors associated with your account.',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
t.client_create_order.no_vendors_title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
t.client_create_order.no_vendors_description,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
PermanentOrderHeader(
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
onBack: onBack,
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
_PermanentOrderForm(
|
||||
eventName: eventName,
|
||||
selectedVendor: selectedVendor,
|
||||
vendors: vendors,
|
||||
startDate: startDate,
|
||||
permanentDays: permanentDays,
|
||||
selectedHub: selectedHub,
|
||||
hubs: hubs,
|
||||
positions: positions,
|
||||
roles: roles,
|
||||
onEventNameChanged: onEventNameChanged,
|
||||
onVendorChanged: onVendorChanged,
|
||||
onStartDateChanged: onStartDateChanged,
|
||||
onDayToggled: onDayToggled,
|
||||
onHubChanged: onHubChanged,
|
||||
onHubManagerChanged: onHubManagerChanged,
|
||||
onPositionAdded: onPositionAdded,
|
||||
onPositionUpdated: onPositionUpdated,
|
||||
onPositionRemoved: onPositionRemoved,
|
||||
hubManagers: hubManagers,
|
||||
selectedHubManager: selectedHubManager,
|
||||
),
|
||||
if (status == OrderFormStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
_BottomActionButton(
|
||||
label: status == OrderFormStatus.loading
|
||||
? oneTimeLabels.creating
|
||||
: oneTimeLabels.create_order,
|
||||
isLoading: status == OrderFormStatus.loading,
|
||||
onPressed: isValid ? onSubmit : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PermanentOrderForm extends StatelessWidget {
|
||||
const _PermanentOrderForm({
|
||||
required this.eventName,
|
||||
required this.selectedVendor,
|
||||
required this.vendors,
|
||||
required this.startDate,
|
||||
required this.permanentDays,
|
||||
required this.selectedHub,
|
||||
required this.hubs,
|
||||
required this.positions,
|
||||
required this.roles,
|
||||
required this.onEventNameChanged,
|
||||
required this.onVendorChanged,
|
||||
required this.onStartDateChanged,
|
||||
required this.onDayToggled,
|
||||
required this.onHubChanged,
|
||||
required this.onHubManagerChanged,
|
||||
required this.onPositionAdded,
|
||||
required this.onPositionUpdated,
|
||||
required this.onPositionRemoved,
|
||||
required this.hubManagers,
|
||||
required this.selectedHubManager,
|
||||
});
|
||||
|
||||
final String eventName;
|
||||
final Vendor? selectedVendor;
|
||||
final List<Vendor> vendors;
|
||||
final DateTime startDate;
|
||||
final List<String> permanentDays;
|
||||
final OrderHubUiModel? selectedHub;
|
||||
final List<OrderHubUiModel> hubs;
|
||||
final List<OrderPositionUiModel> positions;
|
||||
final List<OrderRoleUiModel> roles;
|
||||
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
final ValueChanged<DateTime> onStartDateChanged;
|
||||
final ValueChanged<int> onDayToggled;
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
final VoidCallback onPositionAdded;
|
||||
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
|
||||
final void Function(int index) onPositionRemoved;
|
||||
|
||||
final List<OrderManagerUiModel> hubManagers;
|
||||
final OrderManagerUiModel? selectedHubManager;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderPermanentEn labels =
|
||||
t.client_create_order.permanent;
|
||||
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
labels.title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
PermanentOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: eventName,
|
||||
onChanged: onEventNameChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
PermanentOrderForm(
|
||||
eventName: eventName,
|
||||
selectedVendor: selectedVendor,
|
||||
vendors: vendors,
|
||||
startDate: startDate,
|
||||
permanentDays: permanentDays,
|
||||
selectedHub: selectedHub,
|
||||
hubs: hubs,
|
||||
positions: positions,
|
||||
roles: roles,
|
||||
onEventNameChanged: onEventNameChanged,
|
||||
onVendorChanged: onVendorChanged,
|
||||
onStartDateChanged: onStartDateChanged,
|
||||
onDayToggled: onDayToggled,
|
||||
onHubChanged: onHubChanged,
|
||||
onHubManagerChanged: onHubManagerChanged,
|
||||
onPositionAdded: onPositionAdded,
|
||||
onPositionUpdated: onPositionUpdated,
|
||||
onPositionRemoved: onPositionRemoved,
|
||||
hubManagers: hubManagers,
|
||||
selectedHubManager: selectedHubManager,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
onVendorChanged(vendor);
|
||||
}
|
||||
},
|
||||
items: vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (status == OrderFormStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
PermanentOrderDatePicker(
|
||||
label: 'Start Date',
|
||||
value: startDate,
|
||||
onChanged: onStartDateChanged,
|
||||
OrderBottomActionButton(
|
||||
label: status == OrderFormStatus.loading
|
||||
? oneTimeLabels.creating
|
||||
: oneTimeLabels.create_order,
|
||||
isLoading: status == OrderFormStatus.loading,
|
||||
onPressed: isValid ? onSubmit : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('Permanent Days', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_PermanentDaysSelector(
|
||||
selectedDays: permanentDays,
|
||||
onToggle: onDayToggled,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OrderHubUiModel>(
|
||||
isExpanded: true,
|
||||
value: selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (OrderHubUiModel? hub) {
|
||||
if (hub != null) {
|
||||
onHubChanged(hub);
|
||||
}
|
||||
},
|
||||
items: hubs.map((OrderHubUiModel hub) {
|
||||
return DropdownMenuItem<OrderHubUiModel>(
|
||||
value: hub,
|
||||
child: Text(
|
||||
hub.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
HubManagerSelector(
|
||||
label: oneTimeLabels.hub_manager_label,
|
||||
description: oneTimeLabels.hub_manager_desc,
|
||||
hintText: oneTimeLabels.hub_manager_hint,
|
||||
noManagersText: oneTimeLabels.hub_manager_empty,
|
||||
noneText: oneTimeLabels.hub_manager_none,
|
||||
managers: hubManagers,
|
||||
selectedManager: selectedHubManager,
|
||||
onChanged: onHubManagerChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
PermanentOrderSectionHeader(
|
||||
title: oneTimeLabels.positions_title,
|
||||
actionLabel: oneTimeLabels.add_position,
|
||||
onAction: onPositionAdded,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...positions.asMap().entries.map((
|
||||
MapEntry<int, OrderPositionUiModel> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final OrderPositionUiModel position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: PermanentOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: positions.length > 1,
|
||||
positionLabel: oneTimeLabels.positions_title,
|
||||
roleLabel: oneTimeLabels.select_role,
|
||||
workersLabel: oneTimeLabels.workers_label,
|
||||
startLabel: oneTimeLabels.start_label,
|
||||
endLabel: oneTimeLabels.end_label,
|
||||
lunchLabel: oneTimeLabels.lunch_break_label,
|
||||
roles: roles,
|
||||
onUpdated: (OrderPositionUiModel updated) {
|
||||
onPositionUpdated(index, updated);
|
||||
},
|
||||
onRemoved: () {
|
||||
onPositionRemoved(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PermanentDaysSelector extends StatelessWidget {
|
||||
const _PermanentDaysSelector({
|
||||
required this.selectedDays,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final List<String> selectedDays;
|
||||
final ValueChanged<int> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const List<String> labelsShort = <String>[
|
||||
'S',
|
||||
'M',
|
||||
'T',
|
||||
'W',
|
||||
'T',
|
||||
'F',
|
||||
'S',
|
||||
];
|
||||
const List<String> labelsLong = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
children: List<Widget>.generate(labelsShort.length, (int index) {
|
||||
final bool isSelected = selectedDays.contains(labelsLong[index]);
|
||||
return GestureDetector(
|
||||
onTap: () => onToggle(index),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
labelsShort[index],
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: isSelected ? UiColors.white : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomActionButton extends StatelessWidget {
|
||||
const _BottomActionButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
});
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
top: UiConstants.space5,
|
||||
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: label,
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A horizontal row of circular day-of-week toggle buttons for recurring orders.
|
||||
///
|
||||
/// Displays seven circles labeled S, M, T, W, T, F, S representing the days
|
||||
/// of the week. Selected days are highlighted with the primary color.
|
||||
class RecurringOrderDaysSelector extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderDaysSelector].
|
||||
const RecurringOrderDaysSelector({
|
||||
required this.selectedDays,
|
||||
required this.onToggle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The list of currently selected day abbreviations (e.g. 'MON', 'TUE').
|
||||
final List<String> selectedDays;
|
||||
|
||||
/// Called when a day circle is tapped, with the day index (0 = Sunday).
|
||||
final ValueChanged<int> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const List<String> labelsShort = <String>[
|
||||
'S',
|
||||
'M',
|
||||
'T',
|
||||
'W',
|
||||
'T',
|
||||
'F',
|
||||
'S',
|
||||
];
|
||||
const List<String> labelsLong = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
children: List<Widget>.generate(labelsShort.length, (int index) {
|
||||
final bool isSelected = selectedDays.contains(labelsLong[index]);
|
||||
return GestureDetector(
|
||||
onTap: () => onToggle(index),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
labelsShort[index],
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: isSelected ? UiColors.white : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
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' show Vendor;
|
||||
|
||||
import '../hub_manager_selector.dart';
|
||||
import '../order_ui_models.dart';
|
||||
import 'recurring_order_date_picker.dart';
|
||||
import 'recurring_order_days_selector.dart';
|
||||
import 'recurring_order_event_name_input.dart';
|
||||
import 'recurring_order_position_card.dart';
|
||||
import 'recurring_order_section_header.dart';
|
||||
|
||||
/// The scrollable form body for the recurring order creation flow.
|
||||
///
|
||||
/// Displays fields for event name, vendor selection, start/end dates,
|
||||
/// recurring day toggles, hub, hub manager, and a dynamic list of
|
||||
/// position cards.
|
||||
class RecurringOrderForm extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderForm].
|
||||
const RecurringOrderForm({
|
||||
required this.eventName,
|
||||
required this.selectedVendor,
|
||||
required this.vendors,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.recurringDays,
|
||||
required this.selectedHub,
|
||||
required this.hubs,
|
||||
required this.positions,
|
||||
required this.roles,
|
||||
required this.onEventNameChanged,
|
||||
required this.onVendorChanged,
|
||||
required this.onStartDateChanged,
|
||||
required this.onEndDateChanged,
|
||||
required this.onDayToggled,
|
||||
required this.onHubChanged,
|
||||
required this.onHubManagerChanged,
|
||||
required this.onPositionAdded,
|
||||
required this.onPositionUpdated,
|
||||
required this.onPositionRemoved,
|
||||
required this.hubManagers,
|
||||
required this.selectedHubManager,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current event name value.
|
||||
final String eventName;
|
||||
|
||||
/// The currently selected vendor, if any.
|
||||
final Vendor? selectedVendor;
|
||||
|
||||
/// The list of available vendors to choose from.
|
||||
final List<Vendor> vendors;
|
||||
|
||||
/// The start date for the recurring period.
|
||||
final DateTime startDate;
|
||||
|
||||
/// The end date for the recurring period.
|
||||
final DateTime endDate;
|
||||
|
||||
/// The list of selected recurring day abbreviations (e.g. 'MON', 'TUE').
|
||||
final List<String> recurringDays;
|
||||
|
||||
/// The currently selected hub, if any.
|
||||
final OrderHubUiModel? selectedHub;
|
||||
|
||||
/// The list of available hubs to choose from.
|
||||
final List<OrderHubUiModel> hubs;
|
||||
|
||||
/// The list of position entries in the order.
|
||||
final List<OrderPositionUiModel> positions;
|
||||
|
||||
/// The list of available roles for position assignment.
|
||||
final List<OrderRoleUiModel> roles;
|
||||
|
||||
/// Called when the event name text changes.
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
|
||||
/// Called when a vendor is selected.
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
|
||||
/// Called when the start date is changed.
|
||||
final ValueChanged<DateTime> onStartDateChanged;
|
||||
|
||||
/// Called when the end date is changed.
|
||||
final ValueChanged<DateTime> onEndDateChanged;
|
||||
|
||||
/// Called when a day-of-week toggle is tapped, with the day index (0=Sun).
|
||||
final ValueChanged<int> onDayToggled;
|
||||
|
||||
/// Called when a hub is selected.
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
|
||||
/// Called when a hub manager is selected or cleared.
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
|
||||
/// Called when the user requests adding a new position.
|
||||
final VoidCallback onPositionAdded;
|
||||
|
||||
/// Called when a position at [index] is updated with new values.
|
||||
final void Function(int index, OrderPositionUiModel position)
|
||||
onPositionUpdated;
|
||||
|
||||
/// Called when a position at [index] is removed.
|
||||
final void Function(int index) onPositionRemoved;
|
||||
|
||||
/// The list of available hub managers for the selected hub.
|
||||
final List<OrderManagerUiModel> hubManagers;
|
||||
|
||||
/// The currently selected hub manager, if any.
|
||||
final OrderManagerUiModel? selectedHubManager;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
children: <Widget>[
|
||||
RecurringOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: eventName,
|
||||
onChanged: onEventNameChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
onVendorChanged(vendor);
|
||||
}
|
||||
},
|
||||
items: vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
RecurringOrderDatePicker(
|
||||
label: 'Start Date',
|
||||
value: startDate,
|
||||
onChanged: onStartDateChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
RecurringOrderDatePicker(
|
||||
label: 'End Date',
|
||||
value: endDate,
|
||||
onChanged: onEndDateChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('Recurring Days', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
RecurringOrderDaysSelector(
|
||||
selectedDays: recurringDays,
|
||||
onToggle: onDayToggled,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OrderHubUiModel>(
|
||||
isExpanded: true,
|
||||
value: selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (OrderHubUiModel? hub) {
|
||||
if (hub != null) {
|
||||
onHubChanged(hub);
|
||||
}
|
||||
},
|
||||
items: hubs.map((OrderHubUiModel hub) {
|
||||
return DropdownMenuItem<OrderHubUiModel>(
|
||||
value: hub,
|
||||
child: Text(hub.name, style: UiTypography.body2m.textPrimary),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
HubManagerSelector(
|
||||
label: oneTimeLabels.hub_manager_label,
|
||||
description: oneTimeLabels.hub_manager_desc,
|
||||
hintText: oneTimeLabels.hub_manager_hint,
|
||||
noManagersText: oneTimeLabels.hub_manager_empty,
|
||||
noneText: oneTimeLabels.hub_manager_none,
|
||||
managers: hubManagers,
|
||||
selectedManager: selectedHubManager,
|
||||
onChanged: onHubManagerChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
RecurringOrderSectionHeader(
|
||||
title: oneTimeLabels.positions_title,
|
||||
actionLabel: oneTimeLabels.add_position,
|
||||
onAction: onPositionAdded,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...positions.asMap().entries.map((
|
||||
MapEntry<int, OrderPositionUiModel> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final OrderPositionUiModel position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: RecurringOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: positions.length > 1,
|
||||
positionLabel: oneTimeLabels.positions_title,
|
||||
roleLabel: oneTimeLabels.select_role,
|
||||
workersLabel: oneTimeLabels.workers_label,
|
||||
startLabel: oneTimeLabels.start_label,
|
||||
endLabel: oneTimeLabels.end_label,
|
||||
lunchLabel: oneTimeLabels.lunch_break_label,
|
||||
roles: roles,
|
||||
onUpdated: (OrderPositionUiModel updated) {
|
||||
onPositionUpdated(index, updated);
|
||||
},
|
||||
onRemoved: () {
|
||||
onPositionRemoved(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A header widget for the recurring order flow with a colored background.
|
||||
class RecurringOrderHeader extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderHeader].
|
||||
const RecurringOrderHeader({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onBack,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title of the page.
|
||||
final String title;
|
||||
|
||||
/// The subtitle or description.
|
||||
final String subtitle;
|
||||
|
||||
/// Callback when the back button is pressed.
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + UiConstants.space5,
|
||||
bottom: UiConstants.space5,
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
),
|
||||
color: UiColors.primary,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: onBack,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
color: UiColors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.headline3m.copyWith(color: UiColors.white),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show Vendor;
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show Vendor;
|
||||
|
||||
import '../order_bottom_action_button.dart';
|
||||
import '../order_form_skeleton.dart';
|
||||
import '../order_ui_models.dart';
|
||||
import '../hub_manager_selector.dart';
|
||||
import 'recurring_order_date_picker.dart';
|
||||
import 'recurring_order_event_name_input.dart';
|
||||
import 'recurring_order_header.dart';
|
||||
import 'recurring_order_position_card.dart';
|
||||
import 'recurring_order_section_header.dart';
|
||||
import 'recurring_order_form.dart';
|
||||
import 'recurring_order_success_view.dart';
|
||||
|
||||
/// The main content of the Recurring Order page.
|
||||
@@ -42,9 +40,12 @@ class RecurringOrderView extends StatelessWidget {
|
||||
required this.onSubmit,
|
||||
required this.onDone,
|
||||
required this.onBack,
|
||||
this.isDataLoaded = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
final OrderFormStatus status;
|
||||
final String? errorMessage;
|
||||
final String eventName;
|
||||
@@ -69,7 +70,8 @@ class RecurringOrderView extends StatelessWidget {
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
final VoidCallback onPositionAdded;
|
||||
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
|
||||
final void Function(int index, OrderPositionUiModel position)
|
||||
onPositionUpdated;
|
||||
final void Function(int index) onPositionRemoved;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onDone;
|
||||
@@ -91,7 +93,12 @@ class RecurringOrderView extends StatelessWidget {
|
||||
context,
|
||||
message: message,
|
||||
type: UiSnackbarType.error,
|
||||
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
|
||||
// bottom: 140 clears the bottom navigation bar area
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 140,
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -105,412 +112,101 @@ class RecurringOrderView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
showBackButton: true,
|
||||
onLeadingPressed: onBack,
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
),
|
||||
body: _buildBody(context, labels, oneTimeLabels),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the main body of the Recurring Order page, including the form and handling empty vendor state.
|
||||
Widget _buildBody(
|
||||
BuildContext context,
|
||||
TranslationsClientCreateOrderRecurringEn labels,
|
||||
TranslationsClientCreateOrderOneTimeEn oneTimeLabels,
|
||||
) {
|
||||
if (!isDataLoaded) {
|
||||
return const OrderFormSkeleton();
|
||||
}
|
||||
|
||||
if (vendors.isEmpty && status != OrderFormStatus.loading) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
RecurringOrderHeader(
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
onBack: onBack,
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
'No Vendors Available',
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
'There are no staffing vendors associated with your account.',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
t.client_create_order.no_vendors_title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
t.client_create_order.no_vendors_description,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
RecurringOrderHeader(
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
onBack: onBack,
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
_RecurringOrderForm(
|
||||
eventName: eventName,
|
||||
selectedVendor: selectedVendor,
|
||||
vendors: vendors,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
recurringDays: recurringDays,
|
||||
selectedHub: selectedHub,
|
||||
hubs: hubs,
|
||||
positions: positions,
|
||||
roles: roles,
|
||||
onEventNameChanged: onEventNameChanged,
|
||||
onVendorChanged: onVendorChanged,
|
||||
onStartDateChanged: onStartDateChanged,
|
||||
onEndDateChanged: onEndDateChanged,
|
||||
onDayToggled: onDayToggled,
|
||||
onHubChanged: onHubChanged,
|
||||
onHubManagerChanged: onHubManagerChanged,
|
||||
onPositionAdded: onPositionAdded,
|
||||
onPositionUpdated: onPositionUpdated,
|
||||
onPositionRemoved: onPositionRemoved,
|
||||
hubManagers: hubManagers,
|
||||
selectedHubManager: selectedHubManager,
|
||||
),
|
||||
if (status == OrderFormStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
_BottomActionButton(
|
||||
label: status == OrderFormStatus.loading
|
||||
? oneTimeLabels.creating
|
||||
: oneTimeLabels.create_order,
|
||||
isLoading: status == OrderFormStatus.loading,
|
||||
onPressed: isValid ? onSubmit : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecurringOrderForm extends StatelessWidget {
|
||||
const _RecurringOrderForm({
|
||||
required this.eventName,
|
||||
required this.selectedVendor,
|
||||
required this.vendors,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.recurringDays,
|
||||
required this.selectedHub,
|
||||
required this.hubs,
|
||||
required this.positions,
|
||||
required this.roles,
|
||||
required this.onEventNameChanged,
|
||||
required this.onVendorChanged,
|
||||
required this.onStartDateChanged,
|
||||
required this.onEndDateChanged,
|
||||
required this.onDayToggled,
|
||||
required this.onHubChanged,
|
||||
required this.onHubManagerChanged,
|
||||
required this.onPositionAdded,
|
||||
required this.onPositionUpdated,
|
||||
required this.onPositionRemoved,
|
||||
required this.hubManagers,
|
||||
required this.selectedHubManager,
|
||||
});
|
||||
|
||||
final String eventName;
|
||||
final Vendor? selectedVendor;
|
||||
final List<Vendor> vendors;
|
||||
final DateTime startDate;
|
||||
final DateTime endDate;
|
||||
final List<String> recurringDays;
|
||||
final OrderHubUiModel? selectedHub;
|
||||
final List<OrderHubUiModel> hubs;
|
||||
final List<OrderPositionUiModel> positions;
|
||||
final List<OrderRoleUiModel> roles;
|
||||
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
final ValueChanged<DateTime> onStartDateChanged;
|
||||
final ValueChanged<DateTime> onEndDateChanged;
|
||||
final ValueChanged<int> onDayToggled;
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
final VoidCallback onPositionAdded;
|
||||
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
|
||||
final void Function(int index) onPositionRemoved;
|
||||
|
||||
final List<OrderManagerUiModel> hubManagers;
|
||||
final OrderManagerUiModel? selectedHubManager;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderRecurringEn labels =
|
||||
t.client_create_order.recurring;
|
||||
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
labels.title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
RecurringOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: eventName,
|
||||
onChanged: onEventNameChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
RecurringOrderForm(
|
||||
eventName: eventName,
|
||||
selectedVendor: selectedVendor,
|
||||
vendors: vendors,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
recurringDays: recurringDays,
|
||||
selectedHub: selectedHub,
|
||||
hubs: hubs,
|
||||
positions: positions,
|
||||
roles: roles,
|
||||
onEventNameChanged: onEventNameChanged,
|
||||
onVendorChanged: onVendorChanged,
|
||||
onStartDateChanged: onStartDateChanged,
|
||||
onEndDateChanged: onEndDateChanged,
|
||||
onDayToggled: onDayToggled,
|
||||
onHubChanged: onHubChanged,
|
||||
onHubManagerChanged: onHubManagerChanged,
|
||||
onPositionAdded: onPositionAdded,
|
||||
onPositionUpdated: onPositionUpdated,
|
||||
onPositionRemoved: onPositionRemoved,
|
||||
hubManagers: hubManagers,
|
||||
selectedHubManager: selectedHubManager,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
onVendorChanged(vendor);
|
||||
}
|
||||
},
|
||||
items: vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (status == OrderFormStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
RecurringOrderDatePicker(
|
||||
label: 'Start Date',
|
||||
value: startDate,
|
||||
onChanged: onStartDateChanged,
|
||||
OrderBottomActionButton(
|
||||
label: status == OrderFormStatus.loading
|
||||
? oneTimeLabels.creating
|
||||
: oneTimeLabels.create_order,
|
||||
isLoading: status == OrderFormStatus.loading,
|
||||
onPressed: isValid ? onSubmit : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
RecurringOrderDatePicker(
|
||||
label: 'End Date',
|
||||
value: endDate,
|
||||
onChanged: onEndDateChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('Recurring Days', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_RecurringDaysSelector(
|
||||
selectedDays: recurringDays,
|
||||
onToggle: onDayToggled,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OrderHubUiModel>(
|
||||
isExpanded: true,
|
||||
value: selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (OrderHubUiModel? hub) {
|
||||
if (hub != null) {
|
||||
onHubChanged(hub);
|
||||
}
|
||||
},
|
||||
items: hubs.map((OrderHubUiModel hub) {
|
||||
return DropdownMenuItem<OrderHubUiModel>(
|
||||
value: hub,
|
||||
child: Text(
|
||||
hub.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
HubManagerSelector(
|
||||
label: oneTimeLabels.hub_manager_label,
|
||||
description: oneTimeLabels.hub_manager_desc,
|
||||
hintText: oneTimeLabels.hub_manager_hint,
|
||||
noManagersText: oneTimeLabels.hub_manager_empty,
|
||||
noneText: oneTimeLabels.hub_manager_none,
|
||||
managers: hubManagers,
|
||||
selectedManager: selectedHubManager,
|
||||
onChanged: onHubManagerChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
RecurringOrderSectionHeader(
|
||||
title: oneTimeLabels.positions_title,
|
||||
actionLabel: oneTimeLabels.add_position,
|
||||
onAction: onPositionAdded,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...positions.asMap().entries.map((
|
||||
MapEntry<int, OrderPositionUiModel> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final OrderPositionUiModel position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: RecurringOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: positions.length > 1,
|
||||
positionLabel: oneTimeLabels.positions_title,
|
||||
roleLabel: oneTimeLabels.select_role,
|
||||
workersLabel: oneTimeLabels.workers_label,
|
||||
startLabel: oneTimeLabels.start_label,
|
||||
endLabel: oneTimeLabels.end_label,
|
||||
lunchLabel: oneTimeLabels.lunch_break_label,
|
||||
roles: roles,
|
||||
onUpdated: (OrderPositionUiModel updated) {
|
||||
onPositionUpdated(index, updated);
|
||||
},
|
||||
onRemoved: () {
|
||||
onPositionRemoved(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecurringDaysSelector extends StatelessWidget {
|
||||
const _RecurringDaysSelector({
|
||||
required this.selectedDays,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final List<String> selectedDays;
|
||||
final ValueChanged<int> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const List<String> labelsShort = <String>[
|
||||
'S',
|
||||
'M',
|
||||
'T',
|
||||
'W',
|
||||
'T',
|
||||
'F',
|
||||
'S',
|
||||
];
|
||||
const List<String> labelsLong = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
children: List<Widget>.generate(labelsShort.length, (int index) {
|
||||
final bool isSelected = selectedDays.contains(labelsLong[index]);
|
||||
return GestureDetector(
|
||||
onTap: () => onToggle(index),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
labelsShort[index],
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: isSelected ? UiColors.white : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomActionButton extends StatelessWidget {
|
||||
const _BottomActionButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
});
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
top: UiConstants.space5,
|
||||
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: label,
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../widgets/view_orders_header.dart';
|
||||
import '../widgets/view_orders_empty_state.dart';
|
||||
import '../widgets/view_orders_error_state.dart';
|
||||
import '../widgets/view_orders_list.dart';
|
||||
import '../widgets/view_orders_page_skeleton.dart';
|
||||
|
||||
/// The main page for viewing client orders.
|
||||
///
|
||||
@@ -101,20 +102,26 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
|
||||
|
||||
// Content List
|
||||
Expanded(
|
||||
child: state.status == ViewOrdersStatus.failure
|
||||
? ViewOrdersErrorState(
|
||||
errorMessage: state.errorMessage,
|
||||
selectedDate: state.selectedDate,
|
||||
onRetry: () => BlocProvider.of<ViewOrdersCubit>(
|
||||
context,
|
||||
).jumpToDate(state.selectedDate ?? DateTime.now()),
|
||||
)
|
||||
: filteredOrders.isEmpty
|
||||
? ViewOrdersEmptyState(selectedDate: state.selectedDate)
|
||||
: ViewOrdersList(
|
||||
orders: filteredOrders,
|
||||
filterTab: state.filterTab,
|
||||
),
|
||||
child: switch (state.status) {
|
||||
ViewOrdersStatus.loading ||
|
||||
ViewOrdersStatus.initial =>
|
||||
const ViewOrdersPageSkeleton(),
|
||||
ViewOrdersStatus.failure => ViewOrdersErrorState(
|
||||
errorMessage: state.errorMessage,
|
||||
selectedDate: state.selectedDate,
|
||||
onRetry: () => BlocProvider.of<ViewOrdersCubit>(
|
||||
context,
|
||||
).jumpToDate(state.selectedDate ?? DateTime.now()),
|
||||
),
|
||||
ViewOrdersStatus.success => filteredOrders.isEmpty
|
||||
? ViewOrdersEmptyState(
|
||||
selectedDate: state.selectedDate,
|
||||
)
|
||||
: ViewOrdersList(
|
||||
orders: filteredOrders,
|
||||
filterTab: state.filterTab,
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -161,14 +161,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
@@ -249,9 +242,12 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
||||
size: 14,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
Text(
|
||||
order.eventName,
|
||||
style: UiTypography.headline5m.textSecondary,
|
||||
Expanded(
|
||||
child: Text(
|
||||
order.eventName,
|
||||
style: UiTypography.headline5m.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -313,7 +309,8 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
order.hubManagerName!,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
style:
|
||||
UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -335,7 +332,8 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
||||
bgColor: UiColors.primary.withValues(alpha: 0.08),
|
||||
onTap: () => _openEditSheet(order: order),
|
||||
),
|
||||
if (_canEditOrder(order)) const SizedBox(width: UiConstants.space2),
|
||||
if (_canEditOrder(order))
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
if (order.confirmedApps.isNotEmpty)
|
||||
_buildHeaderIconButton(
|
||||
icon: _expanded
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export 'view_orders_page_skeleton/index.dart';
|
||||
@@ -0,0 +1,5 @@
|
||||
export 'order_card_skeleton.dart';
|
||||
export 'section_header_skeleton.dart';
|
||||
export 'stat_divider_skeleton.dart';
|
||||
export 'stat_item_skeleton.dart';
|
||||
export 'view_orders_page_skeleton.dart';
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'stat_divider_skeleton.dart';
|
||||
import 'stat_item_skeleton.dart';
|
||||
|
||||
/// Shimmer placeholder for a single order card.
|
||||
class OrderCardSkeleton extends StatelessWidget {
|
||||
/// Creates an [OrderCardSkeleton].
|
||||
const OrderCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Status and type badges
|
||||
Row(
|
||||
children: <Widget>[
|
||||
UiShimmerBox(
|
||||
width: 80,
|
||||
height: 22,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
UiShimmerBox(
|
||||
width: 72,
|
||||
height: 22,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Title line
|
||||
const UiShimmerLine(width: 200, height: 18),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
|
||||
// Event name line
|
||||
const UiShimmerLine(width: 160, height: 14),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Location lines
|
||||
const Row(
|
||||
children: <Widget>[
|
||||
UiShimmerCircle(size: 14),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 180, height: 12),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 140, height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Stats row (cost / hours / workers)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
StatItemSkeleton(),
|
||||
StatDividerSkeleton(),
|
||||
StatItemSkeleton(),
|
||||
StatDividerSkeleton(),
|
||||
StatItemSkeleton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
|
||||
// Time boxes (clock in / clock out)
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(child: _timeBoxSkeleton()),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(child: _timeBoxSkeleton()),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Coverage progress bar
|
||||
const UiShimmerLine(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a placeholder for a time display box (clock-in / clock-out).
|
||||
Widget _timeBoxSkeleton() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Column(
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 60, height: 10),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 80, height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for the section header row (dot + title + count).
|
||||
class SectionHeaderSkeleton extends StatelessWidget {
|
||||
/// Creates a [SectionHeaderSkeleton].
|
||||
const SectionHeaderSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
UiShimmerCircle(size: 8),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
UiShimmerLine(width: 100, height: 14),
|
||||
SizedBox(width: UiConstants.space1),
|
||||
UiShimmerLine(width: 24, height: 14),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for the vertical stat divider.
|
||||
class StatDividerSkeleton extends StatelessWidget {
|
||||
/// Creates a [StatDividerSkeleton].
|
||||
const StatDividerSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const UiShimmerBox(
|
||||
width: 1,
|
||||
height: 24,
|
||||
borderRadius: BorderRadius.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for a single stat item (icon + value + label).
|
||||
class StatItemSkeleton extends StatelessWidget {
|
||||
/// Creates a [StatItemSkeleton].
|
||||
const StatItemSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
UiShimmerCircle(size: 14),
|
||||
UiShimmerLine(width: 32, height: 16),
|
||||
UiShimmerLine(width: 40, height: 10),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'order_card_skeleton.dart';
|
||||
import 'section_header_skeleton.dart';
|
||||
|
||||
/// Shimmer loading skeleton for the View Orders page.
|
||||
///
|
||||
/// Mimics the loaded layout: a section header followed by a list of order
|
||||
/// card placeholders, each containing badge, title, location, stats, time
|
||||
/// boxes, and a coverage progress bar.
|
||||
class ViewOrdersPageSkeleton extends StatelessWidget {
|
||||
/// Creates a [ViewOrdersPageSkeleton].
|
||||
const ViewOrdersPageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
UiConstants.space4,
|
||||
UiConstants.space5,
|
||||
// Extra bottom padding for bottom navigation clearance.
|
||||
UiConstants.space24,
|
||||
),
|
||||
children: <Widget>[
|
||||
// Section header placeholder (dot + title + count)
|
||||
const SectionHeaderSkeleton(),
|
||||
// Order card placeholders
|
||||
...List<Widget>.generate(3, (int index) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: OrderCardSkeleton(),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user