Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new
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 [StaffEndpoints] for all network access.
|
||||
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
final dc.ShiftsConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
/// Creates a [ShiftsRepositoryImpl].
|
||||
ShiftsRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
ShiftsRepositoryImpl({
|
||||
dc.ShiftsConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getShiftsRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
/// The API service used for network requests.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
/// Extracts a list of items from the API response data.
|
||||
///
|
||||
/// Handles both the V2 wrapped `{"items": [...]}` shape and a raw
|
||||
/// `List<dynamic>` for backwards compatibility.
|
||||
List<dynamic> _extractItems(dynamic data) {
|
||||
if (data is List<dynamic>) {
|
||||
return data;
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
return data['items'] as List<dynamic>? ?? <dynamic>[];
|
||||
}
|
||||
return <dynamic>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getMyShifts({
|
||||
Future<List<AssignedShift>> getAssignedShifts({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getMyShifts(
|
||||
staffId: staffId,
|
||||
start: start,
|
||||
end: end,
|
||||
final ApiResponse response = await _apiService.get(
|
||||
StaffEndpoints.shiftsAssigned,
|
||||
params: <String, dynamic>{
|
||||
'startDate': start.toIso8601String(),
|
||||
'endDate': end.toIso8601String(),
|
||||
},
|
||||
);
|
||||
final List<dynamic> items = _extractItems(response.data);
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
AssignedShift.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getPendingAssignments() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getPendingAssignments(staffId: staffId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getCancelledShifts() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getCancelledShifts(staffId: staffId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getHistoryShifts() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getHistoryShifts(staffId: staffId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getAvailableShifts(String query, String type) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getAvailableShifts(
|
||||
staffId: staffId,
|
||||
query: query,
|
||||
type: type,
|
||||
Future<List<OpenShift>> getOpenShifts({
|
||||
String? search,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final Map<String, dynamic> params = <String, dynamic>{
|
||||
'limit': limit,
|
||||
};
|
||||
if (search != null && search.isNotEmpty) {
|
||||
params['search'] = search;
|
||||
}
|
||||
final ApiResponse response = await _apiService.get(
|
||||
StaffEndpoints.shiftsOpen,
|
||||
params: params,
|
||||
);
|
||||
final List<dynamic> items = _extractItems(response.data);
|
||||
return items
|
||||
.map(
|
||||
(dynamic json) => OpenShift.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Shift?> getShiftDetails(String shiftId, {String? roleId}) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getShiftDetails(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: roleId,
|
||||
);
|
||||
Future<List<PendingAssignment>> getPendingAssignments() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(StaffEndpoints.shiftsPending);
|
||||
final List<dynamic> items = _extractItems(response.data);
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
PendingAssignment.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CancelledShift>> getCancelledShifts() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(StaffEndpoints.shiftsCancelled);
|
||||
final List<dynamic> items = _extractItems(response.data);
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
CancelledShift.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CompletedShift>> getCompletedShifts() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(StaffEndpoints.shiftsCompleted);
|
||||
final List<dynamic> items = _extractItems(response.data);
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
CompletedShift.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ShiftDetail?> getShiftDetail(String shiftId) async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(StaffEndpoints.shiftDetails(shiftId));
|
||||
if (response.data == null) {
|
||||
return null;
|
||||
}
|
||||
return ShiftDetail.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyForShift(
|
||||
String shiftId, {
|
||||
bool isInstantBook = false,
|
||||
String? roleId,
|
||||
bool instantBook = false,
|
||||
}) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.applyForShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
isInstantBook: isInstantBook,
|
||||
roleId: roleId,
|
||||
await _apiService.post(
|
||||
StaffEndpoints.shiftApply(shiftId),
|
||||
data: <String, dynamic>{
|
||||
if (roleId != null) 'roleId': roleId,
|
||||
'instantBook': instantBook,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> acceptShift(String shiftId) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.acceptShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
);
|
||||
await _apiService.post(StaffEndpoints.shiftAccept(shiftId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> declineShift(String shiftId) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.declineShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
await _apiService.post(StaffEndpoints.shiftDecline(shiftId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestSwap(String shiftId, {String? reason}) async {
|
||||
await _apiService.post(
|
||||
StaffEndpoints.shiftRequestSwap(shiftId),
|
||||
data: <String, dynamic>{
|
||||
if (reason != null) 'reason': reason,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getProfileCompletion() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(StaffEndpoints.profileCompletion);
|
||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
|
||||
return completion.completed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [StaffEndpoints].
|
||||
abstract interface class ShiftsRepositoryInterface {
|
||||
/// Retrieves the list of shifts assigned to the current user.
|
||||
Future<List<Shift>> getMyShifts({
|
||||
/// Retrieves assigned shifts for the current staff within a date range.
|
||||
Future<List<AssignedShift>> getAssignedShifts({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
});
|
||||
|
||||
/// Retrieves available shifts matching the given [query] and [type].
|
||||
Future<List<Shift>> getAvailableShifts(String query, String type);
|
||||
/// Retrieves open shifts available for the staff to apply.
|
||||
Future<List<OpenShift>> getOpenShifts({
|
||||
String? search,
|
||||
int limit,
|
||||
});
|
||||
|
||||
/// Retrieves shifts that are pending acceptance by the user.
|
||||
Future<List<Shift>> getPendingAssignments();
|
||||
/// Retrieves pending assignments awaiting acceptance.
|
||||
Future<List<PendingAssignment>> getPendingAssignments();
|
||||
|
||||
/// Retrieves detailed information for a specific shift by [shiftId].
|
||||
Future<Shift?> getShiftDetails(String shiftId, {String? roleId});
|
||||
/// Retrieves cancelled shift assignments.
|
||||
Future<List<CancelledShift>> getCancelledShifts();
|
||||
|
||||
/// Applies for a specific open shift.
|
||||
///
|
||||
/// [isInstantBook] determines if the application should be immediately accepted.
|
||||
/// Retrieves completed shift history.
|
||||
Future<List<CompletedShift>> getCompletedShifts();
|
||||
|
||||
/// Retrieves full details for a specific shift.
|
||||
Future<ShiftDetail?> getShiftDetail(String shiftId);
|
||||
|
||||
/// Applies for an open shift.
|
||||
Future<void> applyForShift(
|
||||
String shiftId, {
|
||||
bool isInstantBook = false,
|
||||
String? roleId,
|
||||
bool instantBook,
|
||||
});
|
||||
|
||||
/// Accepts a pending shift assignment.
|
||||
@@ -35,9 +42,9 @@ abstract interface class ShiftsRepositoryInterface {
|
||||
/// Declines a pending shift assignment.
|
||||
Future<void> declineShift(String shiftId);
|
||||
|
||||
/// Retrieves shifts that were cancelled for the current user.
|
||||
Future<List<Shift>> getCancelledShifts();
|
||||
/// Requests a swap for an accepted shift assignment.
|
||||
Future<void> requestSwap(String shiftId, {String? reason});
|
||||
|
||||
/// Retrieves completed shifts for the current user.
|
||||
Future<List<Shift>> getHistoryShifts();
|
||||
/// Returns whether the staff profile is complete.
|
||||
Future<bool> getProfileCompletion();
|
||||
}
|
||||
|
||||
@@ -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,37 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<DateTime> days = _getCalendarDaysForOffset(0);
|
||||
final myShiftsResult = await getMyShifts(
|
||||
GetMyShiftsArguments(start: days.first, end: days.last),
|
||||
);
|
||||
|
||||
// Load assigned, pending, and cancelled shifts in parallel.
|
||||
final List<Object> results = await Future.wait(<Future<Object>>[
|
||||
getAssignedShifts(
|
||||
GetAssignedShiftsArguments(start: days.first, end: days.last),
|
||||
),
|
||||
getPendingAssignments(),
|
||||
getCancelledShifts(),
|
||||
]);
|
||||
|
||||
final List<AssignedShift> myShiftsResult =
|
||||
results[0] as List<AssignedShift>;
|
||||
final List<PendingAssignment> pendingResult =
|
||||
results[1] as List<PendingAssignment>;
|
||||
final List<CancelledShift> cancelledResult =
|
||||
results[2] as List<CancelledShift>;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ShiftsStatus.loaded,
|
||||
myShifts: myShiftsResult,
|
||||
pendingShifts: const [],
|
||||
cancelledShifts: const [],
|
||||
availableShifts: const [],
|
||||
historyShifts: const [],
|
||||
pendingShifts: pendingResult,
|
||||
cancelledShifts: cancelledResult,
|
||||
availableShifts: const <OpenShift>[],
|
||||
historyShifts: const <CompletedShift>[],
|
||||
availableLoading: false,
|
||||
availableLoaded: false,
|
||||
historyLoading: false,
|
||||
historyLoaded: false,
|
||||
myShiftsLoaded: true,
|
||||
searchQuery: '',
|
||||
jobType: 'all',
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -92,7 +129,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final historyResult = await getHistoryShifts();
|
||||
final List<CompletedShift> historyResult = await getCompletedShifts();
|
||||
emit(
|
||||
state.copyWith(
|
||||
myShiftsLoaded: true,
|
||||
@@ -125,12 +162,12 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final availableResult = await getAvailableShifts(
|
||||
const GetAvailableShiftsArguments(),
|
||||
final List<OpenShift> availableResult = await getOpenShifts(
|
||||
const GetOpenShiftsArguments(),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
availableShifts: _filterPastShifts(availableResult),
|
||||
availableShifts: _filterPastOpenShifts(availableResult),
|
||||
availableLoading: false,
|
||||
availableLoaded: true,
|
||||
),
|
||||
@@ -154,18 +191,17 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ShiftsStatus.loading,
|
||||
myShifts: const [],
|
||||
pendingShifts: const [],
|
||||
cancelledShifts: const [],
|
||||
availableShifts: const [],
|
||||
historyShifts: const [],
|
||||
myShifts: const <AssignedShift>[],
|
||||
pendingShifts: const <PendingAssignment>[],
|
||||
cancelledShifts: const <CancelledShift>[],
|
||||
availableShifts: const <OpenShift>[],
|
||||
historyShifts: const <CompletedShift>[],
|
||||
availableLoading: false,
|
||||
availableLoaded: false,
|
||||
historyLoading: false,
|
||||
historyLoaded: false,
|
||||
myShiftsLoaded: false,
|
||||
searchQuery: '',
|
||||
jobType: 'all',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -177,13 +213,13 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final availableResult = await getAvailableShifts(
|
||||
const GetAvailableShiftsArguments(),
|
||||
final List<OpenShift> availableResult = await getOpenShifts(
|
||||
const GetOpenShiftsArguments(),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ShiftsStatus.loaded,
|
||||
availableShifts: _filterPastShifts(availableResult),
|
||||
availableShifts: _filterPastOpenShifts(availableResult),
|
||||
availableLoading: false,
|
||||
availableLoaded: true,
|
||||
),
|
||||
@@ -206,8 +242,8 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final myShiftsResult = await getMyShifts(
|
||||
GetMyShiftsArguments(start: event.start, end: event.end),
|
||||
final List<AssignedShift> myShiftsResult = await getAssignedShifts(
|
||||
GetAssignedShiftsArguments(start: event.start, end: event.end),
|
||||
);
|
||||
|
||||
emit(
|
||||
@@ -223,8 +259,8 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onFilterAvailableShifts(
|
||||
FilterAvailableShiftsEvent event,
|
||||
Future<void> _onSearchOpenShifts(
|
||||
SearchOpenShiftsEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
if (state.status == ShiftsStatus.loaded) {
|
||||
@@ -236,18 +272,17 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final result = await getAvailableShifts(
|
||||
GetAvailableShiftsArguments(
|
||||
query: event.query ?? state.searchQuery,
|
||||
type: event.jobType ?? state.jobType,
|
||||
final String search = event.query ?? state.searchQuery;
|
||||
final List<OpenShift> result = await getOpenShifts(
|
||||
GetOpenShiftsArguments(
|
||||
search: search.isEmpty ? null : search,
|
||||
),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
availableShifts: _filterPastShifts(result),
|
||||
searchQuery: event.query ?? state.searchQuery,
|
||||
jobType: event.jobType ?? state.jobType,
|
||||
availableShifts: _filterPastOpenShifts(result),
|
||||
searchQuery: search,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -277,33 +312,60 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
||||
);
|
||||
}
|
||||
|
||||
List<DateTime> _getCalendarDaysForOffset(int weekOffset) {
|
||||
final now = DateTime.now();
|
||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||
final start = now
|
||||
.subtract(Duration(days: daysSinceFriday))
|
||||
.add(Duration(days: weekOffset * 7));
|
||||
final startDate = DateTime(start.year, start.month, start.day);
|
||||
return List.generate(7, (index) => startDate.add(Duration(days: index)));
|
||||
Future<void> _onAcceptShift(
|
||||
AcceptShiftEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await acceptShift(event.shiftId);
|
||||
add(LoadShiftsEvent());
|
||||
},
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: ShiftsStatus.error, errorMessage: errorKey),
|
||||
);
|
||||
}
|
||||
|
||||
List<Shift> _filterPastShifts(List<Shift> shifts) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
return shifts.where((shift) {
|
||||
if (shift.date.isEmpty) return false;
|
||||
try {
|
||||
final shiftDate = DateTime.parse(shift.date).toLocal();
|
||||
final dateOnly = DateTime(
|
||||
shiftDate.year,
|
||||
shiftDate.month,
|
||||
shiftDate.day,
|
||||
);
|
||||
return !dateOnly.isBefore(today);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
Future<void> _onDeclineShift(
|
||||
DeclineShiftEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await declineShift(event.shiftId);
|
||||
add(LoadShiftsEvent());
|
||||
},
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: ShiftsStatus.error, errorMessage: errorKey),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets calendar days for the given week offset (Friday-based week).
|
||||
List<DateTime> _getCalendarDaysForOffset(int weekOffset) {
|
||||
final DateTime now = DateTime.now();
|
||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||
final DateTime start = now
|
||||
.subtract(Duration(days: daysSinceFriday))
|
||||
.add(Duration(days: weekOffset * 7));
|
||||
final DateTime startDate = DateTime(start.year, start.month, start.day);
|
||||
return List<DateTime>.generate(
|
||||
7, (int index) => startDate.add(Duration(days: index)));
|
||||
}
|
||||
|
||||
/// Filters out open shifts whose date is in the past.
|
||||
List<OpenShift> _filterPastOpenShifts(List<OpenShift> shifts) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
return shifts.where((OpenShift shift) {
|
||||
final DateTime dateOnly = DateTime(
|
||||
shift.date.year,
|
||||
shift.date.month,
|
||||
shift.date.day,
|
||||
);
|
||||
return !dateOnly.isBefore(today);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
@@ -301,12 +255,12 @@ class _MyShiftCardState extends State<MyShiftCard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.shift.title,
|
||||
widget.shift.roleName,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
widget.shift.clientName,
|
||||
widget.shift.location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -318,11 +272,11 @@ class _MyShiftCardState extends State<MyShiftCard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
"\$${estimatedTotal.toStringAsFixed(0)}",
|
||||
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
Text(
|
||||
"\$${widget.shift.hourlyRate.toInt()}/hr · ${duration.toInt()}h",
|
||||
'\$${hourlyRate.toInt()}/hr \u00b7 ${duration.toInt()}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -332,134 +286,36 @@ class _MyShiftCardState extends State<MyShiftCard> {
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
|
||||
// Date & Time
|
||||
if (hasSchedules) ...[
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
scheduleRange,
|
||||
style:
|
||||
UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
|
||||
Text(
|
||||
'${schedules.length} schedules',
|
||||
style: UiTypography.footnote2m.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
...visibleSchedules.map(
|
||||
(schedule) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'${_formatDate(schedule.date)}, ${_formatTime(schedule.startTime)} – ${_formatTime(schedule.endTime)}',
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (remainingSchedules > 0)
|
||||
Text(
|
||||
'+$remainingSchedules more schedules',
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.primary.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (widget.shift.durationDays != null &&
|
||||
widget.shift.durationDays! > 1) ...[
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
t.staff_shifts.details.days(
|
||||
days: widget.shift.durationDays!,
|
||||
),
|
||||
style: UiTypography.footnote2m.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)} – ${_formatTime(widget.shift.endTime)}',
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.shift.durationDays! > 1)
|
||||
Text(
|
||||
'... +${widget.shift.durationDays! - 1} more days',
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.primary.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatDate(widget.shift.date),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
"${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}",
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatDate(widget.shift.date),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}',
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
|
||||
// Location
|
||||
Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.iconXs,
|
||||
@@ -468,9 +324,7 @@ class _MyShiftCardState extends State<MyShiftCard> {
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.shift.locationAddress.isNotEmpty
|
||||
? widget.shift.locationAddress
|
||||
: widget.shift.location,
|
||||
widget.shift.location,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -482,7 +336,7 @@ class _MyShiftCardState extends State<MyShiftCard> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (status == 'completed') ...[
|
||||
if (status == AssignmentStatus.completed) ...[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const Divider(),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Variant that controls the visual treatment of the [ShiftCard].
|
||||
///
|
||||
/// Each variant maps to a different colour scheme for the status badge and
|
||||
/// optional footer action area.
|
||||
enum ShiftCardVariant {
|
||||
/// Confirmed / accepted assignment.
|
||||
confirmed,
|
||||
|
||||
/// Pending assignment awaiting acceptance.
|
||||
pending,
|
||||
|
||||
/// Cancelled assignment.
|
||||
cancelled,
|
||||
|
||||
/// Completed shift (history).
|
||||
completed,
|
||||
|
||||
/// Worker is currently checked in.
|
||||
checkedIn,
|
||||
|
||||
/// A swap has been requested.
|
||||
swapRequested,
|
||||
}
|
||||
|
||||
/// Immutable data model that feeds the [ShiftCard].
|
||||
///
|
||||
/// Acts as an adapter between the various shift entity types
|
||||
/// (`AssignedShift`, `CompletedShift`, `CancelledShift`, `PendingAssignment`)
|
||||
/// and the unified card presentation.
|
||||
class ShiftCardData {
|
||||
/// Creates a [ShiftCardData].
|
||||
const ShiftCardData({
|
||||
required this.shiftId,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.date,
|
||||
required this.variant,
|
||||
this.subtitle,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.hourlyRateCents,
|
||||
this.orderType,
|
||||
this.minutesWorked,
|
||||
this.cancellationReason,
|
||||
this.paymentStatus,
|
||||
});
|
||||
|
||||
/// Constructs [ShiftCardData] from an [AssignedShift].
|
||||
factory ShiftCardData.fromAssigned(AssignedShift shift) {
|
||||
return ShiftCardData(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.roleName,
|
||||
subtitle: shift.location,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRateCents: shift.hourlyRateCents,
|
||||
orderType: shift.orderType,
|
||||
variant: _variantFromAssignmentStatus(shift.status),
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs [ShiftCardData] from a [CompletedShift].
|
||||
factory ShiftCardData.fromCompleted(CompletedShift shift) {
|
||||
return ShiftCardData(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.title,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
minutesWorked: shift.minutesWorked,
|
||||
paymentStatus: shift.paymentStatus,
|
||||
variant: ShiftCardVariant.completed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs [ShiftCardData] from a [CancelledShift].
|
||||
factory ShiftCardData.fromCancelled(CancelledShift shift) {
|
||||
return ShiftCardData(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.title,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
cancellationReason: shift.cancellationReason,
|
||||
variant: ShiftCardVariant.cancelled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs [ShiftCardData] from a [PendingAssignment].
|
||||
factory ShiftCardData.fromPending(PendingAssignment assignment) {
|
||||
return ShiftCardData(
|
||||
shiftId: assignment.shiftId,
|
||||
title: assignment.roleName,
|
||||
subtitle: assignment.title.isNotEmpty ? assignment.title : null,
|
||||
location: assignment.location,
|
||||
date: assignment.startTime,
|
||||
startTime: assignment.startTime,
|
||||
endTime: assignment.endTime,
|
||||
variant: ShiftCardVariant.pending,
|
||||
);
|
||||
}
|
||||
|
||||
/// The shift row id.
|
||||
final String shiftId;
|
||||
|
||||
/// Primary display title (role name or shift title).
|
||||
final String title;
|
||||
|
||||
/// Optional secondary text (e.g. location under the role name).
|
||||
final String? subtitle;
|
||||
|
||||
/// Human-readable location label.
|
||||
final String location;
|
||||
|
||||
/// The date of the shift.
|
||||
final DateTime date;
|
||||
|
||||
/// Scheduled start time (null for completed/cancelled).
|
||||
final DateTime? startTime;
|
||||
|
||||
/// Scheduled end time (null for completed/cancelled).
|
||||
final DateTime? endTime;
|
||||
|
||||
/// Hourly pay rate in cents (null when not applicable).
|
||||
final int? hourlyRateCents;
|
||||
|
||||
/// Order type (null for completed/cancelled).
|
||||
final OrderType? orderType;
|
||||
|
||||
/// Minutes worked (only for completed shifts).
|
||||
final int? minutesWorked;
|
||||
|
||||
/// Cancellation reason (only for cancelled shifts).
|
||||
final String? cancellationReason;
|
||||
|
||||
/// Payment processing status (only for completed shifts).
|
||||
final PaymentStatus? paymentStatus;
|
||||
|
||||
/// Visual variant for the card.
|
||||
final ShiftCardVariant variant;
|
||||
|
||||
static ShiftCardVariant _variantFromAssignmentStatus(
|
||||
AssignmentStatus status,
|
||||
) {
|
||||
switch (status) {
|
||||
case AssignmentStatus.accepted:
|
||||
return ShiftCardVariant.confirmed;
|
||||
case AssignmentStatus.checkedIn:
|
||||
return ShiftCardVariant.checkedIn;
|
||||
case AssignmentStatus.swapRequested:
|
||||
return ShiftCardVariant.swapRequested;
|
||||
case AssignmentStatus.completed:
|
||||
return ShiftCardVariant.completed;
|
||||
case AssignmentStatus.cancelled:
|
||||
return ShiftCardVariant.cancelled;
|
||||
case AssignmentStatus.assigned:
|
||||
return ShiftCardVariant.pending;
|
||||
case AssignmentStatus.checkedOut:
|
||||
case AssignmentStatus.noShow:
|
||||
case AssignmentStatus.unknown:
|
||||
return ShiftCardVariant.confirmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified card widget for displaying shift information across all shift types.
|
||||
///
|
||||
/// Replaces `MyShiftCard`, `ShiftAssignmentCard`, and the inline
|
||||
/// `_CompletedShiftCard` / `_buildCancelledCard` from the tabs. Accepts a
|
||||
/// [ShiftCardData] data model that adapts the various domain entities into a
|
||||
/// common display shape.
|
||||
class ShiftCard extends StatelessWidget {
|
||||
/// Creates a [ShiftCard].
|
||||
const ShiftCard({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.onTap,
|
||||
this.onSubmitForApproval,
|
||||
this.showApprovalAction = false,
|
||||
this.isSubmitted = false,
|
||||
this.onAccept,
|
||||
this.onDecline,
|
||||
this.isAccepting = false,
|
||||
});
|
||||
|
||||
/// The shift data to display.
|
||||
final ShiftCardData data;
|
||||
|
||||
/// Callback when the card is tapped (typically navigates to shift details).
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Callback when the "Submit for Approval" button is pressed.
|
||||
final VoidCallback? onSubmitForApproval;
|
||||
|
||||
/// Whether to show the submit-for-approval footer.
|
||||
final bool showApprovalAction;
|
||||
|
||||
/// Whether the timesheet has already been submitted.
|
||||
final bool isSubmitted;
|
||||
|
||||
/// Callback when the accept action is pressed (pending assignments only).
|
||||
final VoidCallback? onAccept;
|
||||
|
||||
/// Callback when the decline action is pressed (pending assignments only).
|
||||
final VoidCallback? onDecline;
|
||||
|
||||
/// Whether the accept action is in progress.
|
||||
final bool isAccepting;
|
||||
|
||||
/// Whether the accept/decline footer should be shown.
|
||||
bool get _showPendingActions => onAccept != null || onDecline != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: _showPendingActions
|
||||
? <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_StatusBadge(
|
||||
variant: data.variant,
|
||||
orderType: data.orderType,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_CardBody(data: data),
|
||||
if (showApprovalAction) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_ApprovalFooter(
|
||||
isSubmitted: isSubmitted,
|
||||
onSubmit: onSubmitForApproval,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_showPendingActions)
|
||||
_PendingActionsFooter(
|
||||
onAccept: onAccept,
|
||||
onDecline: onDecline,
|
||||
isAccepting: isAccepting,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the coloured status dot/icon and label, plus an optional order-type
|
||||
/// chip.
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.variant, this.orderType});
|
||||
|
||||
final ShiftCardVariant variant;
|
||||
final OrderType? orderType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _StatusStyle style = _resolveStyle(context);
|
||||
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
if (style.icon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: UiConstants.space2),
|
||||
child: Icon(
|
||||
style.icon,
|
||||
size: UiConstants.iconXs,
|
||||
color: style.foreground,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(right: UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: style.dot,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
style.label,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: style.foreground,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
if (orderType != null) ...<Widget>[
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_OrderTypeChip(orderType: orderType!),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
_StatusStyle _resolveStyle(BuildContext context) {
|
||||
switch (variant) {
|
||||
case ShiftCardVariant.confirmed:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.status.confirmed,
|
||||
foreground: UiColors.textLink,
|
||||
dot: UiColors.primary,
|
||||
);
|
||||
case ShiftCardVariant.pending:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.status.act_now,
|
||||
foreground: UiColors.destructive,
|
||||
dot: UiColors.destructive,
|
||||
);
|
||||
case ShiftCardVariant.cancelled:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.my_shifts_tab.card.cancelled,
|
||||
foreground: UiColors.destructive,
|
||||
dot: UiColors.destructive,
|
||||
);
|
||||
case ShiftCardVariant.completed:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.status.completed,
|
||||
foreground: UiColors.textSuccess,
|
||||
dot: UiColors.iconSuccess,
|
||||
);
|
||||
case ShiftCardVariant.checkedIn:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.my_shift_card.checked_in,
|
||||
foreground: UiColors.textSuccess,
|
||||
dot: UiColors.iconSuccess,
|
||||
);
|
||||
case ShiftCardVariant.swapRequested:
|
||||
return _StatusStyle(
|
||||
label: context.t.staff_shifts.status.swap_requested,
|
||||
foreground: UiColors.textWarning,
|
||||
dot: UiColors.textWarning,
|
||||
icon: UiIcons.swap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal helper grouping status badge presentation values.
|
||||
class _StatusStyle {
|
||||
const _StatusStyle({
|
||||
required this.label,
|
||||
required this.foreground,
|
||||
required this.dot,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color foreground;
|
||||
final Color dot;
|
||||
final IconData? icon;
|
||||
}
|
||||
|
||||
/// Small chip showing the order type (One Day / Multi-Day / Long Term).
|
||||
class _OrderTypeChip extends StatelessWidget {
|
||||
const _OrderTypeChip({required this.orderType});
|
||||
|
||||
final OrderType orderType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String label = _label(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.background,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.footnote2m.copyWith(color: UiColors.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(BuildContext context) {
|
||||
switch (orderType) {
|
||||
case OrderType.permanent:
|
||||
return context.t.staff_shifts.filter.long_term;
|
||||
case OrderType.recurring:
|
||||
return context.t.staff_shifts.filter.multi_day;
|
||||
case OrderType.oneTime:
|
||||
case OrderType.rapid:
|
||||
case OrderType.unknown:
|
||||
return context.t.staff_shifts.filter.one_day;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main body: icon, title/subtitle, metadata rows, and optional pay info.
|
||||
class _CardBody extends StatelessWidget {
|
||||
const _CardBody({required this.data});
|
||||
|
||||
final ShiftCardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_ShiftIcon(variant: data.variant),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_TitleRow(data: data),
|
||||
if (data.subtitle != null) ...<Widget>[
|
||||
Text(
|
||||
data.subtitle!,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_MetadataRows(data: data),
|
||||
if (data.cancellationReason != null &&
|
||||
data.cancellationReason!.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
data.cancellationReason!,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 44x44 icon box with a gradient background.
|
||||
class _ShiftIcon extends StatelessWidget {
|
||||
const _ShiftIcon({required this.variant});
|
||||
|
||||
final ShiftCardVariant variant;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isCancelled = variant == ShiftCardVariant.cancelled;
|
||||
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: isCancelled
|
||||
? null
|
||||
: LinearGradient(
|
||||
colors: <Color>[
|
||||
UiColors.primary.withValues(alpha: 0.09),
|
||||
UiColors.primary.withValues(alpha: 0.03),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
color: isCancelled
|
||||
? UiColors.primary.withValues(alpha: 0.05)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: isCancelled
|
||||
? null
|
||||
: Border.all(
|
||||
color: UiColors.primary.withValues(alpha: 0.09),
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: UiConstants.iconMd,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Title row with optional pay summary on the right.
|
||||
class _TitleRow extends StatelessWidget {
|
||||
const _TitleRow({required this.data});
|
||||
|
||||
final ShiftCardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasPay = data.hourlyRateCents != null &&
|
||||
data.startTime != null &&
|
||||
data.endTime != null;
|
||||
|
||||
if (!hasPay) {
|
||||
return Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
final double hourlyRate = data.hourlyRateCents! / 100;
|
||||
final int durationMinutes =
|
||||
data.endTime!.difference(data.startTime!).inMinutes;
|
||||
double durationHours = durationMinutes / 60;
|
||||
if (durationHours < 0) durationHours += 24;
|
||||
durationHours = durationHours.roundToDouble();
|
||||
final double estimatedTotal = hourlyRate * durationHours;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Date, time, location, and worked-hours rows.
|
||||
class _MetadataRows extends StatelessWidget {
|
||||
const _MetadataRows({required this.data});
|
||||
|
||||
final ShiftCardData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
// Date and time row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatDate(context, data.date),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
if (data.startTime != null && data.endTime != null) ...<Widget>[
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}',
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
if (data.minutesWorked != null) ...<Widget>[
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatWorkedDuration(data.minutesWorked!),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
// Location row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.location,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(BuildContext context, DateTime date) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
final DateTime tomorrow = today.add(const Duration(days: 1));
|
||||
final DateTime d = DateTime(date.year, date.month, date.day);
|
||||
if (d == today) return context.t.staff_shifts.my_shifts_tab.date.today;
|
||||
if (d == tomorrow) {
|
||||
return context.t.staff_shifts.my_shifts_tab.date.tomorrow;
|
||||
}
|
||||
return DateFormat('EEE, MMM d').format(date);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
String _formatWorkedDuration(int totalMinutes) {
|
||||
final int hours = totalMinutes ~/ 60;
|
||||
final int mins = totalMinutes % 60;
|
||||
return mins > 0 ? '${hours}h ${mins}m' : '${hours}h';
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer showing the submit-for-approval action for completed shifts.
|
||||
class _ApprovalFooter extends StatelessWidget {
|
||||
const _ApprovalFooter({
|
||||
required this.isSubmitted,
|
||||
this.onSubmit,
|
||||
});
|
||||
|
||||
final bool isSubmitted;
|
||||
final VoidCallback? onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
isSubmitted
|
||||
? context.t.staff_shifts.my_shift_card.submitted
|
||||
: context.t.staff_shifts.my_shift_card.ready_to_submit,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: isSubmitted ? UiColors.textSuccess : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (!isSubmitted)
|
||||
UiButton.secondary(
|
||||
text: context.t.staff_shifts.my_shift_card.submit_for_approval,
|
||||
size: UiButtonSize.small,
|
||||
onPressed: onSubmit,
|
||||
)
|
||||
else
|
||||
const Icon(
|
||||
UiIcons.success,
|
||||
color: UiColors.iconSuccess,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Coloured footer with Decline / Accept buttons for pending assignments.
|
||||
class _PendingActionsFooter extends StatelessWidget {
|
||||
const _PendingActionsFooter({
|
||||
this.onAccept,
|
||||
this.onDecline,
|
||||
this.isAccepting = false,
|
||||
});
|
||||
|
||||
final VoidCallback? onAccept;
|
||||
final VoidCallback? onDecline;
|
||||
final bool isAccepting;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.secondary,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(UiConstants.radiusBase),
|
||||
bottomRight: Radius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: onDecline,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
),
|
||||
child: Text(
|
||||
context.t.staff_shifts.action.decline,
|
||||
style: UiTypography.body2m.textError,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: isAccepting ? null : onAccept,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
foregroundColor: UiColors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusMdValue,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: isAccepting
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: UiColors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
context.t.staff_shifts.action.confirm,
|
||||
style: UiTypography.body2m.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,27 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
||||
|
||||
import '../../blocs/shifts/shifts_bloc.dart';
|
||||
import '../my_shift_card.dart';
|
||||
import '../shared/empty_state_view.dart';
|
||||
|
||||
/// Tab showing open shifts available for the worker to browse and apply.
|
||||
class FindShiftsTab extends StatefulWidget {
|
||||
final List<Shift> availableJobs;
|
||||
|
||||
/// Whether the worker's profile is complete. When false, shows incomplete
|
||||
/// profile banner and disables apply actions.
|
||||
final bool profileComplete;
|
||||
|
||||
/// Creates a [FindShiftsTab].
|
||||
const FindShiftsTab({
|
||||
super.key,
|
||||
required this.availableJobs,
|
||||
this.profileComplete = true,
|
||||
});
|
||||
|
||||
/// Open shifts loaded from the V2 API.
|
||||
final List<OpenShift> availableJobs;
|
||||
|
||||
/// Whether the worker's profile is complete.
|
||||
final bool profileComplete;
|
||||
|
||||
@override
|
||||
State<FindShiftsTab> createState() => _FindShiftsTabState();
|
||||
}
|
||||
@@ -35,11 +33,8 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
Position? _currentPosition;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initLocation();
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -142,126 +137,20 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isRecurring(Shift shift) =>
|
||||
(shift.orderType ?? '').toUpperCase() == 'RECURRING';
|
||||
|
||||
bool _isPermanent(Shift shift) =>
|
||||
(shift.orderType ?? '').toUpperCase() == 'PERMANENT';
|
||||
|
||||
DateTime? _parseShiftDate(String date) {
|
||||
if (date.isEmpty) return null;
|
||||
try {
|
||||
return DateTime.parse(date);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
String _formatDate(DateTime date) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
final DateTime tomorrow = today.add(const Duration(days: 1));
|
||||
final DateTime d = DateTime(date.year, date.month, date.day);
|
||||
if (d == today) return 'Today';
|
||||
if (d == tomorrow) return 'Tomorrow';
|
||||
return DateFormat('EEE, MMM d').format(date);
|
||||
}
|
||||
|
||||
List<Shift> _groupMultiDayShifts(List<Shift> shifts) {
|
||||
final Map<String, List<Shift>> grouped = <String, List<Shift>>{};
|
||||
for (final shift in shifts) {
|
||||
if (!_isRecurring(shift) && !_isPermanent(shift)) {
|
||||
continue;
|
||||
}
|
||||
final orderId = shift.orderId;
|
||||
final roleId = shift.roleId;
|
||||
if (orderId == null || roleId == null) {
|
||||
continue;
|
||||
}
|
||||
final key = '$orderId::$roleId';
|
||||
grouped.putIfAbsent(key, () => <Shift>[]).add(shift);
|
||||
}
|
||||
|
||||
final Set<String> addedGroups = <String>{};
|
||||
final List<Shift> result = <Shift>[];
|
||||
|
||||
for (final shift in shifts) {
|
||||
if (!_isRecurring(shift) && !_isPermanent(shift)) {
|
||||
result.add(shift);
|
||||
continue;
|
||||
}
|
||||
final orderId = shift.orderId;
|
||||
final roleId = shift.roleId;
|
||||
if (orderId == null || roleId == null) {
|
||||
result.add(shift);
|
||||
continue;
|
||||
}
|
||||
final key = '$orderId::$roleId';
|
||||
if (addedGroups.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
addedGroups.add(key);
|
||||
final List<Shift> group = grouped[key] ?? <Shift>[];
|
||||
if (group.isEmpty) {
|
||||
result.add(shift);
|
||||
continue;
|
||||
}
|
||||
group.sort((a, b) {
|
||||
final ad = _parseShiftDate(a.date);
|
||||
final bd = _parseShiftDate(b.date);
|
||||
if (ad == null && bd == null) return 0;
|
||||
if (ad == null) return 1;
|
||||
if (bd == null) return -1;
|
||||
return ad.compareTo(bd);
|
||||
});
|
||||
|
||||
final Shift first = group.first;
|
||||
final List<ShiftSchedule> schedules = group
|
||||
.map(
|
||||
(s) => ShiftSchedule(
|
||||
date: s.date,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
result.add(
|
||||
Shift(
|
||||
id: first.id,
|
||||
roleId: first.roleId,
|
||||
title: first.title,
|
||||
clientName: first.clientName,
|
||||
logoUrl: first.logoUrl,
|
||||
hourlyRate: first.hourlyRate,
|
||||
location: first.location,
|
||||
locationAddress: first.locationAddress,
|
||||
date: first.date,
|
||||
endDate: first.endDate,
|
||||
startTime: first.startTime,
|
||||
endTime: first.endTime,
|
||||
createdDate: first.createdDate,
|
||||
tipsAvailable: first.tipsAvailable,
|
||||
travelTime: first.travelTime,
|
||||
mealProvided: first.mealProvided,
|
||||
parkingAvailable: first.parkingAvailable,
|
||||
gasCompensation: first.gasCompensation,
|
||||
description: first.description,
|
||||
instructions: first.instructions,
|
||||
managers: first.managers,
|
||||
latitude: first.latitude,
|
||||
longitude: first.longitude,
|
||||
status: first.status,
|
||||
durationDays: schedules.length,
|
||||
requiredSlots: first.requiredSlots,
|
||||
filledSlots: first.filledSlots,
|
||||
hasApplied: first.hasApplied,
|
||||
totalValue: first.totalValue,
|
||||
breakInfo: first.breakInfo,
|
||||
orderId: first.orderId,
|
||||
orderType: first.orderType,
|
||||
schedules: schedules,
|
||||
recurringDays: first.recurringDays,
|
||||
permanentDays: first.permanentDays,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Widget _buildFilterTab(String id, String label) {
|
||||
final isSelected = _jobType == id;
|
||||
final bool isSelected = _jobType == id;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _jobType = id),
|
||||
child: Container(
|
||||
@@ -287,44 +176,198 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
List<OpenShift> _filterByType(List<OpenShift> shifts) {
|
||||
if (_jobType == 'all') return shifts;
|
||||
return shifts.where((OpenShift s) {
|
||||
if (_jobType == 'one-day') return s.orderType == OrderType.oneTime;
|
||||
if (_jobType == 'multi-day') return s.orderType == OrderType.recurring;
|
||||
if (_jobType == 'long-term') return s.orderType == OrderType.permanent;
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Builds an open shift card.
|
||||
Widget _buildOpenShiftCard(BuildContext context, OpenShift shift) {
|
||||
final double hourlyRate = shift.hourlyRateCents / 100;
|
||||
final int minutes = shift.endTime.difference(shift.startTime).inMinutes;
|
||||
final double duration = minutes / 60;
|
||||
final double estimatedTotal = hourlyRate * duration;
|
||||
|
||||
String typeLabel;
|
||||
switch (shift.orderType) {
|
||||
case OrderType.permanent:
|
||||
typeLabel = t.staff_shifts.filter.long_term;
|
||||
case OrderType.recurring:
|
||||
typeLabel = t.staff_shifts.filter.multi_day;
|
||||
case OrderType.oneTime:
|
||||
default:
|
||||
typeLabel = t.staff_shifts.filter.one_day;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Type badge
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.background,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Text(
|
||||
typeLabel,
|
||||
style: UiTypography.footnote2m
|
||||
.copyWith(color: UiColors.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
UiColors.primary.withValues(alpha: 0.09),
|
||||
UiColors.primary.withValues(alpha: 0.03),
|
||||
],
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(UiIcons.briefcase,
|
||||
color: UiColors.primary, size: UiConstants.iconMd),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(shift.roleName,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
Text(shift.location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text('\$${estimatedTotal.toStringAsFixed(0)}',
|
||||
style: UiTypography.title1m.textPrimary),
|
||||
Text(
|
||||
'\$${hourlyRate.toInt()}/hr \u00b7 ${duration.toInt()}h',
|
||||
style:
|
||||
UiTypography.footnote2r.textSecondary),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(_formatDate(shift.date),
|
||||
style: UiTypography.footnote1r.textSecondary),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(UiIcons.clock,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}',
|
||||
style: UiTypography.footnote1r.textSecondary),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.mapPin,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(shift.location,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groupedJobs = _groupMultiDayShifts(widget.availableJobs);
|
||||
|
||||
// Filter logic
|
||||
final filteredJobs = groupedJobs.where((s) {
|
||||
final matchesSearch =
|
||||
s.title.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
s.location.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
(s.description ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
s.clientName.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
// Client-side filter by order type
|
||||
final List<OpenShift> filteredJobs =
|
||||
_filterByType(widget.availableJobs).where((OpenShift s) {
|
||||
final String q = _searchQuery.toLowerCase();
|
||||
final bool matchesSearch = _searchQuery.isEmpty ||
|
||||
s.roleName.toLowerCase().contains(q) ||
|
||||
s.location.toLowerCase().contains(q);
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
if (_maxDistance != null && s.latitude != null && s.longitude != null) {
|
||||
final double dist = _calculateDistance(s.latitude!, s.longitude!);
|
||||
if (dist > _maxDistance!) return false;
|
||||
// Note: Distance filter is currently disabled as OpenShift model
|
||||
// from the V2 API does not yet include latitude/longitude coordinates.
|
||||
/*
|
||||
if (_maxDistance != null && _currentPosition != null) {
|
||||
// final double dist = _calculateDistance(s.latitude!, s.longitude!);
|
||||
// if (dist > _maxDistance!) return false;
|
||||
}
|
||||
*/
|
||||
|
||||
if (_jobType == 'all') return true;
|
||||
if (_jobType == 'one-day') {
|
||||
if (_isRecurring(s) || _isPermanent(s)) return false;
|
||||
return s.durationDays == null || s.durationDays! <= 1;
|
||||
}
|
||||
if (_jobType == 'multi-day') {
|
||||
return _isRecurring(s) ||
|
||||
(s.durationDays != null && s.durationDays! > 1);
|
||||
}
|
||||
if (_jobType == 'long-term') {
|
||||
return _isPermanent(s);
|
||||
}
|
||||
return true;
|
||||
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
// Incomplete profile banner
|
||||
if (!widget.profileComplete) ...[
|
||||
if (!widget.profileComplete)
|
||||
GestureDetector(
|
||||
onTap: () => Modular.to.toProfile(),
|
||||
child: Container(
|
||||
@@ -332,19 +375,12 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
child: UiNoticeBanner(
|
||||
icon: UiIcons.sparkles,
|
||||
title: context
|
||||
.t
|
||||
.staff_shifts
|
||||
.find_shifts
|
||||
.incomplete_profile_banner_title,
|
||||
description: context
|
||||
.t
|
||||
.staff_shifts
|
||||
.find_shifts
|
||||
.t.staff_shifts.find_shifts.incomplete_profile_banner_title,
|
||||
description: context.t.staff_shifts.find_shifts
|
||||
.incomplete_profile_banner_message,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Search and Filters
|
||||
Container(
|
||||
color: UiColors.white,
|
||||
@@ -353,155 +389,81 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
vertical: UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Search Bar
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.background,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
height: 48,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.background,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.search,
|
||||
size: 20, color: UiColors.textInactive),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
identifier: 'find_shifts_search_input',
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (String v) =>
|
||||
setState(() => _searchQuery = v),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText:
|
||||
context.t.staff_shifts.find_shifts.search_hint,
|
||||
hintStyle: UiTypography.body2r.textPlaceholder,
|
||||
),
|
||||
),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 20,
|
||||
color: UiColors.textInactive,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
identifier: 'find_shifts_search_input',
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (v) =>
|
||||
setState(() => _searchQuery = v),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: context
|
||||
.t
|
||||
.staff_shifts
|
||||
.find_shifts
|
||||
.search_hint,
|
||||
hintStyle: UiTypography.body2r.textPlaceholder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
GestureDetector(
|
||||
onTap: _showDistanceFilter,
|
||||
child: Container(
|
||||
height: 48,
|
||||
width: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _maxDistance != null
|
||||
? UiColors.primary.withValues(alpha: 0.1)
|
||||
: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
border: Border.all(
|
||||
color: _maxDistance != null
|
||||
? UiColors.primary
|
||||
: UiColors.border,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
UiIcons.filter,
|
||||
size: 18,
|
||||
color: _maxDistance != null
|
||||
? UiColors.primary
|
||||
: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
// Filter Tabs
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
_buildFilterTab(
|
||||
'all',
|
||||
context.t.staff_shifts.find_shifts.filter_all,
|
||||
),
|
||||
'all', context.t.staff_shifts.find_shifts.filter_all),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_buildFilterTab(
|
||||
'one-day',
|
||||
context.t.staff_shifts.find_shifts.filter_one_day,
|
||||
),
|
||||
_buildFilterTab('one-day',
|
||||
context.t.staff_shifts.find_shifts.filter_one_day),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_buildFilterTab(
|
||||
'multi-day',
|
||||
context.t.staff_shifts.find_shifts.filter_multi_day,
|
||||
),
|
||||
_buildFilterTab('multi-day',
|
||||
context.t.staff_shifts.find_shifts.filter_multi_day),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_buildFilterTab(
|
||||
'long-term',
|
||||
context.t.staff_shifts.find_shifts.filter_long_term,
|
||||
),
|
||||
_buildFilterTab('long-term',
|
||||
context.t.staff_shifts.find_shifts.filter_long_term),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: filteredJobs.isEmpty
|
||||
? EmptyStateView(
|
||||
icon: UiIcons.search,
|
||||
title: context.t.staff_shifts.find_shifts.no_jobs_title,
|
||||
subtitle: context.t.staff_shifts.find_shifts.no_jobs_subtitle,
|
||||
subtitle:
|
||||
context.t.staff_shifts.find_shifts.no_jobs_subtitle,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
horizontal: UiConstants.space5),
|
||||
child: Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
...filteredJobs.map(
|
||||
(shift) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space3,
|
||||
),
|
||||
child: MyShiftCard(
|
||||
shift: shift,
|
||||
onAccept: widget.profileComplete
|
||||
? () {
|
||||
BlocProvider.of<ShiftsBloc>(
|
||||
context,
|
||||
).add(AcceptShiftEvent(shift.id));
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context
|
||||
.t
|
||||
.staff_shifts
|
||||
.find_shifts
|
||||
.application_submitted,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
(OpenShift shift) =>
|
||||
_buildOpenShiftCard(context, shift),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space32),
|
||||
],
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../my_shift_card.dart';
|
||||
import '../shared/empty_state_view.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
|
||||
|
||||
/// Tab displaying completed shift history.
|
||||
class HistoryShiftsTab extends StatelessWidget {
|
||||
final List<Shift> historyShifts;
|
||||
|
||||
/// Creates a [HistoryShiftsTab].
|
||||
const HistoryShiftsTab({super.key, required this.historyShifts});
|
||||
|
||||
/// Completed shifts.
|
||||
final List<CompletedShift> historyShifts;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (historyShifts.isEmpty) {
|
||||
return const EmptyStateView(
|
||||
return EmptyStateView(
|
||||
icon: UiIcons.clock,
|
||||
title: "No shift history",
|
||||
subtitle: "Completed shifts appear here",
|
||||
title: context.t.staff_shifts.list.no_shifts,
|
||||
subtitle: context.t.staff_shifts.history_tab.subtitle,
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
child: Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
...historyShifts.map(
|
||||
(shift) => Padding(
|
||||
(CompletedShift shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: GestureDetector(
|
||||
onTap: () => Modular.to.toShiftDetails(shift),
|
||||
child: MyShiftCard(shift: shift),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromCompleted(shift),
|
||||
onTap: () =>
|
||||
Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext;
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../blocs/shifts/shifts_bloc.dart';
|
||||
import '../my_shift_card.dart';
|
||||
import '../shift_assignment_card.dart';
|
||||
import '../shared/empty_state_view.dart';
|
||||
|
||||
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
|
||||
|
||||
/// Tab displaying the worker's assigned, pending, and cancelled shifts.
|
||||
class MyShiftsTab extends StatefulWidget {
|
||||
final List<Shift> myShifts;
|
||||
final List<Shift> pendingAssignments;
|
||||
final List<Shift> cancelledShifts;
|
||||
final DateTime? initialDate;
|
||||
|
||||
/// Creates a [MyShiftsTab].
|
||||
const MyShiftsTab({
|
||||
super.key,
|
||||
required this.myShifts,
|
||||
@@ -23,6 +22,18 @@ class MyShiftsTab extends StatefulWidget {
|
||||
this.initialDate,
|
||||
});
|
||||
|
||||
/// Assigned shifts for the current week.
|
||||
final List<AssignedShift> myShifts;
|
||||
|
||||
/// Pending assignments awaiting acceptance.
|
||||
final List<PendingAssignment> pendingAssignments;
|
||||
|
||||
/// Cancelled shift assignments.
|
||||
final List<CancelledShift> cancelledShifts;
|
||||
|
||||
/// Initial date to select in the calendar.
|
||||
final DateTime? initialDate;
|
||||
|
||||
@override
|
||||
State<MyShiftsTab> createState() => _MyShiftsTabState();
|
||||
}
|
||||
@@ -31,6 +42,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
int _weekOffset = 0;
|
||||
|
||||
/// Tracks which completed-shift cards have been submitted locally.
|
||||
final Set<String> _submittedShiftIds = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -54,19 +68,19 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
void _applyInitialDate(DateTime date) {
|
||||
_selectedDate = date;
|
||||
|
||||
final now = DateTime.now();
|
||||
int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||
final DateTime now = DateTime.now();
|
||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||
|
||||
// Base Friday
|
||||
final baseStart = DateTime(
|
||||
final DateTime baseStart = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).subtract(Duration(days: daysSinceFriday));
|
||||
|
||||
final target = DateTime(date.year, date.month, date.day);
|
||||
final diff = target.difference(baseStart).inDays;
|
||||
final DateTime target = DateTime(date.year, date.month, date.day);
|
||||
final int diff = target.difference(baseStart).inDays;
|
||||
|
||||
setState(() {
|
||||
_weekOffset = (diff / 7).floor();
|
||||
@@ -77,19 +91,23 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
}
|
||||
|
||||
List<DateTime> _getCalendarDays() {
|
||||
final now = DateTime.now();
|
||||
int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||
final start = now
|
||||
final DateTime now = DateTime.now();
|
||||
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||
final DateTime start = now
|
||||
.subtract(Duration(days: daysSinceFriday))
|
||||
.add(Duration(days: _weekOffset * 7));
|
||||
final startDate = DateTime(start.year, start.month, start.day);
|
||||
return List.generate(7, (index) => startDate.add(Duration(days: index)));
|
||||
final DateTime startDate =
|
||||
DateTime(start.year, start.month, start.day);
|
||||
return List<DateTime>.generate(
|
||||
7,
|
||||
(int index) => startDate.add(Duration(days: index)),
|
||||
);
|
||||
}
|
||||
|
||||
void _loadShiftsForCurrentWeek() {
|
||||
final List<DateTime> calendarDays = _getCalendarDays();
|
||||
context.read<ShiftsBloc>().add(
|
||||
ReadContext(context).read<ShiftsBloc>().add(
|
||||
LoadShiftsForRangeEvent(
|
||||
start: calendarDays.first,
|
||||
end: calendarDays.last,
|
||||
@@ -104,10 +122,12 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
void _confirmShift(String id) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title),
|
||||
content: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message),
|
||||
actions: [
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
title:
|
||||
Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title),
|
||||
content:
|
||||
Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(context.t.common.cancel),
|
||||
@@ -115,17 +135,19 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
context.read<ShiftsBloc>().add(AcceptShiftEvent(id));
|
||||
ReadContext(context).read<ShiftsBloc>().add(AcceptShiftEvent(id));
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context.t.staff_shifts.my_shifts_tab.confirm_dialog.success,
|
||||
message: context
|
||||
.t.staff_shifts.my_shifts_tab.confirm_dialog.success,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.success,
|
||||
),
|
||||
child: Text(context.t.staff_shifts.shift_details.accept_shift),
|
||||
child:
|
||||
Text(context.t.staff_shifts.shift_details.accept_shift),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -135,12 +157,13 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
void _declineShift(String id) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title),
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
title:
|
||||
Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title),
|
||||
content: Text(
|
||||
context.t.staff_shifts.my_shifts_tab.decline_dialog.message,
|
||||
),
|
||||
actions: [
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(context.t.common.cancel),
|
||||
@@ -148,10 +171,11 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
context.read<ShiftsBloc>().add(DeclineShiftEvent(id));
|
||||
ReadContext(context).read<ShiftsBloc>().add(DeclineShiftEvent(id));
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context.t.staff_shifts.my_shifts_tab.decline_dialog.success,
|
||||
message: context
|
||||
.t.staff_shifts.my_shifts_tab.decline_dialog.success,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
},
|
||||
@@ -165,48 +189,27 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateStr(String dateStr) {
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
final now = DateTime.now();
|
||||
if (_isSameDay(date, now)) return context.t.staff_shifts.my_shifts_tab.date.today;
|
||||
final tomorrow = now.add(const Duration(days: 1));
|
||||
if (_isSameDay(date, tomorrow)) return context.t.staff_shifts.my_shifts_tab.date.tomorrow;
|
||||
return DateFormat('EEE, MMM d').format(date);
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final calendarDays = _getCalendarDays();
|
||||
final weekStartDate = calendarDays.first;
|
||||
final weekEndDate = calendarDays.last;
|
||||
final List<DateTime> calendarDays = _getCalendarDays();
|
||||
final DateTime weekStartDate = calendarDays.first;
|
||||
final DateTime weekEndDate = calendarDays.last;
|
||||
|
||||
final visibleMyShifts = widget.myShifts.where((s) {
|
||||
try {
|
||||
final date = DateTime.parse(s.date);
|
||||
return _isSameDay(date, _selectedDate);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}).toList();
|
||||
final List<AssignedShift> visibleMyShifts = widget.myShifts
|
||||
.where(
|
||||
(AssignedShift s) => _isSameDay(s.date, _selectedDate),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final visibleCancelledShifts = widget.cancelledShifts.where((s) {
|
||||
try {
|
||||
final date = DateTime.parse(s.date);
|
||||
return date.isAfter(
|
||||
weekStartDate.subtract(const Duration(seconds: 1)),
|
||||
) &&
|
||||
date.isBefore(weekEndDate.add(const Duration(days: 1)));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
final List<CancelledShift> visibleCancelledShifts =
|
||||
widget.cancelledShifts.where((CancelledShift s) {
|
||||
return s.date.isAfter(
|
||||
weekStartDate.subtract(const Duration(seconds: 1))) &&
|
||||
s.date.isBefore(weekEndDate.add(const Duration(days: 1)));
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
// Calendar Selector
|
||||
Container(
|
||||
color: UiColors.white,
|
||||
@@ -215,12 +218,12 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
horizontal: UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
@@ -259,22 +262,16 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
// Days Grid
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: calendarDays.map((date) {
|
||||
final isSelected = _isSameDay(date, _selectedDate);
|
||||
// ignore: unused_local_variable
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
||||
final hasShifts = widget.myShifts.any((s) {
|
||||
try {
|
||||
return _isSameDay(DateTime.parse(s.date), date);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
children: calendarDays.map((DateTime date) {
|
||||
final bool isSelected = _isSameDay(date, _selectedDate);
|
||||
final bool hasShifts = widget.myShifts.any(
|
||||
(AssignedShift s) => _isSameDay(s.date, date),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedDate = date),
|
||||
child: Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 44,
|
||||
height: 60,
|
||||
@@ -282,7 +279,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
color: isSelected
|
||||
? UiColors.primary
|
||||
: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? UiColors.primary
|
||||
@@ -292,7 +291,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Text(
|
||||
date.day.toString().padLeft(2, '0'),
|
||||
style: isSelected
|
||||
@@ -302,14 +301,21 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: (isSelected
|
||||
? UiTypography.footnote2m.white
|
||||
: UiTypography.footnote2m.textSecondary).copyWith(
|
||||
color: isSelected ? UiColors.white.withValues(alpha: 0.8) : null,
|
||||
? UiTypography.footnote2m.white
|
||||
: UiTypography
|
||||
.footnote2m.textSecondary)
|
||||
.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.white
|
||||
.withValues(alpha: 0.8)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (hasShifts && !isSelected)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: UiConstants.space1),
|
||||
margin: const EdgeInsets.only(
|
||||
top: UiConstants.space1,
|
||||
),
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: const BoxDecoration(
|
||||
@@ -332,44 +338,52 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
if (widget.pendingAssignments.isNotEmpty) ...[
|
||||
if (widget.pendingAssignments.isNotEmpty) ...<Widget>[
|
||||
_buildSectionHeader(
|
||||
context.t.staff_shifts.my_shifts_tab.sections.awaiting,
|
||||
context
|
||||
.t.staff_shifts.my_shifts_tab.sections.awaiting,
|
||||
UiColors.textWarning,
|
||||
),
|
||||
...widget.pendingAssignments.map(
|
||||
(shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: ShiftAssignmentCard(
|
||||
shift: shift,
|
||||
onConfirm: () => _confirmShift(shift.id),
|
||||
onDecline: () => _declineShift(shift.id),
|
||||
isConfirming: true,
|
||||
(PendingAssignment assignment) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space4,
|
||||
),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromPending(assignment),
|
||||
onTap: () => Modular.to
|
||||
.toShiftDetailsById(assignment.shiftId),
|
||||
onAccept: () =>
|
||||
_confirmShift(assignment.shiftId),
|
||||
onDecline: () =>
|
||||
_declineShift(assignment.shiftId),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
],
|
||||
|
||||
if (visibleCancelledShifts.isNotEmpty) ...[
|
||||
_buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary),
|
||||
if (visibleCancelledShifts.isNotEmpty) ...<Widget>[
|
||||
_buildSectionHeader(
|
||||
context
|
||||
.t.staff_shifts.my_shifts_tab.sections.cancelled,
|
||||
UiColors.textSecondary,
|
||||
),
|
||||
...visibleCancelledShifts.map(
|
||||
(shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: _buildCancelledCard(
|
||||
title: shift.title,
|
||||
client: shift.clientName,
|
||||
pay: "\$${(shift.hourlyRate * 8).toStringAsFixed(0)}",
|
||||
rate: "\$${shift.hourlyRate}/hr · 8h",
|
||||
date: _formatDateStr(shift.date),
|
||||
time: "${shift.startTime} - ${shift.endTime}",
|
||||
address: shift.locationAddress,
|
||||
isLastMinute: true,
|
||||
onTap: () {},
|
||||
(CancelledShift cs) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space4,
|
||||
),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromCancelled(cs),
|
||||
onTap: () =>
|
||||
Modular.to.toShiftDetailsById(cs.shiftId),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -377,23 +391,43 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
],
|
||||
|
||||
// Confirmed Shifts
|
||||
if (visibleMyShifts.isNotEmpty) ...[
|
||||
_buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary),
|
||||
if (visibleMyShifts.isNotEmpty) ...<Widget>[
|
||||
_buildSectionHeader(
|
||||
context
|
||||
.t.staff_shifts.my_shifts_tab.sections.confirmed,
|
||||
UiColors.textSecondary,
|
||||
),
|
||||
...visibleMyShifts.map(
|
||||
(shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: MyShiftCard(
|
||||
shift: shift,
|
||||
onDecline: () => _declineShift(shift.id),
|
||||
onRequestSwap: () {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context.t.staff_shifts.my_shifts_tab.swap_coming_soon,
|
||||
type: UiSnackbarType.message,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
(AssignedShift shift) {
|
||||
final bool isCompleted =
|
||||
shift.status == AssignmentStatus.completed;
|
||||
final bool isSubmitted =
|
||||
_submittedShiftIds.contains(shift.shiftId);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space3,
|
||||
),
|
||||
child: ShiftCard(
|
||||
data: ShiftCardData.fromAssigned(shift),
|
||||
onTap: () => Modular.to
|
||||
.toShiftDetailsById(shift.shiftId),
|
||||
showApprovalAction: isCompleted,
|
||||
isSubmitted: isSubmitted,
|
||||
onSubmitForApproval: () {
|
||||
setState(() {
|
||||
_submittedShiftIds.add(shift.shiftId);
|
||||
});
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: context.t.staff_shifts
|
||||
.my_shift_card.timesheet_submitted,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -402,8 +436,10 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
widget.cancelledShifts.isEmpty)
|
||||
EmptyStateView(
|
||||
icon: UiIcons.calendar,
|
||||
title: context.t.staff_shifts.my_shifts_tab.empty.title,
|
||||
subtitle: context.t.staff_shifts.my_shifts_tab.empty.subtitle,
|
||||
title:
|
||||
context.t.staff_shifts.my_shifts_tab.empty.title,
|
||||
subtitle: context
|
||||
.t.staff_shifts.my_shifts_tab.empty.subtitle,
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space32),
|
||||
@@ -419,11 +455,14 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
@@ -436,163 +475,4 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCancelledCard({
|
||||
required String title,
|
||||
required String client,
|
||||
required String pay,
|
||||
required String rate,
|
||||
required String date,
|
||||
required String time,
|
||||
required String address,
|
||||
required bool isLastMinute,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase + 4),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.destructive,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.t.staff_shifts.my_shifts_tab.card.cancelled,
|
||||
style: UiTypography.footnote2b.textError,
|
||||
),
|
||||
if (isLastMinute) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.t.staff_shifts.my_shifts_tab.card.compensation,
|
||||
style: UiTypography.footnote2m.textSuccess,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
client,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
pay,
|
||||
style: UiTypography.headline4m.textPrimary,
|
||||
),
|
||||
Text(
|
||||
rate,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: 12,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
date,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: 12,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
time,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 12,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
address,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +26,9 @@ dependencies:
|
||||
flutter_bloc: ^8.1.3
|
||||
equatable: ^2.0.5
|
||||
intl: ^0.20.2
|
||||
google_maps_flutter: ^2.14.2
|
||||
url_launcher: ^6.3.1
|
||||
firebase_auth: ^6.1.4
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
meta: ^1.17.0
|
||||
bloc: ^8.1.4
|
||||
meta: ^1.17.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user