feat: Add shifts styles, empty state view, and tabs for finding, history, and my shifts

- Introduced AppColors for consistent color usage across the app.
- Implemented EmptyStateView widget for displaying empty states with icons and messages.
- Created FindShiftsTab for searching and filtering available jobs.
- Developed HistoryShiftsTab to display completed shifts with an empty state.
- Added MyShiftsTab for managing user shifts, including confirmation and decline functionalities.
This commit is contained in:
Achintha Isuru
2026-01-31 17:12:10 -05:00
parent 1a4a797aa3
commit 3e156565c8
7 changed files with 1039 additions and 579 deletions

View File

@@ -51,28 +51,15 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
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 Timestamp) {
return t.toDateTime();
return DateTime.tryParse(t.toJson() as String);
} catch (_) {
try {
return DateTime.tryParse(t.toString());
} catch (e) {
return null;
}
} 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

View File

@@ -1,25 +1,13 @@
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;
}
import '../widgets/tabs/my_shifts_tab.dart';
import '../widgets/tabs/find_shifts_tab.dart';
import '../widgets/tabs/history_shifts_tab.dart';
import '../styles/shifts_styles.dart';
class ShiftsPage extends StatefulWidget {
final String? initialTab;
@@ -31,15 +19,6 @@ class ShiftsPage extends StatefulWidget {
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
@@ -59,93 +38,30 @@ class _ShiftsPageState extends State<ShiftsPage> {
}
}
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) {
_bloc.add(AcceptShiftEvent(id));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Shift confirmed!'),
backgroundColor: Color(0xFF10B981),
),
);
}
void _declineShift(String id) {
_bloc.add(DeclineShiftEvent(id));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Shift declined.'),
backgroundColor: Color(0xFFEF4444),
),
);
}
@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> cancelledShifts = (state is ShiftsLoaded) ? state.cancelledShifts : [];
final List<Shift> historyShifts = (state is ShiftsLoaded) ? state.historyShifts : [];
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> cancelledShifts = (state is ShiftsLoaded)
? state.cancelledShifts
: [];
final List<Shift> historyShifts = (state is ShiftsLoaded)
? state.historyShifts
: [];
// Filter logic
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.durationDays == null || s.durationDays! <= 1;
}
if (_jobType == 'multi-day') return s.durationDays != null && s.durationDays! > 1;
return true;
}).toList();
final calendarDays = _getCalendarDays();
final weekStartDate = calendarDays.first;
final weekEndDate = calendarDays.last;
final visibleMyShifts = myShifts.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();
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();
// Note: "filteredJobs" logic moved to FindShiftsTab
// Note: Calendar logic moved to MyShiftsTab
return Scaffold(
backgroundColor: AppColors.krowBackground,
@@ -161,326 +77,58 @@ class _ShiftsPageState extends State<ShiftsPage> {
20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Shifts",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
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 Text(
"Shifts",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 16),
// Tabs
Row(
children: [
_buildTab("myshifts", "My Shifts", UiIcons.calendar, myShifts.length),
_buildTab(
"myshifts",
"My Shifts",
UiIcons.calendar,
myShifts.length,
),
const SizedBox(width: 8),
_buildTab("find", "Find Shifts", UiIcons.search, filteredJobs.length),
_buildTab(
"find",
"Find Shifts",
UiIcons.search,
availableJobs.length, // Passed unfiltered count as badge? Or logic inside? Pass availableJobs.
),
const SizedBox(width: 8),
_buildTab("history", "History", UiIcons.clock, historyShifts.length),
_buildTab(
"history",
"History",
UiIcons.clock,
historyShifts.length,
),
],
),
],
),
),
// Calendar Selector
if (_activeTab == 'myshifts')
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
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),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.krowCharcoal,
),
),
IconButton(
icon: const Icon(UiIcons.chevronRight, size: 20, color: AppColors.krowCharcoal),
onPressed: () => setState(() => _weekOffset++),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
),
],
),
),
// 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) {
try {
return _isSameDay(DateTime.parse(s.date), date);
} catch (_) { return false; }
});
return GestureDetector(
onTap: () => setState(() => _selectedDate = date),
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,
),
),
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(),
),
],
),
),
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: state is ShiftsLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const SizedBox(height: 20),
if (_activeTab == 'myshifts') ...[
if (pendingAssignments.isNotEmpty) ...[
_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),
],
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) ...[
_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(UiIcons.search, "No jobs available", "Check back later", null, null)
else
...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') ...[
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),
],
),
),
child: state is ShiftsLoading
? const Center(child: CircularProgressIndicator())
: _buildTabContent(
myShifts,
pendingAssignments,
cancelledShifts,
availableJobs,
historyShifts,
),
),
],
),
@@ -490,62 +138,33 @@ 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 _buildTabContent(
List<Shift> myShifts,
List<Shift> pendingAssignments,
List<Shift> cancelledShifts,
List<Shift> availableJobs,
List<Shift> historyShifts,
) {
switch (_activeTab) {
case 'myshifts':
return MyShiftsTab(
myShifts: myShifts,
pendingAssignments: pendingAssignments,
cancelledShifts: cancelledShifts,
);
case 'find':
return FindShiftsTab(
availableJobs: availableJobs,
);
case 'history':
return HistoryShiftsTab(
historyShifts: historyShifts,
);
default:
return const SizedBox.shrink();
}
}
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 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),
),
),
),
);
}
Widget _buildTab(String id, String label, IconData icon, int count) {
final isActive = _activeTab == id;
return Expanded(
@@ -554,112 +173,57 @@ class _ShiftsPageState extends State<ShiftsPage> {
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),
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),
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,
),
child: Center(child: Text("$count", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, 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 _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(
color: AppColors.krowBlue.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
),
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))
]),
])),
]),
]),
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
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;
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import '../../styles/shifts_styles.dart';
class EmptyStateView extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final String? actionLabel;
final VoidCallback? onAction;
const EmptyStateView({
super.key,
required this.icon,
required this.title,
required this.subtitle,
this.actionLabel,
this.onAction,
});
@override
Widget build(BuildContext context) {
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!),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../styles/shifts_styles.dart';
import '../my_shift_card.dart';
import '../shared/empty_state_view.dart';
class FindShiftsTab extends StatefulWidget {
final List<Shift> availableJobs;
const FindShiftsTab({
super.key,
required this.availableJobs,
});
@override
State<FindShiftsTab> createState() => _FindShiftsTabState();
}
class _FindShiftsTabState extends State<FindShiftsTab> {
String _searchQuery = '';
String _jobType = 'all';
Widget _buildFilterTab(String id, String label) {
final isSelected = _jobType == id;
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),
),
),
),
);
}
@override
Widget build(BuildContext context) {
// Filter logic
final filteredJobs = widget.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.durationDays == null || s.durationDays! <= 1;
}
if (_jobType == 'multi-day')
return s.durationDays != null && s.durationDays! > 1;
return true;
}).toList();
return Column(
children: [
// Search and Filters
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'),
],
),
),
],
),
),
Expanded(
child: filteredJobs.isEmpty
? EmptyStateView(
icon: UiIcons.search,
title: "No jobs available",
subtitle: "Check back later",
)
: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const SizedBox(height: 20),
...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),
),
);
},
),
),
),
const SizedBox(height: 40),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
import '../my_shift_card.dart';
import '../shared/empty_state_view.dart';
class HistoryShiftsTab extends StatelessWidget {
final List<Shift> historyShifts;
const HistoryShiftsTab({
super.key,
required this.historyShifts,
});
@override
Widget build(BuildContext context) {
if (historyShifts.isEmpty) {
return EmptyStateView(
icon: UiIcons.clock,
title: "No shift history",
subtitle: "Completed shifts appear here",
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const SizedBox(height: 20),
...historyShifts.map(
(shift) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyShiftCard(
shift: shift,
historyMode: true,
),
),
),
const SizedBox(height: 40),
],
),
);
}
}

