permanent order v1

This commit is contained in:
José Salazar
2026-02-17 18:59:31 -05:00
parent 9fb138c4ee
commit 75e534620d
21 changed files with 2095 additions and 31 deletions

View File

@@ -39,6 +39,8 @@ export 'src/entities/orders/one_time_order.dart';
export 'src/entities/orders/one_time_order_position.dart';
export 'src/entities/orders/recurring_order.dart';
export 'src/entities/orders/recurring_order_position.dart';
export 'src/entities/orders/permanent_order.dart';
export 'src/entities/orders/permanent_order_position.dart';
export 'src/entities/orders/order_item.dart';
// Skills & Certs

View File

@@ -0,0 +1,96 @@
import 'package:equatable/equatable.dart';
import 'permanent_order_position.dart';
/// Represents a permanent staffing request spanning a date range.
class PermanentOrder extends Equatable {
const PermanentOrder({
required this.startDate,
required this.permanentDays,
required this.location,
required this.positions,
this.hub,
this.eventName,
this.vendorId,
this.roleRates = const <String, double>{},
});
/// Start date for the permanent schedule.
final DateTime startDate;
/// Days of the week to repeat on (e.g., ["SUN", "MON", ...]).
final List<String> permanentDays;
/// The primary location where the work will take place.
final String location;
/// The list of positions and headcounts required for this order.
final List<PermanentOrderPosition> positions;
/// Selected hub details for this order.
final PermanentOrderHubDetails? hub;
/// Optional order name.
final String? eventName;
/// Selected vendor id for this order.
final String? vendorId;
/// Role hourly rates keyed by role id.
final Map<String, double> roleRates;
@override
List<Object?> get props => <Object?>[
startDate,
permanentDays,
location,
positions,
hub,
eventName,
vendorId,
roleRates,
];
}
/// Minimal hub details used during permanent order creation.
class PermanentOrderHubDetails extends Equatable {
const PermanentOrderHubDetails({
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,
});
final String id;
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
];
}

View File

@@ -0,0 +1,60 @@
import 'package:equatable/equatable.dart';
/// Represents a specific position requirement within a [PermanentOrder].
class PermanentOrderPosition extends Equatable {
const PermanentOrderPosition({
required this.role,
required this.count,
required this.startTime,
required this.endTime,
this.lunchBreak = 'NO_BREAK',
this.location,
});
/// The job role or title required.
final String role;
/// The number of workers required for this position.
final int count;
/// The scheduled start time (e.g., "09:00 AM").
final String startTime;
/// The scheduled end time (e.g., "05:00 PM").
final String endTime;
/// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30).
final String lunchBreak;
/// Optional specific location for this position, if different from the order's main location.
final String? location;
@override
List<Object?> get props => <Object?>[
role,
count,
startTime,
endTime,
lunchBreak,
location,
];
/// Creates a copy of this position with the given fields replaced.
PermanentOrderPosition copyWith({
String? role,
int? count,
String? startTime,
String? endTime,
String? lunchBreak,
String? location,
}) {
return PermanentOrderPosition(
role: role ?? this.role,
count: count ?? this.count,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
lunchBreak: lunchBreak ?? this.lunchBreak,
location: location ?? this.location,
);
}
}

View File

@@ -5,11 +5,13 @@ import 'package:krow_data_connect/krow_data_connect.dart';
import 'data/repositories_impl/client_create_order_repository_impl.dart';
import 'domain/repositories/client_create_order_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';
import 'domain/usecases/create_rapid_order_usecase.dart';
import 'domain/usecases/get_order_types_usecase.dart';
import 'presentation/blocs/client_create_order_bloc.dart';
import 'presentation/blocs/one_time_order_bloc.dart';
import 'presentation/blocs/permanent_order_bloc.dart';
import 'presentation/blocs/recurring_order_bloc.dart';
import 'presentation/blocs/rapid_order_bloc.dart';
import 'presentation/pages/create_order_page.dart';
@@ -35,6 +37,7 @@ class ClientCreateOrderModule extends Module {
// UseCases
i.addLazySingleton(GetOrderTypesUseCase.new);
i.addLazySingleton(CreateOneTimeOrderUseCase.new);
i.addLazySingleton(CreatePermanentOrderUseCase.new);
i.addLazySingleton(CreateRecurringOrderUseCase.new);
i.addLazySingleton(CreateRapidOrderUseCase.new);
@@ -42,6 +45,7 @@ class ClientCreateOrderModule extends Module {
i.add<ClientCreateOrderBloc>(ClientCreateOrderBloc.new);
i.add<RapidOrderBloc>(RapidOrderBloc.new);
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new);
i.add<PermanentOrderBloc>(PermanentOrderBloc.new);
i.add<RecurringOrderBloc>(RecurringOrderBloc.new);
}

View File

