feat: add ShiftAssignmentCard widget and StaffShifts module

- Implemented ShiftAssignmentCard widget for displaying shift assignments with client details, pay calculation, and confirmation actions.
- Created StaffShiftsModule to manage dependencies, routes, and use cases related to staff shifts.
- Added necessary dependencies in pubspec.yaml and generated pubspec.lock.
This commit is contained in:
Achintha Isuru
2026-01-25 16:09:11 -05:00
parent 533a545da7
commit 8e429dda03
29 changed files with 2992 additions and 13 deletions

View File

@@ -0,0 +1,70 @@
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/shifts_repository_interface.dart';
/// Implementation of [ShiftsRepositoryInterface] that delegates to [ShiftsRepositoryMock].
///
/// This class resides in the data layer and handles the communication with
/// the external data sources (currently mocks).
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
final ShiftsRepositoryMock _mock;
ShiftsRepositoryImpl({ShiftsRepositoryMock? mock}) : _mock = mock ?? ShiftsRepositoryMock();
@override
Future<List<Shift>> getMyShifts() async {
return _mock.getMyShifts();
}
@override
Future<List<Shift>> getAvailableShifts(String query, String type) async {
// Delegates to mock. Logic kept here temporarily as per architecture constraints
// on data_connect modifications, mimicking a query capable datasource.
var shifts = await _mock.getAvailableShifts();
// Simple in-memory filtering for mock adapter
if (query.isNotEmpty) {
shifts = shifts.where((s) =>
s.title.toLowerCase().contains(query.toLowerCase()) ||
s.clientName.toLowerCase().contains(query.toLowerCase())
).toList();
}
if (type != 'all') {
if (type == 'one-day') {
shifts = shifts.where((s) => !s.title.contains('Multi-Day') && !s.title.contains('Long Term')).toList();
} else if (type == 'multi-day') {
shifts = shifts.where((s) => s.title.contains('Multi-Day')).toList();
} else if (type == 'long-term') {
shifts = shifts.where((s) => s.title.contains('Long Term')).toList();
}
}
return shifts;
}
@override
Future<List<Shift>> getPendingAssignments() async {
return _mock.getPendingAssignments();
}
@override
Future<Shift?> getShiftDetails(String shiftId) async {
return _mock.getShiftDetails(shiftId);
}
@override
Future<void> applyForShift(String shiftId) async {
await Future.delayed(const Duration(milliseconds: 500));
}
@override
Future<void> acceptShift(String shiftId) async {
await Future.delayed(const Duration(milliseconds: 500));
}
@override
Future<void> declineShift(String shiftId) async {
await Future.delayed(const Duration(milliseconds: 500));
}
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_core/core.dart';
/// Arguments for [GetAvailableShiftsUseCase].
class GetAvailableShiftsArguments extends UseCaseArgument {
/// The search query to filter shifts.
final String query;
/// The job type filter (e.g., 'all', 'one-day', 'multi-day', 'long-term').
final String type;
/// Creates a [GetAvailableShiftsArguments] instance.
const GetAvailableShiftsArguments({
this.query = '',
this.type = 'all',
});
@override
List<Object?> get props => [query, type];
}

View File

@@ -0,0 +1,28 @@
import 'package:krow_domain/krow_domain.dart';
/// Interface for the Shifts Repository.
///
/// Defines the contract for accessing and modifying shift-related data.
/// Implementations of this interface should reside in the data layer.
abstract interface class ShiftsRepositoryInterface {
/// Retrieves the list of shifts assigned to the current user.
Future<List<Shift>> getMyShifts();
/// Retrieves available shifts matching the given [query] and [type].
Future<List<Shift>> getAvailableShifts(String query, String type);
/// Retrieves shifts that are pending acceptance by the user.
Future<List<Shift>> getPendingAssignments();
/// Retrieves detailed information for a specific shift by [shiftId].
Future<Shift?> getShiftDetails(String shiftId);
/// Applies for a specific open shift.
Future<void> applyForShift(String shiftId);
/// Accepts a pending shift assignment.
Future<void> acceptShift(String shiftId);
/// Declines a pending shift assignment.
Future<void> declineShift(String shiftId);
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
import '../arguments/get_available_shifts_arguments.dart';
/// Use case for retrieving available shifts with filters.
///
/// This use case delegates to [ShiftsRepositoryInterface].
class GetAvailableShiftsUseCase extends UseCase<GetAvailableShiftsArguments, List<Shift>> {
final ShiftsRepositoryInterface repository;
GetAvailableShiftsUseCase(this.repository);
@override
Future<List<Shift>> call(GetAvailableShiftsArguments arguments) async {
return repository.getAvailableShifts(arguments.query, arguments.type);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
/// Use case for retrieving the user's assigned shifts.
///
/// This use case delegates to [ShiftsRepositoryInterface].
class GetMyShiftsUseCase extends NoInputUseCase<List<Shift>> {
final ShiftsRepositoryInterface repository;
GetMyShiftsUseCase(this.repository);
@override
Future<List<Shift>> call() async {
return repository.getMyShifts();
}
}

View File

@@ -0,0 +1,18 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/shifts_repository_interface.dart';
/// Use case for retrieving pending shift assignments.
///
/// This use case delegates to [ShiftsRepositoryInterface].
class GetPendingAssignmentsUseCase extends NoInputUseCase<List<Shift>> {
final ShiftsRepositoryInterface repository;
GetPendingAssignmentsUseCase(this.repository);
@override
Future<List<Shift>> call() async {
return repository.getPendingAssignments();
}
}

View File

@@ -0,0 +1,83 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:meta/meta.dart';
import '../../../domain/usecases/get_available_shifts_usecase.dart';
import '../../../domain/arguments/get_available_shifts_arguments.dart';
import '../../../domain/usecases/get_my_shifts_usecase.dart';
import '../../../domain/usecases/get_pending_assignments_usecase.dart';
part 'shifts_event.dart';
part 'shifts_state.dart';
class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState> {
final GetMyShiftsUseCase getMyShifts;
final GetAvailableShiftsUseCase getAvailableShifts;
final GetPendingAssignmentsUseCase getPendingAssignments;
ShiftsBloc({
required this.getMyShifts,
required this.getAvailableShifts,
required this.getPendingAssignments,
}) : super(ShiftsInitial()) {
on<LoadShiftsEvent>(_onLoadShifts);
on<FilterAvailableShiftsEvent>(_onFilterAvailableShifts);
}
Future<void> _onLoadShifts(
LoadShiftsEvent event,
Emitter<ShiftsState> emit,
) async {
if (state is! ShiftsLoaded) {
emit(ShiftsLoading());
}
// Determine what to load based on current tab?
// Or load all for simplicity as per prototype logic which had them all in memory.
try {
final myShiftsResult = await getMyShifts();
final pendingResult = await getPendingAssignments();
// Initial available with defaults
final availableResult = await getAvailableShifts(const GetAvailableShiftsArguments());
emit(ShiftsLoaded(
myShifts: myShiftsResult,
pendingShifts: pendingResult,
availableShifts: availableResult,
searchQuery: '',
jobType: 'all',
));
} catch (_) {
emit(const ShiftsError('Failed to load shifts'));
}
}
Future<void> _onFilterAvailableShifts(
FilterAvailableShiftsEvent event,
Emitter<ShiftsState> emit,
) async {
final currentState = state;
if (currentState is ShiftsLoaded) {
// Optimistic update or loading indicator?
// Since it's filtering, we can just reload available.
try {
final result = await getAvailableShifts(GetAvailableShiftsArguments(
query: event.query ?? currentState.searchQuery,
type: event.jobType ?? currentState.jobType,
));
emit(currentState.copyWith(
availableShifts: result,
searchQuery: event.query ?? currentState.searchQuery,
jobType: event.jobType ?? currentState.jobType,
));
} catch (_) {
// Error handling if filter fails
}
}
}
}

View File

@@ -0,0 +1,21 @@
part of 'shifts_bloc.dart';
@immutable
sealed class ShiftsEvent extends Equatable {
const ShiftsEvent();
@override
List<Object?> get props => [];
}
class LoadShiftsEvent extends ShiftsEvent {}
class FilterAvailableShiftsEvent extends ShiftsEvent {
final String? query;
final String? jobType;
const FilterAvailableShiftsEvent({this.query, this.jobType});
@override
List<Object?> get props => [query, jobType];
}

View File

@@ -0,0 +1,57 @@
part of 'shifts_bloc.dart';
@immutable
sealed class ShiftsState extends Equatable {
const ShiftsState();
@override
List<Object> get props => [];
}
class ShiftsInitial extends ShiftsState {}
class ShiftsLoading extends ShiftsState {}
class ShiftsLoaded extends ShiftsState {
final List<Shift> myShifts;
final List<Shift> pendingShifts;
final List<Shift> availableShifts;
final String searchQuery;
final String jobType;
const ShiftsLoaded({
required this.myShifts,
required this.pendingShifts,
required this.availableShifts,
required this.searchQuery,
required this.jobType,
});
ShiftsLoaded copyWith({
List<Shift>? myShifts,
List<Shift>? pendingShifts,
List<Shift>? availableShifts,
String? searchQuery,
String? jobType,
}) {
return ShiftsLoaded(
myShifts: myShifts ?? this.myShifts,
pendingShifts: pendingShifts ?? this.pendingShifts,
availableShifts: availableShifts ?? this.availableShifts,
searchQuery: searchQuery ?? this.searchQuery,
jobType: jobType ?? this.jobType,
);
}
@override
List<Object> get props => [myShifts, pendingShifts, availableShifts, searchQuery, jobType];
}
class ShiftsError extends ShiftsState {
final String message;
const ShiftsError(this.message);
@override
List<Object> get props => [message];
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
extension ShiftsNavigator on IModularNavigator {
void pushShiftDetails(Shift shift) {
pushNamed('/shifts/details/${shift.id}', arguments: shift);
}
// Example for going back or internal navigation if needed
}

View File

@@ -0,0 +1,368 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
// Shim to match POC styles locally
class AppColors {
static const Color krowBlue = UiColors.primary;
static const Color krowYellow = Color(0xFFFFED4A);
static const Color krowCharcoal = UiColors.textPrimary; // 121826
static const Color krowMuted = UiColors.textSecondary; // 6A7382
static const Color krowBorder = UiColors.border; // E3E6E9
static const Color krowBackground = UiColors.background; // FAFBFC
static const Color white = Colors.white;
}
class ShiftDetailsPage extends StatefulWidget {
final String shiftId;
final Shift? shift;
const ShiftDetailsPage({super.key, required this.shiftId, this.shift});
@override
State<ShiftDetailsPage> createState() => _ShiftDetailsPageState();
}
class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
late Shift _shift;
bool _isLoading = true;
bool _showDetails = true;
bool _isApplying = false;
@override
void initState() {
super.initState();
_loadShift();
}
void _loadShift() async {
if (widget.shift != null) {
_shift = widget.shift!;
setState(() => _isLoading = false);
} else {
// Simulate fetch or logic to handle missing data
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
// Mock data from POC if needed, but assuming shift is always passed in this context
// based on ShiftsPage navigation.
// If generic fetch needed, we would use a Repo/Bloc here.
// For now, stop loading.
setState(() => _isLoading = false);
}
}
}
double _calculateHours(String start, String end) {
try {
final startParts = start.split(':').map(int.parse).toList();
final endParts = end.split(':').map(int.parse).toList();
double h =
(endParts[0] - startParts[0]) + (endParts[1] - startParts[1]) / 60;
if (h < 0) h += 24;
return h;
} catch (e) {
return 0;
}
}
Widget _buildTag(IconData icon, String label, Color bg, Color activeColor) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: activeColor),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: activeColor,
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
backgroundColor: AppColors.krowBackground,
body: Center(child: CircularProgressIndicator()),
);
}
final hours = _calculateHours(_shift.startTime, _shift.endTime);
final totalPay = _shift.hourlyRate * hours;
return Scaffold(
backgroundColor: AppColors.krowBackground,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(UiIcons.chevronLeft, color: AppColors.krowMuted),
onPressed: () => Modular.to.pop(),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(color: AppColors.krowBorder, height: 1.0),
),
),
body: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Pending Badge (Mock logic)
Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.krowYellow.withOpacity(0.3),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'Pending 6h ago',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.krowCharcoal,
),
),
),
),
const SizedBox(height: 16),
// Header
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.krowBorder),
),
child: _shift.logoUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
_shift.logoUrl!,
fit: BoxFit.contain,
),
)
: Center(
child: Text(
_shift.clientName.isNotEmpty ? _shift.clientName[0] : 'K',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.krowBlue,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
_shift.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.krowCharcoal,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'\$${_shift.hourlyRate.toStringAsFixed(0)}/h',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.krowCharcoal,
),
),
Text(
'(exp.total \$${totalPay.toStringAsFixed(0)})',
style: const TextStyle(
fontSize: 12,
color: AppColors.krowMuted,
),
),
],
),
],
),
Text(
_shift.clientName,
style: const TextStyle(color: AppColors.krowMuted),
),
],
),
),
],
),
const SizedBox(height: 16),
// Tags
Row(
children: [
_buildTag(
UiIcons.zap,
'Immediate start',
AppColors.krowBlue.withOpacity(0.1),
AppColors.krowBlue,
),
const SizedBox(width: 8),
_buildTag(
UiIcons.star,
'No experience',
AppColors.krowYellow.withOpacity(0.3),
AppColors.krowCharcoal,
),
],
),
const SizedBox(height: 24),
// Additional Details
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.krowBorder),
),
child: Column(
children: [
InkWell(
onTap: () =>
setState(() => _showDetails = !_showDetails),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'ADDITIONAL DETAILS',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: AppColors.krowMuted,
),
),
Icon(
_showDetails
? UiIcons.chevronUp
: UiIcons.chevronDown,
color: AppColors.krowMuted,
size: 20,
),
],
),
),
),
if (_showDetails && _shift.description != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 1, color: AppColors.krowBorder),
const SizedBox(height: 16),
Text(
_shift.description!,
style: const TextStyle(
color: AppColors.krowCharcoal,
height: 1.5,
),
),
],
),
),
],
),
),
],
),
),
// Action Button
Align(
alignment: Alignment.bottomCenter,
child: Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: AppColors.krowBorder)),
),
child: SafeArea(
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isApplying ? null : () {
setState(() {
_isApplying = true;
});
// Simulate Apply
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
setState(() => _isApplying = false);
Modular.to.pop();
}
});
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.krowBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: _isApplying
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)
)
: const Text(
'Apply Now',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,618 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/shifts/shifts_bloc.dart';
import '../widgets/my_shift_card.dart';
import '../widgets/shift_assignment_card.dart';
// Shim to match POC styles locally
class AppColors {
static const Color krowBlue = UiColors.primary;
static const Color krowYellow = Color(0xFFFFED4A);
static const Color krowCharcoal = UiColors.textPrimary;
static const Color krowMuted = UiColors.textSecondary;
static const Color krowBorder = UiColors.border;
static const Color krowBackground = UiColors.background;
static const Color white = Colors.white;
static const Color black = Colors.black;
}
class ShiftsPage extends StatefulWidget {
final String? initialTab;
const ShiftsPage({super.key, this.initialTab});
@override
State<ShiftsPage> createState() => _ShiftsPageState();
}
class _ShiftsPageState extends State<ShiftsPage> {
late String _activeTab;
String _searchQuery = '';
// ignore: unused_field
String? _cancelledShiftDemo; // 'lastMinute' or 'advance'
String _jobType = 'all'; // all, one-day, multi-day, long-term
// Calendar State
DateTime _selectedDate = DateTime.now();
int _weekOffset = 0;
final ShiftsBloc _bloc = Modular.get<ShiftsBloc>();
@override
void initState() {
super.initState();
_activeTab = widget.initialTab ?? 'myshifts';
_bloc.add(LoadShiftsEvent());
}
@override
void didUpdateWidget(ShiftsPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialTab != null && widget.initialTab != _activeTab) {
setState(() {
_activeTab = widget.initialTab!;
});
}
}
List<DateTime> _getCalendarDays() {
final now = DateTime.now();
int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
int daysSinceFriday = (reactDayIndex + 2) % 7;
final start = now
.subtract(Duration(days: daysSinceFriday))
.add(Duration(days: _weekOffset * 7));
final startDate = DateTime(start.year, start.month, start.day);
return List.generate(7, (index) => startDate.add(Duration(days: index)));
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
void _confirmShift(String id) {
// TODO: Implement Bloc event
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shift confirmed! (Placeholder)')),
);
}
void _declineShift(String id) {
// TODO: Implement Bloc event
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shift declined. (Placeholder)')),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _bloc,
child: BlocBuilder<ShiftsBloc, ShiftsState>(
builder: (context, state) {
final List<Shift> myShifts = (state is ShiftsLoaded) ? state.myShifts : [];
final List<Shift> availableJobs = (state is ShiftsLoaded) ? state.availableShifts : [];
final List<Shift> pendingAssignments = (state is ShiftsLoaded) ? state.pendingShifts : [];
final List<Shift> historyShifts = []; // Not in state yet, placeholder
// Filter logic from POC
final filteredJobs = availableJobs.where((s) {
final matchesSearch =
s.title.toLowerCase().contains(_searchQuery.toLowerCase()) ||
s.location.toLowerCase().contains(_searchQuery.toLowerCase()) ||
s.clientName.toLowerCase().contains(_searchQuery.toLowerCase());
if (!matchesSearch) return false;
if (_jobType == 'all') return true;
if (_jobType == 'one-day') {
return !s.title.contains('Long Term') && !s.title.contains('Multi-Day');
}
if (_jobType == 'multi-day') return s.title.contains('Multi-Day');
if (_jobType == 'long-term') return s.title.contains('Long Term');
return true;
}).toList();
final calendarDays = _getCalendarDays();
final weekStartDate = calendarDays.first;
final weekEndDate = calendarDays.last;
final visibleMyShifts = myShifts.where((s) {
// Primitive check if shift date string compare
// In real app use DateTime logic
final sDateStr = s.date;
final wStartStr = DateFormat('yyyy-MM-dd').format(weekStartDate);
final wEndStr = DateFormat('yyyy-MM-dd').format(weekEndDate);
return sDateStr.compareTo(wStartStr) >= 0 &&
sDateStr.compareTo(wEndStr) <= 0;
}).toList();
return Scaffold(
backgroundColor: AppColors.krowBackground,
body: Column(
children: [
// Header (Blue)
Container(
color: AppColors.krowBlue,
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 20,
20,
24,
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Shifts",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Row(
children: [
_buildDemoButton("Demo: Cancel <4hr", const Color(0xFFEF4444), () {
setState(() => _cancelledShiftDemo = 'lastMinute');
_showCancelledModal('lastMinute');
}),
const SizedBox(width: 8),
_buildDemoButton("Demo: Cancel >4hr", const Color(0xFFF59E0B), () {
setState(() => _cancelledShiftDemo = 'advance');
_showCancelledModal('advance');
}),
],
),
],
),
const SizedBox(height: 16),
// Tabs
Row(
children: [
_buildTab("myshifts", "My Shifts", LucideIcons.calendar, myShifts.length),
const SizedBox(width: 8),
_buildTab("find", "Find Shifts", LucideIcons.search, filteredJobs.length),
const SizedBox(width: 8),
_buildTab("history", "History", LucideIcons.clock, historyShifts.length),
],
),
],
),
),
// Calendar Selector
if (_activeTab == 'myshifts')
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
onTap: () => setState(() => _weekOffset--),
borderRadius: BorderRadius.circular(20),
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(LucideIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal),
),
),
Text(
DateFormat('MMMM yyyy').format(weekStartDate),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.krowCharcoal,
),
),
InkWell(
onTap: () => setState(() => _weekOffset++),
borderRadius: BorderRadius.circular(20),
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(LucideIcons.chevronRight, size: 20, color: AppColors.krowCharcoal),
),
),
],
),
),
// Days Grid
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: calendarDays.map((date) {
final isSelected = _isSameDay(date, _selectedDate);
final dateStr = DateFormat('yyyy-MM-dd').format(date);
final hasShifts = myShifts.any((s) => s.date == dateStr);
return GestureDetector(
onTap: () => setState(() => _selectedDate = date),
child: Container(
width: 44,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected ? AppColors.krowBlue : Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: isSelected ? AppColors.krowBlue : AppColors.krowBorder,
width: 1,
),
),
child: Column(
children: [
Text(
date.day.toString().padLeft(2, '0'),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: isSelected ? Colors.white : AppColors.krowCharcoal,
),
),
const SizedBox(height: 2),
Text(
DateFormat('E').format(date),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.krowMuted,
),
),
if (hasShifts)
Container(
margin: const EdgeInsets.only(top: 4),
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected ? Colors.white : AppColors.krowBlue,
shape: BoxShape.circle,
),
),
],
),
),
);
}).toList(),
),
],
),
),
if (_activeTab == 'myshifts')
const Divider(height: 1, color: AppColors.krowBorder),
// Body Content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
if (_activeTab == 'find') ...[
// Search & Filter
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.krowBorder),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (val) => setState(() => _searchQuery = val), // Local filter for now
decoration: const InputDecoration(
prefixIcon: Icon(LucideIcons.search, size: 20, color: AppColors.krowMuted),
border: InputBorder.none,
hintText: "Search jobs...",
hintStyle: TextStyle(color: AppColors.krowMuted, fontSize: 14),
contentPadding: EdgeInsets.symmetric(vertical: 12),
),
),
),
),
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFF1F3F5),
borderRadius: BorderRadius.circular(999),
),
child: Row(
children: [
_buildFilterTab('all', 'All Jobs'),
_buildFilterTab('one-day', 'One Day'),
_buildFilterTab('multi-day', 'Multi-Day'),
_buildFilterTab('long-term', 'Long Term'),
],
),
),
],
if (_activeTab == 'myshifts') ...[
if (pendingAssignments.isNotEmpty) ...[
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(width: 8, height: 8, decoration: const BoxDecoration(color: Color(0xFFF59E0B), shape: BoxShape.circle)),
const SizedBox(width: 8),
const Text("Awaiting Confirmation", style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFFD97706)
)),
],
),
),
),
...pendingAssignments.map((shift) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: ShiftAssignmentCard(
shift: shift,
onConfirm: () => _confirmShift(shift.id),
onDecline: () => _declineShift(shift.id),
),
)),
],
// Cancelled Shifts Demo (Visual only as per POC)
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: const Text("Cancelled Shifts", style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowMuted
)),
),
),
_buildCancelledCard(
title: "Annual Tech Conference", client: "TechCorp Inc.", pay: "\$200", rate: "\$25/hr · 8h",
date: "Today", time: "10:00 AM - 6:00 PM", address: "123 Convention Center Dr", isLastMinute: true,
onTap: () => setState(() => _cancelledShiftDemo = 'lastMinute')
),
const SizedBox(height: 12),
_buildCancelledCard(
title: "Morning Catering Setup", client: "EventPro Services", pay: "\$120", rate: "\$20/hr · 6h",
date: "Tomorrow", time: "8:00 AM - 2:00 PM", address: "456 Grand Ballroom Ave", isLastMinute: false,
onTap: () => setState(() => _cancelledShiftDemo = 'advance')
),
const SizedBox(height: 24),
// Confirmed Shifts
if (visibleMyShifts.isNotEmpty) ...[
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: const Text("Confirmed Shifts", style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowMuted
)),
),
),
...visibleMyShifts.map((shift) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyShiftCard(shift: shift),
)),
],
],
if (_activeTab == 'find') ...[
if (filteredJobs.isEmpty)
_buildEmptyState(LucideIcons.search, "No jobs available", "Check back later", null, null)
else
...filteredJobs.map((shift) => GestureDetector(
onTap: () => Modular.to.pushNamed('details/${shift.id}', arguments: shift),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyShiftCard(shift: shift),
),
)),
],
if (_activeTab == 'history')
_buildEmptyState(LucideIcons.clock, "No shift history", "Completed shifts appear here", null, null),
],
),
),
),
],
),
);
},
),
);
}
Widget _buildFilterTab(String id, String label) {
final isSelected = _jobType == id;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _jobType = id),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSelected ? AppColors.krowBlue : Colors.transparent,
borderRadius: BorderRadius.circular(999),
boxShadow: isSelected ? [BoxShadow(color: AppColors.krowBlue.withOpacity(0.2), blurRadius: 4, offset: const Offset(0, 2))] : null,
),
child: Text(label, textAlign: TextAlign.center, style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : AppColors.krowMuted
)),
),
),
);
}
Widget _buildTab(String id, String label, IconData icon, int count) {
final isActive = _activeTab == id;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _activeTab = id),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withAlpha((0.2 * 255).round()),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: isActive ? AppColors.krowBlue : Colors.white),
const SizedBox(width: 6),
Flexible(child: Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: isActive ? AppColors.krowBlue : Colors.white), overflow: TextOverflow.ellipsis)),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
constraints: const BoxConstraints(minWidth: 18),
decoration: BoxDecoration(
color: isActive ? AppColors.krowBlue.withAlpha((0.1 * 255).round()) : Colors.white.withAlpha((0.2 * 255).round()),
borderRadius: BorderRadius.circular(999),
),
child: Center(child: Text("$count", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: isActive ? AppColors.krowBlue : Colors.white))),
),
],
),
),
),
);
}
Widget _buildDemoButton(String label, Color color, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)),
child: Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white)),
),
);
}
Widget _buildEmptyState(IconData icon, String title, String subtitle, String? actionLabel, VoidCallback? onAction) {
return Center(child: Padding(padding: const EdgeInsets.symmetric(vertical: 64), child: Column(children: [
Container(width: 64, height: 64, decoration: BoxDecoration(color: const Color(0xFFF1F3F5), borderRadius: BorderRadius.circular(12)), child: Icon(icon, size: 32, color: AppColors.krowMuted)),
const SizedBox(height: 16),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.krowCharcoal)),
const SizedBox(height: 4),
Text(subtitle, style: const TextStyle(fontSize: 14, color: AppColors.krowMuted)),
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
ElevatedButton(onPressed: onAction, style: ElevatedButton.styleFrom(backgroundColor: AppColors.krowBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), child: Text(actionLabel)),
]
])));
}
Widget _buildCancelledCard({required String title, required String client, required String pay, required String rate, required String date, required String time, required String address, required bool isLastMinute, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.krowBorder)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFEF4444), shape: BoxShape.circle)), const SizedBox(width: 6), const Text("CANCELLED", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFFEF4444))), if (isLastMinute) ...[const SizedBox(width: 4), const Text("• 4hr compensation", style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFF10B981)))]]),
const SizedBox(height: 12),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(width: 44, height: 44, decoration: BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.krowBlue.withAlpha((0.15 * 255).round()), AppColors.krowBlue.withAlpha((0.08 * 255).round())]), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.krowBlue.withAlpha((0.15 * 255).round()))), child: const Center(child: Icon(LucideIcons.briefcase, color: AppColors.krowBlue, size: 20))),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowCharcoal)), Text(client, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text(pay, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.krowCharcoal)), Text(rate, style: const TextStyle(fontSize: 10, color: AppColors.krowMuted))])]),
const SizedBox(height: 8),
Row(children: [const Icon(LucideIcons.calendar, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Text(date, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)), const SizedBox(width: 12), const Icon(LucideIcons.clock, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Text(time, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))]),
const SizedBox(height: 4),
Row(children: [const Icon(LucideIcons.mapPin, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Expanded(child: Text(address, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted), overflow: TextOverflow.ellipsis))]),
])),
]),
])),
);
}
void _showCancelledModal(String type) {
final isLastMinute = type == 'lastMinute';
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
const Icon(LucideIcons.xCircle, color: Color(0xFFEF4444)),
const SizedBox(width: 8),
const Text("Shift Cancelled"),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"We're sorry, but the following shift has been cancelled by the client:",
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Annual Tech Conference", style: TextStyle(fontWeight: FontWeight.bold)),
Text("Today, 10:00 AM - 6:00 PM"),
],
),
),
const SizedBox(height: 16),
if (isLastMinute)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFECFDF5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF10B981)),
),
child: const Row(
children: [
Icon(LucideIcons.checkCircle, color: Color(0xFF10B981), size: 16),
SizedBox(width: 8),
Expanded(
child: Text(
"You are eligible for 4hr cancellation compensation.",
style: TextStyle(
fontSize: 12, color: Color(0xFF065F46), fontWeight: FontWeight.w500),
),
),
],
),
)
else
const Text(
"Reduced schedule at the venue. No compensation is due as this was cancelled more than 4 hours in advance.",
style: TextStyle(fontSize: 12, color: AppColors.krowMuted),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Close"),
),
],
),
);
}
}

View File

@@ -0,0 +1,412 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
class MyShiftCard extends StatefulWidget {
final Shift shift;
final bool historyMode;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
final VoidCallback? onRequestSwap;
final int index;
const MyShiftCard({
super.key,
required this.shift,
this.historyMode = false,
this.onAccept,
this.onDecline,
this.onRequestSwap,
this.index = 0,
});
@override
State<MyShiftCard> createState() => _MyShiftCardState();
}
class _MyShiftCardState extends State<MyShiftCard> {
bool _isExpanded = false;
String _formatTime(String time) {
if (time.isEmpty) return '';
try {
final parts = time.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
final dt = DateTime(2022, 1, 1, hour, minute);
return DateFormat('h:mm a').format(dt);
} catch (e) {
return time;
}
}
String _formatDate(String dateStr) {
if (dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final tomorrow = today.add(const Duration(days: 1));
final d = DateTime(date.year, date.month, date.day);
if (d == today) return 'Today';
if (d == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
} catch (e) {
return dateStr;
}
}
double _calculateDuration() {
if (widget.shift.startTime.isEmpty || widget.shift.endTime.isEmpty) {
return 0;
}
try {
final s = widget.shift.startTime.split(':').map(int.parse).toList();
final e = widget.shift.endTime.split(':').map(int.parse).toList();
double hours = ((e[0] * 60 + e[1]) - (s[0] * 60 + s[1])) / 60;
if (hours < 0) hours += 24;
return hours.roundToDouble();
} catch (_) {
return 0;
}
}
String _getShiftType() {
// Check title for type indicators (for mock data)
if (widget.shift.title.contains('Long Term')) return t.staff_shifts.filter.long_term;
if (widget.shift.title.contains('Multi-Day')) return t.staff_shifts.filter.multi_day;
return t.staff_shifts.filter.one_day;
}
@override
Widget build(BuildContext context) {
// ignore: unused_local_variable
final duration = _calculateDuration();
// Status Logic
String? status = widget.shift.status;
Color statusColor = UiColors.primary;
Color statusBg = UiColors.primary;
String statusText = '';
IconData? statusIcon;
if (status == 'confirmed') {
statusText = t.staff_shifts.status.confirmed;
statusColor = UiColors.textLink;
statusBg = UiColors.primary;
} else if (status == 'pending' || status == 'open') {
statusText = t.staff_shifts.status.act_now;
statusColor = UiColors.destructive;
statusBg = UiColors.destructive;
} else if (status == 'swap') {
statusText = t.staff_shifts.status.swap_requested;
statusColor = UiColors.textWarning;
statusBg = UiColors.textWarning;
statusIcon = UiIcons.swap;
} else if (status == 'completed') {
statusText = t.staff_shifts.status.completed;
statusColor = UiColors.textSuccess;
statusBg = UiColors.iconSuccess;
} else if (status == 'no_show') {
statusText = t.staff_shifts.status.no_show;
statusColor = UiColors.destructive;
statusBg = UiColors.destructive;
}
return GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Column(
children: [
// Collapsed Content
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status Badge
if (statusText.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
if (statusIcon != null)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Icon(
statusIcon,
size: 12,
color: statusColor,
),
)
else
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: statusBg,
shape: BoxShape.circle,
),
),
Text(
statusText,
style: UiTypography.display3r.copyWith(
color: statusColor,
letterSpacing: 0.5,
),
),
// Shift Type Badge for available/pending shifts
if (status == 'open' || status == 'pending') ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: UiColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_getShiftType(),
style: UiTypography.display3r.copyWith(
color: UiColors.primary,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
// Main Content
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date/Time Column
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
_formatDate(widget.shift.date),
style: UiTypography.display2m.copyWith(
color: UiColors.textPrimary,
),
),
if (widget.shift.durationDays != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: UiColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
t.staff_shifts.details.days(days: widget.shift.durationDays!),
style: UiTypography.display3r.copyWith(
color: UiColors.primary,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
const SizedBox(height: 4),
Text(
'${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}',
style: UiTypography.body2r.copyWith(
color: UiColors.textSecondary,
),
),
const SizedBox(height: 12),
Text(
widget.shift.title,
style: UiTypography.body2m.copyWith(
color: UiColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Row(
children: [
const Icon(
UiIcons.mapPin,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
widget.shift.clientName,
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
],
),
),
// Logo Box
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: UiColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: UiColors.border),
),
child: widget.shift.logoUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
widget.shift.logoUrl!,
fit: BoxFit.cover,
),
)
: Center(
child: Text(
widget.shift.clientName.isNotEmpty
? widget.shift.clientName[0]
: 'K',
style: UiTypography.title1m.textLink,
),
),
),
],
),
],
),
),
// Expanded Actions
AnimatedCrossFade(
firstChild: const SizedBox(height: 0),
secondChild: Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: UiColors.border),
),
),
child: Column(
children: [
// Warning for Pending
if (status == 'pending' || status == 'open')
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
color: UiColors.accent.withOpacity(0.1),
child: Row(
children: [
const Icon(
UiIcons.warning,
size: 14,
color: UiColors.textWarning,
),
const SizedBox(width: 8),
Text(
t.staff_shifts.status.pending_warning,
style: UiTypography.display3r.copyWith(
color: UiColors.textWarning,
fontWeight: FontWeight.w500,
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
if (status == 'pending' || status == 'open') ...[
Expanded(
child: OutlinedButton(
onPressed: widget.onDecline,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: const BorderSide(color: UiColors.border),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(t.staff_shifts.action.decline),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: widget.onAccept,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(t.staff_shifts.action.confirm),
),
),
] else if (status == 'confirmed') ...[
Expanded(
child: OutlinedButton.icon(
onPressed: widget.onRequestSwap,
icon: const Icon(UiIcons.swap, size: 16),
label: Text(t.staff_shifts.action.request_swap),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.textPrimary,
side: const BorderSide(color: UiColors.border),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
],
),
),
],
),
),
crossFadeState: _isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
class ShiftAssignmentCard extends StatelessWidget {
final Shift shift;
final VoidCallback onConfirm;
final VoidCallback onDecline;
final bool isConfirming;
const ShiftAssignmentCard({
super.key,
required this.shift,
required this.onConfirm,
required this.onDecline,
this.isConfirming = false,
});
String _formatTime(String time) {
if (time.isEmpty) return '';
try {
final parts = time.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
final dt = DateTime(2022, 1, 1, hour, minute);
return DateFormat('h:mm a').format(dt);
} catch (e) {
return time;
}
}
String _formatDate(String dateStr) {
if (dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final tomorrow = today.add(const Duration(days: 1));
final d = DateTime(date.year, date.month, date.day);
if (d == today) return 'Today';
if (d == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
} catch (e) {
return dateStr;
}
}
double _calculateHours(String start, String end) {
if (start.isEmpty || end.isEmpty) return 0;
try {
final s = start.split(':').map(int.parse).toList();
final e = end.split(':').map(int.parse).toList();
return ((e[0] * 60 + e[1]) - (s[0] * 60 + s[1])) / 60;
} catch (_) {
return 0;
}
}
@override
Widget build(BuildContext context) {
final hours = _calculateHours(shift.startTime, shift.endTime);
final totalPay = shift.hourlyRate * hours;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: UiColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: UiColors.secondary,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
shift.clientName.isNotEmpty
? shift.clientName[0]
: 'K',
style: UiTypography.body2b.copyWith(
color: UiColors.textSecondary,
),
),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shift.title,
style: UiTypography.body2b.copyWith(
color: UiColors.textPrimary,
),
),
Text(
shift.clientName,
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"\$${totalPay.toStringAsFixed(0)}",
style: UiTypography.display2m.copyWith(
color: UiColors.textPrimary,
),
),
Text(
"\$${shift.hourlyRate}/hr · ${hours}h",
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
],
),
),
// Details
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
UiIcons.calendar,
size: 14,
color: UiColors.iconSecondary,
),
const SizedBox(width: 6),
Text(
_formatDate(shift.date),
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
),
),
const SizedBox(width: 16),
const Icon(
UiIcons.clock,
size: 14,
color: UiColors.iconSecondary,
),
const SizedBox(width: 6),
Text(
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const Icon(
UiIcons.mapPin,
size: 14,
color: UiColors.iconSecondary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
shift.location,
style: UiTypography.display3r.copyWith(
color: UiColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
if (isConfirming) ...[
const Divider(height: 1),
Row(
children: [
Expanded(
child: TextButton(
onPressed: onDecline,
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(t.staff_shifts.action.decline),
),
),
Container(width: 1, height: 48, color: UiColors.border),
Expanded(
child: TextButton(
onPressed: onConfirm,
style: TextButton.styleFrom(
foregroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(t.staff_shifts.action.confirm),
),
),
],
),
],
],
),
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'domain/repositories/shifts_repository_interface.dart';
import 'data/repositories_impl/shifts_repository_impl.dart';
import 'domain/usecases/get_my_shifts_usecase.dart';
import 'domain/usecases/get_available_shifts_usecase.dart';
import 'domain/usecases/get_pending_assignments_usecase.dart';
import 'presentation/blocs/shifts/shifts_bloc.dart';
import 'presentation/pages/shifts_page.dart';
import 'presentation/pages/shift_details_page.dart';
class StaffShiftsModule extends Module {
@override
void binds(Injector i) {
// Repository
i.add<ShiftsRepositoryInterface>(ShiftsRepositoryImpl.new);
// UseCases
i.add(GetMyShiftsUseCase.new);
i.add(GetAvailableShiftsUseCase.new);
i.add(GetPendingAssignmentsUseCase.new);
// Bloc
i.add(ShiftsBloc.new);
}
@override
void routes(RouteManager r) {
r.child('/', child: (_) => const ShiftsPage());
r.child('/details/:id', child: (_) => ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data));
}
}

View File

@@ -0,0 +1,4 @@
library staff_shifts;
export 'src/staff_shifts_module.dart';