A whole lot of stuff
This commit is contained in:
parent
faf319322a
commit
ad850d2be6
50 changed files with 3013 additions and 213 deletions
425
docs/class-diagram.md
Normal file
425
docs/class-diagram.md
Normal 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
73
docs/erd.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue