Merge dev into feature branch
This commit is contained in:
@@ -147,6 +147,16 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitForApproval(String shiftId, {String? note}) async {
|
||||
await _apiService.post(
|
||||
StaffEndpoints.shiftSubmitForApproval(shiftId),
|
||||
data: <String, dynamic>{
|
||||
if (note != null) 'note': note,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getProfileCompletion() async {
|
||||
final ApiResponse response =
|
||||
|
||||
@@ -47,4 +47,9 @@ abstract interface class ShiftsRepositoryInterface {
|
||||
|
||||
/// Returns whether the staff profile is complete.
|
||||
Future<bool> getProfileCompletion();
|
||||
|
||||
/// Submits a completed shift for timesheet approval.
|
||||
///
|
||||
/// Only allowed for shifts in CHECKED_OUT or COMPLETED status.
|
||||
Future<void> submitForApproval(String shiftId, {String? note});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
|
||||
|
||||
/// Submits a completed shift for timesheet approval.
|
||||
///
|
||||
/// Delegates to [ShiftsRepositoryInterface.submitForApproval] which calls
|
||||
/// `POST /staff/shifts/:shiftId/submit-for-approval`.
|
||||
class SubmitForApprovalUseCase {
|
||||
/// Creates a [SubmitForApprovalUseCase].
|
||||
SubmitForApprovalUseCase(this.repository);
|
||||
|
||||
/// The shifts repository.
|
||||
final ShiftsRepositoryInterface repository;
|
||||
|
||||
/// Executes the use case.
|
||||
Future<void> call(String shiftId, {String? note}) async {
|
||||
return repository.submitForApproval(shiftId, note: note);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Computes a Friday-based week calendar for the given [weekOffset].
|
||||
///
|
||||
/// Returns a list of 7 [DateTime] values starting from the Friday of the
|
||||
/// week identified by [weekOffset] (0 = current week, negative = past,
|
||||
/// positive = future). Each date is midnight-normalised.
|
||||
List<DateTime> getCalendarDaysForOffset(int weekOffset) {
|
||||
final DateTime now = DateTime.now();
|
||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||
final DateTime start = now
|
||||
.subtract(Duration(days: daysSinceFriday))
|
||||
.add(Duration(days: weekOffset * 7));
|
||||
final DateTime startDate = DateTime(start.year, start.month, start.day);
|
||||
return List<DateTime>.generate(
|
||||
7,
|
||||
(int index) => startDate.add(Duration(days: index)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Filters out [OpenShift] entries whose date is strictly before today.
|
||||
///
|
||||
/// Comparison is done at midnight granularity so shifts scheduled for
|
||||
/// today are always included.
|
||||
List<OpenShift> filterPastOpenShifts(List<OpenShift> shifts) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
return shifts.where((OpenShift shift) {
|
||||
final DateTime dateOnly = DateTime(
|
||||
shift.date.year,
|
||||
shift.date.month,
|
||||
shift.date.day,
|
||||
);
|
||||
return !dateOnly.isBefore(today);
|
||||
}).toList();
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
||||
isProfileComplete: isProfileComplete,
|
||||
));
|
||||
} else {
|
||||
emit(const ShiftDetailsError('Shift not found'));
|
||||
emit(const ShiftDetailsError('errors.shift.not_found'));
|
||||
}
|
||||
},
|
||||
onError: (String errorKey) => ShiftDetailsError(errorKey),
|
||||
@@ -74,7 +74,7 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
||||
);
|
||||
emit(
|
||||
ShiftActionSuccess(
|
||||
'Shift successfully booked!',
|
||||
'shift_booked',
|
||||
shiftDate: event.date,
|
||||
),
|
||||
);
|
||||
@@ -91,7 +91,7 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await declineShift(event.shiftId);
|
||||
emit(const ShiftActionSuccess('Shift declined'));
|
||||
emit(const ShiftActionSuccess('shift_declined_success'));
|
||||
},
|
||||
onError: (String errorKey) => ShiftDetailsError(errorKey),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,8 @@ import 'package:staff_shifts/src/domain/usecases/get_history_shifts_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/submit_for_approval_usecase.dart';
|
||||
import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart';
|
||||
|
||||
part 'shifts_event.dart';
|
||||
part 'shifts_state.dart';
|
||||
@@ -31,6 +33,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
required this.getProfileCompletion,
|
||||
required this.acceptShift,
|
||||
required this.declineShift,
|
||||
required this.submitForApproval,
|
||||
}) : super(const ShiftsState()) {
|
||||
on<LoadShiftsEvent>(_onLoadShifts);
|
||||
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
|
||||
@@ -41,6 +44,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
on<CheckProfileCompletionEvent>(_onCheckProfileCompletion);
|
||||
on<AcceptShiftEvent>(_onAcceptShift);
|
||||
on<DeclineShiftEvent>(_onDeclineShift);
|
||||
on<SubmitForApprovalEvent>(_onSubmitForApproval);
|
||||
}
|
||||
|
||||
/// Use case for assigned shifts.
|
||||
@@ -67,6 +71,9 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
/// Use case for declining a shift.
|
||||
final DeclineShiftUseCase declineShift;
|
||||
|
||||
/// Use case for submitting a shift for timesheet approval.
|
||||
final SubmitForApprovalUseCase submitForApproval;
|
||||
|
||||
Future<void> _onLoadShifts(
|
||||
LoadShiftsEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
@@ -78,7 +85,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<DateTime> days = _getCalendarDaysForOffset(0);
|
||||
final List<DateTime> days = getCalendarDaysForOffset(0);
|
||||
|
||||
// Load assigned, pending, and cancelled shifts in parallel.
|
||||
final List<Object> results = await Future.wait(<Future<Object>>[
|
||||
@@ -110,6 +117,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
historyLoaded: false,
|
||||
myShiftsLoaded: true,
|
||||
searchQuery: '',
|
||||
clearErrorMessage: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -136,6 +144,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
historyShifts: historyResult,
|
||||
historyLoading: false,
|
||||
historyLoaded: true,
|
||||
clearErrorMessage: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -167,9 +176,10 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
availableShifts: _filterPastOpenShifts(availableResult),
|
||||
availableShifts: filterPastOpenShifts(availableResult),
|
||||
availableLoading: false,
|
||||
availableLoaded: true,
|
||||
clearErrorMessage: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -219,9 +229,10 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ShiftsStatus.loaded,
|
||||
availableShifts: _filterPastOpenShifts(availableResult),
|
||||
availableShifts: filterPastOpenShifts(availableResult),
|
||||
availableLoading: false,
|
||||
availableLoaded: true,
|
||||
clearErrorMessage: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -239,6 +250,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
LoadShiftsForRangeEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(myShifts: const <AssignedShift>[], myShiftsLoaded: false));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
@@ -251,6 +263,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
status: ShiftsStatus.loaded,
|
||||
myShifts: myShiftsResult,
|
||||
myShiftsLoaded: true,
|
||||
clearErrorMessage: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -281,7 +294,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
availableShifts: _filterPastOpenShifts(result),
|
||||
availableShifts: filterPastOpenShifts(result),
|
||||
searchQuery: search,
|
||||
),
|
||||
);
|
||||
@@ -342,30 +355,37 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets calendar days for the given week offset (Friday-based week).
|
||||
List<DateTime> _getCalendarDaysForOffset(int weekOffset) {
|
||||
final DateTime now = DateTime.now();
|
||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||
final DateTime start = now
|
||||
.subtract(Duration(days: daysSinceFriday))
|
||||
.add(Duration(days: weekOffset * 7));
|
||||
final DateTime startDate = DateTime(start.year, start.month, start.day);
|
||||
return List<DateTime>.generate(
|
||||
7, (int index) => startDate.add(Duration(days: index)));
|
||||
Future<void> _onSubmitForApproval(
|
||||
SubmitForApprovalEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
// Guard: another submission is already in progress.
|
||||
if (state.submittingShiftId != null) return;
|
||||
// Guard: this shift was already submitted.
|
||||
if (state.submittedShiftIds.contains(event.shiftId)) return;
|
||||
|
||||
emit(state.copyWith(submittingShiftId: event.shiftId));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await submitForApproval(event.shiftId, note: event.note);
|
||||
emit(
|
||||
state.copyWith(
|
||||
clearSubmittingShiftId: true,
|
||||
clearErrorMessage: true,
|
||||
submittedShiftIds: <String>{
|
||||
...state.submittedShiftIds,
|
||||
event.shiftId,
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
clearSubmittingShiftId: true,
|
||||
status: ShiftsStatus.error,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Filters out open shifts whose date is in the past.
|
||||
List<OpenShift> _filterPastOpenShifts(List<OpenShift> shifts) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
return shifts.where((OpenShift shift) {
|
||||
final DateTime dateOnly = DateTime(
|
||||
shift.date.year,
|
||||
shift.date.month,
|
||||
shift.date.day,
|
||||
);
|
||||
return !dateOnly.isBefore(today);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,3 +93,18 @@ class CheckProfileCompletionEvent extends ShiftsEvent {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Submits a completed shift for timesheet approval.
|
||||
class SubmitForApprovalEvent extends ShiftsEvent {
|
||||
/// Creates a [SubmitForApprovalEvent].
|
||||
const SubmitForApprovalEvent({required this.shiftId, this.note});
|
||||
|
||||
/// The shift row id to submit.
|
||||
final String shiftId;
|
||||
|
||||
/// Optional note to include with the submission.
|
||||
final String? note;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[shiftId, note];
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ class ShiftsState extends Equatable {
|
||||
this.searchQuery = '',
|
||||
this.profileComplete,
|
||||
this.errorMessage,
|
||||
this.submittingShiftId,
|
||||
this.submittedShiftIds = const <String>{},
|
||||
});
|
||||
|
||||
/// Current lifecycle status.
|
||||
@@ -65,6 +67,12 @@ class ShiftsState extends Equatable {
|
||||
/// Error message key for display.
|
||||
final String? errorMessage;
|
||||
|
||||
/// The shift ID currently being submitted for approval (null when idle).
|
||||
final String? submittingShiftId;
|
||||
|
||||
/// Set of shift IDs that have been successfully submitted for approval.
|
||||
final Set<String> submittedShiftIds;
|
||||
|
||||
/// Creates a copy with the given fields replaced.
|
||||
ShiftsState copyWith({
|
||||
ShiftsStatus? status,
|
||||
@@ -81,6 +89,10 @@ class ShiftsState extends Equatable {
|
||||
String? searchQuery,
|
||||
bool? profileComplete,
|
||||
String? errorMessage,
|
||||
bool clearErrorMessage = false,
|
||||
String? submittingShiftId,
|
||||
bool clearSubmittingShiftId = false,
|
||||
Set<String>? submittedShiftIds,
|
||||
}) {
|
||||
return ShiftsState(
|
||||
status: status ?? this.status,
|
||||
@@ -96,7 +108,13 @@ class ShiftsState extends Equatable {
|
||||
myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded,
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
profileComplete: profileComplete ?? this.profileComplete,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
errorMessage: clearErrorMessage
|
||||
? null
|
||||
: (errorMessage ?? this.errorMessage),
|
||||
submittingShiftId: clearSubmittingShiftId
|
||||
? null
|
||||
: (submittingShiftId ?? this.submittingShiftId),
|
||||
submittedShiftIds: submittedShiftIds ?? this.submittedShiftIds,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,5 +134,7 @@ class ShiftsState extends Equatable {
|
||||
searchQuery,
|
||||
profileComplete,
|
||||
errorMessage,
|
||||
submittingShiftId,
|
||||
submittedShiftIds,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -47,12 +47,6 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
return DateFormat('EEEE, MMMM d, y').format(dt);
|
||||
}
|
||||
|
||||
double _calculateDuration(ShiftDetail detail) {
|
||||
final int minutes = detail.endTime.difference(detail.startTime).inMinutes;
|
||||
final double hours = minutes / 60;
|
||||
return hours < 0 ? hours + 24 : hours;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ShiftDetailsBloc>(
|
||||
@@ -67,7 +61,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
_isApplying = false;
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.message,
|
||||
message: _translateSuccessKey(context, state.message),
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Modular.to.toShifts(
|
||||
@@ -98,14 +92,8 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
}
|
||||
|
||||
final ShiftDetail detail = state.detail;
|
||||
final dynamic i18n =
|
||||
Translations.of(context).staff_shifts.shift_details;
|
||||
final bool isProfileComplete = state.isProfileComplete;
|
||||
|
||||
final double duration = _calculateDuration(detail);
|
||||
final double hourlyRate = detail.hourlyRateCents / 100;
|
||||
final double estimatedTotal = hourlyRate * duration;
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
centerTitle: false,
|
||||
@@ -122,45 +110,46 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
child: UiNoticeBanner(
|
||||
title: 'Complete Your Account',
|
||||
description:
|
||||
'Complete your account to book this shift and start earning',
|
||||
title: context.t.staff_shifts.shift_details
|
||||
.complete_account_title,
|
||||
description: context.t.staff_shifts.shift_details
|
||||
.complete_account_description,
|
||||
icon: UiIcons.sparkles,
|
||||
),
|
||||
),
|
||||
ShiftDetailsHeader(detail: detail),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
ShiftStatsRow(
|
||||
estimatedTotal: estimatedTotal,
|
||||
hourlyRate: hourlyRate,
|
||||
duration: duration,
|
||||
totalLabel: i18n.est_total,
|
||||
hourlyRateLabel: i18n.hourly_rate,
|
||||
hoursLabel: i18n.hours,
|
||||
estimatedTotal: detail.estimatedTotal,
|
||||
hourlyRate: detail.hourlyRate,
|
||||
duration: detail.durationHours,
|
||||
totalLabel: 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),
|
||||
ShiftDateTimeSection(
|
||||
date: detail.date,
|
||||
startTime: detail.startTime,
|
||||
endTime: detail.endTime,
|
||||
shiftDateLabel: i18n.shift_date,
|
||||
clockInLabel: i18n.start_time,
|
||||
clockOutLabel: i18n.end_time,
|
||||
shiftDateLabel: context.t.staff_shifts.shift_details.shift_date,
|
||||
clockInLabel: context.t.staff_shifts.shift_details.start_time,
|
||||
clockOutLabel: context.t.staff_shifts.shift_details.end_time,
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
ShiftLocationSection(
|
||||
location: detail.location,
|
||||
address: detail.address ?? '',
|
||||
locationLabel: i18n.location,
|
||||
tbdLabel: i18n.tbd,
|
||||
getDirectionLabel: i18n.get_direction,
|
||||
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,
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
if (detail.description != null &&
|
||||
detail.description!.isNotEmpty)
|
||||
ShiftDescriptionSection(
|
||||
description: detail.description!,
|
||||
descriptionLabel: i18n.job_description,
|
||||
descriptionLabel: context.t.staff_shifts.shift_details.job_description,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -190,13 +179,11 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
}
|
||||
|
||||
void _bookShift(BuildContext context, ShiftDetail detail) {
|
||||
final dynamic i18n =
|
||||
Translations.of(context).staff_shifts.shift_details.book_dialog;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
title: Text(i18n.title as String),
|
||||
content: Text(i18n.message as String),
|
||||
title: Text(context.t.staff_shifts.shift_details.book_dialog.title),
|
||||
content: Text(context.t.staff_shifts.shift_details.book_dialog.message),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Modular.to.popSafe(),
|
||||
@@ -228,14 +215,12 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
if (_actionDialogOpen) return;
|
||||
_actionDialogOpen = true;
|
||||
_isApplying = true;
|
||||
final dynamic i18n =
|
||||
Translations.of(context).staff_shifts.shift_details.applying_dialog;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
title: Text(i18n.title as String),
|
||||
title: Text(context.t.staff_shifts.shift_details.applying_dialog.title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
@@ -250,7 +235,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatDate(detail.date)} \u2022 ${_formatTime(detail.startTime)} - ${_formatTime(detail.endTime)}',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
@@ -270,6 +255,18 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
_actionDialogOpen = false;
|
||||
}
|
||||
|
||||
/// Translates a success message key to a localized string.
|
||||
String _translateSuccessKey(BuildContext context, String key) {
|
||||
switch (key) {
|
||||
case 'shift_booked':
|
||||
return context.t.staff_shifts.shift_details.shift_booked;
|
||||
case 'shift_declined_success':
|
||||
return context.t.staff_shifts.shift_details.shift_declined_success;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
void _showEligibilityErrorDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
@@ -288,16 +285,16 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'You are missing required certifications or documents to claim this shift. Please upload them to continue.',
|
||||
context.t.staff_shifts.shift_details.missing_certifications,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
actions: <Widget>[
|
||||
UiButton.secondary(
|
||||
text: 'Cancel',
|
||||
text: Translations.of(context).common.cancel,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
UiButton.primary(
|
||||
text: 'Go to Certificates',
|
||||
text: context.t.staff_shifts.shift_details.go_to_certificates,
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();
|
||||
Modular.to.toCertificates();
|
||||
|
||||
@@ -12,10 +12,15 @@ import 'package:staff_shifts/src/presentation/widgets/tabs/my_shifts_tab.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/tabs/find_shifts_tab.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.dart';
|
||||
|
||||
/// Tabbed page for browsing staff shifts (My Shifts, Find Work, History).
|
||||
///
|
||||
/// Manages tab state locally and delegates data loading to [ShiftsBloc].
|
||||
class ShiftsPage extends StatefulWidget {
|
||||
final ShiftTabType? initialTab;
|
||||
final DateTime? selectedDate;
|
||||
final bool refreshAvailable;
|
||||
/// Creates a [ShiftsPage].
|
||||
///
|
||||
/// [initialTab] selects the active tab on first render.
|
||||
/// [selectedDate] pre-selects a calendar date in the My Shifts tab.
|
||||
/// [refreshAvailable] triggers a forced reload of available shifts.
|
||||
const ShiftsPage({
|
||||
super.key,
|
||||
this.initialTab,
|
||||
@@ -23,6 +28,15 @@ class ShiftsPage extends StatefulWidget {
|
||||
this.refreshAvailable = false,
|
||||
});
|
||||
|
||||
/// The tab to display on initial render. Defaults to [ShiftTabType.find].
|
||||
final ShiftTabType? initialTab;
|
||||
|
||||
/// Optional date to pre-select in the My Shifts calendar.
|
||||
final DateTime? selectedDate;
|
||||
|
||||
/// When true, forces a refresh of available shifts on load.
|
||||
final bool refreshAvailable;
|
||||
|
||||
@override
|
||||
State<ShiftsPage> createState() => _ShiftsPageState();
|
||||
}
|
||||
@@ -251,6 +265,8 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
pendingAssignments: pendingAssignments,
|
||||
cancelledShifts: cancelledShifts,
|
||||
initialDate: _selectedDate,
|
||||
submittedShiftIds: state.submittedShiftIds,
|
||||
submittingShiftId: state.submittingShiftId,
|
||||
);
|
||||
case ShiftTabType.find:
|
||||
if (availableLoading) {
|
||||
@@ -264,7 +280,11 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
if (historyLoading) {
|
||||
return const ShiftsPageSkeleton();
|
||||
}
|
||||
return HistoryShiftsTab(historyShifts: historyShifts);
|
||||
return HistoryShiftsTab(
|
||||
historyShifts: historyShifts,
|
||||
submittedShiftIds: state.submittedShiftIds,
|
||||
submittingShiftId: state.submittingShiftId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,89 +298,85 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
}) {
|
||||
final isActive = _activeTab == type;
|
||||
return Expanded(
|
||||
child: Semantics(
|
||||
identifier: 'shift_tab_${type.name}',
|
||||
label: label,
|
||||
child: GestureDetector(
|
||||
onTap: !enabled
|
||||
? null
|
||||
: () {
|
||||
setState(() => _activeTab = type);
|
||||
if (type == ShiftTabType.history) {
|
||||
_bloc.add(LoadHistoryShiftsEvent());
|
||||
}
|
||||
if (type == ShiftTabType.find) {
|
||||
_bloc.add(LoadAvailableShiftsEvent());
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space2,
|
||||
horizontal: UiConstants.space2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? UiColors.white
|
||||
: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: !enabled
|
||||
? UiColors.white.withValues(alpha: 0.5)
|
||||
: isActive
|
||||
? UiColors.primary
|
||||
: UiColors.white,
|
||||
child: GestureDetector(
|
||||
onTap: !enabled
|
||||
? null
|
||||
: () {
|
||||
setState(() => _activeTab = type);
|
||||
if (type == ShiftTabType.history) {
|
||||
_bloc.add(LoadHistoryShiftsEvent());
|
||||
}
|
||||
if (type == ShiftTabType.find) {
|
||||
_bloc.add(LoadAvailableShiftsEvent());
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space2,
|
||||
horizontal: UiConstants.space2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? UiColors.white
|
||||
: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: !enabled
|
||||
? UiColors.white.withValues(alpha: 0.5)
|
||||
: isActive
|
||||
? 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,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (showCount) ...[
|
||||
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,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space1,
|
||||
vertical: 2,
|
||||
),
|
||||
),
|
||||
if (showCount) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space1,
|
||||
vertical: 2,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? UiColors.primary.withValues(alpha: 0.1)
|
||||
: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"$count",
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: isActive ? UiColors.primary : UiColors.white,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? UiColors.primary.withValues(alpha: 0.1)
|
||||
: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"$count",
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: isActive ? UiColors.primary : UiColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
@@ -107,7 +108,7 @@ class ShiftAssignmentCard extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.calendar,
|
||||
size: 12, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatDate(assignment.startTime),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
@@ -115,19 +116,19 @@ class ShiftAssignmentCard extends StatelessWidget {
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(UiIcons.clock,
|
||||
size: 12, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(assignment.startTime)} - ${_formatTime(assignment.endTime)}',
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.mapPin,
|
||||
size: 12, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
assignment.location,
|
||||
@@ -160,7 +161,10 @@ class ShiftAssignmentCard extends StatelessWidget {
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
),
|
||||
child: Text('Decline', style: UiTypography.body2m.textError),
|
||||
child: Text(
|
||||
context.t.staff_shifts.shift_details.decline,
|
||||
style: UiTypography.body2m.textError,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
@@ -178,14 +182,17 @@ class ShiftAssignmentCard extends StatelessWidget {
|
||||
),
|
||||
child: isConfirming
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
height: UiConstants.space4,
|
||||
width: UiConstants.space4,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: UiColors.white,
|
||||
),
|
||||
)
|
||||
: Text('Accept', style: UiTypography.body2m.white),
|
||||
: Text(
|
||||
context.t.staff_shifts.shift_details.accept_shift,
|
||||
style: UiTypography.body2m.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,775 +0,0 @@
|
||||
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';
|
||||
|
||||
/// Variant that controls the visual treatment of the [ShiftCard].
|
||||
///
|
||||
/// Each variant maps to a different colour scheme for the status badge and
|
||||
/// optional footer action area.
|
||||
enum ShiftCardVariant {
|
||||
/// Confirmed / accepted assignment.
|
||||
confirmed,
|
||||
|
||||
/// Pending assignment awaiting acceptance.
|
||||
pending,
|
||||
|
||||
/// Cancelled assignment.
|
||||
cancelled,
|
||||
|
||||
/// Completed shift (history).
|
||||
completed,
|
||||
|
||||
/// Worker is currently checked in.
|
||||
checkedIn,
|
||||
|
||||
/// A swap has been requested.
|
||||
swapRequested,
|
||||
}
|
||||
|
||||
/// Immutable data model that feeds the [ShiftCard].
|
||||
///
|
||||
/// Acts as an adapter between the various shift entity types
|
||||
/// (`AssignedShift`, `CompletedShift`, `CancelledShift`, `PendingAssignment`)
|
||||
/// and the unified card presentation.
|
||||
class ShiftCardData {
|
||||
/// Creates a [ShiftCardData].
|
||||
const ShiftCardData({
|
||||
required this.shiftId,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.date,
|
||||
required this.variant,
|
||||
this.subtitle,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.hourlyRateCents,
|
||||
this.orderType,
|
||||
this.minutesWorked,
|
||||
this.cancellationReason,
|
||||
this.paymentStatus,
|
||||
});
|
||||
|
||||
/// Constructs [ShiftCardData] from an [AssignedShift].
|
||||
factory ShiftCardData.fromAssigned(AssignedShift shift) {
|
||||
return ShiftCardData(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.roleName,
|
||||
subtitle: shift.location,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRateCents: shift.hourlyRateCents,
|
||||
orderType: shift.orderType,
|
||||
variant: _variantFromAssignmentStatus(shift.status),
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs [ShiftCardData] from a [CompletedShift].
|
||||
factory ShiftCardData.fromCompleted(CompletedShift shift) {
|
||||
return ShiftCardData(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.title,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
minutesWorked: shift.minutesWorked,
|
||||
paymentStatus: shift.paymentStatus,
|
||||
variant: ShiftCardVariant.completed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs [ShiftCardData] from a [CancelledShift].
|
||||
factory ShiftCardData.fromCancelled(CancelledShift shift) {
|
||||
return ShiftCardData(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.title,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
cancellationReason: shift.cancellationReason,
|
||||
variant: ShiftCardVariant.cancelled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs [ShiftCardData] from a [PendingAssignment].
|
||||
factory ShiftCardData.fromPending(PendingAssignment assignment) {
|
||||
return ShiftCardData(
|
||||
shiftId: assignment.shiftId,
|
||||
title: assignment.roleName,
|
||||
subtitle: assignment.title.isNotEmpty ? assignment.title : null,
|
||||
location: assignment.location,
|
||||
date: assignment.startTime,
|
||||
startTime: assignment.startTime,
|
||||
endTime: assignment.endTime,
|
||||
variant: ShiftCardVariant.pending,
|
||||
);
|
||||
}
|
||||
|
||||
/// The shift row id.
|
||||
final String shiftId;
|
||||
|
||||
/// Primary display title (role name or shift title).
|
||||
final String title;
|
||||
|
||||
/// Optional secondary text (e.g. location under the role name).
|
||||
final String? subtitle;
|
||||
|
||||
/// Human-readable location label.
|
||||
final String location;
|
||||
|
||||
/// The date of the shift.
|
||||
final DateTime date;
|
||||
|
||||
/// Scheduled start time (null for completed/cancelled).
|
||||
final DateTime? startTime;
|
||||
|
||||
/// Scheduled end time (null for completed/cancelled).
|
||||
final DateTime? endTime;
|
||||
|
||||
/// Hourly pay rate in cents (null when not applicable).
|
||||
final int? hourlyRateCents;
|
||||
|
||||
/// Order type (null for completed/cancelled).
|
||||
final OrderType? orderType;
|
||||
|
||||
/// Minutes worked (only for completed shifts).
|
||||
final int? minutesWorked;
|
||||
|
||||
/// Cancellation reason (only for cancelled shifts).
|
||||
final String? cancellationReason;
|
||||
|
||||
/// Payment processing status (only for completed shifts).
|
||||
final PaymentStatus? paymentStatus;
|
||||
|
||||
/// Visual variant for the card.
|
||||
final ShiftCardVariant variant;
|
||||
|
||||
static ShiftCardVariant _variantFromAssignmentStatus(
|
||||
AssignmentStatus status,
|
||||
) {
|
||||
switch (status) {
|
||||
case AssignmentStatus.accepted:
|
||||
return ShiftCardVariant.confirmed;
|
||||
case AssignmentStatus.checkedIn:
|
||||
return ShiftCardVariant.checkedIn;
|
||||
case AssignmentStatus.swapRequested:
|
||||
return ShiftCardVariant.swapRequested;
|
||||
case AssignmentStatus.completed:
|
||||
return ShiftCardVariant.completed;
|
||||
case AssignmentStatus.cancelled:
|
||||
return ShiftCardVariant.cancelled;
|
||||
case AssignmentStatus.assigned:
|
||||
return ShiftCardVariant.pending;
|
||||
case AssignmentStatus.checkedOut:
|
||||
case AssignmentStatus.noShow:
|
||||
case AssignmentStatus.unknown:
|
||||
return ShiftCardVariant.confirmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified card widget for displaying shift information across all shift types.
|
||||
///
|
||||
/// Replaces `MyShiftCard`, `ShiftAssignmentCard`, and the inline
|
||||
/// `_CompletedShiftCard` / `_buildCancelledCard` from the tabs. Accepts a
|
||||
/// [ShiftCardData] data model that adapts the various domain entities into a
|
||||
/// common display shape.
|
||||
class ShiftCard extends StatelessWidget {
|
||||
/// Creates a [ShiftCard].
|
||||
const ShiftCard({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.onTap,
|
||||
this.onSubmitForApproval,
|
||||
this.showApprovalAction = false,
|
||||
this.isSubmitted = false,
|
||||
this.onAccept,
|
||||
this.onDecline,
|
||||
this.isAccepting = false,
|
||||
});
|
||||
|
||||
/// The shift data to display.
|
||||
final ShiftCardData data;
|
||||
|
||||
/// Callback when the card is tapped (typically navigates to shift details).
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Callback when the "Submit for Approval" button is pressed.
|
||||
final VoidCallback? onSubmitForApproval;
|
||||
|
||||
/// Whether to show the submit-for-approval footer.
|
||||
final bool showApprovalAction;
|
||||
|
||||
/// Whether the timesheet has already been submitted.
|
||||
final bool isSubmitted;
|
||||
|
||||
/// Callback when the accept action is pressed (pending assignments only).
|
||||
final VoidCallback? onAccept;
|
||||
|
||||
/// Callback when the decline action is pressed (pending assignments only).
|
||||
final VoidCallback? onDecline;
|
||||
|
||||
/// Whether the accept action is in progress.
|
||||
final bool isAccepting;
|
||||
|
||||
/// Whether the accept/decline footer should be shown.
|
||||
bool get _showPendingActions => onAccept != null || onDecline != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: _showPendingActions
|
||||
? <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_StatusBadge(
|
||||
variant: data.variant,
|
||||
orderType: data.orderType,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_CardBody(data: data),
|
||||
if (showApprovalAction) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_ApprovalFooter(
|
||||
isSubmitted: isSubmitted,
|
||||
onSubmit: onSubmitForApproval,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_showPendingActions)
|
||||
_PendingActionsFooter(
|
||||
onAccept: onAccept,
|
||||
onDecline: onDecline,
|
||||
isAccepting: isAccepting,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the coloured status dot/icon and label, plus an optional order-type
|
||||
/// chip.
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.variant, this.orderType});
|
||||
|
||||
final ShiftCardVariant variant;
|
||||
final OrderType? orderType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _StatusStyle style = _resolveStyle(context);
|
||||
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
if (style.icon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: UiConstants.space2),
|
||||
child: Icon(
|
||||
style.icon,
|
||||
size: UiConstants.iconXs,
|
||||
color: style.foreground,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(right: UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: style.dot,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
style.label,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: style.foreground,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
if (orderType != null) ...<Widget>[
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_OrderTypeChip(orderType: orderType!),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
_StatusStyle _resolveStyle(BuildContext context) {
|
||||
switch (variant) {
|
||||
case ShiftCardVariant.confirmed:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.status.confirmed,
|
||||
foreground: UiColors.textLink,
|
||||
dot: UiColors.primary,
|
||||
);
|
||||
case ShiftCardVariant.pending:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.status.act_now,
|
||||
foreground: UiColors.destructive,
|
||||
dot: UiColors.destructive,
|
||||
);
|
||||
case ShiftCardVariant.cancelled:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.my_shifts_tab.card.cancelled,
|
||||
foreground: UiColors.destructive,
|
||||
dot: UiColors.destructive,
|
||||
);
|
||||
case ShiftCardVariant.completed:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.status.completed,
|
||||
foreground: UiColors.textSuccess,
|
||||
dot: UiColors.iconSuccess,
|
||||
);
|
||||
case ShiftCardVariant.checkedIn:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.my_shift_card.checked_in,
|
||||
foreground: UiColors.textSuccess,
|
||||
dot: UiColors.iconSuccess,
|
||||
);
|
||||
case ShiftCardVariant.swapRequested:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.status.swap_requested,
|
||||
foreground: UiColors.textWarning,
|
||||
dot: UiColors.textWarning,
|
||||
icon: UiIcons.swap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal helper grouping status badge presentation values.
|
||||
class _StatusStyle {
|
||||
const _StatusStyle({
|
||||
required this.label,
|
||||
required this.foreground,
|
||||
required this.dot,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color foreground;
|
||||
final Color dot;
|
||||
final IconData? icon;
|
||||
}
|
||||
|
||||
/// Small chip showing the order type (One Day / Multi-Day / Long Term).
|
||||
class _OrderTypeChip extends StatelessWidget {
|
||||
const _OrderTypeChip({required this.orderType});
|
||||
|
||||
final OrderType orderType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String label = _label(context);
|
||||
|
||||
return 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(
|
||||
label,
|
||||
style: UiTypography.footnote2m.copyWith(color: UiColors.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(BuildContext context) {
|
||||
switch (orderType) {
|
||||
case OrderType.permanent:
|
||||
return context.t.staff_shifts.filter.long_term;
|
||||
case OrderType.recurring:
|
||||
return context.t.staff_shifts.filter.multi_day;
|
||||
case OrderType.oneTime:
|
||||
case OrderType.rapid:
|
||||
case OrderType.unknown:
|
||||
return context.t.staff_shifts.filter.one_day;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main body: icon, title/subtitle, metadata rows, and optional pay info.
|
||||
class _CardBody extends StatelessWidget {
|
||||
const _CardBody({required this.data});
|
||||
|
||||
final ShiftCardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_ShiftIcon(variant: data.variant),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_TitleRow(data: data),
|
||||
if (data.subtitle != null) ...<Widget>[
|
||||
Text(
|
||||
data.subtitle!,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_MetadataRows(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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 44x44 icon box with a gradient background.
|
||||
class _ShiftIcon extends StatelessWidget {
|
||||
const _ShiftIcon({required this.variant});
|
||||
|
||||
final ShiftCardVariant variant;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isCancelled = variant == ShiftCardVariant.cancelled;
|
||||
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
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),
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: UiConstants.iconMd,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Title row with optional pay summary on the right.
|
||||
class _TitleRow extends StatelessWidget {
|
||||
const _TitleRow({required this.data});
|
||||
|
||||
final ShiftCardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasPay = data.hourlyRateCents != null &&
|
||||
data.startTime != null &&
|
||||
data.endTime != null;
|
||||
|
||||
if (!hasPay) {
|
||||
return Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
final double hourlyRate = data.hourlyRateCents! / 100;
|
||||
final int durationMinutes =
|
||||
data.endTime!.difference(data.startTime!).inMinutes;
|
||||
double durationHours = durationMinutes / 60;
|
||||
if (durationHours < 0) durationHours += 24;
|
||||
durationHours = durationHours.roundToDouble();
|
||||
final double estimatedTotal = hourlyRate * durationHours;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
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 ${durationHours.toInt()}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Date, time, location, and worked-hours rows.
|
||||
class _MetadataRows extends StatelessWidget {
|
||||
const _MetadataRows({required this.data});
|
||||
|
||||
final ShiftCardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
// Date and time row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatDate(context, data.date),
|
||||
style: UiTypography.footnote1r.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,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatWorkedDuration(data.minutesWorked!),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
// Location row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.location,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(BuildContext context, DateTime date) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
final DateTime tomorrow = today.add(const Duration(days: 1));
|
||||
final DateTime d = DateTime(date.year, date.month, date.day);
|
||||
if (d == today) return context.t.staff_shifts.my_shifts_tab.date.today;
|
||||
if (d == tomorrow) {
|
||||
return context.t.staff_shifts.my_shifts_tab.date.tomorrow;
|
||||
}
|
||||
return DateFormat('EEE, MMM d').format(date);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
String _formatWorkedDuration(int totalMinutes) {
|
||||
final int hours = totalMinutes ~/ 60;
|
||||
final int mins = totalMinutes % 60;
|
||||
return mins > 0 ? '${hours}h ${mins}m' : '${hours}h';
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer showing the submit-for-approval action for completed shifts.
|
||||
class _ApprovalFooter extends StatelessWidget {
|
||||
const _ApprovalFooter({
|
||||
required this.isSubmitted,
|
||||
this.onSubmit,
|
||||
});
|
||||
|
||||
final bool isSubmitted;
|
||||
final VoidCallback? onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
isSubmitted
|
||||
? context.t.staff_shifts.my_shift_card.submitted
|
||||
: context.t.staff_shifts.my_shift_card.ready_to_submit,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: isSubmitted ? UiColors.textSuccess : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (!isSubmitted)
|
||||
UiButton.secondary(
|
||||
text: context.t.staff_shifts.my_shift_card.submit_for_approval,
|
||||
size: UiButtonSize.small,
|
||||
onPressed: onSubmit,
|
||||
)
|
||||
else
|
||||
const Icon(
|
||||
UiIcons.success,
|
||||
color: UiColors.iconSuccess,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Coloured footer with Decline / Accept buttons for pending assignments.
|
||||
class _PendingActionsFooter extends StatelessWidget {
|
||||
const _PendingActionsFooter({
|
||||
this.onAccept,
|
||||
this.onDecline,
|
||||
this.isAccepting = false,
|
||||
});
|
||||
|
||||
final VoidCallback? onAccept;
|
||||
final VoidCallback? onDecline;
|
||||
final bool isAccepting;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.secondary,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(UiConstants.radiusBase),
|
||||
bottomRight: Radius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: onDecline,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
),
|
||||
child: Text(
|
||||
context.t.staff_shifts.action.decline,
|
||||
style: UiTypography.body2m.textError,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: isAccepting ? null : onAccept,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
foregroundColor: UiColors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusMdValue,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: isAccepting
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: UiColors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
context.t.staff_shifts.action.confirm,
|
||||
style: UiTypography.body2m.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export 'shift_card.dart';
|
||||
export 'shift_card_approval_footer.dart';
|
||||
export 'shift_card_body.dart';
|
||||
export 'shift_card_data.dart';
|
||||
export 'shift_card_metadata_rows.dart';
|
||||
export 'shift_card_pending_footer.dart';
|
||||
export 'shift_card_status_badge.dart';
|
||||
export 'shift_card_title_row.dart';
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_approval_footer.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_body.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_pending_footer.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_status_badge.dart';
|
||||
|
||||
/// Unified card widget for displaying shift information across all shift types.
|
||||
///
|
||||
/// Replaces `MyShiftCard`, `ShiftAssignmentCard`, and the inline
|
||||
/// `_CompletedShiftCard` / `_buildCancelledCard` from the tabs. Accepts a
|
||||
/// [ShiftCardData] data model that adapts the various domain entities into a
|
||||
/// common display shape.
|
||||
class ShiftCard extends StatelessWidget {
|
||||
/// Creates a [ShiftCard].
|
||||
const ShiftCard({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.onTap,
|
||||
this.onSubmitForApproval,
|
||||
this.showApprovalAction = false,
|
||||
this.isSubmitted = false,
|
||||
this.isSubmitting = false,
|
||||
this.onAccept,
|
||||
this.onDecline,
|
||||
this.isAccepting = false,
|
||||
});
|
||||
|
||||
/// The shift data to display.
|
||||
final ShiftCardData data;
|
||||
|
||||
/// Callback when the card is tapped (typically navigates to shift details).
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Callback when the "Submit for Approval" button is pressed.
|
||||
final VoidCallback? onSubmitForApproval;
|
||||
|
||||
/// Whether to show the submit-for-approval footer.
|
||||
final bool showApprovalAction;
|
||||
|
||||
/// Whether the timesheet has already been submitted.
|
||||
final bool isSubmitted;
|
||||
|
||||
/// Whether the timesheet submission is currently in progress.
|
||||
final bool isSubmitting;
|
||||
|
||||
/// Callback when the accept action is pressed (pending assignments only).
|
||||
final VoidCallback? onAccept;
|
||||
|
||||
/// Callback when the decline action is pressed (pending assignments only).
|
||||
final VoidCallback? onDecline;
|
||||
|
||||
/// Whether the accept action is in progress.
|
||||
final bool isAccepting;
|
||||
|
||||
/// Whether the accept/decline footer should be shown.
|
||||
bool get _showPendingActions => onAccept != null || onDecline != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
boxShadow: _showPendingActions
|
||||
? <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ShiftCardStatusBadge(
|
||||
variant: data.variant,
|
||||
orderType: data.orderType,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
ShiftCardBody(data: data),
|
||||
if (showApprovalAction) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
ShiftCardApprovalFooter(
|
||||
isSubmitted: isSubmitted,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmit: onSubmitForApproval,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_showPendingActions)
|
||||
ShiftCardPendingActionsFooter(
|
||||
onAccept: onAccept,
|
||||
onDecline: onDecline,
|
||||
isAccepting: isAccepting,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Footer showing the submit-for-approval action for completed shifts.
|
||||
class ShiftCardApprovalFooter extends StatelessWidget {
|
||||
/// Creates a [ShiftCardApprovalFooter].
|
||||
const ShiftCardApprovalFooter({
|
||||
super.key,
|
||||
required this.isSubmitted,
|
||||
this.isSubmitting = false,
|
||||
this.onSubmit,
|
||||
});
|
||||
|
||||
/// Whether the timesheet has already been submitted.
|
||||
final bool isSubmitted;
|
||||
|
||||
/// Whether the submission is currently in progress.
|
||||
final bool isSubmitting;
|
||||
|
||||
/// Callback when the submit button is pressed.
|
||||
final VoidCallback? onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
isSubmitting
|
||||
? context.t.staff_shifts.my_shift_card.submitting
|
||||
: isSubmitted
|
||||
? context.t.staff_shifts.my_shift_card.submitted
|
||||
: context.t.staff_shifts.my_shift_card.ready_to_submit,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: isSubmitted ? UiColors.textSuccess : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (isSubmitting)
|
||||
const SizedBox(
|
||||
height: UiConstants.space4,
|
||||
width: UiConstants.space4,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
)
|
||||
else if (!isSubmitted)
|
||||
UiButton.secondary(
|
||||
text: context.t.staff_shifts.my_shift_card.submit_for_approval,
|
||||
size: UiButtonSize.small,
|
||||
onPressed: onSubmit,
|
||||
)
|
||||
else
|
||||
const Icon(UiIcons.success, color: UiColors.iconSuccess, size: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_title_row.dart';
|
||||
|
||||
/// The main body: icon, title/subtitle, metadata rows, and optional pay info.
|
||||
class ShiftCardBody extends StatelessWidget {
|
||||
/// Creates a [ShiftCardBody].
|
||||
const ShiftCardBody({super.key, required this.data});
|
||||
|
||||
/// The shift data to display.
|
||||
final ShiftCardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 44x44 icon box with a gradient background.
|
||||
class ShiftCardIcon extends StatelessWidget {
|
||||
/// Creates a [ShiftCardIcon].
|
||||
const ShiftCardIcon({super.key, required this.variant});
|
||||
|
||||
/// The variant controlling the icon appearance.
|
||||
final ShiftCardVariant variant;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isCancelled = variant == ShiftCardVariant.cancelled;
|
||||
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
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)),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: UiConstants.iconMd,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Variant that controls the visual treatment of the [ShiftCard].
|
||||
///
|
||||
/// Each variant maps to a different colour scheme for the status badge and
|
||||
/// optional footer action area.
|
||||
enum ShiftCardVariant {
|
||||
/// Confirmed / accepted assignment.
|
||||
confirmed,
|
||||
|
||||
/// Pending assignment awaiting acceptance.
|
||||
pending,
|
||||
|
||||
/// Cancelled assignment.
|
||||
cancelled,
|
||||
|
||||
/// Completed shift (history).
|
||||
completed,
|
||||
|
||||
/// Worker is currently checked in.
|
||||
checkedIn,
|
||||
|
||||
/// A swap has been requested.
|
||||
swapRequested,
|
||||
}
|
||||
|
||||
/// Immutable data model that feeds the [ShiftCard].
|
||||
///
|
||||
/// Acts as an adapter between the various shift entity types
|
||||
/// (`AssignedShift`, `CompletedShift`, `CancelledShift`, `PendingAssignment`)
|
||||
/// and the unified card presentation.
|
||||
class ShiftCardData {
|
||||
/// Creates a [ShiftCardData].
|
||||
const ShiftCardData({
|
||||
required this.shiftId,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.date,
|
||||
required this.variant,
|
||||
this.subtitle,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.hourlyRateCents,
|
||||
this.hourlyRate,
|
||||
this.totalRate,
|
||||
this.orderType,
|
||||
this.minutesWorked,
|
||||
this.cancellationReason,
|
||||
this.paymentStatus,
|
||||
});
|
||||
|
||||
/// Constructs [ShiftCardData] from an [AssignedShift].
|
||||
factory ShiftCardData.fromAssigned(AssignedShift shift) {
|
||||
return ShiftCardData(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.roleName,
|
||||
subtitle: shift.location,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRateCents: shift.hourlyRateCents,
|
||||
orderType: shift.orderType,
|
||||
variant: _variantFromAssignmentStatus(shift.status),
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs [ShiftCardData] from a [CompletedShift].
|
||||
factory ShiftCardData.fromCompleted(CompletedShift shift) {
|
||||
return ShiftCardData(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.clientName.isNotEmpty ? shift.clientName : shift.title,
|
||||
subtitle: shift.title.isNotEmpty ? shift.title : null,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRateCents: shift.hourlyRateCents,
|
||||
hourlyRate: shift.hourlyRate,
|
||||
totalRate: shift.totalRate,
|
||||
minutesWorked: shift.minutesWorked,
|
||||
paymentStatus: shift.paymentStatus,
|
||||
variant: ShiftCardVariant.completed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs [ShiftCardData] from a [CancelledShift].
|
||||
factory ShiftCardData.fromCancelled(CancelledShift shift) {
|
||||
return ShiftCardData(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.title,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
cancellationReason: shift.cancellationReason,
|
||||
variant: ShiftCardVariant.cancelled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs [ShiftCardData] from a [PendingAssignment].
|
||||
factory ShiftCardData.fromPending(PendingAssignment assignment) {
|
||||
return ShiftCardData(
|
||||
shiftId: assignment.shiftId,
|
||||
title: assignment.roleName,
|
||||
subtitle: assignment.title.isNotEmpty ? assignment.title : null,
|
||||
location: assignment.location,
|
||||
date: assignment.startTime,
|
||||
startTime: assignment.startTime,
|
||||
endTime: assignment.endTime,
|
||||
variant: ShiftCardVariant.pending,
|
||||
);
|
||||
}
|
||||
|
||||
/// The shift row id.
|
||||
final String shiftId;
|
||||
|
||||
/// Primary display title (role name or shift title).
|
||||
final String title;
|
||||
|
||||
/// Optional secondary text (e.g. location under the role name).
|
||||
final String? subtitle;
|
||||
|
||||
/// Human-readable location label.
|
||||
final String location;
|
||||
|
||||
/// The date of the shift.
|
||||
final DateTime date;
|
||||
|
||||
/// Scheduled start time (null for completed/cancelled).
|
||||
final DateTime? startTime;
|
||||
|
||||
/// Scheduled end time (null for completed/cancelled).
|
||||
final DateTime? endTime;
|
||||
|
||||
/// Hourly pay rate in cents (null when not applicable).
|
||||
final int? hourlyRateCents;
|
||||
|
||||
/// Hourly pay rate in dollars (null when not applicable).
|
||||
final double? hourlyRate;
|
||||
|
||||
/// Total pay in dollars (null when not applicable).
|
||||
final double? totalRate;
|
||||
|
||||
/// Order type (null for completed/cancelled).
|
||||
final OrderType? orderType;
|
||||
|
||||
/// Minutes worked (only for completed shifts).
|
||||
final int? minutesWorked;
|
||||
|
||||
/// Cancellation reason (only for cancelled shifts).
|
||||
final String? cancellationReason;
|
||||
|
||||
/// Payment processing status (only for completed shifts).
|
||||
final PaymentStatus? paymentStatus;
|
||||
|
||||
/// Visual variant for the card.
|
||||
final ShiftCardVariant variant;
|
||||
|
||||
static ShiftCardVariant _variantFromAssignmentStatus(
|
||||
AssignmentStatus status,
|
||||
) {
|
||||
switch (status) {
|
||||
case AssignmentStatus.accepted:
|
||||
return ShiftCardVariant.confirmed;
|
||||
case AssignmentStatus.checkedIn:
|
||||
return ShiftCardVariant.checkedIn;
|
||||
case AssignmentStatus.swapRequested:
|
||||
return ShiftCardVariant.swapRequested;
|
||||
case AssignmentStatus.completed:
|
||||
return ShiftCardVariant.completed;
|
||||
case AssignmentStatus.cancelled:
|
||||
return ShiftCardVariant.cancelled;
|
||||
case AssignmentStatus.assigned:
|
||||
return ShiftCardVariant.pending;
|
||||
case AssignmentStatus.checkedOut:
|
||||
case AssignmentStatus.noShow:
|
||||
case AssignmentStatus.unknown:
|
||||
return ShiftCardVariant.confirmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||
|
||||
/// Date, time, location, and worked-hours rows.
|
||||
class ShiftCardMetadataRows extends StatelessWidget {
|
||||
/// Creates a [ShiftCardMetadataRows].
|
||||
const ShiftCardMetadataRows({super.key, required this.data});
|
||||
|
||||
/// The shift data to display.
|
||||
final ShiftCardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
// Date and time row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatDate(context, data.date),
|
||||
style: UiTypography.footnote1r.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,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatWorkedDuration(data.minutesWorked!),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
// Location row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.location,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(BuildContext context, DateTime date) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
final DateTime tomorrow = today.add(const Duration(days: 1));
|
||||
final DateTime d = DateTime(date.year, date.month, date.day);
|
||||
if (d == today) return context.t.staff_shifts.my_shifts_tab.date.today;
|
||||
if (d == tomorrow) {
|
||||
return context.t.staff_shifts.my_shifts_tab.date.tomorrow;
|
||||
}
|
||||
return DateFormat('EEE, MMM d').format(date);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
String _formatWorkedDuration(int totalMinutes) {
|
||||
final int hours = totalMinutes ~/ 60;
|
||||
final int mins = totalMinutes % 60;
|
||||
return mins > 0 ? '${hours}h ${mins}m' : '${hours}h';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Coloured footer with Decline / Accept buttons for pending assignments.
|
||||
class ShiftCardPendingActionsFooter extends StatelessWidget {
|
||||
/// Creates a [ShiftCardPendingActionsFooter].
|
||||
const ShiftCardPendingActionsFooter({
|
||||
super.key,
|
||||
this.onAccept,
|
||||
this.onDecline,
|
||||
this.isAccepting = false,
|
||||
});
|
||||
|
||||
/// Callback when the accept action is pressed.
|
||||
final VoidCallback? onAccept;
|
||||
|
||||
/// Callback when the decline action is pressed.
|
||||
final VoidCallback? onDecline;
|
||||
|
||||
/// Whether the accept action is in progress.
|
||||
final bool isAccepting;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.secondary,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(UiConstants.radiusBase),
|
||||
bottomRight: Radius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: onDecline,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
),
|
||||
child: Text(
|
||||
context.t.staff_shifts.action.decline,
|
||||
style: UiTypography.body2m.textError,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: isAccepting ? null : onAccept,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
foregroundColor: UiColors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusMdValue,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: isAccepting
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: UiColors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
context.t.staff_shifts.action.confirm,
|
||||
style: UiTypography.body2m.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||
|
||||
/// Displays the coloured status dot/icon and label, plus an optional order-type
|
||||
/// chip.
|
||||
class ShiftCardStatusBadge extends StatelessWidget {
|
||||
/// Creates a [ShiftCardStatusBadge].
|
||||
const ShiftCardStatusBadge({super.key, required this.variant, this.orderType});
|
||||
|
||||
/// The visual variant for colour resolution.
|
||||
final ShiftCardVariant variant;
|
||||
|
||||
/// Optional order type shown as a trailing chip.
|
||||
final OrderType? orderType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ShiftCardStatusStyle style = _resolveStyle(context);
|
||||
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
if (style.icon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: UiConstants.space2),
|
||||
child: Icon(
|
||||
style.icon,
|
||||
size: UiConstants.iconXs,
|
||||
color: style.foreground,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(right: UiConstants.space2),
|
||||
decoration: BoxDecoration(color: style.dot, shape: BoxShape.circle),
|
||||
),
|
||||
Text(
|
||||
style.label,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: style.foreground,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
if (orderType != null) ...<Widget>[
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
ShiftCardOrderTypeChip(orderType: orderType!),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
ShiftCardStatusStyle _resolveStyle(BuildContext context) {
|
||||
switch (variant) {
|
||||
case ShiftCardVariant.confirmed:
|
||||
return ShiftCardStatusStyle(
|
||||
label: context.t.staff_shifts.status.confirmed,
|
||||
foreground: UiColors.textLink,
|
||||
dot: UiColors.primary,
|
||||
);
|
||||
case ShiftCardVariant.pending:
|
||||
return ShiftCardStatusStyle(
|
||||
label: context.t.staff_shifts.status.act_now,
|
||||
foreground: UiColors.destructive,
|
||||
dot: UiColors.destructive,
|
||||
);
|
||||
case ShiftCardVariant.cancelled:
|
||||
return ShiftCardStatusStyle(
|
||||
label: context.t.staff_shifts.my_shifts_tab.card.cancelled,
|
||||
foreground: UiColors.destructive,
|
||||
dot: UiColors.destructive,
|
||||
);
|
||||
case ShiftCardVariant.completed:
|
||||
return ShiftCardStatusStyle(
|
||||
label: context.t.staff_shifts.status.completed,
|
||||
foreground: UiColors.textSuccess,
|
||||
dot: UiColors.iconSuccess,
|
||||
);
|
||||
case ShiftCardVariant.checkedIn:
|
||||
return ShiftCardStatusStyle(
|
||||
label: context.t.staff_shifts.my_shift_card.checked_in,
|
||||
foreground: UiColors.textSuccess,
|
||||
dot: UiColors.iconSuccess,
|
||||
);
|
||||
case ShiftCardVariant.swapRequested:
|
||||
return ShiftCardStatusStyle(
|
||||
label: context.t.staff_shifts.status.swap_requested,
|
||||
foreground: UiColors.textWarning,
|
||||
dot: UiColors.textWarning,
|
||||
icon: UiIcons.swap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper grouping status badge presentation values.
|
||||
class ShiftCardStatusStyle {
|
||||
/// Creates a [ShiftCardStatusStyle].
|
||||
const ShiftCardStatusStyle({
|
||||
required this.label,
|
||||
required this.foreground,
|
||||
required this.dot,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
/// The human-readable status label.
|
||||
final String label;
|
||||
|
||||
/// Foreground colour for the label and icon.
|
||||
final Color foreground;
|
||||
|
||||
/// Dot colour when no icon is provided.
|
||||
final Color dot;
|
||||
|
||||
/// Optional icon replacing the dot indicator.
|
||||
final IconData? icon;
|
||||
}
|
||||
|
||||
/// Small chip showing the order type (One Day / Multi-Day / Long Term).
|
||||
class ShiftCardOrderTypeChip extends StatelessWidget {
|
||||
/// Creates a [ShiftCardOrderTypeChip].
|
||||
const ShiftCardOrderTypeChip({super.key, required this.orderType});
|
||||
|
||||
/// The order type to display.
|
||||
final OrderType orderType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String label = _label(context);
|
||||
|
||||
return 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(
|
||||
label,
|
||||
style: UiTypography.footnote2m.copyWith(color: UiColors.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(BuildContext context) {
|
||||
switch (orderType) {
|
||||
case OrderType.permanent:
|
||||
return context.t.staff_shifts.filter.long_term;
|
||||
case OrderType.recurring:
|
||||
return context.t.staff_shifts.filter.multi_day;
|
||||
case OrderType.oneTime:
|
||||
case OrderType.rapid:
|
||||
case OrderType.unknown:
|
||||
return context.t.staff_shifts.filter.one_day;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||
|
||||
/// Title row with optional pay summary on the right.
|
||||
class ShiftCardTitleRow extends StatelessWidget {
|
||||
/// Creates a [ShiftCardTitleRow].
|
||||
const ShiftCardTitleRow({super.key, required this.data});
|
||||
|
||||
/// The shift data to display.
|
||||
final ShiftCardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0;
|
||||
final bool hasComputedRate =
|
||||
data.hourlyRateCents != null &&
|
||||
data.startTime != null &&
|
||||
data.endTime != null;
|
||||
|
||||
if (!hasDirectRate && !hasComputedRate) {
|
||||
return Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,34 @@
|
||||
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.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart';
|
||||
|
||||
/// Tab displaying completed shift history.
|
||||
class HistoryShiftsTab extends StatelessWidget {
|
||||
/// Creates a [HistoryShiftsTab].
|
||||
const HistoryShiftsTab({super.key, required this.historyShifts});
|
||||
const HistoryShiftsTab({
|
||||
super.key,
|
||||
required this.historyShifts,
|
||||
this.submittedShiftIds = const <String>{},
|
||||
this.submittingShiftId,
|
||||
});
|
||||
|
||||
/// Completed shifts.
|
||||
final List<CompletedShift> historyShifts;
|
||||
|
||||
/// 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) {
|
||||
if (historyShifts.isEmpty) {
|
||||
@@ -32,14 +45,34 @@ class HistoryShiftsTab extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
...historyShifts.map(
|
||||
(CompletedShift shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromCompleted(shift),
|
||||
onTap: () =>
|
||||
Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
),
|
||||
),
|
||||
(CompletedShift shift) {
|
||||
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.fromCompleted(shift),
|
||||
onTap: () =>
|
||||
Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
showApprovalAction: !isSubmitted,
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space32),
|
||||
],
|
||||
|
||||
@@ -7,9 +7,10 @@ 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.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart';
|
||||
|
||||
/// Tab displaying the worker's assigned, pending, and cancelled shifts.
|
||||
class MyShiftsTab extends StatefulWidget {
|
||||
@@ -20,6 +21,8 @@ class MyShiftsTab extends StatefulWidget {
|
||||
required this.pendingAssignments,
|
||||
required this.cancelledShifts,
|
||||
this.initialDate,
|
||||
this.submittedShiftIds = const <String>{},
|
||||
this.submittingShiftId,
|
||||
});
|
||||
|
||||
/// Assigned shifts for the current week.
|
||||
@@ -34,6 +37,12 @@ class MyShiftsTab extends StatefulWidget {
|
||||
/// Initial date to select in the calendar.
|
||||
final DateTime? initialDate;
|
||||
|
||||
/// 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
|
||||
State<MyShiftsTab> createState() => _MyShiftsTabState();
|
||||
}
|
||||
@@ -42,9 +51,6 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
int _weekOffset = 0;
|
||||
|
||||
/// Tracks which completed-shift cards have been submitted locally.
|
||||
final Set<String> _submittedShiftIds = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -90,20 +96,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
});
|
||||
}
|
||||
|
||||
List<DateTime> _getCalendarDays() {
|
||||
final DateTime now = DateTime.now();
|
||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||
final DateTime start = now
|
||||
.subtract(Duration(days: daysSinceFriday))
|
||||
.add(Duration(days: _weekOffset * 7));
|
||||
final DateTime startDate =
|
||||
DateTime(start.year, start.month, start.day);
|
||||
return List<DateTime>.generate(
|
||||
7,
|
||||
(int index) => startDate.add(Duration(days: index)),
|
||||
);
|
||||
}
|
||||
List<DateTime> _getCalendarDays() => getCalendarDaysForOffset(_weekOffset);
|
||||
|
||||
void _loadShiftsForCurrentWeek() {
|
||||
final List<DateTime> calendarDays = _getCalendarDays();
|
||||
@@ -402,7 +395,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
final bool isCompleted =
|
||||
shift.status == AssignmentStatus.completed;
|
||||
final bool isSubmitted =
|
||||
_submittedShiftIds.contains(shift.shiftId);
|
||||
widget.submittedShiftIds.contains(shift.shiftId);
|
||||
final bool isSubmitting =
|
||||
widget.submittingShiftId == shift.shiftId;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
@@ -414,10 +409,13 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
.toShiftDetailsById(shift.shiftId),
|
||||
showApprovalAction: isCompleted,
|
||||
isSubmitted: isSubmitted,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmitForApproval: () {
|
||||
setState(() {
|
||||
_submittedShiftIds.add(shift.shiftId);
|
||||
});
|
||||
ReadContext(context).read<ShiftsBloc>().add(
|
||||
SubmitForApprovalEvent(
|
||||
shiftId: shift.shiftId,
|
||||
),
|
||||
);
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context.t.staff_shifts
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.
|
||||
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/submit_for_approval_usecase.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';
|
||||
@@ -45,6 +46,9 @@ class StaffShiftsModule extends Module {
|
||||
i.addLazySingleton(ApplyForShiftUseCase.new);
|
||||
i.addLazySingleton(GetShiftDetailUseCase.new);
|
||||
i.addLazySingleton(GetProfileCompletionUseCase.new);
|
||||
i.addLazySingleton(
|
||||
() => SubmitForApprovalUseCase(i.get<ShiftsRepositoryInterface>()),
|
||||
);
|
||||
|
||||
// BLoC
|
||||
i.add(
|
||||
@@ -57,6 +61,7 @@ class StaffShiftsModule extends Module {
|
||||
getProfileCompletion: i.get(),
|
||||
acceptShift: i.get(),
|
||||
declineShift: i.get(),
|
||||
submitForApproval: i.get(),
|
||||
),
|
||||
);
|
||||
i.add(
|
||||
|
||||
Reference in New Issue
Block a user