Merge branch 'origin/dev' into feature/session-persistence-new
This commit is contained in:
@@ -161,6 +161,7 @@ class _CoverageShiftListState extends State<CoverageShiftList> {
|
||||
children: <Widget>[
|
||||
ShiftHeader(
|
||||
title: shift.roleName,
|
||||
locationName: shift.locationName,
|
||||
startTime: _formatTime(shift.timeRange.startsAt),
|
||||
current: shift.assignedWorkerCount,
|
||||
total: shift.requiredWorkerCount,
|
||||
@@ -226,9 +227,10 @@ class _CoverageShiftListState extends State<CoverageShiftList> {
|
||||
worker: worker,
|
||||
shiftStartTime: _formatTime(shift.timeRange.startsAt),
|
||||
showRateButton:
|
||||
worker.status == AssignmentStatus.checkedIn ||
|
||||
worker.status == AssignmentStatus.checkedOut ||
|
||||
worker.status == AssignmentStatus.completed,
|
||||
!worker.hasReview &&
|
||||
(worker.status == AssignmentStatus.checkedIn ||
|
||||
worker.status == AssignmentStatus.checkedOut ||
|
||||
worker.status == AssignmentStatus.completed),
|
||||
showCancelButton:
|
||||
DateTime.now().isAfter(shift.timeRange.startsAt) &&
|
||||
(worker.status == AssignmentStatus.noShow ||
|
||||
|
||||
@@ -21,6 +21,7 @@ class ShiftHeader extends StatelessWidget {
|
||||
required this.lateCount,
|
||||
required this.isExpanded,
|
||||
required this.onToggle,
|
||||
this.locationName,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -57,6 +58,9 @@ class ShiftHeader extends StatelessWidget {
|
||||
/// Callback invoked when the header is tapped to expand or collapse.
|
||||
final VoidCallback onToggle;
|
||||
|
||||
/// Optional location or hub name for the shift.
|
||||
final String? locationName;
|
||||
|
||||
/// Returns the status colour based on [coveragePercent].
|
||||
///
|
||||
/// Green for >= 100 %, yellow for >= 80 %, red otherwise.
|
||||
@@ -110,6 +114,29 @@ class ShiftHeader extends StatelessWidget {
|
||||
title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
if (locationName != null &&
|
||||
locationName!.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 10,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locationName!,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
|
||||
@@ -75,7 +75,7 @@ class ReorderWidget extends StatelessWidget {
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.building,
|
||||
UiIcons.briefcase,
|
||||
size: 16,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
@@ -104,18 +104,6 @@ class ReorderWidget extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.end,
|
||||
// children: <Widget>[
|
||||
// // ASSUMPTION: No i18n key for 'positions' under
|
||||
// // reorder section — carrying forward existing
|
||||
// // hardcoded string pattern for this migration.
|
||||
// Text(
|
||||
// '${order.positionCount} positions',
|
||||
// style: UiTypography.footnote2r.textSecondary,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
@@ -130,7 +118,7 @@ class ReorderWidget extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_Badge(
|
||||
icon: UiIcons.building,
|
||||
icon: UiIcons.users,
|
||||
text: '${order.positionCount}',
|
||||
color: UiColors.textSecondary,
|
||||
bg: UiColors.buttonSecondaryStill,
|
||||
|
||||
@@ -10,6 +10,7 @@ class OneTimeOrderPositionArgument extends UseCaseArgument {
|
||||
required this.endTime,
|
||||
this.roleName,
|
||||
this.lunchBreak,
|
||||
this.hourlyRateCents,
|
||||
});
|
||||
|
||||
/// The role ID for this position.
|
||||
@@ -30,9 +31,19 @@ class OneTimeOrderPositionArgument extends UseCaseArgument {
|
||||
/// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set.
|
||||
final String? lunchBreak;
|
||||
|
||||
/// Hourly rate in cents for this position, if set.
|
||||
final int? hourlyRateCents;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
<Object?>[roleId, roleName, workerCount, startTime, endTime, lunchBreak];
|
||||
List<Object?> get props => <Object?>[
|
||||
roleId,
|
||||
roleName,
|
||||
workerCount,
|
||||
startTime,
|
||||
endTime,
|
||||
lunchBreak,
|
||||
hourlyRateCents,
|
||||
];
|
||||
}
|
||||
|
||||
/// Typed arguments for [CreateOneTimeOrderUseCase].
|
||||
@@ -63,6 +74,40 @@ class OneTimeOrderArguments extends UseCaseArgument {
|
||||
/// The selected vendor ID, if applicable.
|
||||
final String? vendorId;
|
||||
|
||||
/// Serialises these arguments into the V2 API payload shape.
|
||||
///
|
||||
/// Times and dates are converted to UTC so the backend's
|
||||
/// `combineDateAndTime` helper receives the correct values.
|
||||
Map<String, dynamic> toJson() {
|
||||
final String firstStartTime =
|
||||
positions.isNotEmpty ? positions.first.startTime : '00:00';
|
||||
final String utcOrderDate = toUtcDateIso(orderDate, firstStartTime);
|
||||
|
||||
final List<Map<String, dynamic>> positionsList =
|
||||
positions.map((OneTimeOrderPositionArgument p) {
|
||||
return <String, dynamic>{
|
||||
if (p.roleName != null) 'roleName': p.roleName,
|
||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||
'workerCount': p.workerCount,
|
||||
'startTime': toUtcTimeHHmm(orderDate, p.startTime),
|
||||
'endTime': toUtcTimeHHmm(orderDate, p.endTime),
|
||||
if (p.lunchBreak != null &&
|
||||
p.lunchBreak != 'NO_BREAK' &&
|
||||
p.lunchBreak!.isNotEmpty)
|
||||
'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!),
|
||||
if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return <String, dynamic>{
|
||||
'hubId': hubId,
|
||||
'eventName': eventName,
|
||||
'orderDate': utcOrderDate,
|
||||
'positions': positionsList,
|
||||
if (vendorId != null) 'vendorId': vendorId,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
<Object?>[hubId, eventName, orderDate, positions, vendorId];
|
||||
|
||||
@@ -9,6 +9,7 @@ class PermanentOrderPositionArgument extends UseCaseArgument {
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.roleName,
|
||||
this.hourlyRateCents,
|
||||
});
|
||||
|
||||
/// The role ID for this position.
|
||||
@@ -26,9 +27,18 @@ class PermanentOrderPositionArgument extends UseCaseArgument {
|
||||
/// Shift end time in HH:mm format.
|
||||
final String endTime;
|
||||
|
||||
/// Hourly rate in cents for this position, if set.
|
||||
final int? hourlyRateCents;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
<Object?>[roleId, roleName, workerCount, startTime, endTime];
|
||||
List<Object?> get props => <Object?>[
|
||||
roleId,
|
||||
roleName,
|
||||
workerCount,
|
||||
startTime,
|
||||
endTime,
|
||||
hourlyRateCents,
|
||||
];
|
||||
}
|
||||
|
||||
/// Typed arguments for [CreatePermanentOrderUseCase].
|
||||
@@ -63,6 +73,52 @@ class PermanentOrderArguments extends UseCaseArgument {
|
||||
/// The selected vendor ID, if applicable.
|
||||
final String? vendorId;
|
||||
|
||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Serialises these arguments into the V2 API payload shape.
|
||||
///
|
||||
/// Times and dates are converted to UTC so the backend's
|
||||
/// `combineDateAndTime` helper receives the correct values.
|
||||
Map<String, dynamic> toJson() {
|
||||
final String firstStartTime =
|
||||
positions.isNotEmpty ? positions.first.startTime : '00:00';
|
||||
final String utcStartDate = toUtcDateIso(startDate, firstStartTime);
|
||||
|
||||
final List<int> daysOfWeekList = daysOfWeek
|
||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||
.toList();
|
||||
|
||||
final List<Map<String, dynamic>> positionsList =
|
||||
positions.map((PermanentOrderPositionArgument p) {
|
||||
return <String, dynamic>{
|
||||
if (p.roleName != null) 'roleName': p.roleName,
|
||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||
'workerCount': p.workerCount,
|
||||
'startTime': toUtcTimeHHmm(startDate, p.startTime),
|
||||
'endTime': toUtcTimeHHmm(startDate, p.endTime),
|
||||
if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return <String, dynamic>{
|
||||
'hubId': hubId,
|
||||
'eventName': eventName,
|
||||
'startDate': utcStartDate,
|
||||
'daysOfWeek': daysOfWeekList,
|
||||
'positions': positionsList,
|
||||
if (vendorId != null) 'vendorId': vendorId,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
hubId,
|
||||
|
||||
@@ -9,6 +9,7 @@ class RecurringOrderPositionArgument extends UseCaseArgument {
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.roleName,
|
||||
this.hourlyRateCents,
|
||||
});
|
||||
|
||||
/// The role ID for this position.
|
||||
@@ -26,9 +27,18 @@ class RecurringOrderPositionArgument extends UseCaseArgument {
|
||||
/// Shift end time in HH:mm format.
|
||||
final String endTime;
|
||||
|
||||
/// Hourly rate in cents for this position, if set.
|
||||
final int? hourlyRateCents;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
<Object?>[roleId, roleName, workerCount, startTime, endTime];
|
||||
List<Object?> get props => <Object?>[
|
||||
roleId,
|
||||
roleName,
|
||||
workerCount,
|
||||
startTime,
|
||||
endTime,
|
||||
hourlyRateCents,
|
||||
];
|
||||
}
|
||||
|
||||
/// Typed arguments for [CreateRecurringOrderUseCase].
|
||||
@@ -67,6 +77,54 @@ class RecurringOrderArguments extends UseCaseArgument {
|
||||
/// The selected vendor ID, if applicable.
|
||||
final String? vendorId;
|
||||
|
||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Serialises these arguments into the V2 API payload shape.
|
||||
///
|
||||
/// Times and dates are converted to UTC so the backend's
|
||||
/// `combineDateAndTime` helper receives the correct values.
|
||||
Map<String, dynamic> toJson() {
|
||||
final String firstStartTime =
|
||||
positions.isNotEmpty ? positions.first.startTime : '00:00';
|
||||
final String utcStartDate = toUtcDateIso(startDate, firstStartTime);
|
||||
final String utcEndDate = toUtcDateIso(endDate, firstStartTime);
|
||||
|
||||
final List<int> recurrenceDaysList = recurringDays
|
||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||
.toList();
|
||||
|
||||
final List<Map<String, dynamic>> positionsList =
|
||||
positions.map((RecurringOrderPositionArgument p) {
|
||||
return <String, dynamic>{
|
||||
if (p.roleName != null) 'roleName': p.roleName,
|
||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||
'workerCount': p.workerCount,
|
||||
'startTime': toUtcTimeHHmm(startDate, p.startTime),
|
||||
'endTime': toUtcTimeHHmm(startDate, p.endTime),
|
||||
if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return <String, dynamic>{
|
||||
'hubId': hubId,
|
||||
'eventName': eventName,
|
||||
'startDate': utcStartDate,
|
||||
'endDate': utcEndDate,
|
||||
'recurrenceDays': recurrenceDaysList,
|
||||
'positions': positionsList,
|
||||
if (vendorId != null) 'vendorId': vendorId,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
hubId,
|
||||
|
||||
@@ -1,49 +1,19 @@
|
||||
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.
|
||||
///
|
||||
/// Builds the V2 API payload from typed [OneTimeOrderArguments] and
|
||||
/// delegates submission to the repository. Payload construction (date
|
||||
/// formatting, position mapping, break-minutes conversion) is business
|
||||
/// logic that belongs here, not in the BLoC.
|
||||
class CreateOneTimeOrderUseCase
|
||||
implements UseCase<OneTimeOrderArguments, void> {
|
||||
/// Delegates payload construction to [OneTimeOrderArguments.toJson] and
|
||||
/// submission to the repository.
|
||||
class CreateOneTimeOrderUseCase {
|
||||
/// Creates a [CreateOneTimeOrderUseCase].
|
||||
const CreateOneTimeOrderUseCase(this._repository);
|
||||
|
||||
/// The create-order repository.
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
/// Creates a one-time order from the given arguments.
|
||||
Future<void> call(OneTimeOrderArguments input) {
|
||||
final String orderDate = formatDateToIso(input.orderDate);
|
||||
|
||||
final List<Map<String, dynamic>> positions =
|
||||
input.positions.map((OneTimeOrderPositionArgument p) {
|
||||
return <String, dynamic>{
|
||||
if (p.roleName != null) 'roleName': p.roleName,
|
||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||
'workerCount': p.workerCount,
|
||||
'startTime': p.startTime,
|
||||
'endTime': p.endTime,
|
||||
if (p.lunchBreak != null &&
|
||||
p.lunchBreak != 'NO_BREAK' &&
|
||||
p.lunchBreak!.isNotEmpty)
|
||||
'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final Map<String, dynamic> payload = <String, dynamic>{
|
||||
'hubId': input.hubId,
|
||||
'eventName': input.eventName,
|
||||
'orderDate': orderDate,
|
||||
'positions': positions,
|
||||
if (input.vendorId != null) 'vendorId': input.vendorId,
|
||||
};
|
||||
|
||||
return _repository.createOneTimeOrder(payload);
|
||||
return _repository.createOneTimeOrder(input.toJson());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../arguments/permanent_order_arguments.dart';
|
||||
import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||
const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Use case for creating a permanent staffing order.
|
||||
///
|
||||
/// Builds the V2 API payload from typed [PermanentOrderArguments] and
|
||||
/// delegates submission to the repository. Payload construction (date
|
||||
/// formatting, day-of-week mapping, position mapping) is business
|
||||
/// logic that belongs here, not in the BLoC.
|
||||
class CreatePermanentOrderUseCase
|
||||
implements UseCase<PermanentOrderArguments, void> {
|
||||
/// Delegates payload construction to [PermanentOrderArguments.toJson] and
|
||||
/// submission to the repository.
|
||||
class CreatePermanentOrderUseCase {
|
||||
/// Creates a [CreatePermanentOrderUseCase].
|
||||
const CreatePermanentOrderUseCase(this._repository);
|
||||
|
||||
/// The create-order repository.
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
/// Creates a permanent order from the given arguments.
|
||||
Future<void> call(PermanentOrderArguments input) {
|
||||
final String startDate = formatDateToIso(input.startDate);
|
||||
|
||||
final List<int> daysOfWeek = input.daysOfWeek
|
||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||
.toList();
|
||||
|
||||
final List<Map<String, dynamic>> positions =
|
||||
input.positions.map((PermanentOrderPositionArgument p) {
|
||||
return <String, dynamic>{
|
||||
if (p.roleName != null) 'roleName': p.roleName,
|
||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||
'workerCount': p.workerCount,
|
||||
'startTime': p.startTime,
|
||||
'endTime': p.endTime,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final Map<String, dynamic> payload = <String, dynamic>{
|
||||
'hubId': input.hubId,
|
||||
'eventName': input.eventName,
|
||||
'startDate': startDate,
|
||||
'daysOfWeek': daysOfWeek,
|
||||
'positions': positions,
|
||||
if (input.vendorId != null) 'vendorId': input.vendorId,
|
||||
};
|
||||
|
||||
return _repository.createPermanentOrder(payload);
|
||||
return _repository.createPermanentOrder(input.toJson());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../arguments/recurring_order_arguments.dart';
|
||||
import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||
const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Use case for creating a recurring staffing order.
|
||||
///
|
||||
/// Builds the V2 API payload from typed [RecurringOrderArguments] and
|
||||
/// delegates submission to the repository. Payload construction (date
|
||||
/// formatting, recurrence-day mapping, position mapping) is business
|
||||
/// logic that belongs here, not in the BLoC.
|
||||
class CreateRecurringOrderUseCase
|
||||
implements UseCase<RecurringOrderArguments, void> {
|
||||
/// Delegates payload construction to [RecurringOrderArguments.toJson] and
|
||||
/// submission to the repository.
|
||||
class CreateRecurringOrderUseCase {
|
||||
/// Creates a [CreateRecurringOrderUseCase].
|
||||
const CreateRecurringOrderUseCase(this._repository);
|
||||
|
||||
/// The create-order repository.
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
/// Creates a recurring order from the given arguments.
|
||||
Future<void> call(RecurringOrderArguments input) {
|
||||
final String startDate = formatDateToIso(input.startDate);
|
||||
final String endDate = formatDateToIso(input.endDate);
|
||||
|
||||
final List<int> recurrenceDays = input.recurringDays
|
||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||
.toList();
|
||||
|
||||
final List<Map<String, dynamic>> positions =
|
||||
input.positions.map((RecurringOrderPositionArgument p) {
|
||||
return <String, dynamic>{
|
||||
if (p.roleName != null) 'roleName': p.roleName,
|
||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||
'workerCount': p.workerCount,
|
||||
'startTime': p.startTime,
|
||||
'endTime': p.endTime,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final Map<String, dynamic> payload = <String, dynamic>{
|
||||
'hubId': input.hubId,
|
||||
'eventName': input.eventName,
|
||||
'startDate': startDate,
|
||||
'endDate': endDate,
|
||||
'recurrenceDays': recurrenceDays,
|
||||
'positions': positions,
|
||||
if (input.vendorId != null) 'vendorId': input.vendorId,
|
||||
};
|
||||
|
||||
return _repository.createRecurringOrder(payload);
|
||||
return _repository.createRecurringOrder(input.toJson());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,8 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
lunchBreak: p.lunchBreak,
|
||||
hourlyRateCents:
|
||||
role != null ? (role.costPerHour * 100).round() : null,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
|
||||
@@ -360,6 +360,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
workerCount: p.count,
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
hourlyRateCents:
|
||||
role != null ? (role.costPerHour * 100).round() : null,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
|
||||
@@ -380,6 +380,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
workerCount: p.count,
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
hourlyRateCents:
|
||||
role != null ? (role.costPerHour * 100).round() : null,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ class ViewOrdersRepositoryImpl implements ViewOrdersRepositoryInterface {
|
||||
final ApiResponse response = await _api.get(
|
||||
ClientEndpoints.ordersView,
|
||||
params: <String, dynamic>{
|
||||
'startDate': start.toIso8601String(),
|
||||
'endDate': end.toIso8601String(),
|
||||
'startDate': start.toUtc().toIso8601String(),
|
||||
'endDate': end.toUtc().toIso8601String(),
|
||||
},
|
||||
);
|
||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||
|
||||
@@ -48,13 +48,13 @@ class OrderEditSheetState extends State<OrderEditSheet> {
|
||||
_orderNameController = TextEditingController(text: widget.order.roleName);
|
||||
|
||||
final String startHH =
|
||||
widget.order.startsAt.toLocal().hour.toString().padLeft(2, '0');
|
||||
widget.order.startsAt.hour.toString().padLeft(2, '0');
|
||||
final String startMM =
|
||||
widget.order.startsAt.toLocal().minute.toString().padLeft(2, '0');
|
||||
widget.order.startsAt.minute.toString().padLeft(2, '0');
|
||||
final String endHH =
|
||||
widget.order.endsAt.toLocal().hour.toString().padLeft(2, '0');
|
||||
widget.order.endsAt.hour.toString().padLeft(2, '0');
|
||||
final String endMM =
|
||||
widget.order.endsAt.toLocal().minute.toString().padLeft(2, '0');
|
||||
widget.order.endsAt.minute.toString().padLeft(2, '0');
|
||||
|
||||
_positions = <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
|
||||
@@ -77,9 +77,8 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
||||
|
||||
/// 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 int hour24 = dateTime.hour;
|
||||
final int minute = dateTime.minute;
|
||||
final String ampm = hour24 >= 12 ? 'PM' : 'AM';
|
||||
int hour = hour24 % 12;
|
||||
if (hour == 0) hour = 12;
|
||||
@@ -124,7 +123,9 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
||||
: 0;
|
||||
|
||||
final double hours = _computeHours(order);
|
||||
final double cost = order.totalCostCents / 100.0;
|
||||
final double cost = order.totalValue > 0
|
||||
? order.totalValue
|
||||
: order.totalCostCents / 100.0;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -229,12 +229,20 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
event: event,
|
||||
activeShiftId: newStatus.activeShiftId,
|
||||
);
|
||||
} on AppException catch (_) {
|
||||
// The clock-in API call failed. Re-fetch attendance status to
|
||||
// reconcile: if the worker is already clocked in (e.g. duplicate
|
||||
// session from Postgres constraint 23505), treat it as success.
|
||||
} on AppException catch (e) {
|
||||
// The backend returns 409 ALREADY_CLOCKED_IN when the worker has
|
||||
// an active attendance session. This is a normal idempotency
|
||||
// signal — re-fetch the authoritative status and emit success
|
||||
// without surfacing an error snackbar.
|
||||
final bool isAlreadyClockedIn =
|
||||
e is ApiException && e.apiCode == 'ALREADY_CLOCKED_IN';
|
||||
|
||||
// Re-fetch attendance status to reconcile local state with
|
||||
// the backend (handles both ALREADY_CLOCKED_IN and legacy
|
||||
// Postgres constraint 23505 duplicates).
|
||||
final AttendanceStatus currentStatus = await _getAttendanceStatus();
|
||||
if (currentStatus.isClockedIn) {
|
||||
|
||||
if (isAlreadyClockedIn || currentStatus.isClockedIn) {
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
attendance: currentStatus,
|
||||
|
||||
@@ -31,6 +31,15 @@ class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
||||
return ProfileSectionStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StaffReliabilityStats> getReliabilityStats() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(StaffEndpoints.profileStats);
|
||||
final Map<String, dynamic> json =
|
||||
response.data as Map<String, dynamic>;
|
||||
return StaffReliabilityStats.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
await _api.post(AuthEndpoints.signOut);
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
/// Abstract interface for the staff profile repository.
|
||||
///
|
||||
/// Defines the contract for fetching staff profile data,
|
||||
/// section completion statuses, and signing out.
|
||||
/// section completion statuses, reliability stats, and signing out.
|
||||
abstract interface class ProfileRepositoryInterface {
|
||||
/// Fetches the staff profile from the backend.
|
||||
Future<Staff> getStaffProfile();
|
||||
@@ -11,6 +11,9 @@ abstract interface class ProfileRepositoryInterface {
|
||||
/// Fetches the profile section completion statuses.
|
||||
Future<ProfileSectionStatus> getProfileSections();
|
||||
|
||||
/// Fetches reliability and performance statistics for the staff member.
|
||||
Future<StaffReliabilityStats> getReliabilityStats();
|
||||
|
||||
/// Signs out the current user.
|
||||
Future<void> signOut();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
|
||||
|
||||
/// Use case for retrieving the staff member's reliability statistics.
|
||||
class GetReliabilityStatsUseCase
|
||||
implements NoInputUseCase<StaffReliabilityStats> {
|
||||
/// Creates a [GetReliabilityStatsUseCase] with the required [repository].
|
||||
GetReliabilityStatsUseCase(this._repository);
|
||||
|
||||
final ProfileRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<StaffReliabilityStats> call() {
|
||||
return _repository.getReliabilityStats();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart';
|
||||
import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart';
|
||||
import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart';
|
||||
import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart';
|
||||
import 'package:staff_profile/src/presentation/blocs/profile_state.dart';
|
||||
@@ -10,21 +11,24 @@ import 'package:staff_profile/src/presentation/blocs/profile_state.dart';
|
||||
/// Cubit for managing the Profile feature state.
|
||||
///
|
||||
/// Delegates all data fetching to use cases, following Clean Architecture.
|
||||
/// Loads the staff profile and section completion statuses in a single flow.
|
||||
/// Loads the staff profile, section statuses, and reliability stats.
|
||||
class ProfileCubit extends Cubit<ProfileState>
|
||||
with BlocErrorHandler<ProfileState> {
|
||||
/// Creates a [ProfileCubit] with the required use cases.
|
||||
ProfileCubit({
|
||||
required GetStaffProfileUseCase getStaffProfileUseCase,
|
||||
required GetProfileSectionsUseCase getProfileSectionsUseCase,
|
||||
required GetReliabilityStatsUseCase getReliabilityStatsUseCase,
|
||||
required SignOutUseCase signOutUseCase,
|
||||
}) : _getStaffProfileUseCase = getStaffProfileUseCase,
|
||||
_getProfileSectionsUseCase = getProfileSectionsUseCase,
|
||||
_getReliabilityStatsUseCase = getReliabilityStatsUseCase,
|
||||
_signOutUseCase = signOutUseCase,
|
||||
super(const ProfileState());
|
||||
|
||||
final GetStaffProfileUseCase _getStaffProfileUseCase;
|
||||
final GetProfileSectionsUseCase _getProfileSectionsUseCase;
|
||||
final GetReliabilityStatsUseCase _getReliabilityStatsUseCase;
|
||||
final SignOutUseCase _signOutUseCase;
|
||||
|
||||
/// Loads the staff member's profile.
|
||||
@@ -62,6 +66,19 @@ class ProfileCubit extends Cubit<ProfileState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads reliability and performance statistics for the staff member.
|
||||
Future<void> loadReliabilityStats() async {
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final StaffReliabilityStats stats =
|
||||
await _getReliabilityStatsUseCase();
|
||||
emit(state.copyWith(reliabilityStats: stats));
|
||||
},
|
||||
onError: (String _) => state,
|
||||
);
|
||||
}
|
||||
|
||||
/// Signs out the current user.
|
||||
Future<void> signOut() async {
|
||||
if (state.status == ProfileStatus.loading) {
|
||||
|
||||
@@ -28,6 +28,7 @@ class ProfileState extends Equatable {
|
||||
const ProfileState({
|
||||
this.status = ProfileStatus.initial,
|
||||
this.profile,
|
||||
this.reliabilityStats,
|
||||
this.errorMessage,
|
||||
this.personalInfoComplete,
|
||||
this.emergencyContactsComplete,
|
||||
@@ -37,40 +38,45 @@ class ProfileState extends Equatable {
|
||||
this.documentsComplete,
|
||||
this.certificatesComplete,
|
||||
});
|
||||
/// Current status of the profile feature
|
||||
|
||||
/// Current status of the profile feature.
|
||||
final ProfileStatus status;
|
||||
|
||||
/// The staff member's profile object (null if not loaded)
|
||||
|
||||
/// The staff member's profile object (null if not loaded).
|
||||
final Staff? profile;
|
||||
|
||||
/// Error message if status is error
|
||||
|
||||
/// Reliability and performance statistics (null if not loaded).
|
||||
final StaffReliabilityStats? reliabilityStats;
|
||||
|
||||
/// Error message if status is error.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Whether personal information is complete
|
||||
|
||||
/// Whether personal information is complete.
|
||||
final bool? personalInfoComplete;
|
||||
|
||||
/// Whether emergency contacts are complete
|
||||
|
||||
/// Whether emergency contacts are complete.
|
||||
final bool? emergencyContactsComplete;
|
||||
|
||||
/// Whether experience information is complete
|
||||
|
||||
/// Whether experience information is complete.
|
||||
final bool? experienceComplete;
|
||||
|
||||
/// Whether tax forms are complete
|
||||
|
||||
/// Whether tax forms are complete.
|
||||
final bool? taxFormsComplete;
|
||||
|
||||
/// Whether attire options are complete
|
||||
|
||||
/// Whether attire options are complete.
|
||||
final bool? attireComplete;
|
||||
|
||||
/// Whether documents are complete
|
||||
|
||||
/// Whether documents are complete.
|
||||
final bool? documentsComplete;
|
||||
|
||||
/// Whether certificates are complete
|
||||
|
||||
/// Whether certificates are complete.
|
||||
final bool? certificatesComplete;
|
||||
|
||||
/// Creates a copy of this state with updated values.
|
||||
ProfileState copyWith({
|
||||
ProfileStatus? status,
|
||||
Staff? profile,
|
||||
StaffReliabilityStats? reliabilityStats,
|
||||
String? errorMessage,
|
||||
bool? personalInfoComplete,
|
||||
bool? emergencyContactsComplete,
|
||||
@@ -83,6 +89,7 @@ class ProfileState extends Equatable {
|
||||
return ProfileState(
|
||||
status: status ?? this.status,
|
||||
profile: profile ?? this.profile,
|
||||
reliabilityStats: reliabilityStats ?? this.reliabilityStats,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete,
|
||||
emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete,
|
||||
@@ -98,6 +105,7 @@ class ProfileState extends Equatable {
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
profile,
|
||||
reliabilityStats,
|
||||
errorMessage,
|
||||
personalInfoComplete,
|
||||
emergencyContactsComplete,
|
||||
|
||||
@@ -37,10 +37,11 @@ class StaffProfilePage extends StatelessWidget {
|
||||
value: cubit,
|
||||
child: BlocConsumer<ProfileCubit, ProfileState>(
|
||||
listener: (BuildContext context, ProfileState state) {
|
||||
// Load section statuses when profile loads successfully
|
||||
// Load section statuses and reliability stats when profile loads
|
||||
if (state.status == ProfileStatus.loaded &&
|
||||
state.personalInfoComplete == null) {
|
||||
cubit.loadSectionStatuses();
|
||||
cubit.loadReliabilityStats();
|
||||
}
|
||||
|
||||
if (state.status == ProfileStatus.signedOut) {
|
||||
@@ -100,16 +101,16 @@ class StaffProfilePage extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
// Reliability Stats
|
||||
ReliabilityStatsCard(
|
||||
totalShifts: 0,
|
||||
averageRating: profile.averageRating,
|
||||
onTimeRate: 0,
|
||||
noShowCount: 0,
|
||||
cancellationCount: 0,
|
||||
totalShifts: state.reliabilityStats?.totalShifts,
|
||||
averageRating: state.reliabilityStats?.averageRating,
|
||||
onTimeRate: state.reliabilityStats?.onTimeRate.round(),
|
||||
noShowCount: state.reliabilityStats?.noShowCount,
|
||||
cancellationCount: state.reliabilityStats?.cancellationCount,
|
||||
),
|
||||
|
||||
// Reliability Score Bar
|
||||
const ReliabilityScoreBar(
|
||||
reliabilityScore: 0,
|
||||
ReliabilityScoreBar(
|
||||
reliabilityScore: state.reliabilityStats?.reliabilityScore.round(),
|
||||
),
|
||||
|
||||
// Ordered sections
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart';
|
||||
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
|
||||
import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart';
|
||||
import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart';
|
||||
import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart';
|
||||
import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart';
|
||||
import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart';
|
||||
@@ -44,12 +45,18 @@ class StaffProfileModule extends Module {
|
||||
i.get<ProfileRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetReliabilityStatsUseCase>(
|
||||
() => GetReliabilityStatsUseCase(
|
||||
i.get<ProfileRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Cubit
|
||||
i.addLazySingleton<ProfileCubit>(
|
||||
() => ProfileCubit(
|
||||
getStaffProfileUseCase: i.get<GetStaffProfileUseCase>(),
|
||||
getProfileSectionsUseCase: i.get<GetProfileSectionsUseCase>(),
|
||||
getReliabilityStatsUseCase: i.get<GetReliabilityStatsUseCase>(),
|
||||
signOutUseCase: i.get<SignOutUseCase>(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -36,8 +36,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
final ApiResponse response = await _apiService.get(
|
||||
StaffEndpoints.shiftsAssigned,
|
||||
params: <String, dynamic>{
|
||||
'startDate': start.toIso8601String(),
|
||||
'endDate': end.toIso8601String(),
|
||||
'startDate': start.toUtc().toIso8601String(),
|
||||
'endDate': end.toUtc().toIso8601String(),
|
||||
},
|
||||
);
|
||||
final List<dynamic> items = _extractItems(response.data);
|
||||
@@ -165,4 +165,38 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
|
||||
return completion.completed;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AvailableOrder>> getAvailableOrders({
|
||||
String? search,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final Map<String, dynamic> params = <String, dynamic>{
|
||||
'limit': limit,
|
||||
};
|
||||
if (search != null && search.isNotEmpty) {
|
||||
params['search'] = search;
|
||||
}
|
||||
final ApiResponse response = await _apiService.get(
|
||||
StaffEndpoints.ordersAvailable,
|
||||
params: params,
|
||||
);
|
||||
final List<dynamic> items = _extractItems(response.data);
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
AvailableOrder.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrderBooking> bookOrder({
|
||||
required String orderId,
|
||||
required String roleId,
|
||||
}) async {
|
||||
final ApiResponse response = await _apiService.post(
|
||||
StaffEndpoints.orderBook(orderId),
|
||||
data: <String, dynamic>{'roleId': roleId},
|
||||
);
|
||||
return OrderBooking.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Combined result from loading all My Shifts tab data sources.
|
||||
///
|
||||
/// Holds assigned shifts, pending assignments, and cancelled shifts
|
||||
/// fetched in parallel from the V2 API.
|
||||
class MyShiftsData {
|
||||
/// Creates a [MyShiftsData] instance.
|
||||
const MyShiftsData({
|
||||
required this.assignedShifts,
|
||||
required this.pendingAssignments,
|
||||
required this.cancelledShifts,
|
||||
});
|
||||
|
||||
/// Assigned shifts for the requested date range.
|
||||
final List<AssignedShift> assignedShifts;
|
||||
|
||||
/// Pending assignments awaiting worker acceptance.
|
||||
final List<PendingAssignment> pendingAssignments;
|
||||
|
||||
/// Cancelled shift assignments.
|
||||
final List<CancelledShift> cancelledShifts;
|
||||
}
|
||||
@@ -52,4 +52,16 @@ abstract interface class ShiftsRepositoryInterface {
|
||||
///
|
||||
/// Only allowed for shifts in CHECKED_OUT or COMPLETED status.
|
||||
Future<void> submitForApproval(String shiftId, {String? note});
|
||||
|
||||
/// Retrieves available orders from the staff marketplace.
|
||||
Future<List<AvailableOrder>> getAvailableOrders({
|
||||
String? search,
|
||||
int limit,
|
||||
});
|
||||
|
||||
/// Books an order by placing the staff member into a role.
|
||||
Future<OrderBooking> bookOrder({
|
||||
required String orderId,
|
||||
required String roleId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
|
||||
|
||||
/// Books an available order for the current staff member.
|
||||
///
|
||||
/// Delegates to [ShiftsRepositoryInterface.bookOrder] with the order and
|
||||
/// role identifiers.
|
||||
class BookOrderUseCase {
|
||||
/// Creates a [BookOrderUseCase].
|
||||
BookOrderUseCase(this._repository);
|
||||
|
||||
/// The shifts repository.
|
||||
final ShiftsRepositoryInterface _repository;
|
||||
|
||||
/// Executes the use case, returning the [OrderBooking] result.
|
||||
Future<OrderBooking> call({
|
||||
required String orderId,
|
||||
required String roleId,
|
||||
}) {
|
||||
return _repository.bookOrder(orderId: orderId, roleId: roleId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
|
||||
|
||||
/// Retrieves available orders from the staff marketplace.
|
||||
///
|
||||
/// Delegates to [ShiftsRepositoryInterface.getAvailableOrders] with an
|
||||
/// optional search filter.
|
||||
class GetAvailableOrdersUseCase {
|
||||
/// Creates a [GetAvailableOrdersUseCase].
|
||||
GetAvailableOrdersUseCase(this._repository);
|
||||
|
||||
/// The shifts repository.
|
||||
final ShiftsRepositoryInterface _repository;
|
||||
|
||||
/// Executes the use case, returning a list of [AvailableOrder].
|
||||
Future<List<AvailableOrder>> call({String? search, int limit = 20}) {
|
||||
return _repository.getAvailableOrders(search: search, limit: limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart';
|
||||
import 'package:staff_shifts/src/domain/models/my_shifts_data.dart';
|
||||
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
|
||||
|
||||
/// Fetches all data needed for the My Shifts tab in a single call.
|
||||
///
|
||||
/// Calls [ShiftsRepositoryInterface.getAssignedShifts],
|
||||
/// [ShiftsRepositoryInterface.getPendingAssignments], and
|
||||
/// [ShiftsRepositoryInterface.getCancelledShifts] in parallel and returns
|
||||
/// a unified [MyShiftsData].
|
||||
class GetMyShiftsDataUseCase
|
||||
extends UseCase<GetAssignedShiftsArguments, MyShiftsData> {
|
||||
/// Creates a [GetMyShiftsDataUseCase].
|
||||
GetMyShiftsDataUseCase(this._repository);
|
||||
|
||||
/// The shifts repository.
|
||||
final ShiftsRepositoryInterface _repository;
|
||||
|
||||
/// Loads assigned, pending, and cancelled shifts for the given date range.
|
||||
@override
|
||||
Future<MyShiftsData> call(GetAssignedShiftsArguments arguments) async {
|
||||
final List<Object> results = await Future.wait(<Future<Object>>[
|
||||
_repository.getAssignedShifts(
|
||||
start: arguments.start,
|
||||
end: arguments.end,
|
||||
),
|
||||
_repository.getPendingAssignments(),
|
||||
_repository.getCancelledShifts(),
|
||||
]);
|
||||
|
||||
return MyShiftsData(
|
||||
assignedShifts: results[0] as List<AssignedShift>,
|
||||
pendingAssignments: results[1] as List<PendingAssignment>,
|
||||
cancelledShifts: results[2] as List<CancelledShift>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/data/repositories_impl/shifts_repository_impl.dart';
|
||||
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/pages/order_details_page.dart';
|
||||
|
||||
/// DI module for the order details page.
|
||||
///
|
||||
/// Registers the repository, use cases, and BLoC needed to display
|
||||
/// and book an [AvailableOrder] via the V2 API.
|
||||
class OrderDetailsModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.add<ShiftsRepositoryInterface>(
|
||||
() => ShiftsRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use cases
|
||||
i.addLazySingleton(GetAvailableOrdersUseCase.new);
|
||||
i.addLazySingleton(BookOrderUseCase.new);
|
||||
|
||||
// BLoC
|
||||
i.add(
|
||||
() => AvailableOrdersBloc(
|
||||
getAvailableOrders: i.get(),
|
||||
bookOrder: i.get(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child(
|
||||
'/',
|
||||
child: (_) {
|
||||
final AvailableOrder order = r.args.data as AvailableOrder;
|
||||
return OrderDetailsPage(order: order);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart';
|
||||
|
||||
import 'available_orders_event.dart';
|
||||
import 'available_orders_state.dart';
|
||||
|
||||
/// Manages the state for the available-orders marketplace tab.
|
||||
///
|
||||
/// Loads order-level cards from `GET /staff/orders/available` and handles
|
||||
/// booking via `POST /staff/orders/:orderId/book`.
|
||||
class AvailableOrdersBloc
|
||||
extends Bloc<AvailableOrdersEvent, AvailableOrdersState>
|
||||
with BlocErrorHandler<AvailableOrdersState> {
|
||||
/// Creates an [AvailableOrdersBloc].
|
||||
AvailableOrdersBloc({
|
||||
required GetAvailableOrdersUseCase getAvailableOrders,
|
||||
required BookOrderUseCase bookOrder,
|
||||
}) : _getAvailableOrders = getAvailableOrders,
|
||||
_bookOrder = bookOrder,
|
||||
super(const AvailableOrdersState()) {
|
||||
on<LoadAvailableOrdersEvent>(_onLoadAvailableOrders);
|
||||
on<BookOrderEvent>(_onBookOrder);
|
||||
on<ClearBookingResultEvent>(_onClearBookingResult);
|
||||
}
|
||||
|
||||
/// Use case for fetching available orders.
|
||||
final GetAvailableOrdersUseCase _getAvailableOrders;
|
||||
|
||||
/// Use case for booking an order.
|
||||
final BookOrderUseCase _bookOrder;
|
||||
|
||||
Future<void> _onLoadAvailableOrders(
|
||||
LoadAvailableOrdersEvent event,
|
||||
Emitter<AvailableOrdersState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(
|
||||
status: AvailableOrdersStatus.loading,
|
||||
clearErrorMessage: true,
|
||||
));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<AvailableOrder> orders =
|
||||
await _getAvailableOrders(search: event.search);
|
||||
emit(state.copyWith(
|
||||
status: AvailableOrdersStatus.loaded,
|
||||
orders: orders,
|
||||
clearErrorMessage: true,
|
||||
));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: AvailableOrdersStatus.error,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onBookOrder(
|
||||
BookOrderEvent event,
|
||||
Emitter<AvailableOrdersState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(bookingInProgress: true, clearErrorMessage: true));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final OrderBooking booking = await _bookOrder(
|
||||
orderId: event.orderId,
|
||||
roleId: event.roleId,
|
||||
);
|
||||
emit(state.copyWith(
|
||||
bookingInProgress: false,
|
||||
lastBooking: booking,
|
||||
clearErrorMessage: true,
|
||||
));
|
||||
// Reload orders after successful booking.
|
||||
add(const LoadAvailableOrdersEvent());
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
bookingInProgress: false,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onClearBookingResult(
|
||||
ClearBookingResultEvent event,
|
||||
Emitter<AvailableOrdersState> emit,
|
||||
) {
|
||||
emit(state.copyWith(clearLastBooking: true, clearErrorMessage: true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// Base class for all available-orders events.
|
||||
@immutable
|
||||
sealed class AvailableOrdersEvent extends Equatable {
|
||||
/// Creates an [AvailableOrdersEvent].
|
||||
const AvailableOrdersEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Loads available orders from the staff marketplace.
|
||||
class LoadAvailableOrdersEvent extends AvailableOrdersEvent {
|
||||
/// Creates a [LoadAvailableOrdersEvent].
|
||||
const LoadAvailableOrdersEvent({this.search});
|
||||
|
||||
/// Optional search query to filter orders.
|
||||
final String? search;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[search];
|
||||
}
|
||||
|
||||
/// Books the staff member into an order for a specific role.
|
||||
class BookOrderEvent extends AvailableOrdersEvent {
|
||||
/// Creates a [BookOrderEvent].
|
||||
const BookOrderEvent({required this.orderId, required this.roleId});
|
||||
|
||||
/// The order to book.
|
||||
final String orderId;
|
||||
|
||||
/// The role within the order to fill.
|
||||
final String roleId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[orderId, roleId];
|
||||
}
|
||||
|
||||
/// Clears the last booking result so the UI can dismiss confirmation.
|
||||
class ClearBookingResultEvent extends AvailableOrdersEvent {
|
||||
/// Creates a [ClearBookingResultEvent].
|
||||
const ClearBookingResultEvent();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Lifecycle status for the available-orders list.
|
||||
enum AvailableOrdersStatus {
|
||||
/// No data has been requested yet.
|
||||
initial,
|
||||
|
||||
/// A load is in progress.
|
||||
loading,
|
||||
|
||||
/// Data has been loaded successfully.
|
||||
loaded,
|
||||
|
||||
/// An error occurred during loading.
|
||||
error,
|
||||
}
|
||||
|
||||
/// State for the available-orders marketplace tab.
|
||||
class AvailableOrdersState extends Equatable {
|
||||
/// Creates an [AvailableOrdersState].
|
||||
const AvailableOrdersState({
|
||||
this.status = AvailableOrdersStatus.initial,
|
||||
this.orders = const <AvailableOrder>[],
|
||||
this.bookingInProgress = false,
|
||||
this.lastBooking,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// Current lifecycle status.
|
||||
final AvailableOrdersStatus status;
|
||||
|
||||
/// The list of available orders.
|
||||
final List<AvailableOrder> orders;
|
||||
|
||||
/// Whether a booking request is currently in flight.
|
||||
final bool bookingInProgress;
|
||||
|
||||
/// The result of the most recent booking, if any.
|
||||
final OrderBooking? lastBooking;
|
||||
|
||||
/// Error message key for display.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Creates a copy with the given fields replaced.
|
||||
AvailableOrdersState copyWith({
|
||||
AvailableOrdersStatus? status,
|
||||
List<AvailableOrder>? orders,
|
||||
bool? bookingInProgress,
|
||||
OrderBooking? lastBooking,
|
||||
bool clearLastBooking = false,
|
||||
String? errorMessage,
|
||||
bool clearErrorMessage = false,
|
||||
}) {
|
||||
return AvailableOrdersState(
|
||||
status: status ?? this.status,
|
||||
orders: orders ?? this.orders,
|
||||
bookingInProgress: bookingInProgress ?? this.bookingInProgress,
|
||||
lastBooking:
|
||||
clearLastBooking ? null : (lastBooking ?? this.lastBooking),
|
||||
errorMessage:
|
||||
clearErrorMessage ? null : (errorMessage ?? this.errorMessage),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
orders,
|
||||
bookingInProgress,
|
||||
lastBooking,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
|
||||
@@ -18,10 +19,12 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
||||
required this.getShiftDetail,
|
||||
required this.applyForShift,
|
||||
required this.declineShift,
|
||||
required this.acceptShift,
|
||||
required this.getProfileCompletion,
|
||||
}) : super(ShiftDetailsInitial()) {
|
||||
on<LoadShiftDetailsEvent>(_onLoadDetails);
|
||||
on<BookShiftDetailsEvent>(_onBookShift);
|
||||
on<AcceptShiftDetailsEvent>(_onAcceptShift);
|
||||
on<DeclineShiftDetailsEvent>(_onDeclineShift);
|
||||
}
|
||||
|
||||
@@ -34,6 +37,9 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
||||
/// Use case for declining a shift.
|
||||
final DeclineShiftUseCase declineShift;
|
||||
|
||||
/// Use case for accepting an assigned shift.
|
||||
final AcceptShiftUseCase acceptShift;
|
||||
|
||||
/// Use case for checking profile completion.
|
||||
final GetProfileCompletionUseCase getProfileCompletion;
|
||||
|
||||
@@ -83,6 +89,25 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onAcceptShift(
|
||||
AcceptShiftDetailsEvent event,
|
||||
Emitter<ShiftDetailsState> emit,
|
||||
) async {
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await acceptShift(event.shiftId);
|
||||
emit(
|
||||
ShiftActionSuccess(
|
||||
'shift_accepted',
|
||||
shiftDate: event.date,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => ShiftDetailsError(errorKey),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onDeclineShift(
|
||||
DeclineShiftDetailsEvent event,
|
||||
Emitter<ShiftDetailsState> emit,
|
||||
|
||||
@@ -26,6 +26,21 @@ class BookShiftDetailsEvent extends ShiftDetailsEvent {
|
||||
List<Object?> get props => [shiftId, roleId, date];
|
||||
}
|
||||
|
||||
/// Event dispatched when the worker accepts an already-assigned shift.
|
||||
class AcceptShiftDetailsEvent extends ShiftDetailsEvent {
|
||||
/// The shift to accept.
|
||||
final String shiftId;
|
||||
|
||||
/// Optional date used for post-action navigation.
|
||||
final DateTime? date;
|
||||
|
||||
/// Creates an [AcceptShiftDetailsEvent].
|
||||
const AcceptShiftDetailsEvent(this.shiftId, {this.date});
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[shiftId, date];
|
||||
}
|
||||
|
||||
class DeclineShiftDetailsEvent extends ShiftDetailsEvent {
|
||||
final String shiftId;
|
||||
const DeclineShiftDetailsEvent(this.shiftId);
|
||||
|
||||
@@ -8,9 +8,11 @@ import 'package:staff_shifts/src/domain/arguments/get_available_shifts_arguments
|
||||
import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/models/my_shifts_data.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_data_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
|
||||
@@ -34,6 +36,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
required this.acceptShift,
|
||||
required this.declineShift,
|
||||
required this.submitForApproval,
|
||||
required this.getMyShiftsData,
|
||||
}) : super(const ShiftsState()) {
|
||||
on<LoadShiftsEvent>(_onLoadShifts);
|
||||
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
|
||||
@@ -74,6 +77,9 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
/// Use case for submitting a shift for timesheet approval.
|
||||
final SubmitForApprovalUseCase submitForApproval;
|
||||
|
||||
/// Use case that loads assigned, pending, and cancelled shifts in parallel.
|
||||
final GetMyShiftsDataUseCase getMyShiftsData;
|
||||
|
||||
Future<void> _onLoadShifts(
|
||||
LoadShiftsEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
@@ -86,29 +92,16 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<DateTime> days = getCalendarDaysForOffset(0);
|
||||
|
||||
// Load assigned, pending, and cancelled shifts in parallel.
|
||||
final List<Object> results = await Future.wait(<Future<Object>>[
|
||||
getAssignedShifts(
|
||||
GetAssignedShiftsArguments(start: days.first, end: days.last),
|
||||
),
|
||||
getPendingAssignments(),
|
||||
getCancelledShifts(),
|
||||
]);
|
||||
|
||||
final List<AssignedShift> myShiftsResult =
|
||||
results[0] as List<AssignedShift>;
|
||||
final List<PendingAssignment> pendingResult =
|
||||
results[1] as List<PendingAssignment>;
|
||||
final List<CancelledShift> cancelledResult =
|
||||
results[2] as List<CancelledShift>;
|
||||
final MyShiftsData data = await getMyShiftsData(
|
||||
GetAssignedShiftsArguments(start: days.first, end: days.last),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ShiftsStatus.loaded,
|
||||
myShifts: myShiftsResult,
|
||||
pendingShifts: pendingResult,
|
||||
cancelledShifts: cancelledResult,
|
||||
myShifts: data.assignedShifts,
|
||||
pendingShifts: data.pendingAssignments,
|
||||
cancelledShifts: data.cancelledShifts,
|
||||
availableShifts: const <OpenShift>[],
|
||||
historyShifts: const <CompletedShift>[],
|
||||
availableLoading: false,
|
||||
@@ -250,18 +243,23 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
LoadShiftsForRangeEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(myShifts: const <AssignedShift>[], myShiftsLoaded: false));
|
||||
emit(state.copyWith(
|
||||
myShifts: const <AssignedShift>[],
|
||||
myShiftsLoaded: false,
|
||||
));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<AssignedShift> myShiftsResult = await getAssignedShifts(
|
||||
final MyShiftsData data = await getMyShiftsData(
|
||||
GetAssignedShiftsArguments(start: event.start, end: event.end),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ShiftsStatus.loaded,
|
||||
myShifts: myShiftsResult,
|
||||
myShifts: data.assignedShifts,
|
||||
pendingShifts: data.pendingAssignments,
|
||||
cancelledShifts: data.cancelledShifts,
|
||||
myShiftsLoaded: true,
|
||||
clearErrorMessage: true,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_event.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_state.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/order_details/order_details_bottom_bar.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/order_details/order_details_header.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/order_details/order_schedule_section.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_location_section.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_stats_row.dart';
|
||||
|
||||
/// Page displaying full details for an available order.
|
||||
///
|
||||
/// Allows the staff member to review order details and book/apply.
|
||||
/// Uses [AvailableOrdersBloc] for the booking flow.
|
||||
class OrderDetailsPage extends StatefulWidget {
|
||||
/// Creates an [OrderDetailsPage].
|
||||
const OrderDetailsPage({super.key, required this.order});
|
||||
|
||||
/// The available order to display.
|
||||
final AvailableOrder order;
|
||||
|
||||
@override
|
||||
State<OrderDetailsPage> createState() => _OrderDetailsPageState();
|
||||
}
|
||||
|
||||
class _OrderDetailsPageState extends State<OrderDetailsPage> {
|
||||
/// Whether the action (booking) dialog is currently showing.
|
||||
bool _actionDialogOpen = false;
|
||||
|
||||
/// Whether a booking request has been initiated.
|
||||
bool _isBooking = false;
|
||||
|
||||
/// Formats a date-only string (e.g. "2026-03-24") to "Mar 24".
|
||||
String _formatDateShort(String dateStr) {
|
||||
if (dateStr.isEmpty) return '';
|
||||
try {
|
||||
final DateTime date = DateTime.parse(dateStr);
|
||||
return DateFormat('MMM d').format(date);
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the duration in hours from the first shift start to end.
|
||||
double _durationHours() {
|
||||
final int minutes = widget.order.schedule.lastShiftEndsAt
|
||||
.difference(widget.order.schedule.firstShiftStartsAt)
|
||||
.inMinutes;
|
||||
double hours = minutes / 60;
|
||||
if (hours < 0) hours += 24;
|
||||
return hours.roundToDouble();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AvailableOrdersBloc>(
|
||||
create: (_) => Modular.get<AvailableOrdersBloc>(),
|
||||
child: BlocConsumer<AvailableOrdersBloc, AvailableOrdersState>(
|
||||
listener: _onStateChanged,
|
||||
builder: (BuildContext context, AvailableOrdersState state) {
|
||||
return _buildScaffold(context, state);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onStateChanged(BuildContext context, AvailableOrdersState state) {
|
||||
// Booking succeeded
|
||||
if (state.lastBooking != null) {
|
||||
_closeActionDialog(context);
|
||||
final bool isPending = state.lastBooking!.status == 'PENDING';
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: isPending
|
||||
? t.available_orders.order_booked_pending
|
||||
: t.available_orders.order_booked_confirmed,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Modular.to.toShifts(initialTab: 'find', refreshAvailable: true);
|
||||
}
|
||||
|
||||
// Booking failed
|
||||
if (state.errorMessage != null && _isBooking) {
|
||||
_closeActionDialog(context);
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.errorMessage!),
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
setState(() {
|
||||
_isBooking = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildScaffold(BuildContext context, AvailableOrdersState state) {
|
||||
final AvailableOrder order = widget.order;
|
||||
final bool isLongTerm = order.orderType == OrderType.permanent;
|
||||
final double durationHours = _durationHours();
|
||||
final double estimatedTotal = order.hourlyRate * durationHours;
|
||||
final int spotsLeft = order.requiredWorkerCount - order.filledCount;
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
centerTitle: false,
|
||||
onLeadingPressed: () => Modular.to.toShifts(),
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
OrderDetailsHeader(order: order),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
ShiftStatsRow(
|
||||
estimatedTotal:
|
||||
isLongTerm ? order.hourlyRate : estimatedTotal,
|
||||
hourlyRate: order.hourlyRate,
|
||||
duration: isLongTerm ? 0 : durationHours,
|
||||
totalLabel: isLongTerm
|
||||
? context.t.staff_shifts.shift_details.hourly_rate
|
||||
: context.t.staff_shifts.shift_details.est_total,
|
||||
hourlyRateLabel:
|
||||
context.t.staff_shifts.shift_details.hourly_rate,
|
||||
hoursLabel: context.t.staff_shifts.shift_details.hours,
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
OrderScheduleSection(
|
||||
schedule: order.schedule,
|
||||
scheduleLabel:
|
||||
context.t.available_orders.schedule_label,
|
||||
dateRangeLabel:
|
||||
context.t.available_orders.date_range_label,
|
||||
clockInLabel:
|
||||
context.t.staff_shifts.shift_details.start_time,
|
||||
clockOutLabel:
|
||||
context.t.staff_shifts.shift_details.end_time,
|
||||
shiftsCountLabel: t.available_orders.shifts_count(
|
||||
count: order.schedule.totalShifts,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
ShiftLocationSection(
|
||||
location: order.location,
|
||||
address: order.locationAddress,
|
||||
locationLabel:
|
||||
context.t.staff_shifts.shift_details.location,
|
||||
tbdLabel: context.t.staff_shifts.shift_details.tbd,
|
||||
getDirectionLabel:
|
||||
context.t.staff_shifts.shift_details.get_direction,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
OrderDetailsBottomBar(
|
||||
instantBook: order.instantBook,
|
||||
spotsLeft: spotsLeft,
|
||||
bookingInProgress: state.bookingInProgress,
|
||||
onBook: () => _bookOrder(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows the confirmation dialog before booking.
|
||||
void _bookOrder(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
title: Text(t.available_orders.book_dialog.title),
|
||||
content: Text(
|
||||
t.available_orders.book_dialog.message(
|
||||
count: widget.order.schedule.totalShifts,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Modular.to.popSafe(),
|
||||
child: Text(Translations.of(context).common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
_showBookingDialog(context);
|
||||
BlocProvider.of<AvailableOrdersBloc>(context).add(
|
||||
BookOrderEvent(
|
||||
orderId: widget.order.orderId,
|
||||
roleId: widget.order.roleId,
|
||||
),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: UiColors.success),
|
||||
child: Text(t.available_orders.book_dialog.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a non-dismissible dialog while the booking is in progress.
|
||||
void _showBookingDialog(BuildContext context) {
|
||||
if (_actionDialogOpen) return;
|
||||
_actionDialogOpen = true;
|
||||
_isBooking = true;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
title: Text(t.available_orders.booking_dialog.title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
height: 36,
|
||||
width: 36,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
widget.order.roleName,
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatDateShort(widget.order.schedule.startDate)} - '
|
||||
'${_formatDateShort(widget.order.schedule.endDate)} '
|
||||
'\u2022 ${widget.order.schedule.totalShifts} shifts',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
_actionDialogOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Closes the action dialog if it is open.
|
||||
void _closeActionDialog(BuildContext context) {
|
||||
if (!_actionDialogOpen) return;
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
_actionDialogOpen = false;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_
|
||||
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_state.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_date_time_section.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_description_section.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/cancellation_reason_banner.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_header.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_details_page_skeleton.dart';
|
||||
@@ -117,6 +118,15 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
icon: UiIcons.sparkles,
|
||||
),
|
||||
),
|
||||
if (detail.assignmentStatus ==
|
||||
AssignmentStatus.cancelled &&
|
||||
detail.cancellationReason != null &&
|
||||
detail.cancellationReason!.isNotEmpty)
|
||||
CancellationReasonBanner(
|
||||
reason: detail.cancellationReason!,
|
||||
titleLabel: context.t.staff_shifts.shift_details
|
||||
.shift_cancelled,
|
||||
),
|
||||
ShiftDetailsHeader(detail: detail),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
ShiftStatsRow(
|
||||
@@ -140,6 +150,8 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
ShiftLocationSection(
|
||||
location: detail.location,
|
||||
address: detail.address ?? '',
|
||||
latitude: detail.latitude,
|
||||
longitude: detail.longitude,
|
||||
locationLabel: context.t.staff_shifts.shift_details.location,
|
||||
tbdLabel: context.t.staff_shifts.shift_details.tbd,
|
||||
getDirectionLabel: context.t.staff_shifts.shift_details.get_direction,
|
||||
@@ -164,9 +176,9 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
).add(DeclineShiftDetailsEvent(detail.shiftId)),
|
||||
onAccept: () =>
|
||||
BlocProvider.of<ShiftDetailsBloc>(context).add(
|
||||
BookShiftDetailsEvent(
|
||||
AcceptShiftDetailsEvent(
|
||||
detail.shiftId,
|
||||
roleId: detail.roleId,
|
||||
date: detail.date,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -260,6 +272,8 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
switch (key) {
|
||||
case 'shift_booked':
|
||||
return context.t.staff_shifts.shift_details.shift_booked;
|
||||
case 'shift_accepted':
|
||||
return context.t.staff_shifts.shift_details.shift_accepted;
|
||||
case 'shift_declined_success':
|
||||
return context.t.staff_shifts.shift_details.shift_declined_success;
|
||||
default:
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_event.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_state.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shifts_page_skeleton.dart';
|
||||
@@ -14,7 +17,8 @@ import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.da
|
||||
|
||||
/// Tabbed page for browsing staff shifts (My Shifts, Find Work, History).
|
||||
///
|
||||
/// Manages tab state locally and delegates data loading to [ShiftsBloc].
|
||||
/// Manages tab state locally and delegates data loading to [ShiftsBloc]
|
||||
/// and [AvailableOrdersBloc].
|
||||
class ShiftsPage extends StatefulWidget {
|
||||
/// Creates a [ShiftsPage].
|
||||
///
|
||||
@@ -45,9 +49,9 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
late ShiftTabType _activeTab;
|
||||
DateTime? _selectedDate;
|
||||
bool _prioritizeFind = false;
|
||||
bool _refreshAvailable = false;
|
||||
bool _pendingAvailableRefresh = false;
|
||||
final ShiftsBloc _bloc = Modular.get<ShiftsBloc>();
|
||||
final AvailableOrdersBloc _ordersBloc = Modular.get<AvailableOrdersBloc>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,7 +59,6 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
_activeTab = widget.initialTab ?? ShiftTabType.find;
|
||||
_selectedDate = widget.selectedDate;
|
||||
_prioritizeFind = _activeTab == ShiftTabType.find;
|
||||
_refreshAvailable = widget.refreshAvailable;
|
||||
_pendingAvailableRefresh = widget.refreshAvailable;
|
||||
if (_prioritizeFind) {
|
||||
_bloc.add(LoadFindFirstEvent());
|
||||
@@ -66,9 +69,8 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
_bloc.add(LoadHistoryShiftsEvent());
|
||||
}
|
||||
if (_activeTab == ShiftTabType.find) {
|
||||
if (!_prioritizeFind) {
|
||||
_bloc.add(LoadAvailableShiftsEvent(force: _refreshAvailable));
|
||||
}
|
||||
// Load available orders via the new BLoC.
|
||||
_ordersBloc.add(const LoadAvailableOrdersEvent());
|
||||
}
|
||||
|
||||
// Check profile completion
|
||||
@@ -90,162 +92,170 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
});
|
||||
}
|
||||
if (widget.refreshAvailable) {
|
||||
_refreshAvailable = true;
|
||||
_pendingAvailableRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Translations.of(context);
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
final Translations t = Translations.of(context);
|
||||
return MultiBlocProvider(
|
||||
providers: <BlocProvider<dynamic>>[
|
||||
BlocProvider<ShiftsBloc>.value(value: _bloc),
|
||||
BlocProvider<AvailableOrdersBloc>.value(value: _ordersBloc),
|
||||
],
|
||||
child: BlocConsumer<ShiftsBloc, ShiftsState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == ShiftsStatus.error &&
|
||||
state.errorMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.errorMessage!),
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (_pendingAvailableRefresh && state.status == ShiftsStatus.loaded) {
|
||||
_pendingAvailableRefresh = false;
|
||||
_bloc.add(const LoadAvailableShiftsEvent(force: true));
|
||||
}
|
||||
final bool baseLoaded = state.status == ShiftsStatus.loaded;
|
||||
final List<AssignedShift> myShifts = state.myShifts;
|
||||
final List<OpenShift> availableJobs = state.availableShifts;
|
||||
final bool availableLoading = state.availableLoading;
|
||||
final bool availableLoaded = state.availableLoaded;
|
||||
final List<PendingAssignment> pendingAssignments = state.pendingShifts;
|
||||
final List<CancelledShift> cancelledShifts = state.cancelledShifts;
|
||||
final List<CompletedShift> historyShifts = state.historyShifts;
|
||||
final bool historyLoading = state.historyLoading;
|
||||
final bool historyLoaded = state.historyLoaded;
|
||||
final bool myShiftsLoaded = state.myShiftsLoaded;
|
||||
final bool blockTabsForFind = _prioritizeFind && !availableLoaded;
|
||||
listener: (BuildContext context, ShiftsState state) {
|
||||
if (state.status == ShiftsStatus.error &&
|
||||
state.errorMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.errorMessage!),
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ShiftsState state) {
|
||||
if (_pendingAvailableRefresh &&
|
||||
state.status == ShiftsStatus.loaded) {
|
||||
_pendingAvailableRefresh = false;
|
||||
_ordersBloc.add(const LoadAvailableOrdersEvent());
|
||||
}
|
||||
final bool baseLoaded = state.status == ShiftsStatus.loaded;
|
||||
final List<AssignedShift> myShifts = state.myShifts;
|
||||
final List<PendingAssignment> pendingAssignments =
|
||||
state.pendingShifts;
|
||||
final List<CancelledShift> cancelledShifts = state.cancelledShifts;
|
||||
final List<CompletedShift> historyShifts = state.historyShifts;
|
||||
final bool historyLoading = state.historyLoading;
|
||||
final bool historyLoaded = state.historyLoaded;
|
||||
final bool myShiftsLoaded = state.myShiftsLoaded;
|
||||
|
||||
// Note: "filteredJobs" logic moved to FindShiftsTab
|
||||
// Note: Calendar logic moved to MyShiftsTab
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// Header (Blue)
|
||||
Container(
|
||||
color: UiColors.primary,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
MediaQuery.of(context).padding.top + UiConstants.space2,
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space4,
|
||||
children: [
|
||||
Text(
|
||||
t.staff_shifts.title,
|
||||
style: UiTypography.display1b.white,
|
||||
),
|
||||
|
||||
// Tabs
|
||||
Row(
|
||||
children: [
|
||||
if (state.profileComplete != false)
|
||||
Expanded(
|
||||
child: _buildTab(
|
||||
ShiftTabType.myShifts,
|
||||
t.staff_shifts.tabs.my_shifts,
|
||||
UiIcons.calendar,
|
||||
myShifts.length,
|
||||
showCount: myShiftsLoaded,
|
||||
enabled:
|
||||
!blockTabsForFind &&
|
||||
(state.profileComplete ?? false),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (state.profileComplete != false)
|
||||
const SizedBox(width: UiConstants.space2)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
_buildTab(
|
||||
ShiftTabType.find,
|
||||
t.staff_shifts.tabs.find_work,
|
||||
UiIcons.search,
|
||||
availableJobs.length,
|
||||
showCount: availableLoaded,
|
||||
enabled: baseLoaded,
|
||||
),
|
||||
if (state.profileComplete != false)
|
||||
const SizedBox(width: UiConstants.space2)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (state.profileComplete != false)
|
||||
Expanded(
|
||||
child: _buildTab(
|
||||
ShiftTabType.history,
|
||||
t.staff_shifts.tabs.history,
|
||||
UiIcons.clock,
|
||||
historyShifts.length,
|
||||
showCount: historyLoaded,
|
||||
enabled:
|
||||
!blockTabsForFind &&
|
||||
baseLoaded &&
|
||||
(state.profileComplete ?? false),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Body Content
|
||||
Expanded(
|
||||
child: state.status == ShiftsStatus.loading
|
||||
? const ShiftsPageSkeleton()
|
||||
: state.status == ShiftsStatus.error
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
translateErrorKey(state.errorMessage ?? ''),
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: _buildTabContent(
|
||||
state,
|
||||
myShifts,
|
||||
pendingAssignments,
|
||||
cancelledShifts,
|
||||
availableJobs,
|
||||
historyShifts,
|
||||
availableLoading,
|
||||
historyLoading,
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
// Header (Blue)
|
||||
Container(
|
||||
color: UiColors.primary,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
MediaQuery.of(context).padding.top + UiConstants.space2,
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.staff_shifts.title,
|
||||
style: UiTypography.display1b.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Tabs -- use BlocBuilder on orders bloc for count
|
||||
BlocBuilder<AvailableOrdersBloc,
|
||||
AvailableOrdersState>(
|
||||
builder: (BuildContext context,
|
||||
AvailableOrdersState ordersState) {
|
||||
final bool ordersLoaded = ordersState.status ==
|
||||
AvailableOrdersStatus.loaded;
|
||||
final int ordersCount = ordersState.orders.length;
|
||||
final bool blockTabsForFind =
|
||||
_prioritizeFind && !ordersLoaded;
|
||||
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
if (state.profileComplete != false)
|
||||
Expanded(
|
||||
child: _buildTab(
|
||||
ShiftTabType.myShifts,
|
||||
t.staff_shifts.tabs.my_shifts,
|
||||
UiIcons.calendar,
|
||||
myShifts.length,
|
||||
showCount: myShiftsLoaded,
|
||||
enabled: !blockTabsForFind &&
|
||||
(state.profileComplete ?? false),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (state.profileComplete != false)
|
||||
const SizedBox(width: UiConstants.space2)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
_buildTab(
|
||||
ShiftTabType.find,
|
||||
t.staff_shifts.tabs.find_work,
|
||||
UiIcons.search,
|
||||
ordersCount,
|
||||
showCount: ordersLoaded,
|
||||
enabled: baseLoaded,
|
||||
),
|
||||
if (state.profileComplete != false)
|
||||
const SizedBox(width: UiConstants.space2)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (state.profileComplete != false)
|
||||
Expanded(
|
||||
child: _buildTab(
|
||||
ShiftTabType.history,
|
||||
t.staff_shifts.tabs.history,
|
||||
UiIcons.clock,
|
||||
historyShifts.length,
|
||||
showCount: historyLoaded,
|
||||
enabled: !blockTabsForFind &&
|
||||
baseLoaded &&
|
||||
(state.profileComplete ?? false),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Body Content
|
||||
Expanded(
|
||||
child: state.status == ShiftsStatus.loading
|
||||
? const ShiftsPageSkeleton()
|
||||
: state.status == ShiftsStatus.error
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
translateErrorKey(
|
||||
state.errorMessage ?? ''),
|
||||
style:
|
||||
UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: _buildTabContent(
|
||||
state,
|
||||
myShifts,
|
||||
pendingAssignments,
|
||||
cancelledShifts,
|
||||
historyShifts,
|
||||
historyLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabContent(
|
||||
@@ -253,9 +263,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
List<AssignedShift> myShifts,
|
||||
List<PendingAssignment> pendingAssignments,
|
||||
List<CancelledShift> cancelledShifts,
|
||||
List<OpenShift> availableJobs,
|
||||
List<CompletedShift> historyShifts,
|
||||
bool availableLoading,
|
||||
bool historyLoading,
|
||||
) {
|
||||
switch (_activeTab) {
|
||||
@@ -269,12 +277,17 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
submittingShiftId: state.submittingShiftId,
|
||||
);
|
||||
case ShiftTabType.find:
|
||||
if (availableLoading) {
|
||||
return const ShiftsPageSkeleton();
|
||||
}
|
||||
return FindShiftsTab(
|
||||
availableJobs: availableJobs,
|
||||
profileComplete: state.profileComplete ?? true,
|
||||
return BlocBuilder<AvailableOrdersBloc, AvailableOrdersState>(
|
||||
builder:
|
||||
(BuildContext context, AvailableOrdersState ordersState) {
|
||||
if (ordersState.status == AvailableOrdersStatus.loading) {
|
||||
return const ShiftsPageSkeleton();
|
||||
}
|
||||
return FindShiftsTab(
|
||||
availableOrders: ordersState.orders,
|
||||
profileComplete: state.profileComplete ?? true,
|
||||
);
|
||||
},
|
||||
);
|
||||
case ShiftTabType.history:
|
||||
if (historyLoading) {
|
||||
@@ -296,7 +309,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
bool showCount = true,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
final isActive = _activeTab == type;
|
||||
final bool isActive = _activeTab == type;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: !enabled
|
||||
@@ -307,7 +320,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
_bloc.add(LoadHistoryShiftsEvent());
|
||||
}
|
||||
if (type == ShiftTabType.find) {
|
||||
_bloc.add(LoadAvailableShiftsEvent());
|
||||
_ordersBloc.add(const LoadAvailableOrdersEvent());
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
@@ -324,35 +337,33 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: !enabled
|
||||
? UiColors.white.withValues(alpha: 0.5)
|
||||
: isActive
|
||||
? UiColors.primary
|
||||
: UiColors.white,
|
||||
? UiColors.primary
|
||||
: UiColors.white,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style:
|
||||
(isActive
|
||||
? UiTypography.body3m.copyWith(
|
||||
color: UiColors.primary,
|
||||
)
|
||||
: UiTypography.body3m.white)
|
||||
.copyWith(
|
||||
color: !enabled
|
||||
? UiColors.white.withValues(alpha: 0.5)
|
||||
: null,
|
||||
),
|
||||
style: (isActive
|
||||
? UiTypography.body3m
|
||||
.copyWith(color: UiColors.primary)
|
||||
: UiTypography.body3m.white)
|
||||
.copyWith(
|
||||
color: !enabled
|
||||
? UiColors.white.withValues(alpha: 0.5)
|
||||
: null,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (showCount) ...[
|
||||
if (showCount) ...<Widget>[
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -368,7 +379,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"$count",
|
||||
'$count',
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: isActive ? UiColors.primary : UiColors.white,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Card displaying an [AvailableOrder] from the staff marketplace.
|
||||
///
|
||||
/// Shows role, pay (total + hourly), time, date, client, location,
|
||||
/// and schedule chips. Tapping the card navigates to the order details page.
|
||||
class AvailableOrderCard extends StatelessWidget {
|
||||
/// Creates an [AvailableOrderCard].
|
||||
const AvailableOrderCard({
|
||||
super.key,
|
||||
required this.order,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// The available order to display.
|
||||
final AvailableOrder order;
|
||||
|
||||
/// Callback when the user taps the card.
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Formats a DateTime to a time string like "3:30pm".
|
||||
String _formatTime(DateTime time) {
|
||||
return DateFormat('h:mma').format(time).toLowerCase();
|
||||
}
|
||||
|
||||
/// Formats a date-only string (e.g. "2026-03-24") to "Mar 24".
|
||||
String _formatDateShort(String dateStr) {
|
||||
if (dateStr.isEmpty) return '';
|
||||
try {
|
||||
final DateTime date = DateTime.parse(dateStr);
|
||||
return DateFormat('MMM d').format(date);
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the duration in hours from the first shift start to end.
|
||||
double _durationHours() {
|
||||
final int minutes = order.schedule.lastShiftEndsAt
|
||||
.difference(order.schedule.firstShiftStartsAt)
|
||||
.inMinutes;
|
||||
double hours = minutes / 60;
|
||||
if (hours < 0) hours += 24;
|
||||
return hours.roundToDouble();
|
||||
}
|
||||
|
||||
/// Returns a human-readable label for the order type.
|
||||
String _orderTypeLabel(OrderType type) {
|
||||
switch (type) {
|
||||
case OrderType.oneTime:
|
||||
return t.staff_shifts.filter.one_day;
|
||||
case OrderType.recurring:
|
||||
return t.staff_shifts.filter.multi_day;
|
||||
case OrderType.permanent:
|
||||
return t.staff_shifts.filter.long_term;
|
||||
case OrderType.rapid:
|
||||
return 'Rapid';
|
||||
case OrderType.unknown:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a capitalised short label for a dispatch team value.
|
||||
String _dispatchTeamLabel(String team) {
|
||||
switch (team.toUpperCase()) {
|
||||
case 'CORE':
|
||||
return 'Core';
|
||||
case 'CERTIFIED_LOCATION':
|
||||
return 'Certified';
|
||||
case 'MARKETPLACE':
|
||||
return 'Marketplace';
|
||||
default:
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AvailableOrderSchedule schedule = order.schedule;
|
||||
final int spotsLeft = order.requiredWorkerCount - order.filledCount;
|
||||
final bool isLongTerm = order.orderType == OrderType.permanent;
|
||||
final double durationHours = _durationHours();
|
||||
final double estimatedTotal = order.hourlyRate * durationHours;
|
||||
final String dateRange =
|
||||
'${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}';
|
||||
final String timeRange =
|
||||
'${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// -- Badge row --
|
||||
_buildBadgeRow(spotsLeft),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// -- Main content row: icon + details + pay --
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Role icon
|
||||
Container(
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagInProgress,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: UiConstants.space5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
|
||||
// Details + pay
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Role name + pay headline
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
order.roleName,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isLongTerm
|
||||
? '\$${order.hourlyRate.toInt()}/hr'
|
||||
: '\$${estimatedTotal.toStringAsFixed(0)}',
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Time subtitle + pay detail
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
timeRange,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
if (!isLongTerm)
|
||||
Text(
|
||||
'\$${order.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// -- Date --
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
dateRange,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
|
||||
// -- Client name --
|
||||
if (order.clientName.isNotEmpty)
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.building,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
order.clientName,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// -- Address --
|
||||
if (order.locationAddress.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
order.locationAddress,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
// -- Schedule: days of week chips --
|
||||
if (schedule.daysOfWeek.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Wrap(
|
||||
spacing: UiConstants.space1,
|
||||
runSpacing: UiConstants.space1,
|
||||
children: schedule.daysOfWeek
|
||||
.map((DayOfWeek day) => _buildDayChip(day))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
t.available_orders.shifts_count(
|
||||
count: schedule.totalShifts,
|
||||
),
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the horizontal row of badge chips at the top of the card.
|
||||
Widget _buildBadgeRow(int spotsLeft) {
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
// Order type badge
|
||||
_buildBadge(
|
||||
label: _orderTypeLabel(order.orderType),
|
||||
backgroundColor: UiColors.background,
|
||||
textColor: UiColors.textSecondary,
|
||||
borderColor: UiColors.border,
|
||||
),
|
||||
|
||||
// Spots left badge
|
||||
if (spotsLeft > 0)
|
||||
_buildBadge(
|
||||
label: t.available_orders.spots_left(count: spotsLeft),
|
||||
backgroundColor: UiColors.tagPending,
|
||||
textColor: UiColors.textWarning,
|
||||
borderColor: UiColors.textWarning.withValues(alpha: 0.3),
|
||||
),
|
||||
|
||||
// Instant book badge
|
||||
if (order.instantBook)
|
||||
_buildBadge(
|
||||
label: t.available_orders.instant_book,
|
||||
backgroundColor: UiColors.success.withValues(alpha: 0.1),
|
||||
textColor: UiColors.success,
|
||||
borderColor: UiColors.success.withValues(alpha: 0.3),
|
||||
icon: UiIcons.zap,
|
||||
),
|
||||
|
||||
// Dispatch team badge
|
||||
if (order.dispatchTeam.isNotEmpty)
|
||||
_buildBadge(
|
||||
label: _dispatchTeamLabel(order.dispatchTeam),
|
||||
backgroundColor: UiColors.primary.withValues(alpha: 0.08),
|
||||
textColor: UiColors.primary,
|
||||
borderColor: UiColors.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a single badge chip with optional leading icon.
|
||||
Widget _buildBadge({
|
||||
required String label,
|
||||
required Color backgroundColor,
|
||||
required Color textColor,
|
||||
required Color borderColor,
|
||||
IconData? icon,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (icon != null) ...<Widget>[
|
||||
Icon(icon, size: 10, color: textColor),
|
||||
const SizedBox(width: 2),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.footnote2m.copyWith(color: textColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a small chip showing a day-of-week abbreviation.
|
||||
Widget _buildDayChip(DayOfWeek day) {
|
||||
final String label = day.value.isNotEmpty
|
||||
? '${day.value[0]}${day.value.substring(1).toLowerCase()}'
|
||||
: '';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.footnote2m.copyWith(color: UiColors.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A bottom action bar for the order details page.
|
||||
///
|
||||
/// Displays a contextual CTA button based on order booking state:
|
||||
/// fully staffed, instant book, or standard apply.
|
||||
class OrderDetailsBottomBar extends StatelessWidget {
|
||||
/// Creates an [OrderDetailsBottomBar].
|
||||
const OrderDetailsBottomBar({
|
||||
super.key,
|
||||
required this.instantBook,
|
||||
required this.spotsLeft,
|
||||
required this.bookingInProgress,
|
||||
required this.onBook,
|
||||
});
|
||||
|
||||
/// Whether the order supports instant booking (no approval needed).
|
||||
final bool instantBook;
|
||||
|
||||
/// Number of spots still available.
|
||||
final int spotsLeft;
|
||||
|
||||
/// Whether a booking request is currently in flight.
|
||||
final bool bookingInProgress;
|
||||
|
||||
/// Callback when the user taps the book/apply button.
|
||||
final VoidCallback onBook;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
UiConstants.space4,
|
||||
UiConstants.space5,
|
||||
MediaQuery.of(context).padding.bottom + UiConstants.space4,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border)),
|
||||
),
|
||||
child: _buildButton(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildButton(BuildContext context) {
|
||||
// Loading state
|
||||
if (bookingInProgress) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
onPressed: null,
|
||||
child: const SizedBox(
|
||||
width: UiConstants.iconMd,
|
||||
height: UiConstants.iconMd,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: UiColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fully staffed
|
||||
if (spotsLeft <= 0) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
onPressed: null,
|
||||
text: t.available_orders.fully_staffed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
onPressed: onBook,
|
||||
text: 'Book Shift',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Size of the role icon container in the order details header.
|
||||
const double _kIconContainerSize = 68.0;
|
||||
|
||||
/// A header widget for the order details page.
|
||||
///
|
||||
/// Displays the role icon, role name, client name, and a row of status badges
|
||||
/// (order type, spots left, instant book, dispatch team).
|
||||
class OrderDetailsHeader extends StatelessWidget {
|
||||
/// Creates an [OrderDetailsHeader].
|
||||
const OrderDetailsHeader({super.key, required this.order});
|
||||
|
||||
/// The available order entity.
|
||||
final AvailableOrder order;
|
||||
|
||||
/// Returns a human-readable label for the order type.
|
||||
String _orderTypeLabel(OrderType type) {
|
||||
switch (type) {
|
||||
case OrderType.oneTime:
|
||||
return t.staff_shifts.filter.one_day;
|
||||
case OrderType.recurring:
|
||||
return t.staff_shifts.filter.multi_day;
|
||||
case OrderType.permanent:
|
||||
return t.staff_shifts.filter.long_term;
|
||||
case OrderType.rapid:
|
||||
return 'Rapid';
|
||||
case OrderType.unknown:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a capitalised short label for a dispatch team value.
|
||||
String _dispatchTeamLabel(String team) {
|
||||
switch (team.toUpperCase()) {
|
||||
case 'CORE':
|
||||
return 'Core';
|
||||
case 'CERTIFIED_LOCATION':
|
||||
return 'Certified';
|
||||
case 'MARKETPLACE':
|
||||
return 'Marketplace';
|
||||
default:
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int spotsLeft = order.requiredWorkerCount - order.filledCount;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space6,
|
||||
vertical: UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space6,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: _kIconContainerSize,
|
||||
height: _kIconContainerSize,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withAlpha(20),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.primary, width: 0.5),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
order.roleName,
|
||||
style: UiTypography.headline1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
order.clientName,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildBadgeRow(spotsLeft),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the horizontal row of badge chips below the header.
|
||||
Widget _buildBadgeRow(int spotsLeft) {
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
// Order type badge
|
||||
_buildBadge(
|
||||
label: _orderTypeLabel(order.orderType),
|
||||
backgroundColor: UiColors.background,
|
||||
textColor: UiColors.textSecondary,
|
||||
borderColor: UiColors.border,
|
||||
),
|
||||
|
||||
// Spots left badge
|
||||
if (spotsLeft > 0)
|
||||
_buildBadge(
|
||||
label: t.available_orders.spots_left(count: spotsLeft),
|
||||
backgroundColor: UiColors.tagPending,
|
||||
textColor: UiColors.textWarning,
|
||||
borderColor: UiColors.textWarning.withValues(alpha: 0.3),
|
||||
),
|
||||
|
||||
// Instant book badge
|
||||
if (order.instantBook)
|
||||
_buildBadge(
|
||||
label: t.available_orders.instant_book,
|
||||
backgroundColor: UiColors.success.withValues(alpha: 0.1),
|
||||
textColor: UiColors.success,
|
||||
borderColor: UiColors.success.withValues(alpha: 0.3),
|
||||
icon: UiIcons.zap,
|
||||
),
|
||||
|
||||
// Dispatch team badge
|
||||
if (order.dispatchTeam.isNotEmpty)
|
||||
_buildBadge(
|
||||
label: _dispatchTeamLabel(order.dispatchTeam),
|
||||
backgroundColor: UiColors.primary.withValues(alpha: 0.08),
|
||||
textColor: UiColors.primary,
|
||||
borderColor: UiColors.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a single badge chip with optional leading icon.
|
||||
Widget _buildBadge({
|
||||
required String label,
|
||||
required Color backgroundColor,
|
||||
required Color textColor,
|
||||
required Color borderColor,
|
||||
IconData? icon,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (icon != null) ...<Widget>[
|
||||
Icon(icon, size: 10, color: textColor),
|
||||
const SizedBox(width: 2),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.footnote2m.copyWith(color: textColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// A section displaying the schedule for an available order.
|
||||
///
|
||||
/// Shows a date range, Google Calendar-style day-of-week circles,
|
||||
/// clock-in/clock-out time boxes, and total shift count.
|
||||
/// Follows the same visual structure as [ShiftDateTimeSection].
|
||||
class OrderScheduleSection extends StatelessWidget {
|
||||
/// Creates an [OrderScheduleSection].
|
||||
const OrderScheduleSection({
|
||||
super.key,
|
||||
required this.schedule,
|
||||
required this.scheduleLabel,
|
||||
required this.dateRangeLabel,
|
||||
required this.clockInLabel,
|
||||
required this.clockOutLabel,
|
||||
required this.shiftsCountLabel,
|
||||
});
|
||||
|
||||
/// The order schedule data.
|
||||
final AvailableOrderSchedule schedule;
|
||||
|
||||
/// Localised section title (e.g. "SCHEDULE").
|
||||
final String scheduleLabel;
|
||||
|
||||
/// Localised label for the date range row (e.g. "Date Range").
|
||||
final String dateRangeLabel;
|
||||
|
||||
/// Localised label for the clock-in time box (e.g. "START TIME").
|
||||
final String clockInLabel;
|
||||
|
||||
/// Localised label for the clock-out time box (e.g. "END TIME").
|
||||
final String clockOutLabel;
|
||||
|
||||
/// Localised shifts count text (e.g. "3 shift(s)").
|
||||
final String shiftsCountLabel;
|
||||
|
||||
/// All seven days in ISO order for the day-of-week row.
|
||||
static const List<DayOfWeek> _allDays = <DayOfWeek>[
|
||||
DayOfWeek.mon,
|
||||
DayOfWeek.tue,
|
||||
DayOfWeek.wed,
|
||||
DayOfWeek.thu,
|
||||
DayOfWeek.fri,
|
||||
DayOfWeek.sat,
|
||||
DayOfWeek.sun,
|
||||
];
|
||||
|
||||
/// Single-letter labels for each day (ISO order).
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'M',
|
||||
'T',
|
||||
'W',
|
||||
'T',
|
||||
'F',
|
||||
'S',
|
||||
'S',
|
||||
];
|
||||
|
||||
/// Formats a date-only string (e.g. "2026-03-24") to "Mar 24".
|
||||
String _formatDateShort(String dateStr) {
|
||||
if (dateStr.isEmpty) return '';
|
||||
try {
|
||||
final DateTime date = DateTime.parse(dateStr);
|
||||
return DateFormat('MMM d').format(date);
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats [DateTime] to a time string (e.g. "9:00 AM").
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
/// Builds the date range display string including the year.
|
||||
String _buildDateRangeText() {
|
||||
final String start = _formatDateShort(schedule.startDate);
|
||||
final String end = _formatDateShort(schedule.endDate);
|
||||
// Extract year from endDate for display.
|
||||
String year = '';
|
||||
if (schedule.endDate.isNotEmpty) {
|
||||
try {
|
||||
final DateTime endDt = DateTime.parse(schedule.endDate);
|
||||
year = ', ${endDt.year}';
|
||||
} catch (_) {
|
||||
// Ignore parse errors.
|
||||
}
|
||||
}
|
||||
return '$start - $end$year';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Section title
|
||||
Text(
|
||||
scheduleLabel,
|
||||
style: UiTypography.titleUppercase4b.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Date range row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.space5,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
_buildDateRangeText(),
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Days-of-week circles (Google Calendar style)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
for (int i = 0; i < _allDays.length; i++)
|
||||
_buildDayCircle(
|
||||
_allDays[i],
|
||||
_dayLabels[i],
|
||||
schedule.daysOfWeek.contains(_allDays[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Clock in / Clock out time boxes
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _buildTimeBox(clockInLabel, schedule.firstShiftStartsAt),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: _buildTimeBox(clockOutLabel, schedule.lastShiftEndsAt),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
Text(
|
||||
'TOTAL SHIFTS',
|
||||
style: UiTypography.titleUppercase4b.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
// Shifts count
|
||||
Text(shiftsCountLabel, style: UiTypography.body1r),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a single day-of-week circle.
|
||||
///
|
||||
/// Active days are filled with the primary color and white text.
|
||||
/// Inactive days use the background color and secondary text.
|
||||
Widget _buildDayCircle(DayOfWeek day, String label, bool isActive) {
|
||||
return Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isActive ? UiColors.primary : UiColors.background,
|
||||
width: 1.5,
|
||||
),
|
||||
color: isActive ? UiColors.primary.withAlpha(40) : UiColors.background,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: isActive
|
||||
? UiTypography.footnote1b.primary
|
||||
: UiTypography.footnote2m.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a time-display box matching the [ShiftDateTimeSection] pattern.
|
||||
Widget _buildTimeBox(String label, DateTime time) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgThird,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
_formatTime(time),
|
||||
style: UiTypography.title1m
|
||||
.copyWith(fontWeight: FontWeight.w700)
|
||||
.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,37 +14,25 @@ class ShiftCardBody extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ShiftCardIcon(variant: data.variant),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ShiftCardTitleRow(data: data),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
ShiftCardMetadataRows(data: data),
|
||||
if (data.cancellationReason != null &&
|
||||
data.cancellationReason!.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
data.cancellationReason!,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ShiftCardIcon(variant: data.variant),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(child: ShiftCardTitleRow(data: data)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
ShiftCardMetadataRows(data: data),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 44x44 icon box with a gradient background.
|
||||
/// The icon box matching the AvailableOrderCard style.
|
||||
class ShiftCardIcon extends StatelessWidget {
|
||||
/// Creates a [ShiftCardIcon].
|
||||
const ShiftCardIcon({super.key, required this.variant});
|
||||
@@ -57,30 +45,19 @@ class ShiftCardIcon extends StatelessWidget {
|
||||
final bool isCancelled = variant == ShiftCardVariant.cancelled;
|
||||
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
decoration: BoxDecoration(
|
||||
gradient: isCancelled
|
||||
? null
|
||||
: LinearGradient(
|
||||
colors: <Color>[
|
||||
UiColors.primary.withValues(alpha: 0.09),
|
||||
UiColors.primary.withValues(alpha: 0.03),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
color: isCancelled ? UiColors.primary.withValues(alpha: 0.05) : null,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: isCancelled
|
||||
? null
|
||||
: Border.all(color: UiColors.primary.withValues(alpha: 0.09)),
|
||||
color: isCancelled
|
||||
? UiColors.primary.withValues(alpha: 0.05)
|
||||
: UiColors.tagInProgress,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: UiConstants.iconMd,
|
||||
size: UiConstants.space5,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ class ShiftCardData {
|
||||
required this.date,
|
||||
required this.variant,
|
||||
this.subtitle,
|
||||
this.clientName,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.hourlyRateCents,
|
||||
@@ -57,9 +58,12 @@ class ShiftCardData {
|
||||
subtitle: shift.location,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
clientName: shift.clientName,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRateCents: shift.hourlyRateCents,
|
||||
hourlyRate: shift.hourlyRate,
|
||||
totalRate: shift.totalRate,
|
||||
orderType: shift.orderType,
|
||||
variant: _variantFromAssignmentStatus(shift.status),
|
||||
);
|
||||
@@ -73,6 +77,7 @@ class ShiftCardData {
|
||||
subtitle: shift.title.isNotEmpty ? shift.title : null,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
clientName: shift.clientName,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRateCents: shift.hourlyRateCents,
|
||||
@@ -91,6 +96,7 @@ class ShiftCardData {
|
||||
title: shift.title,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
clientName: shift.clientName,
|
||||
cancellationReason: shift.cancellationReason,
|
||||
variant: ShiftCardVariant.cancelled,
|
||||
);
|
||||
@@ -104,6 +110,7 @@ class ShiftCardData {
|
||||
subtitle: assignment.title.isNotEmpty ? assignment.title : null,
|
||||
location: assignment.location,
|
||||
date: assignment.startTime,
|
||||
clientName: assignment.clientName,
|
||||
startTime: assignment.startTime,
|
||||
endTime: assignment.endTime,
|
||||
variant: ShiftCardVariant.pending,
|
||||
@@ -119,6 +126,9 @@ class ShiftCardData {
|
||||
/// Optional secondary text (e.g. location under the role name).
|
||||
final String? subtitle;
|
||||
|
||||
/// Client/business name.
|
||||
final String? clientName;
|
||||
|
||||
/// Human-readable location label.
|
||||
final String location;
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||
|
||||
/// Date, time, location, and worked-hours rows.
|
||||
/// Date, client name, location, and worked-hours metadata rows.
|
||||
///
|
||||
/// Follows the AvailableOrderCard element ordering:
|
||||
/// date -> client name -> location.
|
||||
class ShiftCardMetadataRows extends StatelessWidget {
|
||||
/// Creates a [ShiftCardMetadataRows].
|
||||
const ShiftCardMetadataRows({super.key, required this.data});
|
||||
@@ -15,62 +18,71 @@ class ShiftCardMetadataRows extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Date and time row
|
||||
// Date row (with optional worked duration for completed shifts).
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatDate(context, data.date),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
if (data.startTime != null && data.endTime != null) ...<Widget>[
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}',
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
if (data.minutesWorked != null) ...<Widget>[
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatWorkedDuration(data.minutesWorked!),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Client name row.
|
||||
if (data.clientName != null && data.clientName!.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.building,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.clientName!,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Location row.
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
// Location row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.location,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -80,6 +92,7 @@ class ShiftCardMetadataRows extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats [date] relative to today/tomorrow, or as "EEE, MMM d".
|
||||
String _formatDate(BuildContext context, DateTime date) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
@@ -92,8 +105,7 @@ class ShiftCardMetadataRows extends StatelessWidget {
|
||||
return DateFormat('EEE, MMM d').format(date);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
/// Formats total minutes worked into a "Xh Ym" string.
|
||||
String _formatWorkedDuration(int totalMinutes) {
|
||||
final int hours = totalMinutes ~/ 60;
|
||||
final int mins = totalMinutes % 60;
|
||||
|
||||
@@ -70,8 +70,8 @@ class ShiftCardStatusBadge extends StatelessWidget {
|
||||
case ShiftCardVariant.cancelled:
|
||||
return ShiftCardStatusStyle(
|
||||
label: context.t.staff_shifts.my_shifts_tab.card.cancelled,
|
||||
foreground: UiColors.destructive,
|
||||
dot: UiColors.destructive,
|
||||
foreground: UiColors.mutedForeground,
|
||||
dot: UiColors.mutedForeground,
|
||||
);
|
||||
case ShiftCardVariant.completed:
|
||||
return ShiftCardStatusStyle(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||
|
||||
/// Title row with optional pay summary on the right.
|
||||
/// Title row showing role name + pay headline, with a time subtitle + pay detail
|
||||
/// row below. Matches the AvailableOrderCard layout.
|
||||
class ShiftCardTitleRow extends StatelessWidget {
|
||||
/// Creates a [ShiftCardTitleRow].
|
||||
const ShiftCardTitleRow({super.key, required this.data});
|
||||
@@ -12,77 +14,78 @@ class ShiftCardTitleRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determine if we have enough data to show pay information.
|
||||
final bool hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0;
|
||||
final bool hasComputedRate =
|
||||
data.hourlyRateCents != null &&
|
||||
data.startTime != null &&
|
||||
data.endTime != null;
|
||||
final bool hasPay = hasDirectRate || hasComputedRate;
|
||||
|
||||
if (!hasDirectRate && !hasComputedRate) {
|
||||
return Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
// Compute pay values when available.
|
||||
double hourlyRate = 0;
|
||||
double estimatedTotal = 0;
|
||||
double durationHours = 0;
|
||||
|
||||
if (hasPay) {
|
||||
if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) {
|
||||
hourlyRate = data.hourlyRate!;
|
||||
estimatedTotal = data.totalRate!;
|
||||
durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0;
|
||||
} else if (hasComputedRate) {
|
||||
hourlyRate = data.hourlyRateCents! / 100;
|
||||
final int durationMinutes =
|
||||
data.endTime!.difference(data.startTime!).inMinutes;
|
||||
double hours = durationMinutes / 60;
|
||||
if (hours < 0) hours += 24;
|
||||
durationHours = hours.roundToDouble();
|
||||
estimatedTotal = hourlyRate * durationHours;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer pre-computed values from the API when available.
|
||||
final double hourlyRate;
|
||||
final double estimatedTotal;
|
||||
final double durationHours;
|
||||
|
||||
if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) {
|
||||
hourlyRate = data.hourlyRate!;
|
||||
estimatedTotal = data.totalRate!;
|
||||
durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0;
|
||||
} else {
|
||||
hourlyRate = data.hourlyRateCents! / 100;
|
||||
final int durationMinutes = data.endTime!
|
||||
.difference(data.startTime!)
|
||||
.inMinutes;
|
||||
double hours = durationMinutes / 60;
|
||||
if (hours < 0) hours += 24;
|
||||
durationHours = hours.roundToDouble();
|
||||
estimatedTotal = hourlyRate * durationHours;
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
// Row 1: Title + Pay headline
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (data.subtitle != null) ...<Widget>[
|
||||
Text(
|
||||
data.subtitle!,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
if (hasPay)
|
||||
Text(
|
||||
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Row 2: Time subtitle + pay detail
|
||||
if (data.startTime != null && data.endTime != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
if (hasPay)
|
||||
Text(
|
||||
'\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] to a compact time string like "3:30pm".
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mma').format(dt).toLowerCase();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A banner displaying the cancellation reason for a cancelled shift.
|
||||
///
|
||||
/// Uses error styling to draw attention to the cancellation without being
|
||||
/// overly alarming. Shown at the top of the shift details page when the
|
||||
/// shift has been cancelled with a reason.
|
||||
class CancellationReasonBanner extends StatelessWidget {
|
||||
/// Creates a [CancellationReasonBanner].
|
||||
const CancellationReasonBanner({
|
||||
super.key,
|
||||
required this.reason,
|
||||
required this.titleLabel,
|
||||
});
|
||||
|
||||
/// The cancellation reason text.
|
||||
final String reason;
|
||||
|
||||
/// Localized title label (e.g., "Shift Cancelled").
|
||||
final String titleLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
vertical: UiConstants.space4,
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagError,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(
|
||||
color: UiColors.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.error,
|
||||
color: UiColors.error,
|
||||
size: UiConstants.iconMd,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
titleLabel,
|
||||
style: UiTypography.body2b.copyWith(color: UiColors.error),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
reason,
|
||||
style: UiTypography.body3r.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ class ShiftDateTimeSection extends StatelessWidget {
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: 20,
|
||||
color: UiColors.primary,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
|
||||
@@ -32,7 +32,7 @@ class ShiftDescriptionSection extends StatelessWidget {
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
description,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
style: UiTypography.body2r,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -17,56 +17,34 @@ class ShiftDetailsHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: _kIconContainerSize,
|
||||
height: _kIconContainerSize,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withAlpha(20),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.primary, width: 0.5),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: _kIconContainerSize,
|
||||
height: _kIconContainerSize,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withAlpha(20),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.primary, width: 0.5),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: 20,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(detail.title, style: UiTypography.headline1b.textPrimary),
|
||||
Text(detail.roleName, style: UiTypography.body1m.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 16,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
detail.address ?? detail.location,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(detail.roleName, style: UiTypography.headline1b.textPrimary),
|
||||
Text(detail.clientName, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// A section displaying the shift's location, address, and "Get direction" action.
|
||||
@@ -10,6 +11,8 @@ class ShiftLocationSection extends StatelessWidget {
|
||||
super.key,
|
||||
required this.location,
|
||||
required this.address,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.locationLabel,
|
||||
required this.tbdLabel,
|
||||
required this.getDirectionLabel,
|
||||
@@ -21,6 +24,12 @@ class ShiftLocationSection extends StatelessWidget {
|
||||
/// Street address.
|
||||
final String address;
|
||||
|
||||
/// Latitude coordinate for map preview.
|
||||
final double? latitude;
|
||||
|
||||
/// Longitude coordinate for map preview.
|
||||
final double? longitude;
|
||||
|
||||
/// Localization string for location section title.
|
||||
final String locationLabel;
|
||||
|
||||
@@ -97,15 +106,53 @@ class ShiftLocationSection extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (latitude != null && longitude != null) ...<Widget>[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
child: SizedBox(
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
child: GoogleMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: LatLng(latitude!, longitude!),
|
||||
zoom: 15,
|
||||
),
|
||||
markers: <Marker>{
|
||||
Marker(
|
||||
markerId: const MarkerId('shift_location'),
|
||||
position: LatLng(latitude!, longitude!),
|
||||
),
|
||||
},
|
||||
liteModeEnabled: true,
|
||||
myLocationButtonEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
mapToolbarEnabled: false,
|
||||
compassEnabled: false,
|
||||
rotateGesturesEnabled: false,
|
||||
scrollGesturesEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
zoomGesturesEnabled: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openDirections(BuildContext context) async {
|
||||
final String destination = Uri.encodeComponent(
|
||||
address.isNotEmpty ? address : location,
|
||||
);
|
||||
String destination;
|
||||
if (latitude != null && longitude != null) {
|
||||
destination = '$latitude,$longitude';
|
||||
} else {
|
||||
destination = Uri.encodeComponent(
|
||||
address.isNotEmpty ? address : location,
|
||||
);
|
||||
}
|
||||
|
||||
final String url =
|
||||
'https://www.google.com/maps/dir/?api=1&destination=$destination';
|
||||
|
||||
@@ -6,19 +6,23 @@ import 'package:geolocator/geolocator.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/available_order_card.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
||||
|
||||
/// Tab showing open shifts available for the worker to browse and apply.
|
||||
/// Tab showing available orders for the worker to browse and book.
|
||||
///
|
||||
/// Replaces the former open-shift listing with order-level marketplace cards.
|
||||
/// Tapping a card navigates to the order details page.
|
||||
class FindShiftsTab extends StatefulWidget {
|
||||
/// Creates a [FindShiftsTab].
|
||||
const FindShiftsTab({
|
||||
super.key,
|
||||
required this.availableJobs,
|
||||
required this.availableOrders,
|
||||
this.profileComplete = true,
|
||||
});
|
||||
|
||||
/// Open shifts loaded from the V2 API.
|
||||
final List<OpenShift> availableJobs;
|
||||
/// Available orders loaded from the V2 API.
|
||||
final List<AvailableOrder> availableOrders;
|
||||
|
||||
/// Whether the worker's profile is complete.
|
||||
final bool profileComplete;
|
||||
@@ -148,6 +152,7 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
return DateFormat('EEE, MMM d').format(date);
|
||||
}
|
||||
|
||||
/// Builds a filter tab chip.
|
||||
Widget _buildFilterTab(String id, String label) {
|
||||
final bool isSelected = _jobType == id;
|
||||
return GestureDetector(
|
||||
@@ -175,192 +180,40 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
List<OpenShift> _filterByType(List<OpenShift> shifts) {
|
||||
if (_jobType == 'all') return shifts;
|
||||
return shifts.where((OpenShift s) {
|
||||
if (_jobType == 'one-day') return s.orderType == OrderType.oneTime;
|
||||
if (_jobType == 'multi-day') return s.orderType == OrderType.recurring;
|
||||
if (_jobType == 'long-term') return s.orderType == OrderType.permanent;
|
||||
/// Filters orders by the selected order type tab.
|
||||
List<AvailableOrder> _filterByType(List<AvailableOrder> orders) {
|
||||
if (_jobType == 'all') return orders;
|
||||
return orders.where((AvailableOrder o) {
|
||||
if (_jobType == 'one-day') return o.orderType == OrderType.oneTime;
|
||||
if (_jobType == 'multi-day') return o.orderType == OrderType.recurring;
|
||||
if (_jobType == 'long-term') return o.orderType == OrderType.permanent;
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Builds an open shift card.
|
||||
Widget _buildOpenShiftCard(BuildContext context, OpenShift shift) {
|
||||
final double hourlyRate = shift.hourlyRateCents / 100;
|
||||
final int minutes = shift.endTime.difference(shift.startTime).inMinutes;
|
||||
final double duration = minutes / 60;
|
||||
final double estimatedTotal = hourlyRate * duration;
|
||||
|
||||
String typeLabel;
|
||||
switch (shift.orderType) {
|
||||
case OrderType.permanent:
|
||||
typeLabel = t.staff_shifts.filter.long_term;
|
||||
case OrderType.recurring:
|
||||
typeLabel = t.staff_shifts.filter.multi_day;
|
||||
case OrderType.oneTime:
|
||||
default:
|
||||
typeLabel = t.staff_shifts.filter.one_day;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Type badge
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.background,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Text(
|
||||
typeLabel,
|
||||
style: UiTypography.footnote2m
|
||||
.copyWith(color: UiColors.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
UiColors.primary.withValues(alpha: 0.09),
|
||||
UiColors.primary.withValues(alpha: 0.03),
|
||||
],
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(UiIcons.briefcase,
|
||||
color: UiColors.primary, size: UiConstants.iconMd),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(shift.roleName,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
Text(shift.location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text('\$${estimatedTotal.toStringAsFixed(0)}',
|
||||
style: UiTypography.title1m.textPrimary),
|
||||
Text(
|
||||
'\$${hourlyRate.toInt()}/hr \u00b7 ${duration.toInt()}h',
|
||||
style:
|
||||
UiTypography.footnote2r.textSecondary),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(_formatDate(shift.date),
|
||||
style: UiTypography.footnote1r.textSecondary),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}',
|
||||
style: UiTypography.footnote1r.textSecondary),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.mapPin,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(shift.location,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Client-side filter by order type
|
||||
final List<OpenShift> filteredJobs =
|
||||
_filterByType(widget.availableJobs).where((OpenShift s) {
|
||||
// Client-side filter by order type and search query
|
||||
final List<AvailableOrder> filteredOrders =
|
||||
_filterByType(widget.availableOrders).where((AvailableOrder o) {
|
||||
final String q = _searchQuery.toLowerCase();
|
||||
final bool matchesSearch = _searchQuery.isEmpty ||
|
||||
s.roleName.toLowerCase().contains(q) ||
|
||||
s.location.toLowerCase().contains(q);
|
||||
o.roleName.toLowerCase().contains(q) ||
|
||||
o.clientName.toLowerCase().contains(q) ||
|
||||
o.location.toLowerCase().contains(q);
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
// Note: Distance filter is currently disabled as OpenShift model
|
||||
// Note: Distance filter is currently disabled as AvailableOrder model
|
||||
// from the V2 API does not yet include latitude/longitude coordinates.
|
||||
/*
|
||||
if (_maxDistance != null && _currentPosition != null) {
|
||||
// final double dist = _calculateDistance(s.latitude!, s.longitude!);
|
||||
// final double dist = _calculateDistance(o.latitude!, o.longitude!);
|
||||
// if (dist > _maxDistance!) return false;
|
||||
}
|
||||
*/
|
||||
|
||||
return true;
|
||||
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
@@ -446,7 +299,7 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: filteredJobs.isEmpty
|
||||
child: filteredOrders.isEmpty
|
||||
? EmptyStateView(
|
||||
icon: UiIcons.search,
|
||||
title: context.t.staff_shifts.find_shifts.no_jobs_title,
|
||||
@@ -459,9 +312,11 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
...filteredJobs.map(
|
||||
(OpenShift shift) =>
|
||||
_buildOpenShiftCard(context, shift),
|
||||
...filteredOrders.map(
|
||||
(AvailableOrder order) => AvailableOrderCard(
|
||||
order: order,
|
||||
onTap: () => Modular.to.toOrderDetails(order),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space32),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A section header with a colored dot indicator and title text.
|
||||
class SectionHeader extends StatelessWidget {
|
||||
/// Creates a [SectionHeader].
|
||||
const SectionHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.dotColor,
|
||||
});
|
||||
|
||||
/// The header title text.
|
||||
final String title;
|
||||
|
||||
/// The color of the leading dot indicator.
|
||||
final Color dotColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
title,
|
||||
style: dotColor == UiColors.textSecondary
|
||||
? UiTypography.body2b.textSecondary
|
||||
: UiTypography.body2b.copyWith(color: dotColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext;
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart';
|
||||
import 'section_header.dart';
|
||||
|
||||
/// Scrollable list displaying pending, cancelled, and confirmed shift sections.
|
||||
///
|
||||
/// Renders each section with a [SectionHeader] and a list of [ShiftCard]
|
||||
/// widgets. Shows an [EmptyStateView] when all sections are empty.
|
||||
class ShiftSectionList extends StatelessWidget {
|
||||
/// Creates a [ShiftSectionList].
|
||||
const ShiftSectionList({
|
||||
super.key,
|
||||
required this.assignedShifts,
|
||||
required this.pendingAssignments,
|
||||
required this.cancelledShifts,
|
||||
this.submittedShiftIds = const <String>{},
|
||||
this.submittingShiftId,
|
||||
});
|
||||
|
||||
/// Confirmed/assigned shifts visible for the selected day.
|
||||
final List<AssignedShift> assignedShifts;
|
||||
|
||||
/// Pending assignments awaiting acceptance.
|
||||
final List<PendingAssignment> pendingAssignments;
|
||||
|
||||
/// Cancelled shifts visible for the selected week.
|
||||
final List<CancelledShift> cancelledShifts;
|
||||
|
||||
/// Set of shift IDs that have been successfully submitted for approval.
|
||||
final Set<String> submittedShiftIds;
|
||||
|
||||
/// The shift ID currently being submitted (null when idle).
|
||||
final String? submittingShiftId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
|
||||
// Pending assignments section
|
||||
if (pendingAssignments.isNotEmpty) ...<Widget>[
|
||||
SectionHeader(
|
||||
title:
|
||||
context.t.staff_shifts.my_shifts_tab.sections.awaiting,
|
||||
dotColor: UiColors.textWarning,
|
||||
),
|
||||
...pendingAssignments.map(
|
||||
(PendingAssignment assignment) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space4,
|
||||
),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromPending(assignment),
|
||||
onTap: () =>
|
||||
Modular.to.toShiftDetailsById(assignment.shiftId),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
],
|
||||
|
||||
// Cancelled shifts section
|
||||
if (cancelledShifts.isNotEmpty) ...<Widget>[
|
||||
SectionHeader(
|
||||
title:
|
||||
context.t.staff_shifts.my_shifts_tab.sections.cancelled,
|
||||
dotColor: UiColors.textSecondary,
|
||||
),
|
||||
...cancelledShifts.map(
|
||||
(CancelledShift cs) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space4,
|
||||
),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromCancelled(cs),
|
||||
onTap: () =>
|
||||
Modular.to.toShiftDetailsById(cs.shiftId),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
],
|
||||
|
||||
// Confirmed shifts section
|
||||
if (assignedShifts.isNotEmpty) ...<Widget>[
|
||||
SectionHeader(
|
||||
title:
|
||||
context.t.staff_shifts.my_shifts_tab.sections.confirmed,
|
||||
dotColor: UiColors.textSuccess,
|
||||
),
|
||||
...assignedShifts.map(
|
||||
(AssignedShift shift) {
|
||||
final bool isCompleted =
|
||||
shift.status == AssignmentStatus.completed;
|
||||
final bool isSubmitted =
|
||||
submittedShiftIds.contains(shift.shiftId);
|
||||
final bool isSubmitting =
|
||||
submittingShiftId == shift.shiftId;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space3,
|
||||
),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromAssigned(shift),
|
||||
onTap: () =>
|
||||
Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
showApprovalAction: isCompleted,
|
||||
isSubmitted: isSubmitted,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmitForApproval: () {
|
||||
ReadContext(context).read<ShiftsBloc>().add(
|
||||
SubmitForApprovalEvent(
|
||||
shiftId: shift.shiftId,
|
||||
),
|
||||
);
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context.t.staff_shifts
|
||||
.my_shift_card.timesheet_submitted,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// Empty state
|
||||
if (assignedShifts.isEmpty &&
|
||||
pendingAssignments.isEmpty &&
|
||||
cancelledShifts.isEmpty)
|
||||
EmptyStateView(
|
||||
icon: UiIcons.calendar,
|
||||
title: context.t.staff_shifts.my_shifts_tab.empty.title,
|
||||
subtitle:
|
||||
context.t.staff_shifts.my_shifts_tab.empty.subtitle,
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// A week-view calendar selector showing 7 days with navigation arrows.
|
||||
///
|
||||
/// Displays a month/year header with chevron arrows for week navigation and
|
||||
/// a row of day cells. Days with assigned shifts show a dot indicator.
|
||||
class WeekCalendarSelector extends StatelessWidget {
|
||||
/// Creates a [WeekCalendarSelector].
|
||||
const WeekCalendarSelector({
|
||||
super.key,
|
||||
required this.calendarDays,
|
||||
required this.selectedDate,
|
||||
required this.shifts,
|
||||
required this.onDateSelected,
|
||||
required this.onPreviousWeek,
|
||||
required this.onNextWeek,
|
||||
});
|
||||
|
||||
/// The 7 days to display in the calendar row.
|
||||
final List<DateTime> calendarDays;
|
||||
|
||||
/// The currently selected date.
|
||||
final DateTime selectedDate;
|
||||
|
||||
/// Assigned shifts used to show dot indicators on days with shifts.
|
||||
final List<AssignedShift> shifts;
|
||||
|
||||
/// Called when a day cell is tapped.
|
||||
final ValueChanged<DateTime> onDateSelected;
|
||||
|
||||
/// Called when the previous-week chevron is tapped.
|
||||
final VoidCallback onPreviousWeek;
|
||||
|
||||
/// Called when the next-week chevron is tapped.
|
||||
final VoidCallback onNextWeek;
|
||||
|
||||
bool _isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateTime weekStartDate = calendarDays.first;
|
||||
|
||||
return Container(
|
||||
color: UiColors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space4,
|
||||
horizontal: UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
size: 20,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
onPressed: onPreviousWeek,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
Text(
|
||||
DateFormat('MMMM yyyy').format(weekStartDate),
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: 20,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
onPressed: onNextWeek,
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Days Grid
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: calendarDays.map((DateTime date) {
|
||||
final bool isSelected = _isSameDay(date, selectedDate);
|
||||
final bool hasShifts = shifts.any(
|
||||
(AssignedShift s) => _isSameDay(s.date, date),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onDateSelected(date),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 44,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
border: Border.all(
|
||||
color:
|
||||
isSelected ? UiColors.primary : UiColors.border,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
date.day.toString().padLeft(2, '0'),
|
||||
style: isSelected
|
||||
? UiTypography.body1b.white
|
||||
: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: (isSelected
|
||||
? UiTypography.footnote2m.white
|
||||
: UiTypography.footnote2m.textSecondary)
|
||||
.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.white.withValues(alpha: 0.8)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (hasShifts && !isSelected)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: UiConstants.space1,
|
||||
),
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext;
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart';
|
||||
import 'my_shifts/shift_section_list.dart';
|
||||
import 'my_shifts/week_calendar_selector.dart';
|
||||
|
||||
/// Tab displaying the worker's assigned, pending, and cancelled shifts.
|
||||
///
|
||||
/// Manages the calendar selection state and delegates rendering to
|
||||
/// [WeekCalendarSelector] and [ShiftSectionList].
|
||||
class MyShiftsTab extends StatefulWidget {
|
||||
/// Creates a [MyShiftsTab].
|
||||
const MyShiftsTab({
|
||||
@@ -112,76 +111,6 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
void _confirmShift(String id) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
title:
|
||||
Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title),
|
||||
content:
|
||||
Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(context.t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
ReadContext(context).read<ShiftsBloc>().add(AcceptShiftEvent(id));
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context
|
||||
.t.staff_shifts.my_shifts_tab.confirm_dialog.success,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.success,
|
||||
),
|
||||
child:
|
||||
Text(context.t.staff_shifts.shift_details.accept_shift),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _declineShift(String id) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
title:
|
||||
Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title),
|
||||
content: Text(
|
||||
context.t.staff_shifts.my_shifts_tab.decline_dialog.message,
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(context.t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
ReadContext(context).read<ShiftsBloc>().add(DeclineShiftEvent(id));
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context
|
||||
.t.staff_shifts.my_shifts_tab.decline_dialog.success,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
),
|
||||
child: Text(context.t.staff_shifts.shift_details.decline),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<DateTime> calendarDays = _getCalendarDays();
|
||||
@@ -203,274 +132,32 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
// Calendar Selector
|
||||
Container(
|
||||
color: UiColors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space4,
|
||||
horizontal: UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
size: 20,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
onPressed: () => setState(() {
|
||||
_weekOffset--;
|
||||
_selectedDate = _getCalendarDays().first;
|
||||
_loadShiftsForCurrentWeek();
|
||||
}),
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
Text(
|
||||
DateFormat('MMMM yyyy').format(weekStartDate),
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: 20,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
onPressed: () => setState(() {
|
||||
_weekOffset++;
|
||||
_selectedDate = _getCalendarDays().first;
|
||||
_loadShiftsForCurrentWeek();
|
||||
}),
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Days Grid
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: calendarDays.map((DateTime date) {
|
||||
final bool isSelected = _isSameDay(date, _selectedDate);
|
||||
final bool hasShifts = widget.myShifts.any(
|
||||
(AssignedShift s) => _isSameDay(s.date, date),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedDate = date),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 44,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? UiColors.primary
|
||||
: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? UiColors.primary
|
||||
: UiColors.border,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
date.day.toString().padLeft(2, '0'),
|
||||
style: isSelected
|
||||
? UiTypography.body1b.white
|
||||
: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: (isSelected
|
||||
? UiTypography.footnote2m.white
|
||||
: UiTypography
|
||||
.footnote2m.textSecondary)
|
||||
.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.white
|
||||
.withValues(alpha: 0.8)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (hasShifts && !isSelected)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: UiConstants.space1,
|
||||
),
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
WeekCalendarSelector(
|
||||
calendarDays: calendarDays,
|
||||
selectedDate: _selectedDate,
|
||||
shifts: widget.myShifts,
|
||||
onDateSelected: (DateTime date) =>
|
||||
setState(() => _selectedDate = date),
|
||||
onPreviousWeek: () => setState(() {
|
||||
_weekOffset--;
|
||||
_selectedDate = _getCalendarDays().first;
|
||||
_loadShiftsForCurrentWeek();
|
||||
}),
|
||||
onNextWeek: () => setState(() {
|
||||
_weekOffset++;
|
||||
_selectedDate = _getCalendarDays().first;
|
||||
_loadShiftsForCurrentWeek();
|
||||
}),
|
||||
),
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
if (widget.pendingAssignments.isNotEmpty) ...<Widget>[
|
||||
_buildSectionHeader(
|
||||
context
|
||||
.t.staff_shifts.my_shifts_tab.sections.awaiting,
|
||||
UiColors.textWarning,
|
||||
),
|
||||
...widget.pendingAssignments.map(
|
||||
(PendingAssignment assignment) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space4,
|
||||
),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromPending(assignment),
|
||||
onTap: () => Modular.to
|
||||
.toShiftDetailsById(assignment.shiftId),
|
||||
onAccept: () =>
|
||||
_confirmShift(assignment.shiftId),
|
||||
onDecline: () =>
|
||||
_declineShift(assignment.shiftId),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
],
|
||||
|
||||
if (visibleCancelledShifts.isNotEmpty) ...<Widget>[
|
||||
_buildSectionHeader(
|
||||
context
|
||||
.t.staff_shifts.my_shifts_tab.sections.cancelled,
|
||||
UiColors.textSecondary,
|
||||
),
|
||||
...visibleCancelledShifts.map(
|
||||
(CancelledShift cs) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space4,
|
||||
),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromCancelled(cs),
|
||||
onTap: () =>
|
||||
Modular.to.toShiftDetailsById(cs.shiftId),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
],
|
||||
|
||||
// Confirmed Shifts
|
||||
if (visibleMyShifts.isNotEmpty) ...<Widget>[
|
||||
_buildSectionHeader(
|
||||
context
|
||||
.t.staff_shifts.my_shifts_tab.sections.confirmed,
|
||||
UiColors.textSecondary,
|
||||
),
|
||||
...visibleMyShifts.map(
|
||||
(AssignedShift shift) {
|
||||
final bool isCompleted =
|
||||
shift.status == AssignmentStatus.completed;
|
||||
final bool isSubmitted =
|
||||
widget.submittedShiftIds.contains(shift.shiftId);
|
||||
final bool isSubmitting =
|
||||
widget.submittingShiftId == shift.shiftId;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space3,
|
||||
),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromAssigned(shift),
|
||||
onTap: () => Modular.to
|
||||
.toShiftDetailsById(shift.shiftId),
|
||||
showApprovalAction: isCompleted,
|
||||
isSubmitted: isSubmitted,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmitForApproval: () {
|
||||
ReadContext(context).read<ShiftsBloc>().add(
|
||||
SubmitForApprovalEvent(
|
||||
shiftId: shift.shiftId,
|
||||
),
|
||||
);
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context.t.staff_shifts
|
||||
.my_shift_card.timesheet_submitted,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
if (visibleMyShifts.isEmpty &&
|
||||
widget.pendingAssignments.isEmpty &&
|
||||
widget.cancelledShifts.isEmpty)
|
||||
EmptyStateView(
|
||||
icon: UiIcons.calendar,
|
||||
title:
|
||||
context.t.staff_shifts.my_shifts_tab.empty.title,
|
||||
subtitle: context
|
||||
.t.staff_shifts.my_shifts_tab.empty.subtitle,
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space32),
|
||||
],
|
||||
),
|
||||
),
|
||||
ShiftSectionList(
|
||||
assignedShifts: visibleMyShifts,
|
||||
pendingAssignments: widget.pendingAssignments,
|
||||
cancelledShifts: visibleCancelledShifts,
|
||||
submittedShiftIds: widget.submittedShiftIds,
|
||||
submittingShiftId: widget.submittingShiftId,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title, Color dotColor) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
title,
|
||||
style: (dotColor == UiColors.textSecondary
|
||||
? UiTypography.body2b.textSecondary
|
||||
: UiTypography.body2b.copyWith(color: dotColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/data/repositories_impl/shifts_repository_impl.dart';
|
||||
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
|
||||
@@ -30,6 +31,7 @@ class ShiftDetailsModule extends Module {
|
||||
i.add(GetShiftDetailUseCase.new);
|
||||
i.add(ApplyForShiftUseCase.new);
|
||||
i.add(DeclineShiftUseCase.new);
|
||||
i.add(AcceptShiftUseCase.new);
|
||||
i.add(GetProfileCompletionUseCase.new);
|
||||
|
||||
// BLoC
|
||||
@@ -38,6 +40,7 @@ class ShiftDetailsModule extends Module {
|
||||
getShiftDetail: i.get(),
|
||||
applyForShift: i.get(),
|
||||
declineShift: i.get(),
|
||||
acceptShift: i.get(),
|
||||
getProfileCompletion: i.get(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,13 +8,17 @@ import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_data_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart';
|
||||
@@ -49,6 +53,9 @@ class StaffShiftsModule extends Module {
|
||||
i.addLazySingleton(
|
||||
() => SubmitForApprovalUseCase(i.get<ShiftsRepositoryInterface>()),
|
||||
);
|
||||
i.addLazySingleton(GetMyShiftsDataUseCase.new);
|
||||
i.addLazySingleton(GetAvailableOrdersUseCase.new);
|
||||
i.addLazySingleton(BookOrderUseCase.new);
|
||||
|
||||
// BLoC
|
||||
i.add(
|
||||
@@ -62,6 +69,7 @@ class StaffShiftsModule extends Module {
|
||||
acceptShift: i.get(),
|
||||
declineShift: i.get(),
|
||||
submitForApproval: i.get(),
|
||||
getMyShiftsData: i.get(),
|
||||
),
|
||||
);
|
||||
i.add(
|
||||
@@ -69,9 +77,16 @@ class StaffShiftsModule extends Module {
|
||||
getShiftDetail: i.get(),
|
||||
applyForShift: i.get(),
|
||||
declineShift: i.get(),
|
||||
acceptShift: i.get(),
|
||||
getProfileCompletion: i.get(),
|
||||
),
|
||||
);
|
||||
i.add(
|
||||
() => AvailableOrdersBloc(
|
||||
getAvailableOrders: i.get(),
|
||||
bookOrder: i.get(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -2,4 +2,5 @@ library;
|
||||
|
||||
export 'src/staff_shifts_module.dart';
|
||||
export 'src/shift_details_module.dart';
|
||||
export 'src/order_details_module.dart';
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ dependencies:
|
||||
url_launcher: ^6.3.1
|
||||
bloc: ^8.1.4
|
||||
meta: ^1.17.0
|
||||
google_maps_flutter: ^2.5.3
|
||||
google_maps_flutter: ^2.10.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -133,6 +133,10 @@ class StaffMainModule extends Module {
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute),
|
||||
module: ShiftDetailsModule(),
|
||||
);
|
||||
r.module(
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.orderDetailsRoute),
|
||||
module: OrderDetailsModule(),
|
||||
);
|
||||
r.module(
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs),
|
||||
module: FaqsModule(),
|
||||
|
||||
Reference in New Issue
Block a user