Added Google Drive storage location

This commit is contained in:
Courtney Arnold 2026-08-08 14:59:18 -05:00
parent e630f2a3ec
commit 73a7c93e64
22 changed files with 1690 additions and 205 deletions

View file

@ -6,6 +6,8 @@ class FuelEntry {
final double pricePerGallon;
final double totalCost;
final String? receiptImagePath;
final String? receiptDriveFileId;
final DateTime updatedAt;
FuelEntry({
required this.id,
@ -14,9 +16,40 @@ class FuelEntry {
required this.gallons,
required this.pricePerGallon,
required this.totalCost,
required this.updatedAt,
this.receiptImagePath,
this.receiptDriveFileId,
});
/// True once the receipt photo has been uploaded to Drive and the local
/// copy removed. Viewing it then requires downloading it on demand.
bool get isReceiptUploadedToDrive =>
receiptImagePath == null && receiptDriveFileId != null;
/// True while a receipt photo exists only locally and still needs to be
/// uploaded next sync.
bool get hasPendingLocalReceipt => receiptImagePath != null;
FuelEntry copyWith({
String? receiptImagePath,
String? receiptDriveFileId,
DateTime? updatedAt,
bool clearReceiptImagePath = false,
}) {
return FuelEntry(
id: id,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
updatedAt: updatedAt ?? this.updatedAt,
receiptImagePath:
clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath),
receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'vehicleId': vehicleId,
@ -25,6 +58,8 @@ class FuelEntry {
'pricePerGallon': pricePerGallon,
'totalCost': totalCost,
'receiptImagePath': receiptImagePath,
'receiptDriveFileId': receiptDriveFileId,
'updatedAt': updatedAt.toIso8601String(),
};
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry(
@ -35,5 +70,9 @@ class FuelEntry {
pricePerGallon: (json['pricePerGallon'] as num).toDouble(),
totalCost: (json['totalCost'] as num).toDouble(),
receiptImagePath: json['receiptImagePath'] as String?,
receiptDriveFileId: json['receiptDriveFileId'] as String?,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: DateTime.fromMillisecondsSinceEpoch(0),
);
}

View file

@ -4,6 +4,7 @@ class Vehicle {
final String model;
final String color;
final String licensePlate;
final DateTime updatedAt;
Vehicle({
required this.id,
@ -11,6 +12,7 @@ class Vehicle {
required this.model,
required this.color,
required this.licensePlate,
required this.updatedAt,
});
String get displayName => '$color $make $model ($licensePlate)';
@ -20,6 +22,7 @@ class Vehicle {
String? model,
String? color,
String? licensePlate,
DateTime? updatedAt,
}) {
return Vehicle(
id: id,
@ -27,6 +30,7 @@ class Vehicle {
model: model ?? this.model,
color: color ?? this.color,
licensePlate: licensePlate ?? this.licensePlate,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@ -36,6 +40,7 @@ class Vehicle {
'model': model,
'color': color,
'licensePlate': licensePlate,
'updatedAt': updatedAt.toIso8601String(),
};
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
@ -44,5 +49,8 @@ class Vehicle {
model: json['model'] as String,
color: json['color'] as String,
licensePlate: json['licensePlate'] as String,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: DateTime.fromMillisecondsSinceEpoch(0),
);
}

View file

@ -136,7 +136,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path),
builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
),
),
child: ClipRRect(

View file

@ -0,0 +1,219 @@
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';
enum _BrowseRoot { myDrive, 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});
@override
State<DriveFolderBrowserScreen> createState() => _DriveFolderBrowserScreenState();
}
class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
late final http.Client _client;
late final DriveService _drive;
_BrowseRoot _root = _BrowseRoot.myDrive;
final List<DriveFolder> _pathStack = [];
List<DriveFolder>? _folders;
bool _loading = true;
String? _error;
bool _confirming = false;
@override
void initState() {
super.initState();
_client = context.read<AppState>().driveAuth.authenticatedHttpClient();
_drive = DriveService(_client);
_load();
}
@override
void dispose() {
_client.close();
super.dispose();
}
String get _rootLabel => _root == _BrowseRoot.myDrive ? 'My Drive' : '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';
return null;
}
String get _breadcrumbPath =>
([_rootLabel] + _pathStack.map((f) => f.name).toList()).join(' / ');
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
List<DriveFolder> folders;
if (_pathStack.isNotEmpty) {
folders = await _drive.listFolders(parentId: _pathStack.last.id);
} else if (_root == _BrowseRoot.myDrive) {
folders = await _drive.listFolders(parentId: 'root');
} else {
folders = await _drive.listFolders(sharedWithMe: true);
}
if (!mounted) return;
setState(() {
_folders = folders;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Could not load folders: $e';
_loading = false;
});
}
}
void _switchRoot(_BrowseRoot root) {
if (root == _root) return;
setState(() {
_root = root;
_pathStack.clear();
});
_load();
}
void _openFolder(DriveFolder folder) {
setState(() => _pathStack.add(folder));
_load();
}
void _goToBreadcrumb(int index) {
// index == -1 means the root label itself.
setState(() {
if (index < 0) {
_pathStack.clear();
} else {
_pathStack.removeRange(index + 1, _pathStack.length);
}
});
_load();
}
Future<void> _useThisFolder() async {
final parentId = _currentFolderId;
if (parentId == null) return;
setState(() => _confirming = true);
try {
await context.read<AppState>().chooseDriveFolder(
parentId: parentId,
breadcrumbPath: _breadcrumbPath,
);
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not use this folder: $e')),
);
}
} finally {
if (mounted) setState(() => _confirming = false);
}
}
@override
Widget build(BuildContext context) {
final canUseCurrentFolder = _currentFolderId != null;
return Scaffold(
appBar: AppBar(title: const Text('Choose Drive 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),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
TextButton(
onPressed: () => _goToBreadcrumb(-1),
child: Text(_rootLabel),
),
for (var i = 0; i < _pathStack.length; i++) ...[
const Icon(Icons.chevron_right, size: 18),
TextButton(
onPressed: () => _goToBreadcrumb(i),
child: Text(_pathStack[i].name),
),
],
],
),
),
),
const Divider(height: 1),
Expanded(child: _buildBody()),
],
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: (!canUseCurrentFolder || _confirming) ? null : _useThisFolder,
icon: _confirming
? const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.check),
label: Text('Use "$_breadcrumbPath"'),
),
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_error!)));
}
final folders = _folders ?? [];
if (folders.isEmpty) {
return const Center(child: Text('No folders here.'));
}
return ListView.builder(
itemCount: folders.length,
itemBuilder: (context, index) {
final folder = folders[index];
return ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(folder.name),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openFolder(folder),
);
},
);
}
}

View file

@ -1,12 +1,67 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Full-screen, pinch-zoomable view of a saved receipt photo.
class ReceiptImageScreen extends StatelessWidget {
final String imagePath;
import '../services/app_state.dart';
import '../services/drive_service.dart';
const ReceiptImageScreen({super.key, required this.imagePath});
/// 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).
class ReceiptImageScreen extends StatefulWidget {
final String? localImagePath;
final String? driveFileId;
const ReceiptImageScreen({super.key, this.localImagePath, this.driveFileId})
: assert(localImagePath != null || driveFileId != null,
'Must provide either a local path or a Drive file ID');
@override
State<ReceiptImageScreen> createState() => _ReceiptImageScreenState();
}
class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
Uint8List? _downloadedBytes;
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
if (widget.driveFileId != null) {
_download();
}
}
Future<void> _download() async {
setState(() {
_loading = true;
_error = null;
});
final authService = context.read<AppState>().driveAuth;
final client = authService.authenticatedHttpClient();
try {
final drive = DriveService(client);
final bytes = await drive.downloadFileBytes(widget.driveFileId!);
if (!mounted) return;
setState(() {
_downloadedBytes = Uint8List.fromList(bytes);
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Could not download receipt: $e';
_loading = false;
});
} finally {
client.close();
}
}
@override
Widget build(BuildContext context) {
@ -17,11 +72,26 @@ class ReceiptImageScreen extends StatelessWidget {
foregroundColor: Colors.white,
title: const Text('Receipt'),
),
body: Center(
child: InteractiveViewer(
child: Image.file(File(imagePath)),
),
),
body: Center(child: _buildBody()),
);
}
Widget _buildBody() {
if (widget.localImagePath != null) {
return InteractiveViewer(child: Image.file(File(widget.localImagePath!)));
}
if (_loading) {
return const CircularProgressIndicator(color: Colors.white);
}
if (_error != null) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text(_error!, style: const TextStyle(color: Colors.white)),
);
}
if (_downloadedBytes != null) {
return InteractiveViewer(child: Image.memory(_downloadedBytes!));
}
return const SizedBox.shrink();
}
}

View file

