Merge branch 'dev' into fix/check-boris-applied

This commit is contained in:
bwnyasse
2026-01-31 20:04:10 -05:00
26 changed files with 1859 additions and 814 deletions

View File

@@ -6,7 +6,7 @@
/// Locales: 2
/// Strings: 1108 (554 per locale)
///
/// Built on 2026-01-31 at 17:08 UTC
/// Built on 2026-01-31 at 17:37 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View File

@@ -1,17 +1,10 @@
import 'package:design_system/design_system.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 {
/// Void callback for when the Sign Up button is pressed.
final VoidCallback onSignUpPressed;
/// Void callback for when the Log In button is pressed.
final VoidCallback onLoginPressed;
/// Creates a [GetStartedActions].
const GetStartedActions({
super.key,
required this.onSignUpPressed,
@@ -22,19 +15,37 @@ class GetStartedActions extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// Sign Up Button
UiButton.primary(
text: t.staff_authentication.get_started_page.sign_up_button,
children: [
ElevatedButton(
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: 12),
// Log In Button
UiButton.secondary(
text: t.staff_authentication.get_started_page.log_in_button,
const SizedBox(height: 16),
OutlinedButton(
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:design_system/design_system.dart';
/// A widget that displays the background for the Get Started page.
class GetStartedBackground extends StatelessWidget {
/// Creates a [GetStartedBackground].
const GetStartedBackground({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 24.0),
return Container(
color: Colors.white,
child: Column(
children: <Widget>[
children: [
const SizedBox(height: 32),
// Logo
Image.asset(UiImageAssets.logoBlue, height: 40),
Image.asset(
UiImageAssets.logoBlue,
height: 40,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// Hero Image
Container(
width: 288,
height: 288,
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: UiColors.secondaryForeground.withAlpha(
64,
), // 0.5 opacity
),
child: Padding(
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,
),
),
child: Center(
child: Container(
width: 288,
height: 288,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF3A4A5A).withOpacity(0.05),
),
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,
errorBuilder: (context, error, stackTrace) {
return Image.asset(UiImageAssets.logoBlue);
},
),
),
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: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 {
/// Creates a [GetStartedHeader].
const GetStartedHeader({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
RichText(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Krow Workforce',
style: UiTypography.display1b.copyWith(color: UiColors.textPrimary),
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),
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,
style: UiTypography.body1r.textSecondary,
),
],
);

View File

@@ -1,4 +1,5 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import '../../domain/usecases/get_todays_shift_usecase.dart';
import '../../domain/usecases/get_attendance_status_usecase.dart';
import '../../domain/usecases/clock_in_usecase.dart';
@@ -16,6 +17,11 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
final ClockOutUseCase _clockOut;
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({
required GetTodaysShiftUseCase getTodaysShift,
required GetAttendanceStatusUseCase getAttendanceStatus,
@@ -33,6 +39,9 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
on<CheckInRequested>(_onCheckIn);
on<CheckOutRequested>(_onCheckOut);
on<CheckInModeChanged>(_onModeChanged);
on<RequestLocationPermission>(_onRequestLocationPermission);
on<CommuteModeToggled>(_onCommuteModeToggled);
on<LocationUpdated>(_onLocationUpdated);
add(ClockInPageLoaded());
}
@@ -47,12 +56,20 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
final status = await _getAttendanceStatus();
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(
status: ClockInStatus.success,
todayShift: shift,
attendance: status,
activityLog: activity,
));
if (shift != null && !status.isCheckedIn) {
add(RequestLocationPermission());
}
} catch (e) {
emit(state.copyWith(
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(
DateSelected event,
Emitter<ClockInState> emit,
@@ -79,6 +159,13 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState> {
CheckInRequested event,
Emitter<ClockInState> emit,
) 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));
try {
final newStatus = await _clockIn(

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:geolocator/geolocator.dart';
abstract class ClockInEvent extends Equatable {
const ClockInEvent();
@@ -46,3 +47,25 @@ class CheckInModeChanged extends ClockInEvent {
@override
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:krow_domain/krow_domain.dart';
import 'package:geolocator/geolocator.dart';
enum ClockInStatus { initial, loading, success, failure, actionInProgress }
@@ -12,6 +14,13 @@ class ClockInState extends Equatable {
final String checkInMode;
final String? errorMessage;
final Position? currentLocation;
final double? distanceFromVenue;
final bool isLocationVerified;
final bool isCommuteModeOn;
final bool hasLocationConsent;
final int? etaMinutes;
const ClockInState({
this.status = ClockInStatus.initial,
this.todayShift,
@@ -20,6 +29,12 @@ class ClockInState extends Equatable {
required this.selectedDate,
this.checkInMode = 'swipe',
this.errorMessage,
this.currentLocation,
this.distanceFromVenue,
this.isLocationVerified = false,
this.isCommuteModeOn = false,
this.hasLocationConsent = false,
this.etaMinutes,
});
ClockInState copyWith({
@@ -30,6 +45,12 @@ class ClockInState extends Equatable {
DateTime? selectedDate,
String? checkInMode,
String? errorMessage,
Position? currentLocation,
double? distanceFromVenue,
bool? isLocationVerified,
bool? isCommuteModeOn,
bool? hasLocationConsent,
int? etaMinutes,
}) {
return ClockInState(
status: status ?? this.status,
@@ -39,6 +60,12 @@ class ClockInState extends Equatable {
selectedDate: selectedDate ?? this.selectedDate,
checkInMode: checkInMode ?? this.checkInMode,
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,
checkInMode,
errorMessage,
currentLocation,
distanceFromVenue,
isLocationVerified,
isCommuteModeOn,
hasLocationConsent,
etaMinutes,
];
}

View File

@@ -92,10 +92,13 @@ class _ClockInPageState extends State<ClockInPage> {
if (todayShift != null)
CommuteTracker(
shift: todayShift,
hasLocationConsent: false, // Mock value
isCommuteModeOn: false, // Mock value
distanceMeters: 500, // Mock value for demo
etaMinutes: 8, // Mock value for demo
hasLocationConsent: state.hasLocationConsent,
isCommuteModeOn: state.isCommuteModeOn,
distanceMeters: state.distanceFromVenue,
etaMinutes: state.etaMinutes,
onCommuteToggled: (value) {
_bloc.add(CommuteModeToggled(value));
},
),
// Date Selector
DateSelector(
@@ -183,9 +186,9 @@ class _ClockInPageState extends State<ClockInPage> {
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text(
"9:00 AM - 5:00 PM",
style: TextStyle(
Text(
"${_formatTime(todayShift.startTime)} - ${_formatTime(todayShift.endTime)}",
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF475569), // slate-600
@@ -207,36 +210,73 @@ class _ClockInPageState extends State<ClockInPage> {
// Swipe To Check In / Checked Out State / No Shift State
if (todayShift != null && checkOutTime == null) ...[
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),
if (!isCheckedIn && !_isCheckInAllowed(todayShift))
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9), // slate-100
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
const Icon(
LucideIcons.clock,
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 &&
checkOutTime != null) ...[
// Shift Completed State
@@ -695,4 +735,43 @@ class _ClockInPageState extends State<ClockInPage> {
_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 {
final Shift? shift;
final Function(CommuteMode)? onModeChange;
final ValueChanged<bool>? onCommuteToggled;
final bool hasLocationConsent;
final bool isCommuteModeOn;
final double? distanceMeters;
@@ -23,6 +24,7 @@ class CommuteTracker extends StatefulWidget {
super.key,
this.shift,
this.onModeChange,
this.onCommuteToggled,
this.hasLocationConsent = false,
this.isCommuteModeOn = false,
this.distanceMeters,
@@ -44,6 +46,21 @@ class _CommuteTrackerState extends State<CommuteTracker> {
_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() {
if (widget.shift == null) return CommuteMode.lockedNoShift;
@@ -299,6 +316,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
value: _localIsCommuteOn,
onChanged: (value) {
setState(() => _localIsCommuteOn = value);
widget.onCommuteToggled?.call(value);
},
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/src/session/staff_session_store.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../../domain/repositories/payments_repository.dart';
class PaymentsRepositoryImpl implements PaymentsRepository {
final dc.ExampleConnector _dataConnect;
final FirebaseAuth _auth = FirebaseAuth.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
DateTime? _toDateTime(dynamic t) {
if (t == null) return null;
if (t is DateTime) return t;
if (t is String) return DateTime.tryParse(t);
try {
// Attempt to deserialize via standard methods
return DateTime.tryParse(t.toJson() as String);
} catch (_) {
try {
return DateTime.tryParse(t.toString());
} catch (e) {
return null;
if (t is Timestamp) {
return t.toDateTime();
}
}
} catch (_) {}
try {
if (t.runtimeType.toString().contains('Timestamp')) {
return (t as dynamic).toDate();
}
} catch (_) {}
try {
return DateTime.tryParse(t.toString());
} catch (_) {}
return null;
}
@override
Future<PaymentSummary> getPaymentSummary() async {
final StaffSession? session = StaffSessionStore.instance.session;
if (session?.staff?.id == null) {
return const PaymentSummary(
weeklyEarnings: 0,
monthlyEarnings: 0,
pendingEarnings: 0,
totalEarnings: 0,
);
}
final String currentStaffId = session!.staff!.id;
final String currentStaffId = await _getStaffId();
// Fetch recent payments with a limit
// Note: limit is chained on the query builder
@@ -82,10 +116,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository {
@override
Future<List<StaffPayment>> getPaymentHistory(String period) async {
final StaffSession? session = StaffSessionStore.instance.session;
if (session?.staff?.id == null) return <StaffPayment>[];
final String currentStaffId = session!.staff!.id;
final String currentStaffId = await _getStaffId();
try {
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:intl/intl.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart';
import '../../domain/repositories/shifts_repository_interface.dart';
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
@@ -16,31 +17,62 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
// Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation)
final Map<String, String> _appToRoleIdMap = {};
String get _currentStaffId {
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;
return session!.staff!.id;
}
// Fallback? Or throw.
// If not logged in, we shouldn't be here.
return _auth.currentUser?.uid ?? 'STAFF_123';
// 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 (should ideally not happen if DB is seeded)
return user.uid;
}
/// Helper to convert Data Connect Timestamp to DateTime
DateTime? _toDateTime(dynamic t) {
if (t == null) return null;
if (t is DateTime) return t;
if (t is String) return DateTime.tryParse(t);
// Data Connect Timestamp handling
try {
if (t is String) return DateTime.tryParse(t);
// If it accepts toJson
try {
return DateTime.tryParse(t.toJson() as String);
} catch (_) {}
// If it's a Timestamp object (depends on SDK), usually .toDate() exists but 'dynamic' hides it.
// Assuming toString or toJson covers it, or using helper.
return DateTime.now(); // Placeholder if type unknown, but ideally fetch correct value
} catch (_) {
return null;
}
if (t is Timestamp) {
return t.toDateTime();
}
} catch (_) {}
try {
// Fallback for any object that might have a toDate or similar
if (t.runtimeType.toString().contains('Timestamp')) {
return (t as dynamic).toDate();
}
} catch (_) {}
try {
return DateTime.tryParse(t.toString());
} catch (_) {}
return null;
}
@override
@@ -53,22 +85,49 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
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 {
try {
final staffId = await _getStaffId();
final response = await _dataConnect
.getApplicationsByStaffId(staffId: _currentStaffId)
.getApplicationsByStaffId(staffId: staffId)
.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 = [];
for (final app in apps) {
_shiftToAppIdMap[app.shift.id] = app.id;
_appToRoleIdMap[app.id] = app.shiftRole.id;
final shiftTuple = await _getShiftDetails(app.shift.id);
if (shiftTuple != null) {
shifts.add(shiftTuple);
final shift = await _getShiftDetails(app.shift.id);
if (shift != null) {
// 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;
@@ -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
Future<List<Shift>> getAvailableShifts(String query, String type) async {
try {
@@ -104,8 +179,9 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: s.status?.stringValue ?? 'OPEN',
status: s.status?.stringValue.toLowerCase() ?? 'open',
description: s.description,
durationDays: s.durationDays,
));
}
@@ -152,6 +228,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
createdDate: createdDt?.toIso8601String() ?? '',
status: s.status?.stringValue ?? 'OPEN',
description: s.description,
durationDays: s.durationDays,
);
} catch (e) {
return null;
@@ -165,9 +242,10 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
final role = rolesResult.data.shiftRoles.first;
final staffId = await _getStaffId();
await _dataConnect.createApplication(
shiftId: shiftId,
staffId: _currentStaffId,
staffId: staffId,
roleId: role.id,
status: dc.ApplicationStatus.PENDING,
origin: dc.ApplicationOrigin.STAFF,
@@ -198,7 +276,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
roleId = _appToRoleIdMap[appId];
} else {
// 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;
if (app != null) {
appId = app.id;

View File

@@ -25,4 +25,10 @@ abstract interface class ShiftsRepositoryInterface {
/// Declines a pending shift assignment.
Future<void> declineShift(String shiftId);
/// Retrieves shifts that were cancelled for the current user.
Future<List<Shift>> getCancelledShifts();
/// 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/usecases/get_my_shifts_usecase.dart';
import '../../../domain/usecases/get_pending_assignments_usecase.dart';
import '../../../domain/usecases/get_cancelled_shifts_usecase.dart';
import '../../../domain/usecases/get_history_shifts_usecase.dart';
import '../../../domain/usecases/accept_shift_usecase.dart';
import '../../../domain/usecases/decline_shift_usecase.dart';
part 'shifts_event.dart';
part 'shifts_state.dart';
@@ -15,14 +19,24 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
final GetMyShiftsUseCase getMyShifts;
final GetAvailableShiftsUseCase getAvailableShifts;
final GetPendingAssignmentsUseCase getPendingAssignments;
final GetCancelledShiftsUseCase getCancelledShifts;
final GetHistoryShiftsUseCase getHistoryShifts;
final AcceptShiftUseCase acceptShift;
final DeclineShiftUseCase declineShift;
ShiftsBloc({
required this.getMyShifts,
required this.getAvailableShifts,
required this.getPendingAssignments,
required this.getCancelledShifts,
required this.getHistoryShifts,
required this.acceptShift,
required this.declineShift,
}) : super(ShiftsInitial()) {
on<LoadShiftsEvent>(_onLoadShifts);
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
on<AcceptShiftEvent>(_onAcceptShift);
on<DeclineShiftEvent>(_onDeclineShift);
}
Future<void> _onLoadShifts(
@@ -39,6 +53,8 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
try {
final myShiftsResult = await getMyShifts();
final pendingResult = await getPendingAssignments();
final cancelledResult = await getCancelledShifts();
final historyResult = await getHistoryShifts();
// Initial available with defaults
final availableResult = await getAvailableShifts(const GetAvailableShiftsArguments());
@@ -46,7 +62,9 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
emit(ShiftsLoaded(
myShifts: myShiftsResult,
pendingShifts: pendingResult,
cancelledShifts: cancelledResult,
availableShifts: availableResult,
historyShifts: historyResult,
searchQuery: '',
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
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 {
final List<Shift> myShifts;
final List<Shift> pendingShifts;
final List<Shift> cancelledShifts;
final List<Shift> availableShifts;
final List<Shift> historyShifts;
final String searchQuery;
final String jobType;
const ShiftsLoaded({
required this.myShifts,
required this.pendingShifts,
required this.cancelledShifts,
required this.availableShifts,
required this.historyShifts,
required this.searchQuery,
required this.jobType,
});
@@ -30,21 +34,33 @@ class ShiftsLoaded extends ShiftsState {
ShiftsLoaded copyWith({
List<Shift>? myShifts,
List<Shift>? pendingShifts,
List<Shift>? cancelledShifts,
List<Shift>? availableShifts,
List<Shift>? historyShifts,
String? searchQuery,
String? jobType,
}) {
return ShiftsLoaded(
myShifts: myShifts ?? this.myShifts,
pendingShifts: pendingShifts ?? this.pendingShifts,
cancelledShifts: cancelledShifts ?? this.cancelledShifts,
availableShifts: availableShifts ?? this.availableShifts,
historyShifts: historyShifts ?? this.historyShifts,
searchQuery: searchQuery ?? this.searchQuery,
jobType: jobType ?? this.jobType,
);
}
@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 {

View File

@@ -4,6 +4,10 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.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
class AppColors {
@@ -32,11 +36,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
bool _showDetails = true;
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
void initState() {
@@ -49,27 +49,30 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
_shift = widget.shift!;
setState(() => _isLoading = false);
} else {
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
// Fallback mock shift
setState(() {
_shift = Shift(
id: widget.shiftId,
title: 'Event Server',
clientName: 'Grand Hotel',
logoUrl: null,
hourlyRate: 25.0,
date: DateFormat('yyyy-MM-dd').format(DateTime.now()),
startTime: '16:00',
endTime: '22:00',
location: 'Downtown',
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.',
);
_isLoading = false;
});
try {
final useCase = Modular.get<GetShiftDetailsUseCase>();
final shift = await useCase(widget.shiftId);
if (mounted) {
if (shift != null) {
setState(() {
_shift = shift;
_isLoading = false;
});
} else {
// Handle case where shift is not found
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shift not found')),
);
}
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading shift: $e')),
);
}
}
}
}
@@ -143,6 +146,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Pending Badge
// Status Badge
Align(
alignment: Alignment.centerRight,
child: Container(
@@ -151,15 +155,15 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.krowYellow.withOpacity(0.3),
color: _getStatusColor(_shift.status ?? 'open').withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'Pending 6h ago',
child: Text(
(_shift.status ?? 'open').toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.krowCharcoal,
color: _getStatusColor(_shift.status ?? 'open'),
),
),
),
@@ -248,25 +252,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
),
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
Container(
@@ -310,11 +296,11 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
children: [
_buildDetailRow('Tips', 'Yes', true),
_buildDetailRow('Travel Time', 'Yes', true),
_buildDetailRow('Meal Provided', 'No', false),
_buildDetailRow('Parking Available', 'Yes', true),
_buildDetailRow('Gas Compensation', 'No', false),
_buildDetailRow('Tips', _shift.tipsAvailable == true ? 'Yes' : 'No', _shift.tipsAvailable == true),
_buildDetailRow('Travel Time', _shift.travelTime == true ? 'Yes' : 'No', _shift.travelTime == true),
_buildDetailRow('Meal Provided', _shift.mealProvided == true ? 'Yes' : 'No', _shift.mealProvided == true),
_buildDetailRow('Parking Available', _shift.parkingAvailable == true ? 'Yes' : 'No', _shift.parkingAvailable == true),
_buildDetailRow('Gas Compensation', _shift.gasCompensation == true ? 'Yes' : 'No', _shift.gasCompensation == true),
],
),
),
@@ -550,7 +536,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
),
),
const SizedBox(height: 16),
..._managers
...(_shift.managers ?? [])
.map(
(manager) => Padding(
padding: const EdgeInsets.only(bottom: 16),
@@ -574,13 +560,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
8,
),
),
child: const Center(
child: Icon(
LucideIcons.user,
color: Colors.white,
size: 20,
),
),
child: _buildAvatar(manager),
),
const SizedBox(width: 12),
Column(
@@ -588,14 +568,14 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
CrossAxisAlignment.start,
children: [
Text(
manager['name']!,
manager.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.krowCharcoal,
),
),
Text(
manager['phone']!,
manager.phone,
style: const TextStyle(
fontSize: 12,
color: AppColors.krowMuted,
@@ -611,7 +591,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
context,
).showSnackBar(
SnackBar(
content: Text(manager['phone']!),
content: Text(manager.phone),
duration: const Duration(seconds: 3),
),
);
@@ -702,16 +682,32 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
child: ElevatedButton(
onPressed: () async {
setState(() => _isApplying = true);
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
setState(() => _isApplying = false);
Modular.to.pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Shift Accepted!'),
backgroundColor: Color(0xFF10B981),
),
);
try {
final acceptUseCase = Modular.get<AcceptShiftUseCase>();
await acceptUseCase(_shift.id);
if (mounted) {
setState(() => _isApplying = false);
Modular.to.pop();
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(
@@ -744,7 +740,33 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
width: double.infinity,
height: 48,
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(
'Decline shift',
style: TextStyle(
@@ -789,6 +811,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) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),

View File

@@ -75,16 +75,22 @@ class _ShiftsPageState extends State<ShiftsPage> {
}
void _confirmShift(String id) {
// TODO: Implement Bloc event
_bloc.add(AcceptShiftEvent(id));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shift confirmed! (Placeholder)')),
const SnackBar(
content: Text('Shift confirmed!'),
backgroundColor: Color(0xFF10B981),
),
);
}
void _declineShift(String id) {
// TODO: Implement Bloc event
_bloc.add(DeclineShiftEvent(id));
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> availableJobs = (state is ShiftsLoaded) ? state.availableShifts : [];
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 matchesSearch =
s.title.toLowerCase().contains(_searchQuery.toLowerCase()) ||
@@ -110,10 +117,9 @@ class _ShiftsPageState extends State<ShiftsPage> {
if (_jobType == 'all') return true;
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 == 'long-term') return s.title.contains('Long Term');
if (_jobType == 'multi-day') return s.durationDays != null && s.durationDays! > 1;
return true;
}).toList();
@@ -122,13 +128,23 @@ class _ShiftsPageState extends State<ShiftsPage> {
final weekEndDate = calendarDays.last;
final visibleMyShifts = myShifts.where((s) {
// Primitive check if shift date string compare
// In real app use DateTime logic
final sDateStr = s.date;
final wStartStr = DateFormat('yyyy-MM-dd').format(weekStartDate);
final wEndStr = DateFormat('yyyy-MM-dd').format(weekEndDate);
return sDateStr.compareTo(wStartStr) >= 0 &&
sDateStr.compareTo(wEndStr) <= 0;
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();
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();
return Scaffold(
@@ -140,9 +156,9 @@ class _ShiftsPageState extends State<ShiftsPage> {
color: AppColors.krowBlue,
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 20,
MediaQuery.of(context).padding.top + 10,
20,
20,
24,
),
child: Column(
children: [
@@ -157,17 +173,28 @@ class _ShiftsPageState extends State<ShiftsPage> {
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),
// Tabs
Row(
children: [
_buildTab("myshifts", "My Shifts", LucideIcons.calendar, myShifts.length),
_buildTab("myshifts", "My Shifts", UiIcons.calendar, myShifts.length),
const SizedBox(width: 8),
_buildTab("find", "Find Shifts", LucideIcons.search, filteredJobs.length),
_buildTab("find", "Find Shifts", UiIcons.search, filteredJobs.length),
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')
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
onTap: () => setState(() => _weekOffset--),
borderRadius: BorderRadius.circular(20),
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(LucideIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal),
),
IconButton(
icon: const Icon(UiIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal),
onPressed: () => setState(() => _weekOffset--),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
),
Text(
DateFormat('MMMM yyyy').format(weekStartDate),
@@ -202,13 +227,11 @@ class _ShiftsPageState extends State<ShiftsPage> {
color: AppColors.krowCharcoal,
),
),
InkWell(
onTap: () => setState(() => _weekOffset++),
borderRadius: BorderRadius.circular(20),
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(LucideIcons.chevronRight, size: 20, color: AppColors.krowCharcoal),
),
IconButton(
icon: const Icon(UiIcons.chevronRight, size: 20, color: AppColors.krowCharcoal),
onPressed: () => setState(() => _weekOffset++),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
),
],
),
@@ -219,52 +242,60 @@ class _ShiftsPageState extends State<ShiftsPage> {
children: calendarDays.map((date) {
final isSelected = _isSameDay(date, _selectedDate);
final dateStr = DateFormat('yyyy-MM-dd').format(date);
final hasShifts = myShifts.any((s) => s.date == dateStr);
final hasShifts = myShifts.any((s) {
try {
return _isSameDay(DateTime.parse(s.date), date);
} catch (_) { return false; }
});
return GestureDetector(
onTap: () => setState(() => _selectedDate = date),
child: Container(
width: 44,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected ? AppColors.krowBlue : Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: isSelected ? AppColors.krowBlue : AppColors.krowBorder,
width: 1,
),
),
child: Column(
children: [
Text(
date.day.toString().padLeft(2, '0'),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: isSelected ? Colors.white : AppColors.krowCharcoal,
child: Column(
children: [
Container(
width: 44,
height: 60,
decoration: BoxDecoration(
color: isSelected ? AppColors.krowBlue : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppColors.krowBlue : AppColors.krowBorder,
width: 1,
),
),
const SizedBox(height: 2),
Text(
DateFormat('E').format(date),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.krowMuted,
),
),
if (hasShifts)
Container(
margin: const EdgeInsets.only(top: 4),
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected ? Colors.white : AppColors.krowBlue,
shape: BoxShape.circle,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
date.day.toString().padLeft(2, '0'),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isSelected ? Colors.white : AppColors.krowCharcoal,
),
),
),
],
),
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(),
@@ -276,141 +307,177 @@ class _ShiftsPageState extends State<ShiftsPage> {
if (_activeTab == 'myshifts')
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
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: state is ShiftsLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
if (_activeTab == 'find') ...[
// Search & Filter
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.krowBorder),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (val) => setState(() => _searchQuery = val), // Local filter for now
decoration: const InputDecoration(
prefixIcon: Icon(LucideIcons.search, size: 20, color: AppColors.krowMuted),
border: InputBorder.none,
hintText: "Search jobs...",
hintStyle: TextStyle(color: AppColors.krowMuted, fontSize: 14),
contentPadding: EdgeInsets.symmetric(vertical: 12),
),
),
),
),
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFF1F3F5),
borderRadius: BorderRadius.circular(999),
),
child: Row(
children: [
_buildFilterTab('all', 'All Jobs'),
_buildFilterTab('one-day', 'One Day'),
_buildFilterTab('multi-day', 'Multi-Day'),
_buildFilterTab('long-term', 'Long Term'),
],
),
),
],
const SizedBox(height: 20),
if (_activeTab == 'myshifts') ...[
if (pendingAssignments.isNotEmpty) ...[
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(width: 8, height: 8, decoration: const BoxDecoration(color: Color(0xFFF59E0B), shape: BoxShape.circle)),
const SizedBox(width: 8),
const Text("Awaiting Confirmation", style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFFD97706)
)),
],
),
),
),
_buildSectionHeader("Awaiting Confirmation", const Color(0xFFF59E0B)),
...pendingAssignments.map((shift) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: ShiftAssignmentCard(
shift: shift,
onConfirm: () => _confirmShift(shift.id),
onDecline: () => _declineShift(shift.id),
isConfirming: true,
),
)),
const SizedBox(height: 12),
],
// Cancelled Shifts Demo (Visual only as per POC)
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: const Text("Cancelled Shifts", style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowMuted
)),
),
),
_buildCancelledCard(
title: "Annual Tech Conference", client: "TechCorp Inc.", pay: "\$200", rate: "\$25/hr · 8h",
date: "Today", time: "10:00 AM - 6:00 PM", address: "123 Convention Center Dr", isLastMinute: true,
onTap: () => setState(() => _cancelledShiftDemo = 'lastMinute')
),
const SizedBox(height: 12),
_buildCancelledCard(
title: "Morning Catering Setup", client: "EventPro Services", pay: "\$120", rate: "\$20/hr · 6h",
date: "Tomorrow", time: "8:00 AM - 2:00 PM", address: "456 Grand Ballroom Ave", isLastMinute: false,
onTap: () => setState(() => _cancelledShiftDemo = 'advance')
),
const SizedBox(height: 24),
if (visibleCancelledShifts.isNotEmpty) ...[
_buildSectionHeader("Cancelled Shifts", AppColors.krowMuted),
...visibleCancelledShifts.map((shift) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _buildCancelledCard(
title: shift.title,
client: shift.clientName,
pay: "\$${(shift.hourlyRate * 8).toStringAsFixed(0)}",
rate: "\$${shift.hourlyRate}/hr · 8h",
date: _formatDateStr(shift.date),
time: "${shift.startTime} - ${shift.endTime}",
address: shift.locationAddress,
isLastMinute: true,
onTap: () {}
),
)),
const SizedBox(height: 12),
],
// Confirmed Shifts
if (visibleMyShifts.isNotEmpty) ...[
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: const Text("Confirmed Shifts", style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowMuted
)),
),
),
_buildSectionHeader("Confirmed Shifts", AppColors.krowMuted),
...visibleMyShifts.map((shift) => Padding(
padding: const EdgeInsets.only(bottom: 12),
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 (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
...filteredJobs.map((shift) => MyShiftCard(
shift: shift,
onAccept: () {},
onDecline: () {},
...filteredJobs.map((shift) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyShiftCard(
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')
_buildEmptyState(LucideIcons.clock, "No shift history", "Completed shifts appear here", null, null),
if (_activeTab == 'history') ...[
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) {
final isSelected = _jobType == id;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _jobType = id),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSelected ? AppColors.krowBlue : Colors.transparent,
borderRadius: BorderRadius.circular(999),
boxShadow: isSelected ? [BoxShadow(color: AppColors.krowBlue.withOpacity(0.2), blurRadius: 4, offset: const Offset(0, 2))] : null,
return GestureDetector(
onTap: () => setState(() => _jobType = id),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? AppColors.krowBlue : Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(
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) {
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)),
@@ -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}) {
return GestureDetector(
onTap: onTap,
child: Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.krowBorder)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFEF4444), shape: BoxShape.circle)), const SizedBox(width: 6), const Text("CANCELLED", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFFEF4444))), if (isLastMinute) ...[const SizedBox(width: 4), const Text("• 4hr compensation", style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFF10B981)))]]),
const SizedBox(height: 12),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(width: 44, height: 44, decoration: BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.krowBlue.withAlpha((0.15 * 255).round()), AppColors.krowBlue.withAlpha((0.08 * 255).round())]), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.krowBlue.withAlpha((0.15 * 255).round()))), child: const Center(child: Icon(LucideIcons.briefcase, color: AppColors.krowBlue, size: 20))),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowCharcoal)), Text(client, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text(pay, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.krowCharcoal)), Text(rate, style: const TextStyle(fontSize: 10, color: AppColors.krowMuted))])]),
const SizedBox(height: 8),
Row(children: [const Icon(LucideIcons.calendar, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Text(date, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)), const SizedBox(width: 12), const Icon(LucideIcons.clock, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Text(time, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))]),
const SizedBox(height: 4),
Row(children: [const Icon(LucideIcons.mapPin, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Expanded(child: Text(address, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted), overflow: TextOverflow.ellipsis))]),
])),
]),
])),
);
}
void _showCancelledModal(String type) {
final isLastMinute = type == 'lastMinute';
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
const Icon(LucideIcons.xCircle, color: Color(0xFFEF4444)),
const SizedBox(width: 8),
const Text("Shift Cancelled"),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"We're sorry, but the following shift has been cancelled by the client:",
style: TextStyle(fontSize: 14),
),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.krowBorder)
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFEF4444), shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text("CANCELLED", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFFEF4444))),
if (isLastMinute) ...[
const SizedBox(width: 4),
const Text("• 4hr compensation", style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFF10B981)))
]
]),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Annual Tech Conference", style: TextStyle(fontWeight: FontWeight.bold)),
Text("Today, 10:00 AM - 6:00 PM"),
],
),
),
const SizedBox(height: 16),
if (isLastMinute)
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
padding: const EdgeInsets.all(12),
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFECFDF5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF10B981)),
),
child: const Row(
children: [
Icon(LucideIcons.checkCircle, color: Color(0xFF10B981), size: 16),
SizedBox(width: 8),
Expanded(
child: Text(
"You are eligible for 4hr cancellation compensation.",
style: TextStyle(
fontSize: 12, color: Color(0xFF065F46), fontWeight: FontWeight.w500),
),
),
],
),
)
else
const Text(
"Reduced schedule at the venue. No compensation is due as this was cancelled more than 4 hours in advance.",
style: TextStyle(fontSize: 12, color: AppColors.krowMuted),
color: AppColors.krowBlue.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
),
child: const Center(child: Icon(LucideIcons.briefcase, color: AppColors.krowBlue, size: 20))
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Close"),
),
],
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowCharcoal)),
Text(client, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))
])),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text(pay, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.krowCharcoal)),
Text(rate, style: const TextStyle(fontSize: 10, color: AppColors.krowMuted))
])
]),
const SizedBox(height: 8),
Row(children: [
const Icon(LucideIcons.calendar, size: 12, color: AppColors.krowMuted),
const SizedBox(width: 4),
Text(date, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)),
const SizedBox(width: 12),
const Icon(LucideIcons.clock, size: 12, color: AppColors.krowMuted),
const SizedBox(width: 4),
Text(time, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))
]),
const SizedBox(height: 4),
Row(children: [
const Icon(LucideIcons.mapPin, size: 12, color: AppColors.krowMuted),
const SizedBox(width: 4),
Expanded(child: Text(address, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted), overflow: TextOverflow.ellipsis))
]),
])),
]),
]),
),
);
}
}

View File

@@ -75,16 +75,19 @@ class _MyShiftCardState extends State<MyShiftCard> {
}
String _getShiftType() {
// Check title for type indicators (for mock data)
if (widget.shift.title.contains('Long Term')) return t.staff_shifts.filter.long_term;
if (widget.shift.title.contains('Multi-Day')) return t.staff_shifts.filter.multi_day;
if (widget.shift.durationDays != null && widget.shift.durationDays! > 30) {
return t.staff_shifts.filter.long_term;
}
if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) {
return t.staff_shifts.filter.multi_day;
}
return t.staff_shifts.filter.one_day;
}
@override
Widget build(BuildContext context) {
// ignore: unused_local_variable
final duration = _calculateDuration();
final estimatedTotal = (widget.shift.hourlyRate) * duration;
// Status Logic
String? status = widget.shift.status;
@@ -168,7 +171,7 @@ class _MyShiftCardState extends State<MyShiftCard> {
),
Text(
statusText,
style: UiTypography.display3r.copyWith(
style: UiTypography.footnote2b.copyWith(
color: statusColor,
letterSpacing: 0.5,
),
@@ -187,9 +190,8 @@ class _MyShiftCardState extends State<MyShiftCard> {
),
child: Text(
_getShiftType(),
style: UiTypography.display3r.copyWith(
style: UiTypography.footnote2m.copyWith(
color: UiColors.primary,
fontWeight: FontWeight.w500,
),
),
),
@@ -197,59 +199,167 @@ class _MyShiftCardState extends State<MyShiftCard> {
],
),
),
// Main Content
Row(
crossAxisAlignment: CrossAxisAlignment.start,
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDate(widget.shift.date),
style: UiTypography.display2m.copyWith(
color: UiColors.textPrimary,
Expanded(
child: Column(
crossAxisAlignment:
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),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: UiColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
t.staff_shifts.details.days(days: widget.shift.durationDays!),
style: UiTypography.display3r.copyWith(
color: UiColors.primary,
fontWeight: FontWeight.w600,
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"\$${estimatedTotal.toStringAsFixed(0)}",
style: UiTypography.title1m.copyWith(
color: UiColors.textPrimary,
),
),
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),
Text(
'${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}',
style: UiTypography.body2r.copyWith(
color: UiColors.textSecondary,
),
),
const SizedBox(height: 12),
Text(
widget.shift.title,
style: UiTypography.body2m.copyWith(
color: UiColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Location
Row(
children: [
const Icon(
@@ -258,10 +368,15 @@ class _MyShiftCardState extends State<MyShiftCard> {
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
widget.shift.clientName,
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
Expanded(
child: Text(
widget.shift.locationAddress.isNotEmpty
? 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
AnimatedCrossFade(
firstChild: const SizedBox(height: 0),
secondChild: Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: UiColors.border),
),
),
child: Column(
children: [
// Warning for Pending
if (status == 'pending' || status == 'open')
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
color: UiColors.accent.withOpacity(0.1),
child: Row(
children: [
const Icon(
UiIcons.warning,
size: 14,
color: UiColors.textWarning,
),
const SizedBox(width: 8),
Text(
t.staff_shifts.status.pending_warning,
style: UiTypography.display3r.copyWith(
color: UiColors.textWarning,
fontWeight: FontWeight.w500,
// Expanded Content
AnimatedSize(
duration: const Duration(milliseconds: 300),
child: _isExpanded
? Column(
children: [
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Stats Row
Row(
children: [
Expanded(
child: _buildStatCard(
UiIcons.dollar,
"\$${estimatedTotal.toStringAsFixed(0)}",
"Total",
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
UiIcons.dollar,
"\$${widget.shift.hourlyRate.toInt()}",
"Hourly Rate",
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
UiIcons.clock,
"${duration.toInt()}",
"Hours",
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
if (status == 'pending' || status == 'open') ...[
Expanded(
child: OutlinedButton(
onPressed: widget.onDecline,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: const BorderSide(color: UiColors.border),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
// In/Out Time
Row(
children: [
Expanded(
child: _buildTimeBox(
"CLOCK IN TIME",
widget.shift.startTime,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildTimeBox(
"CLOCK OUT TIME",
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(width: 12),
Expanded(
child: ElevatedButton(
onPressed: widget.onAccept,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
const SizedBox(height: 24),
],
// Actions
if (!widget.historyMode)
if (status == 'confirmed')
SizedBox(
width: double.infinity,
height: 48,
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.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(
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),
],
),
),
],
)
: const SizedBox.shrink(),
),
],
),
),
);
}
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(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: [
BoxShadow(
@@ -81,64 +81,157 @@ class ShiftAssignmentCard extends StatelessWidget {
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Card content starts directly as per prototype
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Logo
Container(
width: 36,
height: 36,
width: 44,
height: 44,
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,
),
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: 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: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shift.title,
style: UiTypography.body2b.copyWith(
color: UiColors.textPrimary,
// Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shift.title,
style: UiTypography.body2m.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,
),
),
],
),
],
),
),
Text(
shift.clientName,
style: UiTypography.display3r.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,
),
),
],
),
),
],
),
],
),
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,
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,
),
),
],
),
],
),
),
],
@@ -147,94 +240,43 @@ class ShiftAssignmentCard extends StatelessWidget {
),
),
// Details
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
UiIcons.calendar,
size: 14,
color: UiColors.iconSecondary,
),
const SizedBox(width: 6),
Text(
_formatDate(shift.date),
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
),
),
const SizedBox(width: 16),
const Icon(
UiIcons.clock,
size: 14,
color: UiColors.iconSecondary,
),
const SizedBox(width: 6),
Text(
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const Icon(
UiIcons.mapPin,
size: 14,
color: UiColors.iconSecondary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
shift.location,
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
if (isConfirming) ...[
const Divider(height: 1),
Row(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: TextButton(
child: OutlinedButton(
onPressed: onDecline,
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
padding: const EdgeInsets.symmetric(vertical: 16),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.iconSecondary,
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),
),
),
Container(width: 1, height: 48, color: UiColors.border),
const SizedBox(width: 12),
Expanded(
child: TextButton(
child: ElevatedButton(
onPressed: onConfirm,
style: TextButton.styleFrom(
foregroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(vertical: 16),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
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_available_shifts_usecase.dart';
import 'domain/usecases/get_pending_assignments_usecase.dart';
import 'domain/usecases/get_cancelled_shifts_usecase.dart';
import 'domain/usecases/get_history_shifts_usecase.dart';
import 'domain/usecases/accept_shift_usecase.dart';
import 'domain/usecases/decline_shift_usecase.dart';
import 'domain/usecases/get_shift_details_usecase.dart';
import 'presentation/blocs/shifts/shifts_bloc.dart';
import 'presentation/pages/shifts_page.dart';
import 'presentation/pages/shift_details_page.dart';
@@ -18,6 +23,11 @@ class StaffShiftsModule extends Module {
i.add(GetMyShiftsUseCase.new);
i.add(GetAvailableShiftsUseCase.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
i.add(ShiftsBloc.new);