feat: Migrate staff profile features from Data Connect to V2 REST API
- Removed data_connect package from mobile pubspec.yaml. - Added documentation for V2 profile migration status and QA findings. - Implemented new session management with ClientSessionStore and StaffSessionStore. - Created V2SessionService for handling user sessions via the V2 API. - Developed use cases for cancelling late worker assignments and submitting worker reviews. - Added arguments and use cases for payment chart retrieval and profile completion checks. - Implemented repository interfaces and their implementations for staff main and profile features. - Ensured proper error handling and validation in use cases.
This commit is contained in:
@@ -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 [V2ApiEndpoints] 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(
|
||||
V2ApiEndpoints.staffShiftsAssigned,
|
||||
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(
|
||||
V2ApiEndpoints.staffShiftsOpen,
|
||||
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(V2ApiEndpoints.staffShiftsPending);
|
||||
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(V2ApiEndpoints.staffShiftsCancelled);
|
||||
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(V2ApiEndpoints.staffShiftsCompleted);
|
||||
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(V2ApiEndpoints.staffShiftDetails(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(
|
||||
V2ApiEndpoints.staffShiftApply(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(V2ApiEndpoints.staffShiftAccept(shiftId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> declineShift(String shiftId) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.declineShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
await _apiService.post(V2ApiEndpoints.staffShiftDecline(shiftId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestSwap(String shiftId, {String? reason}) async {
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.staffShiftRequestSwap(shiftId),
|
||||
data: <String, dynamic>{
|
||||
if (reason != null) 'reason': reason,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getProfileCompletion() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.staffProfileCompletion);
|
||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
|
||||
return completion.completed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 V2ApiEndpoints.
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,24 @@ 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),
|
||||
final List<AssignedShift> myShiftsResult = await getAssignedShifts(
|
||||
GetAssignedShiftsArguments(start: days.first, end: days.last),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ShiftsStatus.loaded,
|
||||
myShifts: myShiftsResult,
|
||||
pendingShifts: const [],
|
||||
cancelledShifts: const [],
|
||||
availableShifts: const [],
|
||||
historyShifts: const [],
|
||||
pendingShifts: const <PendingAssignment>[],
|
||||
cancelledShifts: const <CancelledShift>[],
|
||||
availableShifts: const <OpenShift>[],
|
||||
historyShifts: const <CompletedShift>[],
|
||||
availableLoading: false,
|
||||
availableLoaded: false,
|
||||
historyLoading: false,
|
||||
historyLoaded: false,
|
||||
myShiftsLoaded: true,
|
||||
searchQuery: '',
|
||||
jobType: 'all',
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -92,7 +116,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 +149,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 +178,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 +200,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 +229,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 +246,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 +259,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 +299,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?>[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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),
|
||||
@@ -265,23 +219,13 @@ class _MyShiftCardState extends State<MyShiftCard> {
|
||||
color: UiColors.primary.withValues(alpha: 0.09),
|
||||
),
|
||||
),
|
||||
child: widget.shift.logoUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
child: Image.network(
|
||||
widget.shift.logoUrl!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
)
|
||||
: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: UiConstants.iconMd,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: UiConstants.iconMd,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
|
||||
@@ -298,12 +242,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,
|
||||
),
|
||||
@@ -315,11 +259,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,
|
||||
),
|
||||
],
|
||||
@@ -329,134 +273,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,
|
||||
@@ -465,9 +311,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,
|
||||
),
|
||||
@@ -479,7 +323,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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,27 +3,28 @@ 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 '../../blocs/shifts/shifts_bloc.dart';
|
||||
import '../my_shift_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';
|
||||
|
||||
/// 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();
|
||||
}
|
||||
@@ -31,230 +32,21 @@ class FindShiftsTab extends StatefulWidget {
|
||||
class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
String _searchQuery = '';
|
||||
String _jobType = 'all';
|
||||
double? _maxDistance; // miles
|
||||
Position? _currentPosition;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initLocation();
|
||||
}
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
Future<void> _initLocation() async {
|
||||
try {
|
||||
final LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.always ||
|
||||
permission == LocationPermission.whileInUse) {
|
||||
final Position pos = await Geolocator.getCurrentPosition();
|
||||
if (mounted) {
|
||||
setState(() => _currentPosition = pos);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
double _calculateDistance(double lat, double lng) {
|
||||
if (_currentPosition == null) return -1;
|
||||
final double distMeters = Geolocator.distanceBetween(
|
||||
_currentPosition!.latitude,
|
||||
_currentPosition!.longitude,
|
||||
lat,
|
||||
lng,
|
||||
);
|
||||
return distMeters / 1609.34; // meters to miles
|
||||
}
|
||||
|
||||
void _showDistanceFilter() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: UiColors.bgPopup,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter setModalState) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.staff_shifts.find_shifts.radius_filter_title,
|
||||
style: UiTypography.headline4m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_maxDistance == null
|
||||
? context.t.staff_shifts.find_shifts.unlimited_distance
|
||||
: context.t.staff_shifts.find_shifts.within_miles(
|
||||
miles: _maxDistance!.round().toString(),
|
||||
),
|
||||
style: UiTypography.body2m.textSecondary,
|
||||
),
|
||||
Slider(
|
||||
value: _maxDistance ?? 100,
|
||||
min: 5,
|
||||
max: 100,
|
||||
divisions: 19,
|
||||
activeColor: UiColors.primary,
|
||||
onChanged: (double val) {
|
||||
setModalState(() => _maxDistance = val);
|
||||
setState(() => _maxDistance = val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
text: context.t.staff_shifts.find_shifts.clear,
|
||||
onPressed: () {
|
||||
setModalState(() => _maxDistance = null);
|
||||
setState(() => _maxDistance = null);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: context.t.staff_shifts.find_shifts.apply,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
Widget _buildFilterTab(String id, String label) {
|
||||
final isSelected = _jobType == id;
|
||||
final bool isSelected = _jobType == id;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _jobType = id),
|
||||
child: Container(
|
||||
@@ -280,43 +72,184 @@ 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.clientName.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
// Client-side filter by order type
|
||||
final List<OpenShift> filteredJobs =
|
||||
_filterByType(widget.availableJobs).where((OpenShift s) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
final String q = _searchQuery.toLowerCase();
|
||||
return s.roleName.toLowerCase().contains(q) ||
|
||||
s.location.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
// Incomplete profile banner
|
||||
if (!widget.profileComplete) ...[
|
||||
if (!widget.profileComplete)
|
||||
GestureDetector(
|
||||
onTap: () => Modular.to.toProfile(),
|
||||
child: Container(
|
||||
@@ -324,19 +257,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,
|
||||
@@ -345,151 +271,76 @@ 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: TextField(
|
||||
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: TextField(
|
||||
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),
|
||||
],
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:design_system/design_system.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';
|
||||
|
||||
/// 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(
|
||||
icon: UiIcons.clock,
|
||||
title: "No shift history",
|
||||
subtitle: "Completed shifts appear here",
|
||||
title: 'No shift history',
|
||||
subtitle: 'Completed shifts appear here',
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
child: _CompletedShiftCard(shift: shift),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -41,3 +45,89 @@ class HistoryShiftsTab extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Card displaying a completed shift summary.
|
||||
class _CompletedShiftCard extends StatelessWidget {
|
||||
const _CompletedShiftCard({required this.shift});
|
||||
|
||||
final CompletedShift shift;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int hours = shift.minutesWorked ~/ 60;
|
||||
final int mins = shift.minutesWorked % 60;
|
||||
final String workedLabel =
|
||||
mins > 0 ? '${hours}h ${mins}m' : '${hours}h';
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: UiConstants.iconMd),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(shift.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(DateFormat('EEE, MMM d').format(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(workedLabel,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,15 @@ import 'package:intl/intl.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 '../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/my_shift_card.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_assignment_card.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.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 +21,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();
|
||||
}
|
||||
@@ -165,17 +175,16 @@ 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;
|
||||
String _formatDateFromDateTime(DateTime date) {
|
||||
final DateTime now = DateTime.now();
|
||||
if (_isSameDay(date, now)) {
|
||||
return context.t.staff_shifts.my_shifts_tab.date.today;
|
||||
}
|
||||
final DateTime 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);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -184,25 +193,15 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
final weekStartDate = calendarDays.first;
|
||||
final 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(
|
||||
@@ -263,13 +262,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
final bool hasShifts = widget.myShifts.any(
|
||||
(AssignedShift s) => _isSameDay(s.date, date),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedDate = date),
|
||||
@@ -342,12 +337,12 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
UiColors.textWarning,
|
||||
),
|
||||
...widget.pendingAssignments.map(
|
||||
(shift) => Padding(
|
||||
(PendingAssignment assignment) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: ShiftAssignmentCard(
|
||||
shift: shift,
|
||||
onConfirm: () => _confirmShift(shift.id),
|
||||
onDecline: () => _declineShift(shift.id),
|
||||
assignment: assignment,
|
||||
onConfirm: () => _confirmShift(assignment.shiftId),
|
||||
onDecline: () => _declineShift(assignment.shiftId),
|
||||
isConfirming: true,
|
||||
),
|
||||
),
|
||||
@@ -358,17 +353,13 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
if (visibleCancelledShifts.isNotEmpty) ...[
|
||||
_buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary),
|
||||
...visibleCancelledShifts.map(
|
||||
(shift) => Padding(
|
||||
(CancelledShift cs) => 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,
|
||||
title: cs.title,
|
||||
location: cs.location,
|
||||
date: DateFormat('EEE, MMM d').format(cs.date),
|
||||
reason: cs.cancellationReason,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
@@ -380,11 +371,11 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
if (visibleMyShifts.isNotEmpty) ...[
|
||||
_buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary),
|
||||
...visibleMyShifts.map(
|
||||
(shift) => Padding(
|
||||
(AssignedShift shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: MyShiftCard(
|
||||
shift: shift,
|
||||
onDecline: () => _declineShift(shift.id),
|
||||
onDecline: () => _declineShift(shift.shiftId),
|
||||
onRequestSwap: () {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
@@ -439,13 +430,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
|
||||
Widget _buildCancelledCard({
|
||||
required String title,
|
||||
required String client,
|
||||
required String pay,
|
||||
required String rate,
|
||||
required String location,
|
||||
required String date,
|
||||
required String time,
|
||||
required String address,
|
||||
required bool isLastMinute,
|
||||
String? reason,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
@@ -459,9 +446,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
@@ -475,25 +462,19 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
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: [
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
@@ -507,84 +488,42 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
children: <Widget>[
|
||||
Text(title, style: UiTypography.body2b.textPrimary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: 12,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
children: <Widget>[
|
||||
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,
|
||||
),
|
||||
Text(date,
|
||||
style: UiTypography.footnote1r.textSecondary),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 12,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.mapPin,
|
||||
size: 12, color: UiColors.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
address,
|
||||
location,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (reason != null && reason.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
reason,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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'] ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,11 +26,7 @@ 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
|
||||
|
||||
dev_dependencies:
|
||||
|
||||
Reference in New Issue
Block a user