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

151
README.md
View file

@ -3,37 +3,92 @@
A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a
photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device, photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device,
you confirm or correct the numbers, and it's saved alongside the receipt you confirm or correct the numbers, and it's saved alongside the receipt
photo to a data file in a folder you choose. photo. Vehicles and fuel entries live in a shared Google Drive folder, so
multiple people can log fuel against the same pool of vehicles from their
own phones; each device also keeps a local offline copy and syncs when it
can.
## Features ## Features
- Manage a list of vehicles (make, model, color, license plate). - Manage a list of vehicles (make, model, color, license plate).
- Capture a fuel receipt photo per vehicle and OCR it on-device with Google - Capture a fuel receipt photo per vehicle and OCR it on-device with Google
ML Kit — no internet connection or API key required. ML Kit — no internet connection or API key required for the OCR itself.
- Review/edit the parsed gallons, price per gallon, and total cost before - Review/edit the parsed gallons, price per gallon, and total cost before
saving (OCR on printed receipts is usually good but not perfect). saving (OCR on printed receipts is usually good but not perfect).
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt - Per-vehicle fuel log with running gallons total, tap-to-zoom receipt
photos, and delete. photos, and delete.
- Settings screen to choose where receipt photos and the data file are - Connect Google Drive and pick any folder you have access to — including
stored (defaults to the app's own documents folder; existing data is one someone else created and shared with you — as the shared storage
copied over when you change it). location. The app looks for (or creates) a `MO-Fuel-Tax-Back` subfolder
there, so anyone pointed at the same shared parent converges on the same
data automatically.
- Works fully offline: writes always land locally first; a background sync
pushes changes to Drive and pulls others' changes down once online.
- Once a receipt photo is uploaded to Drive, the local copy is deleted;
viewing it later downloads it fresh from Drive on demand.
## Project layout ## Project layout
``` ```
lib/ lib/
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization.
Both carry `updatedAt` (for merge conflict resolution); FuelEntry
carries either a local receiptImagePath (not yet uploaded) or a
receiptDriveFileId (uploaded, local copy removed).
services/ services/
app_state.dart In-memory state + CRUD, backed by StorageService, exposed via Provider app_state.dart In-memory state + CRUD, exposed via Provider. Stamps
storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path updatedAt on mutations and triggers background sync.
ocr_service.dart Thin wrapper around google_mlkit_text_recognition storage_service.dart Local offline staging area: data.json + receipts/,
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text always at ApplicationDocumentsDirectory/FuelTaxTracker.
drive_auth_service.dart Google sign-in + builds an authenticated http.Client
for the Drive API.
drive_service.dart Raw Drive API calls: browse folders, find-or-create
the MO-Fuel-Tax-Back folder, upload/download files,
lock file operations.
drive_sync_service.dart Orchestrates one sync round: acquire the cross-device
lock, download + merge the remote data file, upload
pending receipt photos, write the merge back, release
the lock.
merge_utils.dart Pure by-ID union merge logic (independently unit-tested).
drive_oauth_config.dart Fill in your OAuth client IDs here — see setup below.
ocr_service.dart Thin wrapper around google_mlkit_text_recognition.
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text.
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail, screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
confirm fuel entry, receipt viewer, settings) confirm fuel entry, receipt viewer, settings, Drive folder browser).
``` ```
Data is stored as a single `fuel_tax_data.json` file plus a `receipts/` ## Manual setup required (Google Cloud Console)
subfolder of photos, both inside whatever directory Settings points at.
This app needs OAuth credentials you create yourself — I can't provision
cloud resources on your behalf. In [Google Cloud Console](https://console.cloud.google.com/):
1. Create/select a project, enable the **Google Drive API** (APIs & Services
→ Library).
2. **OAuth consent screen**: set it to **External**, keep it in **Testing**
status, and add your Google account plus everyone else's you're sharing
with as **test users**. This avoids Google's formal verification review,
which the broad `drive` scope would otherwise require — fine for a known,
small group, not a public release.
3. Add scope `https://www.googleapis.com/auth/drive` to the consent screen
(shows as "restricted/sensitive" — expected, fine in Testing mode).
4. **Credentials → Create Credentials → OAuth client ID**, three times:
- **Android**: package name `com.courtneyarnold.fuel_tax_tracker` + the
SHA-1 of your debug keystore (`keytool -list -v -keystore
~/.android/debug.keystore`, password `android`), and later your release
keystore's SHA-1 too. This client ID itself is never referenced in
code — it exists purely so Android's Credential Manager trusts this
specific signed app.
- **Web application**: no redirect URIs needed. Copy its **Client ID** —
this is what Android sign-in actually authenticates against (a
Credential Manager quirk: it needs a *web* client ID, passed as
`serverClientId`, even for a mobile app).
- **iOS**: bundle ID matching the Xcode project. Copy its **Client ID**
and note the reversed form (`com.googleusercontent.apps.<...>`).
5. Fill in `lib/services/drive_oauth_config.dart`:
- `androidServerClientId` ← the **Web application** client's ID.
- `iosClientId` ← the **iOS** client's ID.
6. Replace the placeholder in `ios/Runner/Info.plist`'s `CFBundleURLTypes` →
`CFBundleURLSchemes` with your iOS client's reversed ID.
## Running it ## Running it
@ -51,43 +106,61 @@ flutter build ios --release # iOS (requires a full Xcode install + signing
This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and
`flutter test` are clean, and `flutter build apk --debug` has been confirmed `flutter test` are clean, and `flutter build apk --debug` has been confirmed
to produce a working APK. to produce a working APK. The Drive sign-in/sync path needs the manual
OAuth setup above before it can be exercised end-to-end.
## Permissions ## Permissions
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and - **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already `android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
set up for receipt capture. set up for receipt capture.
- **Storage**: no explicit storage permission is declared. The default save - **Network/Drive**: no Android manifest changes are needed for
location is inside the app's own sandbox (no permission needed). If you `google_sign_in` when not using `google-services.json` — see the manual
point Settings at a location outside the sandbox, the OS-native folder setup section above instead.
picker (via `file_picker`) is what grants access — see the caveat below.
## How sync works
Every local change (add/edit a vehicle, log a fuel entry) writes to the
local `fuel_tax_data.json` immediately, then triggers a best-effort
background sync — also triggered whenever connectivity comes back. Sync:
1. Creates a lock file `{email}-{utcEpochMillis}.lock` in the shared Drive
folder, then waits until no *other* lock file older than its own remains
(polling every second, deleting any it finds older than 10 minutes as
orphaned/stale) — a simple ticket-based mutex using the Drive folder
itself as the coordination point, so two devices never overwrite each
other's edits to the shared data file mid-write.
2. Downloads the current remote data file and merges it with local changes:
vehicles and fuel entries are unioned by ID, with the newer `updatedAt`
winning when a record exists on both sides.
3. Uploads any locally-pending receipt photos, then deletes the local copy.
4. Writes the merged data back to Drive, then deletes its own lock file.
## Known caveats / things to revisit ## Known caveats / things to revisit
- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions - **Deletions don't propagate through the merge.** If one device deletes a
11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP vehicle/entry before another device has seen that deletion, the deleting
9+ (assuming AGP's built-in Kotlin support handles it), but that isn't change can be resurrected by the other device's next sync. Fixing this
actually wired up for library modules in this Flutter/AGP combination yet, properly needs tombstones (tracking deleted-record IDs for a retention
so the plugin's own Kotlin sources never get compiled and the build fails window) — deferred for now; the by-ID union merge is intentionally simple
with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is and expected to evolve.
retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time - **Field-level conflicts aren't merged.** Two people editing the *same*
you bump dependencies — check the package's CHANGELOG for when this is record at the same time: whole record, newer `updatedAt` wins — not a
properly resolved upstream. field-by-field merge.
- **iOS folder picking and app restarts**: `file_picker`'s directory picker - **Lock acquisition has no hard timeout** beyond the 10-minute staleness
on iOS uses `UIDocumentPickerViewController`, which hands back a reap. Fine at the scale this is built for (a handful of people); could in
security-scoped URL. This app does not currently persist a security-scoped theory spin under many simultaneous contenders.
bookmark for that URL, so if you pick a folder outside the app's own - **`google_sign_in` v7's Android path requires a *Web* OAuth client ID**
sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after (`serverClientId`), not just the Android client's SHA-1 registration —
an app restart is not guaranteed. Picking the default in-sandbox location, see the manual setup section. This is a quirk of the Credential
or a folder on Android, does not have this limitation. If cross-restart Manager-based implementation and easy to miss if you're used to older
external storage on iOS matters for your use case, this needs a proper `google_sign_in` versions.
bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`).
- **Receipt parsing is best-effort regex matching** on the OCR'd text - **Receipt parsing is best-effort regex matching** on the OCR'd text
(`lib/services/receipt_parser.dart`), tuned against common receipt phrasing (`lib/services/receipt_parser.dart`), tuned against common receipt phrasing
("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may ("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may
parse partially or not at all — the confirm screen always lets you fill in parse partially or not at all — the confirm screen always lets you fill in
or correct whatever wasn't found. or correct whatever wasn't found.
- No automated tests exercise the OCR or camera capture path itself (that - No automated tests exercise the OCR, camera, or real Drive API calls
requires a real device/emulator with a camera); `receipt_parser_test.dart` (those need a real device/emulator and live credentials); `receipt_parser_test.dart`,
covers the parsing logic against fixed OCR text. `merge_utils_test.dart`, and `drive_lock_test.dart` cover the pure logic
pieces against fixed inputs.

View file

@ -1,6 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/> <uses-feature android:name="android.hardware.camera" android:required="false"/>
<!-- Required for Drive sign-in/sync. Debug builds get this for free via
android/app/src/debug/AndroidManifest.xml, but release builds need
it declared here explicitly. -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application <application
android:label="fuel_tax_tracker" android:label="fuel_tax_tracker"
android:name="${applicationName}" android:name="${applicationName}"

View file

@ -68,5 +68,22 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<!-- Google Sign-In: required for the OAuth redirect to route back into
the app, regardless of whether the client ID itself is set here or
passed in Dart (see lib/services/drive_oauth_config.dart). Replace
with your iOS OAuth client's REVERSED_CLIENT_ID from Google Cloud
Console (reverse of the client ID, e.g.
com.googleusercontent.apps.XXXXXXXXXXXX-yyyyyyyyyyyyyyyyyyyyyyyyyyyy). -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -6,6 +6,8 @@ class FuelEntry {
final double pricePerGallon; final double pricePerGallon;
final double totalCost; final double totalCost;
final String? receiptImagePath; final String? receiptImagePath;
final String? receiptDriveFileId;
final DateTime updatedAt;
FuelEntry({ FuelEntry({
required this.id, required this.id,
@ -14,9 +16,40 @@ class FuelEntry {
required this.gallons, required this.gallons,
required this.pricePerGallon, required this.pricePerGallon,
required this.totalCost, required this.totalCost,
required this.updatedAt,
this.receiptImagePath, 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() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'vehicleId': vehicleId, 'vehicleId': vehicleId,
@ -25,6 +58,8 @@ class FuelEntry {
'pricePerGallon': pricePerGallon, 'pricePerGallon': pricePerGallon,
'totalCost': totalCost, 'totalCost': totalCost,
'receiptImagePath': receiptImagePath, 'receiptImagePath': receiptImagePath,
'receiptDriveFileId': receiptDriveFileId,
'updatedAt': updatedAt.toIso8601String(),
}; };
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry( factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry(
@ -35,5 +70,9 @@ class FuelEntry {
pricePerGallon: (json['pricePerGallon'] as num).toDouble(), pricePerGallon: (json['pricePerGallon'] as num).toDouble(),
totalCost: (json['totalCost'] as num).toDouble(), totalCost: (json['totalCost'] as num).toDouble(),
receiptImagePath: json['receiptImagePath'] as String?, 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 model;
final String color; final String color;
final String licensePlate; final String licensePlate;
final DateTime updatedAt;
Vehicle({ Vehicle({
required this.id, required this.id,
@ -11,6 +12,7 @@ class Vehicle {
required this.model, required this.model,
required this.color, required this.color,
required this.licensePlate, required this.licensePlate,
required this.updatedAt,
}); });
String get displayName => '$color $make $model ($licensePlate)'; String get displayName => '$color $make $model ($licensePlate)';
@ -20,6 +22,7 @@ class Vehicle {
String? model, String? model,
String? color, String? color,
String? licensePlate, String? licensePlate,
DateTime? updatedAt,
}) { }) {
return Vehicle( return Vehicle(
id: id, id: id,
@ -27,6 +30,7 @@ class Vehicle {
model: model ?? this.model, model: model ?? this.model,
color: color ?? this.color, color: color ?? this.color,
licensePlate: licensePlate ?? this.licensePlate, licensePlate: licensePlate ?? this.licensePlate,
updatedAt: updatedAt ?? this.updatedAt,
); );
} }
@ -36,6 +40,7 @@ class Vehicle {
'model': model, 'model': model,
'color': color, 'color': color,
'licensePlate': licensePlate, 'licensePlate': licensePlate,
'updatedAt': updatedAt.toIso8601String(),
}; };
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle( factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
@ -44,5 +49,8 @@ class Vehicle {
model: json['model'] as String, model: json['model'] as String,
color: json['color'] as String, color: json['color'] as String,
licensePlate: json['licensePlate'] 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( GestureDetector(
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path), builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
), ),
), ),
child: ClipRRect( 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:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Full-screen, pinch-zoomable view of a saved receipt photo. import '../services/app_state.dart';
class ReceiptImageScreen extends StatelessWidget { import '../services/drive_service.dart';
final String imagePath;
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -17,11 +72,26 @@ class ReceiptImageScreen extends StatelessWidget {
foregroundColor: Colors.white, foregroundColor: Colors.white,
title: const Text('Receipt'), title: const Text('Receipt'),
), ),
body: Center( body: Center(child: _buildBody()),
child: InteractiveViewer(
child: Image.file(File(imagePath)),
),
),
); );
} }
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:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/app_state.dart'; import '../services/app_state.dart';
import 'drive_folder_browser_screen.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@ -12,31 +13,50 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
bool _changing = false; bool _busy = false;
String? _error; String? _error;
Future<void> _chooseFolder() async { Future<void> _connect() async {
setState(() => _error = null); setState(() {
_busy = true;
final selectedPath = await FilePicker.platform.getDirectoryPath( _error = null;
dialogTitle: 'Choose a folder for receipts and data', });
);
if (selectedPath == null || !mounted) return;
setState(() => _changing = true);
try { try {
await context.read<AppState>().changeSaveDirectory(selectedPath); await context.read<AppState>().connectDrive();
} catch (e) { } catch (e) {
setState(() => _error = 'Could not switch to that folder: $e'); setState(() => _error = 'Could not sign in: $e');
} finally { } 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appState = context.watch<AppState>(); final appState = context.watch<AppState>();
final saveDir = appState.storage.saveDirectory.path;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Settings')), appBar: AppBar(title: const Text('Settings')),
@ -49,35 +69,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Save Location', style: Theme.of(context).textTheme.titleMedium), Text('Google Drive', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( if (!appState.isDriveConnected) ...[
'Receipt photos and the data file are stored here:', Text(
style: Theme.of(context).textTheme.bodyMedium, 'Connect Google Drive to share vehicles and fuel receipts with '
), 'other people, and to keep a backup off this device.',
const SizedBox(height: 4), style: Theme.of(context).textTheme.bodyMedium,
SelectableText( ),
saveDir, const SizedBox(height: 12),
style: Theme.of(context).textTheme.bodySmall?.copyWith( if (_error != null) ...[
fontFamily: 'monospace', 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'),
), ),
), OutlinedButton.icon(
const SizedBox(height: 16), onPressed: (_busy || appState.driveFolderPath == null)
if (_error != null) ...[ ? null
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), : _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), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appState = context.watch<AppState>(); final appState = context.watch<AppState>();
@ -144,24 +176,7 @@ class VehicleDetailScreen extends StatelessWidget {
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: ListTile( child: ListTile(
leading: entry.receiptImagePath != null leading: _buildReceiptLeading(context, entry),
? 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)),
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'), title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
subtitle: Text( subtitle: Text(
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}', '${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',

View file

@ -1,24 +1,46 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart'; import '../models/fuel_entry.dart';
import '../models/vehicle.dart'; import '../models/vehicle.dart';
import 'drive_auth_service.dart';
import 'drive_sync_service.dart';
import 'storage_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 /// Single source of truth for the app's in-memory data (vehicles + fuel
/// entries), backed by [StorageService] for persistence. Screens read from /// entries), backed by [StorageService] for local persistence and
/// this via Provider and call its mutating methods, which take care of /// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens
/// writing through to disk and notifying listeners. /// 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 { class AppState extends ChangeNotifier {
final StorageService storage = StorageService(); final StorageService storage = StorageService();
final DriveAuthService driveAuth = DriveAuthService();
late final DriveSyncService driveSync =
DriveSyncService(authService: driveAuth, storageService: storage);
final _uuid = const Uuid(); final _uuid = const Uuid();
List<Vehicle> vehicles = []; List<Vehicle> vehicles = [];
List<FuelEntry> fuelEntries = []; List<FuelEntry> fuelEntries = [];
bool isLoading = true; 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 { Future<void> init() async {
await storage.init(); await storage.init();
final data = await storage.loadData(); final data = await storage.loadData();
@ -26,6 +48,100 @@ class AppState extends ChangeNotifier {
fuelEntries = data.entries; fuelEntries = data.entries;
isLoading = false; isLoading = false;
notifyListeners(); 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({ Future<void> addVehicle({
@ -40,6 +156,7 @@ class AppState extends ChangeNotifier {
model: model, model: model,
color: color, color: color,
licensePlate: licensePlate, licensePlate: licensePlate,
updatedAt: DateTime.now().toUtc(),
)); ));
await _persist(); await _persist();
} }
@ -47,7 +164,7 @@ class AppState extends ChangeNotifier {
Future<void> updateVehicle(Vehicle updated) async { Future<void> updateVehicle(Vehicle updated) async {
final index = vehicles.indexWhere((v) => v.id == updated.id); final index = vehicles.indexWhere((v) => v.id == updated.id);
if (index != -1) { if (index != -1) {
vehicles[index] = updated; vehicles[index] = updated.copyWith(updatedAt: DateTime.now().toUtc());
await _persist(); await _persist();
} }
} }
@ -83,6 +200,7 @@ class AppState extends ChangeNotifier {
pricePerGallon: pricePerGallon, pricePerGallon: pricePerGallon,
totalCost: totalCost, totalCost: totalCost,
receiptImagePath: storedImagePath, receiptImagePath: storedImagePath,
updatedAt: DateTime.now().toUtc(),
); );
fuelEntries.add(entry); fuelEntries.add(entry);
await _persist(); 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 { Future<void> _persist() async {
await storage.saveData(vehicles: vehicles, entries: fuelEntries); await storage.saveData(vehicles: vehicles, entries: fuelEntries);
notifyListeners(); 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/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/fuel_entry.dart'; import '../models/fuel_entry.dart';
import '../models/vehicle.dart'; import '../models/vehicle.dart';
/// Owns the on-disk layout for the app: a `data.json` file holding vehicles /// Owns the local, offline staging area: a `data.json` file holding
/// and fuel entries, plus a `receipts/` subfolder holding receipt photos. /// vehicles and fuel entries, plus a `receipts/` subfolder holding receipt
/// The parent directory containing both is user-configurable (see /// photos not yet uploaded to Drive. This is always
/// [setSaveDirectory]) and persisted across launches via SharedPreferences. /// `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 { class StorageService {
static const _prefsKey = 'save_directory_path';
static const _dataFileName = 'fuel_tax_data.json'; static const _dataFileName = 'fuel_tax_data.json';
static const _receiptsFolderName = 'receipts'; static const _receiptsFolderName = 'receipts';
@ -24,36 +24,31 @@ class StorageService {
Directory get receiptsDirectory => Directory get receiptsDirectory =>
Directory(p.join(_saveDirectory.path, _receiptsFolderName)); 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 /// Must be called once before any other method.
/// chosen save directory, falling back to the app's own documents
/// directory the first time the app runs.
Future<void> init() async { Future<void> init() async {
final prefs = await SharedPreferences.getInstance(); final docsDir = await getApplicationDocumentsDirectory();
final storedPath = prefs.getString(_prefsKey); _saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
if (storedPath != null && await Directory(storedPath).exists()) {
_saveDirectory = Directory(storedPath);
} else {
final docsDir = await getApplicationDocumentsDirectory();
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
}
await _saveDirectory.create(recursive: true); await _saveDirectory.create(recursive: true);
await receiptsDirectory.create(recursive: true); await receiptsDirectory.create(recursive: true);
} }
Future<({List<Vehicle> vehicles, List<FuelEntry> entries})> loadData() async { Future<({List<Vehicle> vehicles, List<FuelEntry> entries})> loadData() async {
if (!await _dataFile.exists()) { if (!await dataFile.exists()) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]); return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
} }
final raw = await _dataFile.readAsString(); final raw = await dataFile.readAsString();
if (raw.trim().isEmpty) { if (raw.trim().isEmpty) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]); 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 json = jsonDecode(raw) as Map<String, dynamic>;
final vehicles = (json['vehicles'] as List<dynamic>? ?? []) final vehicles = (json['vehicles'] as List<dynamic>? ?? [])
.map((v) => Vehicle.fromJson(v as Map<String, dynamic>)) .map((v) => Vehicle.fromJson(v as Map<String, dynamic>))
@ -65,19 +60,26 @@ class StorageService {
return (vehicles: vehicles, entries: entries); return (vehicles: vehicles, entries: entries);
} }
Future<void> saveData({ String encodeData({
required List<Vehicle> vehicles, required List<Vehicle> vehicles,
required List<FuelEntry> entries, required List<FuelEntry> entries,
}) async { }) {
final json = { final json = {
'vehicles': vehicles.map((v) => v.toJson()).toList(), 'vehicles': vehicles.map((v) => v.toJson()).toList(),
'fuelEntries': entries.map((e) => e.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 Future<void> saveData({
/// the path it was stored at. 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 { Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
final ext = p.extension(sourceImage.path); final ext = p.extension(sourceImage.path);
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext'); final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
@ -92,37 +94,4 @@ class StorageService {
await file.delete(); 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);
}
} }

View file

@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_discoveryapis_commons:
dependency: transitive
description:
name: _discoveryapis_commons
sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -57,6 +65,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.dev"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
@ -113,14 +137,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
url: "https://pub.dev"
source: hosted
version: "10.3.10"
file_selector_linux: file_selector_linux:
dependency: transitive dependency: transitive
description: description:
@ -192,6 +208,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_mlkit_commons: google_mlkit_commons:
dependency: transitive dependency: transitive
description: description:
@ -208,6 +232,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.15.1" version: "0.15.1"
google_sign_in:
dependency: "direct main"
description:
name: google_sign_in
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e"
url: "https://pub.dev"
source: hosted
version: "7.2.16"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14
url: "https://pub.dev"
source: hosted
version: "1.1.3"
googleapis:
dependency: "direct main"
description:
name: googleapis
sha256: "5c9e0f25be1dec13d8d2158263141104c51b5ba83487537c17a2330581e505ee"
url: "https://pub.dev"
source: hosted
version: "14.0.0"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@ -217,7 +289,7 @@ packages:
source: hosted source: hosted
version: "2.0.2" version: "2.0.2"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@ -408,6 +480,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
@ -677,14 +757,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View file

@ -44,21 +44,10 @@ dependencies:
# On-device OCR text recognition # On-device OCR text recognition
google_mlkit_text_recognition: ^0.15.0 google_mlkit_text_recognition: ^0.15.0
# Directory picker for configurable save location. # App sandbox paths (local offline staging area)
# Pinned below 11.x: file_picker 11.0.0-11.0.3 skip applying the Kotlin
# Gradle plugin under AGP 9+ assuming AGP's built-in Kotlin support covers
# it, but that isn't actually wired up yet for library modules in this
# Flutter/AGP combo, so FilePickerPlugin.kt never gets compiled. 10.3.11
# still compiles against compileSdk 36 (matches flutter.compileSdkVersion,
# satisfying flutter_plugin_android_lifecycle's requirement) but applies
# its own Kotlin plugin unconditionally, which works. (10.3.11 itself is
# retracted on pub.dev, hence pinning to 10.3.10.)
file_picker: 10.3.10
# App sandbox paths (default save location, temp files)
path_provider: ^2.1.5 path_provider: ^2.1.5
# Persist small settings like chosen save directory # Persist small settings (Drive connection state, last synced time)
shared_preferences: ^2.3.4 shared_preferences: ^2.3.4
# Unique IDs for vehicles/fuel entries # Unique IDs for vehicles/fuel entries
@ -70,6 +59,18 @@ dependencies:
# Path joining/manipulation helpers # Path joining/manipulation helpers
path: ^1.9.0 path: ^1.9.0
# Google OAuth sign-in for Drive access
google_sign_in: ^7.1.1
# Google Drive API client
googleapis: ^14.0.0
# HTTP client interface (for the authenticated client we hand to DriveApi)
http: ^1.2.2
# Detect connectivity changes to trigger background sync
connectivity_plus: ^6.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View file

@ -0,0 +1,133 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/drive_service.dart' show DriveLockFile;
import 'package:fuel_tax_tracker/services/lock_coordinator.dart';
void main() {
group('acquireLock', () {
test('creates a lock file named {username}-{epochMillis}.lock', () async {
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
String? capturedName;
await acquireLock(
username: 'me@example.com',
nowUtc: () => now,
createLock: (name) async {
capturedName = name;
return 'id';
},
listLocks: () async =>
[DriveLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)],
deleteLock: (_) async {},
delay: (_) async {},
);
expect(capturedName, 'me@example.com-${now.millisecondsSinceEpoch}.lock');
});
test('proceeds immediately when no other lock is older', () async {
var listCallCount = 0;
final delayCalls = <Duration>[];
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
final lockId = await acquireLock(
username: 'me',
nowUtc: () => now,
createLock: (_) async => 'my-lock-id',
listLocks: () async {
listCallCount++;
return [DriveLockFile(id: 'my-lock-id', username: 'me', createdAtUtc: now)];
},
deleteLock: (_) async {},
delay: (d) async => delayCalls.add(d),
);
expect(lockId, 'my-lock-id');
expect(listCallCount, 1);
expect(delayCalls, isEmpty);
});
test('a newer lock does not block acquisition', () async {
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
final delayCalls = <Duration>[];
final lockId = await acquireLock(
username: 'me',
nowUtc: () => now,
createLock: (_) async => 'mine',
listLocks: () async => [
DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now),
DriveLockFile(
id: 'newer', username: 'other', createdAtUtc: now.add(const Duration(seconds: 5))),
],
deleteLock: (_) async {},
delay: (d) async => delayCalls.add(d),
);
expect(lockId, 'mine');
expect(delayCalls, isEmpty);
});
test('waits for an older, non-stale lock, then proceeds once it is gone', () async {
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
var listCallCount = 0;
final delayCalls = <Duration>[];
final deleteCalls = <String>[];
final lockId = await acquireLock(
username: 'me',
nowUtc: () => now,
createLock: (_) async => 'mine',
listLocks: () async {
listCallCount++;
if (listCallCount == 1) {
return [
DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now),
DriveLockFile(
id: 'other',
username: 'other',
createdAtUtc: now.subtract(const Duration(seconds: 5))),
];
}
return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
},
deleteLock: (id) async => deleteCalls.add(id),
delay: (d) async => delayCalls.add(d),
);
expect(lockId, 'mine');
expect(listCallCount, 2);
expect(delayCalls.length, 1);
expect(deleteCalls, isEmpty, reason: 'a non-stale older lock should not be deleted');
});
test('deletes an older lock once it exceeds the staleness threshold', () async {
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
var listCallCount = 0;
final deleteCalls = <String>[];
await acquireLock(
username: 'me',
nowUtc: () => now,
staleAge: const Duration(minutes: 10),
createLock: (_) async => 'mine',
listLocks: () async {
listCallCount++;
if (listCallCount == 1) {
return [
DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now),
DriveLockFile(
id: 'stale',
username: 'ghost',
createdAtUtc: now.subtract(const Duration(minutes: 15))),
];
}
return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
},
deleteLock: (id) async => deleteCalls.add(id),
delay: (_) async {},
);
expect(deleteCalls, ['stale']);
});
});
}

View file

@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/merge_utils.dart';
class _Record {
final String id;
final String value;
final DateTime updatedAt;
_Record(this.id, this.value, this.updatedAt);
}
void main() {
group('mergeById', () {
test('unions records that only exist on one side', () {
final remote = [_Record('a', 'remote-a', DateTime(2024, 1, 1))];
final local = [_Record('b', 'local-b', DateTime(2024, 1, 1))];
final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt);
expect(result.map((r) => r.id), containsAll(['a', 'b']));
expect(result.length, 2);
});
test('keeps the newer updatedAt when a record exists on both sides', () {
final remote = [_Record('a', 'remote-old', DateTime(2024, 1, 1))];
final local = [_Record('a', 'local-new', DateTime(2024, 6, 1))];
final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt);
expect(result.length, 1);
expect(result.first.value, 'local-new');
});
test('keeps the remote copy when it is newer than local', () {
final remote = [_Record('a', 'remote-new', DateTime(2024, 6, 1))];
final local = [_Record('a', 'local-old', DateTime(2024, 1, 1))];
final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt);
expect(result.length, 1);
expect(result.first.value, 'remote-new');
});
test('local wins ties', () {
final sameTime = DateTime(2024, 1, 1);
final remote = [_Record('a', 'remote', sameTime)];
final local = [_Record('a', 'local', sameTime)];
final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt);
expect(result.first.value, 'local');
});
});
}