Initial commit: Krow Worker App
This commit is contained in:
421
lib/screens/today_screen.dart
Normal file
421
lib/screens/today_screen.dart
Normal file
@@ -0,0 +1,421 @@
|
||||
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';
|
||||
|
||||
class TodayScreen extends ConsumerWidget {
|
||||
const TodayScreen({super.key});
|
||||
|
||||
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: 152,
|
||||
collapsedHeight: 60,
|
||||
builder: (context, t) => HeaderCrossFade(
|
||||
t: t,
|
||||
expandedHeight: 152,
|
||||
expanded: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.7,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.messageCircle,
|
||||
tooltip: 'Support',
|
||||
onPressed: () => context.push('/support'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user