432 lines
15 KiB
Dart
432 lines
15 KiB
Dart
import 'package:flutter/material.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/availability_controller.dart';
|
|
import '../state/compliance_controller.dart';
|
|
import '../state/profile_controller.dart';
|
|
import '../state/shifts_controller.dart';
|
|
import '../widgets/collapsing_header.dart';
|
|
import '../widgets/common.dart';
|
|
import '../widgets/shift_card.dart';
|
|
import '../widgets/krow_wordmark.dart';
|
|
|
|
class TodayScreen extends ConsumerWidget {
|
|
const TodayScreen({super.key});
|
|
|
|
/// Brand line (40) + gap + identity row (44) + gap + stats, plus 6/18
|
|
/// padding.
|
|
static const _expandedHeader = 188.0;
|
|
|
|
String _greeting() {
|
|
final h = DateTime.now().hour;
|
|
if (h < 12) return 'Good morning';
|
|
if (h < 18) return 'Good afternoon';
|
|
return 'Good evening';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final worker = ref.watch(workerStatsProvider);
|
|
final next = ref.watch(nextShiftProvider);
|
|
final open = ref.watch(openShiftsProvider);
|
|
final scheduled = ref.watch(scheduledEarningsProvider);
|
|
final hours = ref.watch(hoursLoggedProvider);
|
|
final daysSet = ref.watch(daysSetProvider);
|
|
final compliant = ref.watch(isCompliantProvider);
|
|
final (done, total) = ref.watch(complianceProgressProvider);
|
|
|
|
return Scaffold(
|
|
body: CustomScrollView(
|
|
slivers: [
|
|
KrowSliverHeader(
|
|
expandedHeight: _expandedHeader,
|
|
collapsedHeight: 60,
|
|
builder: (context, t) => HeaderCrossFade(
|
|
t: t,
|
|
expandedHeight: _expandedHeader,
|
|
expanded: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 6, 20, 18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
HeaderBrandLine(
|
|
trailing: HeaderIconButton(
|
|
icon: LucideIcons.messageCircle,
|
|
tooltip: 'Support',
|
|
onPressed: () => context.push('/support'),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
_Avatar(worker.initials),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_greeting().toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 9.5,
|
|
letterSpacing: 1.6,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
worker.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.7,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
// Flexible, not free-sized: on a 320px screen the two
|
|
// captions plus the rule are wider than the header.
|
|
Flexible(
|
|
child: _HeaderStat(
|
|
Fmt.money(scheduled),
|
|
'This period',
|
|
),
|
|
),
|
|
Container(
|
|
width: 1,
|
|
height: 34,
|
|
margin: const EdgeInsets.symmetric(horizontal: 18),
|
|
color: Colors.white24,
|
|
),
|
|
Flexible(
|
|
child: _HeaderStat(
|
|
Fmt.duration(hours),
|
|
'Hours logged',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Once collapsed, keep who you are and the figure that matters,
|
|
// plus the one control that was in the open state.
|
|
collapsed: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
|
child: Row(
|
|
children: [
|
|
_Avatar(worker.initials, size: 32),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
worker.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.4,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
Fmt.money(scheduled),
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.5,
|
|
color: KrowPalette.yellow,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
HeaderIconButton(
|
|
icon: LucideIcons.messageCircle,
|
|
tooltip: 'Support',
|
|
onPressed: () => context.push('/support'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
|
sliver: SliverList.list(
|
|
children: [
|
|
if (!compliant) ...[
|
|
const SizedBox(height: 16),
|
|
KrowCard(
|
|
onTap: () => context.push('/compliance'),
|
|
padding: const EdgeInsets.all(14),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
LucideIcons.triangleAlert,
|
|
size: 20,
|
|
color: KrowPalette.ink,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Finish your compliance',
|
|
style: TextStyle(
|
|
fontSize: 14.5,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'$done of $total items cleared',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: KrowPalette.mute,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const StatusChip('Action', tone: ChipTone.yellow),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const SectionLabel('Up next'),
|
|
if (next == null)
|
|
EmptyState(
|
|
icon: LucideIcons.ticket,
|
|
title: 'Nothing booked',
|
|
message:
|
|
'Open shifts near you start at '
|
|
'${open.isEmpty ? '-' : Fmt.money(open.last.rate)}/hr.',
|
|
action: FilledButton(
|
|
onPressed: () => context.go('/shifts'),
|
|
child: const Text('Browse open shifts'),
|
|
),
|
|
)
|
|
else
|
|
ShiftCard(
|
|
shift: next,
|
|
showDate: !Fmt.sameDay(next.start, DateTime.now()),
|
|
onTap: () => context.push('/shift/${next.id}'),
|
|
),
|
|
if (next != null && next.status == ShiftStatus.active) ...[
|
|
const SizedBox(height: 10),
|
|
FilledButton.icon(
|
|
onPressed: () => context.go('/clock'),
|
|
icon: const Icon(LucideIcons.squareCheck, size: 18),
|
|
label: const Text('You are on shift - go to clock'),
|
|
),
|
|
],
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _QuickTile(
|
|
icon: LucideIcons.search,
|
|
label: 'Find shifts',
|
|
caption: '${open.length} OPEN',
|
|
onTap: () => context.go('/shifts'),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _QuickTile(
|
|
icon: LucideIcons.calendar,
|
|
label: 'Availability',
|
|
caption: '$daysSet DAYS SET',
|
|
onTap: () => context.push('/availability'),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _QuickTile(
|
|
icon: LucideIcons.banknote,
|
|
label: 'Earnings',
|
|
caption: 'PENDING',
|
|
onTap: () => context.go('/pay'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SectionLabel(
|
|
'Matched to you',
|
|
trailing: TextButton(
|
|
onPressed: () => context.go('/shifts'),
|
|
child: const Text('See all'),
|
|
),
|
|
),
|
|
if (open.isEmpty)
|
|
const InfoBanner(
|
|
'No open shifts right now. Turn on more availability blocks '
|
|
'and we will match you to more work.',
|
|
)
|
|
else
|
|
for (final shift in open.take(3))
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: ShiftCard(
|
|
shift: shift,
|
|
showDate: true,
|
|
onTap: () => context.push('/shift/${shift.id}'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The worker's initials, used at two sizes as the header collapses.
|
|
class _Avatar extends StatelessWidget {
|
|
const _Avatar(this.initials, {this.size = 44});
|
|
|
|
final String initials;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: KrowPalette.yellow,
|
|
borderRadius: BorderRadius.circular(size / 3.4),
|
|
),
|
|
child: Text(
|
|
initials,
|
|
style: TextStyle(
|
|
fontSize: size * 0.39,
|
|
fontWeight: FontWeight.w700,
|
|
color: KrowPalette.ink,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HeaderStat extends StatelessWidget {
|
|
const _HeaderStat(this.value, this.caption);
|
|
|
|
final String value;
|
|
final String caption;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Both lines hold to one line and shrink to fit: a wrapped caption
|
|
// would push the header past its pinned height.
|
|
FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
value,
|
|
maxLines: 1,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.8,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 3),
|
|
FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
caption.toUpperCase(),
|
|
maxLines: 1,
|
|
style: TextStyle(
|
|
fontSize: 9.5,
|
|
letterSpacing: 1.4,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QuickTile extends StatelessWidget {
|
|
const _QuickTile({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.caption,
|
|
required this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final String caption;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return KrowCard(
|
|
onTap: onTap,
|
|
padding: const EdgeInsets.fromLTRB(12, 14, 12, 14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: 34,
|
|
height: 34,
|
|
decoration: BoxDecoration(
|
|
color: KrowPalette.blueTint,
|
|
borderRadius: BorderRadius.circular(11),
|
|
),
|
|
child: Icon(icon, size: 17, color: KrowPalette.blue),
|
|
),
|
|
const SizedBox(height: 22),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.2,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
caption,
|
|
style: const TextStyle(
|
|
fontSize: 9.5,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.5,
|
|
color: Color(0xFF949FA5),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|