A whole bunch of changes
This commit is contained in:
parent
901ec678a3
commit
956231048e
122 changed files with 5442 additions and 914 deletions
|
|
@ -1,12 +1,24 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/fuel_report.dart';
|
||||
import '../services/fuel_report_images.dart';
|
||||
import '../services/fuel_report_pdf.dart';
|
||||
|
||||
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
|
||||
/// file for the user — it's a state form, submitted to the state — so the
|
||||
/// Reports tab just links out to where to get it rather than trying to
|
||||
/// reproduce or embed it.
|
||||
final _refundFormUrl = Uri.parse(
|
||||
'https://dor.mo.gov/forms/?formName=Motor+Fuel+Refund+Claim&category=&year=99',
|
||||
);
|
||||
|
||||
class ReportScreen extends StatefulWidget {
|
||||
const ReportScreen({super.key});
|
||||
|
||||
|
|
@ -14,13 +26,30 @@ class ReportScreen extends StatefulWidget {
|
|||
State<ReportScreen> createState() => _ReportScreenState();
|
||||
}
|
||||
|
||||
class _ReportScreenState extends State<ReportScreen> {
|
||||
class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClientMixin {
|
||||
static final _dateFormat = DateFormat.yMMMd();
|
||||
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
bool _includeReceiptImages = false;
|
||||
bool _includeLineItems = false;
|
||||
bool _busy = false;
|
||||
|
||||
/// Which vehicles (by id) to include in the report — checkboxes next to
|
||||
/// each vehicle below the date range. [_seenVehicleIds] tracks every
|
||||
/// vehicle a checkbox has ever been shown for, so a vehicle newly
|
||||
/// appearing in range (first time it's seen) defaults to selected without
|
||||
/// clobbering a selection the user already made for one seen before (e.g.
|
||||
/// after picking a different range and back).
|
||||
final Set<String> _selectedVehicleIds = {};
|
||||
final Set<String> _seenVehicleIds = {};
|
||||
|
||||
// Keeps this tab's state (chosen date range, include-image/line-item
|
||||
// toggles) 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> _pickDate({required bool isStart}) async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
|
|
@ -39,10 +68,39 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
});
|
||||
}
|
||||
|
||||
Future<Uint8List> _buildPdfBytes(FuelReport report) async {
|
||||
var receiptImages = const <String, Uint8List>{};
|
||||
if (_includeReceiptImages) {
|
||||
final loaded = await loadReceiptImageBytes(
|
||||
report: report,
|
||||
activeProvider: context.read<AppState>().activeProvider,
|
||||
);
|
||||
receiptImages = loaded.bytesByEntryId;
|
||||
if (loaded.failedEntries.isNotEmpty && mounted) {
|
||||
final count = loaded.failedEntries.length;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"$count receipt photo${count == 1 ? '' : 's'} couldn't be included — offline, "
|
||||
'not signed in to the provider that has it, or the remote file is gone.',
|
||||
),
|
||||
duration: const Duration(seconds: 6),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return buildFuelReportPdf(
|
||||
report,
|
||||
includeLineItems: _includeLineItems,
|
||||
includeReceiptImages: _includeReceiptImages,
|
||||
receiptImages: receiptImages,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _share(FuelReport report) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final bytes = await buildFuelReportPdf(report);
|
||||
final bytes = await _buildPdfBytes(report);
|
||||
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
|
|
@ -52,32 +110,55 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
Future<void> _print(FuelReport report) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final bytes = await buildFuelReportPdf(report);
|
||||
final bytes = await _buildPdfBytes(report);
|
||||
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openRefundFormLink() async {
|
||||
final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Couldn't open the link.")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
String? rangeError;
|
||||
FuelReport? report;
|
||||
FuelReport? rawReport;
|
||||
if (_startDate != null && _endDate != null) {
|
||||
if (_endDate!.isBefore(_startDate!)) {
|
||||
rangeError = 'End date must be on or after the start date.';
|
||||
} else {
|
||||
report = buildFuelReport(
|
||||
rawReport = buildFuelReport(
|
||||
vehicles: appState.vehicles,
|
||||
fuelEntries: appState.fuelEntries,
|
||||
startDate: _startDate!,
|
||||
endDate: _endDate!,
|
||||
);
|
||||
for (final row in rawReport.rows) {
|
||||
if (_seenVehicleIds.add(row.vehicle.id)) {
|
||||
_selectedVehicleIds.add(row.vehicle.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final report = rawReport == null
|
||||
? null
|
||||
: FuelReport(
|
||||
startDate: rawReport.startDate,
|
||||
endDate: rawReport.endDate,
|
||||
rows: rawReport.rows.where((r) => _selectedVehicleIds.contains(r.vehicle.id)).toList(),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Fuel Report')),
|
||||
body: ListView(
|
||||
|
|
@ -104,69 +185,202 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
const SizedBox(height: 8),
|
||||
Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (report == null)
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Include Receipt Images'),
|
||||
subtitle: const Text(
|
||||
'Every receipt photo in the date range, grouped by vehicle and '
|
||||
'sorted by date — packed as many to a page as will fit.',
|
||||
),
|
||||
value: _includeReceiptImages,
|
||||
onChanged: (value) => setState(() => _includeReceiptImages = value),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Include Receipt Line Items'),
|
||||
subtitle: const Text(
|
||||
'Every receipt as its own line (date, gallons, price/gal, '
|
||||
'cost), grouped by vehicle and sorted by date.',
|
||||
),
|
||||
value: _includeLineItems,
|
||||
onChanged: (value) => setState(() => _includeLineItems = value),
|
||||
),
|
||||
if (_includeReceiptImages && _includeLineItems)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'Each line item and its receipt photo will be tagged with a '
|
||||
'matching number.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (rawReport == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('Choose a start and end date to generate a report.'),
|
||||
)
|
||||
else if (report.rows.isEmpty)
|
||||
else if (rawReport.rows.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('No vehicles had a receipted fuel entry in that date range.'),
|
||||
)
|
||||
else ...[
|
||||
for (final row in report.rows)
|
||||
Card(
|
||||
child: ListTile(
|
||||
title: Text(row.vehicle.displayLabel),
|
||||
subtitle: Text(
|
||||
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
|
||||
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('${row.totalGallons.toStringAsFixed(3)} gal'),
|
||||
Text(NumberFormat.simpleCurrency().format(row.totalCost)),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final row in rawReport.rows)
|
||||
_VehicleSelectionCard(
|
||||
row: row,
|
||||
selected: _selectedVehicleIds.contains(row.vehicle.id),
|
||||
onTap: () => setState(() {
|
||||
if (_selectedVehicleIds.contains(row.vehicle.id)) {
|
||||
_selectedVehicleIds.remove(row.vehicle.id);
|
||||
} else {
|
||||
_selectedVehicleIds.add(row.vehicle.id);
|
||||
}
|
||||
}),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
ListTile(
|
||||
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
trailing: Text(
|
||||
'${report.totalGallons.toStringAsFixed(3)} gal · '
|
||||
'${NumberFormat.simpleCurrency().format(report.totalCost)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
if (report!.rows.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('No vehicles selected — tap at least one above to build a report.'),
|
||||
)
|
||||
else ...[
|
||||
ListTile(
|
||||
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
trailing: Text(
|
||||
'${report.totalGallons.toStringAsFixed(3)} gal · '
|
||||
'${NumberFormat.simpleCurrency().format(report.totalCost)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : () => _share(report),
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
label: const Text('Share'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy ? null : () => _print(report),
|
||||
icon: const Icon(Icons.print_outlined),
|
||||
label: const Text('Print'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Need the refund claim form?', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"This app tracks your fuel purchases, but the actual refund claim is "
|
||||
"filed with the state. Get the official form from Missouri's "
|
||||
'Department of Revenue website.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: _openRefundFormLink,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.open_in_new, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Get the Motor Fuel Refund Claim form',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : () => _share(report!),
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
label: const Text('Share'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy ? null : () => _print(report!),
|
||||
icon: const Icon(Icons.print_outlined),
|
||||
label: const Text('Print'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-vehicle row in the vehicle-selection list — tapping the whole
|
||||
/// card toggles [selected], indicated purely by color rather than a
|
||||
/// checkbox: [selected] uses the same primary/on-primary theming as the
|
||||
/// "Share" [FilledButton] below it, [!selected] looks like a plain
|
||||
/// unselected [Card] (default surface color, normal text colors).
|
||||
class _VehicleSelectionCard extends StatelessWidget {
|
||||
final VehicleReportRow row;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _VehicleSelectionCard({required this.row, required this.selected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface;
|
||||
final foregroundVariant = selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant;
|
||||
|
||||
return Card(
|
||||
color: selected ? colorScheme.primary : null,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
row.vehicle.displayLabel,
|
||||
style: TextStyle(color: foreground, fontWeight: FontWeight.w600, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
|
||||
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
|
||||
style: TextStyle(color: foregroundVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('${row.totalGallons.toStringAsFixed(3)} gal', style: TextStyle(color: foreground)),
|
||||
Text(
|
||||
NumberFormat.simpleCurrency().format(row.totalCost),
|
||||
style: TextStyle(color: foreground),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue