425 lines
12 KiB
Markdown
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
|
|
```
|