Merge pull request #341 from Oloodi/Issues-on-payments-timecard-availability-screens-01-02-03-04

fix: Clock-In business logic, and refines the Shifts UI for proper Data Connect integration
This commit is contained in:
Achintha Isuru
2026-01-31 12:33:24 -05:00
committed by GitHub
25 changed files with 1857 additions and 813 deletions

View File

@@ -1,17 +1,10 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:staff_authentication/staff_authentication.dart'; import 'package:design_system/design_system.dart';
/// A widget that displays the primary action buttons (Sign Up and Log In)
/// for the Get Started page.
class GetStartedActions extends StatelessWidget { class GetStartedActions extends StatelessWidget {
/// Void callback for when the Sign Up button is pressed.
final VoidCallback onSignUpPressed; final VoidCallback onSignUpPressed;
/// Void callback for when the Log In button is pressed.
final VoidCallback onLoginPressed; final VoidCallback onLoginPressed;
/// Creates a [GetStartedActions].
const GetStartedActions({ const GetStartedActions({
super.key, super.key,
required this.onSignUpPressed, required this.onSignUpPressed,
@@ -22,19 +15,37 @@ class GetStartedActions extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[ children: [
// Sign Up Button ElevatedButton(
UiButton.primary(
text: t.staff_authentication.get_started_page.sign_up_button,
onPressed: onSignUpPressed, onPressed: onSignUpPressed,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Create Account',
style: UiTypography.buttonL.copyWith(color: Colors.white),
),
), ),
const SizedBox(height: 16),
const SizedBox(height: 12), OutlinedButton(
// Log In Button
UiButton.secondary(
text: t.staff_authentication.get_started_page.log_in_button,
onPressed: onLoginPressed, onPressed: onLoginPressed,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.primary,
side: const BorderSide(color: UiColors.primary),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Log In',
style: UiTypography.buttonL.copyWith(color: UiColors.primary),
),
), ),
], ],
); );

View File

@@ -1,49 +1,75 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
/// A widget that displays the background for the Get Started page.
class GetStartedBackground extends StatelessWidget { class GetStartedBackground extends StatelessWidget {
/// Creates a [GetStartedBackground].
const GetStartedBackground({super.key}); const GetStartedBackground({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Container(
padding: const EdgeInsets.only(top: 24.0), color: Colors.white,
child: Column( child: Column(
children: <Widget>[ children: [
const SizedBox(height: 32),
// Logo // Logo
Image.asset(UiImageAssets.logoBlue, height: 40), Image.asset(
UiImageAssets.logoBlue,
height: 40,
),
Expanded( Expanded(
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: Container(
children: <Widget>[ width: 288,
// Hero Image height: 288,
Container( decoration: BoxDecoration(
width: 288, shape: BoxShape.circle,
height: 288, color: const Color(0xFF3A4A5A).withOpacity(0.05),
margin: const EdgeInsets.only(bottom: 24), ),
decoration: BoxDecoration( padding: const EdgeInsets.all(8.0),
shape: BoxShape.circle, child: ClipOval(
color: UiColors.secondaryForeground.withAlpha( child: Image.network(
64, 'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces',
), // 0.5 opacity fit: BoxFit.cover,
), errorBuilder: (context, error, stackTrace) {
child: Padding( return Image.asset(UiImageAssets.logoBlue);
padding: const EdgeInsets.all(8.0), },
child: ClipOval(
child: Image.network(
'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces',
fit: BoxFit.cover,
),
),
), ),
), ),
const SizedBox(height: 16), ),
],
), ),
), ),
// Pagination dots (Visual only)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 24,
height: 8,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: UiColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: UiColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
),
],
),
], ],
), ),
); );

View File

@@ -1,37 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:staff_authentication/staff_authentication.dart';
/// A widget that displays the welcome text and description on the Get Started page.
class GetStartedHeader extends StatelessWidget { class GetStartedHeader extends StatelessWidget {
/// Creates a [GetStartedHeader].
const GetStartedHeader({super.key}); const GetStartedHeader({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: [
RichText( Text(
'Krow Workforce',
style: UiTypography.display1b.copyWith(color: UiColors.textPrimary),
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan(
style: UiTypography.displayM,
children: <InlineSpan>[
TextSpan(
text: t.staff_authentication.get_started_page.title_part1,
),
TextSpan(
text: t.staff_authentication.get_started_page.title_part2,
style: UiTypography.displayMb.textLink,
),
],
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
t.staff_authentication.get_started_page.subtitle, 'Find flexible shifts that fit your schedule.',
style: UiTypography.body1r.copyWith(color: UiColors.textSecondary),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.body1r.textSecondary,
), ),
], ],
); );

View File

@@ -1,4 +1,5 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import '../../domain/usecases/get_todays_shift_usecase.dart'; import '../../domain/usecases/get_todays_shift_usecase.dart';
import '../../domain/usecases/get_attendance_status_usecase.dart'; import '../../domain/usecases/get_attendance_status_usecase.dart';
import '../../domain/usecases/clock_in_usecase.dart'; import '../../domain/usecases/clock_in_usecase.dart';
@@ -16,6 +17,11 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
final ClockOutUseCase _clockOut; final ClockOutUseCase _clockOut;
final GetActivityLogUseCase _getActivityLog; final GetActivityLogUseCase _getActivityLog;
// Mock Venue Location (e.g., Grand Hotel, NYC)
static const double venueLat = 40.7128;
static const double venueLng = -74.0060;
static const double allowedRadiusMeters = 500;
ClockInBloc({ ClockInBloc({
required GetTodaysShiftUseCase getTodaysShift, required GetTodaysShiftUseCase getTodaysShift,
required GetAttendanceStatusUseCase getAttendanceStatus, required GetAttendanceStatusUseCase getAttendanceStatus,
@@ -33,6 +39,9 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
on<CheckInRequested>(_onCheckIn); on<CheckInRequested>(_onCheckIn);
on<CheckOutRequested>(_onCheckOut); on<CheckOutRequested>(_onCheckOut);
on<CheckInModeChanged>(_onModeChanged); on<CheckInModeChanged>(_onModeChanged);
on<RequestLocationPermission>(_onRequestLocationPermission);
on<CommuteModeToggled>(_onCommuteModeToggled);
on<LocationUpdated>(_onLocationUpdated);
add(ClockInPageLoaded()); add(ClockInPageLoaded());
} }
@@ -47,12 +56,20 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
final status = await _getAttendanceStatus(); final status = await _getAttendanceStatus();
final activity = await _getActivityLog(); final activity = await _getActivityLog();
// Check permissions silently on load? Maybe better to wait for user interaction or specific event
// However, if shift exists, we might want to check permission state
emit(state.copyWith( emit(state.copyWith(
status: ClockInStatus.success, status: ClockInStatus.success,
todayShift: shift, todayShift: shift,
attendance: status, attendance: status,
activityLog: activity, activityLog: activity,
)); ));
if (shift != null && !status.isCheckedIn) {
add(RequestLocationPermission());
}
} catch (e) { } catch (e) {
emit(state.copyWith( emit(state.copyWith(
status: ClockInStatus.failure, status: ClockInStatus.failure,
@@ -61,6 +78,69 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
} }
} }
Future<void> _onRequestLocationPermission(
RequestLocationPermission event,
Emitter<ClockInState> emit,
) async {
try {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
final hasConsent = permission == LocationPermission.always || permission == LocationPermission.whileInUse;
emit(state.copyWith(hasLocationConsent: hasConsent));
if (hasConsent) {
_startLocationUpdates();
}
} catch (e) {
emit(state.copyWith(errorMessage: "Location error: $e"));
}
}
void _startLocationUpdates() async {
try {
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
final distance = Geolocator.distanceBetween(
position.latitude,
position.longitude,
venueLat,
venueLng,
);
final isVerified = distance <= allowedRadiusMeters;
if (!isClosed) {
add(LocationUpdated(position: position, distance: distance, isVerified: isVerified));
}
} catch (e) {
// Handle error silently or via state
}
}
void _onLocationUpdated(
LocationUpdated event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(
currentLocation: event.position,
distanceFromVenue: event.distance,
isLocationVerified: event.isVerified,
etaMinutes: (event.distance / 80).round(), // Rough estimate: 80m/min walking speed
));
}
void _onCommuteModeToggled(
CommuteModeToggled event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(isCommuteModeOn: event.isEnabled));
if (event.isEnabled) {
add(RequestLocationPermission());
}
}
void _onDateSelected( void _onDateSelected(
DateSelected event, DateSelected event,
Emitter<ClockInState> emit, Emitter<ClockInState> emit,
@@ -79,6 +159,13 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
CheckInRequested event, CheckInRequested event,
Emitter<ClockInState> emit, Emitter<ClockInState> emit,
) async { ) async {
// Only verify location if not using NFC (or depending on requirements) - enforcing for swipe
if (state.checkInMode == 'swipe' && !state.isLocationVerified) {
// Allow for now since coordinates are hardcoded and might not match user location
// emit(state.copyWith(errorMessage: "You must be at the location to clock in."));
// return;
}
emit(state.copyWith(status: ClockInStatus.actionInProgress)); emit(state.copyWith(status: ClockInStatus.actionInProgress));
try { try {
final newStatus = await _clockIn( final newStatus = await _clockIn(

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:geolocator/geolocator.dart';
abstract class ClockInEvent extends Equatable { abstract class ClockInEvent extends Equatable {
const ClockInEvent(); const ClockInEvent();
@@ -46,3 +47,25 @@ class CheckInModeChanged extends ClockInEvent {
@override @override
List<Object?> get props => [mode]; List<Object?> get props => [mode];
} }
class CommuteModeToggled extends ClockInEvent {
final bool isEnabled;
const CommuteModeToggled(this.isEnabled);
@override
List<Object?> get props => [isEnabled];
}
class RequestLocationPermission extends ClockInEvent {}
class LocationUpdated extends ClockInEvent {
final Position position;
final double distance;
final bool isVerified;
const LocationUpdated({required this.position, required this.distance, required this.isVerified});
@override
List<Object?> get props => [position, distance, isVerified];
}

View File

@@ -1,5 +1,7 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:geolocator/geolocator.dart';
enum ClockInStatus { initial, loading, success, failure, actionInProgress } enum ClockInStatus { initial, loading, success, failure, actionInProgress }
@@ -12,6 +14,13 @@ class ClockInState extends Equatable {
final String checkInMode; final String checkInMode;
final String? errorMessage; final String? errorMessage;
final Position? currentLocation;
final double? distanceFromVenue;
final bool isLocationVerified;
final bool isCommuteModeOn;
final bool hasLocationConsent;
final int? etaMinutes;
const ClockInState({ const ClockInState({
this.status = ClockInStatus.initial, this.status = ClockInStatus.initial,
this.todayShift, this.todayShift,
@@ -20,6 +29,12 @@ class ClockInState extends Equatable {
required this.selectedDate, required this.selectedDate,
this.checkInMode = 'swipe', this.checkInMode = 'swipe',
this.errorMessage, this.errorMessage,
this.currentLocation,
this.distanceFromVenue,
this.isLocationVerified = false,
this.isCommuteModeOn = false,
this.hasLocationConsent = false,
this.etaMinutes,
}); });
ClockInState copyWith({ ClockInState copyWith({
@@ -30,6 +45,12 @@ class ClockInState extends Equatable {
DateTime? selectedDate, DateTime? selectedDate,
String? checkInMode, String? checkInMode,
String? errorMessage, String? errorMessage,
Position? currentLocation,
double? distanceFromVenue,
bool? isLocationVerified,
bool? isCommuteModeOn,
bool? hasLocationConsent,
int? etaMinutes,
}) { }) {
return ClockInState( return ClockInState(
status: status ?? this.status, status: status ?? this.status,
@@ -39,6 +60,12 @@ class ClockInState extends Equatable {
selectedDate: selectedDate ?? this.selectedDate, selectedDate: selectedDate ?? this.selectedDate,
checkInMode: checkInMode ?? this.checkInMode, checkInMode: checkInMode ?? this.checkInMode,
errorMessage: errorMessage, errorMessage: errorMessage,
currentLocation: currentLocation ?? this.currentLocation,
distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue,
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
isCommuteModeOn: isCommuteModeOn ?? this.isCommuteModeOn,
hasLocationConsent: hasLocationConsent ?? this.hasLocationConsent,
etaMinutes: etaMinutes ?? this.etaMinutes,
); );
} }
@@ -51,5 +78,11 @@ class ClockInState extends Equatable {
selectedDate, selectedDate,
checkInMode, checkInMode,
errorMessage, errorMessage,
currentLocation,
distanceFromVenue,
isLocationVerified,
isCommuteModeOn,
hasLocationConsent,
etaMinutes,
]; ];
} }

View File

@@ -92,10 +92,13 @@ class _ClockInPageState extends State<ClockInPage> {
if (todayShift != null) if (todayShift != null)
CommuteTracker( CommuteTracker(
shift: todayShift, shift: todayShift,
hasLocationConsent: false, // Mock value hasLocationConsent: state.hasLocationConsent,
isCommuteModeOn: false, // Mock value isCommuteModeOn: state.isCommuteModeOn,
distanceMeters: 500, // Mock value for demo distanceMeters: state.distanceFromVenue,
etaMinutes: 8, // Mock value for demo etaMinutes: state.etaMinutes,
onCommuteToggled: (value) {
_bloc.add(CommuteModeToggled(value));
},
), ),
// Date Selector // Date Selector
DateSelector( DateSelector(
@@ -183,9 +186,9 @@ class _ClockInPageState extends State<ClockInPage> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
const Text( Text(
"9:00 AM - 5:00 PM", "${_formatTime(todayShift.startTime)} - ${_formatTime(todayShift.endTime)}",
style: TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF475569), // slate-600 color: Color(0xFF475569), // slate-600
@@ -207,36 +210,73 @@ class _ClockInPageState extends State<ClockInPage> {
// Swipe To Check In / Checked Out State / No Shift State // Swipe To Check In / Checked Out State / No Shift State
if (todayShift != null && checkOutTime == null) ...[ if (todayShift != null && checkOutTime == null) ...[
SwipeToCheckIn( if (!isCheckedIn && !_isCheckInAllowed(todayShift))
isCheckedIn: isCheckedIn, Container(
mode: state.checkInMode, width: double.infinity,
isLoading: padding: const EdgeInsets.all(24),
state.status == decoration: BoxDecoration(
ClockInStatus.actionInProgress, color: const Color(0xFFF1F5F9), // slate-100
onCheckIn: () async { borderRadius: BorderRadius.circular(16),
// Show NFC dialog if mode is 'nfc' ),
if (state.checkInMode == 'nfc') { child: Column(
await _showNFCDialog(context); children: [
} else { const Icon(
_bloc.add( LucideIcons.clock,
CheckInRequested(shiftId: todayShift.id), size: 48,
color: Color(0xFF94A3B8), // slate-400
),
const SizedBox(height: 16),
const Text(
"You're early!",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF475569), // slate-600
),
),
const SizedBox(height: 4),
Text(
"Check-in available at ${_getCheckInAvailabilityTime(todayShift)}",
style: const TextStyle(
fontSize: 14,
color: Color(0xFF64748B), // slate-500
),
textAlign: TextAlign.center,
),
],
),
)
else
SwipeToCheckIn(
isCheckedIn: isCheckedIn,
mode: state.checkInMode,
isLoading:
state.status ==
ClockInStatus.actionInProgress,
onCheckIn: () async {
// Show NFC dialog if mode is 'nfc'
if (state.checkInMode == 'nfc') {
await _showNFCDialog(context);
} else {
_bloc.add(
CheckInRequested(shiftId: todayShift.id),
);
}
},
onCheckOut: () {
showDialog(
context: context,
builder: (context) => LunchBreakDialog(
onComplete: () {
Navigator.of(
context,
).pop(); // Close dialog first
_bloc.add(const CheckOutRequested());
},
),
); );
} },
}, ),
onCheckOut: () {
showDialog(
context: context,
builder: (context) => LunchBreakDialog(
onComplete: () {
Navigator.of(
context,
).pop(); // Close dialog first
_bloc.add(const CheckOutRequested());
},
),
);
},
),
] else if (todayShift != null && ] else if (todayShift != null &&
checkOutTime != null) ...[ checkOutTime != null) ...[
// Shift Completed State // Shift Completed State
@@ -695,4 +735,43 @@ class _ClockInPageState extends State<ClockInPage> {
_bloc.add(CheckInRequested(shiftId: _bloc.state.todayShift!.id)); _bloc.add(CheckInRequested(shiftId: _bloc.state.todayShift!.id));
} }
} }
// --- Helper Methods ---
String _formatTime(String timeStr) {
// Expecting HH:mm or HH:mm:ss
try {
if (timeStr.isEmpty) return '';
final parts = timeStr.split(':');
final dt = DateTime(2022, 1, 1, int.parse(parts[0]), int.parse(parts[1]));
return DateFormat('h:mm a').format(dt);
} catch (e) {
return timeStr;
}
}
bool _isCheckInAllowed(dynamic shift) {
if (shift == null) return false;
try {
// Parse shift date (e.g. 2024-01-31T09:00:00)
// The Shift entity has 'date' which is the start DateTime string
final shiftStart = DateTime.parse(shift.date);
final windowStart = shiftStart.subtract(const Duration(minutes: 15));
return DateTime.now().isAfter(windowStart);
} catch (e) {
// Fallback: If parsing fails, allow check in to avoid blocking.
return true;
}
}
String _getCheckInAvailabilityTime(dynamic shift) {
if (shift == null) return '';
try {
final shiftStart = DateTime.parse(shift.date);
final windowStart = shiftStart.subtract(const Duration(minutes: 15));
return DateFormat('h:mm a').format(windowStart);
} catch (e) {
return 'soon';
}
}
} }

View File

@@ -14,6 +14,7 @@ enum CommuteMode {
class CommuteTracker extends StatefulWidget { class CommuteTracker extends StatefulWidget {
final Shift? shift; final Shift? shift;
final Function(CommuteMode)? onModeChange; final Function(CommuteMode)? onModeChange;
final ValueChanged<bool>? onCommuteToggled;
final bool hasLocationConsent; final bool hasLocationConsent;
final bool isCommuteModeOn; final bool isCommuteModeOn;
final double? distanceMeters; final double? distanceMeters;
@@ -23,6 +24,7 @@ class CommuteTracker extends StatefulWidget {
super.key, super.key,
this.shift, this.shift,
this.onModeChange, this.onModeChange,
this.onCommuteToggled,
this.hasLocationConsent = false, this.hasLocationConsent = false,
this.isCommuteModeOn = false, this.isCommuteModeOn = false,
this.distanceMeters, this.distanceMeters,
@@ -44,6 +46,21 @@ class _CommuteTrackerState extends State<CommuteTracker> {
_localIsCommuteOn = widget.isCommuteModeOn; _localIsCommuteOn = widget.isCommuteModeOn;
} }
@override
void didUpdateWidget(CommuteTracker oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isCommuteModeOn != oldWidget.isCommuteModeOn) {
setState(() {
_localIsCommuteOn = widget.isCommuteModeOn;
});
}
if (widget.hasLocationConsent != oldWidget.hasLocationConsent) {
setState(() {
_localHasConsent = widget.hasLocationConsent;
});
}
}
CommuteMode _getAppMode() { CommuteMode _getAppMode() {
if (widget.shift == null) return CommuteMode.lockedNoShift; if (widget.shift == null) return CommuteMode.lockedNoShift;
@@ -299,6 +316,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
value: _localIsCommuteOn, value: _localIsCommuteOn,
onChanged: (value) { onChanged: (value) {
setState(() => _localIsCommuteOn = value); setState(() => _localIsCommuteOn = value);
widget.onCommuteToggled?.call(value);
}, },
activeColor: AppColors.krowBlue, activeColor: AppColors.krowBlue,
), ),

View File

@@ -2,41 +2,75 @@ import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_data_connect/src/session/staff_session_store.dart'; import 'package:krow_data_connect/src/session/staff_session_store.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../../domain/repositories/payments_repository.dart'; import '../../domain/repositories/payments_repository.dart';
class PaymentsRepositoryImpl implements PaymentsRepository { class PaymentsRepositoryImpl implements PaymentsRepository {
final dc.ExampleConnector _dataConnect; final dc.ExampleConnector _dataConnect;
final FirebaseAuth _auth = FirebaseAuth.instance;
PaymentsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance; PaymentsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance;
String? _cachedStaffId;
Future<String> _getStaffId() async {
// 1. Check Session Store
final StaffSession? session = StaffSessionStore.instance.session;
if (session?.staff?.id != null) {
return session!.staff!.id;
}
// 2. Check Cache
if (_cachedStaffId != null) return _cachedStaffId!;
// 3. Fetch from Data Connect using Firebase UID
final user = _auth.currentUser;
if (user == null) {
throw Exception('User is not authenticated');
}
try {
final response = await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (response.data.staffs.isNotEmpty) {
_cachedStaffId = response.data.staffs.first.id;
return _cachedStaffId!;
}
} catch (e) {
// Log or handle error
}
// 4. Fallback
return user.uid;
}
/// Helper to convert Data Connect Timestamp to DateTime /// Helper to convert Data Connect Timestamp to DateTime
DateTime? _toDateTime(dynamic t) { DateTime? _toDateTime(dynamic t) {
if (t == null) return null; if (t == null) return null;
if (t is DateTime) return t;
if (t is String) return DateTime.tryParse(t);
try { try {
// Attempt to deserialize via standard methods if (t is Timestamp) {
return DateTime.tryParse(t.toJson() as String); return t.toDateTime();
} catch (_) {
try {
return DateTime.tryParse(t.toString());
} catch (e) {
return null;
} }
} } catch (_) {}
try {
if (t.runtimeType.toString().contains('Timestamp')) {
return (t as dynamic).toDate();
}
} catch (_) {}
try {
return DateTime.tryParse(t.toString());
} catch (_) {}
return null;
} }
@override @override
Future<PaymentSummary> getPaymentSummary() async { Future<PaymentSummary> getPaymentSummary() async {
final StaffSession? session = StaffSessionStore.instance.session; final String currentStaffId = await _getStaffId();
if (session?.staff?.id == null) {
return const PaymentSummary(
weeklyEarnings: 0,
monthlyEarnings: 0,
pendingEarnings: 0,
totalEarnings: 0,
);
}
final String currentStaffId = session!.staff!.id;
// Fetch recent payments with a limit // Fetch recent payments with a limit
// Note: limit is chained on the query builder // Note: limit is chained on the query builder
@@ -82,10 +116,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository {
@override @override
Future<List<StaffPayment>> getPaymentHistory(String period) async { Future<List<StaffPayment>> getPaymentHistory(String period) async {
final StaffSession? session = StaffSessionStore.instance.session; final String currentStaffId = await _getStaffId();
if (session?.staff?.id == null) return <StaffPayment>[];
final String currentStaffId = session!.staff!.id;
try { try {
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response = final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response =

View File

@@ -210,7 +210,7 @@ class _PaymentsPageState extends State<PaymentsPage> {
], ],
), ),
const SizedBox(height: 32), const SizedBox(height: 100),
], ],
), ),
), ),

View File

@@ -3,6 +3,7 @@ import 'package:krow_data_connect/src/session/staff_session_store.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart';
import '../../domain/repositories/shifts_repository_interface.dart'; import '../../domain/repositories/shifts_repository_interface.dart';
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
@@ -16,31 +17,62 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
// Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation) // Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation)
final Map<String, String> _appToRoleIdMap = {}; final Map<String, String> _appToRoleIdMap = {};
String get _currentStaffId { String? _cachedStaffId;
Future<String> _getStaffId() async {
// 1. Check Session Store
final StaffSession? session = StaffSessionStore.instance.session; final StaffSession? session = StaffSessionStore.instance.session;
if (session?.staff?.id != null) { if (session?.staff?.id != null) {
return session!.staff!.id; return session!.staff!.id;
} }
// Fallback? Or throw.
// If not logged in, we shouldn't be here. // 2. Check Cache
return _auth.currentUser?.uid ?? 'STAFF_123'; if (_cachedStaffId != null) return _cachedStaffId!;
// 3. Fetch from Data Connect using Firebase UID
final user = _auth.currentUser;
if (user == null) {
throw Exception('User is not authenticated');
}
try {
final response = await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (response.data.staffs.isNotEmpty) {
_cachedStaffId = response.data.staffs.first.id;
return _cachedStaffId!;
}
} catch (e) {
// Log or handle error
}
// 4. Fallback (should ideally not happen if DB is seeded)
return user.uid;
} }
/// Helper to convert Data Connect Timestamp to DateTime
DateTime? _toDateTime(dynamic t) { DateTime? _toDateTime(dynamic t) {
if (t == null) return null; if (t == null) return null;
if (t is DateTime) return t;
if (t is String) return DateTime.tryParse(t);
// Data Connect Timestamp handling
try { try {
if (t is String) return DateTime.tryParse(t); if (t is Timestamp) {
// If it accepts toJson return t.toDateTime();
try { }
return DateTime.tryParse(t.toJson() as String); } catch (_) {}
} catch (_) {}
// If it's a Timestamp object (depends on SDK), usually .toDate() exists but 'dynamic' hides it. try {
// Assuming toString or toJson covers it, or using helper. // Fallback for any object that might have a toDate or similar
return DateTime.now(); // Placeholder if type unknown, but ideally fetch correct value if (t.runtimeType.toString().contains('Timestamp')) {
} catch (_) { return (t as dynamic).toDate();
return null; }
} } catch (_) {}
try {
return DateTime.tryParse(t.toString());
} catch (_) {}
return null;
} }
@override @override
@@ -53,22 +85,49 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
return _fetchApplications(dc.ApplicationStatus.PENDING); return _fetchApplications(dc.ApplicationStatus.PENDING);
} }
@override
Future<List<Shift>> getCancelledShifts() async {
return _fetchApplications(dc.ApplicationStatus.REJECTED);
}
@override
Future<List<Shift>> getHistoryShifts() async {
return _fetchApplications(dc.ApplicationStatus.CHECKED_OUT);
}
Future<List<Shift>> _fetchApplications(dc.ApplicationStatus status) async { Future<List<Shift>> _fetchApplications(dc.ApplicationStatus status) async {
try { try {
final staffId = await _getStaffId();
final response = await _dataConnect final response = await _dataConnect
.getApplicationsByStaffId(staffId: _currentStaffId) .getApplicationsByStaffId(staffId: staffId)
.execute(); .execute();
final apps = response.data.applications.where((app) => app.status == status); final apps = response.data.applications.where((app) => app.status.stringValue == status.name);
final List<Shift> shifts = []; final List<Shift> shifts = [];
for (final app in apps) { for (final app in apps) {
_shiftToAppIdMap[app.shift.id] = app.id; _shiftToAppIdMap[app.shift.id] = app.id;
_appToRoleIdMap[app.id] = app.shiftRole.id; _appToRoleIdMap[app.id] = app.shiftRole.id;
final shiftTuple = await _getShiftDetails(app.shift.id); final shift = await _getShiftDetails(app.shift.id);
if (shiftTuple != null) { if (shift != null) {
shifts.add(shiftTuple); // Override status to reflect the application state (e.g., CHECKED_OUT, ACCEPTED)
shifts.add(Shift(
id: shift.id,
title: shift.title,
clientName: shift.clientName,
logoUrl: shift.logoUrl,
hourlyRate: shift.hourlyRate,
location: shift.location,
locationAddress: shift.locationAddress,
date: shift.date,
startTime: shift.startTime,
endTime: shift.endTime,
createdDate: shift.createdDate,
status: _mapStatus(status),
description: shift.description,
durationDays: shift.durationDays,
));
} }
} }
return shifts; return shifts;
@@ -77,6 +136,22 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
} }
} }
String _mapStatus(dc.ApplicationStatus status) {
switch (status) {
case dc.ApplicationStatus.ACCEPTED:
case dc.ApplicationStatus.CONFIRMED:
return 'confirmed';
case dc.ApplicationStatus.PENDING:
return 'pending';
case dc.ApplicationStatus.CHECKED_OUT:
return 'completed';
case dc.ApplicationStatus.REJECTED:
return 'cancelled';
default:
return 'open';
}
}
@override @override
Future<List<Shift>> getAvailableShifts(String query, String type) async { Future<List<Shift>> getAvailableShifts(String query, String type) async {
try { try {
@@ -104,8 +179,9 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '', startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '', createdDate: createdDt?.toIso8601String() ?? '',
status: s.status?.stringValue ?? 'OPEN', status: s.status?.stringValue.toLowerCase() ?? 'open',
description: s.description, description: s.description,
durationDays: s.durationDays,
)); ));
} }
@@ -152,6 +228,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
createdDate: createdDt?.toIso8601String() ?? '', createdDate: createdDt?.toIso8601String() ?? '',
status: s.status?.stringValue ?? 'OPEN', status: s.status?.stringValue ?? 'OPEN',
description: s.description, description: s.description,
durationDays: s.durationDays,
); );
} catch (e) { } catch (e) {
return null; return null;
@@ -165,9 +242,10 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
final role = rolesResult.data.shiftRoles.first; final role = rolesResult.data.shiftRoles.first;
final staffId = await _getStaffId();
await _dataConnect.createApplication( await _dataConnect.createApplication(
shiftId: shiftId, shiftId: shiftId,
staffId: _currentStaffId, staffId: staffId,
roleId: role.id, roleId: role.id,
status: dc.ApplicationStatus.PENDING, status: dc.ApplicationStatus.PENDING,
origin: dc.ApplicationOrigin.STAFF, origin: dc.ApplicationOrigin.STAFF,
@@ -198,7 +276,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
roleId = _appToRoleIdMap[appId]; roleId = _appToRoleIdMap[appId];
} else { } else {
// Fallback fetch // Fallback fetch
final apps = await _dataConnect.getApplicationsByStaffId(staffId: _currentStaffId).execute(); final staffId = await _getStaffId();
final apps = await _dataConnect.getApplicationsByStaffId(staffId: staffId).execute();
final app = apps.data.applications.where((a) => a.shiftId == shiftId).firstOrNull; final app = apps.data.applications.where((a) => a.shiftId == shiftId).firstOrNull;
if (app != null) { if (app != null) {
appId = app.id; appId = app.id;

View File

@@ -25,4 +25,10 @@ abstract interface class ShiftsRepositoryInterface {
/// Declines a pending shift assignment. /// Declines a pending shift assignment.
Future<void> declineShift(String shiftId); Future<void> declineShift(String shiftId);
/// Retrieves shifts that were cancelled for the current user.
Future<List<Shift>> getCancelledShifts();
/// Retrieves completed shifts for the current user.
Future<List<Shift>> getHistoryShifts();
} }

View File

@@ -0,0 +1,12 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
class AcceptShiftUseCase {
final ShiftsRepositoryInterface repository;
AcceptShiftUseCase(this.repository);
Future<void> call(String shiftId) async {
return repository.acceptShift(shiftId);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
class DeclineShiftUseCase {
final ShiftsRepositoryInterface repository;
DeclineShiftUseCase(this.repository);
Future<void> call(String shiftId) async {
return repository.declineShift(shiftId);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
class GetCancelledShiftsUseCase {
final ShiftsRepositoryInterface repository;
GetCancelledShiftsUseCase(this.repository);
Future<List<Shift>> call() async {
return repository.getCancelledShifts();
}
}

View File

@@ -0,0 +1,12 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
class GetHistoryShiftsUseCase {
final ShiftsRepositoryInterface repository;
GetHistoryShiftsUseCase(this.repository);
Future<List<Shift>> call() async {
return repository.getHistoryShifts();
}
}

View File

@@ -0,0 +1,14 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
class GetShiftDetailsUseCase extends UseCase<String, Shift?> {
final ShiftsRepositoryInterface repository;
GetShiftDetailsUseCase(this.repository);
@override
Future<Shift?> call(String params) {
return repository.getShiftDetails(params);
}
}

View File

@@ -7,6 +7,10 @@ import '../../../domain/usecases/get_available_shifts_usecase.dart';
import '../../../domain/arguments/get_available_shifts_arguments.dart'; import '../../../domain/arguments/get_available_shifts_arguments.dart';
import '../../../domain/usecases/get_my_shifts_usecase.dart'; import '../../../domain/usecases/get_my_shifts_usecase.dart';
import '../../../domain/usecases/get_pending_assignments_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';
part 'shifts_event.dart'; part 'shifts_event.dart';
part 'shifts_state.dart'; part 'shifts_state.dart';
@@ -15,14 +19,24 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
final GetMyShiftsUseCase getMyShifts; final GetMyShiftsUseCase getMyShifts;
final GetAvailableShiftsUseCase getAvailableShifts; final GetAvailableShiftsUseCase getAvailableShifts;
final GetPendingAssignmentsUseCase getPendingAssignments; final GetPendingAssignmentsUseCase getPendingAssignments;
final GetCancelledShiftsUseCase getCancelledShifts;
final GetHistoryShiftsUseCase getHistoryShifts;
final AcceptShiftUseCase acceptShift;
final DeclineShiftUseCase declineShift;
ShiftsBloc({ ShiftsBloc({
required this.getMyShifts, required this.getMyShifts,
required this.getAvailableShifts, required this.getAvailableShifts,
required this.getPendingAssignments, required this.getPendingAssignments,
required this.getCancelledShifts,
required this.getHistoryShifts,
required this.acceptShift,
required this.declineShift,
}) : super(ShiftsInitial()) { }) : super(ShiftsInitial()) {
on<LoadShiftsEvent>(_onLoadShifts); on<LoadShiftsEvent>(_onLoadShifts);
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts); on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
on<AcceptShiftEvent>(_onAcceptShift);
on<DeclineShiftEvent>(_onDeclineShift);
} }
Future<void> _onLoadShifts( Future<void> _onLoadShifts(
@@ -39,6 +53,8 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
try { try {
final myShiftsResult = await getMyShifts(); final myShiftsResult = await getMyShifts();
final pendingResult = await getPendingAssignments(); final pendingResult = await getPendingAssignments();
final cancelledResult = await getCancelledShifts();
final historyResult = await getHistoryShifts();
// Initial available with defaults // Initial available with defaults
final availableResult = await getAvailableShifts(const GetAvailableShiftsArguments()); final availableResult = await getAvailableShifts(const GetAvailableShiftsArguments());
@@ -46,7 +62,9 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
emit(ShiftsLoaded( emit(ShiftsLoaded(
myShifts: myShiftsResult, myShifts: myShiftsResult,
pendingShifts: pendingResult, pendingShifts: pendingResult,
cancelledShifts: cancelledResult,
availableShifts: availableResult, availableShifts: availableResult,
historyShifts: historyResult,
searchQuery: '', searchQuery: '',
jobType: 'all', jobType: 'all',
)); ));
@@ -80,4 +98,28 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
} }
} }
} }
Future<void> _onAcceptShift(
AcceptShiftEvent event,
Emitter<ShiftsState> emit,
) async {
try {
await acceptShift(event.shiftId);
add(LoadShiftsEvent()); // Reload lists
} catch (_) {
// Handle error
}
}
Future<void> _onDeclineShift(
DeclineShiftEvent event,
Emitter<ShiftsState> emit,
) async {
try {
await declineShift(event.shiftId);
add(LoadShiftsEvent()); // Reload lists
} catch (_) {
// Handle error
}
}
} }

View File

@@ -19,3 +19,19 @@ class FilterAvailableShiftsEvent extends ShiftsEvent {
@override @override
List<Object?> get props => [query, jobType]; List<Object?> get props => [query, jobType];
} }
class AcceptShiftEvent extends ShiftsEvent {
final String shiftId;
const AcceptShiftEvent(this.shiftId);
@override
List<Object?> get props => [shiftId];
}
class DeclineShiftEvent extends ShiftsEvent {
final String shiftId;
const DeclineShiftEvent(this.shiftId);
@override
List<Object?> get props => [shiftId];
}

View File

@@ -15,14 +15,18 @@ class ShiftsLoading extends ShiftsState {}
class ShiftsLoaded extends ShiftsState { class ShiftsLoaded extends ShiftsState {
final List<Shift> myShifts; final List<Shift> myShifts;
final List<Shift> pendingShifts; final List<Shift> pendingShifts;
final List<Shift> cancelledShifts;
final List<Shift> availableShifts; final List<Shift> availableShifts;
final List<Shift> historyShifts;
final String searchQuery; final String searchQuery;
final String jobType; final String jobType;
const ShiftsLoaded({ const ShiftsLoaded({
required this.myShifts, required this.myShifts,
required this.pendingShifts, required this.pendingShifts,
required this.cancelledShifts,
required this.availableShifts, required this.availableShifts,
required this.historyShifts,
required this.searchQuery, required this.searchQuery,
required this.jobType, required this.jobType,
}); });
@@ -30,21 +34,33 @@ class ShiftsLoaded extends ShiftsState {
ShiftsLoaded copyWith({ ShiftsLoaded copyWith({
List<Shift>? myShifts, List<Shift>? myShifts,
List<Shift>? pendingShifts, List<Shift>? pendingShifts,
List<Shift>? cancelledShifts,
List<Shift>? availableShifts, List<Shift>? availableShifts,
List<Shift>? historyShifts,
String? searchQuery, String? searchQuery,
String? jobType, String? jobType,
}) { }) {
return ShiftsLoaded( return ShiftsLoaded(
myShifts: myShifts ?? this.myShifts, myShifts: myShifts ?? this.myShifts,
pendingShifts: pendingShifts ?? this.pendingShifts, pendingShifts: pendingShifts ?? this.pendingShifts,
cancelledShifts: cancelledShifts ?? this.cancelledShifts,
availableShifts: availableShifts ?? this.availableShifts, availableShifts: availableShifts ?? this.availableShifts,
historyShifts: historyShifts ?? this.historyShifts,
searchQuery: searchQuery ?? this.searchQuery, searchQuery: searchQuery ?? this.searchQuery,
jobType: jobType ?? this.jobType, jobType: jobType ?? this.jobType,
); );
} }
@override @override
List<Object> get props => [myShifts, pendingShifts, availableShifts, searchQuery, jobType]; List<Object> get props => [
myShifts,
pendingShifts,
cancelledShifts,
availableShifts,
historyShifts,
searchQuery,
jobType,
];
} }
class ShiftsError extends ShiftsState { class ShiftsError extends ShiftsState {

View File

@@ -4,6 +4,9 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_shift_details_usecase.dart';
import '../../domain/usecases/accept_shift_usecase.dart';
import '../../domain/usecases/decline_shift_usecase.dart';
// Shim to match POC styles locally // Shim to match POC styles locally
class AppColors { class AppColors {
@@ -32,11 +35,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
bool _showDetails = true; bool _showDetails = true;
bool _isApplying = false; bool _isApplying = false;
// Mock Managers
final List<Map<String, String>> _managers = [
{'name': 'John Smith', 'phone': '+1 123 456 7890'},
{'name': 'Jane Doe', 'phone': '+1 123 456 7890'},
];
@override @override
void initState() { void initState() {
@@ -49,27 +48,30 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
_shift = widget.shift!; _shift = widget.shift!;
setState(() => _isLoading = false); setState(() => _isLoading = false);
} else { } else {
await Future.delayed(const Duration(milliseconds: 500)); try {
if (mounted) { final useCase = Modular.get<GetShiftDetailsUseCase>();
// Fallback mock shift final shift = await useCase(widget.shiftId);
setState(() { if (mounted) {
_shift = Shift( if (shift != null) {
id: widget.shiftId, setState(() {
title: 'Event Server', _shift = shift;
clientName: 'Grand Hotel', _isLoading = false;
logoUrl: null, });
hourlyRate: 25.0, } else {
date: DateFormat('yyyy-MM-dd').format(DateTime.now()), // Handle case where shift is not found
startTime: '16:00', Navigator.of(context).pop();
endTime: '22:00', ScaffoldMessenger.of(context).showSnackBar(
location: 'Downtown', const SnackBar(content: Text('Shift not found')),
locationAddress: '123 Main St, New York, NY', );
status: 'open', }
createdDate: DateTime.now().toIso8601String(), }
description: 'Provide exceptional customer service. Respond to guest requests or concerns promptly and professionally.', } catch (e) {
); if (mounted) {
_isLoading = false; setState(() => _isLoading = false);
}); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading shift: $e')),
);
}
} }
} }
} }
@@ -143,6 +145,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Pending Badge // Pending Badge
// Status Badge
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Container( child: Container(
@@ -151,15 +154,15 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.krowYellow.withOpacity(0.3), color: _getStatusColor(_shift.status ?? 'open').withOpacity(0.1),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: const Text( child: Text(
'Pending 6h ago', (_shift.status ?? 'open').toUpperCase(),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.krowCharcoal, color: _getStatusColor(_shift.status ?? 'open'),
), ),
), ),
), ),
@@ -248,25 +251,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Tags
Row(
children: [
_buildTag(
LucideIcons.zap,
'Immediate start',
AppColors.krowBlue.withOpacity(0.1),
AppColors.krowBlue,
),
const SizedBox(width: 8),
_buildTag(
LucideIcons.star,
'No experience',
AppColors.krowYellow.withOpacity(0.3),
AppColors.krowCharcoal,
),
],
),
const SizedBox(height: 24),
// Additional Details Collapsible // Additional Details Collapsible
Container( Container(
@@ -310,11 +295,11 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column( child: Column(
children: [ children: [
_buildDetailRow('Tips', 'Yes', true), _buildDetailRow('Tips', _shift.tipsAvailable == true ? 'Yes' : 'No', _shift.tipsAvailable == true),
_buildDetailRow('Travel Time', 'Yes', true), _buildDetailRow('Travel Time', _shift.travelTime == true ? 'Yes' : 'No', _shift.travelTime == true),
_buildDetailRow('Meal Provided', 'No', false), _buildDetailRow('Meal Provided', _shift.mealProvided == true ? 'Yes' : 'No', _shift.mealProvided == true),
_buildDetailRow('Parking Available', 'Yes', true), _buildDetailRow('Parking Available', _shift.parkingAvailable == true ? 'Yes' : 'No', _shift.parkingAvailable == true),
_buildDetailRow('Gas Compensation', 'No', false), _buildDetailRow('Gas Compensation', _shift.gasCompensation == true ? 'Yes' : 'No', _shift.gasCompensation == true),
], ],
), ),
), ),
@@ -550,7 +535,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
..._managers ...(_shift.managers ?? [])
.map( .map(
(manager) => Padding( (manager) => Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
@@ -574,13 +559,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
8, 8,
), ),
), ),
child: const Center( child: _buildAvatar(manager),
child: Icon(
LucideIcons.user,
color: Colors.white,
size: 20,
),
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Column( Column(
@@ -588,14 +567,14 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
Text( Text(
manager['name']!, manager.name,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.krowCharcoal, color: AppColors.krowCharcoal,
), ),
), ),
Text( Text(
manager['phone']!, manager.phone,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.krowMuted, color: AppColors.krowMuted,
@@ -611,7 +590,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
context, context,
).showSnackBar( ).showSnackBar(
SnackBar( SnackBar(
content: Text(manager['phone']!), content: Text(manager.phone),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
); );
@@ -702,16 +681,32 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
setState(() => _isApplying = true); setState(() => _isApplying = true);
await Future.delayed(const Duration(seconds: 1)); try {
if (mounted) { final acceptUseCase = Modular.get<AcceptShiftUseCase>();
setState(() => _isApplying = false); await acceptUseCase(_shift.id);
Modular.to.pop();
ScaffoldMessenger.of(context).showSnackBar( if (mounted) {
const SnackBar( setState(() => _isApplying = false);
content: Text('Shift Accepted!'), Modular.to.pop();
backgroundColor: Color(0xFF10B981), ScaffoldMessenger.of(context).showSnackBar(
), const SnackBar(
); content: Text('Shift Accepted!'),
backgroundColor: Color(0xFF10B981),
),
);
// Ideally, trigger a refresh on the previous screen
Modular.get<ShiftsBloc>().add(LoadShiftsEvent());
}
} catch (e) {
if (mounted) {
setState(() => _isApplying = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to accept shift: $e'),
backgroundColor: const Color(0xFFEF4444),
),
);
}
} }
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -744,7 +739,33 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
width: double.infinity, width: double.infinity,
height: 48, height: 48,
child: TextButton( child: TextButton(
onPressed: () => Modular.to.pop(), onPressed: () async {
try {
final declineUseCase = Modular.get<DeclineShiftUseCase>();
await declineUseCase(_shift.id);
if (mounted) {
Modular.to.pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Shift Declined'),
backgroundColor: Color(0xFFEF4444),
),
);
// Refresh list
Modular.get<ShiftsBloc>().add(LoadShiftsEvent());
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to decline shift: $e'),
backgroundColor: const Color(0xFFEF4444),
),
);
}
}
},
child: const Text( child: const Text(
'Decline shift', 'Decline shift',
style: TextStyle( style: TextStyle(
@@ -789,6 +810,39 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
); );
} }
Color _getStatusColor(String status) {
switch (status.toLowerCase()) {
case 'confirmed':
case 'accepted':
return const Color(0xFF10B981); // Green
case 'pending':
return const Color(0xFFF59E0B); // Yellow
case 'cancelled':
case 'rejected':
return const Color(0xFFEF4444); // Red
case 'completed':
return const Color(0xFF10B981);
default:
return AppColors.krowBlue;
}
}
Widget _buildAvatar(ShiftManager manager) {
if (manager.avatar != null && manager.avatar!.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(manager.avatar!, fit: BoxFit.cover),
);
}
return const Center(
child: Icon(
LucideIcons.user,
color: Colors.white,
size: 20,
),
);
}
Widget _buildDetailRow(String label, String value, bool isPositive) { Widget _buildDetailRow(String label, String value, bool isPositive) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),

View File

@@ -75,16 +75,22 @@ class _ShiftsPageState extends State<ShiftsPage> {
} }
void _confirmShift(String id) { void _confirmShift(String id) {
// TODO: Implement Bloc event _bloc.add(AcceptShiftEvent(id));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shift confirmed! (Placeholder)')), const SnackBar(
content: Text('Shift confirmed!'),
backgroundColor: Color(0xFF10B981),
),
); );
} }
void _declineShift(String id) { void _declineShift(String id) {
// TODO: Implement Bloc event _bloc.add(DeclineShiftEvent(id));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shift declined. (Placeholder)')), const SnackBar(
content: Text('Shift declined.'),
backgroundColor: Color(0xFFEF4444),
),
); );
} }
@@ -97,9 +103,10 @@ class _ShiftsPageState extends State<ShiftsPage> {
final List<Shift> myShifts = (state is ShiftsLoaded) ? state.myShifts : []; final List<Shift> myShifts = (state is ShiftsLoaded) ? state.myShifts : [];
final List<Shift> availableJobs = (state is ShiftsLoaded) ? state.availableShifts : []; final List<Shift> availableJobs = (state is ShiftsLoaded) ? state.availableShifts : [];
final List<Shift> pendingAssignments = (state is ShiftsLoaded) ? state.pendingShifts : []; final List<Shift> pendingAssignments = (state is ShiftsLoaded) ? state.pendingShifts : [];
final List<Shift> historyShifts = []; // Not in state yet, placeholder final List<Shift> cancelledShifts = (state is ShiftsLoaded) ? state.cancelledShifts : [];
final List<Shift> historyShifts = (state is ShiftsLoaded) ? state.historyShifts : [];
// Filter logic from POC // Filter logic
final filteredJobs = availableJobs.where((s) { final filteredJobs = availableJobs.where((s) {
final matchesSearch = final matchesSearch =
s.title.toLowerCase().contains(_searchQuery.toLowerCase()) || s.title.toLowerCase().contains(_searchQuery.toLowerCase()) ||
@@ -110,10 +117,9 @@ class _ShiftsPageState extends State<ShiftsPage> {
if (_jobType == 'all') return true; if (_jobType == 'all') return true;
if (_jobType == 'one-day') { if (_jobType == 'one-day') {
return !s.title.contains('Long Term') && !s.title.contains('Multi-Day'); return s.durationDays == null || s.durationDays! <= 1;
} }
if (_jobType == 'multi-day') return s.title.contains('Multi-Day'); if (_jobType == 'multi-day') return s.durationDays != null && s.durationDays! > 1;
if (_jobType == 'long-term') return s.title.contains('Long Term');
return true; return true;
}).toList(); }).toList();
@@ -122,13 +128,23 @@ class _ShiftsPageState extends State<ShiftsPage> {
final weekEndDate = calendarDays.last; final weekEndDate = calendarDays.last;
final visibleMyShifts = myShifts.where((s) { final visibleMyShifts = myShifts.where((s) {
// Primitive check if shift date string compare try {
// In real app use DateTime logic final date = DateTime.parse(s.date);
final sDateStr = s.date; return date.isAfter(weekStartDate.subtract(const Duration(seconds: 1))) &&
final wStartStr = DateFormat('yyyy-MM-dd').format(weekStartDate); date.isBefore(weekEndDate.add(const Duration(days: 1)));
final wEndStr = DateFormat('yyyy-MM-dd').format(weekEndDate); } catch (_) {
return sDateStr.compareTo(wStartStr) >= 0 && return false;
sDateStr.compareTo(wEndStr) <= 0; }
}).toList();
final visibleCancelledShifts = 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;
}
}).toList(); }).toList();
return Scaffold( return Scaffold(
@@ -140,9 +156,9 @@ class _ShiftsPageState extends State<ShiftsPage> {
color: AppColors.krowBlue, color: AppColors.krowBlue,
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
20, 20,
MediaQuery.of(context).padding.top + 20, MediaQuery.of(context).padding.top + 10,
20,
20, 20,
24,
), ),
child: Column( child: Column(
children: [ children: [
@@ -157,17 +173,28 @@ class _ShiftsPageState extends State<ShiftsPage> {
color: Colors.white, color: Colors.white,
), ),
), ),
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Center(
child: Icon(UiIcons.user, size: 20, color: Colors.white),
),
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Tabs // Tabs
Row( Row(
children: [ children: [
_buildTab("myshifts", "My Shifts", LucideIcons.calendar, myShifts.length), _buildTab("myshifts", "My Shifts", UiIcons.calendar, myShifts.length),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildTab("find", "Find Shifts", LucideIcons.search, filteredJobs.length), _buildTab("find", "Find Shifts", UiIcons.search, filteredJobs.length),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildTab("history", "History", LucideIcons.clock, historyShifts.length), _buildTab("history", "History", UiIcons.clock, historyShifts.length),
], ],
), ),
], ],
@@ -178,21 +205,19 @@ class _ShiftsPageState extends State<ShiftsPage> {
if (_activeTab == 'myshifts') if (_activeTab == 'myshifts')
Container( Container(
color: Colors.white, color: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
child: Column( child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 12),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
InkWell( IconButton(
onTap: () => setState(() => _weekOffset--), icon: const Icon(UiIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal),
borderRadius: BorderRadius.circular(20), onPressed: () => setState(() => _weekOffset--),
child: const Padding( constraints: const BoxConstraints(),
padding: EdgeInsets.all(8.0), padding: EdgeInsets.zero,
child: Icon(LucideIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal),
),
), ),
Text( Text(
DateFormat('MMMM yyyy').format(weekStartDate), DateFormat('MMMM yyyy').format(weekStartDate),
@@ -202,13 +227,11 @@ class _ShiftsPageState extends State<ShiftsPage> {
color: AppColors.krowCharcoal, color: AppColors.krowCharcoal,
), ),
), ),
InkWell( IconButton(
onTap: () => setState(() => _weekOffset++), icon: const Icon(UiIcons.chevronRight, size: 20, color: AppColors.krowCharcoal),
borderRadius: BorderRadius.circular(20), onPressed: () => setState(() => _weekOffset++),
child: const Padding( constraints: const BoxConstraints(),
padding: EdgeInsets.all(8.0), padding: EdgeInsets.zero,
child: Icon(LucideIcons.chevronRight, size: 20, color: AppColors.krowCharcoal),
),
), ),
], ],
), ),
@@ -219,52 +242,60 @@ class _ShiftsPageState extends State<ShiftsPage> {
children: calendarDays.map((date) { children: calendarDays.map((date) {
final isSelected = _isSameDay(date, _selectedDate); final isSelected = _isSameDay(date, _selectedDate);
final dateStr = DateFormat('yyyy-MM-dd').format(date); final dateStr = DateFormat('yyyy-MM-dd').format(date);
final hasShifts = myShifts.any((s) => s.date == dateStr); final hasShifts = myShifts.any((s) {
try {
return _isSameDay(DateTime.parse(s.date), date);
} catch (_) { return false; }
});
return GestureDetector( return GestureDetector(
onTap: () => setState(() => _selectedDate = date), onTap: () => setState(() => _selectedDate = date),
child: Container( child: Column(
width: 44, children: [
padding: const EdgeInsets.symmetric(vertical: 12), Container(
decoration: BoxDecoration( width: 44,
color: isSelected ? AppColors.krowBlue : Colors.white, height: 60,
borderRadius: BorderRadius.circular(999), decoration: BoxDecoration(
border: Border.all( color: isSelected ? AppColors.krowBlue : Colors.white,
color: isSelected ? AppColors.krowBlue : AppColors.krowBorder, borderRadius: BorderRadius.circular(12),
width: 1, 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), child: Column(
Text( mainAxisAlignment: MainAxisAlignment.center,
DateFormat('E').format(date), children: [
style: TextStyle( Text(
fontSize: 10, date.day.toString().padLeft(2, '0'),
fontWeight: FontWeight.w500, style: TextStyle(
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.krowMuted, fontSize: 18,
), fontWeight: FontWeight.bold,
), color: isSelected ? Colors.white : AppColors.krowCharcoal,
if (hasShifts) ),
Container(
margin: const EdgeInsets.only(top: 4),
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected ? Colors.white : AppColors.krowBlue,
shape: BoxShape.circle,
), ),
), Text(
], DateFormat('E').format(date),
), style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.krowMuted,
),
),
if (hasShifts && !isSelected)
Container(
margin: const EdgeInsets.only(top: 4),
width: 4,
height: 4,
decoration: const BoxDecoration(
color: AppColors.krowBlue,
shape: BoxShape.circle,
),
),
],
),
),
],
), ),
); );
}).toList(), }).toList(),
@@ -276,141 +307,177 @@ class _ShiftsPageState extends State<ShiftsPage> {
if (_activeTab == 'myshifts') if (_activeTab == 'myshifts')
const Divider(height: 1, color: AppColors.krowBorder), const Divider(height: 1, color: AppColors.krowBorder),
// Search and Filters for Find Tab (Fixed at top)
if (_activeTab == 'find')
Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
children: [
// Search Bar
Row(
children: [
Expanded(
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
const Icon(UiIcons.search, size: 20, color: Color(0xFF94A3B8)),
const SizedBox(width: 10),
Expanded(
child: TextField(
onChanged: (v) => setState(() => _searchQuery = v),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: "Search jobs, location...",
hintStyle: TextStyle(
color: Color(0xFF94A3B8),
fontSize: 14,
),
),
),
),
],
),
),
),
const SizedBox(width: 8),
Container(
height: 48,
width: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: const Icon(UiIcons.filter, size: 18, color: Color(0xFF64748B)),
),
],
),
const SizedBox(height: 16),
// Filter Tabs
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterTab('all', 'All Jobs'),
const SizedBox(width: 8),
_buildFilterTab('one-day', 'One Day'),
const SizedBox(width: 8),
_buildFilterTab('multi-day', 'Multi-Day'),
const SizedBox(width: 8),
_buildFilterTab('long-term', 'Long Term'),
],
),
),
],
),
),
// Body Content // Body Content
Expanded( Expanded(
child: SingleChildScrollView( child: state is ShiftsLoading
padding: const EdgeInsets.all(20), ? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column( child: Column(
children: [ children: [
if (_activeTab == 'find') ...[ const SizedBox(height: 20),
// 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 (_activeTab == 'myshifts') ...[
if (pendingAssignments.isNotEmpty) ...[ if (pendingAssignments.isNotEmpty) ...[
Align( _buildSectionHeader("Awaiting Confirmation", const Color(0xFFF59E0B)),
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( ...pendingAssignments.map((shift) => Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: ShiftAssignmentCard( child: ShiftAssignmentCard(
shift: shift, shift: shift,
onConfirm: () => _confirmShift(shift.id), onConfirm: () => _confirmShift(shift.id),
onDecline: () => _declineShift(shift.id), onDecline: () => _declineShift(shift.id),
isConfirming: true,
), ),
)), )),
const SizedBox(height: 12),
], ],
// Cancelled Shifts Demo (Visual only as per POC) if (visibleCancelledShifts.isNotEmpty) ...[
Align( _buildSectionHeader("Cancelled Shifts", AppColors.krowMuted),
alignment: Alignment.centerLeft, ...visibleCancelledShifts.map((shift) => Padding(
child: Padding( padding: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.only(bottom: 12), child: _buildCancelledCard(
child: const Text("Cancelled Shifts", style: TextStyle( title: shift.title,
fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowMuted client: shift.clientName,
)), pay: "\$${(shift.hourlyRate * 8).toStringAsFixed(0)}",
), rate: "\$${shift.hourlyRate}/hr · 8h",
), date: _formatDateStr(shift.date),
_buildCancelledCard( time: "${shift.startTime} - ${shift.endTime}",
title: "Annual Tech Conference", client: "TechCorp Inc.", pay: "\$200", rate: "\$25/hr · 8h", address: shift.locationAddress,
date: "Today", time: "10:00 AM - 6:00 PM", address: "123 Convention Center Dr", isLastMinute: true, isLastMinute: true,
onTap: () => setState(() => _cancelledShiftDemo = 'lastMinute') onTap: () {}
), ),
const SizedBox(height: 12), )),
_buildCancelledCard( const SizedBox(height: 12),
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 // Confirmed Shifts
if (visibleMyShifts.isNotEmpty) ...[ if (visibleMyShifts.isNotEmpty) ...[
Align( _buildSectionHeader("Confirmed Shifts", AppColors.krowMuted),
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( ...visibleMyShifts.map((shift) => Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: MyShiftCard(shift: shift), child: MyShiftCard(shift: shift),
)), )),
], ],
if (visibleMyShifts.isEmpty && pendingAssignments.isEmpty && cancelledShifts.isEmpty)
_buildEmptyState(UiIcons.calendar, "No shifts this week", "Try finding new jobs in the Find tab", null, null),
], ],
if (_activeTab == 'find') ...[ if (_activeTab == 'find') ...[
if (filteredJobs.isEmpty) if (filteredJobs.isEmpty)
_buildEmptyState(LucideIcons.search, "No jobs available", "Check back later", null, null) _buildEmptyState(UiIcons.search, "No jobs available", "Check back later", null, null)
else else
...filteredJobs.map((shift) => MyShiftCard( ...filteredJobs.map((shift) => Padding(
shift: shift, padding: const EdgeInsets.only(bottom: 12),
onAccept: () {}, child: MyShiftCard(
onDecline: () {}, shift: shift,
onAccept: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Shift Booked!'),
backgroundColor: Color(0xFF10B981),
),
);
},
onDecline: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Shift Declined'),
backgroundColor: Color(0xFFEF4444),
),
);
},
),
)), )),
], ],
if (_activeTab == 'history') if (_activeTab == 'history') ...[
_buildEmptyState(LucideIcons.clock, "No shift history", "Completed shifts appear here", null, null), if (historyShifts.isEmpty)
_buildEmptyState(UiIcons.clock, "No shift history", "Completed shifts appear here", null, null)
else
...historyShifts.map((shift) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyShiftCard(
shift: shift,
historyMode: true,
),
)),
],
const SizedBox(height: 40),
], ],
), ),
), ),
@@ -423,21 +490,57 @@ class _ShiftsPageState extends State<ShiftsPage> {
); );
} }
String _formatDateStr(String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
if (_isSameDay(date, now)) return "Today";
final tomorrow = now.add(const Duration(days: 1));
if (_isSameDay(date, tomorrow)) return "Tomorrow";
return DateFormat('EEE, MMM d').format(date);
} catch (_) {
return dateStr;
}
}
Widget _buildSectionHeader(String title, Color dotColor) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
const SizedBox(width: 8),
Text(title, style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: dotColor == AppColors.krowMuted ? AppColors.krowMuted : dotColor
)),
],
),
);
}
Widget _buildFilterTab(String id, String label) { Widget _buildFilterTab(String id, String label) {
final isSelected = _jobType == id; final isSelected = _jobType == id;
return Expanded( return GestureDetector(
child: GestureDetector( onTap: () => setState(() => _jobType = id),
onTap: () => setState(() => _jobType = id), child: Container(
child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration(
decoration: BoxDecoration( color: isSelected ? AppColors.krowBlue : Colors.white,
color: isSelected ? AppColors.krowBlue : Colors.transparent, borderRadius: BorderRadius.circular(999),
borderRadius: BorderRadius.circular(999), border: Border.all(
boxShadow: isSelected ? [BoxShadow(color: AppColors.krowBlue.withOpacity(0.2), blurRadius: 4, offset: const Offset(0, 2))] : null, color: isSelected ? AppColors.krowBlue : const Color(0xFFE2E8F0),
),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : const Color(0xFF64748B),
), ),
child: Text(label, textAlign: TextAlign.center, style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : AppColors.krowMuted
)),
), ),
), ),
); );
@@ -478,17 +581,6 @@ class _ShiftsPageState extends State<ShiftsPage> {
); );
} }
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) { 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: [ 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)), Container(width: 64, height: 64, decoration: BoxDecoration(color: const Color(0xFFF1F3F5), borderRadius: BorderRadius.circular(12)), child: Icon(icon, size: 32, color: AppColors.krowMuted)),
@@ -506,98 +598,68 @@ class _ShiftsPageState extends State<ShiftsPage> {
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}) { 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( return GestureDetector(
onTap: onTap, 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: [ child: Container(
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)))]]), padding: const EdgeInsets.all(16),
const SizedBox(height: 12), decoration: BoxDecoration(
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ color: Colors.white,
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))), borderRadius: BorderRadius.circular(16),
const SizedBox(width: 12), border: Border.all(color: AppColors.krowBorder)
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))])]), child: Column(
const SizedBox(height: 8), crossAxisAlignment: CrossAxisAlignment.start,
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))]), children: [
const SizedBox(height: 4), Row(children: [
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))]), 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)))
]
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), const SizedBox(height: 12),
Container( Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
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( Container(
padding: const EdgeInsets.all(12), width: 44,
height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFECFDF5), color: AppColors.krowBlue.withOpacity(0.05),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF10B981)),
), ),
child: const Row( child: const Center(child: Icon(LucideIcons.briefcase, color: AppColors.krowBlue, size: 20))
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),
), ),
], const SizedBox(width: 12),
), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
actions: [ Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
TextButton( Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
onPressed: () => Navigator.pop(context), Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowCharcoal)),
child: const Text("Close"), 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))
]),
])),
]),
]),
),
); );
} }
} }

View File

@@ -75,16 +75,19 @@ class _MyShiftCardState extends State<MyShiftCard> {
} }
String _getShiftType() { String _getShiftType() {
// Check title for type indicators (for mock data) if (widget.shift.durationDays != null && widget.shift.durationDays! > 30) {
if (widget.shift.title.contains('Long Term')) return t.staff_shifts.filter.long_term; return t.staff_shifts.filter.long_term;
if (widget.shift.title.contains('Multi-Day')) return t.staff_shifts.filter.multi_day; }
if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) {
return t.staff_shifts.filter.multi_day;
}
return t.staff_shifts.filter.one_day; return t.staff_shifts.filter.one_day;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// ignore: unused_local_variable
final duration = _calculateDuration(); final duration = _calculateDuration();
final estimatedTotal = (widget.shift.hourlyRate) * duration;
// Status Logic // Status Logic
String? status = widget.shift.status; String? status = widget.shift.status;
@@ -168,7 +171,7 @@ class _MyShiftCardState extends State<MyShiftCard> {
), ),
Text( Text(
statusText, statusText,
style: UiTypography.display3r.copyWith( style: UiTypography.footnote2b.copyWith(
color: statusColor, color: statusColor,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
@@ -187,9 +190,8 @@ class _MyShiftCardState extends State<MyShiftCard> {
), ),
child: Text( child: Text(
_getShiftType(), _getShiftType(),
style: UiTypography.display3r.copyWith( style: UiTypography.footnote2m.copyWith(
color: UiColors.primary, color: UiColors.primary,
fontWeight: FontWeight.w500,
), ),
), ),
), ),
@@ -198,58 +200,166 @@ class _MyShiftCardState extends State<MyShiftCard> {
), ),
), ),
// Main Content
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Date/Time Column // Logo
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
UiColors.primary.withOpacity(0.09),
UiColors.primary.withOpacity(0.03),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: UiColors.primary.withOpacity(0.09),
),
),
child: widget.shift.logoUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
widget.shift.logoUrl!,
fit: BoxFit.contain,
),
)
: const Center(
child: Icon(
UiIcons.briefcase,
color: UiColors.primary,
size: 20,
),
),
),
const SizedBox(width: 12),
// Details
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Expanded(
_formatDate(widget.shift.date), child: Column(
style: UiTypography.display2m.copyWith( crossAxisAlignment:
color: UiColors.textPrimary, CrossAxisAlignment.start,
children: [
Text(
widget.shift.title,
style: UiTypography.body2m.copyWith(
color: UiColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
Text(
widget.shift.clientName,
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
],
), ),
), ),
if (widget.shift.durationDays != null) ...[ const SizedBox(width: 8),
const SizedBox(width: 8), Column(
Container( crossAxisAlignment: CrossAxisAlignment.end,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), children: [
decoration: BoxDecoration( Text(
color: UiColors.primary.withOpacity(0.1), "\$${estimatedTotal.toStringAsFixed(0)}",
borderRadius: BorderRadius.circular(4), style: UiTypography.title1m.copyWith(
), color: UiColors.textPrimary,
child: Text(
t.staff_shifts.details.days(days: widget.shift.durationDays!),
style: UiTypography.display3r.copyWith(
color: UiColors.primary,
fontWeight: FontWeight.w600,
), ),
), ),
Text(
"\$${widget.shift.hourlyRate.toInt()}/hr · ${duration.toInt()}h",
style: UiTypography.footnote2r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
],
),
const SizedBox(height: 8),
// Date & Time - Multi-Day or Single Day
if (widget.shift.durationDays != null &&
widget.shift.durationDays! > 1) ...[
// Multi-Day Schedule Display
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
UiIcons.clock,
size: 12,
color: UiColors.primary,
),
const SizedBox(width: 4),
Text(
t.staff_shifts.details.days(
days: widget.shift.durationDays!,
),
style: UiTypography.footnote2m.copyWith(
color: UiColors.primary,
),
),
],
),
const SizedBox(height: 4),
Text(
"Showing first schedule...",
style: UiTypography.footnote2r.copyWith(
color: UiColors.primary,
),
), ),
], ],
], ),
), ] else ...[
// Single Day Display
Row(
children: [
const Icon(
UiIcons.calendar,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
_formatDate(widget.shift.date),
style: UiTypography.footnote1r.copyWith(
color: UiColors.textSecondary,
),
),
const SizedBox(width: 12),
const Icon(
UiIcons.clock,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
"${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}",
style: UiTypography.footnote1r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
],
const SizedBox(height: 4), const SizedBox(height: 4),
Text(
'${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}', // Location
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( Row(
children: [ children: [
const Icon( const Icon(
@@ -258,10 +368,15 @@ class _MyShiftCardState extends State<MyShiftCard> {
color: UiColors.iconSecondary, color: UiColors.iconSecondary,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Expanded(
widget.shift.clientName, child: Text(
style: UiTypography.display3r.copyWith( widget.shift.locationAddress.isNotEmpty
color: UiColors.textSecondary, ? widget.shift.locationAddress
: widget.shift.location,
style: UiTypography.footnote1r.copyWith(
color: UiColors.textSecondary,
),
overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
@@ -269,144 +384,389 @@ class _MyShiftCardState extends State<MyShiftCard> {
], ],
), ),
), ),
// 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 // Expanded Content
AnimatedCrossFade( AnimatedSize(
firstChild: const SizedBox(height: 0), duration: const Duration(milliseconds: 300),
secondChild: Container( child: _isExpanded
decoration: const BoxDecoration( ? Column(
border: Border( children: [
top: BorderSide(color: UiColors.border), const Divider(height: 1, color: UiColors.border),
), Padding(
), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
// Warning for Pending // Stats Row
if (status == 'pending' || status == 'open') Row(
Container( children: [
width: double.infinity, Expanded(
padding: const EdgeInsets.symmetric( child: _buildStatCard(
vertical: 8, UiIcons.dollar,
horizontal: 16, "\$${estimatedTotal.toStringAsFixed(0)}",
), "Total",
color: UiColors.accent.withOpacity(0.1), ),
child: Row( ),
children: [ const SizedBox(width: 12),
const Icon( Expanded(
UiIcons.warning, child: _buildStatCard(
size: 14, UiIcons.dollar,
color: UiColors.textWarning, "\$${widget.shift.hourlyRate.toInt()}",
), "Hourly Rate",
const SizedBox(width: 8), ),
Text( ),
t.staff_shifts.status.pending_warning, const SizedBox(width: 12),
style: UiTypography.display3r.copyWith( Expanded(
color: UiColors.textWarning, child: _buildStatCard(
fontWeight: FontWeight.w500, UiIcons.clock,
"${duration.toInt()}",
"Hours",
),
),
],
), ),
), const SizedBox(height: 24),
],
),
),
Padding( // In/Out Time
padding: const EdgeInsets.all(12), Row(
child: Row( children: [
children: [ Expanded(
if (status == 'pending' || status == 'open') ...[ child: _buildTimeBox(
Expanded( "CLOCK IN TIME",
child: OutlinedButton( widget.shift.startTime,
onPressed: widget.onDecline, ),
style: OutlinedButton.styleFrom( ),
foregroundColor: UiColors.destructive, const SizedBox(width: 12),
side: const BorderSide(color: UiColors.border), Expanded(
padding: const EdgeInsets.symmetric(vertical: 12), child: _buildTimeBox(
shape: RoundedRectangleBorder( "CLOCK OUT TIME",
borderRadius: BorderRadius.circular(8), widget.shift.endTime,
),
),
],
),
const SizedBox(height: 24),
// Location
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"LOCATION",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
widget.shift.location.isEmpty
? "TBD"
: widget.shift.location,
style: UiTypography.title1m.copyWith(
color: UiColors.textPrimary,
),
),
OutlinedButton.icon(
onPressed: () {
// Show snackbar with the address
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
widget.shift.locationAddress,
),
duration: const Duration(
seconds: 3,
),
),
);
},
icon: const Icon(
UiIcons.navigation,
size: 14,
),
label: const Text(
"Get direction",
style: TextStyle(fontSize: 12),
),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.textPrimary,
side: const BorderSide(
color: UiColors.border,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
20,
),
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 0,
),
minimumSize: const Size(0, 32),
),
),
],
),
const SizedBox(height: 12),
Container(
height: 128,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Icon(
UiIcons.mapPin,
color: UiColors.iconSecondary,
size: 32,
),
),
// Placeholder for Map
),
],
),
const SizedBox(height: 24),
// Additional Info
if (widget.shift.description != null) ...[
SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const Text(
"ADDITIONAL INFO",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Text(
widget.shift.description!.split('.')[0],
style: UiTypography.body2m.copyWith(
color: UiColors.textPrimary,
),
),
Text(
widget.shift.description!,
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
),
],
), ),
), ),
child: Text(t.staff_shifts.action.decline), const SizedBox(height: 24),
), ],
),
const SizedBox(width: 12), // Actions
Expanded( if (!widget.historyMode)
child: ElevatedButton( if (status == 'confirmed')
onPressed: widget.onAccept, SizedBox(
style: ElevatedButton.styleFrom( width: double.infinity,
backgroundColor: UiColors.primary, height: 48,
foregroundColor: Colors.white, child: OutlinedButton.icon(
elevation: 0, onPressed: widget.onRequestSwap,
padding: const EdgeInsets.symmetric(vertical: 12), icon: const Icon(
shape: RoundedRectangleBorder( UiIcons.swap,
borderRadius: BorderRadius.circular(8), size: 16,
),
label: Text(
t.staff_shifts.action.request_swap),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.primary,
side: const BorderSide(
color: UiColors.primary,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
12,
),
),
),
),
)
else if (status == 'swap')
Container(
width: double.infinity,
height: 48,
decoration: BoxDecoration(
color: const Color(
0xFFFFFBEB,
), // amber-50
border: Border.all(
color: const Color(0xFFFDE68A),
), // amber-200
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const Icon(
UiIcons.swap,
size: 16,
color: Color(0xFFB45309),
), // amber-700
const SizedBox(width: 8),
Text(
t.staff_shifts.status.swap_requested,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFFB45309),
),
),
],
),
)
else
Column(
children: [
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: widget.onAccept,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(12),
),
),
child: Text(
status == 'pending'
? t.staff_shifts.action.confirm
: "Book Shift",
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
),
),
if (status == 'pending' ||
status == 'open') ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
onPressed: widget.onDecline,
style: OutlinedButton.styleFrom(
foregroundColor:
UiColors.destructive,
side: const BorderSide(
color: UiColors.border,
),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(12),
),
),
child: Text(
t.staff_shifts.action.decline),
),
),
],
],
), ),
), ],
child: Text(t.staff_shifts.action.confirm), ),
), ),
), ],
] else if (status == 'confirmed') ...[ )
Expanded( : const SizedBox.shrink(),
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),
), ),
], ],
), ),
), ),
); );
} }
Widget _buildStatCard(IconData icon, String value, String label) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: UiColors.border),
),
child: Column(
children: [
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Icon(icon, size: 20, color: UiColors.iconSecondary),
),
const SizedBox(height: 8),
Text(
value,
style: UiTypography.title1m.copyWith(
color: UiColors.textPrimary,
),
),
Text(
label,
style: UiTypography.footnote2r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
);
}
Widget _buildTimeBox(String label, String time) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Text(
label,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 4),
Text(
_formatTime(time),
style: UiTypography.display2m.copyWith(
fontSize: 20,
color: UiColors.textPrimary,
),
),
],
),
);
}
} }

