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

12 KiB

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

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

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

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.

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