A whole lot of stuff

This commit is contained in:
Courtney Arnold 2026-08-18 11:04:58 -05:00
parent faf319322a
commit ad850d2be6
50 changed files with 3013 additions and 213 deletions

425
docs/class-diagram.md Normal file
View file

@ -0,0 +1,425 @@
# Application class diagram
Fuel Tax Tracker is a Flutter app. Screens read and mutate through `AppState` (a `ChangeNotifier` provided at the root). Persistence is `DatabaseService` (SQLite); cloud I/O goes through `CloudStorageProvider` so sync never depends on a specific backend.
Private Flutter `State` / painter classes are omitted. Functions that are not classes (`buildFuelReport`, `estimatedFuelRefund`, `acquireLock`) are noted where they sit in the design.
## Domain and application facade
```mermaid
classDiagram
class Vehicle {
+String id
+String vin
+String? nickname
+DateTime updatedAt
+DateTime? deletedAt
+String displayLabel
+copyWith() Vehicle
+toMap() Map
+fromMap(map) Vehicle
}
class FuelEntry {
+String id
+String vehicleId
+DateTime date
+double gallons
+double pricePerGallon
+double totalCost
+String? receiptImagePath
+String? receiptDriveFileId
+DateTime updatedAt
+DateTime? deletedAt
+bool isReceiptUploadedToDrive
+bool needsReceiptUpload
+copyWith() FuelEntry
+toMap() Map
+fromMap(map) FuelEntry
}
class DuplicateVinException {
+String vin
}
class AppState {
+DatabaseService database
+AdService adService
+PurchaseService purchaseService
+List~CloudStorageProvider~ availableProviders
+CloudStorageProvider? activeProvider
+CloudSyncService? cloudSync
+List~Vehicle~ vehicles
+List~FuelEntry~ fuelEntries
+DateTime? adFreeUntil
+bool adsCurrentlyDisabled
+bool isCloudConnected
+bool hasCloudBackupConfigured
+init()
+addVehicle()
+updateVehicle()
+deleteVehicle()
+addFuelEntry() FuelEntry
+updateFuelEntry()
+deleteFuelEntry()
+syncNow()
+connectProvider()
+disconnectCloud()
+purgeAllData()
}
class DatabaseService {
+Directory rootDirectory
+Database rawDb
+init()
+getVehicles() List~Vehicle~
+getFuelEntries() List~FuelEntry~
+getAdFreeUntil() DateTime?
+setAdFreeUntil()
+vinExists() bool
+saveVehicle()
+saveFuelEntry()
+softDeleteVehicle()
+softDeleteFuelEntry()
+storeReceiptImage() String
+purgeAllData()
}
Vehicle "1" <-- "0..*" FuelEntry : vehicleId
AppState "1" *-- "1" DatabaseService
AppState "1" --> "*" Vehicle : caches
AppState "1" --> "*" FuelEntry : caches
AppState ..> DuplicateVinException : throws
DatabaseService --> Vehicle
DatabaseService --> FuelEntry
AppState --|> ChangeNotifier
```
`ad_free_entitlement` has **no** Dart model. `DatabaseService.getAdFreeUntil` / `setAdFreeUntil` read and write that singleton row; `AppState.adFreeUntil` is the in-memory cache.
## Cloud storage and sync
```mermaid
classDiagram
class CloudProviderId {
<<enumeration>>
googleDrive
dropbox
oneDrive
webdav
}
class CloudStorageProvider {
<<abstract>>
+CloudProviderId id
+String displayName
+bool isSignedIn
+String? accountLabel
+attemptSilentSignIn() bool
+signIn() String
+signOut()
+beginSession() CloudStorageSession
}
class CloudStorageSession {
<<abstract>>
+bool supportsSharedWithMe
+listFolders() List~CloudFolder~
+findOrCreateFolder() String
+moveFolder() String
+findFile() CloudFileInfo?
+downloadFileBytes() List~int~
+uploadFile() CloudFileInfo
+deleteFile()
+createLockFile() String
+listLockFiles() List~CloudLockFile~
+close()
}
class ManualCredentialCloudStorageProvider {
<<abstract>>
+signInWithCredentials() String
}
class GoogleDriveProvider
class DropboxProvider
class OneDriveProvider
class WebDavProvider
class GoogleDriveSession
class DropboxSession
class OneDriveSession
class WebDavSession
class CloudFolder {
+String id
+String name
}
class CloudFileInfo {
+String id
+String? versionTag
}
class CloudLockFile {
+String id
+String username
+DateTime createdAtUtc
}
class CloudNotAuthorizedException {
+String providerName
}
class CloudSyncService {
+CloudStorageProvider provider
+DatabaseService databaseService
+bool isConfigured
+configure(appFolderId)
+clearConfiguration()
+selectAppFolder() String
+syncNow() SyncResult
}
class SyncResult {
+bool ranSync
+Object? error
+bool adGateBlocked
+skipped() SyncResult
+success() SyncResult
+failure(error) SyncResult
+adGateBlocked() SyncResult
}
CloudStorageProvider <|.. GoogleDriveProvider
CloudStorageProvider <|.. DropboxProvider
CloudStorageProvider <|.. OneDriveProvider
CloudStorageProvider <|.. WebDavProvider
ManualCredentialCloudStorageProvider <|.. WebDavProvider
CloudStorageSession <|.. GoogleDriveSession
CloudStorageSession <|.. DropboxSession
CloudStorageSession <|.. OneDriveSession
CloudStorageSession <|.. WebDavSession
CloudStorageProvider --> CloudStorageSession : beginSession()
CloudStorageProvider --> CloudProviderId
CloudStorageSession --> CloudFolder
CloudStorageSession --> CloudFileInfo
CloudStorageSession --> CloudLockFile
CloudSyncService --> CloudStorageProvider
CloudSyncService --> DatabaseService
CloudSyncService --> SyncResult
AppState o-- CloudStorageProvider : activeProvider
AppState o-- CloudSyncService : cloudSync
AppState "1" *-- "*" CloudStorageProvider : availableProviders
```
Lock acquisition is a free function, `acquireLock` in `lock_coordinator.dart`, injected with create/list/delete callbacks so it can be unit-tested without a network. `CloudSyncService` is the only production caller.
OAuth helpers (`PkcePair`, `CloudOAuthConfig`) support Dropbox and OneDrive sign-in. Google uses its own SDK; WebDAV uses HTTP Basic Auth.
## Reports, OCR, ads, purchases
```mermaid
classDiagram
class FuelReport {
+DateTime startDate
+DateTime endDate
+List~VehicleReportRow~ rows
+double totalGallons
+double totalCost
+int fillCount
+List~ReportEntry~ allEntries
+List~MonthlyTotal~ monthlyTotals
}
class VehicleReportRow {
+Vehicle vehicle
+List~FuelEntry~ entries
+double totalGallons
+double totalCost
+int entryCount
}
class ReportEntry {
+FuelEntry entry
+Vehicle vehicle
}
class MonthlyTotal {
+DateTime month
+int fillCount
+double totalGallons
+double totalCost
+double avgPricePerGallon
}
class ParsedReceipt {
+double? gallons
+double? pricePerGallon
+double? totalCost
+DateTime? date
+String? state
+String rawText
}
class ReceiptParser {
+parse(text) ParsedReceipt$
}
class OcrService {
+recognizeText(imageFile) String
+dispose()
}
class VinParser {
+parse(text) String?$
}
class AdService {
+initialize()
+showGateAd() AdGateResult
}
class AdGateResult {
<<enumeration>>
alreadyOpen
justShown
blocked
}
class PurchaseService {
+String adFreeYearProductId$
+String? priceLabel
+initialize()
+buyAdFreeYear()
+dispose()
}
class ReceiptImageLoadResult {
+Map bytesByEntryId
+List~FuelEntry~ failedEntries
}
FuelReport "1" *-- "*" VehicleReportRow
VehicleReportRow --> Vehicle
VehicleReportRow --> FuelEntry
FuelReport --> ReportEntry
ReportEntry --> FuelEntry
ReportEntry --> Vehicle
FuelReport --> MonthlyTotal
ReceiptParser --> ParsedReceipt
OcrService ..> ReceiptParser : raw text in
AdService --> AdGateResult
AppState *-- AdService
AppState *-- PurchaseService
```
`buildFuelReport(...)` (in `fuel_report.dart`) constructs a `FuelReport` from the in-memory vehicle/entry lists. `buildFuelReportPdf(...)` renders it; `loadReceiptImageBytes(...)` resolves local or cloud receipt photos into a `ReceiptImageLoadResult`. `estimatedFuelRefund(gallons)` is a pure function (Missouri highway rate, $0.125/gal).
## UI structure
Widgets consume `AppState` via `Provider` / `context.watch`. They do not talk to SQLite or cloud APIs directly.
```mermaid
classDiagram
class FuelTaxTrackerApp {
<<StatelessWidget>>
}
class AppRoot {
<<StatelessWidget>>
}
class UserAgreementScreen {
<<StatelessWidget>>
}
class MainShell {
<<StatefulWidget>>
}
class ReceiptsScreen {
<<StatefulWidget>>
}
class HomeScreen {
<<StatelessWidget>>
}
class ReportScreen {
<<StatefulWidget>>
}
class SettingsScreen {
<<StatelessWidget>>
}
class DataSettingsScreen {
<<StatefulWidget>>
}
class UiSettingsScreen {
<<StatelessWidget>>
}
class FaqScreen {
<<StatelessWidget>>
}
class AddEditVehicleScreen {
<<StatefulWidget>>
}
class VehicleDetailScreen {
<<StatefulWidget>>
}
class ConfirmFuelEntryScreen {
<<StatefulWidget>>
}
class EditFuelEntryScreen {
<<StatefulWidget>>
}
class ReceiptDetailScreen {
<<StatelessWidget>>
}
class ReceiptImageScreen {
<<StatefulWidget>>
}
class CloudFolderBrowserScreen {
<<StatefulWidget>>
}
FuelTaxTrackerApp --> AppRoot
FuelTaxTrackerApp --> AppState : ChangeNotifierProvider
AppRoot --> UserAgreementScreen : if not accepted
AppRoot --> MainShell : after agreement
MainShell --> ReceiptsScreen
MainShell --> HomeScreen
MainShell --> ReportScreen
MainShell --> SettingsScreen
SettingsScreen --> DataSettingsScreen
SettingsScreen --> UiSettingsScreen
SettingsScreen --> FaqScreen
DataSettingsScreen --> CloudFolderBrowserScreen
HomeScreen --> AddEditVehicleScreen
HomeScreen --> VehicleDetailScreen
HomeScreen --> FaqScreen
VehicleDetailScreen --> ConfirmFuelEntryScreen
VehicleDetailScreen --> EditFuelEntryScreen
VehicleDetailScreen --> ReceiptImageScreen
ReceiptsScreen --> ConfirmFuelEntryScreen
ReceiptsScreen --> ReceiptDetailScreen
ReceiptsScreen --> EditFuelEntryScreen
ReceiptDetailScreen --> ReceiptImageScreen
ReportScreen --> FuelReport : buildFuelReport()
```
Shared widgets (not expanded above): `HeroBanner`, `ReceiptThumbnail`, `ReceiptCapture`, `OnboardingTourOverlay`, `BackupReminder`, `AdFreeUpsellDialog`, `ImageSourceSheet`. Theme tokens live on `AppTheme`.
## How the layers connect
```
UI screens/widgets
│ Provider
▼
AppState ──────────► PurchaseService
│ AdService
├── DatabaseService ── SQLite (vehicles, fuel_entries, ad_free_entitlement)
│ └── local receipts/ folder
└── CloudSyncService ── CloudStorageProvider
├── GoogleDriveProvider
├── DropboxProvider
├── OneDriveProvider
└── WebDavProvider
```

