MO-Fuel-Tax-Back/docs/class-diagram.md

425 lines
12 KiB
Markdown

# 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
```