@ -1,8 +1,9 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import 'drive_folder_browser_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@ -12,31 +13,50 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _changing = false;
bool _busy = false;
String? _error;
Future<void> _chooseFolder() async {
setState(() => _error = null);
final selectedPath = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Choose a folder for receipts and data',
);
if (selectedPath == null || !mounted) return;
setState(() => _changing = true);
Future<void> _connect() async {
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().changeSaveDirectory(selectedPath);
await context.read<AppState>().connectDrive();
} catch (e) {
setState(() => _error = 'Could not switch to that folder: $e');
setState(() => _error = 'Could not sign in: $e');
} finally {
if (mounted) setState(() => _changing = false);
if (mounted) setState(() => _busy = false);
}
}
Future<void> _disconnect() async {
setState(() => _busy = true);
try {
await context.read<AppState>().disconnectDrive();
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _chooseFolder() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DriveFolderBrowserScreen()),
);
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await context.read<AppState>().syncNow();
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final saveDir = appState.storage.saveDirectory.path;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
@ -49,35 +69,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Save Location', style: Theme.of(context).textTheme.titleMedium),
Text('Google Drive', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Receipt photos and the data file are stored here:',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 4),
SelectableText(
saveDir,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
if (!appState.isDriveConnected) ...[
Text(
'Connect Google Drive 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),
if (_error != null) ...[
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'),
),
] else ...[
Text('Signed in as ${appState.driveAccountEmail}'),
const SizedBox(height: 4),
Text(
appState.driveFolderPath == null
? 'No folder selected yet.'
: 'Folder: ${appState.driveFolderPath}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _busy ? null : _chooseFolder,
icon: const Icon(Icons.folder_open),
label: Text(appState.driveFolderPath == null
? 'Choose Folder'
: 'Change Folder'),
),
),
const SizedBox(height: 16),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
OutlinedButton.icon(
onPressed: (_busy || appState.driveFolderPath == null)
? null
: _syncNow,
icon: appState.isSyncing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.sync),
label: const Text('Sync Now'),
),
TextButton.icon(
onPressed: _busy ? null : _disconnect,
icon: const Icon(Icons.logout),
label: const Text('Disconnect'),
),
],
),
const SizedBox(height: 8),
if (appState.lastSyncedAt != null)
Text(
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
style: Theme.of(context).textTheme.bodySmall,
),
if (appState.lastSyncError != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Last sync failed: ${appState.lastSyncError}',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
FilledButton.icon(
onPressed: _changing ? null : _chooseFolder,
icon: _changing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.folder_open),
label: const Text('Choose Folder'),
),
],
),
),

View file

@ -88,6 +88,38 @@ class VehicleDetailScreen extends StatelessWidget {
}
}
Widget _buildReceiptLeading(BuildContext context, FuelEntry entry) {
if (entry.receiptImagePath != null) {
return GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(
File(entry.receiptImagePath!),
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
);
}
if (entry.isReceiptUploadedToDrive) {
return GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId),
),
),
child: const CircleAvatar(child: Icon(Icons.cloud_outlined)),
);
}
return const CircleAvatar(child: Icon(Icons.receipt_long));
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
@ -144,24 +176,7 @@ class VehicleDetailScreen extends StatelessWidget {
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: entry.receiptImagePath != null
? GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(
File(entry.receiptImagePath!),
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
)
: const CircleAvatar(child: Icon(Icons.receipt_long)),
leading: _buildReceiptLeading(context, entry),
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
subtitle: Text(
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',

View file

@ -1,24 +1,46 @@
import 'dart:async';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'drive_auth_service.dart';
import 'drive_sync_service.dart';
import 'storage_service.dart';
const _prefsKeyDriveFolderId = 'drive_app_folder_id';
const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
/// Single source of truth for the app's in-memory data (vehicles + fuel
/// entries), backed by [StorageService] for persistence. Screens read from
/// this via Provider and call its mutating methods, which take care of
/// writing through to disk and notifying listeners.
/// entries), backed by [StorageService] for local persistence and
/// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens
/// read from this via Provider and call its mutating methods, which write
/// through to local disk immediately and kick off a best-effort background
/// sync to Drive.
class AppState extends ChangeNotifier {
final StorageService storage = StorageService();
final DriveAuthService driveAuth = DriveAuthService();
late final DriveSyncService driveSync =
DriveSyncService(authService: driveAuth, storageService: storage);
final _uuid = const Uuid();
List<Vehicle> vehicles = [];
List<FuelEntry> fuelEntries = [];
bool isLoading = true;
bool isDriveConnected = false;
String? driveAccountEmail;
String? driveFolderPath;
bool isSyncing = false;
DateTime? lastSyncedAt;
Object? lastSyncError;
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Future<void> init() async {
await storage.init();
final data = await storage.loadData();
@ -26,6 +48,100 @@ class AppState extends ChangeNotifier {
fuelEntries = data.entries;
isLoading = false;
notifyListeners();
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) {
if (results.any((r) => r != ConnectivityResult.none)) {
unawaited(syncNow());
}
});
unawaited(_restoreDriveConnection());
}
@override
void dispose() {
_connectivitySubscription?.cancel();
super.dispose();
}
Future<void> _restoreDriveConnection() async {
final signedIn = await driveAuth.attemptSilentSignIn();
if (!signedIn) return;
isDriveConnected = true;
driveAccountEmail = driveAuth.currentAccountEmail;
final prefs = await SharedPreferences.getInstance();
final folderId = prefs.getString(_prefsKeyDriveFolderId);
driveFolderPath = prefs.getString(_prefsKeyDriveFolderPath);
if (folderId != null) {
driveSync.configure(folderId);
}
notifyListeners();
if (folderId != null) {
unawaited(syncNow());
}
}
Future<void> connectDrive() async {
final email = await driveAuth.signIn();
isDriveConnected = true;
driveAccountEmail = email;
notifyListeners();
}
Future<void> disconnectDrive() async {
await driveAuth.signOut();
driveSync.clearConfiguration();
isDriveConnected = false;
driveAccountEmail = null;
driveFolderPath = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsKeyDriveFolderId);
await prefs.remove(_prefsKeyDriveFolderPath);
notifyListeners();
}
Future<void> chooseDriveFolder({
required String parentId,
required String breadcrumbPath,
}) async {
final folderId = await driveSync.selectAppFolder(parentId);
driveFolderPath = breadcrumbPath;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyDriveFolderId, folderId);
await prefs.setString(_prefsKeyDriveFolderPath, breadcrumbPath);
notifyListeners();
unawaited(syncNow());
}
Future<void> syncNow() async {
if (isSyncing || !driveSync.isConfigured) return;
isSyncing = true;
notifyListeners();
final result = await driveSync.syncNow(
localVehicles: vehicles,
localFuelEntries: fuelEntries,
);
if (result.ranSync) {
vehicles = result.vehicles!;
fuelEntries = result.fuelEntries!;
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
lastSyncedAt = DateTime.now();
lastSyncError = null;
} else if (result.error != null) {
lastSyncError = result.error;
}
isSyncing = false;
notifyListeners();
}
Future<void> addVehicle({
@ -40,6 +156,7 @@ class AppState extends ChangeNotifier {
model: model,
color: color,
licensePlate: licensePlate,
updatedAt: DateTime.now().toUtc(),
));
await _persist();
}
@ -47,7 +164,7 @@ class AppState extends ChangeNotifier {
Future<void> updateVehicle(Vehicle updated) async {
final index = vehicles.indexWhere((v) => v.id == updated.id);
if (index != -1) {
vehicles[index] = updated;
vehicles[index] = updated.copyWith(updatedAt: DateTime.now().toUtc());
await _persist();
}
}
@ -83,6 +200,7 @@ class AppState extends ChangeNotifier {
pricePerGallon: pricePerGallon,
totalCost: totalCost,
receiptImagePath: storedImagePath,
updatedAt: DateTime.now().toUtc(),
);
fuelEntries.add(entry);
await _persist();
@ -117,13 +235,9 @@ class AppState extends ChangeNotifier {
}
}
Future<void> changeSaveDirectory(String newPath) async {
await storage.setSaveDirectory(newPath);
notifyListeners();
}
Future<void> _persist() async {
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
notifyListeners();
unawaited(syncNow());
}
}

View file

@ -0,0 +1,108 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:google_sign_in/google_sign_in.dart';
import 'package:http/http.dart' as http;
import 'drive_oauth_config.dart';
/// Full Drive access is required (not the narrower `drive.file` scope)
/// because users need to browse to and reuse folders that someone else
/// created and shared with them, not just folders/files this app itself
/// created. See the plan doc for the tradeoffs (this requires Google
/// Cloud Console "Testing" mode with explicit test users, to avoid needing
/// a full OAuth verification review).
const driveScopes = <String>['https://www.googleapis.com/auth/drive'];
/// Thrown when a Drive API call needs authorization that isn't currently
/// available without prompting the user, e.g. during a background sync.
class DriveNotAuthorizedException implements Exception {
@override
String toString() => 'Drive access is not currently authorized.';
}
/// Wraps `google_sign_in` for authenticating with Google and producing an
/// authenticated [http.Client] for the Drive API.
class DriveAuthService {
bool _initialized = false;
GoogleSignInAccount? _account;
bool get isSignedIn => _account != null;
String? get currentAccountEmail => _account?.email;
Future<void> _ensureInitialized() async {
if (_initialized) return;
await GoogleSignIn.instance.initialize(
clientId: Platform.isIOS ? DriveOAuthConfig.iosClientId : null,
serverClientId: Platform.isAndroid ? DriveOAuthConfig.androidServerClientId : null,
);
_initialized = true;
}
/// Attempts to restore a previous sign-in without any UI. Returns true if
/// the user is signed in and Drive access is already authorized.
Future<bool> attemptSilentSignIn() async {
await _ensureInitialized();
final account = await GoogleSignIn.instance.attemptLightweightAuthentication();
_account = account;
if (account == null) return false;
final authorization =
await account.authorizationClient.authorizationForScopes(driveScopes);
return authorization != null;
}
/// Interactive sign-in + Drive scope authorization. Must be called from a
/// user-initiated action (e.g. a button press).
Future<String> signIn() async {
await _ensureInitialized();
final account = await GoogleSignIn.instance.authenticate(scopeHint: driveScopes);
_account = account;
await account.authorizationClient.authorizeScopes(driveScopes);
return account.email;
}
Future<void> signOut() async {
await GoogleSignIn.instance.signOut();
_account = null;
}
/// Builds an [http.Client] that attaches a fresh Drive authorization
/// header to every request. Fetches headers per-request (rather than
/// once) so a client that lives across a long sync doesn't use a stale,
/// expired token. Never prompts for UI — suitable for background sync —
/// so throws [DriveNotAuthorizedException] if authorization isn't already
/// in place (the caller should treat that as "Drive is disconnected").
http.Client authenticatedHttpClient() {
final account = _account;
if (account == null) {
throw DriveNotAuthorizedException();
}
return _DriveHttpClient(account.authorizationClient);
}
}
class _DriveHttpClient extends http.BaseClient {
final GoogleSignInAuthorizationClient _authClient;
final http.Client _inner = http.Client();
_DriveHttpClient(this._authClient);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final headers =
await _authClient.authorizationHeaders(driveScopes, promptIfNecessary: false);
if (headers == null) {
throw DriveNotAuthorizedException();
}
request.headers.addAll(headers);
return _inner.send(request);
}
@override
void close() {
_inner.close();
super.close();
}
}

View file

@ -0,0 +1,20 @@
/// Fill these in once the corresponding OAuth clients exist in Google Cloud
/// Console (see README.md "Manual setup required"). Both are needed even
/// though only one app runs on each platform:
///
/// - Android sign-in (Credential Manager-based, as of google_sign_in v7)
/// authenticates using a *Web application* type OAuth client's ID, not the
/// Android client's own ID. The separate Android OAuth client (registered
/// with the package name + debug/release SHA-1) is still required, but
/// only to let Credential Manager verify this specific signed app — its
/// client ID itself is never referenced here.
/// - iOS uses its own iOS-type OAuth client ID directly.
class DriveOAuthConfig {
/// The Web application OAuth client ID. Required for sign-in to work on
/// Android.
static const String? androidServerClientId = null; // TODO: fill in
/// The iOS OAuth client ID. Leave null if GIDClientID is instead set
/// directly in ios/Runner/Info.plist.
static const String? iosClientId = null; // TODO: fill in
}

View file

@ -0,0 +1,229 @@
import 'dart:convert';
import 'dart:io';
import 'package:googleapis/drive/v3.dart' as drive;
import 'package:http/http.dart' as http;
const appFolderName = 'MO-Fuel-Tax-Back';
const receiptsFolderName = 'receipts';
const dataFileName = 'fuel_tax_data.json';
const _folderMimeType = 'application/vnd.google-apps.folder';
class DriveFolder {
final String id;
final String name;
DriveFolder({required this.id, required this.name});
}
class DriveLockFile {
final String id;
final String username;
final DateTime createdAtUtc;
DriveLockFile({required this.id, required this.username, required this.createdAtUtc});
}
/// Raw Google Drive API operations, built on top of an already-authenticated
/// [http.Client] (see [DriveAuthService.authenticatedHttpClient]). Callers
/// own the client's lifecycle (create it, use a [DriveService] instance for
/// the duration of one sync, then close it).
class DriveService {
final drive.DriveApi _api;
DriveService(http.Client authenticatedClient) : _api = drive.DriveApi(authenticatedClient);
/// Lists folders under [parentId], or (if [sharedWithMe] is true) the
/// top-level folders that other users have shared with the signed-in
/// account, regardless of parent.
Future<List<DriveFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
final query = sharedWithMe
? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false"
: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false";
final result = await _api.files.list(
q: query,
orderBy: 'name',
$fields: 'files(id,name)',
spaces: 'drive',
);
return (result.files ?? [])
.where((f) => f.id != null && f.name != null)
.map((f) => DriveFolder(id: f.id!, name: f.name!))
.toList();
}
/// Finds a folder named [appFolderName] under [parentId], or creates one
/// if none exists. Multiple devices pointed at the same shared parent
/// converge on the same folder this way. If duplicates exist (Drive
/// allows same-named folders), the earliest-created one wins.
Future<String> findOrCreateAppFolder(String parentId) {
return _findOrCreateFolder(name: appFolderName, parentId: parentId);
}
Future<String> findOrCreateReceiptsFolder(String appFolderId) {
return _findOrCreateFolder(name: receiptsFolderName, parentId: appFolderId);
}
Future<String> _findOrCreateFolder({required String name, required String parentId}) async {
final existing = await _api.files.list(
q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'",
orderBy: 'createdTime',
$fields: 'files(id,name)',
spaces: 'drive',
);
final firstMatch = (existing.files ?? []).firstOrNullWithId();
if (firstMatch != null) return firstMatch;
final created = await _api.files.create(
drive.File()
..name = name
..mimeType = _folderMimeType
..parents = [parentId],
);
return created.id!;
}
Future<String?> findDataFileId(String appFolderId) async {
final result = await _api.files.list(
q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'",
orderBy: 'modifiedTime desc',
$fields: 'files(id,name)',
spaces: 'drive',
);
return (result.files ?? []).firstOrNullWithId();
}
Future<String> downloadTextFile(String fileId) async {
final media = await _api.files.get(
fileId,
downloadOptions: drive.DownloadOptions.fullMedia,
) as drive.Media;
final bytes = await _collectBytes(media.stream);
return utf8.decode(bytes);
}
Future<List<int>> downloadFileBytes(String fileId) async {
final media = await _api.files.get(
fileId,
downloadOptions: drive.DownloadOptions.fullMedia,
) as drive.Media;
return _collectBytes(media.stream);
}
/// Creates the data file if [existingFileId] is null, otherwise
/// overwrites its content. Returns the (possibly new) file ID.
Future<String> uploadDataFile({
required String appFolderId,
required String? existingFileId,
required String jsonContent,
}) async {
final bytes = utf8.encode(jsonContent);
final media = drive.Media(
Stream.value(bytes),
bytes.length,
contentType: 'application/json',
);
if (existingFileId != null) {
final updated = await _api.files.update(
drive.File(),
existingFileId,
uploadMedia: media,
);
return updated.id ?? existingFileId;
}
final created = await _api.files.create(
drive.File()
..name = dataFileName
..parents = [appFolderId],
uploadMedia: media,
);
return created.id!;
}
Future<String> uploadReceiptImage({
required String receiptsFolderId,
required String fileName,
required File imageFile,
}) async {
final length = await imageFile.length();
final media = drive.Media(
imageFile.openRead(),
length,
contentType: 'image/jpeg',
);
final created = await _api.files.create(
drive.File()
..name = fileName
..parents = [receiptsFolderId],
uploadMedia: media,
);
return created.id!;
}
Future<void> deleteFile(String fileId) async {
try {
await _api.files.delete(fileId);
} on drive.DetailedApiRequestError catch (e) {
// Already gone (e.g. deleted by another device) — not an error for
// our purposes.
if (e.status != 404) rethrow;
}
}
Future<String> createLockFile({required String appFolderId, required String name}) async {
final created = await _api.files.create(
drive.File()
..name = name
..parents = [appFolderId],
uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'),
);
return created.id!;
}
Future<List<DriveLockFile>> listLockFiles(String appFolderId) async {
final result = await _api.files.list(
q: "'$appFolderId' in parents and trashed=false and name contains '.lock'",
$fields: 'files(id,name)',
spaces: 'drive',
);
final locks = <DriveLockFile>[];
for (final f in result.files ?? <drive.File>[]) {
final parsed = _parseLockFileName(f.name);
if (f.id != null && parsed != null) {
locks.add(DriveLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2));
}
}
return locks;
}
static (String, DateTime)? _parseLockFileName(String? name) {
if (name == null || !name.endsWith('.lock')) return null;
final withoutExt = name.substring(0, name.length - '.lock'.length);
final lastDash = withoutExt.lastIndexOf('-');
if (lastDash == -1) return null;
final username = withoutExt.substring(0, lastDash);
final epochStr = withoutExt.substring(lastDash + 1);
final epoch = int.tryParse(epochStr);
if (epoch == null) return null;
return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true));
}
Future<List<int>> _collectBytes(Stream<List<int>> stream) async {
final bytes = <int>[];
await for (final chunk in stream) {
bytes.addAll(chunk);
}
return bytes;
}
}
extension _FirstMatchExtension on List<drive.File> {
String? firstOrNullWithId() => isEmpty ? null : first.id;
}

View file

@ -0,0 +1,204 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'drive_auth_service.dart';
import 'drive_service.dart';
import 'lock_coordinator.dart' as lock;
import 'merge_utils.dart';
import 'storage_service.dart';
class SyncResult {
final bool ranSync;
final List<Vehicle>? vehicles;
final List<FuelEntry>? fuelEntries;
final Object? error;
SyncResult.skipped()
: ranSync = false,
vehicles = null,
fuelEntries = null,
error = null;
SyncResult.success({required this.vehicles, required this.fuelEntries})
: ranSync = true,
error = null;
SyncResult.failure(this.error)
: ranSync = false,
vehicles = null,
fuelEntries = null;
}
/// Orchestrates one round of push-local/pull-remote sync against the shared
/// Drive folder: acquires the cross-device file lock, downloads and merges
/// the remote data file with local changes, uploads any pending receipt
/// photos, writes the merged data file back, then releases the lock.
class DriveSyncService {
final DriveAuthService authService;
final StorageService storageService;
String? _appFolderId;
String? _receiptsFolderId;
String? _dataFileId;
DriveSyncService({required this.authService, required this.storageService});
bool get isConfigured => _appFolderId != null;
/// Call once a Drive app folder has been chosen (or restored at launch).
void configure(String appFolderId) {
_appFolderId = appFolderId;
_receiptsFolderId = null;
_dataFileId = null;
}
void clearConfiguration() {
_appFolderId = null;
_receiptsFolderId = null;
_dataFileId = null;
}
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
/// folder the user picked in the Drive folder browser) and configures
/// this service to use it. Returns the resulting folder ID.
Future<String> selectAppFolder(String parentId) async {
final client = authService.authenticatedHttpClient();
try {
final drive = DriveService(client);
final folderId = await drive.findOrCreateAppFolder(parentId);
configure(folderId);
return folderId;
} finally {
client.close();
}
}
Future<SyncResult> syncNow({
required List<Vehicle> localVehicles,
required List<FuelEntry> localFuelEntries,
}) async {
final appFolderId = _appFolderId;
if (!authService.isSignedIn || appFolderId == null) {
return SyncResult.skipped();
}
http.Client? client;
DriveService? drive;
String? lockFileId;
try {
client = authService.authenticatedHttpClient();
drive = DriveService(client);
lockFileId = await _acquireLock(
drive,
appFolderId: appFolderId,
username: authService.currentAccountEmail!,
);
_receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId);
_dataFileId ??= await drive.findDataFileId(appFolderId);
var remoteVehicles = <Vehicle>[];
var remoteFuelEntries = <FuelEntry>[];
if (_dataFileId != null) {
final text = await drive.downloadTextFile(_dataFileId!);
if (text.trim().isNotEmpty) {
final decoded = storageService.decodeData(text);
remoteVehicles = decoded.vehicles;
remoteFuelEntries = decoded.entries;
}
}
final mergedVehicles = mergeById(
remoteVehicles,
localVehicles,
(v) => v.id,
(v) => v.updatedAt,
);
final mergedFuelEntries = await _uploadPendingReceiptsAndMerge(
drive: drive,
remoteFuelEntries: remoteFuelEntries,
localFuelEntries: localFuelEntries,
);
final jsonContent = storageService.encodeData(
vehicles: mergedVehicles,
entries: mergedFuelEntries,
);
_dataFileId = await drive.uploadDataFile(
appFolderId: appFolderId,
existingFileId: _dataFileId,
jsonContent: jsonContent,
);
return SyncResult.success(vehicles: mergedVehicles, fuelEntries: mergedFuelEntries);
} catch (e) {
return SyncResult.failure(e);
} finally {
if (lockFileId != null && drive != null) {
try {
await drive.deleteFile(lockFileId);
} catch (_) {
// Best-effort: if this fails, the 10-minute staleness reap on
// other devices' next sync attempt will clean it up.
}
}
client?.close();
}
}
Future<List<FuelEntry>> _uploadPendingReceiptsAndMerge({
required DriveService drive,
required List<FuelEntry> remoteFuelEntries,
required List<FuelEntry> localFuelEntries,
}) async {
final merged = mergeById(
remoteFuelEntries,
localFuelEntries,
(e) => e.id,
(e) => e.updatedAt,
);
final result = <FuelEntry>[];
for (final entry in merged) {
if (!entry.hasPendingLocalReceipt) {
result.add(entry);
continue;
}
final localPath = entry.receiptImagePath!;
final localFile = File(localPath);
if (!await localFile.exists()) {
result.add(entry);
continue;
}
final driveFileId = await drive.uploadReceiptImage(
receiptsFolderId: _receiptsFolderId!,
fileName: '${entry.id}${p.extension(localPath)}',
imageFile: localFile,
);
await storageService.deleteReceiptImage(localPath);
result.add(entry.copyWith(receiptDriveFileId: driveFileId, clearReceiptImagePath: true));
}
return result;
}
Future<String> _acquireLock(
DriveService drive, {
required String appFolderId,
required String username,
}) {
return lock.acquireLock(
username: username,
createLock: (name) => drive.createLockFile(appFolderId: appFolderId, name: name),
listLocks: () => drive.listLockFiles(appFolderId),
deleteLock: drive.deleteFile,
);
}
}

