Added Google Drive storage location
This commit is contained in:
parent
e630f2a3ec
commit
73a7c93e64
22 changed files with 1690 additions and 205 deletions
151
README.md
151
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
219
lib/screens/drive_folder_browser_screen.dart
Normal file
219
lib/screens/drive_folder_browser_screen.dart
Normal 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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
if (!appState.isDriveConnected) ...[
|
||||||
Text(
|
Text(
|
||||||
'Receipt photos and the data file are stored here:',
|
'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,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 12),
|
||||||
SelectableText(
|
|
||||||
saveDir,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (_error != null) ...[
|
if (_error != null) ...[
|
||||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _changing ? null : _chooseFolder,
|
onPressed: _busy ? null : _connect,
|
||||||
icon: _changing
|
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(
|
||||||
|
onPressed: (_busy || appState.driveFolderPath == null)
|
||||||
|
? null
|
||||||
|
: _syncNow,
|
||||||
|
icon: appState.isSyncing
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 16,
|
height: 16,
|
||||||
width: 16,
|
width: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
)
|
: const Icon(Icons.sync),
|
||||||
: const Icon(Icons.folder_open),
|
label: const Text('Sync Now'),
|
||||||
label: const Text('Choose Folder'),
|
|
||||||
),
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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)}',
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
lib/services/drive_auth_service.dart
Normal file
108
lib/services/drive_auth_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
lib/services/drive_oauth_config.dart
Normal file
20
lib/services/drive_oauth_config.dart
Normal 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
|
||||||
|
}
|
||||||
229
lib/services/drive_service.dart
Normal file
229
lib/services/drive_service.dart
Normal 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;
|
||||||
|
}
|
||||||
204
lib/services/drive_sync_service.dart
Normal file
204
lib/services/drive_sync_service.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
lib/services/lock_coordinator.dart
Normal file
48
lib/services/lock_coordinator.dart
Normal 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;
|
||||||
|
}
|
||||||
23
lib/services/merge_utils.dart
Normal file
23
lib/services/merge_utils.dart
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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 storedPath = prefs.getString(_prefsKey);
|
|
||||||
|
|
||||||
if (storedPath != null && await Directory(storedPath).exists()) {
|
|
||||||
_saveDirectory = Directory(storedPath);
|
|
||||||
} else {
|
|
||||||
final docsDir = await getApplicationDocumentsDirectory();
|
final docsDir = await getApplicationDocumentsDirectory();
|
||||||
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
|
_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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
pubspec.lock
106
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
27
pubspec.yaml
27
pubspec.yaml
|
|
@ -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
|
||||||
|
|
|
||||||
133
test/lock_coordinator_test.dart
Normal file
133
test/lock_coordinator_test.dart
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
54
test/merge_utils_test.dart
Normal file
54
test/merge_utils_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue