Initial commit: Krow Worker App
This commit is contained in:
388
lib/screens/timecard_screens.dart
Normal file
388
lib/screens/timecard_screens.dart
Normal file
@@ -0,0 +1,388 @@
|
||||
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/timecard.dart';
|
||||
import '../state/timecards_controller.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
class TimecardsScreen extends ConsumerWidget {
|
||||
const TimecardsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cards = ref.watch(timecardsProvider);
|
||||
final pending = cards.where((c) => c.status == TimecardStatus.needsReview);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Your timecards'),
|
||||
actions: [
|
||||
if (pending.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: StatusChip(
|
||||
'${pending.length} to review',
|
||||
tone: ChipTone.yellow,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: cards.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: EmptyState(
|
||||
icon: LucideIcons.fileText,
|
||||
title: 'No timecards yet',
|
||||
message: 'One is filed automatically every time you clock out.',
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
|
||||
itemCount: cards.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 11),
|
||||
itemBuilder: (context, i) => _TimecardTile(card: cards[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimecardTile extends StatelessWidget {
|
||||
const _TimecardTile({required this.card});
|
||||
|
||||
final Timecard card;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, tone) = switch (card.status) {
|
||||
TimecardStatus.needsReview => ('Needs review', ChipTone.yellow),
|
||||
TimecardStatus.approved => ('Approved', ChipTone.neutral),
|
||||
TimecardStatus.disputed => ('Disputed', ChipTone.danger),
|
||||
};
|
||||
|
||||
return KrowCard(
|
||||
onTap: () => context.push('/timecard/${card.id}'),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
BusinessBadge(card.business[0], size: 40),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
card.role,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${card.business} · ${Fmt.dayShort(card.date)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusChip(label, tone: tone),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MiniStat(
|
||||
LucideIcons.calendar,
|
||||
'Scheduled',
|
||||
Fmt.window(card.scheduledStart, card.scheduledEnd),
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 34, color: KrowPalette.line),
|
||||
Expanded(
|
||||
child: _MiniStat(
|
||||
LucideIcons.clock,
|
||||
'Clocked',
|
||||
Fmt.window(card.clockIn, card.clockOut),
|
||||
highlight: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Filed ${Fmt.time(card.submittedAt)} · '
|
||||
'${Fmt.duration(card.worked)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Fmt.money(card.amount),
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniStat extends StatelessWidget {
|
||||
const _MiniStat(this.icon, this.label, this.value, {this.highlight = false});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final bool highlight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 16, color: KrowPalette.blue),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: highlight ? KrowPalette.blue : KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Review a filed timecard: confirm it, or raise an issue before it locks for
|
||||
/// payroll.
|
||||
class TimecardReviewScreen extends ConsumerWidget {
|
||||
const TimecardReviewScreen({super.key, required this.timecardId});
|
||||
|
||||
final String timecardId;
|
||||
|
||||
Future<void> _dispute(BuildContext context, WidgetRef ref) async {
|
||||
final controller = TextEditingController();
|
||||
final note = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Report an issue'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'What is wrong with these hours?',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
child: const Text('Send'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (note == null || note.isEmpty || !context.mounted) return;
|
||||
ref.read(timecardsProvider.notifier).dispute(timecardId, note);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Issue raised with payroll')));
|
||||
context.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final card = ref.watch(timecardsProvider.notifier).byId(timecardId);
|
||||
if (card == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(child: Text('Timecard not found.')),
|
||||
);
|
||||
}
|
||||
|
||||
final variance = card.varianceMinutes;
|
||||
final open = card.status == TimecardStatus.needsReview;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Review timecard'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: StatusChip(
|
||||
card.status.label,
|
||||
tone: switch (card.status) {
|
||||
TimecardStatus.needsReview => ChipTone.yellow,
|
||||
TimecardStatus.approved => ChipTone.neutral,
|
||||
TimecardStatus.disputed => ChipTone.danger,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
Text(
|
||||
'${card.role} at ${card.business}',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
Fmt.dayFull(card.date),
|
||||
style: const TextStyle(fontSize: 13.5, color: KrowPalette.mute),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InfoBanner(
|
||||
open
|
||||
? 'Sent automatically when you clocked out. You have 7 days to '
|
||||
'raise an issue before it locks for payroll.'
|
||||
: 'This timecard is ${card.status.label.toLowerCase()}.',
|
||||
),
|
||||
const SectionLabel('Shift'),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
DetailRow('Date', Fmt.dayShort(card.date)),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Scheduled',
|
||||
Fmt.window(card.scheduledStart, card.scheduledEnd),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Clocked',
|
||||
Fmt.window(card.clockIn, card.clockOut),
|
||||
valueColor: KrowPalette.blue,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Variance',
|
||||
variance == 0
|
||||
? 'On time'
|
||||
: '${variance > 0 ? '+' : ''}$variance min',
|
||||
valueColor: variance > 5
|
||||
? const Color(0xFFA6403F)
|
||||
: KrowPalette.blue,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Hours and pay'),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
DetailRow('Base rate', '${Fmt.money(card.rate)}/hr'),
|
||||
const Divider(height: 1),
|
||||
DetailRow('Break, unpaid', '${card.breakMinutes} min'),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Regular · ${Fmt.duration(card.worked)}',
|
||||
Fmt.money(card.amount),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Total, pre-tax',
|
||||
Fmt.money(card.amount),
|
||||
emphasis: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (card.disputeNote != null) ...[
|
||||
const SectionLabel('Your note'),
|
||||
KrowCard(child: Text(card.disputeNote!)),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
if (open)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _dispute(context, ref),
|
||||
child: const Text('Report an issue'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
ref.read(timecardsProvider.notifier).approve(card.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Timecard approved')),
|
||||
);
|
||||
context.pop();
|
||||
},
|
||||
child: const Text('Looks right'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user