Initial commit: Krow Worker App
This commit is contained in:
396
lib/screens/shifts_screen.dart
Normal file
396
lib/screens/shifts_screen.dart
Normal file
@@ -0,0 +1,396 @@
|
||||
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}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user