Added Google Drive storage location

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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