73
docs/erd.md Normal file
View file

@ -0,0 +1,73 @@
# SQLite entity-relationship diagram
Local database: `ApplicationDocumentsDirectory/FuelTaxTracker/fuel_tax_tracker.db`
Schema version: **5** (defined in `lib/services/database_service.dart` / `lib/services/db_schema.dart`)
```mermaid
erDiagram
VEHICLES ||--o{ FUEL_ENTRIES : "owns (vehicle_id)"
VEHICLES {
TEXT id PK "UUID, hidden, immutable"
TEXT vin "required; unique among active rows, app-enforced"
TEXT nickname "nullable display label"
INTEGER updated_at "UTC millis; newest-wins merge key"
INTEGER deleted_at "nullable soft-delete tombstone"
INTEGER dirty "local-only; 1 = not yet pushed"
}
FUEL_ENTRIES {
TEXT id PK "UUID"
TEXT vehicle_id FK "references vehicles.id — no SQLite FK"
INTEGER date "purchase datetime, local millis"
REAL gallons
REAL price_per_gallon
REAL total_cost
TEXT receipt_image_path "nullable on-device path"
TEXT receipt_drive_file_id "nullable cloud file id"
INTEGER updated_at "UTC millis; newest-wins merge key"
INTEGER deleted_at "nullable soft-delete tombstone"
INTEGER dirty "local-only; 1 = not yet pushed"
}
AD_FREE_ENTITLEMENT {
INTEGER id PK "always 1 (CHECK id = 1)"
INTEGER ad_free_until "nullable UTC millis"
INTEGER updated_at "UTC millis; newest-wins merge key"
}
```
## Relationships
| From | To | Cardinality | Enforced by |
| --- | --- | --- | --- |
| `vehicles` | `fuel_entries` | 1 : 0..n | Application (`FuelEntry.vehicleId` → `Vehicle.id`). There is **no** `FOREIGN KEY` clause. |
| `ad_free_entitlement` | — | singleton | `PRIMARY KEY CHECK (id = 1)` |
VIN is **not** the foreign key. Fuel entries reference the hidden vehicle `id` so the user can edit a VIN without breaking receipts or sync history.
## Indexes and constraints
- `vehicles.id` — `PRIMARY KEY`
- `fuel_entries.id` — `PRIMARY KEY`
- `idx_fuel_entries_vehicle_id` on `fuel_entries(vehicle_id)`
- `ad_free_entitlement.id` — `PRIMARY KEY CHECK (id = 1)`
- VIN uniqueness among **active** (`deleted_at IS NULL`) vehicles is enforced in `DatabaseService.vinExists` / `AppState.addVehicle` / `AppState.updateVehicle`, not by a unique index. Two offline devices can independently add the same VIN; that rare conflict is accepted rather than merged field-by-field.
## Sync and lifecycle columns
Every synced table carries `updated_at`. Cloud merge is last-write-wins on that timestamp (`INSERT OR REPLACE` of a remote row that is new or newer). See `mergeVehiclesSql`, `mergeFuelEntriesSql`, and `mergeAdFreeEntitlementSql` in `db_schema.dart`.
| Column | Scope | Meaning |
| --- | --- | --- |
| `updated_at` | all three tables | Newest value wins when merging a remote copy. |
| `deleted_at` | `vehicles`, `fuel_entries` | Soft-delete tombstone. Rows are never hard-deleted, so a deletion can propagate to other devices instead of being resurrected by a stale remote copy. |
| `dirty` | `vehicles`, `fuel_entries` only | Local bookkeeping: `1` = not yet pushed. Cleared to `0` on merge-in. **Not** mapped onto the Dart models. |
| `receipt_image_path` | `fuel_entries` | Device-local filesystem path. Forced to `NULL` when merging a remote row — a path from another phone is never valid here. |
| `receipt_drive_file_id` | `fuel_entries` | Opaque id of the uploaded receipt on the active cloud provider. Presence means “already uploaded”; a pending upload is `path IS NOT NULL AND drive_file_id IS NULL`. |
`ad_free_entitlement` has no `dirty` or `deleted_at`. It is a single row that exists so a consumable Play Store purchase (which cannot be restored after consume) can survive reinstall via the same cloud merge as vehicles and fuel entries.
## What is *not* in SQLite
Preferences live in `SharedPreferences`, not this database: theme, onboarding/agreement flags, cloud provider + folder ids, keep-photos-locally, stale-lock timeout, estimated-refund toggle.