266 lines
9.4 KiB
Dart
266 lines
9.4 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
import '../services/app_state.dart';
|
||
import '../services/receipt_parser.dart';
|
||
import '../widgets/backup_reminder.dart';
|
||
import 'receipt_image_screen.dart';
|
||
|
||
/// Shown right after a receipt photo is captured and OCR'd. Pre-fills
|
||
/// whatever the parser found and lets the user review/correct every field
|
||
/// before it's written to the data file, since OCR on a printed receipt is
|
||
/// usually good but not perfect.
|
||
class ConfirmFuelEntryScreen extends StatefulWidget {
|
||
final String vehicleId;
|
||
final File imageFile;
|
||
final ParsedReceipt parsed;
|
||
|
||
const ConfirmFuelEntryScreen({
|
||
super.key,
|
||
required this.vehicleId,
|
||
required this.imageFile,
|
||
required this.parsed,
|
||
});
|
||
|
||
@override
|
||
State<ConfirmFuelEntryScreen> createState() => _ConfirmFuelEntryScreenState();
|
||
}
|
||
|
||
class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
late final TextEditingController _gallonsController;
|
||
late final TextEditingController _priceController;
|
||
late final TextEditingController _totalController;
|
||
late DateTime _date;
|
||
bool _saving = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// Prefer the date/time printed on the receipt; fall back to now, same
|
||
// as before, when it couldn't be parsed — the date picker below is
|
||
// always available either way for manual entry/correction.
|
||
_date = widget.parsed.date ?? DateTime.now();
|
||
_gallonsController = TextEditingController(
|
||
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
|
||
);
|
||
_priceController = TextEditingController(
|
||
text: widget.parsed.pricePerGallon?.toStringAsFixed(3) ?? '',
|
||
);
|
||
_totalController = TextEditingController(
|
||
text: widget.parsed.totalCost?.toStringAsFixed(2) ?? '',
|
||
);
|
||
// So an ad is ready by the time _save() finishes without delaying it.
|
||
context.read<AppState>().preloadFuelSaveAd();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_gallonsController.dispose();
|
||
_priceController.dispose();
|
||
_totalController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _calculateTotal() {
|
||
final gallons = double.tryParse(_gallonsController.text);
|
||
final price = double.tryParse(_priceController.text);
|
||
if (gallons != null && price != null) {
|
||
setState(() {
|
||
_totalController.text = (gallons * price).toStringAsFixed(2);
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _pickDate() async {
|
||
// initialDate must fall within [firstDate, lastDate] or the picker
|
||
// throws — widen the bounds to cover _date in case a misread date from
|
||
// the receipt landed outside the normal 5-years-back-to-today window.
|
||
final today = DateTime.now();
|
||
final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
|
||
? _date
|
||
: today.subtract(const Duration(days: 365 * 5));
|
||
final lastDate = _date.isAfter(today) ? _date : today;
|
||
|
||
final pickedDate = await showDatePicker(
|
||
context: context,
|
||
initialDate: _date,
|
||
firstDate: firstDate,
|
||
lastDate: lastDate,
|
||
);
|
||
if (pickedDate == null || !mounted) return;
|
||
|
||
final pickedTime = await showTimePicker(
|
||
context: context,
|
||
initialTime: TimeOfDay.fromDateTime(_date),
|
||
);
|
||
if (pickedTime == null) return;
|
||
|
||
setState(() {
|
||
_date = DateTime(
|
||
pickedDate.year,
|
||
pickedDate.month,
|
||
pickedDate.day,
|
||
pickedTime.hour,
|
||
pickedTime.minute,
|
||
);
|
||
});
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
if (!_formKey.currentState!.validate()) return;
|
||
|
||
setState(() => _saving = true);
|
||
try {
|
||
final appState = context.read<AppState>();
|
||
await appState.addFuelEntry(
|
||
vehicleId: widget.vehicleId,
|
||
date: _date,
|
||
gallons: double.parse(_gallonsController.text),
|
||
pricePerGallon: double.parse(_priceController.text),
|
||
totalCost: double.parse(_totalController.text),
|
||
receiptImage: widget.imageFile,
|
||
);
|
||
if (!mounted) return;
|
||
|
||
// The entry is already committed by this point, so the ad isn't
|
||
// gating the save — it just fills the moment between saving and
|
||
// landing back on the list. Awaited rather than fired off, so the
|
||
// two don't race and the ad can't end up drawn over the Receipts
|
||
// tab after this screen has gone.
|
||
final adShown = await appState.maybeShowFuelSaveAd();
|
||
if (!mounted) return;
|
||
|
||
// Ask while this screen (and its context) is still fully alive,
|
||
// before popping — simpler than trying to show a dialog against a
|
||
// context whose widget is mid-removal. Held back when an ad just
|
||
// ran: one full-screen surface per save, and the reminder comes
|
||
// back around on the next save anyway.
|
||
var wantsBackupSetup = false;
|
||
if (!adShown && !appState.hasCloudBackupConfigured) {
|
||
wantsBackupSetup = await showBackupReminderDialog(context);
|
||
if (!mounted) return;
|
||
}
|
||
|
||
Navigator.of(context).pop();
|
||
if (wantsBackupSetup) {
|
||
unawaited(openCloudBackupSetup(context));
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
|
||
String? _requiredDecimal(String? value) {
|
||
if (value == null || value.trim().isEmpty) return 'Required';
|
||
if (double.tryParse(value) == null) return 'Enter a valid number';
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dateFormat = DateFormat.yMMMd().add_jm();
|
||
final nothingParsed = widget.parsed.gallons == null &&
|
||
widget.parsed.pricePerGallon == null &&
|
||
widget.parsed.totalCost == null;
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Confirm Fuel Entry')),
|
||
body: Form(
|
||
key: _formKey,
|
||
child: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
|
||
),
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Image.file(widget.imageFile, height: 180, fit: BoxFit.cover, width: double.infinity),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Tap the photo to view it full-screen.',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
if (nothingParsed) ...[
|
||
const SizedBox(height: 16),
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.errorContainer,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.warning_amber_rounded, color: Theme.of(context).colorScheme.onErrorContainer),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
"Couldn't automatically read this receipt. Please enter the values manually.",
|
||
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 16),
|
||
ListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: const Text('Date & time'),
|
||
subtitle: Text(dateFormat.format(_date)),
|
||
trailing: const Icon(Icons.edit_calendar_outlined),
|
||
onTap: _pickDate,
|
||
),
|
||
const SizedBox(height: 8),
|
||
TextFormField(
|
||
controller: _gallonsController,
|
||
decoration: const InputDecoration(labelText: 'Gallons', suffixText: 'gal'),
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
validator: _requiredDecimal,
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _priceController,
|
||
decoration: const InputDecoration(labelText: 'Price per gallon', prefixText: '\$'),
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
validator: _requiredDecimal,
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _totalController,
|
||
decoration: InputDecoration(
|
||
labelText: 'Total cost',
|
||
prefixText: '\$',
|
||
suffixIcon: IconButton(
|
||
icon: const Icon(Icons.calculate_outlined),
|
||
tooltip: 'Calculate from gallons × price',
|
||
onPressed: _calculateTotal,
|
||
),
|
||
),
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
validator: _requiredDecimal,
|
||
),
|
||
const SizedBox(height: 24),
|
||
FilledButton(
|
||
onPressed: _saving ? null : _save,
|
||
child: _saving
|
||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||
: const Text('Save Entry'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|