View file

@ -0,0 +1,48 @@
import 'drive_service.dart' show DriveLockFile;
/// Ticket-based mutex using timestamped lock files as the coordination
/// point: create a lock named after our own timestamp, then wait until no
/// *other* lock older than ours remains — reaping ("deleting") any it
/// finds older than [staleAge] along the way, as a backstop against a
/// device that crashed/went offline before releasing its own lock.
///
/// Pure orchestration over injected operations (rather than a concrete
/// Drive dependency) so the state machine is unit-testable without a real
/// network connection.
Future<String> acquireLock({
required String username,
required Future<String> Function(String lockName) createLock,
required Future<List<DriveLockFile>> Function() listLocks,
required Future<void> Function(String lockId) deleteLock,
DateTime Function()? nowUtc,
Future<void> Function(Duration)? delay,
Duration staleAge = const Duration(minutes: 10),
Duration pollInterval = const Duration(seconds: 1),
}) async {
final now = nowUtc ?? () => DateTime.now().toUtc();
final wait = delay ?? Future.delayed;
final epochMillis = now().millisecondsSinceEpoch;
final lockName = '$username-$epochMillis.lock';
final lockId = await createLock(lockName);
while (true) {
final locks = await listLocks();
final others = locks.where((l) => l.id != lockId);
final olderOthers =
others.where((l) => l.createdAtUtc.millisecondsSinceEpoch < epochMillis).toList();
if (olderOthers.isEmpty) break;
final current = now();
for (final stale in olderOthers) {
if (current.difference(stale.createdAtUtc) > staleAge) {
await deleteLock(stale.id);
}
}
await wait(pollInterval);
}
return lockId;
}

View file

