MO-Fuel-Tax-Back/lib/screens/receipts_screen.dart
2026-08-15 19:23:03 -05:00

334 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import '../services/onboarding_keys.dart';
import '../theme/app_theme.dart';
import '../widgets/hero_banner.dart';
import '../widgets/receipt_capture.dart';
import 'add_edit_vehicle_screen.dart';
import 'receipt_detail_screen.dart';
/// The three summary periods selectable below the hero totals — the
/// totals and the "Recent Activity" list below are both filtered to
/// whichever one is currently selected.
enum _DateRange {
currentMonth('Current Month'),
last3Months('Last 3 Months'),
yearToDate('Year to Date');
final String label;
const _DateRange(this.label);
/// Inclusive start of this range, relative to [now]. There's no
/// explicit end — an entry just needs to not be *before* this to count,
/// since receipts are never dated in the future in practice.
DateTime startDate(DateTime now) {
switch (this) {
case _DateRange.currentMonth:
return DateTime(now.year, now.month, 1);
case _DateRange.last3Months:
// Current month plus the two before it, from the 1st of the
// earliest — DateTime normalizes a zero/negative month across a
// year boundary correctly (e.g. month 0 becomes December prior year).
return DateTime(now.year, now.month - 2, 1);
case _DateRange.yearToDate:
return DateTime(now.year, 1, 1);
}
}
}
/// The "Receipts" tab: every fuel entry across every vehicle, newest
/// first, styled after the theme mockups' receipt-focused layout — a
/// hero total, a date-range filter, then a flat, divided list of receipts
/// (date + amount + which vehicle) for that range, each tapping straight
/// through to that receipt's photo rather than to the vehicle it belongs to.
class ReceiptsScreen extends StatefulWidget {
const ReceiptsScreen({super.key});
@override
State<ReceiptsScreen> createState() => _ReceiptsScreenState();
}
class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAliveClientMixin {
_DateRange _range = _DateRange.yearToDate;
// Keeps this tab's state (the selected date range) alive when MainShell's
// PageView builds/tears down tabs outside its cache extent as the user
// swipes between them.
@override
bool get wantKeepAlive => true;
Future<void> _addReceipt(BuildContext context) async {
final vehicles = context.read<AppState>().vehicles;
if (vehicles.isEmpty) {
final newVehicleId = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => const AddEditVehicleScreen(promptToCreateForReceipt: true),
),
);
// Creating the vehicle was only ever a means to an end here — continue
// straight into the receipt capture the user actually came here for,
// rather than dropping them back on a Receipts tab that's still empty.
if (newVehicleId != null && context.mounted) {
await captureReceiptForVehicle(context, newVehicleId);
}
return;
}
if (vehicles.length == 1) {
await captureReceiptForVehicle(context, vehicles.first.id);
return;
}
final chosen = await showModalBottomSheet<Vehicle>(
context: context,
builder: (context) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('Which vehicle is this receipt for?'),
),
for (final vehicle in vehicles)
ListTile(
leading: const Icon(Icons.directions_car_outlined),
title: Text(vehicle.displayLabel),
onTap: () => Navigator.of(context).pop(vehicle),
),
],
),
),
);
if (chosen != null && context.mounted) {
await captureReceiptForVehicle(context, chosen.id);
}
}
void _openReceipt(BuildContext context, FuelEntry entry) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ReceiptDetailScreen(entryId: entry.id)),
);
}
@override
Widget build(BuildContext context) {
super.build(context);
final appState = context.watch<AppState>();
final rangeStart = _range.startDate(DateTime.now());
final entries = appState.fuelEntries.where((e) => !e.date.isBefore(rangeStart)).toList()
..sort((a, b) => b.date.compareTo(a.date));
final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
final totalGallons = entries.fold(0.0, (sum, e) => sum + e.gallons);
final dateFormat = DateFormat.yMMMd();
final currencyFormat = NumberFormat.simpleCurrency();
final gallonsFormat = NumberFormat('#,##0.000');
// The hero stat is a big headline number, not a precise figure — round
// to whole gallons there rather than showing the same 3-decimal
// precision the per-entry rows use.
final heroGallonsFormat = NumberFormat('#,##0');
return Scaffold(
appBar: AppBar(
title: const Text('Receipts'),
actionsPadding: const EdgeInsets.only(right: 20),
actions: [
IconButton(
key: OnboardingKeys.receiptsAddButton,
icon: const Icon(Icons.add),
tooltip: 'Log Fuel Receipt',
onPressed: () => _addReceipt(context),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
_HeroTotal(
totalCost: totalCost,
totalGallons: totalGallons,
currencyFormat: currencyFormat,
gallonsFormat: heroGallonsFormat,
selectedRange: _range,
onRangeSelected: (range) => setState(() => _range = range),
),
Padding(
padding: const EdgeInsets.fromLTRB(2, 14, 2, 8),
child: Text(
'Recent Activity',
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
),
if (entries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('No fuel entries in this range.'),
)
else
for (var i = 0; i < entries.length; i++) ...[
if (i > 0) const Divider(height: 1),
_ReceiptRow(
entry: entries[i],
vehicle: appState.vehicleById(entries[i].vehicleId),
dateFormat: dateFormat,
currencyFormat: currencyFormat,
gallonsFormat: gallonsFormat,
onTap: () => _openReceipt(context, entries[i]),
),
],
],
),
);
}
}
/// Sits inside the hero banner (on the gradient, not the plain scaffold
/// background), so selected/unselected use the hero's own white/translucent
/// text colors rather than the surface-based colorScheme ones.
class _DateRangeSelector extends StatelessWidget {
final _DateRange selected;
final ValueChanged<_DateRange> onSelected;
const _DateRangeSelector({required this.selected, required this.onSelected});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (final range in _DateRange.values)
GestureDetector(
onTap: () => onSelected(range),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
range.label,
style: TextStyle(
color: range == selected
? AppTheme.heroText
: AppTheme.heroTextSecondary(context),
fontWeight: range == selected ? FontWeight.w800 : FontWeight.w600,
fontSize: 12.5,
),
),
),
),
],
);
}
}
class _HeroTotal extends StatelessWidget {
final double totalCost;
final double totalGallons;
final NumberFormat currencyFormat;
final NumberFormat gallonsFormat;
final _DateRange selectedRange;
final ValueChanged<_DateRange> onRangeSelected;
const _HeroTotal({
required this.totalCost,
required this.totalGallons,
required this.currencyFormat,
required this.gallonsFormat,
required this.selectedRange,
required this.onRangeSelected,
});
@override
Widget build(BuildContext context) {
return HeroBanner(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HeroStatsRow(
gallonsValue: gallonsFormat.format(totalGallons),
costValue: currencyFormat.format(totalCost),
),
const SizedBox(height: 7),
_DateRangeSelector(selected: selectedRange, onSelected: onRangeSelected),
],
),
);
}
}
class _ReceiptRow extends StatelessWidget {
final FuelEntry entry;
final Vehicle? vehicle;
final DateFormat dateFormat;
final NumberFormat currencyFormat;
final NumberFormat gallonsFormat;
final VoidCallback onTap;
const _ReceiptRow({
required this.entry,
required this.vehicle,
required this.dateFormat,
required this.currencyFormat,
required this.gallonsFormat,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateFormat.format(entry.date),
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(height: 3),
Text(
'${gallonsFormat.format(entry.gallons)} gal',
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12.5),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
currencyFormat.format(entry.totalCost),
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
),
child: Text(
vehicle?.displayLabel ?? 'Unknown vehicle',
style: TextStyle(
color: colorScheme.primary,
fontSize: 10.5,
fontWeight: FontWeight.w700,
),
),
),
],
),
],
),
),
);
}
}