@@ -43,6 +43,11 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
// titleKey: 'client_create_order.types.permanent',
// descriptionKey: 'client_create_order.types.permanent_desc',
// ),
domain.OrderType(
id: 'permanent',
titleKey: 'client_create_order.types.permanent',
descriptionKey: 'client_create_order.types.permanent_desc',
),
]);
}
@@ -261,6 +266,124 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
});
}
@override
Future<void> createPermanentOrder(domain.PermanentOrder order) async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final String? vendorId = order.vendorId;
if (vendorId == null || vendorId.isEmpty) {
throw Exception('Vendor is missing.');
}
final domain.PermanentOrderHubDetails? hub = order.hub;
if (hub == null || hub.id.isEmpty) {
throw Exception('Hub is missing.');
}
final DateTime orderDateOnly = DateTime(
order.startDate.year,
order.startDate.month,
order.startDate.day,
);
final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly);
final fdc.Timestamp startTimestamp = orderTimestamp;
final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables> orderResult =
await _service.connector
.createOrder(
businessId: businessId,
orderType: dc.OrderType.PERMANENT,
teamHubId: hub.id,
)
.vendorId(vendorId)
.eventName(order.eventName)
.status(dc.OrderStatus.POSTED)
.date(orderTimestamp)
.startDate(startTimestamp)
.permanentDays(order.permanentDays)
.execute();
final String orderId = orderResult.data.order_insert.id;
// NOTE: Permanent orders are limited to 30 days of generated shifts.
// Future shifts beyond 30 days should be created by a scheduled job.
final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29));
final Set<String> selectedDays = Set<String>.from(order.permanentDays);
final int workersNeeded = order.positions.fold<int>(
0,
(int sum, domain.PermanentOrderPosition position) => sum + position.count,
);
final double shiftCost = _calculatePermanentShiftCost(order);
final List<String> shiftIds = <String>[];
for (DateTime day = orderDateOnly;
!day.isAfter(maxEndDate);
day = day.add(const Duration(days: 1))) {
final String dayLabel = _weekdayLabel(day);
if (!selectedDays.contains(dayLabel)) {
continue;
}
final String shiftTitle = 'Shift ${_formatDate(day)}';
final fdc.Timestamp dayTimestamp = _service.toTimestamp(
DateTime(day.year, day.month, day.day),
);
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables> shiftResult =
await _service.connector
.createShift(title: shiftTitle, orderId: orderId)
.date(dayTimestamp)
.location(hub.name)
.locationAddress(hub.address)
.latitude(hub.latitude)
.longitude(hub.longitude)
.placeId(hub.placeId)
.city(hub.city)
.state(hub.state)
.street(hub.street)
.country(hub.country)
.status(dc.ShiftStatus.CONFIRMED)
.workersNeeded(workersNeeded)
.filled(0)
.durationDays(1)
.cost(shiftCost)
.execute();
final String shiftId = shiftResult.data.shift_insert.id;
shiftIds.add(shiftId);
for (final domain.PermanentOrderPosition position in order.positions) {
final DateTime start = _parseTime(day, position.startTime);
final DateTime end = _parseTime(day, position.endTime);
final DateTime normalizedEnd =
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
final double rate = order.roleRates[position.role] ?? 0;
final double totalValue = rate * hours * position.count;
await _service.connector
.createShiftRole(
shiftId: shiftId,
roleId: position.role,
count: position.count,
)
.startTime(_service.toTimestamp(start))
.endTime(_service.toTimestamp(normalizedEnd))
.hours(hours)
.breakType(_breakDurationFromValue(position.lunchBreak))
.isBreakPaid(_isBreakPaid(position.lunchBreak))
.totalValue(totalValue)
.execute();
}
}
await _service.connector
.updateOrder(id: orderId, teamHubId: hub.id)
.shifts(fdc.AnyValue(shiftIds))
.execute();
});
}
@override
Future<void> createRapidOrder(String description) async {
// TO-DO: connect IA and return array with the information.
@@ -295,6 +418,20 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
return total;
}
double _calculatePermanentShiftCost(domain.PermanentOrder order) {
double total = 0;
for (final domain.PermanentOrderPosition position in order.positions) {
final DateTime start = _parseTime(order.startDate, position.startTime);
final DateTime end = _parseTime(order.startDate, position.endTime);
final DateTime normalizedEnd =
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
final double rate = order.roleRates[position.role] ?? 0;
total += rate * hours * position.count;
}
return total;
}
String _weekdayLabel(DateTime date) {
switch (date.weekday) {
case DateTime.monday:

View File

@@ -0,0 +1,6 @@
import 'package:krow_domain/krow_domain.dart';
class PermanentOrderArguments {
const PermanentOrderArguments({required this.order});
final PermanentOrder order;
}

View File

@@ -20,6 +20,9 @@ abstract interface class ClientCreateOrderRepositoryInterface {
/// Submits a recurring staffing order with specific details.
Future<void> createRecurringOrder(RecurringOrder order);
/// Submits a permanent staffing order with specific details.
Future<void> createPermanentOrder(PermanentOrder order);
/// Submits a rapid (urgent) staffing order via a text description.
///
/// [description] is the text message (or transcribed voice) describing the need.

View File

@@ -0,0 +1,16 @@
import 'package:krow_core/core.dart';
import '../arguments/permanent_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Use case for creating a permanent staffing order.
class CreatePermanentOrderUseCase
implements UseCase<PermanentOrderArguments, void> {
/// Creates a [CreatePermanentOrderUseCase].
const CreatePermanentOrderUseCase(this._repository);
final ClientCreateOrderRepositoryInterface _repository;
@override
Future<void> call(PermanentOrderArguments input) {
return _repository.createPermanentOrder(input.order);
}
}

View File

@@ -0,0 +1,338 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
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 '../../domain/arguments/permanent_order_arguments.dart';
import '../../domain/usecases/create_permanent_order_usecase.dart';
import 'permanent_order_event.dart';
import 'permanent_order_state.dart';
/// BLoC for managing the permanent order creation form.
class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
with BlocErrorHandler<PermanentOrderState>, SafeBloc<PermanentOrderEvent, PermanentOrderState> {
PermanentOrderBloc(this._createPermanentOrderUseCase, this._service)
: super(PermanentOrderState.initial()) {
on<PermanentOrderVendorsLoaded>(_onVendorsLoaded);
on<PermanentOrderVendorChanged>(_onVendorChanged);
on<PermanentOrderHubsLoaded>(_onHubsLoaded);
on<PermanentOrderHubChanged>(_onHubChanged);
on<PermanentOrderEventNameChanged>(_onEventNameChanged);
on<PermanentOrderStartDateChanged>(_onStartDateChanged);
on<PermanentOrderDayToggled>(_onDayToggled);
on<PermanentOrderPositionAdded>(_onPositionAdded);
on<PermanentOrderPositionRemoved>(_onPositionRemoved);
on<PermanentOrderPositionUpdated>(_onPositionUpdated);
on<PermanentOrderSubmitted>(_onSubmitted);
_loadVendors();
_loadHubs();
}
final CreatePermanentOrderUseCase _createPermanentOrderUseCase;
final dc.DataConnectService _service;
static const List<String> _dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
Future<void> _loadVendors() async {
final List<domain.Vendor>? vendors = await handleErrorWithResult(
action: () async {
final 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();
},
onError: (_) => add(const PermanentOrderVendorsLoaded(<domain.Vendor>[])),
);
if (vendors != null) {
add(PermanentOrderVendorsLoaded(vendors));
}
}
Future<void> _loadRolesForVendor(
String vendorId,
Emitter<PermanentOrderState> emit,
) async {
final List<PermanentOrderRoleOption>? roles = await handleErrorWithResult(
action: () async {
final QueryResult<dc.ListRolesByVendorIdData, dc.ListRolesByVendorIdVariables>
result = await _service.connector
.listRolesByVendorId(vendorId: vendorId)
.execute();
return result.data.roles
.map(
(dc.ListRolesByVendorIdRoles role) => PermanentOrderRoleOption(
id: role.id,
name: role.name,
costPerHour: role.costPerHour,
),
)
.toList();
},
onError: (_) => emit(state.copyWith(roles: const <PermanentOrderRoleOption>[])),
);
if (roles != null) {
emit(state.copyWith(roles: roles));
}
}
Future<void> _loadHubs() async {
final List<PermanentOrderHubOption>? hubs = await handleErrorWithResult(
action: () async {
final String businessId = await _service.getBusinessId();
final QueryResult<dc.ListTeamHubsByOwnerIdData, dc.ListTeamHubsByOwnerIdVariables>
result = await _service.connector
.listTeamHubsByOwnerId(ownerId: businessId)
.execute();
return result.data.teamHubs
.map(
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => PermanentOrderHubOption(
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();
},
onError: (_) => add(const PermanentOrderHubsLoaded(<PermanentOrderHubOption>[])),
);
if (hubs != null) {
add(PermanentOrderHubsLoaded(hubs));
}
}
Future<void> _onVendorsLoaded(
PermanentOrderVendorsLoaded event,
Emitter<PermanentOrderState> emit,
) async {
final domain.Vendor? selectedVendor =
event.vendors.isNotEmpty ? event.vendors.first : null;
emit(
state.copyWith(
vendors: event.vendors,
selectedVendor: selectedVendor,
),
);
if (selectedVendor != null) {
await _loadRolesForVendor(selectedVendor.id, emit);
}
}
Future<void> _onVendorChanged(
PermanentOrderVendorChanged event,
Emitter<PermanentOrderState> emit,
) async {
emit(state.copyWith(selectedVendor: event.vendor));
await _loadRolesForVendor(event.vendor.id, emit);
}
void _onHubsLoaded(
PermanentOrderHubsLoaded event,
Emitter<PermanentOrderState> emit,
) {
final PermanentOrderHubOption? selectedHub =
event.hubs.isNotEmpty ? event.hubs.first : null;
emit(
state.copyWith(
hubs: event.hubs,
selectedHub: selectedHub,
location: selectedHub?.name ?? '',
),
);
}
void _onHubChanged(
PermanentOrderHubChanged event,
Emitter<PermanentOrderState> emit,
) {
emit(
state.copyWith(
selectedHub: event.hub,
location: event.hub.name,
),
);
}
void _onEventNameChanged(
PermanentOrderEventNameChanged event,
Emitter<PermanentOrderState> emit,
) {
emit(state.copyWith(eventName: event.eventName));
}
void _onStartDateChanged(
PermanentOrderStartDateChanged event,
Emitter<PermanentOrderState> emit,
) {
final int newDayIndex = event.date.weekday % 7;
final int? autoIndex = state.autoSelectedDayIndex;
List<String> days = List<String>.from(state.permanentDays);
if (autoIndex != null) {
final String oldDay = _dayLabels[autoIndex];
days.remove(oldDay);
final String newDay = _dayLabels[newDayIndex];
if (!days.contains(newDay)) {
days.add(newDay);
}
days = _sortDays(days);
}
emit(
state.copyWith(
startDate: event.date,
permanentDays: days,
autoSelectedDayIndex: autoIndex == null ? null : newDayIndex,
),
);
}
void _onDayToggled(
PermanentOrderDayToggled event,
Emitter<PermanentOrderState> emit,
) {
final List<String> days = List<String>.from(state.permanentDays);
final String label = _dayLabels[event.dayIndex];
int? autoIndex = state.autoSelectedDayIndex;
if (days.contains(label)) {
days.remove(label);
if (autoIndex == event.dayIndex) {
autoIndex = null;
}
} else {
days.add(label);
}
emit(state.copyWith(permanentDays: _sortDays(days), autoSelectedDayIndex: autoIndex));
}
void _onPositionAdded(
PermanentOrderPositionAdded event,
Emitter<PermanentOrderState> emit,
) {
final List<PermanentOrderPosition> newPositions =
List<PermanentOrderPosition>.from(state.positions)..add(
const PermanentOrderPosition(
role: '',
count: 1,
startTime: '09:00',
endTime: '17:00',
),
);
emit(state.copyWith(positions: newPositions));
}
void _onPositionRemoved(
PermanentOrderPositionRemoved event,
Emitter<PermanentOrderState> emit,
) {
if (state.positions.length > 1) {
final List<PermanentOrderPosition> newPositions =
List<PermanentOrderPosition>.from(state.positions)
..removeAt(event.index);
emit(state.copyWith(positions: newPositions));
}
}
void _onPositionUpdated(
PermanentOrderPositionUpdated event,
Emitter<PermanentOrderState> emit,
) {
final List<PermanentOrderPosition> newPositions =
List<PermanentOrderPosition>.from(state.positions);
newPositions[event.index] = event.position;
emit(state.copyWith(positions: newPositions));
}
Future<void> _onSubmitted(
PermanentOrderSubmitted event,
Emitter<PermanentOrderState> emit,
) async {
emit(state.copyWith(status: PermanentOrderStatus.loading));
await handleError(
emit: emit,
action: () async {
final Map<String, double> roleRates = <String, double>{
for (final PermanentOrderRoleOption role in state.roles)
role.id: role.costPerHour,
};
final PermanentOrderHubOption? selectedHub = state.selectedHub;
if (selectedHub == null) {
throw domain.OrderMissingHubException();
}
final domain.PermanentOrder order = domain.PermanentOrder(
startDate: state.startDate,
permanentDays: state.permanentDays,
location: selectedHub.name,
positions: state.positions
.map(
(PermanentOrderPosition p) => domain.PermanentOrderPosition(
role: p.role,
count: p.count,
startTime: p.startTime,
endTime: p.endTime,
lunchBreak: p.lunchBreak ?? 'NO_BREAK',
location: null,
),
)
.toList(),
hub: domain.PermanentOrderHubDetails(
id: selectedHub.id,
name: selectedHub.name,
address: selectedHub.address,
placeId: selectedHub.placeId,
latitude: selectedHub.latitude,
longitude: selectedHub.longitude,
city: selectedHub.city,
state: selectedHub.state,
street: selectedHub.street,
country: selectedHub.country,
zipCode: selectedHub.zipCode,
),
eventName: state.eventName,
vendorId: state.selectedVendor?.id,
roleRates: roleRates,
);
await _createPermanentOrderUseCase(
PermanentOrderArguments(order: order),
);
emit(state.copyWith(status: PermanentOrderStatus.success));
},
onError: (String errorKey) => state.copyWith(
status: PermanentOrderStatus.failure,
errorMessage: errorKey,
),
);
}
static List<String> _sortDays(List<String> days) {
days.sort(
(String a, String b) =>
_dayLabels.indexOf(a).compareTo(_dayLabels.indexOf(b)),
);
return days;
}
}

View File

@@ -0,0 +1,100 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart' show Vendor;
import 'permanent_order_state.dart';
abstract class PermanentOrderEvent extends Equatable {
const PermanentOrderEvent();
@override
List<Object?> get props => <Object?>[];
}
class PermanentOrderVendorsLoaded extends PermanentOrderEvent {
const PermanentOrderVendorsLoaded(this.vendors);
final List<Vendor> vendors;
@override
List<Object?> get props => <Object?>[vendors];
}
class PermanentOrderVendorChanged extends PermanentOrderEvent {
const PermanentOrderVendorChanged(this.vendor);
final Vendor vendor;
@override
List<Object?> get props => <Object?>[vendor];
}
class PermanentOrderHubsLoaded extends PermanentOrderEvent {
const PermanentOrderHubsLoaded(this.hubs);
final List<PermanentOrderHubOption> hubs;
@override
List<Object?> get props => <Object?>[hubs];
}
class PermanentOrderHubChanged extends PermanentOrderEvent {
const PermanentOrderHubChanged(this.hub);
final PermanentOrderHubOption hub;
@override
List<Object?> get props => <Object?>[hub];
}
class PermanentOrderEventNameChanged extends PermanentOrderEvent {
const PermanentOrderEventNameChanged(this.eventName);
final String eventName;
@override
List<Object?> get props => <Object?>[eventName];
}
class PermanentOrderStartDateChanged extends PermanentOrderEvent {
const PermanentOrderStartDateChanged(this.date);
final DateTime date;
@override
List<Object?> get props => <Object?>[date];
}
class PermanentOrderDayToggled extends PermanentOrderEvent {
const PermanentOrderDayToggled(this.dayIndex);
final int dayIndex;
@override
List<Object?> get props => <Object?>[dayIndex];
}
class PermanentOrderPositionAdded extends PermanentOrderEvent {
const PermanentOrderPositionAdded();
}
class PermanentOrderPositionRemoved extends PermanentOrderEvent {
const PermanentOrderPositionRemoved(this.index);
final int index;
@override
List<Object?> get props => <Object?>[index];
}
class PermanentOrderPositionUpdated extends PermanentOrderEvent {
const PermanentOrderPositionUpdated(this.index, this.position);
final int index;
final PermanentOrderPosition position;
@override
List<Object?> get props => <Object?>[index, position];
}
class PermanentOrderSubmitted extends PermanentOrderEvent {
const PermanentOrderSubmitted();
}

View File

@@ -0,0 +1,221 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
enum PermanentOrderStatus { initial, loading, success, failure }
class PermanentOrderState extends Equatable {
const PermanentOrderState({
required this.startDate,
required this.permanentDays,
required this.location,
required this.eventName,
required this.positions,
required this.autoSelectedDayIndex,
this.status = PermanentOrderStatus.initial,
this.errorMessage,
this.vendors = const <Vendor>[],
this.selectedVendor,
this.hubs = const <PermanentOrderHubOption>[],
this.selectedHub,
this.roles = const <PermanentOrderRoleOption>[],
});
factory PermanentOrderState.initial() {
final DateTime now = DateTime.now();
final DateTime start = DateTime(now.year, now.month, now.day);
final List<String> dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
final int weekdayIndex = now.weekday % 7;
return PermanentOrderState(
startDate: start,
permanentDays: <String>[dayLabels[weekdayIndex]],
location: '',
eventName: '',
positions: const <PermanentOrderPosition>[
PermanentOrderPosition(role: '', count: 1, startTime: '', endTime: ''),
],
autoSelectedDayIndex: weekdayIndex,
vendors: const <Vendor>[],
hubs: const <PermanentOrderHubOption>[],
roles: const <PermanentOrderRoleOption>[],
);
}
final DateTime startDate;
final List<String> permanentDays;
final String location;
final String eventName;
final List<PermanentOrderPosition> positions;
final int? autoSelectedDayIndex;
final PermanentOrderStatus status;
final String? errorMessage;
final List<Vendor> vendors;
final Vendor? selectedVendor;
final List<PermanentOrderHubOption> hubs;
final PermanentOrderHubOption? selectedHub;
final List<PermanentOrderRoleOption> roles;
PermanentOrderState copyWith({
DateTime? startDate,
List<String>? permanentDays,
String? location,
String? eventName,
List<PermanentOrderPosition>? positions,
int? autoSelectedDayIndex,
PermanentOrderStatus? status,
String? errorMessage,
List<Vendor>? vendors,
Vendor? selectedVendor,
List<PermanentOrderHubOption>? hubs,
PermanentOrderHubOption? selectedHub,
List<PermanentOrderRoleOption>? roles,
}) {
return PermanentOrderState(
startDate: startDate ?? this.startDate,
permanentDays: permanentDays ?? this.permanentDays,
location: location ?? this.location,
eventName: eventName ?? this.eventName,
positions: positions ?? this.positions,
autoSelectedDayIndex: autoSelectedDayIndex ?? this.autoSelectedDayIndex,
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
vendors: vendors ?? this.vendors,
selectedVendor: selectedVendor ?? this.selectedVendor,
hubs: hubs ?? this.hubs,
selectedHub: selectedHub ?? this.selectedHub,
roles: roles ?? this.roles,
);
}
bool get isValid {
return eventName.isNotEmpty &&
selectedVendor != null &&
selectedHub != null &&
positions.isNotEmpty &&
permanentDays.isNotEmpty &&
positions.every(
(PermanentOrderPosition p) =>
p.role.isNotEmpty &&
p.count > 0 &&
p.startTime.isNotEmpty &&
p.endTime.isNotEmpty,
);
}
@override
List<Object?> get props => <Object?>[
startDate,
permanentDays,
location,
eventName,
positions,
autoSelectedDayIndex,
status,
errorMessage,
vendors,
selectedVendor,
hubs,
selectedHub,
roles,
];
}
class PermanentOrderHubOption extends Equatable {
const PermanentOrderHubOption({
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,
});
final String id;
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
];
}
class PermanentOrderRoleOption extends Equatable {
const PermanentOrderRoleOption({
required this.id,
required this.name,
required this.costPerHour,
});
final String id;
final String name;
final double costPerHour;
@override
List<Object?> get props => <Object?>[id, name, costPerHour];
}
class PermanentOrderPosition extends Equatable {
const PermanentOrderPosition({
required this.role,
required this.count,
required this.startTime,
required this.endTime,
this.lunchBreak,
});
final String role;
final int count;
final String startTime;
final String endTime;
final String? lunchBreak;
PermanentOrderPosition copyWith({
String? role,
int? count,
String? startTime,
String? endTime,
String? lunchBreak,
}) {
return PermanentOrderPosition(
role: role ?? this.role,
count: count ?? this.count,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
lunchBreak: lunchBreak ?? this.lunchBreak,
);
}
@override
List<Object?> get props => <Object?>[role, count, startTime, endTime, lunchBreak];
}

View File

@@ -1,40 +1,19 @@
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:krow_core/core.dart';
import '../blocs/permanent_order_bloc.dart';
import '../widgets/permanent_order/permanent_order_view.dart';
/// Permanent Order Page - Long-term staffing placement.
/// Placeholder for future implementation.
/// Page for creating a permanent staffing order.
class PermanentOrderPage extends StatelessWidget {
/// Creates a [PermanentOrderPage].
const PermanentOrderPage({super.key});
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderPermanentEn labels =
t.client_create_order.permanent;
return Scaffold(
appBar: UiAppBar(
title: labels.title,
onLeadingPressed: () => Modular.to.navigate(ClientPaths.createOrder),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space6),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
labels.subtitle,
style: UiTypography.body1r.textSecondary,
textAlign: TextAlign.center,
),
],
),
),
),
return BlocProvider<PermanentOrderBloc>(
create: (BuildContext context) => Modular.get<PermanentOrderBloc>(),
child: const PermanentOrderView(),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// A date picker field for the permanent order form.
class PermanentOrderDatePicker extends StatefulWidget {
/// Creates a [PermanentOrderDatePicker].
const PermanentOrderDatePicker({
required this.label,
required this.value,
required this.onChanged,
super.key,
});
/// The label text to display above the field.
final String label;
/// The currently selected date.
final DateTime value;
/// Callback when a new date is selected.
final ValueChanged<DateTime> onChanged;
@override
State<PermanentOrderDatePicker> createState() =>
_PermanentOrderDatePickerState();
}
class _PermanentOrderDatePickerState extends State<PermanentOrderDatePicker> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: DateFormat('yyyy-MM-dd').format(widget.value),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(PermanentOrderDatePicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
_controller.text = DateFormat('yyyy-MM-dd').format(widget.value);
}
}
@override
Widget build(BuildContext context) {
return UiTextField(
label: widget.label,
controller: _controller,
readOnly: true,
prefixIcon: UiIcons.calendar,
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: widget.value,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
widget.onChanged(picked);
}
},
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A text input for the order name in the permanent order form.
class PermanentOrderEventNameInput extends StatefulWidget {
const PermanentOrderEventNameInput({
required this.label,
required this.value,
required this.onChanged,
super.key,
});
final String label;
final String value;
final ValueChanged<String> onChanged;
@override
State<PermanentOrderEventNameInput> createState() =>
_PermanentOrderEventNameInputState();
}
class _PermanentOrderEventNameInputState
extends State<PermanentOrderEventNameInput> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(PermanentOrderEventNameInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _controller.text) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return UiTextField(
label: widget.label,
controller: _controller,
onChanged: widget.onChanged,
hintText: 'Order name',
prefixIcon: UiIcons.briefcase,
);
}
}