@ -0,0 +1,23 @@
/// Unions [remote] and [local] by ID, keeping whichever copy of each
/// record has the newer `updatedAt` when a record exists on both sides.
/// This lets two devices' independent new records both survive a sync;
/// it does not propagate deletions (see plan doc's Known limitations).
List<T> mergeById<T>(
List<T> remote,
List<T> local,
String Function(T) idOf,
DateTime Function(T) updatedAtOf,
) {
final merged = <String, T>{};
for (final item in remote) {
merged[idOf(item)] = item;
}
for (final item in local) {
final id = idOf(item);
final existing = merged[id];
if (existing == null || !updatedAtOf(existing).isAfter(updatedAtOf(item))) {
merged[id] = item;
}
}
return merged.values.toList();
}

View file

@ -3,17 +3,17 @@ import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
/// Owns the on-disk layout for the app: a `data.json` file holding vehicles
/// and fuel entries, plus a `receipts/` subfolder holding receipt photos.
/// The parent directory containing both is user-configurable (see
/// [setSaveDirectory]) and persisted across launches via SharedPreferences.
/// Owns the local, offline staging area: a `data.json` file holding
/// vehicles and fuel entries, plus a `receipts/` subfolder holding receipt
/// photos not yet uploaded to Drive. This is always
/// `ApplicationDocumentsDirectory/FuelTaxTracker` — not user-configurable —
/// since Drive (via [DriveSyncService]) is now the real shared destination
/// and this is just a local cache/holding area used while offline.
class StorageService {
static const _prefsKey = 'save_directory_path';
static const _dataFileName = 'fuel_tax_data.json';
static const _receiptsFolderName = 'receipts';
@ -24,36 +24,31 @@ class StorageService {
Directory get receiptsDirectory =>
Directory(p.join(_saveDirectory.path, _receiptsFolderName));
File get _dataFile => File(p.join(_saveDirectory.path, _dataFileName));
File get dataFile => File(p.join(_saveDirectory.path, _dataFileName));
/// Must be called once before any other method. Loads the previously
/// chosen save directory, falling back to the app's own documents
/// directory the first time the app runs.
/// Must be called once before any other method.
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final storedPath = prefs.getString(_prefsKey);
if (storedPath != null && await Directory(storedPath).exists()) {
_saveDirectory = Directory(storedPath);
} else {
final docsDir = await getApplicationDocumentsDirectory();
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
}
final docsDir = await getApplicationDocumentsDirectory();
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
await _saveDirectory.create(recursive: true);
await receiptsDirectory.create(recursive: true);
}
Future<({List<Vehicle> vehicles, List<FuelEntry> entries})> loadData() async {
if (!await _dataFile.exists()) {
if (!await dataFile.exists()) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
final raw = await _dataFile.readAsString();
final raw = await dataFile.readAsString();
if (raw.trim().isEmpty) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
return decodeData(raw);
}
({List<Vehicle> vehicles, List<FuelEntry> entries}) decodeData(String raw) {
final json = jsonDecode(raw) as Map<String, dynamic>;
final vehicles = (json['vehicles'] as List<dynamic>? ?? [])
.map((v) => Vehicle.fromJson(v as Map<String, dynamic>))
@ -65,19 +60,26 @@ class StorageService {
return (vehicles: vehicles, entries: entries);
}
Future<void> saveData({
String encodeData({
required List<Vehicle> vehicles,
required List<FuelEntry> entries,
}) async {
}) {
final json = {
'vehicles': vehicles.map((v) => v.toJson()).toList(),
'fuelEntries': entries.map((e) => e.toJson()).toList(),
};
await _dataFile.writeAsString(const JsonEncoder.withIndent(' ').convert(json));
return const JsonEncoder.withIndent(' ').convert(json);
}
/// Copies a captured receipt image into the receipts folder and returns
/// the path it was stored at.
Future<void> saveData({
required List<Vehicle> vehicles,
required List<FuelEntry> entries,
}) async {
await dataFile.writeAsString(encodeData(vehicles: vehicles, entries: entries));
}
/// Copies a captured receipt image into the local receipts folder and
/// returns the path it was stored at, pending upload to Drive.
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
final ext = p.extension(sourceImage.path);
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
@ -92,37 +94,4 @@ class StorageService {
await file.delete();
}
}
/// Changes where the data file and receipt images live, moving any
/// existing data/images from the old location into the new one.
Future<void> setSaveDirectory(String newPath) async {
final newDir = Directory(newPath);
await newDir.create(recursive: true);
final newReceiptsDir = Directory(p.join(newPath, _receiptsFolderName));
await newReceiptsDir.create(recursive: true);
final oldDataFile = _dataFile;
final oldReceiptsDir = receiptsDirectory;
if (await oldDataFile.exists() &&
p.equals(oldDataFile.path, p.join(newPath, _dataFileName)) == false) {
await oldDataFile.copy(p.join(newPath, _dataFileName));
}
if (await oldReceiptsDir.exists()) {
await for (final entity in oldReceiptsDir.list()) {
if (entity is File) {
final destPath = p.join(newReceiptsDir.path, p.basename(entity.path));
if (!p.equals(entity.path, destPath)) {
await entity.copy(destPath);
}
}
}
}
_saveDirectory = newDir;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, newPath);
}
}