View File

@@ -0,0 +1,582 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../blocs/shifts/shifts_bloc.dart';
import '../my_shift_card.dart';
import '../shift_assignment_card.dart';
import '../shared/empty_state_view.dart';
import '../../styles/shifts_styles.dart';
class MyShiftsTab extends StatefulWidget {
final List<Shift> myShifts;
final List<Shift> pendingAssignments;
final List<Shift> cancelledShifts;
const MyShiftsTab({
super.key,
required this.myShifts,
required this.pendingAssignments,
required this.cancelledShifts,
});
@override
State<MyShiftsTab> createState() => _MyShiftsTabState();
}
class _MyShiftsTabState extends State<MyShiftsTab> {
DateTime _selectedDate = DateTime.now();
int _weekOffset = 0;
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) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Accept Shift'),
content: const Text(
'Are you sure you want to accept this shift?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
context.read<ShiftsBloc>().add(AcceptShiftEvent(id));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Shift confirmed!'),
backgroundColor: Color(0xFF10B981),
),
);
},
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF10B981),
),
child: const Text('Accept'),
),
],
),
);
}
void _declineShift(String id) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Decline Shift'),
content: const Text(
'Are you sure you want to decline this shift? This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
context.read<ShiftsBloc>().add(DeclineShiftEvent(id));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Shift declined.'),
backgroundColor: Color(0xFFEF4444),
),
);
},
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFEF4444),
),
child: const Text('Decline'),
),
],
),
);
}
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;
}
}
@override
Widget build(BuildContext context) {
final calendarDays = _getCalendarDays();
final weekStartDate = calendarDays.first;
final weekEndDate = calendarDays.last;
final visibleMyShifts = widget.myShifts.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();
final visibleCancelledShifts = widget.cancelledShifts.where((s) {
try {
final date = DateTime.parse(s.date);
return date.isAfter(
weekStartDate.subtract(const Duration(seconds: 1)),
) &&
date.isBefore(weekEndDate.add(const Duration(days: 1)));
} catch (_) {
return false;
}
}).toList();
return Column(
children: [
// Calendar Selector
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
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),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.krowCharcoal,
),
),
IconButton(
icon: const Icon(
UiIcons.chevronRight,
size: 20,
color: AppColors.krowCharcoal,
),
onPressed: () => setState(() => _weekOffset++),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
),
],
),
),
// Days Grid
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: calendarDays.map((date) {
final isSelected = _isSameDay(date, _selectedDate);
// ignore: unused_local_variable
final dateStr = DateFormat('yyyy-MM-dd').format(date);
final hasShifts = widget.myShifts.any((s) {
try {
return _isSameDay(DateTime.parse(s.date), date);
} catch (_) {
return false;
}
});
return GestureDetector(
onTap: () => setState(() => _selectedDate = date),
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,
),
),
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(),
),
],
),
),
const Divider(height: 1, color: AppColors.krowBorder),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const SizedBox(height: 20),
if (widget.pendingAssignments.isNotEmpty) ...[
_buildSectionHeader(
"Awaiting Confirmation",
const Color(0xFFF59E0B),
),
...widget.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),
],
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) ...[
_buildSectionHeader(
"Confirmed Shifts",
AppColors.krowMuted,
),
...visibleMyShifts.map(
(shift) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyShiftCard(shift: shift),
),
),
],
if (visibleMyShifts.isEmpty &&
widget.pendingAssignments.isEmpty &&
widget.cancelledShifts.isEmpty)
const EmptyStateView(
icon: UiIcons.calendar,
title: "No shifts this week",
subtitle: "Try finding new jobs in the Find tab",
),
const SizedBox(height: 40),
],
),
),
),
],
);
}
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 _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(
color: AppColors.krowBlue.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
),
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,
),
),
],
),
],
),
),
],
),
],
),
),
);
}
}