Added Google Drive storage location
This commit is contained in:
parent
e630f2a3ec
commit
73a7c93e64
22 changed files with 1690 additions and 205 deletions
|
|
@ -6,6 +6,8 @@ class FuelEntry {
|
|||
final double pricePerGallon;
|
||||
final double totalCost;
|
||||
final String? receiptImagePath;
|
||||
final String? receiptDriveFileId;
|
||||
final DateTime updatedAt;
|
||||
|
||||
FuelEntry({
|
||||
required this.id,
|
||||
|
|
@ -14,9 +16,40 @@ class FuelEntry {
|
|||
required this.gallons,
|
||||
required this.pricePerGallon,
|
||||
required this.totalCost,
|
||||
required this.updatedAt,
|
||||
this.receiptImagePath,
|
||||
this.receiptDriveFileId,
|
||||
});
|
||||
|
||||
/// True once the receipt photo has been uploaded to Drive and the local
|
||||
/// copy removed. Viewing it then requires downloading it on demand.
|
||||
bool get isReceiptUploadedToDrive =>
|
||||
receiptImagePath == null && receiptDriveFileId != null;
|
||||
|
||||
/// True while a receipt photo exists only locally and still needs to be
|
||||
/// uploaded next sync.
|
||||
bool get hasPendingLocalReceipt => receiptImagePath != null;
|
||||
|
||||
FuelEntry copyWith({
|
||||
String? receiptImagePath,
|
||||
String? receiptDriveFileId,
|
||||
DateTime? updatedAt,
|
||||
bool clearReceiptImagePath = false,
|
||||
}) {
|
||||
return FuelEntry(
|
||||
id: id,
|
||||
vehicleId: vehicleId,
|
||||
date: date,
|
||||
gallons: gallons,
|
||||
pricePerGallon: pricePerGallon,
|
||||
totalCost: totalCost,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
receiptImagePath:
|
||||
clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath),
|
||||
receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'vehicleId': vehicleId,
|
||||
|
|
@ -25,6 +58,8 @@ class FuelEntry {
|
|||
'pricePerGallon': pricePerGallon,
|
||||
'totalCost': totalCost,
|
||||
'receiptImagePath': receiptImagePath,
|
||||
'receiptDriveFileId': receiptDriveFileId,
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry(
|
||||
|
|
@ -35,5 +70,9 @@ class FuelEntry {
|
|||
pricePerGallon: (json['pricePerGallon'] as num).toDouble(),
|
||||
totalCost: (json['totalCost'] as num).toDouble(),
|
||||
receiptImagePath: json['receiptImagePath'] as String?,
|
||||
receiptDriveFileId: json['receiptDriveFileId'] as String?,
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'] as String)
|
||||
: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ class Vehicle {
|
|||
final String model;
|
||||
final String color;
|
||||
final String licensePlate;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Vehicle({
|
||||
required this.id,
|
||||
|
|
@ -11,6 +12,7 @@ class Vehicle {
|
|||
required this.model,
|
||||
required this.color,
|
||||
required this.licensePlate,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
String get displayName => '$color $make $model ($licensePlate)';
|
||||
|
|
@ -20,6 +22,7 @@ class Vehicle {
|
|||
String? model,
|
||||
String? color,
|
||||
String? licensePlate,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Vehicle(
|
||||
id: id,
|
||||
|
|
@ -27,6 +30,7 @@ class Vehicle {
|
|||
model: model ?? this.model,
|
||||
color: color ?? this.color,
|
||||
licensePlate: licensePlate ?? this.licensePlate,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +40,7 @@ class Vehicle {
|
|||
'model': model,
|
||||
'color': color,
|
||||
'licensePlate': licensePlate,
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
|
||||
|
|
@ -44,5 +49,8 @@ class Vehicle {
|
|||
model: json['model'] as String,
|
||||
color: json['color'] as String,
|
||||
licensePlate: json['licensePlate'] as String,
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'] as String)
|
||||
: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path),
|
||||
builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
|
|
|
|||
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:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Full-screen, pinch-zoomable view of a saved receipt photo.
|
||||
class ReceiptImageScreen extends StatelessWidget {
|
||||
final String imagePath;
|
||||
import '../services/app_state.dart';
|
||||
import '../services/drive_service.dart';
|
||||
|
||||
const ReceiptImageScreen({super.key, required this.imagePath});
|
||||
/// Full-screen, pinch-zoomable view of a receipt photo. Pass
|
||||
/// [localImagePath] for a receipt still held locally, or [driveFileId] for
|
||||
/// one already uploaded to Drive and removed locally — in which case it's
|
||||
/// downloaded on demand (no local caching afterward).
|
||||
class ReceiptImageScreen extends StatefulWidget {
|
||||
final String? localImagePath;
|
||||
final String? driveFileId;
|
||||
|
||||
const ReceiptImageScreen({super.key, this.localImagePath, this.driveFileId})
|
||||
: assert(localImagePath != null || driveFileId != null,
|
||||
'Must provide either a local path or a Drive file ID');
|
||||
|
||||
@override
|
||||
State<ReceiptImageScreen> createState() => _ReceiptImageScreenState();
|
||||
}
|
||||
|
||||
class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
|
||||
Uint8List? _downloadedBytes;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.driveFileId != null) {
|
||||
_download();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _download() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final authService = context.read<AppState>().driveAuth;
|
||||
final client = authService.authenticatedHttpClient();
|
||||
try {
|
||||
final drive = DriveService(client);
|
||||
final bytes = await drive.downloadFileBytes(widget.driveFileId!);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_downloadedBytes = Uint8List.fromList(bytes);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = 'Could not download receipt: $e';
|
||||
_loading = false;
|
||||
});
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -17,11 +72,26 @@ class ReceiptImageScreen extends StatelessWidget {
|
|||
foregroundColor: Colors.white,
|
||||
title: const Text('Receipt'),
|
||||
),
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
child: Image.file(File(imagePath)),
|
||||
),
|
||||
),
|
||||
body: Center(child: _buildBody()),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (widget.localImagePath != null) {
|
||||
return InteractiveViewer(child: Image.file(File(widget.localImagePath!)));
|
||||
}
|
||||
if (_loading) {
|
||||
return const CircularProgressIndicator(color: Colors.white);
|
||||
}
|
||||
if (_error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(_error!, style: const TextStyle(color: Colors.white)),
|
||||
);
|
||||
}
|
||||
if (_downloadedBytes != null) {
|
||||
return InteractiveViewer(child: Image.memory(_downloadedBytes!));
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import 'drive_folder_browser_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
|
@ -12,31 +13,50 @@ class SettingsScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _changing = false;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _chooseFolder() async {
|
||||
setState(() => _error = null);
|
||||
|
||||
final selectedPath = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: 'Choose a folder for receipts and data',
|
||||
);
|
||||
if (selectedPath == null || !mounted) return;
|
||||
|
||||
setState(() => _changing = true);
|
||||
Future<void> _connect() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().changeSaveDirectory(selectedPath);
|
||||
await context.read<AppState>().connectDrive();
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not switch to that folder: $e');
|
||||
setState(() => _error = 'Could not sign in: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _changing = false);
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().disconnectDrive();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _chooseFolder() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const DriveFolderBrowserScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _syncNow() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().syncNow();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final saveDir = appState.storage.saveDirectory.path;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
|
|
@ -49,35 +69,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Save Location', style: Theme.of(context).textTheme.titleMedium),
|
||||
Text('Google Drive', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Receipt photos and the data file are stored here:',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
saveDir,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
if (!appState.isDriveConnected) ...[
|
||||
Text(
|
||||
'Connect Google Drive to share vehicles and fuel receipts with '
|
||||
'other people, and to keep a backup off this device.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null) ...[
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _connect,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Connect Google Drive'),
|
||||
),
|
||||
] else ...[
|
||||
Text('Signed in as ${appState.driveAccountEmail}'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
appState.driveFolderPath == null
|
||||
? 'No folder selected yet.'
|
||||
: 'Folder: ${appState.driveFolderPath}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _chooseFolder,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: Text(appState.driveFolderPath == null
|
||||
? 'Choose Folder'
|
||||
: 'Change Folder'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_error != null) ...[
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
OutlinedButton.icon(
|
||||
onPressed: (_busy || appState.driveFolderPath == null)
|
||||
? null
|
||||
: _syncNow,
|
||||
icon: appState.isSyncing
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.sync),
|
||||
label: const Text('Sync Now'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _disconnect,
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (appState.lastSyncedAt != null)
|
||||
Text(
|
||||
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (appState.lastSyncError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Last sync failed: ${appState.lastSyncError}',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: _changing ? null : _chooseFolder,
|
||||
icon: _changing
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.folder_open),
|
||||
label: const Text('Choose Folder'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -88,6 +88,38 @@ class VehicleDetailScreen extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Widget _buildReceiptLeading(BuildContext context, FuelEntry entry) {
|
||||
if (entry.receiptImagePath != null) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.file(
|
||||
File(entry.receiptImagePath!),
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (entry.isReceiptUploadedToDrive) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId),
|
||||
),
|
||||
),
|
||||
child: const CircleAvatar(child: Icon(Icons.cloud_outlined)),
|
||||
);
|
||||
}
|
||||
return const CircleAvatar(child: Icon(Icons.receipt_long));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
|
@ -144,24 +176,7 @@ class VehicleDetailScreen extends StatelessWidget {
|
|||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: entry.receiptImagePath != null
|
||||
? GestureDetector(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.file(
|
||||
File(entry.receiptImagePath!),
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const CircleAvatar(child: Icon(Icons.receipt_long)),
|
||||
leading: _buildReceiptLeading(context, entry),
|
||||
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
|
||||
subtitle: Text(
|
||||
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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_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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue