feat: Migrate staff profile features from Data Connect to V2 REST API

- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
This commit is contained in:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -1,16 +1,16 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_modular/flutter_modular.dart';
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 'package:krow_domain/krow_domain.dart';
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';
import 'domain/usecases/create_rapid_order_usecase.dart';
import 'domain/usecases/create_recurring_order_usecase.dart';
import 'domain/usecases/get_order_details_for_reorder_usecase.dart';
import 'domain/usecases/parse_rapid_order_usecase.dart';
import 'domain/usecases/transcribe_rapid_order_usecase.dart';
@@ -24,19 +24,17 @@ import 'presentation/pages/review_order_page.dart';
/// Module for the Client Create Order feature.
///
/// This module orchestrates the dependency injection for the create order feature,
/// connecting the domain use cases with their data layer implementations and
/// presentation layer BLoCs.
/// Uses [CoreModule] for [BaseApiService] injection (V2 API).
class ClientCreateOrderModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule(), CoreModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<ClientCreateOrderRepositoryInterface>(
() => ClientCreateOrderRepositoryImpl(
service: i.get<dc.DataConnectService>(),
apiService: i.get<BaseApiService>(),
rapidOrderService: i.get<RapidOrderService>(),
fileUploadService: i.get<FileUploadService>(),
),
@@ -44,7 +42,7 @@ class ClientCreateOrderModule extends Module {
i.addLazySingleton<ClientOrderQueryRepositoryInterface>(
() => ClientOrderQueryRepositoryImpl(
service: i.get<dc.DataConnectService>(),
apiService: i.get<BaseApiService>(),
),
);

View File

@@ -1,793 +1,89 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:intl/intl.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 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/client_create_order_repository_interface.dart';
/// Implementation of [ClientCreateOrderRepositoryInterface].
/// V2 API implementation of [ClientCreateOrderRepositoryInterface].
///
/// This implementation coordinates data access for order creation by [DataConnectService] from the shared
/// Data Connect package.
///
/// It follows the KROW Clean Architecture by keeping the data layer focused
/// on delegation and data mapping, without business logic.
/// Each create method sends a single POST to the typed V2 endpoint.
/// The backend handles shift and role creation internally.
class ClientCreateOrderRepositoryImpl
implements ClientCreateOrderRepositoryInterface {
/// Creates an instance backed by the given [apiService].
ClientCreateOrderRepositoryImpl({
required dc.DataConnectService service,
required BaseApiService apiService,
required RapidOrderService rapidOrderService,
required FileUploadService fileUploadService,
}) : _service = service,
_rapidOrderService = rapidOrderService,
_fileUploadService = fileUploadService;
}) : _api = apiService,
_rapidOrderService = rapidOrderService,
_fileUploadService = fileUploadService;
final dc.DataConnectService _service;
final BaseApiService _api;
final RapidOrderService _rapidOrderService;
final FileUploadService _fileUploadService;
@override
Future<void> createOneTimeOrder(domain.OneTimeOrder 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.OneTimeOrderHubDetails? hub = order.hub;
if (hub == null || hub.id.isEmpty) {
throw Exception('Hub is missing.');
}
final DateTime orderDateOnly = DateTime(
order.date.year,
order.date.month,
order.date.day,
);
final Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly);
final OperationResult<dc.CreateOrderData, dc.CreateOrderVariables>
orderResult = await _service.connector
.createOrder(
businessId: businessId,
orderType: dc.OrderType.ONE_TIME,
teamHubId: hub.id,
)
.vendorId(vendorId)
.eventName(order.eventName)
.status(dc.OrderStatus.POSTED)
.date(orderTimestamp)
.execute();
final String orderId = orderResult.data.order_insert.id;
final int workersNeeded = order.positions.fold<int>(
0,
(int sum, domain.OneTimeOrderPosition position) => sum + position.count,
);
final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}';
final double shiftCost = _calculateShiftCost(order);
final OperationResult<dc.CreateShiftData, dc.CreateShiftVariables>
shiftResult = await _service.connector
.createShift(title: shiftTitle, orderId: orderId)
.date(orderTimestamp)
.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.OPEN)
.workersNeeded(workersNeeded)
.filled(0)
.durationDays(1)
.cost(shiftCost)
.execute();
final String shiftId = shiftResult.data.shift_insert.id;
for (final domain.OneTimeOrderPosition position in order.positions) {
final DateTime start = _parseTime(order.date, position.startTime);
final DateTime end = _parseTime(order.date, 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(AnyValue(<String>[shiftId]))
.execute();
});
Future<void> createOneTimeOrder(Map<String, dynamic> payload) async {
await _api.post(V2ApiEndpoints.clientOrdersOneTime, data: payload);
}
@override
Future<void> createRecurringOrder(domain.RecurringOrder 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.RecurringOrderHubDetails? 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 Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly);
final Timestamp startTimestamp = _service.toTimestamp(order.startDate);
final Timestamp endTimestamp = _service.toTimestamp(order.endDate);
final OperationResult<dc.CreateOrderData, dc.CreateOrderVariables>
orderResult = await _service.connector
.createOrder(
businessId: businessId,
orderType: dc.OrderType.RECURRING,
teamHubId: hub.id,
)
.vendorId(vendorId)
.eventName(order.eventName)
.status(dc.OrderStatus.POSTED)
.date(orderTimestamp)
.startDate(startTimestamp)
.endDate(endTimestamp)
.recurringDays(order.recurringDays)
.execute();
final String orderId = orderResult.data.order_insert.id;
// NOTE: Recurring 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 DateTime effectiveEndDate = order.endDate.isAfter(maxEndDate)
? maxEndDate
: order.endDate;
final Set<String> selectedDays = Set<String>.from(order.recurringDays);
final int workersNeeded = order.positions.fold<int>(
0,
(int sum, domain.RecurringOrderPosition position) =>
sum + position.count,
);
final double shiftCost = _calculateRecurringShiftCost(order);
final List<String> shiftIds = <String>[];
for (
DateTime day = orderDateOnly;
!day.isAfter(effectiveEndDate);
day = day.add(const Duration(days: 1))
) {
final String dayLabel = _weekdayLabel(day);
if (!selectedDays.contains(dayLabel)) {
continue;
}
final String shiftTitle = 'Shift ${_formatDate(day)}';
final Timestamp dayTimestamp = _service.toTimestamp(
DateTime(day.year, day.month, day.day),
);
final 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.OPEN)
.workersNeeded(workersNeeded)
.filled(0)
.durationDays(1)
.cost(shiftCost)
.execute();
final String shiftId = shiftResult.data.shift_insert.id;
shiftIds.add(shiftId);
for (final domain.RecurringOrderPosition 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(AnyValue(shiftIds))
.execute();
});
Future<void> createRecurringOrder(Map<String, dynamic> payload) async {
await _api.post(V2ApiEndpoints.clientOrdersRecurring, data: payload);
}
@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.OneTimeOrderHubDetails? 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 Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly);
final Timestamp startTimestamp = _service.toTimestamp(order.startDate);
final 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.OneTimeOrderPosition 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 Timestamp dayTimestamp = _service.toTimestamp(
DateTime(day.year, day.month, day.day),
);
final 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.OPEN)
.workersNeeded(workersNeeded)
.filled(0)
.durationDays(1)
.cost(shiftCost)
.execute();
final String shiftId = shiftResult.data.shift_insert.id;
shiftIds.add(shiftId);
for (final domain.OneTimeOrderPosition 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(AnyValue(shiftIds))
.execute();
});
Future<void> createPermanentOrder(Map<String, dynamic> payload) async {
await _api.post(V2ApiEndpoints.clientOrdersPermanent, data: payload);
}
@override
Future<void> createRapidOrder(String description) async {
// TO-DO: connect IA and return array with the information.
throw UnimplementedError('Rapid order IA is not connected yet.');
}
@override
Future<domain.OneTimeOrder> parseRapidOrder(String text) async {
final RapidOrderParseResponse response = await _rapidOrderService.parseText(
text: text,
);
final RapidOrderParsedData data = response.parsed;
// Fetch Business ID
final String businessId = await _service.getBusinessId();
// 1. Hub Matching
final OperationResult<
dc.ListTeamHubsByOwnerIdData,
dc.ListTeamHubsByOwnerIdVariables
>
hubResult = await _service.connector
.listTeamHubsByOwnerId(ownerId: businessId)
.execute();
final List<dc.ListTeamHubsByOwnerIdTeamHubs> hubs = hubResult.data.teamHubs;
final dc.ListTeamHubsByOwnerIdTeamHubs? bestHub = _findBestHub(
hubs,
data.locationHint,
);
// 2. Roles Matching
// We fetch vendors to get the first one as a context for role matching.
final OperationResult<dc.ListVendorsData, void> vendorResult =
await _service.connector.listVendors().execute();
final List<dc.ListVendorsVendors> vendors = vendorResult.data.vendors;
String? selectedVendorId;
List<dc.ListRolesByVendorIdRoles> availableRoles =
<dc.ListRolesByVendorIdRoles>[];
if (vendors.isNotEmpty) {
selectedVendorId = vendors.first.id;
final OperationResult<
dc.ListRolesByVendorIdData,
dc.ListRolesByVendorIdVariables
>
roleResult = await _service.connector
.listRolesByVendorId(vendorId: selectedVendorId)
.execute();
availableRoles = roleResult.data.roles;
}
final DateTime startAt =
DateTime.tryParse(data.startAt ?? '') ?? DateTime.now();
final DateTime endAt =
DateTime.tryParse(data.endAt ?? '') ??
startAt.add(const Duration(hours: 8));
final String startTimeStr = DateFormat('hh:mm a').format(startAt.toLocal());
final String endTimeStr = DateFormat('hh:mm a').format(endAt.toLocal());
return domain.OneTimeOrder(
date: startAt,
location: bestHub?.hubName ?? data.locationHint ?? '',
eventName: data.notes ?? '',
vendorId: selectedVendorId,
hub: bestHub != null
? domain.OneTimeOrderHubDetails(
id: bestHub.id,
name: bestHub.hubName,
address: bestHub.address,
placeId: bestHub.placeId,
latitude: bestHub.latitude ?? 0,
longitude: bestHub.longitude ?? 0,
city: bestHub.city,
state: bestHub.state,
street: bestHub.street,
country: bestHub.country,
zipCode: bestHub.zipCode,
)
: null,
positions: data.positions.map((RapidOrderPosition p) {
final dc.ListRolesByVendorIdRoles? matchedRole = _findBestRole(
availableRoles,
p.role,
);
return domain.OneTimeOrderPosition(
role: matchedRole?.id ?? p.role,
count: p.count,
startTime: startTimeStr,
endTime: endTimeStr,
);
}).toList(),
);
throw UnimplementedError('Rapid order creation is not connected yet.');
}
@override
Future<String> transcribeRapidOrder(String audioPath) async {
// 1. Upload the audio file first
final String fileName = audioPath.split('/').last;
final FileUploadResponse uploadResponse = await _fileUploadService
.uploadFile(
filePath: audioPath,
fileName: fileName,
category: 'rapid-order-audio',
);
final FileUploadResponse uploadResponse =
await _fileUploadService.uploadFile(
filePath: audioPath,
fileName: fileName,
category: 'rapid-order-audio',
);
// 2. Transcribe using the remote URI
final RapidOrderTranscriptionResponse response = await _rapidOrderService
.transcribeAudio(audioFileUri: uploadResponse.fileUri);
final RapidOrderTranscriptionResponse response =
await _rapidOrderService.transcribeAudio(
audioFileUri: uploadResponse.fileUri,
);
return response.transcript;
}
@override
Future<void> reorder(String previousOrderId, DateTime newDate) async {
// TODO: Implement reorder functionality to fetch the previous order and create a new one with the updated date.
throw UnimplementedError('Reorder functionality is not yet implemented.');
Future<Map<String, dynamic>> parseRapidOrder(String text) async {
final RapidOrderParseResponse response =
await _rapidOrderService.parseText(text: text);
final RapidOrderParsedData data = response.parsed;
return <String, dynamic>{
'eventName': data.notes ?? '',
'locationHint': data.locationHint ?? '',
'startAt': data.startAt,
'endAt': data.endAt,
'positions': data.positions
.map((RapidOrderPosition p) => <String, dynamic>{
'roleName': p.role,
'workerCount': p.count,
})
.toList(),
};
}
@override
Future<domain.ReorderData> getOrderDetailsForReorder(String orderId) async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final QueryResult<
dc.ListShiftRolesByBusinessAndOrderData,
dc.ListShiftRolesByBusinessAndOrderVariables
>
result = await _service.connector
.listShiftRolesByBusinessAndOrder(
businessId: businessId,
orderId: orderId,
)
.execute();
final List<dc.ListShiftRolesByBusinessAndOrderShiftRoles> shiftRoles =
result.data.shiftRoles;
if (shiftRoles.isEmpty) {
throw Exception('Order not found or has no roles.');
}
final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrder order =
shiftRoles.first.shift.order;
final domain.OrderType orderType = _mapOrderType(order.orderType);
final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrderTeamHub
teamHub = order.teamHub;
return domain.ReorderData(
orderId: orderId,
eventName: order.eventName ?? '',
vendorId: order.vendorId ?? '',
orderType: orderType,
hub: domain.OneTimeOrderHubDetails(
id: teamHub.id,
name: teamHub.hubName,
address: teamHub.address,
placeId: teamHub.placeId,
latitude: 0, // Not available in this query
longitude: 0,
),
positions: shiftRoles.map((
dc.ListShiftRolesByBusinessAndOrderShiftRoles role,
) {
return domain.ReorderPosition(
roleId: role.roleId,
count: role.count,
startTime: _formatTimestamp(role.startTime),
endTime: _formatTimestamp(role.endTime),
lunchBreak: _formatBreakDuration(role.breakType),
);
}).toList(),
startDate: order.startDate?.toDateTime(),
endDate: order.endDate?.toDateTime(),
recurringDays: order.recurringDays ?? const <String>[],
permanentDays: order.permanentDays ?? const <String>[],
);
});
}
double _calculateShiftCost(domain.OneTimeOrder order) {
double total = 0;
for (final domain.OneTimeOrderPosition position in order.positions) {
final DateTime start = _parseTime(order.date, position.startTime);
final DateTime end = _parseTime(order.date, 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;
}
double _calculateRecurringShiftCost(domain.RecurringOrder order) {
double total = 0;
for (final domain.RecurringOrderPosition 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;
}
double _calculatePermanentShiftCost(domain.PermanentOrder order) {
double total = 0;
for (final domain.OneTimeOrderPosition 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:
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:
default:
return 'SUN';
}
}
dc.BreakDuration _breakDurationFromValue(String value) {
switch (value) {
case 'MIN_10':
return dc.BreakDuration.MIN_10;
case 'MIN_15':
return dc.BreakDuration.MIN_15;
case 'MIN_30':
return dc.BreakDuration.MIN_30;
case 'MIN_45':
return dc.BreakDuration.MIN_45;
case 'MIN_60':
return dc.BreakDuration.MIN_60;
default:
return dc.BreakDuration.NO_BREAK;
}
}
bool _isBreakPaid(String value) {
return value == 'MIN_10' || value == 'MIN_15';
}
DateTime _parseTime(DateTime date, String time) {
if (time.trim().isEmpty) {
throw Exception('Shift time is missing.');
}
DateTime parsed;
try {
parsed = DateFormat.jm().parse(time);
} catch (_) {
parsed = DateFormat.Hm().parse(time);
}
return DateTime(
date.year,
date.month,
date.day,
parsed.hour,
parsed.minute,
Future<OrderPreview> getOrderDetailsForReorder(String orderId) async {
final ApiResponse response = await _api.get(
V2ApiEndpoints.clientOrderReorderPreview(orderId),
);
}
String _formatDate(DateTime dateTime) {
final String year = dateTime.year.toString().padLeft(4, '0');
final String month = dateTime.month.toString().padLeft(2, '0');
final String day = dateTime.day.toString().padLeft(2, '0');
return '$year-$month-$day';
}
String _formatTimestamp(Timestamp? value) {
if (value == null) return '';
try {
return DateFormat('HH:mm').format(value.toDateTime());
} catch (_) {
return '';
}
}
String _formatBreakDuration(dc.EnumValue<dc.BreakDuration>? breakType) {
if (breakType is dc.Known<dc.BreakDuration>) {
switch (breakType.value) {
case dc.BreakDuration.MIN_10:
return 'MIN_10';
case dc.BreakDuration.MIN_15:
return 'MIN_15';
case dc.BreakDuration.MIN_30:
return 'MIN_30';
case dc.BreakDuration.MIN_45:
return 'MIN_45';
case dc.BreakDuration.MIN_60:
return 'MIN_60';
case dc.BreakDuration.NO_BREAK:
return 'NO_BREAK';
}
}
return 'NO_BREAK';
}
domain.OrderType _mapOrderType(dc.EnumValue<dc.OrderType>? orderType) {
if (orderType is dc.Known<dc.OrderType>) {
switch (orderType.value) {
case dc.OrderType.ONE_TIME:
return domain.OrderType.oneTime;
case dc.OrderType.RECURRING:
return domain.OrderType.recurring;
case dc.OrderType.PERMANENT:
return domain.OrderType.permanent;
case dc.OrderType.RAPID:
return domain.OrderType.oneTime;
}
}
return domain.OrderType.oneTime;
}
dc.ListTeamHubsByOwnerIdTeamHubs? _findBestHub(
List<dc.ListTeamHubsByOwnerIdTeamHubs> hubs,
String? hint,
) {
if (hint == null || hint.isEmpty || hubs.isEmpty) return null;
final String normalizedHint = hint.toLowerCase();
dc.ListTeamHubsByOwnerIdTeamHubs? bestMatch;
double highestScore = -1;
for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) {
final String name = hub.hubName.toLowerCase();
final String address = hub.address.toLowerCase();
double score = 0;
if (name == normalizedHint || address == normalizedHint) {
score = 100;
} else if (name.contains(normalizedHint) ||
address.contains(normalizedHint)) {
score = 80;
} else if (normalizedHint.contains(name) ||
normalizedHint.contains(address)) {
score = 60;
} else {
final List<String> hintWords = normalizedHint.split(RegExp(r'\s+'));
final List<String> hubWords = ('$name $address').split(RegExp(r'\s+'));
int overlap = 0;
for (final String word in hintWords) {
if (word.length > 2 && hubWords.contains(word)) overlap++;
}
score = overlap * 10.0;
}
if (score > highestScore) {
highestScore = score;
bestMatch = hub;
}
}
return (highestScore >= 10) ? bestMatch : null;
}
dc.ListRolesByVendorIdRoles? _findBestRole(
List<dc.ListRolesByVendorIdRoles> roles,
String? hint,
) {
if (hint == null || hint.isEmpty || roles.isEmpty) return null;
final String normalizedHint = hint.toLowerCase();
dc.ListRolesByVendorIdRoles? bestMatch;
double highestScore = -1;
for (final dc.ListRolesByVendorIdRoles role in roles) {
final String name = role.name.toLowerCase();
double score = 0;
if (name == normalizedHint) {
score = 100;
} else if (name.contains(normalizedHint)) {
score = 80;
} else if (normalizedHint.contains(name)) {
score = 60;
} else {
final List<String> hintWords = normalizedHint.split(RegExp(r'\s+'));
final List<String> roleWords = name.split(RegExp(r'\s+'));
int overlap = 0;
for (final String word in hintWords) {
if (word.length > 2 && roleWords.contains(word)) overlap++;
}
score = overlap * 10.0;
}
if (score > highestScore) {
highestScore = score;
bestMatch = role;
}
}
return (highestScore >= 10) ? bestMatch : null;
return OrderPreview.fromJson(response.data as Map<String, dynamic>);
}
}

View File

@@ -1,4 +1,4 @@
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/models/order_hub.dart';
@@ -6,102 +6,83 @@ 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].
/// V2 API 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.
/// Delegates all backend calls to [BaseApiService] with [V2ApiEndpoints].
class ClientOrderQueryRepositoryImpl
implements ClientOrderQueryRepositoryInterface {
/// Creates an instance backed by the given [service].
ClientOrderQueryRepositoryImpl({required dc.DataConnectService service})
: _service = service;
/// Creates an instance backed by the given [apiService].
ClientOrderQueryRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
final dc.DataConnectService _service;
final BaseApiService _api;
@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();
});
final ApiResponse response = await _api.get(V2ApiEndpoints.clientVendors);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<dynamic> items = data['items'] as List<dynamic>;
return items
.map((dynamic json) => Vendor.fromJson(json as Map<String, dynamic>))
.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();
});
final ApiResponse response =
await _api.get(V2ApiEndpoints.clientVendorRoles(vendorId));
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<dynamic> items = data['items'] as List<dynamic>;
return items.map((dynamic json) {
final Map<String, dynamic> role = json as Map<String, dynamic>;
return OrderRole(
id: role['roleId'] as String? ?? role['id'] as String? ?? '',
name: role['roleName'] as String? ?? role['name'] as String? ?? '',
costPerHour:
((role['billRateCents'] as num?)?.toDouble() ?? 0) / 100.0,
);
}).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();
});
Future<List<OrderHub>> getHubs() async {
final ApiResponse response = await _api.get(V2ApiEndpoints.clientHubs);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<dynamic> items = data['items'] as List<dynamic>;
return items.map((dynamic json) {
final Map<String, dynamic> hub = json as Map<String, dynamic>;
return OrderHub(
id: hub['hubId'] as String? ?? hub['id'] as String? ?? '',
name: hub['hubName'] as String? ?? hub['name'] as String? ?? '',
address:
hub['fullAddress'] as String? ?? hub['address'] as String? ?? '',
placeId: hub['placeId'] as String?,
latitude: (hub['latitude'] as num?)?.toDouble(),
longitude: (hub['longitude'] as num?)?.toDouble(),
city: hub['city'] as String?,
state: hub['state'] as String?,
street: hub['street'] as String?,
country: hub['country'] as String?,
zipCode: hub['zipCode'] as String?,
);
}).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();
});
final ApiResponse response =
await _api.get(V2ApiEndpoints.clientHubManagers(hubId));
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<dynamic> items = data['items'] as List<dynamic>;
return items.map((dynamic json) {
final Map<String, dynamic> mgr = json as Map<String, dynamic>;
return OrderManager(
id: mgr['managerAssignmentId'] as String? ??
mgr['businessMembershipId'] as String? ??
mgr['id'] as String? ??
'',
name: mgr['name'] as String? ?? '',
);
}).toList();
}
@override
Future<String> getBusinessId() => _service.getBusinessId();
}

View File

@@ -1,19 +1,15 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// Represents the arguments required for the [CreateOneTimeOrderUseCase].
/// Arguments for the [CreateOneTimeOrderUseCase].
///
/// Encapsulates the [OneTimeOrder] details required to create a new
/// one-time staffing request.
/// Wraps the V2 API payload map for a one-time order.
class OneTimeOrderArguments extends UseCaseArgument {
/// Creates a [OneTimeOrderArguments] instance.
///
/// Requires the [order] details.
const OneTimeOrderArguments({required this.order});
/// Creates a [OneTimeOrderArguments] with the given [payload].
const OneTimeOrderArguments({required this.payload});
/// The order details to be created.
final OneTimeOrder order;
/// The V2 API payload map.
final Map<String, dynamic> payload;
@override
List<Object?> get props => <Object?>[order];
List<Object?> get props => <Object?>[payload];
}

View File

@@ -1,6 +1,10 @@
import 'package:krow_domain/krow_domain.dart';
/// Arguments for the [CreatePermanentOrderUseCase].
///
/// Wraps the V2 API payload map for a permanent order.
class PermanentOrderArguments {
const PermanentOrderArguments({required this.order});
final PermanentOrder order;
/// Creates a [PermanentOrderArguments] with the given [payload].
const PermanentOrderArguments({required this.payload});
/// The V2 API payload map.
final Map<String, dynamic> payload;
}

View File

@@ -1,6 +1,10 @@
import 'package:krow_domain/krow_domain.dart';
/// Arguments for the [CreateRecurringOrderUseCase].
///
/// Wraps the V2 API payload map for a recurring order.
class RecurringOrderArguments {
const RecurringOrderArguments({required this.order});
final RecurringOrder order;
/// Creates a [RecurringOrderArguments] with the given [payload].
const RecurringOrderArguments({required this.payload});
/// The V2 API payload map.
final Map<String, dynamic> payload;
}

View File

@@ -2,46 +2,33 @@ import 'package:krow_domain/krow_domain.dart';
/// Interface for the Client Create Order repository.
///
/// This repository is responsible for:
/// 1. Submitting different types of staffing orders (Rapid, One-Time, Recurring, Permanent).
///
/// It follows the KROW Clean Architecture by defining the contract in the
/// domain layer, to be implemented in the data layer.
/// V2 API uses typed endpoints per order type. Each method receives
/// a [Map<String, dynamic>] payload matching the V2 schema.
abstract interface class ClientCreateOrderRepositoryInterface {
/// Submits a one-time staffing order with specific details.
/// Submits a one-time staffing order.
///
/// [order] contains the date, location, and required positions.
Future<void> createOneTimeOrder(OneTimeOrder order);
/// [payload] follows the V2 `clientOneTimeOrderSchema` shape.
Future<void> createOneTimeOrder(Map<String, dynamic> payload);
/// Submits a recurring staffing order with specific details.
Future<void> createRecurringOrder(RecurringOrder order);
/// Submits a recurring staffing order.
///
/// [payload] follows the V2 `clientRecurringOrderSchema` shape.
Future<void> createRecurringOrder(Map<String, dynamic> payload);
/// Submits a permanent staffing order with specific details.
Future<void> createPermanentOrder(PermanentOrder order);
/// Submits a permanent staffing order.
///
/// [payload] follows the V2 `clientPermanentOrderSchema` shape.
Future<void> createPermanentOrder(Map<String, dynamic> payload);
/// Submits a rapid (urgent) staffing order via a text description.
///
/// [description] is the text message (or transcribed voice) describing the need.
Future<void> createRapidOrder(String description);
/// Transcribes the audio file for a rapid order.
///
/// [audioPath] is the local path to the recorded audio file.
Future<String> transcribeRapidOrder(String audioPath);
/// Parses the text description for a rapid order into a structured draft.
///
/// [text] is the text message describing the need.
Future<OneTimeOrder> parseRapidOrder(String text);
/// Parses the text description into a structured draft payload.
Future<Map<String, dynamic>> parseRapidOrder(String text);
/// Reorders an existing staffing order with a new date.
///
/// [previousOrderId] is the ID of the order to reorder.
/// [newDate] is the new date for the order.
Future<void> reorder(String previousOrderId, DateTime newDate);
/// Fetches the details of an existing order to be used as a template for a new order.
///
/// returns [ReorderData] containing the order details and positions.
Future<ReorderData> getOrderDetailsForReorder(String orderId);
/// Fetches the reorder preview for an existing order.
Future<OrderPreview> getOrderDetailsForReorder(String orderId);
}

View File

@@ -6,34 +6,19 @@ 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.
/// Implementations use V2 API endpoints for vendors, roles, hubs, and
/// managers. The V2 API resolves the business context from the auth token,
/// so no explicit business ID parameter is needed.
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 hubs for the current business.
Future<List<OrderHub>> getHubs();
/// 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();
}

View File

@@ -1,22 +1,20 @@
import 'package:krow_core/core.dart';
import '../arguments/one_time_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Use case for creating a one-time staffing order.
///
/// This use case encapsulates the logic for submitting a structured
/// staffing request and delegates the data operation to the
/// [ClientCreateOrderRepositoryInterface].
/// Delegates the V2 API payload to the repository.
class CreateOneTimeOrderUseCase
implements UseCase<OneTimeOrderArguments, void> {
/// Creates a [CreateOneTimeOrderUseCase].
///
/// Requires a [ClientCreateOrderRepositoryInterface] to interact with the data layer.
const CreateOneTimeOrderUseCase(this._repository);
final ClientCreateOrderRepositoryInterface _repository;
@override
Future<void> call(OneTimeOrderArguments input) {
return _repository.createOneTimeOrder(input.order);
return _repository.createOneTimeOrder(input.payload);
}
}

View File

@@ -1,15 +1,17 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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<PermanentOrder, void> {
///
/// Delegates the V2 API payload to the repository.
class CreatePermanentOrderUseCase {
/// Creates a [CreatePermanentOrderUseCase].
const CreatePermanentOrderUseCase(this._repository);
final ClientCreateOrderRepositoryInterface _repository;
@override
Future<void> call(PermanentOrder params) {
return _repository.createPermanentOrder(params);
/// Executes the use case with the given [args].
Future<void> call(PermanentOrderArguments args) {
return _repository.createPermanentOrder(args.payload);
}
}

View File

@@ -1,15 +1,17 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/recurring_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Use case for creating a recurring staffing order.
class CreateRecurringOrderUseCase implements UseCase<RecurringOrder, void> {
///
/// Delegates the V2 API payload to the repository.
class CreateRecurringOrderUseCase {
/// Creates a [CreateRecurringOrderUseCase].
const CreateRecurringOrderUseCase(this._repository);
final ClientCreateOrderRepositoryInterface _repository;
@override
Future<void> call(RecurringOrder params) {
return _repository.createRecurringOrder(params);
/// Executes the use case with the given [args].
Future<void> call(RecurringOrderArguments args) {
return _repository.createRecurringOrder(args.payload);
}
}

View File

@@ -1,14 +1,20 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Use case for fetching order details for reordering.
class GetOrderDetailsForReorderUseCase implements UseCase<String, ReorderData> {
///
/// Returns an [OrderPreview] from the V2 reorder-preview endpoint.
class GetOrderDetailsForReorderUseCase
implements UseCase<String, OrderPreview> {
/// Creates a [GetOrderDetailsForReorderUseCase].
const GetOrderDetailsForReorderUseCase(this._repository);
final ClientCreateOrderRepositoryInterface _repository;
@override
Future<ReorderData> call(String orderId) {
Future<OrderPreview> call(String orderId) {
return _repository.getOrderDetailsForReorder(orderId);
}
}

View File

@@ -1,15 +1,18 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Use case for parsing rapid order text into a structured OneTimeOrder.
/// Use case for parsing rapid order text into a structured draft.
///
/// Returns a [Map<String, dynamic>] containing parsed order data.
class ParseRapidOrderTextToOrderUseCase {
/// Creates a [ParseRapidOrderTextToOrderUseCase].
ParseRapidOrderTextToOrderUseCase({
required ClientCreateOrderRepositoryInterface repository,
}) : _repository = repository;
final ClientCreateOrderRepositoryInterface _repository;
Future<OneTimeOrder> call(String text) async {
/// Parses the given [text] into an order draft map.
Future<Map<String, dynamic>> call(String text) async {
return _repository.parseRapidOrder(text);
}
}

View File

@@ -1,25 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Arguments for the ReorderUseCase.
class ReorderArguments {
const ReorderArguments({
required this.previousOrderId,
required this.newDate,
});
final String previousOrderId;
final DateTime newDate;
}
/// Use case for reordering an existing staffing order.
class ReorderUseCase implements UseCase<ReorderArguments, void> {
const ReorderUseCase(this._repository);
final ClientCreateOrderRepositoryInterface _repository;
@override
Future<void> call(ReorderArguments params) {
return _repository.reorder(params.previousOrderId, params.newDate);
}
}

View File

@@ -13,10 +13,13 @@ import 'one_time_order_event.dart';
import 'one_time_order_state.dart';
/// BLoC for managing the multi-step one-time order creation form.
///
/// Builds V2 API payloads and uses [OrderPreview] for reorder.
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
with
BlocErrorHandler<OneTimeOrderState>,
SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
/// Creates the BLoC with required dependencies.
OneTimeOrderBloc(
this._createOneTimeOrderUseCase,
this._getOrderDetailsForReorderUseCase,
@@ -39,6 +42,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
_loadVendors();
_loadHubs();
}
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
final ClientOrderQueryRepositoryInterface _queryRepository;
@@ -48,10 +52,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
action: () => _queryRepository.getVendors(),
onError: (_) => add(const OneTimeOrderVendorsLoaded(<Vendor>[])),
);
if (vendors != null) {
add(OneTimeOrderVendorsLoaded(vendors));
}
if (vendors != null) add(OneTimeOrderVendorsLoaded(vendors));
}
Future<void> _loadRolesForVendor(
@@ -63,98 +64,70 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
final List<OrderRole> result =
await _queryRepository.getRolesByVendor(vendorId);
return result
.map(
(OrderRole r) => OneTimeOrderRoleOption(
id: r.id,
name: r.name,
costPerHour: r.costPerHour,
),
)
.map((OrderRole r) => OneTimeOrderRoleOption(
id: r.id, name: r.name, costPerHour: r.costPerHour))
.toList();
},
onError: (_) =>
emit(state.copyWith(roles: const <OneTimeOrderRoleOption>[])),
);
if (roles != null) {
emit(state.copyWith(roles: roles));
}
if (roles != null) emit(state.copyWith(roles: roles));
}
Future<void> _loadHubs() async {
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
action: () async {
final String businessId = await _queryRepository.getBusinessId();
final List<OrderHub> result =
await _queryRepository.getHubsByOwner(businessId);
final List<OrderHub> result = await _queryRepository.getHubs();
return result
.map(
(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,
),
)
.map((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();
},
onError: (_) =>
add(const OneTimeOrderHubsLoaded(<OneTimeOrderHubOption>[])),
);
if (hubs != null) {
add(OneTimeOrderHubsLoaded(hubs));
}
if (hubs != null) add(OneTimeOrderHubsLoaded(hubs));
}
Future<void> _loadManagersForHub(String hubId) async {
final List<OneTimeOrderManagerOption>? managers =
await handleErrorWithResult(
action: () async {
final List<OrderManager> result =
await _queryRepository.getManagersByHub(hubId);
return result
.map(
(OrderManager m) => OneTimeOrderManagerOption(
id: m.id,
name: m.name,
),
)
.toList();
},
onError: (_) {
add(
const OneTimeOrderManagersLoaded(<OneTimeOrderManagerOption>[]),
);
},
);
if (managers != null) {
add(OneTimeOrderManagersLoaded(managers));
}
action: () async {
final List<OrderManager> result =
await _queryRepository.getManagersByHub(hubId);
return result
.map((OrderManager m) =>
OneTimeOrderManagerOption(id: m.id, name: m.name))
.toList();
},
onError: (_) =>
add(const OneTimeOrderManagersLoaded(<OneTimeOrderManagerOption>[])),
);
if (managers != null) add(OneTimeOrderManagersLoaded(managers));
}
Future<void> _onVendorsLoaded(
OneTimeOrderVendorsLoaded event,
Emitter<OneTimeOrderState> emit,
) async {
final Vendor? selectedVendor = event.vendors.isNotEmpty
? event.vendors.first
: null;
emit(
state.copyWith(
vendors: event.vendors,
selectedVendor: selectedVendor,
isDataLoaded: true,
),
);
final Vendor? selectedVendor =
event.vendors.isNotEmpty ? event.vendors.first : null;
emit(state.copyWith(
vendors: event.vendors,
selectedVendor: selectedVendor,
isDataLoaded: true,
));
if (selectedVendor != null) {
await _loadRolesForVendor(selectedVendor.id, emit);
}
@@ -172,20 +145,14 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
OneTimeOrderHubsLoaded event,
Emitter<OneTimeOrderState> emit,
) {
final OneTimeOrderHubOption? selectedHub = event.hubs.isNotEmpty
? event.hubs.first
: null;
emit(
state.copyWith(
hubs: event.hubs,
selectedHub: selectedHub,
location: selectedHub?.name ?? '',
),
);
if (selectedHub != null) {
_loadManagersForHub(selectedHub.id);
}
final OneTimeOrderHubOption? selectedHub =
event.hubs.isNotEmpty ? event.hubs.first : null;
emit(state.copyWith(
hubs: event.hubs,
selectedHub: selectedHub,
location: selectedHub?.name ?? '',
));
if (selectedHub != null) _loadManagersForHub(selectedHub.id);
}
void _onHubChanged(
@@ -229,14 +196,9 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
Emitter<OneTimeOrderState> emit,
) {
final List<OneTimeOrderPosition> newPositions =
List<OneTimeOrderPosition>.from(state.positions)..add(
const OneTimeOrderPosition(
role: '',
count: 1,
startTime: '09:00',
endTime: '17:00',
),
);
List<OneTimeOrderPosition>.from(state.positions)
..add(const OneTimeOrderPosition(
role: '', count: 1, startTime: '09:00', endTime: '17:00'));
emit(state.copyWith(positions: newPositions));
}
@@ -262,6 +224,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
emit(state.copyWith(positions: newPositions));
}
/// Builds a V2 API payload and submits the one-time order.
Future<void> _onSubmitted(
OneTimeOrderSubmitted event,
Emitter<OneTimeOrderState> emit,
@@ -270,37 +233,45 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
await handleError(
emit: emit.call,
action: () async {
final Map<String, double> roleRates = <String, double>{
for (final OneTimeOrderRoleOption role in state.roles)
role.id: role.costPerHour,
};
final OneTimeOrderHubOption? selectedHub = state.selectedHub;
if (selectedHub == null) {
throw const OrderMissingHubException();
}
final OneTimeOrder order = OneTimeOrder(
date: state.date,
location: selectedHub.name,
positions: state.positions,
hub: OneTimeOrderHubDetails(
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,
hubManagerId: state.selectedManager?.id,
roleRates: roleRates,
if (selectedHub == null) throw const OrderMissingHubException();
final String orderDate =
'${state.date.year.toString().padLeft(4, '0')}-'
'${state.date.month.toString().padLeft(2, '0')}-'
'${state.date.day.toString().padLeft(2, '0')}';
final List<Map<String, dynamic>> positions =
state.positions.map((OneTimeOrderPosition p) {
final OneTimeOrderRoleOption? role = state.roles
.cast<OneTimeOrderRoleOption?>()
.firstWhere(
(OneTimeOrderRoleOption? r) => r != null && r.id == p.role,
orElse: () => null,
);
return <String, dynamic>{
if (role != null) 'roleName': role.name,
if (p.role.isNotEmpty) 'roleId': p.role,
'workerCount': p.count,
'startTime': p.startTime,
'endTime': p.endTime,
if (p.lunchBreak != 'NO_BREAK' && p.lunchBreak.isNotEmpty)
'lunchBreakMinutes': _breakMinutes(p.lunchBreak),
};
}).toList();
final Map<String, dynamic> payload = <String, dynamic>{
'hubId': selectedHub.id,
'eventName': state.eventName,
'orderDate': orderDate,
'positions': positions,
if (state.selectedVendor != null)
'vendorId': state.selectedVendor!.id,
};
await _createOneTimeOrderUseCase(
OneTimeOrderArguments(payload: payload),
);
await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order));
emit(state.copyWith(status: OneTimeOrderStatus.success));
},
onError: (String errorKey) => state.copyWith(
@@ -310,6 +281,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
);
}
/// Initializes the form from route arguments or reorder preview data.
Future<void> _onInitialized(
OneTimeOrderInitialized event,
Emitter<OneTimeOrderState> emit,
@@ -321,39 +293,30 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
// Handle Rapid Order Draft
if (data['isRapidDraft'] == true) {
final OneTimeOrder? order = data['order'] as OneTimeOrder?;
if (order != null) {
emit(
state.copyWith(
eventName: order.eventName ?? '',
date: order.date,
positions: order.positions,
location: order.location,
isRapidDraft: true,
),
);
final Map<String, dynamic>? draft =
data['order'] as Map<String, dynamic>?;
if (draft != null) {
final List<dynamic> draftPositions =
draft['positions'] as List<dynamic>? ?? const <dynamic>[];
final List<OneTimeOrderPosition> positions = draftPositions
.map((dynamic p) {
final Map<String, dynamic> pos = p as Map<String, dynamic>;
return OneTimeOrderPosition(
role: pos['roleName'] as String? ?? '',
count: pos['workerCount'] as int? ?? 1,
startTime: pos['startTime'] as String? ?? '09:00',
endTime: pos['endTime'] as String? ?? '17:00',
);
})
.toList();
// Try to match vendor if available
if (order.vendorId != null) {
final Vendor? vendor = state.vendors
.where((Vendor v) => v.id == order.vendorId)
.firstOrNull;
if (vendor != null) {
emit(state.copyWith(selectedVendor: vendor));
await _loadRolesForVendor(vendor.id, emit);
}
}
// Try to match hub if available
if (order.hub != null) {
final OneTimeOrderHubOption? hub = state.hubs
.where((OneTimeOrderHubOption h) => h.id == order.hub?.id)
.firstOrNull;
if (hub != null) {
emit(state.copyWith(selectedHub: hub));
await _loadManagersForHub(hub.id);
}
}
emit(state.copyWith(
eventName: draft['eventName'] as String? ?? '',
date: startDate ?? DateTime.now(),
positions: positions.isNotEmpty ? positions : null,
location: draft['locationHint'] as String? ?? '',
isRapidDraft: true,
));
return;
}
}
@@ -367,50 +330,26 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
await handleError(
emit: emit.call,
action: () async {
final ReorderData orderDetails =
final OrderPreview preview =
await _getOrderDetailsForReorderUseCase(orderId);
// Map positions
final List<OneTimeOrderPosition> positions = orderDetails.positions.map(
(ReorderPosition role) {
return OneTimeOrderPosition(
final List<OneTimeOrderPosition> positions = <OneTimeOrderPosition>[];
for (final OrderPreviewShift shift in preview.shifts) {
for (final OrderPreviewRole role in shift.roles) {
positions.add(OneTimeOrderPosition(
role: role.roleId,
count: role.count,
startTime: role.startTime,
endTime: role.endTime,
lunchBreak: role.lunchBreak,
);
},
).toList();
// Update state with order details
final Vendor? selectedVendor = state.vendors
.where((Vendor v) => v.id == orderDetails.vendorId)
.firstOrNull;
final OneTimeOrderHubOption? selectedHub = state.hubs
.where(
(OneTimeOrderHubOption h) =>
h.placeId == orderDetails.hub.placeId,
)
.firstOrNull;
emit(
state.copyWith(
eventName: orderDetails.eventName.isNotEmpty
? orderDetails.eventName
: title,
positions: positions,
selectedVendor: selectedVendor,
selectedHub: selectedHub,
location: selectedHub?.name ?? '',
status: OneTimeOrderStatus.initial,
),
);
if (selectedVendor != null) {
await _loadRolesForVendor(selectedVendor.id, emit);
count: role.workersNeeded,
startTime: _formatTime(shift.startsAt),
endTime: _formatTime(shift.endsAt),
));
}
}
emit(state.copyWith(
eventName: preview.title.isNotEmpty ? preview.title : title,
positions: positions.isNotEmpty ? positions : null,
status: OneTimeOrderStatus.initial,
));
},
onError: (String errorKey) => state.copyWith(
status: OneTimeOrderStatus.failure,
@@ -418,4 +357,29 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
),
);
}
/// Formats a [DateTime] to HH:mm string.
String _formatTime(DateTime dt) {
final DateTime local = dt.toLocal();
return '${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
}
/// Converts a break duration string to minutes.
int _breakMinutes(String value) {
switch (value) {
case 'MIN_10':
return 10;
case 'MIN_15':
return 15;
case 'MIN_30':
return 30;
case 'MIN_45':
return 45;
case 'MIN_60':
return 60;
default:
return 0;
}
}
}

View File

@@ -1,8 +1,12 @@
import 'package:client_orders_common/client_orders_common.dart';
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../utils/time_parsing_utils.dart';
/// Position type alias for one-time orders.
typedef OneTimeOrderPosition = OrderPositionUiModel;
enum OneTimeOrderStatus { initial, loading, success, failure }
class OneTimeOrderState extends Equatable {

View File

@@ -6,6 +6,7 @@ import 'package:client_create_order/src/domain/usecases/create_permanent_order_u
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:client_create_order/src/domain/arguments/permanent_order_arguments.dart';
import 'package:krow_domain/krow_domain.dart' as domain;
import 'permanent_order_event.dart';
@@ -95,12 +96,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
Future<void> _loadHubs() async {
final List<PermanentOrderHubOption>? hubs = await handleErrorWithResult(
action: () async {
final String? businessId = await _queryRepository.getBusinessId();
if (businessId == null || businessId.isEmpty) {
return <PermanentOrderHubOption>[];
}
final List<OrderHub> orderHubs =
await _queryRepository.getHubsByOwner(businessId);
final List<OrderHub> orderHubs = await _queryRepository.getHubs();
return orderHubs
.map(
(OrderHub hub) => PermanentOrderHubOption(
@@ -327,48 +323,50 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
await handleError(
emit: emit.call,
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 const domain.OrderMissingHubException();
}
final domain.PermanentOrder order = domain.PermanentOrder(
startDate: state.startDate,
permanentDays: state.permanentDays,
positions: state.positions
.map(
(PermanentOrderPosition p) => domain.OneTimeOrderPosition(
role: p.role,
count: p.count,
startTime: p.startTime,
endTime: p.endTime,
lunchBreak: p.lunchBreak ?? 'NO_BREAK',
location: null,
),
)
.toList(),
hub: domain.OneTimeOrderHubDetails(
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,
hubManagerId: state.selectedManager?.id,
roleRates: roleRates,
final String startDate =
'${state.startDate.year.toString().padLeft(4, '0')}-'
'${state.startDate.month.toString().padLeft(2, '0')}-'
'${state.startDate.day.toString().padLeft(2, '0')}';
final List<int> daysOfWeek = state.permanentDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
state.positions.map((PermanentOrderPosition p) {
final PermanentOrderRoleOption? role = state.roles
.cast<PermanentOrderRoleOption?>()
.firstWhere(
(PermanentOrderRoleOption? r) => r != null && r.id == p.role,
orElse: () => null,
);
return <String, dynamic>{
if (role != null) 'roleName': role.name,
if (p.role.isNotEmpty) 'roleId': p.role,
'workerCount': p.count,
'startTime': p.startTime,
'endTime': p.endTime,
};
}).toList();
final Map<String, dynamic> payload = <String, dynamic>{
'hubId': selectedHub.id,
'eventName': state.eventName,
'startDate': startDate,
'daysOfWeek': daysOfWeek,
'positions': positions,
if (state.selectedVendor != null)
'vendorId': state.selectedVendor!.id,
};
await _createPermanentOrderUseCase(
PermanentOrderArguments(payload: payload),
);
await _createPermanentOrderUseCase(order);
emit(state.copyWith(status: PermanentOrderStatus.success));
},
onError: (String errorKey) => state.copyWith(
@@ -398,52 +396,32 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
await handleError(
emit: emit.call,
action: () async {
final domain.ReorderData orderDetails =
final domain.OrderPreview preview =
await _getOrderDetailsForReorderUseCase(orderId);
// Map positions
final List<PermanentOrderPosition> positions = orderDetails.positions
.map((domain.ReorderPosition role) {
return PermanentOrderPosition(
role: role.roleId,
count: role.count,
startTime: role.startTime,
endTime: role.endTime,
lunchBreak: role.lunchBreak,
);
})
.toList();
// Update state with order details
final domain.Vendor? selectedVendor = state.vendors
.where((domain.Vendor v) => v.id == orderDetails.vendorId)
.firstOrNull;
final PermanentOrderHubOption? selectedHub = state.hubs
.where(
(PermanentOrderHubOption h) =>
h.placeId == orderDetails.hub.placeId,
)
.firstOrNull;
final List<PermanentOrderPosition> positions =
<PermanentOrderPosition>[];
for (final domain.OrderPreviewShift shift in preview.shifts) {
for (final domain.OrderPreviewRole role in shift.roles) {
positions.add(PermanentOrderPosition(
role: role.roleId,
count: role.workersNeeded,
startTime: _formatTime(shift.startsAt),
endTime: _formatTime(shift.endsAt),
));
}
}
emit(
state.copyWith(
eventName: orderDetails.eventName.isNotEmpty
? orderDetails.eventName
: title,
positions: positions,
selectedVendor: selectedVendor,
selectedHub: selectedHub,
location: selectedHub?.name ?? '',
eventName:
preview.title.isNotEmpty ? preview.title : title,
positions: positions.isNotEmpty ? positions : null,
status: PermanentOrderStatus.initial,
startDate: startDate ?? orderDetails.startDate ?? DateTime.now(),
permanentDays: orderDetails.permanentDays,
startDate:
startDate ?? preview.startsAt ?? DateTime.now(),
),
);
if (selectedVendor != null) {
await _loadRolesForVendor(selectedVendor.id, emit);
}
},
onError: (String errorKey) => state.copyWith(
status: PermanentOrderStatus.failure,
@@ -452,6 +430,13 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
);
}
/// Formats a [DateTime] to HH:mm string.
String _formatTime(DateTime dt) {
final DateTime local = dt.toLocal();
return '${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
}
static List<String> _sortDays(List<String> days) {
days.sort(
(String a, String b) =>

View File

@@ -2,8 +2,6 @@ import 'package:client_create_order/src/domain/usecases/parse_rapid_order_usecas
import 'package:client_create_order/src/domain/usecases/transcribe_rapid_order_usecase.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'rapid_order_event.dart';
import 'rapid_order_state.dart';
@@ -119,9 +117,13 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
await handleError(
emit: emit.call,
action: () async {
final OneTimeOrder order = await _parseRapidOrderUseCase(message);
final Map<String, dynamic> parsedDraft =
await _parseRapidOrderUseCase(message);
emit(
state.copyWith(status: RapidOrderStatus.parsed, parsedOrder: order),
state.copyWith(
status: RapidOrderStatus.parsed,
parsedDraft: parsedDraft,
),
);
},
onError: (String errorKey) =>

View File

@@ -1,9 +1,11 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Status of the rapid order creation flow.
enum RapidOrderStatus { initial, submitting, parsed, failure }
/// State for the rapid order BLoC.
class RapidOrderState extends Equatable {
/// Creates a [RapidOrderState].
const RapidOrderState({
this.status = RapidOrderStatus.initial,
this.message = '',
@@ -11,28 +13,42 @@ class RapidOrderState extends Equatable {
this.isTranscribing = false,
this.examples = const <String>[],
this.error,
this.parsedOrder,
this.parsedDraft,
});
/// Current status of the rapid order flow.
final RapidOrderStatus status;
/// The text message entered or transcribed.
final String message;
/// Whether the microphone is actively recording.
final bool isListening;
/// Whether audio is being transcribed.
final bool isTranscribing;
/// Example prompts for the user.
final List<String> examples;
/// Error message, if any.
final String? error;
final OneTimeOrder? parsedOrder;
/// The parsed draft from the AI, as a map matching the V2 payload shape.
final Map<String, dynamic>? parsedDraft;
@override
List<Object?> get props => <Object?>[
status,
message,
isListening,
isTranscribing,
examples,
error,
parsedOrder,
];
status,
message,
isListening,
isTranscribing,
examples,
error,
parsedDraft,
];
/// Creates a copy with overridden fields.
RapidOrderState copyWith({
RapidOrderStatus? status,
String? message,
@@ -40,7 +56,7 @@ class RapidOrderState extends Equatable {
bool? isTranscribing,
List<String>? examples,
String? error,
OneTimeOrder? parsedOrder,
Map<String, dynamic>? parsedDraft,
}) {
return RapidOrderState(
status: status ?? this.status,
@@ -49,7 +65,7 @@ class RapidOrderState extends Equatable {
isTranscribing: isTranscribing ?? this.isTranscribing,
examples: examples ?? this.examples,
error: error ?? this.error,
parsedOrder: parsedOrder ?? this.parsedOrder,
parsedDraft: parsedDraft ?? this.parsedDraft,
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:client_create_order/src/domain/usecases/create_recurring_order_u
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:client_create_order/src/domain/arguments/recurring_order_arguments.dart';
import 'package:krow_domain/krow_domain.dart' as domain;
import 'recurring_order_event.dart';
@@ -13,10 +14,9 @@ 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.
/// Delegates all backend queries to [ClientOrderQueryRepositoryInterface]
/// and order submission to [CreateRecurringOrderUseCase].
/// Builds V2 API payloads from form state.
class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
with
BlocErrorHandler<RecurringOrderState>,
@@ -111,9 +111,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
Future<void> _loadHubs() async {
final List<RecurringOrderHubOption>? hubs = await handleErrorWithResult(
action: () async {
final String businessId = await _queryRepository.getBusinessId();
final List<OrderHub> orderHubs =
await _queryRepository.getHubsByOwner(businessId);
final List<OrderHub> orderHubs = await _queryRepository.getHubs();
return orderHubs
.map(
(OrderHub hub) => RecurringOrderHubOption(
@@ -357,50 +355,56 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
await handleError(
emit: emit.call,
action: () async {
final Map<String, double> roleRates = <String, double>{
for (final RecurringOrderRoleOption role in state.roles)
role.id: role.costPerHour,
};
final RecurringOrderHubOption? selectedHub = state.selectedHub;
if (selectedHub == null) {
throw const domain.OrderMissingHubException();
}
final domain.RecurringOrder order = domain.RecurringOrder(
startDate: state.startDate,
endDate: state.endDate,
recurringDays: state.recurringDays,
location: selectedHub.name,
positions: state.positions
.map(
(RecurringOrderPosition p) => domain.RecurringOrderPosition(
role: p.role,
count: p.count,
startTime: p.startTime,
endTime: p.endTime,
lunchBreak: p.lunchBreak ?? 'NO_BREAK',
location: null,
),
)
.toList(),
hub: domain.RecurringOrderHubDetails(
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,
hubManagerId: state.selectedManager?.id,
roleRates: roleRates,
final String startDate =
'${state.startDate.year.toString().padLeft(4, '0')}-'
'${state.startDate.month.toString().padLeft(2, '0')}-'
'${state.startDate.day.toString().padLeft(2, '0')}';
final String endDate =
'${state.endDate.year.toString().padLeft(4, '0')}-'
'${state.endDate.month.toString().padLeft(2, '0')}-'
'${state.endDate.day.toString().padLeft(2, '0')}';
// Map day labels (MON=1, TUE=2, ..., SUN=0) to V2 int format
final List<int> recurrenceDays = state.recurringDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
state.positions.map((RecurringOrderPosition p) {
final RecurringOrderRoleOption? role = state.roles
.cast<RecurringOrderRoleOption?>()
.firstWhere(
(RecurringOrderRoleOption? r) => r != null && r.id == p.role,
orElse: () => null,
);
return <String, dynamic>{
if (role != null) 'roleName': role.name,
if (p.role.isNotEmpty) 'roleId': p.role,
'workerCount': p.count,
'startTime': p.startTime,
'endTime': p.endTime,
};
}).toList();
final Map<String, dynamic> payload = <String, dynamic>{
'hubId': selectedHub.id,
'eventName': state.eventName,
'startDate': startDate,
'endDate': endDate,
'recurrenceDays': recurrenceDays,
'positions': positions,
if (state.selectedVendor != null)
'vendorId': state.selectedVendor!.id,
};
await _createRecurringOrderUseCase(
RecurringOrderArguments(payload: payload),
);
await _createRecurringOrderUseCase(order);
emit(state.copyWith(status: RecurringOrderStatus.success));
},
onError: (String errorKey) => state.copyWith(
@@ -430,53 +434,34 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
await handleError(
emit: emit.call,
action: () async {
final domain.ReorderData orderDetails =
final domain.OrderPreview preview =
await _getOrderDetailsForReorderUseCase(orderId);
// Map positions
final List<RecurringOrderPosition> positions = orderDetails.positions
.map((domain.ReorderPosition role) {
return RecurringOrderPosition(
role: role.roleId,
count: role.count,
startTime: role.startTime,
endTime: role.endTime,
lunchBreak: role.lunchBreak,
);
})
.toList();
// Update state with order details
final domain.Vendor? selectedVendor = state.vendors
.where((domain.Vendor v) => v.id == orderDetails.vendorId)
.firstOrNull;
final RecurringOrderHubOption? selectedHub = state.hubs
.where(
(RecurringOrderHubOption h) =>
h.placeId == orderDetails.hub.placeId,
)
.firstOrNull;
// Map positions from preview shifts/roles
final List<RecurringOrderPosition> positions =
<RecurringOrderPosition>[];
for (final domain.OrderPreviewShift shift in preview.shifts) {
for (final domain.OrderPreviewRole role in shift.roles) {
positions.add(RecurringOrderPosition(
role: role.roleId,
count: role.workersNeeded,
startTime: _formatTime(shift.startsAt),
endTime: _formatTime(shift.endsAt),
));
}
}
emit(
state.copyWith(
eventName: orderDetails.eventName.isNotEmpty
? orderDetails.eventName
: title,
positions: positions,
selectedVendor: selectedVendor,
selectedHub: selectedHub,
location: selectedHub?.name ?? '',
eventName:
preview.title.isNotEmpty ? preview.title : title,
positions: positions.isNotEmpty ? positions : null,
status: RecurringOrderStatus.initial,
startDate: startDate ?? orderDetails.startDate ?? DateTime.now(),
endDate: orderDetails.endDate ?? DateTime.now(),
recurringDays: orderDetails.recurringDays,
startDate:
startDate ?? preview.startsAt ?? DateTime.now(),
endDate: preview.endsAt ?? DateTime.now(),
),
);
if (selectedVendor != null) {
await _loadRolesForVendor(selectedVendor.id, emit);
}
},
onError: (String errorKey) => state.copyWith(
status: RecurringOrderStatus.failure,
@@ -485,6 +470,13 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
);
}
/// Formats a [DateTime] to HH:mm string.
String _formatTime(DateTime dt) {
final DateTime local = dt.toLocal();
return '${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
}
static List<String> _sortDays(List<String> days) {
days.sort(
(String a, String b) =>

View File

@@ -5,7 +5,7 @@ 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 'package:krow_domain/krow_domain.dart';
import '../blocs/permanent_order/permanent_order_bloc.dart';
import '../blocs/permanent_order/permanent_order_event.dart';
import '../blocs/permanent_order/permanent_order_state.dart';

View File

@@ -4,7 +4,7 @@ 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 'package:krow_domain/krow_domain.dart';
import '../blocs/recurring_order/recurring_order_bloc.dart';
import '../blocs/recurring_order/recurring_order_event.dart';
import '../blocs/recurring_order/recurring_order_state.dart';

View File

@@ -56,10 +56,10 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
);
}
} else if (state.status == RapidOrderStatus.parsed &&
state.parsedOrder != null) {
state.parsedDraft != null) {
Modular.to.toCreateOrderOneTime(
arguments: <String, dynamic>{
'order': state.parsedOrder,
'order': state.parsedDraft,
'isRapidDraft': true,
},
);

View File

@@ -22,12 +22,8 @@ dependencies:
path: ../../../../domain
krow_core:
path: ../../../../core
krow_data_connect:
path: ../../../../data_connect
client_orders_common:
path: ../orders_common
firebase_data_connect: ^0.2.2+2
firebase_auth: ^6.1.4
dev_dependencies:
flutter_test:

View File

@@ -137,7 +137,7 @@ class OneTimeOrderForm extends StatelessWidget {
return DropdownMenuItem<Vendor>(
value: vendor,
child: Text(
vendor.name,
vendor.companyName,
style: UiTypography.body2m.textPrimary,
),
);

View File

@@ -147,7 +147,7 @@ class PermanentOrderForm extends StatelessWidget {
return DropdownMenuItem<Vendor>(
value: vendor,
child: Text(
vendor.name,
vendor.companyName,
style: UiTypography.body2m.textPrimary,
),
);

View File

@@ -155,7 +155,7 @@ class RecurringOrderForm extends StatelessWidget {
return DropdownMenuItem<Vendor>(
value: vendor,
child: Text(
vendor.name,
vendor.companyName,
style: UiTypography.body2m.textPrimary,
),
);

View File

@@ -7,7 +7,6 @@ import Foundation
import file_picker
import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
import flutter_local_notifications
@@ -19,7 +18,6 @@ import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))

View File

@@ -14,20 +14,18 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
intl: any
# Architecture Packages
design_system:
path: ../../../../design_system
core_localization:
path: ../../../../core_localization
krow_domain: ^0.0.1
krow_data_connect: ^0.0.1
krow_domain:
path: ../../../../domain
krow_core:
path: ../../../../core
firebase_data_connect: any
intl: any
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -1,199 +1,98 @@
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/i_view_orders_repository.dart';
/// Implementation of [IViewOrdersRepository] using Data Connect.
/// V2 API implementation of [IViewOrdersRepository].
///
/// Replaces the old Data Connect implementation with [BaseApiService] calls
/// to the V2 query and command API endpoints.
class ViewOrdersRepositoryImpl implements IViewOrdersRepository {
ViewOrdersRepositoryImpl({required dc.DataConnectService service})
: _service = service;
final dc.DataConnectService _service;
/// Creates an instance backed by the given [apiService].
ViewOrdersRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
final BaseApiService _api;
@override
Future<List<domain.OrderItem>> getOrdersForRange({
Future<List<OrderItem>> getOrdersForRange({
required DateTime start,
required DateTime end,
}) async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.Timestamp startTimestamp = _service.toTimestamp(
_startOfDay(start),
);
final fdc.Timestamp endTimestamp = _service.toTimestamp(_endOfDay(end));
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables
>
result = await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: startTimestamp,
end: endTimestamp,
)
.execute();
debugPrint(
'ViewOrders range start=${start.toIso8601String()} end=${end.toIso8601String()} shiftRoles=${result.data.shiftRoles.length}',
);
final String businessName =
dc.ClientSessionStore.instance.session?.business?.businessName ??
'Your Company';
return result.data.shiftRoles.map((
dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole,
) {
final DateTime? shiftDate = shiftRole.shift.date
?.toDateTime()
.toLocal();
final String dateStr = shiftDate == null
? ''
: DateFormat('yyyy-MM-dd').format(shiftDate);
final String startTime = _formatTime(shiftRole.startTime);
final String endTime = _formatTime(shiftRole.endTime);
final int filled = shiftRole.assigned ?? 0;
final int workersNeeded = shiftRole.count;
final double hours = shiftRole.hours ?? 0;
final double totalValue = shiftRole.totalValue ?? 0;
final double hourlyRate = _hourlyRate(
shiftRole.totalValue,
shiftRole.hours,
);
// final String status = filled >= workersNeeded ? 'filled' : 'open';
final String status = shiftRole.shift.status?.stringValue ?? 'OPEN';
debugPrint(
'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} '
'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} '
'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue',
);
final String eventName =
shiftRole.shift.order.eventName ?? shiftRole.shift.title;
final order = shiftRole.shift.order;
final String? hubManagerId = order.hubManagerId;
final String? hubManagerName = order.hubManager?.user?.fullName;
return domain.OrderItem(
id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId),
orderId: order.id,
orderType: domain.OrderType.fromString(
order.orderType.stringValue,
),
title: shiftRole.role.name,
eventName: eventName,
clientName: businessName,
status: status,
date: dateStr,
startTime: startTime,
endTime: endTime,
location: shiftRole.shift.location ?? '',
locationAddress: shiftRole.shift.locationAddress ?? '',
filled: filled,
workersNeeded: workersNeeded,
hourlyRate: hourlyRate,
hours: hours,
totalValue: totalValue,
confirmedApps: const <Map<String, dynamic>>[],
hubManagerId: hubManagerId,
hubManagerName: hubManagerName,
);
}).toList();
});
final ApiResponse response = await _api.get(
V2ApiEndpoints.clientOrdersView,
params: <String, dynamic>{
'startDate': start.toIso8601String(),
'endDate': end.toIso8601String(),
},
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<dynamic> items = data['items'] as List<dynamic>;
return items
.map((dynamic json) =>
OrderItem.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<Map<String, List<Map<String, dynamic>>>> getAcceptedApplicationsForDay(
DateTime day,
) async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.Timestamp dayStart = _service.toTimestamp(_startOfDay(day));
final fdc.Timestamp dayEnd = _service.toTimestamp(_endOfDay(day));
final fdc.QueryResult<
dc.ListAcceptedApplicationsByBusinessForDayData,
dc.ListAcceptedApplicationsByBusinessForDayVariables
>
result = await _service.connector
.listAcceptedApplicationsByBusinessForDay(
businessId: businessId,
dayStart: dayStart,
dayEnd: dayEnd,
)
.execute();
print(
'ViewOrders day=${day.toIso8601String()} applications=${result.data.applications.length}',
);
final Map<String, List<Map<String, dynamic>>> grouped =
<String, List<Map<String, dynamic>>>{};
for (final dc.ListAcceptedApplicationsByBusinessForDayApplications
application
in result.data.applications) {
print(
'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} '
'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}',
);
final String key = _shiftRoleKey(
application.shiftId,
application.roleId,
);
grouped.putIfAbsent(key, () => <Map<String, dynamic>>[]);
grouped[key]!.add(<String, dynamic>{
'id': application.id,
'worker_id': application.staff.id,
'worker_name': application.staff.fullName,
'status': 'confirmed',
'photo_url': application.staff.photoUrl,
'phone': application.staff.phone,
'rating': application.staff.averageRating,
});
}
return grouped;
});
Future<String> editOrder({
required String orderId,
required Map<String, dynamic> payload,
}) async {
final ApiResponse response = await _api.post(
V2ApiEndpoints.clientOrderEdit(orderId),
data: payload,
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return data['orderId'] as String? ?? orderId;
}
String _shiftRoleKey(String shiftId, String roleId) {
return '$shiftId:$roleId';
}
DateTime _startOfDay(DateTime dateTime) {
return DateTime(dateTime.year, dateTime.month, dateTime.day);
}
DateTime _endOfDay(DateTime dateTime) {
// We add the current microseconds to ensure the query variables are unique
// each time we fetch, bypassing any potential Data Connect caching.
final DateTime now = DateTime.now();
return DateTime(
dateTime.year,
dateTime.month,
dateTime.day,
23,
59,
59,
now.millisecond,
now.microsecond,
@override
Future<void> cancelOrder({
required String orderId,
String? reason,
}) async {
await _api.post(
V2ApiEndpoints.clientOrderCancel(orderId),
data: <String, dynamic>{
if (reason != null) 'reason': reason,
},
);
}
String _formatTime(fdc.Timestamp? timestamp) {
if (timestamp == null) {
return '';
}
final DateTime dateTime = timestamp.toDateTime().toLocal();
return DateFormat('HH:mm').format(dateTime);
@override
Future<List<Vendor>> getVendors() async {
final ApiResponse response = await _api.get(V2ApiEndpoints.clientVendors);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<dynamic> items = data['items'] as List<dynamic>;
return items
.map((dynamic json) => Vendor.fromJson(json as Map<String, dynamic>))
.toList();
}
double _hourlyRate(double? totalValue, double? hours) {
if (totalValue == null || hours == null || hours == 0) {
return 0;
}
return totalValue / hours;
@override
Future<List<Map<String, dynamic>>> getRolesByVendor(String vendorId) async {
final ApiResponse response =
await _api.get(V2ApiEndpoints.clientVendorRoles(vendorId));
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<dynamic> items = data['items'] as List<dynamic>;
return items.cast<Map<String, dynamic>>();
}
@override
Future<List<Map<String, dynamic>>> getHubs() async {
final ApiResponse response = await _api.get(V2ApiEndpoints.clientHubs);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<dynamic> items = data['items'] as List<dynamic>;
return items.cast<Map<String, dynamic>>();
}
@override
Future<List<Map<String, dynamic>>> getManagersByHub(String hubId) async {
final ApiResponse response =
await _api.get(V2ApiEndpoints.clientHubManagers(hubId));
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final List<dynamic> items = data['items'] as List<dynamic>;
return items.cast<Map<String, dynamic>>();
}
}

View File

@@ -1,10 +0,0 @@
import 'package:krow_core/core.dart';
class OrdersDayArguments extends UseCaseArgument {
const OrdersDayArguments({required this.day});
final DateTime day;
@override
List<Object?> get props => <Object?>[day];
}

View File

@@ -1,15 +1,40 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for fetching and managing client orders.
///
/// V2 API returns workers inline with order items, so the separate
/// accepted-applications method is no longer needed.
abstract class IViewOrdersRepository {
/// Fetches a list of [OrderItem] for the client.
/// Fetches [OrderItem] list for the given date range via the V2 API.
Future<List<OrderItem>> getOrdersForRange({
required DateTime start,
required DateTime end,
});
/// Fetches accepted staff applications for the given day, grouped by shift+role.
Future<Map<String, List<Map<String, dynamic>>>> getAcceptedApplicationsForDay(
DateTime day,
);
/// Submits an edit for the order identified by [orderId].
///
/// The [payload] map follows the V2 `clientOrderEdit` schema.
/// The backend creates a new order copy and cancels the original.
Future<String> editOrder({
required String orderId,
required Map<String, dynamic> payload,
});
/// Cancels the order identified by [orderId].
Future<void> cancelOrder({
required String orderId,
String? reason,
});
/// Fetches available vendors for the current tenant.
Future<List<Vendor>> getVendors();
/// Fetches roles offered by the given [vendorId].
Future<List<Map<String, dynamic>>> getRolesByVendor(String vendorId);
/// Fetches hubs for the current business.
Future<List<Map<String, dynamic>>> getHubs();
/// Fetches team members for the given [hubId].
Future<List<Map<String, dynamic>>> getManagersByHub(String hubId);
}

View File

@@ -1,17 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/i_view_orders_repository.dart';
import '../arguments/orders_day_arguments.dart';
class GetAcceptedApplicationsForDayUseCase
implements UseCase<OrdersDayArguments, Map<String, List<Map<String, dynamic>>>> {
const GetAcceptedApplicationsForDayUseCase(this._repository);
final IViewOrdersRepository _repository;
@override
Future<Map<String, List<Map<String, dynamic>>>> call(
OrdersDayArguments input,
) {
return _repository.getAcceptedApplicationsForDay(input.day);
}
}

View File

@@ -1,39 +1,36 @@
import 'package:intl/intl.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/arguments/orders_day_arguments.dart';
import '../../domain/arguments/orders_range_arguments.dart';
import '../../domain/usecases/get_accepted_applications_for_day_use_case.dart';
import '../../domain/usecases/get_orders_use_case.dart';
import 'view_orders_state.dart';
/// Cubit for managing the state of the View Orders feature.
///
/// This Cubit handles loading orders, date selection, and tab filtering.
/// Handles loading orders, date selection, and tab filtering.
/// V2 API returns workers inline so no separate applications fetch is needed.
class ViewOrdersCubit extends Cubit<ViewOrdersState>
with BlocErrorHandler<ViewOrdersState> {
/// Creates the cubit with the required use case.
ViewOrdersCubit({
required GetOrdersUseCase getOrdersUseCase,
required GetAcceptedApplicationsForDayUseCase getAcceptedAppsUseCase,
}) : _getOrdersUseCase = getOrdersUseCase,
_getAcceptedAppsUseCase = getAcceptedAppsUseCase,
super(ViewOrdersState(selectedDate: DateTime.now())) {
}) : _getOrdersUseCase = getOrdersUseCase,
super(ViewOrdersState(selectedDate: DateTime.now())) {
_init();
}
final GetOrdersUseCase _getOrdersUseCase;
final GetAcceptedApplicationsForDayUseCase _getAcceptedAppsUseCase;
int _requestId = 0;
void _init() {
updateWeekOffset(0); // Initialize calendar days
updateWeekOffset(0);
}
/// Loads orders for the given date range.
Future<void> _loadOrdersForRange({
required DateTime rangeStart,
required DateTime rangeEnd,
required DateTime dayForApps,
}) async {
final int requestId = ++_requestId;
emit(state.copyWith(status: ViewOrdersStatus.loading));
@@ -46,18 +43,13 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState>
final List<OrderItem> orders = await _getOrdersUseCase(
OrdersRangeArguments(start: rangeStart, end: rangeEnd),
);
final Map<String, List<Map<String, dynamic>>> apps =
await _getAcceptedAppsUseCase(OrdersDayArguments(day: dayForApps));
if (requestId != _requestId) {
return;
}
if (requestId != _requestId) return;
final List<OrderItem> updatedOrders = _applyApplications(orders, apps);
emit(
state.copyWith(
status: ViewOrdersStatus.success,
orders: updatedOrders,
orders: orders,
),
);
_updateDerivedState();
@@ -69,25 +61,28 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState>
);
}
/// Selects a date and refilters.
void selectDate(DateTime date) {
emit(state.copyWith(selectedDate: date));
_refreshAcceptedApplications(date);
_updateDerivedState();
}
/// Selects a filter tab and refilters.
void selectFilterTab(String tabId) {
emit(state.copyWith(filterTab: tabId));
_updateDerivedState();
}
/// Navigates the calendar by week offset.
void updateWeekOffset(int offset) {
final int newWeekOffset = state.weekOffset + offset;
final List<DateTime> calendarDays = _calculateCalendarDays(newWeekOffset);
final DateTime? selectedDate = state.selectedDate;
final DateTime updatedSelectedDate =
selectedDate != null &&
calendarDays.any((DateTime day) => _isSameDay(day, selectedDate))
? selectedDate
: calendarDays.first;
calendarDays.any((DateTime day) => _isSameDay(day, selectedDate))
? selectedDate
: calendarDays.first;
emit(
state.copyWith(
weekOffset: newWeekOffset,
@@ -99,10 +94,10 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState>
_loadOrdersForRange(
rangeStart: calendarDays.first,
rangeEnd: calendarDays.last,
dayForApps: updatedSelectedDate,
);
}
/// Jumps the calendar to a specific date.
void jumpToDate(DateTime date) {
final DateTime target = DateTime(date.year, date.month, date.day);
final DateTime startDate = _calculateCalendarDays(0).first;
@@ -121,14 +116,13 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState>
_loadOrdersForRange(
rangeStart: calendarDays.first,
rangeEnd: calendarDays.last,
dayForApps: target,
);
}
void _updateDerivedState() {
final List<OrderItem> filteredOrders = _calculateFilteredOrders(state);
final int activeCount = _calculateCategoryCount('active');
final int completedCount = _calculateCategoryCount('completed');
final int activeCount = _calculateCategoryCount(ShiftStatus.active);
final int completedCount = _calculateCategoryCount(ShiftStatus.completed);
final int upNextCount = _calculateUpNextCount();
emit(
@@ -141,64 +135,6 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState>
);
}
Future<void> _refreshAcceptedApplications(DateTime day) async {
await handleErrorWithResult(
action: () async {
final Map<String, List<Map<String, dynamic>>> apps =
await _getAcceptedAppsUseCase(OrdersDayArguments(day: day));
final List<OrderItem> updatedOrders = _applyApplications(
state.orders,
apps,
);
emit(state.copyWith(orders: updatedOrders));
_updateDerivedState();
},
onError: (_) {
// Keep existing data on failure, just log error via handleErrorWithResult
},
);
}
List<OrderItem> _applyApplications(
List<OrderItem> orders,
Map<String, List<Map<String, dynamic>>> apps,
) {
return orders.map((OrderItem order) {
final List<Map<String, dynamic>> confirmed =
apps[order.id] ?? const <Map<String, dynamic>>[];
if (confirmed.isEmpty) {
return order;
}
final int filled = confirmed.length;
final String status = filled >= order.workersNeeded
? 'FILLED'
: order.status;
return OrderItem(
id: order.id,
orderId: order.orderId,
orderType: order.orderType,
title: order.title,
eventName: order.eventName,
clientName: order.clientName,
status: status,
date: order.date,
startTime: order.startTime,
endTime: order.endTime,
location: order.location,
locationAddress: order.locationAddress,
filled: filled,
workersNeeded: order.workersNeeded,
hourlyRate: order.hourlyRate,
hours: order.hours,
totalValue: order.totalValue,
confirmedApps: confirmed,
hubManagerId: order.hubManagerId,
hubManagerName: order.hubManagerName,
);
}).toList();
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
@@ -218,103 +154,64 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState>
);
}
/// Filters orders for the selected date and tab.
List<OrderItem> _calculateFilteredOrders(ViewOrdersState state) {
if (state.selectedDate == null) return <OrderItem>[];
final String selectedDateStr = DateFormat(
'yyyy-MM-dd',
).format(state.selectedDate!);
final DateTime selectedDay = state.selectedDate!;
// Filter by date
final List<OrderItem> ordersOnDate = state.orders
.where((OrderItem s) => s.date == selectedDateStr)
.where((OrderItem s) => _isSameDay(s.date, selectedDay))
.toList();
// Sort by start time
ordersOnDate.sort(
(OrderItem a, OrderItem b) => a.startTime.compareTo(b.startTime),
(OrderItem a, OrderItem b) => a.startsAt.compareTo(b.startsAt),
);
if (state.filterTab == 'all') {
final List<OrderItem> filtered = ordersOnDate
return ordersOnDate
.where(
(OrderItem s) =>
// TODO(orders): move PENDING to its own tab once available.
<String>[
'OPEN',
'FILLED',
'CONFIRMED',
'PENDING',
'ASSIGNED',
].contains(s.status),
(OrderItem s) => <ShiftStatus>[
ShiftStatus.open,
ShiftStatus.pendingConfirmation,
ShiftStatus.assigned,
].contains(s.status),
)
.toList();
print(
'ViewOrders tab=all statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}',
);
return filtered;
} else if (state.filterTab == 'active') {
final List<OrderItem> filtered = ordersOnDate
return ordersOnDate
.where((OrderItem s) => s.status == ShiftStatus.active)
.toList();
print(
'ViewOrders tab=active statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}',
);
return filtered;
} else if (state.filterTab == 'completed') {
final List<OrderItem> filtered = ordersOnDate
return ordersOnDate
.where((OrderItem s) => s.status == ShiftStatus.completed)
.toList();
print(
'ViewOrders tab=completed statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}',
);
return filtered;
}
return <OrderItem>[];
}
int _calculateCategoryCount(String category) {
int _calculateCategoryCount(ShiftStatus targetStatus) {
if (state.selectedDate == null) return 0;
final String selectedDateStr = DateFormat(
'yyyy-MM-dd',
).format(state.selectedDate!);
if (category == 'active') {
return state.orders
.where(
(OrderItem s) =>
s.date == selectedDateStr && s.status == ShiftStatus.active,
)
.length;
} else if (category == 'completed') {
return state.orders
.where(
(OrderItem s) =>
s.date == selectedDateStr && s.status == ShiftStatus.completed,
)
.length;
}
return 0;
final DateTime selectedDay = state.selectedDate!;
return state.orders
.where(
(OrderItem s) =>
_isSameDay(s.date, selectedDay) && s.status == targetStatus,
)
.length;
}
int _calculateUpNextCount() {
if (state.selectedDate == null) return 0;
final String selectedDateStr = DateFormat(
'yyyy-MM-dd',
).format(state.selectedDate!);
final DateTime selectedDay = state.selectedDate!;
return state.orders
.where(
(OrderItem s) =>
s.date == selectedDateStr &&
<String>[
'OPEN',
'FILLED',
'CONFIRMED',
'PENDING',
'ASSIGNED',
_isSameDay(s.date, selectedDay) &&
<ShiftStatus>[
ShiftStatus.open,
ShiftStatus.pendingConfirmation,
ShiftStatus.assigned,
].contains(s.status),
)
.length;

View File

@@ -3,16 +3,12 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:url_launcher/url_launcher.dart';
import '../blocs/view_orders_cubit.dart';
/// A rich card displaying details of a client order/shift.
///
/// This widget complies with the KROW Design System by using
/// tokens from `package:design_system`.
import 'order_edit_sheet.dart';
/// A rich card displaying details of a V2 [OrderItem].
///
/// Uses DateTime-based fields and [AssignedWorkerSummary] workers list.
class ViewOrderCard extends StatefulWidget {
/// Creates a [ViewOrderCard] for the given [order].
const ViewOrderCard({required this.order, super.key});
@@ -41,18 +37,18 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
}
/// Returns the semantic color for the given status.
Color _getStatusColor({required String status}) {
Color _getStatusColor({required ShiftStatus status}) {
switch (status) {
case 'OPEN':
case ShiftStatus.open:
return UiColors.primary;
case 'FILLED':
case 'CONFIRMED':
case ShiftStatus.assigned:
case ShiftStatus.pendingConfirmation:
return UiColors.textSuccess;
case 'IN_PROGRESS':
case ShiftStatus.active:
return UiColors.textWarning;
case 'COMPLETED':
case ShiftStatus.completed:
return UiColors.primary;
case 'CANCELED':
case ShiftStatus.cancelled:
return UiColors.destructive;
default:
return UiColors.textSecondary;
@@ -60,41 +56,42 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
}
/// Returns the localized label for the given status.
String _getStatusLabel({required String status}) {
String _getStatusLabel({required ShiftStatus status}) {
switch (status) {
case 'OPEN':
case ShiftStatus.open:
return t.client_view_orders.card.open;
case 'FILLED':
case ShiftStatus.assigned:
return t.client_view_orders.card.filled;
case 'CONFIRMED':
case ShiftStatus.pendingConfirmation:
return t.client_view_orders.card.confirmed;
case 'IN_PROGRESS':
case ShiftStatus.active:
return t.client_view_orders.card.in_progress;
case 'COMPLETED':
case ShiftStatus.completed:
return t.client_view_orders.card.completed;
case 'CANCELED':
case ShiftStatus.cancelled:
return t.client_view_orders.card.cancelled;
default:
return status.toUpperCase();
return status.value.toUpperCase();
}
}
/// Formats the time string for display.
String _formatTime({required String timeStr}) {
if (timeStr.isEmpty) return '';
try {
final List<String> parts = timeStr.split(':');
int hour = int.parse(parts[0]);
final int minute = int.parse(parts[1]);
final String ampm = hour >= 12 ? 'PM' : 'AM';
hour = hour % 12;
if (hour == 0) hour = 12;
return '$hour:${minute.toString().padLeft(2, '0')} $ampm';
} catch (_) {
return timeStr;
}
/// Formats a [DateTime] to a display time string (e.g. "9:00 AM").
String _formatTime({required DateTime dateTime}) {
final DateTime local = dateTime.toLocal();
final int hour24 = local.hour;
final int minute = local.minute;
final String ampm = hour24 >= 12 ? 'PM' : 'AM';
int hour = hour24 % 12;
if (hour == 0) hour = 12;
return '$hour:${minute.toString().padLeft(2, '0')} $ampm';
}
/// Computes the duration in hours between start and end.
double _computeHours(OrderItem order) {
return order.endsAt.difference(order.startsAt).inMinutes / 60.0;
}
/// Returns the order type display label.
String _getOrderTypeLabel(OrderType type) {
switch (type) {
case OrderType.oneTime:
@@ -105,44 +102,16 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
return 'RECURRING';
case OrderType.rapid:
return 'RAPID';
case OrderType.unknown:
return 'ORDER';
}
}
/// Returns true if the edit icon should be shown.
/// Hidden for completed orders and for past orders (shift has ended).
bool _canEditOrder(OrderItem order) {
if (order.status == ShiftStatus.completed) return false;
if (order.date.isEmpty) return true;
try {
final DateTime orderDate = DateTime.parse(order.date);
final String endTime = order.endTime.trim();
final DateTime endDateTime;
if (endTime.isEmpty) {
// No end time: use end of day so orders today remain editable
endDateTime = DateTime(
orderDate.year,
orderDate.month,
orderDate.day,
23,
59,
59,
);
} else {
final List<String> endParts = endTime.split(':');
final int hour = endParts.isNotEmpty ? int.parse(endParts[0]) : 0;
final int minute = endParts.length > 1 ? int.parse(endParts[1]) : 0;
endDateTime = DateTime(
orderDate.year,
orderDate.month,
orderDate.day,
hour,
minute,
);
}
return endDateTime.isAfter(DateTime.now());
} catch (_) {
return true;
}
if (order.status == ShiftStatus.cancelled) return false;
return order.endsAt.isAfter(DateTime.now());
}
@override
@@ -150,12 +119,12 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
final OrderItem order = widget.order;
final Color statusColor = _getStatusColor(status: order.status);
final String statusLabel = _getStatusLabel(status: order.status);
final int coveragePercent = order.workersNeeded > 0
? ((order.filled / order.workersNeeded) * 100).round()
final int coveragePercent = order.requiredWorkerCount > 0
? ((order.filledCount / order.requiredWorkerCount) * 100).round()
: 0;
final double hours = order.hours;
final double cost = order.totalValue;
final double hours = _computeHours(order);
final double cost = order.totalCostCents / 100.0;
return Container(
decoration: BoxDecoration(
@@ -232,92 +201,28 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
],
),
const SizedBox(height: UiConstants.space3),
// Title
Text(order.title, style: UiTypography.headline3b),
Row(
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.calendarCheck,
size: 14,
color: UiColors.iconSecondary,
),
Expanded(
child: Text(
order.eventName,
style: UiTypography.headline5m.textSecondary,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: UiConstants.space4),
// Location (Hub name + Address)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Padding(
padding: EdgeInsets.only(top: 2),
child: Icon(
// Title (role name)
Text(order.roleName, style: UiTypography.headline3b),
if (order.locationName != null &&
order.locationName!.isNotEmpty)
Row(
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 14,
color: UiColors.iconSecondary,
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (order.location.isNotEmpty)
Text(
order.location,
style: UiTypography
.footnote1b
.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (order.locationAddress.isNotEmpty)
Text(
order.locationAddress,
style: UiTypography
.footnote2r
.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
if (order.hubManagerName != null) ...<Widget>[
const SizedBox(height: UiConstants.space2),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Padding(
padding: EdgeInsets.only(top: 2),
child: Icon(
UiIcons.user,
size: 14,
color: UiColors.iconSecondary,
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
order.hubManagerName!,
order.locationName!,
style:
UiTypography.footnote2r.textSecondary,
maxLines: 1,
UiTypography.headline5m.textSecondary,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),
@@ -334,7 +239,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
),
if (_canEditOrder(order))
const SizedBox(width: UiConstants.space2),
if (order.confirmedApps.isNotEmpty)
if (order.workers.isNotEmpty)
_buildHeaderIconButton(
icon: _expanded
? UiIcons.chevronUp
@@ -374,7 +279,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
_buildStatDivider(),
_buildStatItem(
icon: UiIcons.users,
value: '${order.workersNeeded}',
value: '${order.requiredWorkerCount}',
label: t.client_create_order.one_time.workers_label,
),
],
@@ -389,14 +294,14 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
Expanded(
child: _buildTimeDisplay(
label: t.client_view_orders.card.clock_in,
time: _formatTime(timeStr: order.startTime),
time: _formatTime(dateTime: order.startsAt),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildTimeDisplay(
label: t.client_view_orders.card.clock_out,
time: _formatTime(timeStr: order.endTime),
time: _formatTime(dateTime: order.endsAt),
),
),
],
@@ -405,7 +310,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
const SizedBox(height: UiConstants.space4),
// Coverage Section
if (order.status != 'completed') ...<Widget>[
if (order.status != ShiftStatus.completed) ...<Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
@@ -428,7 +333,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
coveragePercent == 100
? t.client_view_orders.card.all_confirmed
: t.client_view_orders.card.workers_needed(
count: order.workersNeeded,
count: order.requiredWorkerCount,
),
style: UiTypography.body2m.textPrimary,
),
@@ -456,17 +361,17 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
),
// Avatar Stack Preview (if not expanded)
if (!_expanded && order.confirmedApps.isNotEmpty) ...<Widget>[
if (!_expanded && order.workers.isNotEmpty) ...<Widget>[
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
_buildAvatarStack(order.confirmedApps),
if (order.confirmedApps.length > 3)
_buildAvatarStack(order.workers),
if (order.workers.length > 3)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(
t.client_view_orders.card.show_more_workers(
count: order.confirmedApps.length - 3,
count: order.workers.length - 3,
),
style: UiTypography.footnote2r.textSecondary,
),
@@ -480,7 +385,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
),
// Assigned Workers (Expanded section)
if (_expanded && order.confirmedApps.isNotEmpty) ...<Widget>[
if (_expanded && order.workers.isNotEmpty) ...<Widget>[
Container(
decoration: const BoxDecoration(
color: UiColors.bgSecondary,
@@ -512,10 +417,12 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
],
),
const SizedBox(height: UiConstants.space4),
...order.confirmedApps
...order.workers
.take(5)
.map((Map<String, dynamic> app) => _buildWorkerRow(app)),
if (order.confirmedApps.length > 5)
.map(
(AssignedWorkerSummary w) => _buildWorkerRow(w),
),
if (order.workers.length > 5)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
@@ -523,7 +430,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
onPressed: () {},
child: Text(
t.client_view_orders.card.show_more_workers(
count: order.confirmedApps.length - 5,
count: order.workers.length - 5,
),
style: UiTypography.body2m.copyWith(
color: UiColors.primary,
@@ -541,10 +448,12 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
);
}
/// Builds a stat divider.
Widget _buildStatDivider() {
return Container(width: 1, height: 24, color: UiColors.border);
}
/// Builds a time display box.
Widget _buildTimeDisplay({required String label, required String time}) {
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
@@ -565,11 +474,11 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
);
}
/// Builds a stacked avatar UI for a list of applications.
Widget _buildAvatarStack(List<Map<String, dynamic>> apps) {
/// Builds a stacked avatar UI for assigned workers.
Widget _buildAvatarStack(List<AssignedWorkerSummary> workers) {
const double size = 32.0;
const double overlap = 22.0;
final int count = apps.length > 3 ? 3 : apps.length;
final int count = workers.length > 3 ? 3 : workers.length;
return SizedBox(
height: size,
@@ -589,7 +498,9 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
),
child: Center(
child: Text(
(apps[i]['worker_name'] as String)[0],
(workers[i].workerName ?? '').isNotEmpty
? (workers[i].workerName ?? '?')[0]
: '?',
style: UiTypography.footnote2b.copyWith(
color: UiColors.primary,
),
@@ -603,8 +514,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
}
/// Builds a detailed row for a worker.
Widget _buildWorkerRow(Map<String, dynamic> app) {
final String? phone = app['phone'] as String?;
Widget _buildWorkerRow(AssignedWorkerSummary worker) {
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space3),
@@ -618,7 +528,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
CircleAvatar(
backgroundColor: UiColors.primary.withValues(alpha: 0.1),
child: Text(
(app['worker_name'] as String)[0],
(worker.workerName ?? '').isNotEmpty ? (worker.workerName ?? '?')[0] : '?',
style: UiTypography.body1b.copyWith(color: UiColors.primary),
),
),
@@ -628,129 +538,35 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
app['worker_name'] as String,
worker.workerName ?? '',
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space1 / 2),
Row(
children: <Widget>[
if ((app['rating'] as num?) != null &&
(app['rating'] as num) > 0) ...<Widget>[
const Icon(
UiIcons.star,
size: 10,
color: UiColors.accent,
if (worker.confirmationStatus != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusSm,
),
child: Text(
worker.confirmationStatus!.value.toUpperCase(),
style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textSecondary,
),
const SizedBox(width: 2),
Text(
(app['rating'] as num).toStringAsFixed(1),
style: UiTypography.footnote2r.textSecondary,
),
],
if (app['check_in_time'] != null) ...<Widget>[
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: UiColors.textSuccess.withValues(alpha: 0.1),
borderRadius: UiConstants.radiusSm,
),
child: Text(
t.client_view_orders.card.checked_in,
style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textSuccess,
),
),
),
] else if ((app['status'] as String?)?.isNotEmpty ??
false) ...<Widget>[
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusSm,
),
child: Text(
(app['status'] as String).toUpperCase(),
style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textSecondary,
),
),
),
],
],
),
),
),
],
),
),
if (phone != null && phone.isNotEmpty) ...<Widget>[
_buildActionIconButton(
icon: UiIcons.phone,
onTap: () => _confirmAndCall(phone),
),
],
],
),
);
}
Future<void> _confirmAndCall(String phone) async {
final bool? shouldCall = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(t.client_view_orders.card.call_dialog.title),
content: Text(
t.client_view_orders.card.call_dialog.message(phone: phone),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(t.client_view_orders.card.call_dialog.title),
),
],
);
},
);
if (shouldCall != true) {
return;
}
final Uri uri = Uri(scheme: 'tel', path: phone);
await launchUrl(uri);
}
/// Specialized action button for worker rows.
Widget _buildActionIconButton({
required IconData icon,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: BorderRadius.circular(UiConstants.space2),
),
child: Icon(icon, size: 16, color: UiColors.primary),
),
);
}
/// Builds a small icon button used in row headers.
Widget _buildHeaderIconButton({
required IconData icon,
@@ -771,7 +587,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
);
}
/// Builds a single stat item (e.g., Cost, Hours, Workers).
/// Builds a single stat item.
Widget _buildStatItem({
required IconData icon,
required String value,

View File

@@ -2,7 +2,6 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
@@ -49,6 +48,13 @@ class ViewOrdersEmptyState extends StatelessWidget {
if (checkDate == today) return 'Today';
if (checkDate == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
const List<String> weekdays = <String>[
'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun',
];
const List<String> months = <String>[
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
return '${weekdays[date.weekday - 1]}, ${months[date.month - 1]} ${date.day}';
}
}

View File

@@ -3,7 +3,6 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -32,6 +31,25 @@ class ViewOrdersHeader extends StatelessWidget {
/// The list of calendar days to display.
final List<DateTime> calendarDays;
static const List<String> _months = <String>[
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
static const List<String> _weekdays = <String>[
'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun',
];
/// Formats a date as "Month YYYY".
static String _formatMonthYear(DateTime date) {
return '${_months[date.month - 1]} ${date.year}';
}
/// Returns the abbreviated weekday name.
static String _weekdayAbbr(int weekday) {
return _weekdays[weekday - 1];
}
@override
Widget build(BuildContext context) {
return ClipRect(
@@ -133,7 +151,7 @@ class ViewOrdersHeader extends StatelessWidget {
splashRadius: UiConstants.iconMd,
),
Text(
DateFormat('MMMM yyyy').format(calendarDays.first),
_formatMonthYear(calendarDays.first),
style: UiTypography.body2m.copyWith(
color: UiColors.textSecondary,
),
@@ -175,11 +193,11 @@ class ViewOrdersHeader extends StatelessWidget {
date.day == state.selectedDate!.day;
// Check if this date has any shifts
final String dateStr = DateFormat(
'yyyy-MM-dd',
).format(date);
final bool hasShifts = state.orders.any(
(OrderItem s) => s.date == dateStr,
(OrderItem s) =>
s.date.year == date.year &&
s.date.month == date.month &&
s.date.day == date.day,
);
// Check if date is in the past
@@ -221,7 +239,7 @@ class ViewOrdersHeader extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('dd').format(date),
date.day.toString().padLeft(2, '0'),
style: UiTypography.title1m.copyWith(
fontWeight: FontWeight.bold,
color: isSelected
@@ -230,7 +248,7 @@ class ViewOrdersHeader extends StatelessWidget {
),
),
Text(
DateFormat('E').format(date),
_weekdayAbbr(date.weekday),
style: UiTypography.footnote2m.copyWith(
color: isSelected
? UiColors.white.withValues(alpha: 0.8)

View File

@@ -1,31 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import 'data/repositories/view_orders_repository_impl.dart';
import 'domain/repositories/i_view_orders_repository.dart';
import 'domain/usecases/get_accepted_applications_for_day_use_case.dart';
import 'domain/usecases/get_orders_use_case.dart';
import 'presentation/blocs/view_orders_cubit.dart';
import 'presentation/pages/view_orders_page.dart';
/// Module for the View Orders feature.
///
/// This module sets up Dependency Injection for repositories, use cases,
/// and BLoCs, and defines the feature's navigation routes.
/// Sets up DI for repositories, use cases, and BLoCs, and defines routes.
/// Uses [CoreModule] for [BaseApiService] injection (V2 API).
class ViewOrdersModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.add<IViewOrdersRepository>(ViewOrdersRepositoryImpl.new);
i.add<IViewOrdersRepository>(
() => ViewOrdersRepositoryImpl(
apiService: i.get<BaseApiService>(),
),
);
// UseCases
i.add(GetOrdersUseCase.new);
i.add(GetAcceptedApplicationsForDayUseCase.new);
// BLoCs
i.addLazySingleton(ViewOrdersCubit.new);
@@ -38,11 +40,11 @@ class ViewOrdersModule extends Module {
child: (BuildContext context) {
final Object? args = Modular.args.data;
DateTime? initialDate;
// Try parsing from args.data first
if (args is DateTime) {
initialDate = args;
} else if (args is Map<String, dynamic> && args['initialDate'] != null) {
} else if (args is Map<String, dynamic> &&
args['initialDate'] != null) {
final Object? rawDate = args['initialDate'];
if (rawDate is DateTime) {
initialDate = rawDate;
@@ -50,15 +52,14 @@ class ViewOrdersModule extends Module {
initialDate = DateTime.tryParse(rawDate);
}
}
// Fallback to query params
if (initialDate == null) {
final String? queryDate = Modular.args.queryParams['initialDate'];
if (queryDate != null && queryDate.isNotEmpty) {
initialDate = DateTime.tryParse(queryDate);
}
}
return ViewOrdersPage(initialDate: initialDate);
},
);

View File

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