View File

@@ -0,0 +1,71 @@
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),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,345 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../blocs/permanent_order_state.dart';
/// A card widget for editing a specific position in a permanent order.
class PermanentOrderPositionCard extends StatelessWidget {
/// Creates a [PermanentOrderPositionCard].
const PermanentOrderPositionCard({
required this.index,
required this.position,
required this.isRemovable,
required this.onUpdated,
required this.onRemoved,
required this.positionLabel,
required this.roleLabel,
required this.workersLabel,
required this.startLabel,
required this.endLabel,
required this.lunchLabel,
required this.roles,
super.key,
});
/// The index of the position in the list.
final int index;
/// The position entity data.
final PermanentOrderPosition position;
/// Whether this position can be removed (usually if there's more than one).
final bool isRemovable;
/// Callback when the position data is updated.
final ValueChanged<PermanentOrderPosition> onUpdated;
/// Callback when the position is removed.
final VoidCallback onRemoved;
/// Label for positions (e.g., "Position").
final String positionLabel;
/// Label for the role selection.
final String roleLabel;
/// Label for the worker count.
final String workersLabel;
/// Label for the start time.
final String startLabel;
/// Label for the end time.
final String endLabel;
/// Label for the lunch break.
final String lunchLabel;
/// Available roles for the selected vendor.
final List<PermanentOrderRoleOption> roles;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'$positionLabel #${index + 1}',
style: UiTypography.footnote1m.textSecondary,
),
if (isRemovable)
GestureDetector(
onTap: onRemoved,
child: Text(
t.client_create_order.one_time.remove,
style: UiTypography.footnote1m.copyWith(
color: UiColors.destructive,
),
),
),
],
),
const SizedBox(height: UiConstants.space3),
// Role (Dropdown)
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 44,
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
hint: Text(
roleLabel,
style: UiTypography.body2r.textPlaceholder,
),
value: position.role.isEmpty ? null : position.role,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (String? val) {
if (val != null) {
onUpdated(position.copyWith(role: val));
}
},
items: _buildRoleItems(),
),
),
),
const SizedBox(height: UiConstants.space3),
// Start/End/Workers Row
Row(
children: <Widget>[
// Start Time
Expanded(
child: _buildTimeInput(
context: context,
label: startLabel,
value: position.startTime,
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
onUpdated(
position.copyWith(startTime: picked.format(context)),
);
}
},
),
),
const SizedBox(width: UiConstants.space2),
// End Time
Expanded(
child: _buildTimeInput(
context: context,
label: endLabel,
value: position.endTime,
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
onUpdated(
position.copyWith(endTime: picked.format(context)),
);
}
},
),
),
const SizedBox(width: UiConstants.space2),
// Workers Count
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
workersLabel,
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Container(
height: 40,
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusSm,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
GestureDetector(
onTap: () {
if (position.count > 1) {
onUpdated(
position.copyWith(count: position.count - 1),
);
}
},
child: const Icon(UiIcons.minus, size: 12),
),
Text(
'${position.count}',
style: UiTypography.body2b.textPrimary,
),
GestureDetector(
onTap: () {
onUpdated(
position.copyWith(count: position.count + 1),
);
},
child: const Icon(UiIcons.add, size: 12),
),
],
),
),
],
),
),
],
),
const SizedBox(height: UiConstants.space4),
// Lunch Break
Text(lunchLabel, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space1),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 44,
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: position.lunchBreak,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (String? val) {
if (val != null) {
onUpdated(position.copyWith(lunchBreak: val));
}
},
items: <String>[
'NO_BREAK',
'MIN_10',
'MIN_15',
'MIN_30',
'MIN_45',
'MIN_60',
].map((String value) {
final String label = switch (value) {
'NO_BREAK' => 'No Break',
'MIN_10' => '10 min (Paid)',
'MIN_15' => '15 min (Paid)',
'MIN_30' => '30 min (Unpaid)',
'MIN_45' => '45 min (Unpaid)',
'MIN_60' => '60 min (Unpaid)',
_ => value,
};
return DropdownMenuItem<String>(
value: value,
child: Text(
label,
style: UiTypography.body2r.textPrimary,
),
);
}).toList(),
),
),
),
],
),
);
}
Widget _buildTimeInput({
required BuildContext context,
required String label,
required String value,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(label, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space1),
GestureDetector(
onTap: onTap,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
decoration: BoxDecoration(
borderRadius: UiConstants.radiusSm,
border: Border.all(color: UiColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
value.isEmpty ? '--:--' : value,
style: UiTypography.body2r.textPrimary,
),
const Icon(
UiIcons.clock,
size: 14,
color: UiColors.iconSecondary,
),
],
),
),
),
],
);
}
List<DropdownMenuItem<String>> _buildRoleItems() {
final List<DropdownMenuItem<String>> items = roles
.map(
(PermanentOrderRoleOption role) => DropdownMenuItem<String>(
value: role.id,
child: Text(
'${role.name} - \$${role.costPerHour.toStringAsFixed(0)}',
style: UiTypography.body2r.textPrimary,
),
),
)
.toList();
final bool hasSelected = roles.any((PermanentOrderRoleOption role) => role.id == position.role);
if (position.role.isNotEmpty && !hasSelected) {
items.add(
DropdownMenuItem<String>(
value: position.role,
child: Text(
position.role,
style: UiTypography.body2r.textPrimary,
),
),
);
}
return items;
}
}

View File

@@ -0,0 +1,52 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A header widget for sections in the permanent order form.
class PermanentOrderSectionHeader extends StatelessWidget {
/// Creates a [PermanentOrderSectionHeader].
const PermanentOrderSectionHeader({
required this.title,
this.actionLabel,
this.onAction,
super.key,
});
/// The title text for the section.
final String title;
/// Optional label for an action button on the right.
final String? actionLabel;
/// Callback when the action button is tapped.
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(title, style: UiTypography.headline4m.textPrimary),
if (actionLabel != null && onAction != null)
TextButton(
onPressed: onAction,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(UiIcons.add, size: 16, color: UiColors.primary),
const SizedBox(width: UiConstants.space2),
Text(
actionLabel!,
style: UiTypography.body2m.primary,
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A view to display when a permanent order has been successfully created.
class PermanentOrderSuccessView extends StatelessWidget {
/// Creates a [PermanentOrderSuccessView].
const PermanentOrderSuccessView({
required this.title,
required this.message,
required this.buttonLabel,
required this.onDone,
super.key,
});
/// The title of the success message.
final String title;
/// The body of the success message.
final String message;
/// Label for the completion button.
final String buttonLabel;
/// Callback when the completion button is tapped.
final VoidCallback onDone;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[UiColors.primary, UiColors.buttonPrimaryHover],
),
),
child: SafeArea(
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10),
padding: const EdgeInsets.all(UiConstants.space8),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg * 1.5,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.2),
blurRadius: 20,
offset: const Offset(0, UiConstants.space2 + 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: UiConstants.space16,
height: UiConstants.space16,
decoration: const BoxDecoration(
color: UiColors.accent,
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
UiIcons.check,
color: UiColors.black,
size: UiConstants.space8,
),
),
),
const SizedBox(height: UiConstants.space6),
Text(
title,
style: UiTypography.headline2m.textPrimary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space3),
Text(
message,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary.copyWith(
height: 1.5,
),
),
const SizedBox(height: UiConstants.space8),
SizedBox(
width: double.infinity,
child: UiButton.primary(
text: buttonLabel,
onPressed: onDone,
size: UiButtonSize.large,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,400 @@
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:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' show Vendor;
import '../../blocs/permanent_order_bloc.dart';
import '../../blocs/permanent_order_event.dart';
import '../../blocs/permanent_order_state.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_success_view.dart';
/// The main content of the Permanent Order page.
class PermanentOrderView extends StatelessWidget {
/// Creates a [PermanentOrderView].
const PermanentOrderView({super.key});
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderPermanentEn labels =
t.client_create_order.permanent;
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
t.client_create_order.one_time;
return BlocConsumer<PermanentOrderBloc, PermanentOrderState>(
listener: (BuildContext context, PermanentOrderState state) {
if (state.status == PermanentOrderStatus.failure &&
state.errorMessage != null) {
final String message = translateErrorKey(state.errorMessage!);
UiSnackbar.show(
context,
message: message,
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
);
}
},
builder: (BuildContext context, PermanentOrderState state) {
if (state.status == PermanentOrderStatus.success) {
return PermanentOrderSuccessView(
title: labels.title,
message: labels.subtitle,
buttonLabel: oneTimeLabels.back_to_orders,
onDone: () => Modular.to.pushNamedAndRemoveUntil(
ClientPaths.orders,
(_) => false,
arguments: <String, dynamic>{
'initialDate': state.startDate.toIso8601String(),
},
),
);
}
if (state.vendors.isEmpty &&
state.status != PermanentOrderStatus.loading) {
return Scaffold(
body: Column(
children: <Widget>[
PermanentOrderHeader(
title: labels.title,
subtitle: labels.subtitle,
onBack: () => Modular.to.navigate(ClientPaths.createOrder),
),
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 Scaffold(
body: Column(
children: <Widget>[
PermanentOrderHeader(
title: labels.title,
subtitle: labels.subtitle,
onBack: () => Modular.to.navigate(ClientPaths.createOrder),
),
Expanded(
child: Stack(
children: <Widget>[
_PermanentOrderForm(state: state),
if (state.status == PermanentOrderStatus.loading)
const Center(child: CircularProgressIndicator()),
],
),
),
_BottomActionButton(
label: state.status == PermanentOrderStatus.loading
? oneTimeLabels.creating
: oneTimeLabels.create_order,
isLoading: state.status == PermanentOrderStatus.loading,
onPressed: state.isValid
? () => BlocProvider.of<PermanentOrderBloc>(
context,
).add(const PermanentOrderSubmitted())
: null,
),
],
),
);
},
);
}
}
class _PermanentOrderForm extends StatelessWidget {
const _PermanentOrderForm({required this.state});
final PermanentOrderState state;
@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),
children: <Widget>[
Text(
labels.title,
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space4),
PermanentOrderEventNameInput(
label: 'ORDER NAME',
value: state.eventName,
onChanged: (String value) => BlocProvider.of<PermanentOrderBloc>(
context,
).add(PermanentOrderEventNameChanged(value)),
),
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: state.selectedVendor,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (Vendor? vendor) {
if (vendor != null) {
BlocProvider.of<PermanentOrderBloc>(
context,
).add(PermanentOrderVendorChanged(vendor));
}
},
items: state.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: state.startDate,
onChanged: (DateTime date) => BlocProvider.of<PermanentOrderBloc>(
context,
).add(PermanentOrderStartDateChanged(date)),
),
const SizedBox(height: UiConstants.space4),
Text('Permanent Days', style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space2),
_PermanentDaysSelector(
selectedDays: state.permanentDays,
onToggle: (int dayIndex) => BlocProvider.of<PermanentOrderBloc>(
context,
).add(PermanentOrderDayToggled(dayIndex)),
),
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<PermanentOrderHubOption>(
isExpanded: true,
value: state.selectedHub,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (PermanentOrderHubOption? hub) {
if (hub != null) {
BlocProvider.of<PermanentOrderBloc>(
context,
).add(PermanentOrderHubChanged(hub));
}
},
items: state.hubs.map((PermanentOrderHubOption hub) {
return DropdownMenuItem<PermanentOrderHubOption>(
value: hub,
child: Text(
hub.name,
style: UiTypography.body2m.textPrimary,
),
);
}).toList(),
),
),
),
const SizedBox(height: UiConstants.space6),
PermanentOrderSectionHeader(
title: oneTimeLabels.positions_title,
actionLabel: oneTimeLabels.add_position,
onAction: () => BlocProvider.of<PermanentOrderBloc>(
context,
).add(const PermanentOrderPositionAdded()),
),
const SizedBox(height: UiConstants.space3),
// Positions List
...state.positions.asMap().entries.map((
MapEntry<int, PermanentOrderPosition> entry,
) {
final int index = entry.key;
final PermanentOrderPosition position = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: PermanentOrderPositionCard(
index: index,
position: position,
isRemovable: state.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: state.roles,
onUpdated: (PermanentOrderPosition updated) {
BlocProvider.of<PermanentOrderBloc>(
context,
).add(PermanentOrderPositionUpdated(index, updated));
},
onRemoved: () {
BlocProvider.of<PermanentOrderBloc>(
context,
).add(PermanentOrderPositionRemoved(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,
),
),
);
}
}