Files
krow-worker-app/lib/screens/shifts_screen.dart
2026-09-17 22:26:11 +05:30

397 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/formatters.dart';
import '../core/theme.dart';
import '../models/shift.dart';
import '../state/shifts_controller.dart';
import '../widgets/common.dart';
import '../widgets/glass_segments.dart';
import '../widgets/shift_card.dart';
import '../widgets/week_strip.dart';
enum _Tab { booked, open, history }
class ShiftsScreen extends ConsumerStatefulWidget {
const ShiftsScreen({super.key});
@override
ConsumerState<ShiftsScreen> createState() => _ShiftsScreenState();
}
class _ShiftsScreenState extends ConsumerState<ShiftsScreen> {
_Tab _tab = _Tab.booked;
/// Where we came from, so the new list can slide in from the right side.
_Tab _previousTab = _Tab.booked;
DateTime _selectedDay = DateTime.now();
String _query = '';
String _filter = 'All';
void _selectTab(_Tab tab) {
if (tab == _tab) return;
setState(() {
_previousTab = _tab;
_tab = tab;
});
}
List<DateTime> get _week {
final now = DateTime.now();
return [for (var i = 0; i < 7; i++) now.add(Duration(days: i))];
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Fmt.monthYear(DateTime.now()).toUpperCase(),
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.6,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 4),
const Text(
'Shifts',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
const SizedBox(height: 18),
_HeaderSegments(value: _tab, onChanged: _selectTab),
],
),
),
Expanded(
// The list slides in from the side the new tab sits on, so the
// content moves the same way your eye just did.
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 280),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeIn,
layoutBuilder: (current, previous) => Stack(
alignment: Alignment.topCenter,
children: [...previous, ?current],
),
transitionBuilder: (child, animation) {
final incoming = child.key == ValueKey(_tab);
final direction = _tab.index >= _previousTab.index ? 1.0 : -1.0;
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(
begin: Offset(incoming ? 0.08 * direction : 0, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: KeyedSubtree(
key: ValueKey(_tab),
child: switch (_tab) {
_Tab.booked => _BookedTab(
week: _week,
selected: _selectedDay,
onSelect: (d) => setState(() => _selectedDay = d),
),
_Tab.open => _OpenTab(
query: _query,
filter: _filter,
onQuery: (q) => setState(() => _query = q),
onFilter: (f) => setState(() => _filter = f),
),
_Tab.history => const _HistoryTab(),
},
),
),
),
],
),
);
}
}
class _HeaderSegments extends StatelessWidget {
const _HeaderSegments({required this.value, required this.onChanged});
final _Tab value;
final ValueChanged<_Tab> onChanged;
@override
Widget build(BuildContext context) {
return GlassSegments<_Tab>(
segments: const [
(_Tab.booked, 'Booked'),
(_Tab.open, 'Open'),
(_Tab.history, 'History'),
],
value: value,
onChanged: onChanged,
);
}
}
class _BookedTab extends ConsumerWidget {
const _BookedTab({
required this.week,
required this.selected,
required this.onSelect,
});
final List<DateTime> week;
final DateTime selected;
final ValueChanged<DateTime> onSelect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final booked = ref.watch(bookedShiftsProvider);
final forDay = booked.where((s) => Fmt.sameDay(s.start, selected)).toList();
final marked = <int>{
for (var i = 0; i < week.length; i++)
if (booked.any((s) => Fmt.sameDay(s.start, week[i]))) i,
};
return ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
children: [
WeekStrip(
days: week,
selected: selected,
onSelect: onSelect,
marked: marked,
),
SectionLabel(
'${Fmt.dayFull(selected)} · ${forDay.length} '
'${forDay.length == 1 ? 'shift' : 'shifts'}',
),
if (forDay.isEmpty)
const EmptyState(
icon: LucideIcons.ticket,
title: 'Nothing booked this day',
message: 'Switch to Open above to claim a shift near you.',
)
else
for (final shift in forDay)
Padding(
padding: const EdgeInsets.only(bottom: 11),
child: ShiftCard(
shift: shift,
onTap: () => context.push('/shift/${shift.id}'),
),
),
const SizedBox(height: 6),
const InfoBanner(
'Auto-Match only offers you shifts inside the availability blocks you '
'have switched on.',
),
],
);
}
}
class _OpenTab extends ConsumerWidget {
const _OpenTab({
required this.query,
required this.filter,
required this.onQuery,
required this.onFilter,
});
final String query;
final String filter;
final ValueChanged<String> onQuery;
final ValueChanged<String> onFilter;
static const _filters = ['All', 'Today', 'This week', 'Top pay'];
@override
Widget build(BuildContext context, WidgetRef ref) {
var shifts = ref.watch(openShiftsProvider);
final now = DateTime.now();
if (query.isNotEmpty) {
final q = query.toLowerCase();
shifts = shifts
.where(
(s) =>
s.role.toLowerCase().contains(q) ||
s.business.toLowerCase().contains(q) ||
s.venue.toLowerCase().contains(q),
)
.toList();
}
shifts = switch (filter) {
'Today' => shifts.where((s) => Fmt.sameDay(s.start, now)).toList(),
'This week' =>
shifts.where((s) => s.start.difference(now).inDays < 7).toList(),
'Top pay' => shifts.where((s) => s.rate >= 22).toList(),
_ => shifts,
};
return ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
children: [
TextField(
onChanged: onQuery,
decoration: const InputDecoration(
hintText: 'Role, business or area',
prefixIcon: Icon(LucideIcons.search, size: 18),
),
),
const SizedBox(height: 14),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final f in _filters)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f),
selected: f == filter,
showCheckmark: false,
selectedColor: KrowPalette.blue,
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: f == filter ? Colors.white : KrowPalette.mute,
),
onSelected: (_) => onFilter(f),
),
),
],
),
),
SectionLabel('${shifts.length} shifts · highest paying first'),
if (shifts.isEmpty)
const EmptyState(
icon: LucideIcons.search,
title: 'Nothing matches',
message: 'Try a different filter or clear your search.',
)
else
for (final shift in shifts)
Padding(
padding: const EdgeInsets.only(bottom: 11),
child: ShiftCard(
shift: shift,
showDate: true,
onTap: () => context.push('/shift/${shift.id}'),
trailingAction: _ApplyButton(shift: shift),
),
),
],
);
}
}
class _ApplyButton extends ConsumerWidget {
const _ApplyButton({required this.shift});
final Shift shift;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
Fmt.money(shift.scheduledPay),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
),
const SizedBox(width: 10),
FilledButton(
onPressed: () {
HapticFeedback.selectionClick();
ref.read(shiftsProvider.notifier).apply(shift.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${shift.role} at ${shift.business} booked for '
'${Fmt.dayShort(shift.start)}',
),
),
);
},
style: FilledButton.styleFrom(
minimumSize: const Size(0, 34),
padding: const EdgeInsets.symmetric(horizontal: 16),
textStyle: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
),
),
child: const Text('Apply'),
),
],
);
}
}
class _HistoryTab extends ConsumerWidget {
const _HistoryTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final history = ref.watch(historyShiftsProvider);
final completed = history
.where((s) => s.status == ShiftStatus.completed)
.length;
final cancelled = history
.where((s) => s.status == ShiftStatus.cancelled)
.length;
final logged = ref.watch(hoursLoggedProvider);
return ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
children: [
StatRail(
stats: [
('Completed', '$completed'),
('Cancelled', '$cancelled'),
('Logged', Fmt.duration(logged)),
],
),
const SectionLabel('Everything you have worked'),
if (history.isEmpty)
const EmptyState(
icon: LucideIcons.history,
title: 'No history yet',
message: 'Shifts you finish or cancel will show up here.',
)
else
for (final shift in history)
Padding(
padding: const EdgeInsets.only(bottom: 11),
child: ShiftCard(
shift: shift,
showDate: true,
onTap: () => context.push('/shift/${shift.id}'),
),
),
],
);
}
}