Remote sync of webdav

This commit is contained in:
Courtney Arnold 2026-08-13 17:05:10 -05:00
parent f01186df79
commit 483fbeafb6
44 changed files with 4842 additions and 975 deletions

View file

@ -1,8 +1,14 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import '../services/ocr_service.dart';
import '../services/vin_parser.dart';
import '../widgets/image_source_sheet.dart';
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
/// omit it to create a new one.
@ -17,52 +23,107 @@ class AddEditVehicleScreen extends StatefulWidget {
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _makeController;
late final TextEditingController _modelController;
late final TextEditingController _colorController;
late final TextEditingController _plateController;
late final TextEditingController _nicknameController;
late final TextEditingController _vinController;
bool _saving = false;
bool _scanningVin = false;
String? _error;
bool get _isEditing => widget.vehicle != null;
@override
void initState() {
super.initState();
_makeController = TextEditingController(text: widget.vehicle?.make ?? '');
_modelController = TextEditingController(text: widget.vehicle?.model ?? '');
_colorController = TextEditingController(text: widget.vehicle?.color ?? '');
_plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? '');
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
}
@override
void dispose() {
_makeController.dispose();
_modelController.dispose();
_colorController.dispose();
_plateController.dispose();
_nicknameController.dispose();
_vinController.dispose();
super.dispose();
}
Future<void> _scanVin() async {
final source = await chooseImageSource(context);
if (source == null || !mounted) return;
final picker = ImagePicker();
XFile? photo;
try {
photo = await picker.pickImage(source: source, imageQuality: 85);
} catch (e) {
if (mounted) {
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open $sourceLabel: $e')),
);
}
return;
}
if (photo == null) return;
setState(() => _scanningVin = true);
final ocrService = OcrService();
String recognizedText = '';
try {
recognizedText = await ocrService.recognizeText(File(photo.path));
} catch (_) {
recognizedText = '';
} finally {
ocrService.dispose();
}
if (!mounted) return;
final vin = VinParser.parse(recognizedText);
setState(() => _scanningVin = false);
if (vin != null) {
_vinController.text = vin;
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Couldn't read a VIN from that photo. Please enter it manually."),
),
);
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final appState = context.read<AppState>();
if (_isEditing) {
await appState.updateVehicle(widget.vehicle!.copyWith(
make: _makeController.text.trim(),
model: _modelController.text.trim(),
color: _colorController.text.trim(),
licensePlate: _plateController.text.trim(),
));
} else {
await appState.addVehicle(
make: _makeController.text.trim(),
model: _modelController.text.trim(),
color: _colorController.text.trim(),
licensePlate: _plateController.text.trim(),
);
}
setState(() {
_saving = true;
_error = null;
});
if (mounted) Navigator.of(context).pop();
final nickname = _nicknameController.text.trim();
final appState = context.read<AppState>();
try {
if (_isEditing) {
await appState.updateVehicle(widget.vehicle!.copyWith(
vin: _vinController.text.trim(),
nickname: nickname.isEmpty ? null : nickname,
clearNickname: nickname.isEmpty,
));
} else {
await appState.addVehicle(
vin: _vinController.text.trim(),
nickname: nickname.isEmpty ? null : nickname,
);
}
if (mounted) Navigator.of(context).pop();
} on DuplicateVinException catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _confirmDelete() async {
@ -72,7 +133,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
title: const Text('Delete vehicle?'),
content: Text(
'This will also delete all fuel entries and receipt photos logged for '
'${widget.vehicle!.displayName}. This cannot be undone.',
'${widget.vehicle!.displayLabel}. This cannot be undone.',
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
@ -114,36 +175,48 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _makeController,
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'),
controller: _nicknameController,
decoration: const InputDecoration(
labelText: 'Nickname (optional)',
hintText: 'e.g. Mom\'s Car, Red Ford F-150',
),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _modelController,
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _colorController,
decoration: const InputDecoration(labelText: 'Color', hintText: 'e.g. Red'),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _plateController,
decoration: const InputDecoration(labelText: 'License Plate'),
controller: _vinController,
decoration: InputDecoration(
labelText: 'VIN *',
hintText: 'Vehicle Identification Number',
helperText: 'Required — tap the camera to scan it from a photo',
suffixIcon: _scanningVin
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
icon: const Icon(Icons.camera_alt_outlined),
tooltip: 'Scan VIN from a photo',
onPressed: _scanVin,
),
),
textCapitalization: TextCapitalization.characters,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _save,
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
),
],
),

View file

@ -1,32 +1,31 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/drive_service.dart';
import '../services/cloud/cloud_storage_provider.dart';
enum _BrowseRoot { myDrive, sharedWithMe }
enum _BrowseRoot { myFiles, sharedWithMe }
/// Lets the user navigate their Google Drive — either "My Drive" or
/// folders others have shared with them — and pick a parent location.
/// Confirming looks for (or creates) the `MO-Fuel-Tax-Back` folder under
/// that location, so two people pointing at the same shared parent
/// converge on the same app folder.
class DriveFolderBrowserScreen extends StatefulWidget {
const DriveFolderBrowserScreen({super.key});
/// Lets the user navigate whichever cloud storage provider is connected —
/// their own files, and (if the provider supports it) files others have
/// shared with them — and pick a parent location. Confirming looks for (or
/// creates) the `MO-Fuel-Tax-Back` folder under that location, so two
/// people pointing at the same shared parent converge on the same app
/// folder, regardless of which provider each of them is using.
class CloudFolderBrowserScreen extends StatefulWidget {
const CloudFolderBrowserScreen({super.key});
@override
State<DriveFolderBrowserScreen> createState() => _DriveFolderBrowserScreenState();
State<CloudFolderBrowserScreen> createState() => _CloudFolderBrowserScreenState();
}
class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
late final http.Client _client;
late final DriveService _drive;
class _CloudFolderBrowserScreenState extends State<CloudFolderBrowserScreen> {
late final CloudStorageSession _session;
_BrowseRoot _root = _BrowseRoot.myDrive;
final List<DriveFolder> _pathStack = [];
_BrowseRoot _root = _BrowseRoot.myFiles;
final List<CloudFolder> _pathStack = [];
List<DriveFolder>? _folders;
List<CloudFolder>? _folders;
bool _loading = true;
String? _error;
bool _confirming = false;
@ -34,25 +33,24 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
@override
void initState() {
super.initState();
_client = context.read<AppState>().driveAuth.authenticatedHttpClient();
_drive = DriveService(_client);
_session = context.read<AppState>().activeProvider!.beginSession();
_load();
}
@override
void dispose() {
_client.close();
_session.close();
super.dispose();
}
String get _rootLabel => _root == _BrowseRoot.myDrive ? 'My Drive' : 'Shared with me';
String get _rootLabel => _root == _BrowseRoot.myFiles ? 'My Files' : 'Shared with me';
/// The folder ID that "Use This Folder" would act on, or null if the
/// current view is a virtual listing (top-level "Shared with me") rather
/// than an actual folder.
String? get _currentFolderId {
if (_pathStack.isNotEmpty) return _pathStack.last.id;
if (_root == _BrowseRoot.myDrive) return 'root';
if (_root == _BrowseRoot.myFiles) return 'root';
return null;
}
@ -66,13 +64,13 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
});
try {
List<DriveFolder> folders;
List<CloudFolder> folders;
if (_pathStack.isNotEmpty) {
folders = await _drive.listFolders(parentId: _pathStack.last.id);
} else if (_root == _BrowseRoot.myDrive) {
folders = await _drive.listFolders(parentId: 'root');
folders = await _session.listFolders(parentId: _pathStack.last.id);
} else if (_root == _BrowseRoot.myFiles) {
folders = await _session.listFolders(parentId: 'root');
} else {
folders = await _drive.listFolders(sharedWithMe: true);
folders = await _session.listFolders(sharedWithMe: true);
}
if (!mounted) return;
setState(() {
@ -97,7 +95,7 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
_load();
}
void _openFolder(DriveFolder folder) {
void _openFolder(CloudFolder folder) {
setState(() => _pathStack.add(folder));
_load();
}
@ -120,7 +118,7 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
setState(() => _confirming = true);
try {
await context.read<AppState>().chooseDriveFolder(
await context.read<AppState>().chooseCloudFolder(
parentId: parentId,
breadcrumbPath: _breadcrumbPath,
);
@ -138,20 +136,22 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
@override
Widget build(BuildContext context) {
final providerName = context.read<AppState>().activeProvider!.displayName;
final canUseCurrentFolder = _currentFolderId != null;
return Scaffold(
appBar: AppBar(title: const Text('Choose Drive Folder')),
appBar: AppBar(title: Text('Choose $providerName Folder')),
body: Column(
children: [
SegmentedButton<_BrowseRoot>(
segments: const [
ButtonSegment(value: _BrowseRoot.myDrive, label: Text('My Drive')),
ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')),
],
selected: {_root},
onSelectionChanged: (selection) => _switchRoot(selection.first),
),
if (_session.supportsSharedWithMe)
SegmentedButton<_BrowseRoot>(
segments: const [
ButtonSegment(value: _BrowseRoot.myFiles, label: Text('My Files')),
ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')),
],
selected: {_root},
onSelectionChanged: (selection) => _switchRoot(selection.first),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SingleChildScrollView(

View file

@ -33,12 +33,16 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
late final TextEditingController _gallonsController;
late final TextEditingController _priceController;
late final TextEditingController _totalController;
DateTime _date = DateTime.now();
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) ?? '',
);
@ -69,11 +73,20 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
}
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: DateTime.now().subtract(const Duration(days: 365 * 5)),
lastDate: DateTime.now(),
firstDate: firstDate,
lastDate: lastDate,
);
if (pickedDate == null || !mounted) return;

View file

@ -59,18 +59,20 @@ class _VehicleCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasNickname = vehicle.nickname?.trim().isNotEmpty ?? false;
final gallonsLine = '${totalGallons.toStringAsFixed(3)} gallons logged';
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: CircleAvatar(
child: Text(vehicle.make.isNotEmpty ? vehicle.make[0].toUpperCase() : '?'),
),
title: Text('${vehicle.color} ${vehicle.make} ${vehicle.model}'),
subtitle: Text(
'Plate: ${vehicle.licensePlate}\n${totalGallons.toStringAsFixed(3)} gallons logged',
),
isThreeLine: true,
leading: const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
title: Text(vehicle.displayLabel),
// Only repeat the VIN here when the title is already showing the
// nickname instead — otherwise the title (falling back to the VIN
// when there's no nickname) would show it twice.
subtitle: Text(hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine),
isThreeLine: hasNickname,
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),

View file

@ -5,12 +5,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/drive_service.dart';
/// Full-screen, pinch-zoomable view of a receipt photo. Pass
/// [localImagePath] for a receipt still held locally, or [driveFileId] for
/// one already uploaded to Drive and removed locally — in which case it's
/// downloaded on demand (no local caching afterward).
/// one already uploaded to the cloud and removed locally — in which case
/// it's downloaded on demand (no local caching afterward).
class ReceiptImageScreen extends StatefulWidget {
final String? localImagePath;
final String? driveFileId;
@ -42,11 +41,18 @@ class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
_error = null;
});
final authService = context.read<AppState>().driveAuth;
final client = authService.authenticatedHttpClient();
final provider = context.read<AppState>().activeProvider;
if (provider == null) {
setState(() {
_error = 'Not connected to a cloud storage provider.';
_loading = false;
});
return;
}
final session = provider.beginSession();
try {
final drive = DriveService(client);
final bytes = await drive.downloadFileBytes(widget.driveFileId!);
final bytes = await session.downloadFileBytes(widget.driveFileId!);
if (!mounted) return;
setState(() {
_downloadedBytes = Uint8List.fromList(bytes);
@ -59,7 +65,7 @@ class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
_loading = false;
});
} finally {
client.close();
session.close();
}
}

View file

@ -3,7 +3,8 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import 'drive_folder_browser_screen.dart';
import '../services/cloud/cloud_storage_provider.dart';
import 'cloud_folder_browser_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@ -16,13 +17,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _busy = false;
String? _error;
Future<void> _connect() async {
Future<void> _connect(CloudProviderId id) async {
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectDrive();
await context.read<AppState>().connectProvider(id);
} catch (e) {
setState(() => _error = 'Could not sign in: $e');
} finally {
@ -30,10 +31,35 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _connectManual(CloudProviderId id, String providerName) async {
final credentials = await showDialog<_WebDavCredentials>(
context: context,
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
);
if (credentials == null || !mounted) return;
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProviderWithCredentials(
id,
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
);
} catch (e) {
setState(() => _error = 'Could not connect: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _disconnect() async {
setState(() => _busy = true);
try {
await context.read<AppState>().disconnectDrive();
await context.read<AppState>().disconnectCloud();
} finally {
if (mounted) setState(() => _busy = false);
}
@ -41,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
void _chooseFolder() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DriveFolderBrowserScreen()),
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
);
}
@ -69,12 +95,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Google Drive', style: Theme.of(context).textTheme.titleMedium),
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (!appState.isDriveConnected) ...[
if (!appState.isCloudConnected) ...[
Text(
'Connect Google Drive to share vehicles and fuel receipts with '
'other people, and to keep a backup off this device.',
'Connect a cloud storage account to share vehicles and fuel '
'receipts with other people, and to keep a backup off this device.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
@ -82,18 +108,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
FilledButton.icon(
onPressed: _busy ? null : _connect,
icon: const Icon(Icons.login),
label: const Text('Connect Google Drive'),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final provider in appState.availableProviders)
FilledButton.icon(
onPressed: _busy
? null
: () => provider is ManualCredentialCloudStorageProvider
? _connectManual(provider.id, provider.displayName)
: _connect(provider.id),
icon: const Icon(Icons.login),
label: Text('Connect ${provider.displayName}'),
),
],
),
] else ...[
Text('Signed in as ${appState.driveAccountEmail}'),
Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
const SizedBox(height: 4),
Text(
appState.driveFolderPath == null
appState.cloudFolderPath == null
? 'No folder selected yet.'
: 'Folder: ${appState.driveFolderPath}',
: 'Folder: ${appState.cloudFolderPath}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
@ -104,12 +141,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
OutlinedButton.icon(
onPressed: _busy ? null : _chooseFolder,
icon: const Icon(Icons.folder_open),
label: Text(appState.driveFolderPath == null
label: Text(appState.cloudFolderPath == null
? 'Choose Folder'
: 'Change Folder'),
),
OutlinedButton.icon(
onPressed: (_busy || appState.driveFolderPath == null)
onPressed: (_busy || appState.cloudFolderPath == null)
? null
: _syncNow,
icon: appState.isSyncing
@ -147,6 +184,62 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep photos on this phone after syncing'),
subtitle: const Text(
'Otherwise, a receipt photo is removed from this device once '
"it's safely uploaded to the cloud.",
),
value: appState.keepReceiptPhotosLocally,
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep max quality images'),
subtitle: const Text(
'Otherwise, receipt photos are downscaled to a reasonable size '
'before storing — smaller cloud storage and faster syncs, with '
'no loss of legibility for a printed receipt.',
),
value: appState.keepMaxQualityReceiptPhotos,
onChanged: (value) =>
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
),
),
const SizedBox(height: 16),
Card(
child: ExpansionTile(
title: const Text('Advanced'),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
),
const SizedBox(height: 4),
Text(
"If another device disconnects mid-sync without releasing its lock, "
"this is how long to wait before treating it as abandoned and clearing "
"it so sync can continue.",
style: Theme.of(context).textTheme.bodySmall,
),
Slider(
value: appState.staleLockMinutes.toDouble(),
min: minStaleLockMinutes.toDouble(),
max: maxStaleLockMinutes.toDouble(),
divisions: maxStaleLockMinutes - minStaleLockMinutes,
label: '${appState.staleLockMinutes} min',
onChanged: (value) =>
context.read<AppState>().setStaleLockMinutes(value.round()),
),
Text('${appState.staleLockMinutes} minute(s)'),
],
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -167,3 +260,93 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
}
class _WebDavCredentials {
final String serverUrl;
final String username;
final String password;
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
}
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
/// needs, since (unlike the OAuth providers) there's no browser flow to
/// gather these instead.
class _WebDavCredentialsDialog extends StatefulWidget {
final String providerName;
const _WebDavCredentialsDialog({required this.providerName});
@override
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
}
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(_WebDavCredentials(
serverUrl: _serverController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text,
));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Connect ${widget.providerName}'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _serverController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
),
keyboardType: TextInputType.url,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Connect'),
),
],
);
}
}

View file

@ -9,24 +9,42 @@ import '../models/fuel_entry.dart';
import '../services/app_state.dart';
import '../services/ocr_service.dart';
import '../services/receipt_parser.dart';
import '../widgets/image_source_sheet.dart';
import 'add_edit_vehicle_screen.dart';
import 'confirm_fuel_entry_screen.dart';
import 'receipt_image_screen.dart';
/// Long edge and JPEG quality a receipt photo is downscaled to unless
/// "keep max quality" is on — a receipt is a photo of small printed text,
/// not something that benefits from a multi-megapixel original, and this
/// keeps typical files in the low hundreds of KB instead of several MB.
const receiptImageMaxDimension = 1600.0;
const receiptImageQuality = 70;
class VehicleDetailScreen extends StatelessWidget {
final String vehicleId;
const VehicleDetailScreen({super.key, required this.vehicleId});
Future<void> _captureReceipt(BuildContext context) async {
final source = await chooseImageSource(context);
if (source == null || !context.mounted) return;
final keepMaxQuality = context.read<AppState>().keepMaxQualityReceiptPhotos;
final picker = ImagePicker();
XFile? photo;
try {
photo = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
photo = await picker.pickImage(
source: source,
imageQuality: keepMaxQuality ? null : receiptImageQuality,
maxWidth: keepMaxQuality ? null : receiptImageMaxDimension,
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
);
} catch (e) {
if (context.mounted) {
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open camera: $e')),
SnackBar(content: Text('Could not open $sourceLabel: $e')),
);
}
return;
@ -54,8 +72,16 @@ class VehicleDetailScreen extends StatelessWidget {
final parsed = ReceiptParser.parse(recognizedText);
if (!context.mounted) return;
Navigator.of(context).pop(); // close the OCR loading spinner
final state = parsed.state;
if (state != null && state != 'MO') {
final proceed = await _confirmNonMissouriPurchase(context, state);
if (!proceed || !context.mounted) return;
}
if (context.mounted) {
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ConfirmFuelEntryScreen(
@ -68,6 +94,30 @@ class VehicleDetailScreen extends StatelessWidget {
}
}
/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
/// warn if the receipt's address is somewhere else, and let the user
/// decide whether to log it anyway (e.g. it might still be worth
/// tracking for other reasons even if it won't qualify for a refund).
Future<bool> _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
final stateName = usStateNames[stateCode] ?? stateCode;
final proceed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Non-Missouri Purchase'),
content: Text(
'This receipt looks like it\'s from a gas station in $stateName. '
'The Missouri fuel tax refund only applies to fuel purchased in '
'Missouri.\n\nDo you still want to add this entry?',
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')),
],
),
);
return proceed ?? false;
}
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
@ -136,7 +186,7 @@ class VehicleDetailScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: Text(vehicle.displayName),
title: Text(vehicle.displayLabel),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),