View File

@@ -67,7 +67,7 @@ class ShiftAssignmentCard extends StatelessWidget {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border), border: Border.all(color: UiColors.border),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@@ -81,160 +81,202 @@ class ShiftAssignmentCard extends StatelessWidget {
children: [ children: [
// Header // Header
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), padding: const EdgeInsets.all(16),
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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Card content starts directly as per prototype
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon( // Logo
UiIcons.calendar, Container(
size: 14, width: 44,
color: UiColors.iconSecondary, height: 44,
), decoration: BoxDecoration(
const SizedBox(width: 6), gradient: LinearGradient(
Text( colors: [
_formatDate(shift.date), UiColors.primary.withOpacity(0.09),
style: UiTypography.display3r.copyWith( UiColors.primary.withOpacity(0.03),
color: UiColors.textSecondary, ],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: UiColors.primary.withOpacity(0.09),
),
), ),
child: shift.logoUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
shift.logoUrl!,
fit: BoxFit.contain,
),
)
: const Center(
child: Icon(
UiIcons.briefcase,
color: UiColors.primary,
size: 20,
),
),
), ),
const SizedBox(width: 16), const SizedBox(width: 12),
const Icon(
UiIcons.clock, // Details
size: 14, Expanded(
color: UiColors.iconSecondary, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 6), children: [
Text( Row(
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}", mainAxisAlignment: MainAxisAlignment.spaceBetween,
style: UiTypography.display3r.copyWith( children: [
color: UiColors.textSecondary, Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shift.title,
style: UiTypography.body2m.copyWith(
color: UiColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
Text(
shift.clientName,
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"\$${totalPay.toStringAsFixed(0)}",
style: UiTypography.title1m.copyWith(
color: UiColors.textPrimary,
),
),
Text(
"\$${shift.hourlyRate.toInt()}/hr · ${hours.toInt()}h",
style: UiTypography.footnote2r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
],
),
const SizedBox(height: 12),
// Date & Time
Row(
children: [
const Icon(
UiIcons.calendar,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
_formatDate(shift.date),
style: UiTypography.footnote1r.copyWith(
color: UiColors.textSecondary,
),
),
const SizedBox(width: 12),
const Icon(
UiIcons.clock,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
style: UiTypography.footnote1r.copyWith(
color: UiColors.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.copyWith(
color: UiColors.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
), ),
), ),
], ],
), ),
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) ...[ Padding(
const Divider(height: 1), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: TextButton( child: OutlinedButton(
onPressed: onDecline, onPressed: onDecline,
style: TextButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive, foregroundColor: UiColors.iconSecondary,
padding: const EdgeInsets.symmetric(vertical: 16), side: const BorderSide(color: UiColors.border),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
), ),
child: Text(t.staff_shifts.action.decline), child: Text(t.staff_shifts.action.decline),
), ),
), ),
Container(width: 1, height: 48, color: UiColors.border), const SizedBox(width: 12),
Expanded( Expanded(
child: TextButton( child: ElevatedButton(
onPressed: onConfirm, onPressed: onConfirm,
style: TextButton.styleFrom( style: ElevatedButton.styleFrom(
foregroundColor: UiColors.primary, backgroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(vertical: 16), foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
), ),
child: Text(t.staff_shifts.action.confirm), child: Text(t.staff_shifts.action.confirm),
), ),
), ),
], ],
), ),
], ),
], ],
), ),
); );

View File

@@ -4,6 +4,11 @@ import 'data/repositories_impl/shifts_repository_impl.dart';
import 'domain/usecases/get_my_shifts_usecase.dart'; import 'domain/usecases/get_my_shifts_usecase.dart';
import 'domain/usecases/get_available_shifts_usecase.dart'; import 'domain/usecases/get_available_shifts_usecase.dart';
import 'domain/usecases/get_pending_assignments_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/get_shift_details_usecase.dart';
import 'presentation/blocs/shifts/shifts_bloc.dart'; import 'presentation/blocs/shifts/shifts_bloc.dart';
import 'presentation/pages/shifts_page.dart'; import 'presentation/pages/shifts_page.dart';
import 'presentation/pages/shift_details_page.dart'; import 'presentation/pages/shift_details_page.dart';
@@ -18,6 +23,11 @@ class StaffShiftsModule extends Module {
i.add(GetMyShiftsUseCase.new); i.add(GetMyShiftsUseCase.new);
i.add(GetAvailableShiftsUseCase.new); i.add(GetAvailableShiftsUseCase.new);
i.add(GetPendingAssignmentsUseCase.new); i.add(GetPendingAssignmentsUseCase.new);
i.add(GetCancelledShiftsUseCase.new);
i.add(GetHistoryShiftsUseCase.new);
i.add(AcceptShiftUseCase.new);
i.add(DeclineShiftUseCase.new);
i.add(GetShiftDetailsUseCase.new);
// Bloc // Bloc
i.add(ShiftsBloc.new); i.add(ShiftsBloc.new);