334 lines
11 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|