feat: add ShiftAssignmentCard widget and StaffShifts module
- Implemented ShiftAssignmentCard widget for displaying shift assignments with client details, pay calculation, and confirmation actions. - Created StaffShiftsModule to manage dependencies, routes, and use cases related to staff shifts. - Added necessary dependencies in pubspec.yaml and generated pubspec.lock.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/shifts_repository_interface.dart';
|
||||
|
||||
/// Implementation of [ShiftsRepositoryInterface] that delegates to [ShiftsRepositoryMock].
|
||||
///
|
||||
/// This class resides in the data layer and handles the communication with
|
||||
/// the external data sources (currently mocks).
|
||||
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
final ShiftsRepositoryMock _mock;
|
||||
|
||||
ShiftsRepositoryImpl({ShiftsRepositoryMock? mock}) : _mock = mock ?? ShiftsRepositoryMock();
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getMyShifts() async {
|
||||
return _mock.getMyShifts();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getAvailableShifts(String query, String type) async {
|
||||
// Delegates to mock. Logic kept here temporarily as per architecture constraints
|
||||
// on data_connect modifications, mimicking a query capable datasource.
|
||||
var shifts = await _mock.getAvailableShifts();
|
||||
|
||||
// Simple in-memory filtering for mock adapter
|
||||
if (query.isNotEmpty) {
|
||||
shifts = shifts.where((s) =>
|
||||
s.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
s.clientName.toLowerCase().contains(query.toLowerCase())
|
||||
).toList();
|
||||
}
|
||||
|
||||
if (type != 'all') {
|
||||
if (type == 'one-day') {
|
||||
shifts = shifts.where((s) => !s.title.contains('Multi-Day') && !s.title.contains('Long Term')).toList();
|
||||
} else if (type == 'multi-day') {
|
||||
shifts = shifts.where((s) => s.title.contains('Multi-Day')).toList();
|
||||
} else if (type == 'long-term') {
|
||||
shifts = shifts.where((s) => s.title.contains('Long Term')).toList();
|
||||
}
|
||||
}
|
||||
|
||||
return shifts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getPendingAssignments() async {
|
||||
return _mock.getPendingAssignments();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Shift?> getShiftDetails(String shiftId) async {
|
||||
return _mock.getShiftDetails(shiftId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyForShift(String shiftId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> acceptShift(String shiftId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> declineShift(String shiftId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [query, type];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Interface for the Shifts Repository.
|
||||
///
|
||||
/// Defines the contract for accessing and modifying shift-related data.
|
||||
/// Implementations of this interface should reside in the data layer.
|
||||
abstract interface class ShiftsRepositoryInterface {
|
||||
/// Retrieves the list of shifts assigned to the current user.
|
||||
Future<List<Shift>> getMyShifts();
|
||||
|
||||
/// Retrieves available shifts matching the given [query] and [type].
|
||||
Future<List<Shift>> getAvailableShifts(String query, String type);
|
||||
|
||||
/// Retrieves shifts that are pending acceptance by the user.
|
||||
Future<List<Shift>> getPendingAssignments();
|
||||
|
||||
/// Retrieves detailed information for a specific shift by [shiftId].
|
||||
Future<Shift?> getShiftDetails(String shiftId);
|
||||
|
||||
/// Applies for a specific open shift.
|
||||
Future<void> applyForShift(String shiftId);
|
||||
|
||||
/// Accepts a pending shift assignment.
|
||||
Future<void> acceptShift(String shiftId);
|
||||
|
||||
/// Declines a pending shift assignment.
|
||||
Future<void> declineShift(String shiftId);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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>> {
|
||||
final ShiftsRepositoryInterface repository;
|
||||
|
||||
GetAvailableShiftsUseCase(this.repository);
|
||||
|
||||
@override
|
||||
Future<List<Shift>> call(GetAvailableShiftsArguments arguments) async {
|
||||
return repository.getAvailableShifts(arguments.query, arguments.type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.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 NoInputUseCase<List<Shift>> {
|
||||
final ShiftsRepositoryInterface repository;
|
||||
|
||||
GetMyShiftsUseCase(this.repository);
|
||||
|
||||
@override
|
||||
Future<List<Shift>> call() async {
|
||||
return repository.getMyShifts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
|
||||
GetPendingAssignmentsUseCase(this.repository);
|
||||
|
||||
@override
|
||||
Future<List<Shift>> call() async {
|
||||
return repository.getPendingAssignments();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
import '../../../domain/usecases/get_available_shifts_usecase.dart';
|
||||
import '../../../domain/arguments/get_available_shifts_arguments.dart';
|
||||
import '../../../domain/usecases/get_my_shifts_usecase.dart';
|
||||
import '../../../domain/usecases/get_pending_assignments_usecase.dart';
|
||||
|
||||
part 'shifts_event.dart';
|
||||
part 'shifts_state.dart';
|
||||
|
||||
class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
|
||||
final GetMyShiftsUseCase getMyShifts;
|
||||
final GetAvailableShiftsUseCase getAvailableShifts;
|
||||
final GetPendingAssignmentsUseCase getPendingAssignments;
|
||||
|
||||
ShiftsBloc({
|
||||
required this.getMyShifts,
|
||||
required this.getAvailableShifts,
|
||||
required this.getPendingAssignments,
|
||||
}) : super(ShiftsInitial()) {
|
||||
on<LoadShiftsEvent>(_onLoadShifts);
|
||||
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
|
||||
}
|
||||
|
||||
Future<void> _onLoadShifts(
|
||||
LoadShiftsEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
if (state is! ShiftsLoaded) {
|
||||
emit(ShiftsLoading());
|
||||
}
|
||||
|
||||
// Determine what to load based on current tab?
|
||||
// Or load all for simplicity as per prototype logic which had them all in memory.
|
||||
|
||||
try {
|
||||
final myShiftsResult = await getMyShifts();
|
||||
final pendingResult = await getPendingAssignments();
|
||||
|
||||
// Initial available with defaults
|
||||
final availableResult = await getAvailableShifts(const GetAvailableShiftsArguments());
|
||||
|
||||
emit(ShiftsLoaded(
|
||||
myShifts: myShiftsResult,
|
||||
pendingShifts: pendingResult,
|
||||
availableShifts: availableResult,
|
||||
searchQuery: '',
|
||||
jobType: 'all',
|
||||
));
|
||||
} catch (_) {
|
||||
emit(const ShiftsError('Failed to load shifts'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onFilterAvailableShifts(
|
||||
FilterAvailableShiftsEvent event,
|
||||
Emitter<ShiftsState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is ShiftsLoaded) {
|
||||
// Optimistic update or loading indicator?
|
||||
// Since it's filtering, we can just reload available.
|
||||
|
||||
try {
|
||||
final result = await getAvailableShifts(GetAvailableShiftsArguments(
|
||||
query: event.query ?? currentState.searchQuery,
|
||||
type: event.jobType ?? currentState.jobType,
|
||||
));
|
||||
|
||||
emit(currentState.copyWith(
|
||||
availableShifts: result,
|
||||
searchQuery: event.query ?? currentState.searchQuery,
|
||||
jobType: event.jobType ?? currentState.jobType,
|
||||
));
|
||||
} catch (_) {
|
||||
// Error handling if filter fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
part of 'shifts_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class ShiftsEvent extends Equatable {
|
||||
const ShiftsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class LoadShiftsEvent extends ShiftsEvent {}
|
||||
|
||||
class FilterAvailableShiftsEvent extends ShiftsEvent {
|
||||
final String? query;
|
||||
final String? jobType;
|
||||
|
||||
const FilterAvailableShiftsEvent({this.query, this.jobType});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [query, jobType];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
part of 'shifts_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class ShiftsState extends Equatable {
|
||||
const ShiftsState();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class ShiftsInitial extends ShiftsState {}
|
||||
|
||||
class ShiftsLoading extends ShiftsState {}
|
||||
|
||||
class ShiftsLoaded extends ShiftsState {
|
||||
final List<Shift> myShifts;
|
||||
final List<Shift> pendingShifts;
|
||||
final List<Shift> availableShifts;
|
||||
final String searchQuery;
|
||||
final String jobType;
|
||||
|
||||
const ShiftsLoaded({
|
||||
required this.myShifts,
|
||||
required this.pendingShifts,
|
||||
required this.availableShifts,
|
||||
required this.searchQuery,
|
||||
required this.jobType,
|
||||
});
|
||||
|
||||
ShiftsLoaded copyWith({
|
||||
List<Shift>? myShifts,
|
||||
List<Shift>? pendingShifts,
|
||||
List<Shift>? availableShifts,
|
||||
String? searchQuery,
|
||||
String? jobType,
|
||||
}) {
|
||||
return ShiftsLoaded(
|
||||
myShifts: myShifts ?? this.myShifts,
|
||||
pendingShifts: pendingShifts ?? this.pendingShifts,
|
||||
availableShifts: availableShifts ?? this.availableShifts,
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
jobType: jobType ?? this.jobType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => [myShifts, pendingShifts, availableShifts, searchQuery, jobType];
|
||||
}
|
||||
|
||||
class ShiftsError extends ShiftsState {
|
||||
final String message;
|
||||
|
||||
const ShiftsError(this.message);
|
||||
|
||||
@override
|
||||
List<Object> get props => [message];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
extension ShiftsNavigator on IModularNavigator {
|
||||
void pushShiftDetails(Shift shift) {
|
||||
pushNamed('/shifts/details/${shift.id}', arguments: shift);
|
||||
}
|
||||
|
||||
// Example for going back or internal navigation if needed
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
// Shim to match POC styles locally
|
||||
class AppColors {
|
||||
static const Color krowBlue = UiColors.primary;
|
||||
static const Color krowYellow = Color(0xFFFFED4A);
|
||||
static const Color krowCharcoal = UiColors.textPrimary; // 121826
|
||||
static const Color krowMuted = UiColors.textSecondary; // 6A7382
|
||||
static const Color krowBorder = UiColors.border; // E3E6E9
|
||||
static const Color krowBackground = UiColors.background; // FAFBFC
|
||||
static const Color white = Colors.white;
|
||||
}
|
||||
|
||||
class ShiftDetailsPage extends StatefulWidget {
|
||||
final String shiftId;
|
||||
final Shift? shift;
|
||||
|
||||
const ShiftDetailsPage({super.key, required this.shiftId, this.shift});
|
||||
|
||||
@override
|
||||
State<ShiftDetailsPage> createState() => _ShiftDetailsPageState();
|
||||
}
|
||||
|
||||
class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
late Shift _shift;
|
||||
bool _isLoading = true;
|
||||
bool _showDetails = true;
|
||||
bool _isApplying = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadShift();
|
||||
}
|
||||
|
||||
void _loadShift() async {
|
||||
if (widget.shift != null) {
|
||||
_shift = widget.shift!;
|
||||
setState(() => _isLoading = false);
|
||||
} else {
|
||||
// Simulate fetch or logic to handle missing data
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (mounted) {
|
||||
// Mock data from POC if needed, but assuming shift is always passed in this context
|
||||
// based on ShiftsPage navigation.
|
||||
// If generic fetch needed, we would use a Repo/Bloc here.
|
||||
// For now, stop loading.
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double _calculateHours(String start, String end) {
|
||||
try {
|
||||
final startParts = start.split(':').map(int.parse).toList();
|
||||
final endParts = end.split(':').map(int.parse).toList();
|
||||
double h =
|
||||
(endParts[0] - startParts[0]) + (endParts[1] - startParts[1]) / 60;
|
||||
if (h < 0) h += 24;
|
||||
return h;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTag(IconData icon, String label, Color bg, Color activeColor) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: activeColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: activeColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Scaffold(
|
||||
backgroundColor: AppColors.krowBackground,
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final hours = _calculateHours(_shift.startTime, _shift.endTime);
|
||||
final totalPay = _shift.hourlyRate * hours;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.krowBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.chevronLeft, color: AppColors.krowMuted),
|
||||
onPressed: () => Modular.to.pop(),
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: Container(color: AppColors.krowBorder, height: 1.0),
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 120),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Pending Badge (Mock logic)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.krowYellow.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
'Pending 6h ago',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.krowCharcoal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Header
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.krowBorder),
|
||||
),
|
||||
child: _shift.logoUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
_shift.logoUrl!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
_shift.clientName.isNotEmpty ? _shift.clientName[0] : 'K',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.krowBlue,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_shift.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.krowCharcoal,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'\$${_shift.hourlyRate.toStringAsFixed(0)}/h',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.krowCharcoal,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'(exp.total \$${totalPay.toStringAsFixed(0)})',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.krowMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
_shift.clientName,
|
||||
style: const TextStyle(color: AppColors.krowMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tags
|
||||
Row(
|
||||
children: [
|
||||
_buildTag(
|
||||
UiIcons.zap,
|
||||
'Immediate start',
|
||||
AppColors.krowBlue.withOpacity(0.1),
|
||||
AppColors.krowBlue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildTag(
|
||||
UiIcons.star,
|
||||
'No experience',
|
||||
AppColors.krowYellow.withOpacity(0.3),
|
||||
AppColors.krowCharcoal,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Additional Details
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.krowBorder),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
setState(() => _showDetails = !_showDetails),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'ADDITIONAL DETAILS',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
color: AppColors.krowMuted,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_showDetails
|
||||
? UiIcons.chevronUp
|
||||
: UiIcons.chevronDown,
|
||||
color: AppColors.krowMuted,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showDetails && _shift.description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(height: 1, color: AppColors.krowBorder),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_shift.description!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.krowCharcoal,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Action Button
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: AppColors.krowBorder)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isApplying ? null : () {
|
||||
setState(() {
|
||||
_isApplying = true;
|
||||
});
|
||||
// Simulate Apply
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
setState(() => _isApplying = false);
|
||||
Modular.to.pop();
|
||||
}
|
||||
});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.krowBlue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isApplying
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)
|
||||
)
|
||||
: const Text(
|
||||
'Apply Now',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/shifts/shifts_bloc.dart';
|
||||
import '../widgets/my_shift_card.dart';
|
||||
import '../widgets/shift_assignment_card.dart';
|
||||
|
||||
// Shim to match POC styles locally
|
||||
class AppColors {
|
||||
static const Color krowBlue = UiColors.primary;
|
||||
static const Color krowYellow = Color(0xFFFFED4A);
|
||||
static const Color krowCharcoal = UiColors.textPrimary;
|
||||
static const Color krowMuted = UiColors.textSecondary;
|
||||
static const Color krowBorder = UiColors.border;
|
||||
static const Color krowBackground = UiColors.background;
|
||||
static const Color white = Colors.white;
|
||||
static const Color black = Colors.black;
|
||||
}
|
||||
|
||||
class ShiftsPage extends StatefulWidget {
|
||||
final String? initialTab;
|
||||
const ShiftsPage({super.key, this.initialTab});
|
||||
|
||||
@override
|
||||
State<ShiftsPage> createState() => _ShiftsPageState();
|
||||
}
|
||||
|
||||
class _ShiftsPageState extends State<ShiftsPage> {
|
||||
late String _activeTab;
|
||||
String _searchQuery = '';
|
||||
// ignore: unused_field
|
||||
String? _cancelledShiftDemo; // 'lastMinute' or 'advance'
|
||||
String _jobType = 'all'; // all, one-day, multi-day, long-term
|
||||
|
||||
// Calendar State
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
int _weekOffset = 0;
|
||||
|
||||
final ShiftsBloc _bloc = Modular.get<ShiftsBloc>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeTab = widget.initialTab ?? 'myshifts';
|
||||
_bloc.add(LoadShiftsEvent());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ShiftsPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialTab != null && widget.initialTab != _activeTab) {
|
||||
setState(() {
|
||||
_activeTab = widget.initialTab!;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<DateTime> _getCalendarDays() {
|
||||
final now = DateTime.now();
|
||||
int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||
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)));
|
||||
}
|
||||
|
||||
bool _isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
void _confirmShift(String id) {
|
||||
// TODO: Implement Bloc event
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Shift confirmed! (Placeholder)')),
|
||||
);
|
||||
}
|
||||
|
||||
void _declineShift(String id) {
|
||||
// TODO: Implement Bloc event
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Shift declined. (Placeholder)')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: BlocBuilder<ShiftsBloc, ShiftsState>(
|
||||
builder: (context, state) {
|
||||
final List<Shift> myShifts = (state is ShiftsLoaded) ? state.myShifts : [];
|
||||
final List<Shift> availableJobs = (state is ShiftsLoaded) ? state.availableShifts : [];
|
||||
final List<Shift> pendingAssignments = (state is ShiftsLoaded) ? state.pendingShifts : [];
|
||||
final List<Shift> historyShifts = []; // Not in state yet, placeholder
|
||||
|
||||
// Filter logic from POC
|
||||
final filteredJobs = availableJobs.where((s) {
|
||||
final matchesSearch =
|
||||
s.title.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
s.location.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
s.clientName.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
if (_jobType == 'all') return true;
|
||||
if (_jobType == 'one-day') {
|
||||
return !s.title.contains('Long Term') && !s.title.contains('Multi-Day');
|
||||
}
|
||||
if (_jobType == 'multi-day') return s.title.contains('Multi-Day');
|
||||
if (_jobType == 'long-term') return s.title.contains('Long Term');
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final calendarDays = _getCalendarDays();
|
||||
final weekStartDate = calendarDays.first;
|
||||
final weekEndDate = calendarDays.last;
|
||||
|
||||
final visibleMyShifts = myShifts.where((s) {
|
||||
// Primitive check if shift date string compare
|
||||
// In real app use DateTime logic
|
||||
final sDateStr = s.date;
|
||||
final wStartStr = DateFormat('yyyy-MM-dd').format(weekStartDate);
|
||||
final wEndStr = DateFormat('yyyy-MM-dd').format(weekEndDate);
|
||||
return sDateStr.compareTo(wStartStr) >= 0 &&
|
||||
sDateStr.compareTo(wEndStr) <= 0;
|
||||
}).toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.krowBackground,
|
||||
body: Column(
|
||||
children: [
|
||||
// Header (Blue)
|
||||
Container(
|
||||
color: AppColors.krowBlue,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
MediaQuery.of(context).padding.top + 20,
|
||||
20,
|
||||
24,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Shifts",
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_buildDemoButton("Demo: Cancel <4hr", const Color(0xFFEF4444), () {
|
||||
setState(() => _cancelledShiftDemo = 'lastMinute');
|
||||
_showCancelledModal('lastMinute');
|
||||
}),
|
||||
const SizedBox(width: 8),
|
||||
_buildDemoButton("Demo: Cancel >4hr", const Color(0xFFF59E0B), () {
|
||||
setState(() => _cancelledShiftDemo = 'advance');
|
||||
_showCancelledModal('advance');
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Tabs
|
||||
Row(
|
||||
children: [
|
||||
_buildTab("myshifts", "My Shifts", LucideIcons.calendar, myShifts.length),
|
||||
const SizedBox(width: 8),
|
||||
_buildTab("find", "Find Shifts", LucideIcons.search, filteredJobs.length),
|
||||
const SizedBox(width: 8),
|
||||
_buildTab("history", "History", LucideIcons.clock, historyShifts.length),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Calendar Selector
|
||||
if (_activeTab == 'myshifts')
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _weekOffset--),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Icon(LucideIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('MMMM yyyy').format(weekStartDate),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.krowCharcoal,
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => setState(() => _weekOffset++),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Icon(LucideIcons.chevronRight, size: 20, color: AppColors.krowCharcoal),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Days Grid
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: calendarDays.map((date) {
|
||||
final isSelected = _isSameDay(date, _selectedDate);
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
||||
final hasShifts = myShifts.any((s) => s.date == dateStr);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedDate = date),
|
||||
child: Container(
|
||||
width: 44,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.krowBlue : Colors.white,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.krowBlue : AppColors.krowBorder,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
date.day.toString().padLeft(2, '0'),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected ? Colors.white : AppColors.krowCharcoal,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.krowMuted,
|
||||
),
|
||||
),
|
||||
if (hasShifts)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.white : AppColors.krowBlue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_activeTab == 'myshifts')
|
||||
const Divider(height: 1, color: AppColors.krowBorder),
|
||||
|
||||
// Body Content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_activeTab == 'find') ...[
|
||||
// Search & Filter
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.krowBorder),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextField(
|
||||
onChanged: (val) => setState(() => _searchQuery = val), // Local filter for now
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(LucideIcons.search, size: 20, color: AppColors.krowMuted),
|
||||
border: InputBorder.none,
|
||||
hintText: "Search jobs...",
|
||||
hintStyle: TextStyle(color: AppColors.krowMuted, fontSize: 14),
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F3F5),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterTab('all', 'All Jobs'),
|
||||
_buildFilterTab('one-day', 'One Day'),
|
||||
_buildFilterTab('multi-day', 'Multi-Day'),
|
||||
_buildFilterTab('long-term', 'Long Term'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (_activeTab == 'myshifts') ...[
|
||||
if (pendingAssignments.isNotEmpty) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(width: 8, height: 8, decoration: const BoxDecoration(color: Color(0xFFF59E0B), shape: BoxShape.circle)),
|
||||
const SizedBox(width: 8),
|
||||
const Text("Awaiting Confirmation", style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFFD97706)
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
...pendingAssignments.map((shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: ShiftAssignmentCard(
|
||||
shift: shift,
|
||||
onConfirm: () => _confirmShift(shift.id),
|
||||
onDecline: () => _declineShift(shift.id),
|
||||
),
|
||||
)),
|
||||
],
|
||||
|
||||
// Cancelled Shifts Demo (Visual only as per POC)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: const Text("Cancelled Shifts", style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowMuted
|
||||
)),
|
||||
),
|
||||
),
|
||||
_buildCancelledCard(
|
||||
title: "Annual Tech Conference", client: "TechCorp Inc.", pay: "\$200", rate: "\$25/hr · 8h",
|
||||
date: "Today", time: "10:00 AM - 6:00 PM", address: "123 Convention Center Dr", isLastMinute: true,
|
||||
onTap: () => setState(() => _cancelledShiftDemo = 'lastMinute')
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCancelledCard(
|
||||
title: "Morning Catering Setup", client: "EventPro Services", pay: "\$120", rate: "\$20/hr · 6h",
|
||||
date: "Tomorrow", time: "8:00 AM - 2:00 PM", address: "456 Grand Ballroom Ave", isLastMinute: false,
|
||||
onTap: () => setState(() => _cancelledShiftDemo = 'advance')
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Confirmed Shifts
|
||||
if (visibleMyShifts.isNotEmpty) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: const Text("Confirmed Shifts", style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowMuted
|
||||
)),
|
||||
),
|
||||
),
|
||||
...visibleMyShifts.map((shift) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: MyShiftCard(shift: shift),
|
||||
)),
|
||||
],
|
||||
],
|
||||
|
||||
if (_activeTab == 'find') ...[
|
||||
if (filteredJobs.isEmpty)
|
||||
_buildEmptyState(LucideIcons.search, "No jobs available", "Check back later", null, null)
|
||||
else
|
||||
...filteredJobs.map((shift) => GestureDetector(
|
||||
onTap: () => Modular.to.pushNamed('details/${shift.id}', arguments: shift),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: MyShiftCard(shift: shift),
|
||||
),
|
||||
)),
|
||||
],
|
||||
|
||||
if (_activeTab == 'history')
|
||||
_buildEmptyState(LucideIcons.clock, "No shift history", "Completed shifts appear here", null, null),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterTab(String id, String label) {
|
||||
final isSelected = _jobType == id;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _jobType = id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.krowBlue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
boxShadow: isSelected ? [BoxShadow(color: AppColors.krowBlue.withOpacity(0.2), blurRadius: 4, offset: const Offset(0, 2))] : null,
|
||||
),
|
||||
child: Text(label, textAlign: TextAlign.center, style: TextStyle(
|
||||
fontSize: 11, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : AppColors.krowMuted
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTab(String id, String label, IconData icon, int count) {
|
||||
final isActive = _activeTab == id;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _activeTab = id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.white : Colors.white.withAlpha((0.2 * 255).round()),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: isActive ? AppColors.krowBlue : Colors.white),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(child: Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: isActive ? AppColors.krowBlue : Colors.white), overflow: TextOverflow.ellipsis)),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? AppColors.krowBlue.withAlpha((0.1 * 255).round()) : Colors.white.withAlpha((0.2 * 255).round()),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Center(child: Text("$count", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: isActive ? AppColors.krowBlue : Colors.white))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDemoButton(String label, Color color, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(IconData icon, String title, String subtitle, String? actionLabel, VoidCallback? onAction) {
|
||||
return Center(child: Padding(padding: const EdgeInsets.symmetric(vertical: 64), child: Column(children: [
|
||||
Container(width: 64, height: 64, decoration: BoxDecoration(color: const Color(0xFFF1F3F5), borderRadius: BorderRadius.circular(12)), child: Icon(icon, size: 32, color: AppColors.krowMuted)),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.krowCharcoal)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: const TextStyle(fontSize: 14, color: AppColors.krowMuted)),
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(onPressed: onAction, style: ElevatedButton.styleFrom(backgroundColor: AppColors.krowBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), child: Text(actionLabel)),
|
||||
]
|
||||
])));
|
||||
}
|
||||
|
||||
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(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.krowBorder)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFEF4444), shape: BoxShape.circle)), const SizedBox(width: 6), const Text("CANCELLED", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFFEF4444))), if (isLastMinute) ...[const SizedBox(width: 4), const Text("• 4hr compensation", style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFF10B981)))]]),
|
||||
const SizedBox(height: 12),
|
||||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Container(width: 44, height: 44, decoration: BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.krowBlue.withAlpha((0.15 * 255).round()), AppColors.krowBlue.withAlpha((0.08 * 255).round())]), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.krowBlue.withAlpha((0.15 * 255).round()))), child: const Center(child: Icon(LucideIcons.briefcase, color: AppColors.krowBlue, size: 20))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowCharcoal)), Text(client, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text(pay, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.krowCharcoal)), Text(rate, style: const TextStyle(fontSize: 10, color: AppColors.krowMuted))])]),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [const Icon(LucideIcons.calendar, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Text(date, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)), const SizedBox(width: 12), const Icon(LucideIcons.clock, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Text(time, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))]),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [const Icon(LucideIcons.mapPin, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Expanded(child: Text(address, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted), overflow: TextOverflow.ellipsis))]),
|
||||
])),
|
||||
]),
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCancelledModal(String type) {
|
||||
final isLastMinute = type == 'lastMinute';
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.xCircle, color: Color(0xFFEF4444)),
|
||||
const SizedBox(width: 8),
|
||||
const Text("Shift Cancelled"),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"We're sorry, but the following shift has been cancelled by the client:",
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Annual Tech Conference", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text("Today, 10:00 AM - 6:00 PM"),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isLastMinute)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFECFDF5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFF10B981)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(LucideIcons.checkCircle, color: Color(0xFF10B981), size: 16),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"You are eligible for 4hr cancellation compensation.",
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Color(0xFF065F46), fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
const Text(
|
||||
"Reduced schedule at the venue. No compensation is due as this was cancelled more than 4 hours in advance.",
|
||||
style: TextStyle(fontSize: 12, color: AppColors.krowMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
|
||||
class MyShiftCard extends StatefulWidget {
|
||||
final Shift shift;
|
||||
final bool historyMode;
|
||||
final VoidCallback? onAccept;
|
||||
final VoidCallback? onDecline;
|
||||
final VoidCallback? onRequestSwap;
|
||||
final int index;
|
||||
|
||||
const MyShiftCard({
|
||||
super.key,
|
||||
required this.shift,
|
||||
this.historyMode = false,
|
||||
this.onAccept,
|
||||
this.onDecline,
|
||||
this.onRequestSwap,
|
||||
this.index = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MyShiftCard> createState() => _MyShiftCardState();
|
||||
}
|
||||
|
||||
class _MyShiftCardState extends State<MyShiftCard> {
|
||||
bool _isExpanded = 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 _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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
String _getShiftType() {
|
||||
// Check title for type indicators (for mock data)
|
||||
if (widget.shift.title.contains('Long Term')) return t.staff_shifts.filter.long_term;
|
||||
if (widget.shift.title.contains('Multi-Day')) return t.staff_shifts.filter.multi_day;
|
||||
return t.staff_shifts.filter.one_day;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ignore: unused_local_variable
|
||||
final duration = _calculateDuration();
|
||||
|
||||
// Status Logic
|
||||
String? status = widget.shift.status;
|
||||
Color statusColor = UiColors.primary;
|
||||
Color statusBg = UiColors.primary;
|
||||
String statusText = '';
|
||||
IconData? statusIcon;
|
||||
|
||||
if (status == 'confirmed') {
|
||||
statusText = t.staff_shifts.status.confirmed;
|
||||
statusColor = UiColors.textLink;
|
||||
statusBg = UiColors.primary;
|
||||
} 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;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Collapsed Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Status Badge
|
||||
if (statusText.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (statusIcon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
statusIcon,
|
||||
size: 12,
|
||||
color: statusColor,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
statusText,
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: statusColor,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
// Shift Type Badge for available/pending shifts
|
||||
if (status == 'open' || status == 'pending') ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_getShiftType(),
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: UiColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Main Content
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Date/Time Column
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDate(widget.shift.date),
|
||||
style: UiTypography.display2m.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (widget.shift.durationDays != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
t.staff_shifts.details.days(days: widget.shift.durationDays!),
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: UiColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}',
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.shift.title,
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 12,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.shift.clientName,
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Logo Box
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: widget.shift.logoUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
widget.shift.logoUrl!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
widget.shift.clientName.isNotEmpty
|
||||
? widget.shift.clientName[0]
|
||||
: 'K',
|
||||
style: UiTypography.title1m.textLink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Expanded Actions
|
||||
AnimatedCrossFade(
|
||||
firstChild: const SizedBox(height: 0),
|
||||
secondChild: Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: UiColors.border),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Warning for Pending
|
||||
if (status == 'pending' || status == 'open')
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 16,
|
||||
),
|
||||
color: UiColors.accent.withOpacity(0.1),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.warning,
|
||||
size: 14,
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.staff_shifts.status.pending_warning,
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: UiColors.textWarning,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (status == 'pending' || status == 'open') ...[
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onDecline,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: const BorderSide(color: UiColors.border),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(t.staff_shifts.action.decline),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: widget.onAccept,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(t.staff_shifts.action.confirm),
|
||||
),
|
||||
),
|
||||
] else if (status == 'confirmed') ...[
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: widget.onRequestSwap,
|
||||
icon: const Icon(UiIcons.swap, size: 16),
|
||||
label: Text(t.staff_shifts.action.request_swap),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.textPrimary,
|
||||
side: const BorderSide(color: UiColors.border),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
crossFadeState: _isExpanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
|
||||
class ShiftAssignmentCard extends StatelessWidget {
|
||||
final Shift shift;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onDecline;
|
||||
final bool isConfirming;
|
||||
|
||||
const ShiftAssignmentCard({
|
||||
super.key,
|
||||
required this.shift,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hours = _calculateHours(shift.startTime, shift.endTime);
|
||||
final totalPay = shift.hourlyRate * hours;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.secondary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
shift.clientName.isNotEmpty
|
||||
? shift.clientName[0]
|
||||
: 'K',
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
shift.title,
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
shift.clientName,
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
"\$${totalPay.toStringAsFixed(0)}",
|
||||
style: UiTypography.display2m.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$${shift.hourlyRate}/hr · ${hours}h",
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Details
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: 14,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_formatDate(shift.date),
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: 14,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 14,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
shift.location,
|
||||
style: UiTypography.display3r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isConfirming) ...[
|
||||
const Divider(height: 1),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: onDecline,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: Text(t.staff_shifts.action.decline),
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 48, color: UiColors.border),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: onConfirm,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.primary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: Text(t.staff_shifts.action.confirm),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_modular/flutter_modular.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 'presentation/blocs/shifts/shifts_bloc.dart';
|
||||
import 'presentation/pages/shifts_page.dart';
|
||||
import 'presentation/pages/shift_details_page.dart';
|
||||
|
||||
class StaffShiftsModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.add<ShiftsRepositoryInterface>(ShiftsRepositoryImpl.new);
|
||||
|
||||
// UseCases
|
||||
i.add(GetMyShiftsUseCase.new);
|
||||
i.add(GetAvailableShiftsUseCase.new);
|
||||
i.add(GetPendingAssignmentsUseCase.new);
|
||||
|
||||
// Bloc
|
||||
i.add(ShiftsBloc.new);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child('/', child: (_) => const ShiftsPage());
|
||||
r.child('/details/:id', child: (_) => ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
library staff_shifts;
|
||||
|
||||
export 'src/staff_shifts_module.dart';
|
||||
|
||||
Reference in New Issue
Block a user