392 lines
12 KiB
Dart
392 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/krow_wordmark.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: [
|
|
HeaderBrandLine(
|
|
trailing: HeaderEyebrow(Fmt.monthYear(DateTime.now())),
|
|
),
|
|
const SizedBox(height: 8),
|
|
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}'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|