Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new

This commit is contained in:
2026-03-18 12:51:23 +05:30
660 changed files with 18935 additions and 21383 deletions

View File

@@ -1,103 +1,158 @@
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/shifts_repository_interface.dart';
/// Implementation of [ShiftsRepositoryInterface] that delegates to [dc.ShiftsConnectorRepository].
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// V2 API implementation of [ShiftsRepositoryInterface].
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
/// Uses [BaseApiService] with [StaffEndpoints] for all network access.
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
final dc.ShiftsConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
/// Creates a [ShiftsRepositoryImpl].
ShiftsRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
ShiftsRepositoryImpl({
dc.ShiftsConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getShiftsRepository(),
_service = service ?? dc.DataConnectService.instance;
/// The API service used for network requests.
final BaseApiService _apiService;
/// Extracts a list of items from the API response data.
///
/// Handles both the V2 wrapped `{"items": [...]}` shape and a raw
/// `List<dynamic>` for backwards compatibility.
List<dynamic> _extractItems(dynamic data) {
if (data is List<dynamic>) {
return data;
}
if (data is Map<String, dynamic>) {
return data['items'] as List<dynamic>? ?? <dynamic>[];
}
return <dynamic>[];
}
@override
Future<List<Shift>> getMyShifts({
Future<List<AssignedShift>> getAssignedShifts({
required DateTime start,
required DateTime end,
}) async {
final staffId = await _service.getStaffId();
return _connectorRepository.getMyShifts(
staffId: staffId,
start: start,
end: end,
final ApiResponse response = await _apiService.get(
StaffEndpoints.shiftsAssigned,
params: <String, dynamic>{
'startDate': start.toIso8601String(),
'endDate': end.toIso8601String(),
},
);
final List<dynamic> items = _extractItems(response.data);
return items
.map((dynamic json) =>
AssignedShift.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<Shift>> getPendingAssignments() async {
final staffId = await _service.getStaffId();
return _connectorRepository.getPendingAssignments(staffId: staffId);
}
@override
Future<List<Shift>> getCancelledShifts() async {
final staffId = await _service.getStaffId();
return _connectorRepository.getCancelledShifts(staffId: staffId);
}
@override
Future<List<Shift>> getHistoryShifts() async {
final staffId = await _service.getStaffId();
return _connectorRepository.getHistoryShifts(staffId: staffId);
}
@override
Future<List<Shift>> getAvailableShifts(String query, String type) async {
final staffId = await _service.getStaffId();
return _connectorRepository.getAvailableShifts(
staffId: staffId,
query: query,
type: type,
Future<List<OpenShift>> getOpenShifts({
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.shiftsOpen,
params: params,
);
final List<dynamic> items = _extractItems(response.data);
return items
.map(
(dynamic json) => OpenShift.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<Shift?> getShiftDetails(String shiftId, {String? roleId}) async {
final staffId = await _service.getStaffId();
return _connectorRepository.getShiftDetails(
shiftId: shiftId,
staffId: staffId,
roleId: roleId,
);
Future<List<PendingAssignment>> getPendingAssignments() async {
final ApiResponse response =
await _apiService.get(StaffEndpoints.shiftsPending);
final List<dynamic> items = _extractItems(response.data);
return items
.map((dynamic json) =>
PendingAssignment.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<CancelledShift>> getCancelledShifts() async {
final ApiResponse response =
await _apiService.get(StaffEndpoints.shiftsCancelled);
final List<dynamic> items = _extractItems(response.data);
return items
.map((dynamic json) =>
CancelledShift.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<CompletedShift>> getCompletedShifts() async {
final ApiResponse response =
await _apiService.get(StaffEndpoints.shiftsCompleted);
final List<dynamic> items = _extractItems(response.data);
return items
.map((dynamic json) =>
CompletedShift.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<ShiftDetail?> getShiftDetail(String shiftId) async {
final ApiResponse response =
await _apiService.get(StaffEndpoints.shiftDetails(shiftId));
if (response.data == null) {
return null;
}
return ShiftDetail.fromJson(response.data as Map<String, dynamic>);
}
@override
Future<void> applyForShift(
String shiftId, {
bool isInstantBook = false,
String? roleId,
bool instantBook = false,
}) async {
final staffId = await _service.getStaffId();
return _connectorRepository.applyForShift(
shiftId: shiftId,
staffId: staffId,
isInstantBook: isInstantBook,
roleId: roleId,
await _apiService.post(
StaffEndpoints.shiftApply(shiftId),
data: <String, dynamic>{
if (roleId != null) 'roleId': roleId,
'instantBook': instantBook,
},
);
}
@override
Future<void> acceptShift(String shiftId) async {
final staffId = await _service.getStaffId();
return _connectorRepository.acceptShift(
shiftId: shiftId,
staffId: staffId,
);
await _apiService.post(StaffEndpoints.shiftAccept(shiftId));
}
@override
Future<void> declineShift(String shiftId) async {
final staffId = await _service.getStaffId();
return _connectorRepository.declineShift(
shiftId: shiftId,
staffId: staffId,
await _apiService.post(StaffEndpoints.shiftDecline(shiftId));
}
@override
Future<void> requestSwap(String shiftId, {String? reason}) async {
await _apiService.post(
StaffEndpoints.shiftRequestSwap(shiftId),
data: <String, dynamic>{
if (reason != null) 'reason': reason,
},
);
}
@override
Future<bool> getProfileCompletion() async {
final ApiResponse response =
await _apiService.get(StaffEndpoints.profileCompletion);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
return completion.completed;
}
}

View File

@@ -1,19 +1,19 @@
import 'package:krow_core/core.dart';
/// Arguments for [GetAvailableShiftsUseCase].
class GetAvailableShiftsArguments extends UseCaseArgument {
/// The search query to filter shifts.
final String query;
/// The job type filter (e.g., 'all', 'one-day', 'multi-day', 'long-term').
final String type;
/// Creates a [GetAvailableShiftsArguments] instance.
const GetAvailableShiftsArguments({
this.query = '',
this.type = 'all',
/// Arguments for GetOpenShiftsUseCase.
class GetOpenShiftsArguments extends UseCaseArgument {
/// Creates a [GetOpenShiftsArguments] instance.
const GetOpenShiftsArguments({
this.search,
this.limit = 20,
});
/// Optional search query to filter by role name or location.
final String? search;
/// Maximum number of results to return.
final int limit;
@override
List<Object?> get props => [query, type];
List<Object?> get props => <Object?>[search, limit];
}

View File

@@ -1,11 +1,19 @@
import 'package:krow_core/core.dart';
class GetMyShiftsArguments extends UseCaseArgument {
final DateTime start;
final DateTime end;
const GetMyShiftsArguments({
/// Arguments for GetAssignedShiftsUseCase.
class GetAssignedShiftsArguments extends UseCaseArgument {
/// Creates a [GetAssignedShiftsArguments] instance.
const GetAssignedShiftsArguments({
required this.start,
required this.end,
});
/// Start of the date range.
final DateTime start;
/// End of the date range.
final DateTime end;
@override
List<Object?> get props => <Object?>[start, end];
}

View File

@@ -1,32 +1,39 @@
import 'package:krow_domain/krow_domain.dart';
/// Interface for the Shifts Repository.
/// Contract for accessing shift-related data from the V2 API.
///
/// Defines the contract for accessing and modifying shift-related data.
/// Implementations of this interface should reside in the data layer.
/// Implementations reside in the data layer and use [BaseApiService]
/// with [StaffEndpoints].
abstract interface class ShiftsRepositoryInterface {
/// Retrieves the list of shifts assigned to the current user.
Future<List<Shift>> getMyShifts({
/// Retrieves assigned shifts for the current staff within a date range.
Future<List<AssignedShift>> getAssignedShifts({
required DateTime start,
required DateTime end,
});
/// Retrieves available shifts matching the given [query] and [type].
Future<List<Shift>> getAvailableShifts(String query, String type);
/// Retrieves open shifts available for the staff to apply.
Future<List<OpenShift>> getOpenShifts({
String? search,
int limit,
});
/// Retrieves shifts that are pending acceptance by the user.
Future<List<Shift>> getPendingAssignments();
/// Retrieves pending assignments awaiting acceptance.
Future<List<PendingAssignment>> getPendingAssignments();
/// Retrieves detailed information for a specific shift by [shiftId].
Future<Shift?> getShiftDetails(String shiftId, {String? roleId});
/// Retrieves cancelled shift assignments.
Future<List<CancelledShift>> getCancelledShifts();
/// Applies for a specific open shift.
///
/// [isInstantBook] determines if the application should be immediately accepted.
/// Retrieves completed shift history.
Future<List<CompletedShift>> getCompletedShifts();
/// Retrieves full details for a specific shift.
Future<ShiftDetail?> getShiftDetail(String shiftId);
/// Applies for an open shift.
Future<void> applyForShift(
String shiftId, {
bool isInstantBook = false,
String? roleId,
bool instantBook,
});
/// Accepts a pending shift assignment.
@@ -35,9 +42,9 @@ abstract interface class ShiftsRepositoryInterface {
/// Declines a pending shift assignment.
Future<void> declineShift(String shiftId);
/// Retrieves shifts that were cancelled for the current user.
Future<List<Shift>> getCancelledShifts();
/// Requests a swap for an accepted shift assignment.
Future<void> requestSwap(String shiftId, {String? reason});
/// Retrieves completed shifts for the current user.
Future<List<Shift>> getHistoryShifts();
/// Returns whether the staff profile is complete.
Future<bool> getProfileCompletion();
}

View File

@@ -1,10 +1,14 @@
import '../repositories/shifts_repository_interface.dart';
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Accepts a pending shift assignment.
class AcceptShiftUseCase {
final ShiftsRepositoryInterface repository;
/// Creates an [AcceptShiftUseCase].
AcceptShiftUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
/// Executes the use case.
Future<void> call(String shiftId) async {
return repository.acceptShift(shiftId);
}

View File

@@ -1,18 +1,22 @@
import '../repositories/shifts_repository_interface.dart';
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Applies for an open shift.
class ApplyForShiftUseCase {
final ShiftsRepositoryInterface repository;
/// Creates an [ApplyForShiftUseCase].
ApplyForShiftUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
/// Executes the use case.
Future<void> call(
String shiftId, {
bool isInstantBook = false,
bool instantBook = false,
String? roleId,
}) async {
return repository.applyForShift(
shiftId,
isInstantBook: isInstantBook,
instantBook: instantBook,
roleId: roleId,
);
}

View File

@@ -1,10 +1,14 @@
import '../repositories/shifts_repository_interface.dart';
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Declines a pending shift assignment.
class DeclineShiftUseCase {
final ShiftsRepositoryInterface repository;
/// Creates a [DeclineShiftUseCase].
DeclineShiftUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
/// Executes the use case.
Future<void> call(String shiftId) async {
return repository.declineShift(shiftId);
}

View File

@@ -1,19 +1,24 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
import '../arguments/get_available_shifts_arguments.dart';
/// Use case for retrieving available shifts with filters.
///
/// This use case delegates to [ShiftsRepositoryInterface].
class GetAvailableShiftsUseCase extends UseCase<GetAvailableShiftsArguments, List<Shift>> {
import 'package:staff_shifts/src/domain/arguments/get_available_shifts_arguments.dart';
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Retrieves open shifts available for the worker to apply.
class GetOpenShiftsUseCase
extends UseCase<GetOpenShiftsArguments, List<OpenShift>> {
/// Creates a [GetOpenShiftsUseCase].
GetOpenShiftsUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
GetAvailableShiftsUseCase(this.repository);
@override
Future<List<Shift>> call(GetAvailableShiftsArguments arguments) async {
return repository.getAvailableShifts(arguments.query, arguments.type);
Future<List<OpenShift>> call(GetOpenShiftsArguments arguments) async {
return repository.getOpenShifts(
search: arguments.search,
limit: arguments.limit,
);
}
}

View File

@@ -1,12 +1,17 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Retrieves cancelled shift assignments.
class GetCancelledShiftsUseCase {
final ShiftsRepositoryInterface repository;
/// Creates a [GetCancelledShiftsUseCase].
GetCancelledShiftsUseCase(this.repository);
Future<List<Shift>> call() async {
/// The shifts repository.
final ShiftsRepositoryInterface repository;
/// Executes the use case.
Future<List<CancelledShift>> call() async {
return repository.getCancelledShifts();
}
}

View File

@@ -1,12 +1,17 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
class GetHistoryShiftsUseCase {
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Retrieves completed shift history.
class GetCompletedShiftsUseCase {
/// Creates a [GetCompletedShiftsUseCase].
GetCompletedShiftsUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
GetHistoryShiftsUseCase(this.repository);
Future<List<Shift>> call() async {
return repository.getHistoryShifts();
/// Executes the use case.
Future<List<CompletedShift>> call() async {
return repository.getCompletedShifts();
}
}

View File

@@ -1,19 +1,21 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/get_my_shifts_arguments.dart';
import '../repositories/shifts_repository_interface.dart';
/// Use case for retrieving the user's assigned shifts.
///
/// This use case delegates to [ShiftsRepositoryInterface].
class GetMyShiftsUseCase extends UseCase<GetMyShiftsArguments, List<Shift>> {
import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart';
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Retrieves assigned shifts within a date range.
class GetAssignedShiftsUseCase
extends UseCase<GetAssignedShiftsArguments, List<AssignedShift>> {
/// Creates a [GetAssignedShiftsUseCase].
GetAssignedShiftsUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
GetMyShiftsUseCase(this.repository);
@override
Future<List<Shift>> call(GetMyShiftsArguments arguments) async {
return repository.getMyShifts(
Future<List<AssignedShift>> call(GetAssignedShiftsArguments arguments) async {
return repository.getAssignedShifts(
start: arguments.start,
end: arguments.end,
);

View File

@@ -1,17 +1,19 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
/// Use case for retrieving pending shift assignments.
///
/// This use case delegates to [ShiftsRepositoryInterface].
class GetPendingAssignmentsUseCase extends NoInputUseCase<List<Shift>> {
final ShiftsRepositoryInterface repository;
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Retrieves pending assignments awaiting acceptance.
class GetPendingAssignmentsUseCase
extends NoInputUseCase<List<PendingAssignment>> {
/// Creates a [GetPendingAssignmentsUseCase].
GetPendingAssignmentsUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
@override
Future<List<Shift>> call() async {
Future<List<PendingAssignment>> call() async {
return repository.getPendingAssignments();
}
}

View File

@@ -0,0 +1,17 @@
import 'package:krow_core/core.dart';
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Checks whether the staff member's profile is complete.
class GetProfileCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetProfileCompletionUseCase].
GetProfileCompletionUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
@override
Future<bool> call() {
return repository.getProfileCompletion();
}
}

View File

@@ -1,18 +1,18 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/get_shift_details_arguments.dart';
import '../repositories/shifts_repository_interface.dart';
class GetShiftDetailsUseCase extends UseCase<GetShiftDetailsArguments, Shift?> {
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Retrieves full details for a specific shift.
class GetShiftDetailUseCase extends UseCase<String, ShiftDetail?> {
/// Creates a [GetShiftDetailUseCase].
GetShiftDetailUseCase(this.repository);
/// The shifts repository.
final ShiftsRepositoryInterface repository;
GetShiftDetailsUseCase(this.repository);
@override
Future<Shift?> call(GetShiftDetailsArguments params) {
return repository.getShiftDetails(
params.shiftId,
roleId: params.roleId,
);
Future<ShiftDetail?> call(String shiftId) {
return repository.getShiftDetail(shiftId);
}
}

View File

@@ -1,22 +1,21 @@
import 'package:bloc/bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import '../../../domain/usecases/apply_for_shift_usecase.dart';
import '../../../domain/usecases/decline_shift_usecase.dart';
import '../../../domain/usecases/get_shift_details_usecase.dart';
import '../../../domain/arguments/get_shift_details_arguments.dart';
import 'package:krow_domain/krow_domain.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';
import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart';
import 'shift_details_event.dart';
import 'shift_details_state.dart';
/// Manages the state for the shift details page.
class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
with BlocErrorHandler<ShiftDetailsState> {
final GetShiftDetailsUseCase getShiftDetails;
final ApplyForShiftUseCase applyForShift;
final DeclineShiftUseCase declineShift;
final GetProfileCompletionUseCase getProfileCompletion;
/// Creates a [ShiftDetailsBloc].
ShiftDetailsBloc({
required this.getShiftDetails,
required this.getShiftDetail,
required this.applyForShift,
required this.declineShift,
required this.getProfileCompletion,
@@ -26,6 +25,18 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
on<DeclineShiftDetailsEvent>(_onDeclineShift);
}
/// Use case for fetching shift details.
final GetShiftDetailUseCase getShiftDetail;
/// Use case for applying to a shift.
final ApplyForShiftUseCase applyForShift;
/// Use case for declining a shift.
final DeclineShiftUseCase declineShift;
/// Use case for checking profile completion.
final GetProfileCompletionUseCase getProfileCompletion;
Future<void> _onLoadDetails(
LoadShiftDetailsEvent event,
Emitter<ShiftDetailsState> emit,
@@ -34,14 +45,15 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
await handleError(
emit: emit.call,
action: () async {
final shift = await getShiftDetails(
GetShiftDetailsArguments(shiftId: event.shiftId, roleId: event.roleId),
);
final isProfileComplete = await getProfileCompletion();
if (shift != null) {
emit(ShiftDetailsLoaded(shift, isProfileComplete: isProfileComplete));
final ShiftDetail? detail = await getShiftDetail(event.shiftId);
final bool isProfileComplete = await getProfileCompletion();
if (detail != null) {
emit(ShiftDetailsLoaded(
detail,
isProfileComplete: isProfileComplete,
));
} else {
emit(const ShiftDetailsError("Shift not found"));
emit(const ShiftDetailsError('Shift not found'));
}
},
onError: (String errorKey) => ShiftDetailsError(errorKey),
@@ -57,11 +69,14 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
action: () async {
await applyForShift(
event.shiftId,
isInstantBook: true,
instantBook: true,
roleId: event.roleId,
);
emit(
ShiftActionSuccess("Shift successfully booked!", shiftDate: event.date),
ShiftActionSuccess(
'Shift successfully booked!',
shiftDate: event.date,
),
);
},
onError: (String errorKey) => ShiftDetailsError(errorKey),
@@ -76,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'));
},
onError: (String errorKey) => ShiftDetailsError(errorKey),
);

View File

@@ -1,39 +1,59 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base class for shift details states.
abstract class ShiftDetailsState extends Equatable {
/// Creates a [ShiftDetailsState].
const ShiftDetailsState();
@override
List<Object?> get props => [];
List<Object?> get props => <Object?>[];
}
/// Initial state before any data is loaded.
class ShiftDetailsInitial extends ShiftDetailsState {}
/// Loading state while fetching shift details.
class ShiftDetailsLoading extends ShiftDetailsState {}
/// Loaded state containing the full shift detail.
class ShiftDetailsLoaded extends ShiftDetailsState {
final Shift shift;
/// Creates a [ShiftDetailsLoaded].
const ShiftDetailsLoaded(this.detail, {this.isProfileComplete = false});
/// The full shift detail from the V2 API.
final ShiftDetail detail;
/// Whether the staff profile is complete.
final bool isProfileComplete;
const ShiftDetailsLoaded(this.shift, {this.isProfileComplete = false});
@override
List<Object?> get props => [shift, isProfileComplete];
List<Object?> get props => <Object?>[detail, isProfileComplete];
}
/// Error state with a message key.
class ShiftDetailsError extends ShiftDetailsState {
final String message;
/// Creates a [ShiftDetailsError].
const ShiftDetailsError(this.message);
/// The error message key.
final String message;
@override
List<Object?> get props => [message];
List<Object?> get props => <Object?>[message];
}
/// Success state after a shift action (apply, accept, decline).
class ShiftActionSuccess extends ShiftDetailsState {
final String message;
final DateTime? shiftDate;
/// Creates a [ShiftActionSuccess].
const ShiftActionSuccess(this.message, {this.shiftDate});
/// Success message.
final String message;
/// The date of the shift for navigation.
final DateTime? shiftDate;
@override
List<Object?> get props => [message, shiftDate];
List<Object?> get props => <Object?>[message, shiftDate];
}

View File

@@ -1,47 +1,72 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:meta/meta.dart';
import '../../../domain/arguments/get_available_shifts_arguments.dart';
import '../../../domain/arguments/get_my_shifts_arguments.dart';
import '../../../domain/usecases/get_available_shifts_usecase.dart';
import '../../../domain/usecases/get_cancelled_shifts_usecase.dart';
import '../../../domain/usecases/get_history_shifts_usecase.dart';
import '../../../domain/usecases/get_my_shifts_usecase.dart';
import '../../../domain/usecases/get_pending_assignments_usecase.dart';
import 'package:staff_shifts/src/domain/arguments/get_available_shifts_arguments.dart';
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/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_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';
part 'shifts_event.dart';
part 'shifts_state.dart';
/// Manages the state for the shifts listing page (My Shifts / Find / History).
class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
with BlocErrorHandler<ShiftsState> {
final GetMyShiftsUseCase getMyShifts;
final GetAvailableShiftsUseCase getAvailableShifts;
final GetPendingAssignmentsUseCase getPendingAssignments;
final GetCancelledShiftsUseCase getCancelledShifts;
final GetHistoryShiftsUseCase getHistoryShifts;
final GetProfileCompletionUseCase getProfileCompletion;
/// Creates a [ShiftsBloc].
ShiftsBloc({
required this.getMyShifts,
required this.getAvailableShifts,
required this.getAssignedShifts,
required this.getOpenShifts,
required this.getPendingAssignments,
required this.getCancelledShifts,
required this.getHistoryShifts,
required this.getCompletedShifts,
required this.getProfileCompletion,
required this.acceptShift,
required this.declineShift,
}) : super(const ShiftsState()) {
on<LoadShiftsEvent>(_onLoadShifts);
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
on<LoadAvailableShiftsEvent>(_onLoadAvailableShifts);
on<LoadFindFirstEvent>(_onLoadFindFirst);
on<LoadShiftsForRangeEvent>(_onLoadShiftsForRange);
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
on<SearchOpenShiftsEvent>(_onSearchOpenShifts);
on<CheckProfileCompletionEvent>(_onCheckProfileCompletion);
on<AcceptShiftEvent>(_onAcceptShift);
on<DeclineShiftEvent>(_onDeclineShift);
}
/// Use case for assigned shifts.
final GetAssignedShiftsUseCase getAssignedShifts;
/// Use case for open shifts.
final GetOpenShiftsUseCase getOpenShifts;
/// Use case for pending assignments.
final GetPendingAssignmentsUseCase getPendingAssignments;
/// Use case for cancelled shifts.
final GetCancelledShiftsUseCase getCancelledShifts;
/// Use case for completed shifts.
final GetCompletedShiftsUseCase getCompletedShifts;
/// Use case for profile completion.
final GetProfileCompletionUseCase getProfileCompletion;
/// Use case for accepting a shift.
final AcceptShiftUseCase acceptShift;
/// Use case for declining a shift.
final DeclineShiftUseCase declineShift;
Future<void> _onLoadShifts(
LoadShiftsEvent event,
Emitter<ShiftsState> emit,
@@ -54,25 +79,37 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
emit: emit.call,
action: () async {
final List<DateTime> days = _getCalendarDaysForOffset(0);
final myShiftsResult = await getMyShifts(
GetMyShiftsArguments(start: days.first, end: days.last),
);
// 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>;
emit(
state.copyWith(
status: ShiftsStatus.loaded,
myShifts: myShiftsResult,
pendingShifts: const [],
cancelledShifts: const [],
availableShifts: const [],
historyShifts: const [],
pendingShifts: pendingResult,
cancelledShifts: cancelledResult,
availableShifts: const <OpenShift>[],
historyShifts: const <CompletedShift>[],
availableLoading: false,
availableLoaded: false,
historyLoading: false,
historyLoaded: false,
myShiftsLoaded: true,
searchQuery: '',
jobType: 'all',
),
);
},
@@ -92,7 +129,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
await handleError(
emit: emit.call,
action: () async {
final historyResult = await getHistoryShifts();
final List<CompletedShift> historyResult = await getCompletedShifts();
emit(
state.copyWith(
myShiftsLoaded: true,
@@ -125,12 +162,12 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
await handleError(
emit: emit.call,
action: () async {
final availableResult = await getAvailableShifts(
const GetAvailableShiftsArguments(),
final List<OpenShift> availableResult = await getOpenShifts(
const GetOpenShiftsArguments(),
);
emit(
state.copyWith(
availableShifts: _filterPastShifts(availableResult),
availableShifts: _filterPastOpenShifts(availableResult),
availableLoading: false,
availableLoaded: true,
),
@@ -154,18 +191,17 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
emit(
state.copyWith(
status: ShiftsStatus.loading,
myShifts: const [],
pendingShifts: const [],
cancelledShifts: const [],
availableShifts: const [],
historyShifts: const [],
myShifts: const <AssignedShift>[],
pendingShifts: const <PendingAssignment>[],
cancelledShifts: const <CancelledShift>[],
availableShifts: const <OpenShift>[],
historyShifts: const <CompletedShift>[],
availableLoading: false,
availableLoaded: false,
historyLoading: false,
historyLoaded: false,
myShiftsLoaded: false,
searchQuery: '',
jobType: 'all',
),
);
}
@@ -177,13 +213,13 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
await handleError(
emit: emit.call,
action: () async {
final availableResult = await getAvailableShifts(
const GetAvailableShiftsArguments(),
final List<OpenShift> availableResult = await getOpenShifts(
const GetOpenShiftsArguments(),
);
emit(
state.copyWith(
status: ShiftsStatus.loaded,
availableShifts: _filterPastShifts(availableResult),
availableShifts: _filterPastOpenShifts(availableResult),
availableLoading: false,
availableLoaded: true,
),
@@ -206,8 +242,8 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
await handleError(
emit: emit.call,
action: () async {
final myShiftsResult = await getMyShifts(
GetMyShiftsArguments(start: event.start, end: event.end),
final List<AssignedShift> myShiftsResult = await getAssignedShifts(
GetAssignedShiftsArguments(start: event.start, end: event.end),
);
emit(
@@ -223,8 +259,8 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
);
}
Future<void> _onFilterAvailableShifts(
FilterAvailableShiftsEvent event,
Future<void> _onSearchOpenShifts(
SearchOpenShiftsEvent event,
Emitter<ShiftsState> emit,
) async {
if (state.status == ShiftsStatus.loaded) {
@@ -236,18 +272,17 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
await handleError(
emit: emit.call,
action: () async {
final result = await getAvailableShifts(
GetAvailableShiftsArguments(
query: event.query ?? state.searchQuery,
type: event.jobType ?? state.jobType,
final String search = event.query ?? state.searchQuery;
final List<OpenShift> result = await getOpenShifts(
GetOpenShiftsArguments(
search: search.isEmpty ? null : search,
),
);
emit(
state.copyWith(
availableShifts: _filterPastShifts(result),
searchQuery: event.query ?? state.searchQuery,
jobType: event.jobType ?? state.jobType,
availableShifts: _filterPastOpenShifts(result),
searchQuery: search,
),
);
},
@@ -277,33 +312,60 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
);
}
List<DateTime> _getCalendarDaysForOffset(int weekOffset) {
final now = DateTime.now();
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
final int daysSinceFriday = (reactDayIndex + 2) % 7;
final start = now
.subtract(Duration(days: daysSinceFriday))
.add(Duration(days: weekOffset * 7));
final startDate = DateTime(start.year, start.month, start.day);
return List.generate(7, (index) => startDate.add(Duration(days: index)));
Future<void> _onAcceptShift(
AcceptShiftEvent event,
Emitter<ShiftsState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
await acceptShift(event.shiftId);
add(LoadShiftsEvent());
},
onError: (String errorKey) =>
state.copyWith(status: ShiftsStatus.error, errorMessage: errorKey),
);
}
List<Shift> _filterPastShifts(List<Shift> shifts) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
return shifts.where((shift) {
if (shift.date.isEmpty) return false;
try {
final shiftDate = DateTime.parse(shift.date).toLocal();
final dateOnly = DateTime(
shiftDate.year,
shiftDate.month,
shiftDate.day,
);
return !dateOnly.isBefore(today);
} catch (_) {
return false;
}
Future<void> _onDeclineShift(
DeclineShiftEvent event,
Emitter<ShiftsState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
await declineShift(event.shiftId);
add(LoadShiftsEvent());
},
onError: (String errorKey) =>
state.copyWith(status: ShiftsStatus.error, errorMessage: errorKey),
);
}
/// 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)));
}
/// 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();
}
}

View File

@@ -1,69 +1,95 @@
part of 'shifts_bloc.dart';
/// Base class for all shifts events.
@immutable
sealed class ShiftsEvent extends Equatable {
/// Creates a [ShiftsEvent].
const ShiftsEvent();
@override
List<Object?> get props => [];
List<Object?> get props => <Object?>[];
}
/// Triggers initial load of assigned shifts for the current week.
class LoadShiftsEvent extends ShiftsEvent {}
/// Triggers lazy load of completed shift history.
class LoadHistoryShiftsEvent extends ShiftsEvent {}
/// Triggers load of open shifts available to apply.
class LoadAvailableShiftsEvent extends ShiftsEvent {
final bool force;
/// Creates a [LoadAvailableShiftsEvent].
const LoadAvailableShiftsEvent({this.force = false});
/// Whether to force reload even if already loaded.
final bool force;
@override
List<Object?> get props => [force];
List<Object?> get props => <Object?>[force];
}
/// Loads open shifts first (for when Find tab is the initial tab).
class LoadFindFirstEvent extends ShiftsEvent {}
/// Loads assigned shifts for a specific date range.
class LoadShiftsForRangeEvent extends ShiftsEvent {
final DateTime start;
final DateTime end;
/// Creates a [LoadShiftsForRangeEvent].
const LoadShiftsForRangeEvent({
required this.start,
required this.end,
});
/// Start of the date range.
final DateTime start;
/// End of the date range.
final DateTime end;
@override
List<Object?> get props => [start, end];
List<Object?> get props => <Object?>[start, end];
}
class FilterAvailableShiftsEvent extends ShiftsEvent {
/// Triggers a server-side search for open shifts.
class SearchOpenShiftsEvent extends ShiftsEvent {
/// Creates a [SearchOpenShiftsEvent].
const SearchOpenShiftsEvent({this.query});
/// The search query string.
final String? query;
final String? jobType;
const FilterAvailableShiftsEvent({this.query, this.jobType});
@override
List<Object?> get props => [query, jobType];
List<Object?> get props => <Object?>[query];
}
/// Accepts a pending shift assignment.
class AcceptShiftEvent extends ShiftsEvent {
final String shiftId;
/// Creates an [AcceptShiftEvent].
const AcceptShiftEvent(this.shiftId);
/// The shift row id to accept.
final String shiftId;
@override
List<Object?> get props => [shiftId];
List<Object?> get props => <Object?>[shiftId];
}
/// Declines a pending shift assignment.
class DeclineShiftEvent extends ShiftsEvent {
final String shiftId;
/// Creates a [DeclineShiftEvent].
const DeclineShiftEvent(this.shiftId);
/// The shift row id to decline.
final String shiftId;
@override
List<Object?> get props => [shiftId];
List<Object?> get props => <Object?>[shiftId];
}
/// Triggers a profile completion check.
class CheckProfileCompletionEvent extends ShiftsEvent {
/// Creates a [CheckProfileCompletionEvent].
const CheckProfileCompletionEvent();
@override
List<Object?> get props => [];
List<Object?> get props => <Object?>[];
}

View File

@@ -1,56 +1,84 @@
part of 'shifts_bloc.dart';
/// Lifecycle status for the shifts page.
enum ShiftsStatus { initial, loading, loaded, error }
/// State for the shifts listing page.
class ShiftsState extends Equatable {
final ShiftsStatus status;
final List<Shift> myShifts;
final List<Shift> pendingShifts;
final List<Shift> cancelledShifts;
final List<Shift> availableShifts;
final List<Shift> historyShifts;
final bool availableLoading;
final bool availableLoaded;
final bool historyLoading;
final bool historyLoaded;
final bool myShiftsLoaded;
final String searchQuery;
final String jobType;
final bool? profileComplete;
final String? errorMessage;
/// Creates a [ShiftsState].
const ShiftsState({
this.status = ShiftsStatus.initial,
this.myShifts = const [],
this.pendingShifts = const [],
this.cancelledShifts = const [],
this.availableShifts = const [],
this.historyShifts = const [],
this.myShifts = const <AssignedShift>[],
this.pendingShifts = const <PendingAssignment>[],
this.cancelledShifts = const <CancelledShift>[],
this.availableShifts = const <OpenShift>[],
this.historyShifts = const <CompletedShift>[],
this.availableLoading = false,
this.availableLoaded = false,
this.historyLoading = false,
this.historyLoaded = false,
this.myShiftsLoaded = false,
this.searchQuery = '',
this.jobType = 'all',
this.profileComplete,
this.errorMessage,
});
/// Current lifecycle status.
final ShiftsStatus status;
/// Assigned shifts for the selected week.
final List<AssignedShift> myShifts;
/// Pending assignments awaiting acceptance.
final List<PendingAssignment> pendingShifts;
/// Cancelled shift assignments.
final List<CancelledShift> cancelledShifts;
/// Open shifts available for application.
final List<OpenShift> availableShifts;
/// Completed shift history.
final List<CompletedShift> historyShifts;
/// Whether open shifts are currently loading.
final bool availableLoading;
/// Whether open shifts have been loaded at least once.
final bool availableLoaded;
/// Whether history is currently loading.
final bool historyLoading;
/// Whether history has been loaded at least once.
final bool historyLoaded;
/// Whether assigned shifts have been loaded at least once.
final bool myShiftsLoaded;
/// Current search query for open shifts.
final String searchQuery;
/// Whether the staff profile is complete.
final bool? profileComplete;
/// Error message key for display.
final String? errorMessage;
/// Creates a copy with the given fields replaced.
ShiftsState copyWith({
ShiftsStatus? status,
List<Shift>? myShifts,
List<Shift>? pendingShifts,
List<Shift>? cancelledShifts,
List<Shift>? availableShifts,
List<Shift>? historyShifts,
List<AssignedShift>? myShifts,
List<PendingAssignment>? pendingShifts,
List<CancelledShift>? cancelledShifts,
List<OpenShift>? availableShifts,
List<CompletedShift>? historyShifts,
bool? availableLoading,
bool? availableLoaded,
bool? historyLoading,
bool? historyLoaded,
bool? myShiftsLoaded,
String? searchQuery,
String? jobType,
bool? profileComplete,
String? errorMessage,
}) {
@@ -67,28 +95,26 @@ class ShiftsState extends Equatable {
historyLoaded: historyLoaded ?? this.historyLoaded,
myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded,
searchQuery: searchQuery ?? this.searchQuery,
jobType: jobType ?? this.jobType,
profileComplete: profileComplete ?? this.profileComplete,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
status,
myShifts,
pendingShifts,
cancelledShifts,
availableShifts,
historyShifts,
availableLoading,
availableLoaded,
historyLoading,
historyLoaded,
myShiftsLoaded,
searchQuery,
jobType,
profileComplete,
errorMessage,
];
List<Object?> get props => <Object?>[
status,
myShifts,
pendingShifts,
cancelledShifts,
availableShifts,
historyShifts,
availableLoading,
availableLoaded,
historyLoading,
historyLoaded,
myShiftsLoaded,
searchQuery,
profileComplete,
errorMessage,
];
}

View File

@@ -7,29 +7,30 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/shift_details/shift_details_bloc.dart';
import '../blocs/shift_details/shift_details_event.dart';
import '../blocs/shift_details/shift_details_state.dart';
import '../widgets/shift_details/shift_break_section.dart';
import '../widgets/shift_details/shift_date_time_section.dart';
import '../widgets/shift_details/shift_description_section.dart';
import '../widgets/shift_details/shift_details_bottom_bar.dart';
import '../widgets/shift_details/shift_details_header.dart';
import '../widgets/shift_details_page_skeleton.dart';
import '../widgets/shift_details/shift_location_section.dart';
import '../widgets/shift_details/shift_schedule_summary_section.dart';
import '../widgets/shift_details/shift_stats_row.dart';
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart';
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_event.dart';
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/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';
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 a single shift.
///
/// Loads data via [ShiftDetailsBloc] from the V2 API.
class ShiftDetailsPage extends StatefulWidget {
final String shiftId;
final Shift shift;
/// Creates a [ShiftDetailsPage].
const ShiftDetailsPage({
super.key,
required this.shiftId,
required this.shift,
});
/// The shift row ID to load details for.
final String shiftId;
@override
State<ShiftDetailsPage> createState() => _ShiftDetailsPageState();
}
@@ -38,53 +39,27 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
bool _actionDialogOpen = false;
bool _isApplying = false;
String _formatTime(String time) {
if (time.isEmpty) return '';
try {
final parts = time.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
final dt = DateTime(2022, 1, 1, hour, minute);
return DateFormat('h:mm a').format(dt);
} catch (e) {
return time;
}
String _formatTime(DateTime dt) {
return DateFormat('h:mm a').format(dt);
}
String _formatDate(String dateStr) {
if (dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
return DateFormat('EEEE, MMMM d, y').format(date);
} catch (e) {
return dateStr;
}
String _formatDate(DateTime dt) {
return DateFormat('EEEE, MMMM d, y').format(dt);
}
double _calculateDuration(Shift shift) {
if (shift.startTime.isEmpty || shift.endTime.isEmpty) {
return 0;
}
try {
final s = shift.startTime.split(':').map(int.parse).toList();
final e = shift.endTime.split(':').map(int.parse).toList();
double hours = ((e[0] * 60 + e[1]) - (s[0] * 60 + s[1])) / 60;
if (hours < 0) hours += 24;
return hours.roundToDouble();
} catch (_) {
return 0;
}
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>(
create: (_) => Modular.get<ShiftDetailsBloc>()
..add(
LoadShiftDetailsEvent(widget.shiftId, roleId: widget.shift.roleId),
),
..add(LoadShiftDetailsEvent(widget.shiftId)),
child: BlocConsumer<ShiftDetailsBloc, ShiftDetailsState>(
listener: (context, state) {
listener: (BuildContext context, ShiftDetailsState state) {
if (state is ShiftActionSuccess || state is ShiftDetailsError) {
_closeActionDialog(context);
}
@@ -117,20 +92,19 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
_isApplying = false;
}
},
builder: (context, state) {
if (state is ShiftDetailsLoading) {
builder: (BuildContext context, ShiftDetailsState state) {
if (state is! ShiftDetailsLoaded) {
return const ShiftDetailsPageSkeleton();
}
final Shift displayShift = widget.shift;
final i18n = Translations.of(context).staff_shifts.shift_details;
final isProfileComplete = state is ShiftDetailsLoaded
? state.isProfileComplete
: false;
final ShiftDetail detail = state.detail;
final dynamic i18n =
Translations.of(context).staff_shifts.shift_details;
final bool isProfileComplete = state.isProfileComplete;
final duration = _calculateDuration(displayShift);
final estimatedTotal =
displayShift.totalValue ?? (displayShift.hourlyRate * duration);
final double duration = _calculateDuration(detail);
final double hourlyRate = detail.hourlyRateCents / 100;
final double estimatedTotal = hourlyRate * duration;
return Scaffold(
appBar: UiAppBar(
@@ -138,12 +112,12 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
onLeadingPressed: () => Modular.to.toShifts(),
),
body: Column(
children: [
children: <Widget>[
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
if (!isProfileComplete)
Padding(
padding: const EdgeInsets.all(UiConstants.space6),
@@ -154,56 +128,38 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
icon: UiIcons.sparkles,
),
),
ShiftDetailsHeader(shift: displayShift),
ShiftDetailsHeader(detail: detail),
const Divider(height: 1, thickness: 0.5),
ShiftStatsRow(
estimatedTotal: estimatedTotal,
hourlyRate: displayShift.hourlyRate,
hourlyRate: hourlyRate,
duration: duration,
totalLabel: i18n.est_total,
hourlyRateLabel: i18n.hourly_rate,
hoursLabel: i18n.hours,
),
const Divider(height: 1, thickness: 0.5),
ShiftDateTimeSection(
date: displayShift.date,
endDate: displayShift.endDate,
startTime: displayShift.startTime,
endTime: displayShift.endTime,
date: detail.date,
startTime: detail.startTime,
endTime: detail.endTime,
shiftDateLabel: i18n.shift_date,
clockInLabel: i18n.start_time,
clockOutLabel: i18n.end_time,
),
const Divider(height: 1, thickness: 0.5),
ShiftScheduleSummarySection(shift: displayShift),
const Divider(height: 1, thickness: 0.5),
if (displayShift.breakInfo != null &&
displayShift.breakInfo!.duration !=
BreakDuration.none) ...[
ShiftBreakSection(
breakInfo: displayShift.breakInfo!,
breakTitle: i18n.break_title,
paidLabel: i18n.paid,
unpaidLabel: i18n.unpaid,
minLabel: i18n.min,
),
const Divider(height: 1, thickness: 0.5),
],
ShiftLocationSection(
shift: displayShift,
location: detail.location,
address: detail.address ?? '',
locationLabel: i18n.location,
tbdLabel: i18n.tbd,
getDirectionLabel: i18n.get_direction,
),
const Divider(height: 1, thickness: 0.5),
if (displayShift.description != null &&
displayShift.description!.isNotEmpty)
if (detail.description != null &&
detail.description!.isNotEmpty)
ShiftDescriptionSection(
description: displayShift.description!,
description: detail.description!,
descriptionLabel: i18n.job_description,
),
],
@@ -212,18 +168,18 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
),
if (isProfileComplete)
ShiftDetailsBottomBar(
shift: displayShift,
onApply: () => _bookShift(context, displayShift),
detail: detail,
onApply: () => _bookShift(context, detail),
onDecline: () => BlocProvider.of<ShiftDetailsBloc>(
context,
).add(DeclineShiftDetailsEvent(displayShift.id)),
).add(DeclineShiftDetailsEvent(detail.shiftId)),
onAccept: () =>
BlocProvider.of<ShiftDetailsBloc>(context).add(
BookShiftDetailsEvent(
displayShift.id,
roleId: displayShift.roleId,
),
),
BookShiftDetailsEvent(
detail.shiftId,
roleId: detail.roleId,
),
),
),
],
),
@@ -233,16 +189,15 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
);
}
void _bookShift(BuildContext context, Shift shift) {
final i18n = Translations.of(
context,
).staff_shifts.shift_details.book_dialog;
showDialog(
void _bookShift(BuildContext context, ShiftDetail detail) {
final dynamic i18n =
Translations.of(context).staff_shifts.shift_details.book_dialog;
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(i18n.title),
content: Text(i18n.message),
actions: [
builder: (BuildContext ctx) => AlertDialog(
title: Text(i18n.title as String),
content: Text(i18n.message as String),
actions: <Widget>[
TextButton(
onPressed: () => Modular.to.popSafe(),
child: Text(Translations.of(context).common.cancel),
@@ -250,12 +205,12 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
TextButton(
onPressed: () {
Modular.to.popSafe();
_showApplyingDialog(context, shift);
_showApplyingDialog(context, detail);
BlocProvider.of<ShiftDetailsBloc>(context).add(
BookShiftDetailsEvent(
shift.id,
roleId: shift.roleId,
date: DateTime.tryParse(shift.date),
detail.shiftId,
roleId: detail.roleId,
date: detail.date,
),
);
},
@@ -269,22 +224,21 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
);
}
void _showApplyingDialog(BuildContext context, Shift shift) {
void _showApplyingDialog(BuildContext context, ShiftDetail detail) {
if (_actionDialogOpen) return;
_actionDialogOpen = true;
_isApplying = true;
final i18n = Translations.of(
context,
).staff_shifts.shift_details.applying_dialog;
showDialog(
final dynamic i18n =
Translations.of(context).staff_shifts.shift_details.applying_dialog;
showDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(i18n.title),
builder: (BuildContext ctx) => AlertDialog(
title: Text(i18n.title as String),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
children: <Widget>[
const SizedBox(
height: 36,
width: 36,
@@ -292,24 +246,16 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
),
const SizedBox(height: UiConstants.space4),
Text(
shift.title,
detail.title,
style: UiTypography.body2b.textPrimary,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
'${_formatDate(shift.date)} ${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}',
'${_formatDate(detail.date)} \u2022 ${_formatTime(detail.startTime)} - ${_formatTime(detail.endTime)}',
style: UiTypography.body3r.textSecondary,
textAlign: TextAlign.center,
),
if (shift.clientName.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
shift.clientName,
style: UiTypography.body3r.textSecondary,
textAlign: TextAlign.center,
),
],
],
),
),
@@ -325,14 +271,14 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
}
void _showEligibilityErrorDialog(BuildContext context) {
showDialog(
showDialog<void>(
context: context,
builder: (BuildContext ctx) => AlertDialog(
backgroundColor: UiColors.bgPopup,
shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg),
title: Row(
spacing: UiConstants.space2,
children: [
children: <Widget>[
const Icon(UiIcons.warning, color: UiColors.error),
Expanded(
child: Text(
@@ -342,16 +288,16 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
],
),
content: Text(
"You are missing required certifications or documents to claim this shift. Please upload them to continue.",
'You are missing required certifications or documents to claim this shift. Please upload them to continue.',
style: UiTypography.body2r.textSecondary,
),
actions: [
actions: <Widget>[
UiButton.secondary(
text: "Cancel",
text: 'Cancel',
onPressed: () => Navigator.of(ctx).pop(),
),
UiButton.primary(
text: "Go to Certificates",
text: 'Go to Certificates',
onPressed: () {
Modular.to.popSafe();
Modular.to.toCertificates();

View File

@@ -4,12 +4,13 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/shifts/shifts_bloc.dart';
import '../utils/shift_tab_type.dart';
import '../widgets/shifts_page_skeleton.dart';
import '../widgets/tabs/my_shifts_tab.dart';
import '../widgets/tabs/find_shifts_tab.dart';
import '../widgets/tabs/history_shifts_tab.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';
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';
class ShiftsPage extends StatefulWidget {
final ShiftTabType? initialTab;
@@ -102,13 +103,13 @@ class _ShiftsPageState extends State<ShiftsPage> {
_bloc.add(const LoadAvailableShiftsEvent(force: true));
}
final bool baseLoaded = state.status == ShiftsStatus.loaded;
final List<Shift> myShifts = state.myShifts;
final List<Shift> availableJobs = state.availableShifts;
final List<AssignedShift> myShifts = state.myShifts;
final List<OpenShift> availableJobs = state.availableShifts;
final bool availableLoading = state.availableLoading;
final bool availableLoaded = state.availableLoaded;
final List<Shift> pendingAssignments = state.pendingShifts;
final List<Shift> cancelledShifts = state.cancelledShifts;
final List<Shift> historyShifts = state.historyShifts;
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;
@@ -235,11 +236,11 @@ class _ShiftsPageState extends State<ShiftsPage> {
Widget _buildTabContent(
ShiftsState state,
List<Shift> myShifts,
List<Shift> pendingAssignments,
List<Shift> cancelledShifts,
List<Shift> availableJobs,
List<Shift> historyShifts,
List<AssignedShift> myShifts,
List<PendingAssignment> pendingAssignments,
List<CancelledShift> cancelledShifts,
List<OpenShift> availableJobs,
List<CompletedShift> historyShifts,
bool availableLoading,
bool historyLoading,
) {

View File

@@ -4,24 +4,31 @@ import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_core/core.dart'; // For modular navigation
import 'package:krow_core/core.dart';
/// Card widget displaying an assigned shift summary.
class MyShiftCard extends StatefulWidget {
final Shift shift;
final bool historyMode;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
final VoidCallback? onRequestSwap;
/// Creates a [MyShiftCard].
const MyShiftCard({
super.key,
required this.shift,
this.historyMode = false,
this.onAccept,
this.onDecline,
this.onRequestSwap,
});
/// The assigned shift entity.
final AssignedShift shift;
/// Callback when the shift is accepted.
final VoidCallback? onAccept;
/// Callback when the shift is declined.
final VoidCallback? onDecline;
/// Callback when a swap is requested.
final VoidCallback? onRequestSwap;
@override
State<MyShiftCard> createState() => _MyShiftCardState();
}
@@ -29,141 +36,88 @@ class MyShiftCard extends StatefulWidget {
class _MyShiftCardState extends State<MyShiftCard> {
bool _isSubmitted = false;
String _formatTime(String time) {
if (time.isEmpty) return '';
try {
final parts = time.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
// Date doesn't matter for time formatting
final dt = DateTime(2022, 1, 1, hour, minute);
return DateFormat('h:mm a').format(dt);
} catch (e) {
return time;
}
}
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
String _formatDate(String dateStr) {
if (dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final tomorrow = today.add(const Duration(days: 1));
final d = DateTime(date.year, date.month, date.day);
if (d == today) return 'Today';
if (d == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
} catch (e) {
return dateStr;
}
String _formatDate(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 'Today';
if (d == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
}
double _calculateDuration() {
if (widget.shift.startTime.isEmpty || widget.shift.endTime.isEmpty) {
return 0;
}
try {
final s = widget.shift.startTime.split(':').map(int.parse).toList();
final e = widget.shift.endTime.split(':').map(int.parse).toList();
double hours = ((e[0] * 60 + e[1]) - (s[0] * 60 + s[1])) / 60;
if (hours < 0) hours += 24;
return hours.roundToDouble();
} catch (_) {
return 0;
}
final int minutes =
widget.shift.endTime.difference(widget.shift.startTime).inMinutes;
double hours = minutes / 60;
if (hours < 0) hours += 24;
return hours.roundToDouble();
}
String _getShiftType() {
// Handling potential localization key availability
try {
final String orderType = (widget.shift.orderType ?? '').toUpperCase();
if (orderType == 'PERMANENT') {
return t.staff_shifts.filter.long_term;
switch (widget.shift.orderType) {
case OrderType.permanent:
return t.staff_shifts.filter.long_term;
case OrderType.recurring:
return t.staff_shifts.filter.multi_day;
case OrderType.oneTime:
default:
return t.staff_shifts.filter.one_day;
}
if (orderType == 'RECURRING') {
return t.staff_shifts.filter.multi_day;
}
if (widget.shift.durationDays != null &&
widget.shift.durationDays! > 30) {
return t.staff_shifts.filter.long_term;
}
if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) {
return t.staff_shifts.filter.multi_day;
}
return t.staff_shifts.filter.one_day;
} catch (_) {
return "One Day";
return 'One Day';
}
}
@override
Widget build(BuildContext context) {
final duration = _calculateDuration();
final estimatedTotal = (widget.shift.hourlyRate) * duration;
final double duration = _calculateDuration();
final double hourlyRate = widget.shift.hourlyRateCents / 100;
final double estimatedTotal = hourlyRate * duration;
// Status Logic
String? status = widget.shift.status;
final AssignmentStatus status = widget.shift.status;
Color statusColor = UiColors.primary;
Color statusBg = UiColors.primary;
String statusText = '';
IconData? statusIcon;
// Fallback localization if keys missing
try {
if (status == 'confirmed') {
statusText = t.staff_shifts.status.confirmed;
statusColor = UiColors.textLink;
statusBg = UiColors.primary;
} else if (status == 'checked_in') {
statusText = context.t.staff_shifts.my_shift_card.checked_in;
statusColor = UiColors.textSuccess;
statusBg = UiColors.iconSuccess;
} else if (status == 'pending' || status == 'open') {
statusText = t.staff_shifts.status.act_now;
statusColor = UiColors.destructive;
statusBg = UiColors.destructive;
} else if (status == 'swap') {
statusText = t.staff_shifts.status.swap_requested;
statusColor = UiColors.textWarning;
statusBg = UiColors.textWarning;
statusIcon = UiIcons.swap;
} else if (status == 'completed') {
statusText = t.staff_shifts.status.completed;
statusColor = UiColors.textSuccess;
statusBg = UiColors.iconSuccess;
} else if (status == 'no_show') {
statusText = t.staff_shifts.status.no_show;
statusColor = UiColors.destructive;
statusBg = UiColors.destructive;
switch (status) {
case AssignmentStatus.accepted:
statusText = t.staff_shifts.status.confirmed;
statusColor = UiColors.textLink;
statusBg = UiColors.primary;
case AssignmentStatus.checkedIn:
statusText = context.t.staff_shifts.my_shift_card.checked_in;
statusColor = UiColors.textSuccess;
statusBg = UiColors.iconSuccess;
case AssignmentStatus.assigned:
statusText = t.staff_shifts.status.act_now;
statusColor = UiColors.destructive;
statusBg = UiColors.destructive;
case AssignmentStatus.swapRequested:
statusText = t.staff_shifts.status.swap_requested;
statusColor = UiColors.textWarning;
statusBg = UiColors.textWarning;
statusIcon = UiIcons.swap;
case AssignmentStatus.completed:
statusText = t.staff_shifts.status.completed;
statusColor = UiColors.textSuccess;
statusBg = UiColors.iconSuccess;
default:
statusText = status.toJson().toUpperCase();
}
} catch (_) {
statusText = status?.toUpperCase() ?? "";
statusText = status.toJson().toUpperCase();
}
final schedules = widget.shift.schedules ?? <ShiftSchedule>[];
final hasSchedules = schedules.isNotEmpty;
final List<ShiftSchedule> visibleSchedules = schedules.length <= 5
? schedules
: schedules.take(3).toList();
final int remainingSchedules = schedules.length <= 5
? 0
: schedules.length - 3;
final String scheduleRange = hasSchedules
? () {
final first = schedules.first.date;
final last = schedules.last.date;
if (first == last) {
return _formatDate(first);
}
return '${_formatDate(first)} ${_formatDate(last)}';
}()
: '';
return GestureDetector(
onTap: () {
Modular.to.toShiftDetails(widget.shift);
Modular.to.toShiftDetailsById(widget.shift.shiftId);
},
child: Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
@@ -301,12 +255,12 @@ class _MyShiftCardState extends State<MyShiftCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.shift.title,
widget.shift.roleName,
style: UiTypography.body2m.textPrimary,
overflow: TextOverflow.ellipsis,
),
Text(
widget.shift.clientName,
widget.shift.location,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
),
@@ -318,11 +272,11 @@ class _MyShiftCardState extends State<MyShiftCard> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"\$${estimatedTotal.toStringAsFixed(0)}",
'\$${estimatedTotal.toStringAsFixed(0)}',
style: UiTypography.title1m.textPrimary,
),
Text(
"\$${widget.shift.hourlyRate.toInt()}/hr · ${duration.toInt()}h",
'\$${hourlyRate.toInt()}/hr \u00b7 ${duration.toInt()}h',
style: UiTypography.footnote2r.textSecondary,
),
],
@@ -332,134 +286,36 @@ class _MyShiftCardState extends State<MyShiftCard> {
const SizedBox(height: UiConstants.space2),
// Date & Time
if (hasSchedules) ...[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
UiIcons.clock,
size: UiConstants.iconXs,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space1),
Text(
scheduleRange,
style:
UiTypography.footnote2r.textSecondary,
),
],
),
const SizedBox(height: UiConstants.space2),
Text(
'${schedules.length} schedules',
style: UiTypography.footnote2m.copyWith(
color: UiColors.primary,
),
),
const SizedBox(height: UiConstants.space1),
...visibleSchedules.map(
(schedule) => Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${_formatDate(schedule.date)}, ${_formatTime(schedule.startTime)} ${_formatTime(schedule.endTime)}',
style: UiTypography.footnote2r.copyWith(
color: UiColors.primary,
),
),
),
),
if (remainingSchedules > 0)
Text(
'+$remainingSchedules more schedules',
style: UiTypography.footnote2r.copyWith(
color: UiColors.primary.withValues(
alpha: 0.7,
),
),
),
],
),
] else if (widget.shift.durationDays != null &&
widget.shift.durationDays! > 1) ...[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
UiIcons.clock,
size: UiConstants.iconXs,
color: UiColors.primary,
),
const SizedBox(width: UiConstants.space1),
Text(
t.staff_shifts.details.days(
days: widget.shift.durationDays!,
),
style: UiTypography.footnote2m.copyWith(
color: UiColors.primary,
),
),
],
),
const SizedBox(height: UiConstants.space1),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)} ${_formatTime(widget.shift.endTime)}',
style: UiTypography.footnote2r.copyWith(
color: UiColors.primary,
),
),
),
if (widget.shift.durationDays! > 1)
Text(
'... +${widget.shift.durationDays! - 1} more days',
style: UiTypography.footnote2r.copyWith(
color: UiColors.primary.withValues(
alpha: 0.7,
),
),
),
],
),
] else ...[
Row(
children: [
const Icon(
UiIcons.calendar,
size: UiConstants.iconXs,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space1),
Text(
_formatDate(widget.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(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}",
style: UiTypography.footnote1r.textSecondary,
),
],
),
],
Row(
children: <Widget>[
const Icon(
UiIcons.calendar,
size: UiConstants.iconXs,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space1),
Text(
_formatDate(widget.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(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}',
style: UiTypography.footnote1r.textSecondary,
),
],
),
const SizedBox(height: UiConstants.space1),
// Location
Row(
children: [
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: UiConstants.iconXs,
@@ -468,9 +324,7 @@ class _MyShiftCardState extends State<MyShiftCard> {
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(
widget.shift.locationAddress.isNotEmpty
? widget.shift.locationAddress
: widget.shift.location,
widget.shift.location,
style: UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
@@ -482,7 +336,7 @@ class _MyShiftCardState extends State<MyShiftCard> {
),
],
),
if (status == 'completed') ...[
if (status == AssignmentStatus.completed) ...[
const SizedBox(height: UiConstants.space4),
const Divider(),
const SizedBox(height: UiConstants.space2),

View File

@@ -3,72 +3,49 @@ import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:design_system/design_system.dart';
/// Card displaying a pending assignment with accept/decline actions.
class ShiftAssignmentCard extends StatelessWidget {
final Shift shift;
final VoidCallback onConfirm;
final VoidCallback onDecline;
final bool isConfirming;
/// Creates a [ShiftAssignmentCard].
const ShiftAssignmentCard({
super.key,
required this.shift,
required this.assignment,
required this.onConfirm,
required this.onDecline,
this.isConfirming = false,
});
String _formatTime(String time) {
if (time.isEmpty) return '';
try {
final parts = time.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
final dt = DateTime(2022, 1, 1, hour, minute);
return DateFormat('h:mm a').format(dt);
} catch (e) {
return time;
}
}
/// The pending assignment entity.
final PendingAssignment assignment;
String _formatDate(String dateStr) {
if (dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final tomorrow = today.add(const Duration(days: 1));
final d = DateTime(date.year, date.month, date.day);
/// Callback for accepting the assignment.
final VoidCallback onConfirm;
if (d == today) return 'Today';
if (d == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
} catch (e) {
return dateStr;
}
}
/// Callback for declining the assignment.
final VoidCallback onDecline;
double _calculateHours(String start, String end) {
if (start.isEmpty || end.isEmpty) return 0;
try {
final s = start.split(':').map(int.parse).toList();
final e = end.split(':').map(int.parse).toList();
return ((e[0] * 60 + e[1]) - (s[0] * 60 + s[1])) / 60;
} catch (_) {
return 0;
}
/// Whether the confirm action is in progress.
final bool isConfirming;
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
String _formatDate(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 'Today';
if (d == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
}
@override
Widget build(BuildContext context) {
final hours = _calculateHours(shift.startTime, shift.endTime);
final totalPay = shift.hourlyRate * hours;
return Container(
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: [
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.05),
blurRadius: 2,
@@ -77,155 +54,95 @@ class ShiftAssignmentCard extends StatelessWidget {
],
),
child: Column(
children: [
// Header
children: <Widget>[
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Card content starts directly as per prototype
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Logo
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
UiColors.primary.withValues(alpha: 0.09),
UiColors.primary.withValues(alpha: 0.03),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: UiColors.primary.withValues(alpha: 0.09),
),
),
child: shift.logoUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
child: Image.network(
shift.logoUrl!,
fit: BoxFit.contain,
),
)
: const Center(
child: Icon(
UiIcons.briefcase,
color: UiColors.primary,
size: 20,
),
),
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),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
const SizedBox(width: UiConstants.space3),
// Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shift.title,
style: UiTypography.body2m.textPrimary,
overflow: TextOverflow.ellipsis,
),
Text(
shift.clientName,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: UiConstants.space2),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"\$${totalPay.toStringAsFixed(0)}",
style: UiTypography.title1m.textPrimary,
),
Text(
"\$${shift.hourlyRate.toInt()}/hr · ${hours.toInt()}h",
style: UiTypography.footnote2r.textSecondary,
),
],
),
],
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: UiColors.primary.withValues(alpha: 0.09),
),
),
child: const Center(
child: Icon(
UiIcons.briefcase,
color: UiColors.primary,
size: 20,
),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
assignment.roleName,
style: UiTypography.body2m.textPrimary,
overflow: TextOverflow.ellipsis,
),
if (assignment.title.isNotEmpty)
Text(
assignment.title,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
const Icon(UiIcons.calendar,
size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
Text(
_formatDate(assignment.startTime),
style: UiTypography.footnote1r.textSecondary,
),
const SizedBox(height: UiConstants.space3),
// Date & Time
Row(
children: [
const Icon(
UiIcons.calendar,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
_formatDate(shift.date),
style: UiTypography.footnote1r.textSecondary,
),
const SizedBox(width: UiConstants.space3),
const Icon(
UiIcons.clock,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
style: UiTypography.footnote1r.textSecondary,
),
],
),
const SizedBox(height: 4),
// Location
Row(
children: [
const Icon(
UiIcons.mapPin,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
shift.locationAddress.isNotEmpty
? shift.locationAddress
: shift.location,
style: UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
),
],
const SizedBox(width: UiConstants.space3),
const Icon(UiIcons.clock,
size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
Text(
'${_formatTime(assignment.startTime)} - ${_formatTime(assignment.endTime)}',
style: UiTypography.footnote1r.textSecondary,
),
],
),
),
],
const SizedBox(height: 4),
Row(
children: <Widget>[
const Icon(UiIcons.mapPin,
size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
Expanded(
child: Text(
assignment.location,
style: UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
],
),
),
// Actions
Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: const BoxDecoration(
@@ -236,17 +153,14 @@ class ShiftAssignmentCard extends StatelessWidget {
),
),
child: Row(
children: [
children: <Widget>[
Expanded(
child: TextButton(
onPressed: onDecline,
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
),
child: Text(
"Decline", // Fallback if translation is broken
style: UiTypography.body2m.textError,
),
child: Text('Decline', style: UiTypography.body2m.textError),
),
),
const SizedBox(width: UiConstants.space2),
@@ -258,7 +172,8 @@ class ShiftAssignmentCard extends StatelessWidget {
foregroundColor: UiColors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderRadius: BorderRadius.circular(
UiConstants.radiusMdValue),
),
),
child: isConfirming
@@ -270,10 +185,7 @@ class ShiftAssignmentCard extends StatelessWidget {
color: UiColors.white,
),
)
: Text(
"Accept", // Fallback
style: UiTypography.body2m.white,
),
: Text('Accept', style: UiTypography.body2m.white),
),
),
],

View File

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

View File

@@ -1,62 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// A section displaying shift break details (duration and payment status).
class ShiftBreakSection extends StatelessWidget {
/// The break information.
final Break breakInfo;
/// Localization string for break section title.
final String breakTitle;
/// Localization string for paid status.
final String paidLabel;
/// Localization string for unpaid status.
final String unpaidLabel;
/// Localization string for minutes ("min").
final String minLabel;
/// Creates a [ShiftBreakSection].
const ShiftBreakSection({
super.key,
required this.breakInfo,
required this.breakTitle,
required this.paidLabel,
required this.unpaidLabel,
required this.minLabel,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
breakTitle,
style: UiTypography.titleUppercase4b.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Row(
children: [
const Icon(
UiIcons.breakIcon,
size: 20,
color: UiColors.primary,
),
const SizedBox(width: UiConstants.space2),
Text(
"${breakInfo.duration.minutes} $minLabel (${breakInfo.isBreakPaid ? paidLabel : unpaidLabel})",
style: UiTypography.headline5m.textPrimary,
),
],
),
],
),
);
}
}

View File

@@ -4,17 +4,25 @@ import 'package:intl/intl.dart';
/// A section displaying the date and the shift's start/end times.
class ShiftDateTimeSection extends StatelessWidget {
/// The ISO string of the date.
final String date;
/// Creates a [ShiftDateTimeSection].
const ShiftDateTimeSection({
super.key,
required this.date,
required this.startTime,
required this.endTime,
required this.shiftDateLabel,
required this.clockInLabel,
required this.clockOutLabel,
});
/// The end date string (ISO).
final String? endDate;
/// The shift date.
final DateTime date;
/// The start time string (HH:mm).
final String startTime;
/// Scheduled start time.
final DateTime startTime;
/// The end time string (HH:mm).
final String endTime;
/// Scheduled end time.
final DateTime endTime;
/// Localization string for shift date.
final String shiftDateLabel;
@@ -25,40 +33,9 @@ class ShiftDateTimeSection extends StatelessWidget {
/// Localization string for clock out time.
final String clockOutLabel;
/// Creates a [ShiftDateTimeSection].
const ShiftDateTimeSection({
super.key,
required this.date,
required this.endDate,
required this.startTime,
required this.endTime,
required this.shiftDateLabel,
required this.clockInLabel,
required this.clockOutLabel,
});
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
String _formatTime(String time) {
if (time.isEmpty) return '';
try {
final parts = time.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
final dt = DateTime(2022, 1, 1, hour, minute);
return DateFormat('h:mm a').format(dt);
} catch (e) {
return time;
}
}
String _formatDate(String dateStr) {
if (dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
return DateFormat('EEEE, MMMM d, y').format(date);
} catch (e) {
return dateStr;
}
}
String _formatDate(DateTime dt) => DateFormat('EEEE, MMMM d, y').format(dt);
@override
Widget build(BuildContext context) {
@@ -66,17 +43,17 @@ class ShiftDateTimeSection extends StatelessWidget {
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
Text(
shiftDateLabel,
style: UiTypography.titleUppercase4b.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Row(
children: [
children: <Widget>[
const Icon(
UiIcons.calendar,
size: 20,
@@ -91,36 +68,9 @@ class ShiftDateTimeSection extends StatelessWidget {
),
],
),
if (endDate != null) ...[
const SizedBox(height: UiConstants.space6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SHIFT END DATE',
style: UiTypography.titleUppercase4b.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Row(
children: [
const Icon(
UiIcons.calendar,
size: 20,
color: UiColors.primary,
),
const SizedBox(width: UiConstants.space2),
Text(
_formatDate(endDate!),
style: UiTypography.headline5m.textPrimary,
),
],
),
],
),
],
const SizedBox(height: UiConstants.space6),
Row(
children: [
children: <Widget>[
Expanded(child: _buildTimeBox(clockInLabel, startTime)),
const SizedBox(width: UiConstants.space4),
Expanded(child: _buildTimeBox(clockOutLabel, endTime)),
@@ -131,7 +81,7 @@ class ShiftDateTimeSection extends StatelessWidget {
);
}
Widget _buildTimeBox(String label, String time) {
Widget _buildTimeBox(String label, DateTime time) {
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
@@ -139,7 +89,7 @@ class ShiftDateTimeSection extends StatelessWidget {
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Column(
children: [
children: <Widget>[
Text(
label,
style: UiTypography.footnote2b.copyWith(

View File

@@ -7,8 +7,17 @@ import 'package:krow_domain/krow_domain.dart';
/// A bottom action bar containing contextual buttons based on shift status.
class ShiftDetailsBottomBar extends StatelessWidget {
/// The current shift.
final Shift shift;
/// Creates a [ShiftDetailsBottomBar].
const ShiftDetailsBottomBar({
super.key,
required this.detail,
required this.onApply,
required this.onDecline,
required this.onAccept,
});
/// The shift detail entity.
final ShiftDetail detail;
/// Callback for applying/booking a shift.
final VoidCallback onApply;
@@ -19,19 +28,9 @@ class ShiftDetailsBottomBar extends StatelessWidget {
/// Callback for accepting a shift.
final VoidCallback onAccept;
/// Creates a [ShiftDetailsBottomBar].
const ShiftDetailsBottomBar({
super.key,
required this.shift,
required this.onApply,
required this.onDecline,
required this.onAccept,
});
@override
Widget build(BuildContext context) {
final String status = shift.status ?? 'open';
final i18n = Translations.of(context).staff_shifts.shift_details;
final dynamic i18n = Translations.of(context).staff_shifts.shift_details;
return Container(
padding: EdgeInsets.fromLTRB(
@@ -40,16 +39,17 @@ class ShiftDetailsBottomBar extends StatelessWidget {
UiConstants.space5,
MediaQuery.of(context).padding.bottom + UiConstants.space4,
),
decoration: BoxDecoration(
decoration: const BoxDecoration(
color: UiColors.white,
border: Border(top: BorderSide(color: UiColors.border)),
),
child: _buildButtons(status, i18n, context),
child: _buildButtons(i18n, context),
);
}
Widget _buildButtons(String status, dynamic i18n, BuildContext context) {
if (status == 'confirmed') {
Widget _buildButtons(dynamic i18n, BuildContext context) {
// If worker has an accepted assignment, show clock-in
if (detail.assignmentStatus == AssignmentStatus.accepted) {
return UiButton.primary(
onPressed: () => Modular.to.toClockIn(),
fullWidth: true,
@@ -57,9 +57,10 @@ class ShiftDetailsBottomBar extends StatelessWidget {
);
}
if (status == 'pending') {
// If worker has a pending (assigned) assignment, show accept/decline
if (detail.assignmentStatus == AssignmentStatus.assigned) {
return Row(
children: [
children: <Widget>[
Expanded(
child: UiButton.secondary(
onPressed: onDecline,
@@ -70,14 +71,17 @@ class ShiftDetailsBottomBar extends StatelessWidget {
Expanded(
child: UiButton.primary(
onPressed: onAccept,
child: Text(i18n.accept_shift, style: UiTypography.body2b.white),
child:
Text(i18n.accept_shift, style: UiTypography.body2b.white),
),
),
],
);
}
if (status == 'open' || status == 'available') {
// If worker has no assignment and no pending application, show apply
if (detail.assignmentStatus == null &&
detail.applicationStatus == null) {
return UiButton.primary(
onPressed: onApply,
fullWidth: true,

View File

@@ -2,13 +2,16 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// A header widget for the shift details page displaying the role, client name, and address.
class ShiftDetailsHeader extends StatelessWidget {
/// The shift entity containing the header information.
final Shift shift;
/// Size of the role icon container in the shift details header.
const double _kIconContainerSize = 68.0;
/// A header widget for the shift details page displaying the role and address.
class ShiftDetailsHeader extends StatelessWidget {
/// Creates a [ShiftDetailsHeader].
const ShiftDetailsHeader({super.key, required this.shift});
const ShiftDetailsHeader({super.key, required this.detail});
/// The shift detail entity.
final ShiftDetail detail;
@override
Widget build(BuildContext context) {
@@ -17,15 +20,14 @@ class ShiftDetailsHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space4,
children: [
// Icon + role name + client name
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: UiConstants.space4,
children: [
children: <Widget>[
Container(
width: 68,
height: 68,
width: _kIconContainerSize,
height: _kIconContainerSize,
decoration: BoxDecoration(
color: UiColors.primary.withAlpha(20),
borderRadius: UiConstants.radiusLg,
@@ -42,19 +44,17 @@ class ShiftDetailsHeader extends StatelessWidget {
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(shift.title, style: UiTypography.headline1b.textPrimary),
Text(shift.clientName, style: UiTypography.body1m.textSecondary),
children: <Widget>[
Text(detail.title, style: UiTypography.headline1b.textPrimary),
Text(detail.roleName, style: UiTypography.body1m.textSecondary),
],
),
),
],
),
// Location address
Row(
spacing: UiConstants.space1,
children: [
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 16,
@@ -62,7 +62,7 @@ class ShiftDetailsHeader extends StatelessWidget {
),
Expanded(
child: Text(
shift.locationAddress,
detail.address ?? detail.location,
style: UiTypography.body2r.textSecondary,
),
),

View File

@@ -1,121 +0,0 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
/// A widget that displays the shift location on an interactive Google Map.
class ShiftLocationMap extends StatefulWidget {
/// The shift entity containing location and coordinates.
final Shift shift;
/// The height of the map widget.
final double height;
/// The border radius for the map container.
final double borderRadius;
/// Creates a [ShiftLocationMap].
const ShiftLocationMap({
super.key,
required this.shift,
this.height = 120,
this.borderRadius = 8,
});
@override
State<ShiftLocationMap> createState() => _ShiftLocationMapState();
}
class _ShiftLocationMapState extends State<ShiftLocationMap> {
late final CameraPosition _initialPosition;
final Set<Marker> _markers = {};
@override
void initState() {
super.initState();
// Default to a fallback coordinate if latitude/longitude are null.
// In a real app, you might want to geocode the address if coordinates are missing.
final double lat = widget.shift.latitude ?? 0.0;
final double lng = widget.shift.longitude ?? 0.0;
final LatLng position = LatLng(lat, lng);
_initialPosition = CameraPosition(
target: position,
zoom: 15,
);
_markers.add(
Marker(
markerId: MarkerId(widget.shift.id),
position: position,
infoWindow: InfoWindow(
title: widget.shift.location,
snippet: widget.shift.locationAddress,
),
),
);
}
@override
Widget build(BuildContext context) {
// If coordinates are missing, we show a placeholder.
if (widget.shift.latitude == null || widget.shift.longitude == null) {
return _buildPlaceholder(context, "Coordinates unavailable");
}
return Container(
height: widget.height * 1.25, // Slightly taller to accommodate map controls
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.borderRadius),
border: Border.all(color: UiColors.border),
),
clipBehavior: Clip.antiAlias,
child: GoogleMap(
initialCameraPosition: _initialPosition,
markers: _markers,
liteModeEnabled: true, // Optimized for static-like display in details page
scrollGesturesEnabled: false,
zoomGesturesEnabled: true,
tiltGesturesEnabled: false,
rotateGesturesEnabled: false,
myLocationButtonEnabled: false,
mapToolbarEnabled: false,
compassEnabled: false,
),
);
}
Widget _buildPlaceholder(BuildContext context, String message) {
return Container(
height: widget.height,
width: double.infinity,
decoration: BoxDecoration(
color: UiColors.bgThird,
borderRadius: BorderRadius.circular(widget.borderRadius),
border: Border.all(color: UiColors.border),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
UiIcons.mapPin,
size: 32,
color: UiColors.primary,
),
if (message.isNotEmpty) ...[
const SizedBox(height: UiConstants.space2),
Text(
message,
style: UiTypography.footnote2r.textSecondary,
),
],
],
),
),
);
}
}

View File

@@ -1,14 +1,25 @@
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:url_launcher/url_launcher.dart';
import 'shift_location_map.dart';
/// A section displaying the shift's location, address, map, and "Get direction" action.
/// A section displaying the shift's location, address, and "Get direction" action.
class ShiftLocationSection extends StatelessWidget {
/// The shift entity containing location data.
final Shift shift;
/// Creates a [ShiftLocationSection].
const ShiftLocationSection({
super.key,
required this.location,
required this.address,
required this.locationLabel,
required this.tbdLabel,
required this.getDirectionLabel,
});
/// Human-readable location label.
final String location;
/// Street address.
final String address;
/// Localization string for location section title.
final String locationLabel;
@@ -19,15 +30,6 @@ class ShiftLocationSection extends StatelessWidget {
/// Localization string for "Get direction".
final String getDirectionLabel;
/// Creates a [ShiftLocationSection].
const ShiftLocationSection({
super.key,
required this.shift,
required this.locationLabel,
required this.tbdLabel,
required this.getDirectionLabel,
});
@override
Widget build(BuildContext context) {
return Padding(
@@ -36,33 +38,32 @@ class ShiftLocationSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4,
children: [
children: <Widget>[
Column(
spacing: UiConstants.space2,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
children: <Widget>[
Text(
locationLabel,
style: UiTypography.titleUppercase4b.textSecondary,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: UiConstants.space4,
children: [
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
Text(
shift.location.isEmpty ? tbdLabel : shift.location,
location.isEmpty ? tbdLabel : location,
style: UiTypography.title1m.textPrimary,
overflow: TextOverflow.ellipsis,
),
if (shift.locationAddress.isNotEmpty)
if (address.isNotEmpty)
Text(
shift.locationAddress,
address,
style: UiTypography.body2r.textSecondary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -96,28 +97,19 @@ class ShiftLocationSection extends StatelessWidget {
),
],
),
ShiftLocationMap(
shift: shift,
borderRadius: UiConstants.radiusBase,
),
],
),
);
}
Future<void> _openDirections(BuildContext context) async {
final destination = (shift.latitude != null && shift.longitude != null)
? '${shift.latitude},${shift.longitude}'
: Uri.encodeComponent(
shift.locationAddress.isNotEmpty
? shift.locationAddress
: shift.location,
);
final String destination = Uri.encodeComponent(
address.isNotEmpty ? address : location,
);
final String url =
'https://www.google.com/maps/dir/?api=1&destination=$destination';
final uri = Uri.parse(url);
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);

View File

@@ -1,162 +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';
/// A section displaying the shift type, date range, and weekday schedule summary.
class ShiftScheduleSummarySection extends StatelessWidget {
/// The shift entity.
final Shift shift;
/// Creates a [ShiftScheduleSummarySection].
const ShiftScheduleSummarySection({super.key, required this.shift});
String _getShiftTypeLabel(Translations t) {
final String type = (shift.orderType ?? '').toUpperCase();
if (type == 'PERMANENT') {
return t.staff_shifts.filter.long_term;
}
if (type == 'RECURRING') {
return t.staff_shifts.filter.multi_day;
}
return t.staff_shifts.filter.one_day;
}
bool _isMultiDayOrLongTerm() {
final String type = (shift.orderType ?? '').toUpperCase();
return type == 'RECURRING' || type == 'PERMANENT';
}
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
final isMultiDay = _isMultiDayOrLongTerm();
final typeLabel = _getShiftTypeLabel(t);
final String orderType = (shift.orderType ?? '').toUpperCase();
return Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Shift Type Title
UiChip(label: typeLabel, variant: UiChipVariant.secondary),
const SizedBox(height: UiConstants.space2),
if (isMultiDay) ...[
// Date Range
if (shift.startDate != null && shift.endDate != null)
Row(
children: [
const Icon(
UiIcons.calendar,
size: 16,
color: UiColors.textSecondary,
),
const SizedBox(width: UiConstants.space2),
Text(
'${_formatDate(shift.startDate!)} ${_formatDate(shift.endDate!)}',
style: UiTypography.body2m.textPrimary,
),
],
),
const SizedBox(height: UiConstants.space4),
// Weekday Circles
_buildWeekdaySchedule(context),
// Available Shifts Count (Only for RECURRING/Multi-Day)
if (orderType == 'RECURRING' && shift.schedules != null) ...[
const SizedBox(height: UiConstants.space4),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: UiColors.success,
shape: BoxShape.circle,
),
),
const SizedBox(width: UiConstants.space2),
Text(
'${shift.schedules!.length} available shifts',
style: UiTypography.body2b.copyWith(
color: UiColors.textSuccess,
),
),
],
),
],
],
],
),
);
}
String _formatDate(String dateStr) {
try {
final date = DateTime.parse(dateStr);
return DateFormat('MMM d, y').format(date);
} catch (_) {
return dateStr;
}
}
Widget _buildWeekdaySchedule(BuildContext context) {
final List<String> weekDays = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
final Set<int> activeDays = _getActiveWeekdayIndices();
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(weekDays.length, (index) {
final bool isActive = activeDays.contains(index); // 1-7 (Mon-Sun)
return Container(
width: 38,
height: 38,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive ? UiColors.primaryInverse : UiColors.bgThird,
border: Border.all(
color: isActive ? UiColors.primary : UiColors.border,
),
),
child: Center(
child: Text(
weekDays[index],
style: UiTypography.body2b.copyWith(
color: isActive ? UiColors.primary : UiColors.textSecondary,
),
),
),
);
}),
);
}
Set<int> _getActiveWeekdayIndices() {
final List<String> days = shift.recurringDays ?? shift.permanentDays ?? [];
return days.map((day) {
switch (day.toUpperCase()) {
case 'MON':
return DateTime.monday;
case 'TUE':
return DateTime.tuesday;
case 'WED':
return DateTime.wednesday;
case 'THU':
return DateTime.thursday;
case 'FRI':
return DateTime.friday;
case 'SAT':
return DateTime.saturday;
case 'SUN':
return DateTime.sunday;
default:
return -1;
}
}).toSet();
}
}

View File

@@ -1,29 +1,27 @@
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: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/shared/empty_state_view.dart';
import '../../blocs/shifts/shifts_bloc.dart';
import '../my_shift_card.dart';
import '../shared/empty_state_view.dart';
/// Tab showing open shifts available for the worker to browse and apply.
class FindShiftsTab extends StatefulWidget {
final List<Shift> availableJobs;
/// Whether the worker's profile is complete. When false, shows incomplete
/// profile banner and disables apply actions.
final bool profileComplete;
/// Creates a [FindShiftsTab].
const FindShiftsTab({
super.key,
required this.availableJobs,
this.profileComplete = true,
});
/// Open shifts loaded from the V2 API.
final List<OpenShift> availableJobs;
/// Whether the worker's profile is complete.
final bool profileComplete;
@override
State<FindShiftsTab> createState() => _FindShiftsTabState();
}
@@ -35,11 +33,8 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
Position? _currentPosition;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_initLocation();
}
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
@override
void dispose() {
@@ -142,126 +137,20 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
);
}
bool _isRecurring(Shift shift) =>
(shift.orderType ?? '').toUpperCase() == 'RECURRING';
bool _isPermanent(Shift shift) =>
(shift.orderType ?? '').toUpperCase() == 'PERMANENT';
DateTime? _parseShiftDate(String date) {
if (date.isEmpty) return null;
try {
return DateTime.parse(date);
} catch (_) {
return null;
}
String _formatDate(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 'Today';
if (d == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
}
List<Shift> _groupMultiDayShifts(List<Shift> shifts) {
final Map<String, List<Shift>> grouped = <String, List<Shift>>{};
for (final shift in shifts) {
if (!_isRecurring(shift) && !_isPermanent(shift)) {
continue;
}
final orderId = shift.orderId;
final roleId = shift.roleId;
if (orderId == null || roleId == null) {
continue;
}
final key = '$orderId::$roleId';
grouped.putIfAbsent(key, () => <Shift>[]).add(shift);
}
final Set<String> addedGroups = <String>{};
final List<Shift> result = <Shift>[];
for (final shift in shifts) {
if (!_isRecurring(shift) && !_isPermanent(shift)) {
result.add(shift);
continue;
}
final orderId = shift.orderId;
final roleId = shift.roleId;
if (orderId == null || roleId == null) {
result.add(shift);
continue;
}
final key = '$orderId::$roleId';
if (addedGroups.contains(key)) {
continue;
}
addedGroups.add(key);
final List<Shift> group = grouped[key] ?? <Shift>[];
if (group.isEmpty) {
result.add(shift);
continue;
}
group.sort((a, b) {
final ad = _parseShiftDate(a.date);
final bd = _parseShiftDate(b.date);
if (ad == null && bd == null) return 0;
if (ad == null) return 1;
if (bd == null) return -1;
return ad.compareTo(bd);
});
final Shift first = group.first;
final List<ShiftSchedule> schedules = group
.map(
(s) => ShiftSchedule(
date: s.date,
startTime: s.startTime,
endTime: s.endTime,
),
)
.toList();
result.add(
Shift(
id: first.id,
roleId: first.roleId,
title: first.title,
clientName: first.clientName,
logoUrl: first.logoUrl,
hourlyRate: first.hourlyRate,
location: first.location,
locationAddress: first.locationAddress,
date: first.date,
endDate: first.endDate,
startTime: first.startTime,
endTime: first.endTime,
createdDate: first.createdDate,
tipsAvailable: first.tipsAvailable,
travelTime: first.travelTime,
mealProvided: first.mealProvided,
parkingAvailable: first.parkingAvailable,
gasCompensation: first.gasCompensation,
description: first.description,
instructions: first.instructions,
managers: first.managers,
latitude: first.latitude,
longitude: first.longitude,
status: first.status,
durationDays: schedules.length,
requiredSlots: first.requiredSlots,
filledSlots: first.filledSlots,
hasApplied: first.hasApplied,
totalValue: first.totalValue,
breakInfo: first.breakInfo,
orderId: first.orderId,
orderType: first.orderType,
schedules: schedules,
recurringDays: first.recurringDays,
permanentDays: first.permanentDays,
),
);
}
return result;
}
Widget _buildFilterTab(String id, String label) {
final isSelected = _jobType == id;
final bool isSelected = _jobType == id;
return GestureDetector(
onTap: () => setState(() => _jobType = id),
child: Container(
@@ -287,44 +176,198 @@ 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;
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) {
final groupedJobs = _groupMultiDayShifts(widget.availableJobs);
// Filter logic
final filteredJobs = groupedJobs.where((s) {
final matchesSearch =
s.title.toLowerCase().contains(_searchQuery.toLowerCase()) ||
s.location.toLowerCase().contains(_searchQuery.toLowerCase()) ||
(s.description ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) ||
s.clientName.toLowerCase().contains(_searchQuery.toLowerCase());
// Client-side filter by order type
final List<OpenShift> filteredJobs =
_filterByType(widget.availableJobs).where((OpenShift s) {
final String q = _searchQuery.toLowerCase();
final bool matchesSearch = _searchQuery.isEmpty ||
s.roleName.toLowerCase().contains(q) ||
s.location.toLowerCase().contains(q);
if (!matchesSearch) return false;
if (_maxDistance != null && s.latitude != null && s.longitude != null) {
final double dist = _calculateDistance(s.latitude!, s.longitude!);
if (dist > _maxDistance!) return false;
// Note: Distance filter is currently disabled as OpenShift 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!);
// if (dist > _maxDistance!) return false;
}
*/
if (_jobType == 'all') return true;
if (_jobType == 'one-day') {
if (_isRecurring(s) || _isPermanent(s)) return false;
return s.durationDays == null || s.durationDays! <= 1;
}
if (_jobType == 'multi-day') {
return _isRecurring(s) ||
(s.durationDays != null && s.durationDays! > 1);
}
if (_jobType == 'long-term') {
return _isPermanent(s);
}
return true;
}).toList();
return Column(
children: [
children: <Widget>[
// Incomplete profile banner
if (!widget.profileComplete) ...[
if (!widget.profileComplete)
GestureDetector(
onTap: () => Modular.to.toProfile(),
child: Container(
@@ -332,19 +375,12 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
child: UiNoticeBanner(
icon: UiIcons.sparkles,
title: context
.t
.staff_shifts
.find_shifts
.incomplete_profile_banner_title,
description: context
.t
.staff_shifts
.find_shifts
.t.staff_shifts.find_shifts.incomplete_profile_banner_title,
description: context.t.staff_shifts.find_shifts
.incomplete_profile_banner_message,
),
),
),
],
// Search and Filters
Container(
color: UiColors.white,
@@ -353,155 +389,81 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
vertical: UiConstants.space4,
),
child: Column(
children: [
// Search Bar
Row(
children: [
Expanded(
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.background,
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
children: <Widget>[
Container(
height: 48,
padding:
const EdgeInsets.symmetric(horizontal: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.background,
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
const Icon(UiIcons.search,
size: 20, color: UiColors.textInactive),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Semantics(
identifier: 'find_shifts_search_input',
child: TextField(
controller: _searchController,
onChanged: (String v) =>
setState(() => _searchQuery = v),
decoration: InputDecoration(
border: InputBorder.none,
hintText:
context.t.staff_shifts.find_shifts.search_hint,
hintStyle: UiTypography.body2r.textPlaceholder,
),
),
border: Border.all(color: UiColors.border),
),
child: Row(
children: [
const Icon(
UiIcons.search,
size: 20,
color: UiColors.textInactive,
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Semantics(
identifier: 'find_shifts_search_input',
child: TextField(
controller: _searchController,
onChanged: (v) =>
setState(() => _searchQuery = v),
decoration: InputDecoration(
border: InputBorder.none,
hintText: context
.t
.staff_shifts
.find_shifts
.search_hint,
hintStyle: UiTypography.body2r.textPlaceholder,
),
),
),
),
],
),
),
),
const SizedBox(width: UiConstants.space2),
GestureDetector(
onTap: _showDistanceFilter,
child: Container(
height: 48,
width: 48,
decoration: BoxDecoration(
color: _maxDistance != null
? UiColors.primary.withValues(alpha: 0.1)
: UiColors.white,
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
border: Border.all(
color: _maxDistance != null
? UiColors.primary
: UiColors.border,
),
),
child: Icon(
UiIcons.filter,
size: 18,
color: _maxDistance != null
? UiColors.primary
: UiColors.textSecondary,
),
),
),
],
],
),
),
const SizedBox(height: UiConstants.space4),
// Filter Tabs
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
children: <Widget>[
_buildFilterTab(
'all',
context.t.staff_shifts.find_shifts.filter_all,
),
'all', context.t.staff_shifts.find_shifts.filter_all),
const SizedBox(width: UiConstants.space2),
_buildFilterTab(
'one-day',
context.t.staff_shifts.find_shifts.filter_one_day,
),
_buildFilterTab('one-day',
context.t.staff_shifts.find_shifts.filter_one_day),
const SizedBox(width: UiConstants.space2),
_buildFilterTab(
'multi-day',
context.t.staff_shifts.find_shifts.filter_multi_day,
),
_buildFilterTab('multi-day',
context.t.staff_shifts.find_shifts.filter_multi_day),
const SizedBox(width: UiConstants.space2),
_buildFilterTab(
'long-term',
context.t.staff_shifts.find_shifts.filter_long_term,
),
_buildFilterTab('long-term',
context.t.staff_shifts.find_shifts.filter_long_term),
],
),
),
],
),
),
Expanded(
child: filteredJobs.isEmpty
? EmptyStateView(
icon: UiIcons.search,
title: context.t.staff_shifts.find_shifts.no_jobs_title,
subtitle: context.t.staff_shifts.find_shifts.no_jobs_subtitle,
subtitle:
context.t.staff_shifts.find_shifts.no_jobs_subtitle,
)
: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
horizontal: UiConstants.space5),
child: Column(
children: [
children: <Widget>[
const SizedBox(height: UiConstants.space5),
...filteredJobs.map(
(shift) => Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: MyShiftCard(
shift: shift,
onAccept: widget.profileComplete
? () {
BlocProvider.of<ShiftsBloc>(
context,
).add(AcceptShiftEvent(shift.id));
UiSnackbar.show(
context,
message: context
.t
.staff_shifts
.find_shifts
.application_submitted,
type: UiSnackbarType.success,
);
}
: null,
),
),
(OpenShift shift) =>
_buildOpenShiftCard(context, shift),
),
const SizedBox(height: UiConstants.space32),
],

View File

@@ -1,37 +1,43 @@
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../my_shift_card.dart';
import '../shared/empty_state_view.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
/// Tab displaying completed shift history.
class HistoryShiftsTab extends StatelessWidget {
final List<Shift> historyShifts;
/// Creates a [HistoryShiftsTab].
const HistoryShiftsTab({super.key, required this.historyShifts});
/// Completed shifts.
final List<CompletedShift> historyShifts;
@override
Widget build(BuildContext context) {
if (historyShifts.isEmpty) {
return const EmptyStateView(
return EmptyStateView(
icon: UiIcons.clock,
title: "No shift history",
subtitle: "Completed shifts appear here",
title: context.t.staff_shifts.list.no_shifts,
subtitle: context.t.staff_shifts.history_tab.subtitle,
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: Column(
children: [
children: <Widget>[
const SizedBox(height: UiConstants.space5),
...historyShifts.map(
(shift) => Padding(
(CompletedShift shift) => Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: GestureDetector(
onTap: () => Modular.to.toShiftDetails(shift),
child: MyShiftCard(shift: shift),
child: ShiftCard(
data: ShiftCardData.fromCompleted(shift),
onTap: () =>
Modular.to.toShiftDetailsById(shift.shiftId),
),
),
),

View File

@@ -1,20 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:design_system/design_system.dart';
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 '../../blocs/shifts/shifts_bloc.dart';
import '../my_shift_card.dart';
import '../shift_assignment_card.dart';
import '../shared/empty_state_view.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';
/// Tab displaying the worker's assigned, pending, and cancelled shifts.
class MyShiftsTab extends StatefulWidget {
final List<Shift> myShifts;
final List<Shift> pendingAssignments;
final List<Shift> cancelledShifts;
final DateTime? initialDate;
/// Creates a [MyShiftsTab].
const MyShiftsTab({
super.key,
required this.myShifts,
@@ -23,6 +22,18 @@ class MyShiftsTab extends StatefulWidget {
this.initialDate,
});
/// Assigned shifts for the current week.
final List<AssignedShift> myShifts;
/// Pending assignments awaiting acceptance.
final List<PendingAssignment> pendingAssignments;
/// Cancelled shift assignments.
final List<CancelledShift> cancelledShifts;
/// Initial date to select in the calendar.
final DateTime? initialDate;
@override
State<MyShiftsTab> createState() => _MyShiftsTabState();
}
@@ -31,6 +42,9 @@ 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();
@@ -54,19 +68,19 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
void _applyInitialDate(DateTime date) {
_selectedDate = date;
final now = DateTime.now();
int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
int daysSinceFriday = (reactDayIndex + 2) % 7;
final DateTime now = DateTime.now();
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
final int daysSinceFriday = (reactDayIndex + 2) % 7;
// Base Friday
final baseStart = DateTime(
final DateTime baseStart = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: daysSinceFriday));
final target = DateTime(date.year, date.month, date.day);
final diff = target.difference(baseStart).inDays;
final DateTime target = DateTime(date.year, date.month, date.day);
final int diff = target.difference(baseStart).inDays;
setState(() {
_weekOffset = (diff / 7).floor();
@@ -77,19 +91,23 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
}
List<DateTime> _getCalendarDays() {
final now = DateTime.now();
int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
int daysSinceFriday = (reactDayIndex + 2) % 7;
final start = now
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 startDate = DateTime(start.year, start.month, start.day);
return List.generate(7, (index) => startDate.add(Duration(days: index)));
final DateTime startDate =
DateTime(start.year, start.month, start.day);
return List<DateTime>.generate(
7,
(int index) => startDate.add(Duration(days: index)),
);
}
void _loadShiftsForCurrentWeek() {
final List<DateTime> calendarDays = _getCalendarDays();
context.read<ShiftsBloc>().add(
ReadContext(context).read<ShiftsBloc>().add(
LoadShiftsForRangeEvent(
start: calendarDays.first,
end: calendarDays.last,
@@ -104,10 +122,12 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
void _confirmShift(String id) {
showDialog(
context: context,
builder: (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: [
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),
@@ -115,17 +135,19 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
context.read<ShiftsBloc>().add(AcceptShiftEvent(id));
ReadContext(context).read<ShiftsBloc>().add(AcceptShiftEvent(id));
UiSnackbar.show(
context,
message: context.t.staff_shifts.my_shifts_tab.confirm_dialog.success,
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),
child:
Text(context.t.staff_shifts.shift_details.accept_shift),
),
],
),
@@ -135,12 +157,13 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
void _declineShift(String id) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title),
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: [
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(context.t.common.cancel),
@@ -148,10 +171,11 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
context.read<ShiftsBloc>().add(DeclineShiftEvent(id));
ReadContext(context).read<ShiftsBloc>().add(DeclineShiftEvent(id));
UiSnackbar.show(
context,
message: context.t.staff_shifts.my_shifts_tab.decline_dialog.success,
message: context
.t.staff_shifts.my_shifts_tab.decline_dialog.success,
type: UiSnackbarType.error,
);
},
@@ -165,48 +189,27 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
);
}
String _formatDateStr(String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
if (_isSameDay(date, now)) return context.t.staff_shifts.my_shifts_tab.date.today;
final tomorrow = now.add(const Duration(days: 1));
if (_isSameDay(date, tomorrow)) return context.t.staff_shifts.my_shifts_tab.date.tomorrow;
return DateFormat('EEE, MMM d').format(date);
} catch (_) {
return dateStr;
}
}
@override
Widget build(BuildContext context) {
final calendarDays = _getCalendarDays();
final weekStartDate = calendarDays.first;
final weekEndDate = calendarDays.last;
final List<DateTime> calendarDays = _getCalendarDays();
final DateTime weekStartDate = calendarDays.first;
final DateTime weekEndDate = calendarDays.last;
final visibleMyShifts = widget.myShifts.where((s) {
try {
final date = DateTime.parse(s.date);
return _isSameDay(date, _selectedDate);
} catch (_) {
return false;
}
}).toList();
final List<AssignedShift> visibleMyShifts = widget.myShifts
.where(
(AssignedShift s) => _isSameDay(s.date, _selectedDate),
)
.toList();
final visibleCancelledShifts = widget.cancelledShifts.where((s) {
try {
final date = DateTime.parse(s.date);
return date.isAfter(
weekStartDate.subtract(const Duration(seconds: 1)),
) &&
date.isBefore(weekEndDate.add(const Duration(days: 1)));
} catch (_) {
return false;
}
final List<CancelledShift> visibleCancelledShifts =
widget.cancelledShifts.where((CancelledShift s) {
return s.date.isAfter(
weekStartDate.subtract(const Duration(seconds: 1))) &&
s.date.isBefore(weekEndDate.add(const Duration(days: 1)));
}).toList();
return Column(
children: [
children: <Widget>[
// Calendar Selector
Container(
color: UiColors.white,
@@ -215,12 +218,12 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
horizontal: UiConstants.space4,
),
child: Column(
children: [
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
IconButton(
icon: const Icon(
UiIcons.chevronLeft,
@@ -259,22 +262,16 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
// Days Grid
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: calendarDays.map((date) {
final isSelected = _isSameDay(date, _selectedDate);
// ignore: unused_local_variable
final dateStr = DateFormat('yyyy-MM-dd').format(date);
final hasShifts = widget.myShifts.any((s) {
try {
return _isSameDay(DateTime.parse(s.date), date);
} catch (_) {
return false;
}
});
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: [
children: <Widget>[
Container(
width: 44,
height: 60,
@@ -282,7 +279,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
color: isSelected
? UiColors.primary
: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
border: Border.all(
color: isSelected
? UiColors.primary
@@ -292,7 +291,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
children: <Widget>[
Text(
date.day.toString().padLeft(2, '0'),
style: isSelected
@@ -302,14 +301,21 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
Text(
DateFormat('E').format(date),
style: (isSelected
? UiTypography.footnote2m.white
: UiTypography.footnote2m.textSecondary).copyWith(
color: isSelected ? UiColors.white.withValues(alpha: 0.8) : null,
? 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),
margin: const EdgeInsets.only(
top: UiConstants.space1,
),
width: 4,
height: 4,
decoration: const BoxDecoration(
@@ -332,44 +338,52 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
children: [
children: <Widget>[
const SizedBox(height: UiConstants.space5),
if (widget.pendingAssignments.isNotEmpty) ...[
if (widget.pendingAssignments.isNotEmpty) ...<Widget>[
_buildSectionHeader(
context.t.staff_shifts.my_shifts_tab.sections.awaiting,
context
.t.staff_shifts.my_shifts_tab.sections.awaiting,
UiColors.textWarning,
),
...widget.pendingAssignments.map(
(shift) => Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: ShiftAssignmentCard(
shift: shift,
onConfirm: () => _confirmShift(shift.id),
onDecline: () => _declineShift(shift.id),
isConfirming: true,
(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) ...[
_buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary),
if (visibleCancelledShifts.isNotEmpty) ...<Widget>[
_buildSectionHeader(
context
.t.staff_shifts.my_shifts_tab.sections.cancelled,
UiColors.textSecondary,
),
...visibleCancelledShifts.map(
(shift) => Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: _buildCancelledCard(
title: shift.title,
client: shift.clientName,
pay: "\$${(shift.hourlyRate * 8).toStringAsFixed(0)}",
rate: "\$${shift.hourlyRate}/hr · 8h",
date: _formatDateStr(shift.date),
time: "${shift.startTime} - ${shift.endTime}",
address: shift.locationAddress,
isLastMinute: true,
onTap: () {},
(CancelledShift cs) => Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space4,
),
child: ShiftCard(
data: ShiftCardData.fromCancelled(cs),
onTap: () =>
Modular.to.toShiftDetailsById(cs.shiftId),
),
),
),
@@ -377,23 +391,43 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
],
// Confirmed Shifts
if (visibleMyShifts.isNotEmpty) ...[
_buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary),
if (visibleMyShifts.isNotEmpty) ...<Widget>[
_buildSectionHeader(
context
.t.staff_shifts.my_shifts_tab.sections.confirmed,
UiColors.textSecondary,
),
...visibleMyShifts.map(
(shift) => Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: MyShiftCard(
shift: shift,
onDecline: () => _declineShift(shift.id),
onRequestSwap: () {
UiSnackbar.show(
context,
message: context.t.staff_shifts.my_shifts_tab.swap_coming_soon,
type: UiSnackbarType.message,
);
},
),
),
(AssignedShift shift) {
final bool isCompleted =
shift.status == AssignmentStatus.completed;
final bool isSubmitted =
_submittedShiftIds.contains(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,
onSubmitForApproval: () {
setState(() {
_submittedShiftIds.add(shift.shiftId);
});
UiSnackbar.show(
context,
message: context.t.staff_shifts
.my_shift_card.timesheet_submitted,
type: UiSnackbarType.success,
);
},
),
);
},
),
],
@@ -402,8 +436,10 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
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,
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),
@@ -419,11 +455,14 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: Row(
children: [
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
decoration: BoxDecoration(
color: dotColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: UiConstants.space2),
Text(
@@ -436,163 +475,4 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
),
);
}
Widget _buildCancelledCard({
required String title,
required String client,
required String pay,
required String rate,
required String date,
required String time,
required String address,
required bool isLastMinute,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase + 4),
border: Border.all(color: UiColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: UiColors.destructive,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
context.t.staff_shifts.my_shifts_tab.card.cancelled,
style: UiTypography.footnote2b.textError,
),
if (isLastMinute) ...[
const SizedBox(width: 4),
Text(
context.t.staff_shifts.my_shifts_tab.card.compensation,
style: UiTypography.footnote2m.textSuccess,
),
],
],
),
const SizedBox(height: UiConstants.space3),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: const Center(
child: Icon(
UiIcons.briefcase,
color: UiColors.primary,
size: 20,
),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: UiTypography.body2b.textPrimary,
),
Text(
client,
style: UiTypography.footnote1r.textSecondary,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
pay,
style: UiTypography.headline4m.textPrimary,
),
Text(
rate,
style: UiTypography.footnote2r.textSecondary,
),
],
),
],
),
const SizedBox(height: UiConstants.space2),
Row(
children: [
const Icon(
UiIcons.calendar,
size: 12,
color: UiColors.textSecondary,
),
const SizedBox(width: 4),
Text(
date,
style: UiTypography.footnote1r.textSecondary,
),
const SizedBox(width: UiConstants.space3),
const Icon(
UiIcons.clock,
size: 12,
color: UiColors.textSecondary,
),
const SizedBox(width: 4),
Text(
time,
style: UiTypography.footnote1r.textSecondary,
),
],
),
const SizedBox(height: 4),
Row(
children: [
const Icon(
UiIcons.mapPin,
size: 12,
color: UiColors.textSecondary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
address,
style: UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
],
),
],
),
),
);
}
}

View File

@@ -1,46 +1,55 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'domain/repositories/shifts_repository_interface.dart';
import 'data/repositories_impl/shifts_repository_impl.dart';
import 'domain/usecases/get_shift_details_usecase.dart';
import 'domain/usecases/accept_shift_usecase.dart';
import 'domain/usecases/decline_shift_usecase.dart';
import 'domain/usecases/apply_for_shift_usecase.dart';
import 'presentation/blocs/shift_details/shift_details_bloc.dart';
import 'presentation/pages/shift_details_page.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/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';
import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart';
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart';
import 'package:staff_shifts/src/presentation/pages/shift_details_page.dart';
/// DI module for the shift details page.
///
/// Registers the detail-specific repository, use cases, and BLoC using
/// the V2 API via [BaseApiService].
class ShiftDetailsModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repository
i.add<ShiftsRepositoryInterface>(ShiftsRepositoryImpl.new);
// StaffConnectorRepository for profile completion
i.addLazySingleton<StaffConnectorRepository>(
() => StaffConnectorRepositoryImpl(),
i.add<ShiftsRepositoryInterface>(
() => ShiftsRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// UseCases
i.add(GetShiftDetailsUseCase.new);
i.add(AcceptShiftUseCase.new);
i.add(DeclineShiftUseCase.new);
// Use cases
i.add(GetShiftDetailUseCase.new);
i.add(ApplyForShiftUseCase.new);
i.addLazySingleton<GetProfileCompletionUseCase>(
() => GetProfileCompletionUseCase(
repository: i.get<StaffConnectorRepository>(),
i.add(DeclineShiftUseCase.new);
i.add(GetProfileCompletionUseCase.new);
// BLoC
i.add(
() => ShiftDetailsBloc(
getShiftDetail: i.get(),
applyForShift: i.get(),
declineShift: i.get(),
getProfileCompletion: i.get(),
),
);
// Bloc
i.add(ShiftDetailsBloc.new);
}
@override
void routes(RouteManager r) {
r.child(
'/:id',
child: (_) =>
ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data),
child: (_) => ShiftDetailsPage(
shiftId: r.args.params['id'] ?? '',
),
);
}
}

View File

@@ -1,62 +1,72 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'domain/repositories/shifts_repository_interface.dart';
import 'data/repositories_impl/shifts_repository_impl.dart';
import 'domain/usecases/get_my_shifts_usecase.dart';
import 'domain/usecases/get_available_shifts_usecase.dart';
import 'domain/usecases/get_pending_assignments_usecase.dart';
import 'domain/usecases/get_cancelled_shifts_usecase.dart';
import 'domain/usecases/get_history_shifts_usecase.dart';
import 'domain/usecases/accept_shift_usecase.dart';
import 'domain/usecases/decline_shift_usecase.dart';
import 'domain/usecases/apply_for_shift_usecase.dart';
import 'domain/usecases/get_shift_details_usecase.dart';
import 'presentation/blocs/shifts/shifts_bloc.dart';
import 'presentation/blocs/shift_details/shift_details_bloc.dart';
import 'presentation/utils/shift_tab_type.dart';
import 'presentation/pages/shifts_page.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/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_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/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';
import 'package:staff_shifts/src/presentation/pages/shifts_page.dart';
/// DI module for the staff shifts feature.
///
/// Registers repository, use cases, and BLoCs using the V2 API
/// via [BaseApiService].
class StaffShiftsModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// StaffConnectorRepository for profile completion
i.addLazySingleton<StaffConnectorRepository>(
() => StaffConnectorRepositoryImpl(),
);
// Profile completion use case
i.addLazySingleton<GetProfileCompletionUseCase>(
() => GetProfileCompletionUseCase(
repository: i.get<StaffConnectorRepository>(),
),
);
// Repository
i.addLazySingleton<ShiftsRepositoryInterface>(ShiftsRepositoryImpl.new);
i.addLazySingleton<ShiftsRepositoryInterface>(
() => ShiftsRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// UseCases
i.addLazySingleton(GetMyShiftsUseCase.new);
i.addLazySingleton(GetAvailableShiftsUseCase.new);
// Use cases
i.addLazySingleton(GetAssignedShiftsUseCase.new);
i.addLazySingleton(GetOpenShiftsUseCase.new);
i.addLazySingleton(GetPendingAssignmentsUseCase.new);
i.addLazySingleton(GetCancelledShiftsUseCase.new);
i.addLazySingleton(GetHistoryShiftsUseCase.new);
i.addLazySingleton(GetCompletedShiftsUseCase.new);
i.addLazySingleton(AcceptShiftUseCase.new);
i.addLazySingleton(DeclineShiftUseCase.new);
i.addLazySingleton(ApplyForShiftUseCase.new);
i.addLazySingleton(GetShiftDetailsUseCase.new);
i.addLazySingleton(GetShiftDetailUseCase.new);
i.addLazySingleton(GetProfileCompletionUseCase.new);
// Bloc
// BLoC
i.add(
() => ShiftsBloc(
getMyShifts: i.get(),
getAvailableShifts: i.get(),
getAssignedShifts: i.get(),
getOpenShifts: i.get(),
getPendingAssignments: i.get(),
getCancelledShifts: i.get(),
getHistoryShifts: i.get(),
getCompletedShifts: i.get(),
getProfileCompletion: i.get(),
acceptShift: i.get(),
declineShift: i.get(),
),
);
i.add(
() => ShiftDetailsBloc(
getShiftDetail: i.get(),
applyForShift: i.get(),
declineShift: i.get(),
getProfileCompletion: i.get(),
),
);
i.add(ShiftDetailsBloc.new);
}
@override
@@ -64,12 +74,14 @@ class StaffShiftsModule extends Module {
r.child(
'/',
child: (_) {
final args = r.args.data as Map?;
final queryParams = r.args.queryParams;
final initialTabStr = queryParams['tab'] ?? args?['initialTab'];
final Map<dynamic, dynamic>? args =
r.args.data as Map<dynamic, dynamic>?;
final Map<String, String> queryParams = r.args.queryParams;
final dynamic initialTabStr =
queryParams['tab'] ?? args?['initialTab'];
return ShiftsPage(
initialTab: ShiftTabType.fromString(initialTabStr),
selectedDate: args?['selectedDate'],
selectedDate: args?['selectedDate'] as DateTime?,
refreshAvailable: args?['refreshAvailable'] == true,
);
},

View File

@@ -12,15 +12,13 @@ dependencies:
flutter:
sdk: flutter
# Internal packages
# Architecture packages
krow_core:
path: ../../../core
design_system:
path: ../../../design_system
krow_domain:
path: ../../../domain
krow_data_connect:
path: ../../../data_connect
core_localization:
path: ../../../core_localization
@@ -28,12 +26,9 @@ dependencies:
flutter_bloc: ^8.1.3
equatable: ^2.0.5
intl: ^0.20.2
google_maps_flutter: ^2.14.2
url_launcher: ^6.3.1
firebase_auth: ^6.1.4
firebase_data_connect: ^0.2.2+2
meta: ^1.17.0
bloc: ^8.1.4
meta: ^1.17.0
dev_dependencies:
flutter_test: