first commit

This commit is contained in:
Courtney Arnold 2026-08-08 08:22:14 -05:00
commit e630f2a3ec
80 changed files with 3889 additions and 0 deletions

45
.gitignore vendored Normal file
View file

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

33
.metadata Normal file
View file

@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "6b182d2c7585eba26d4edce0f97630effd256c33"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
- platform: android
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
- platform: ios
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

93
README.md Normal file
View file

@ -0,0 +1,93 @@
# Fuel Tax Tracker
A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a
photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device,
you confirm or correct the numbers, and it's saved alongside the receipt
photo to a data file in a folder you choose.
## Features
- Manage a list of vehicles (make, model, color, license plate).
- Capture a fuel receipt photo per vehicle and OCR it on-device with Google
ML Kit — no internet connection or API key required.
- Review/edit the parsed gallons, price per gallon, and total cost before
saving (OCR on printed receipts is usually good but not perfect).
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt
photos, and delete.
- Settings screen to choose where receipt photos and the data file are
stored (defaults to the app's own documents folder; existing data is
copied over when you change it).
## Project layout
```
lib/
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization
services/
app_state.dart In-memory state + CRUD, backed by StorageService, exposed via Provider
storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path
ocr_service.dart Thin wrapper around google_mlkit_text_recognition
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
confirm fuel entry, receipt viewer, settings)
```
Data is stored as a single `fuel_tax_data.json` file plus a `receipts/`
subfolder of photos, both inside whatever directory Settings points at.
## Running it
```
flutter pub get
flutter run # with a device/emulator connected or a simulator booted
```
To build release artifacts:
```
flutter build apk --release # Android
flutter build ios --release # iOS (requires a full Xcode install + signing setup)
```
This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and
`flutter test` are clean, and `flutter build apk --debug` has been confirmed
to produce a working APK.
## Permissions
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
set up for receipt capture.
- **Storage**: no explicit storage permission is declared. The default save
location is inside the app's own sandbox (no permission needed). If you
point Settings at a location outside the sandbox, the OS-native folder
picker (via `file_picker`) is what grants access — see the caveat below.
## Known caveats / things to revisit
- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions
11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP
9+ (assuming AGP's built-in Kotlin support handles it), but that isn't
actually wired up for library modules in this Flutter/AGP combination yet,
so the plugin's own Kotlin sources never get compiled and the build fails
with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is
retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time
you bump dependencies — check the package's CHANGELOG for when this is
properly resolved upstream.
- **iOS folder picking and app restarts**: `file_picker`'s directory picker
on iOS uses `UIDocumentPickerViewController`, which hands back a
security-scoped URL. This app does not currently persist a security-scoped
bookmark for that URL, so if you pick a folder outside the app's own
sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after
an app restart is not guaranteed. Picking the default in-sandbox location,
or a folder on Android, does not have this limitation. If cross-restart
external storage on iOS matters for your use case, this needs a proper
bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`).
- **Receipt parsing is best-effort regex matching** on the OCR'd text
(`lib/services/receipt_parser.dart`), tuned against common receipt phrasing
("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may
parse partially or not at all — the confirm screen always lets you fill in
or correct whatever wasn't found.
- No automated tests exercise the OCR or camera capture path itself (that
requires a real device/emulator with a camera); `receipt_parser_test.dart`
covers the parsing logic against fixed OCR text.

28
analysis_options.yaml Normal file
View file

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View file

@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.courtneyarnold.fuel_tax_tracker"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.courtneyarnold.fuel_tax_tracker"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View file

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View file

@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<application
android:label="fuel_tax_tracker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View file

@ -0,0 +1,5 @@
package com.courtneyarnold.fuel_tax_tracker
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View file

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View file

@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View file

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

View file

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")

34
ios/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View file

@ -0,0 +1 @@
#include "Generated.xcconfig"

View file

@ -0,0 +1 @@
#include "Generated.xcconfig"

View file

@ -0,0 +1,644 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.courtneyarnold.fuelTaxTracker;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.courtneyarnold.fuelTaxTracker.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.courtneyarnold.fuelTaxTracker.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.courtneyarnold.fuelTaxTracker.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.courtneyarnold.fuelTaxTracker;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.courtneyarnold.fuelTaxTracker;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View file

@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View file

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

72
ios/Runner/Info.plist Normal file
View file

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>NSCameraUsageDescription</key>
<string>Fuel Tax Tracker uses the camera to take photos of fuel receipts.</string>
<key>CFBundleDisplayName</key>
<string>Fuel Tax Tracker</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>fuel_tax_tracker</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View file

@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View file

@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

48
lib/main.dart Normal file
View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/home_screen.dart';
import 'services/app_state.dart';
void main() {
runApp(const FuelTaxTrackerApp());
}
class FuelTaxTrackerApp extends StatelessWidget {
const FuelTaxTrackerApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AppState()..init(),
child: MaterialApp(
title: 'Fuel Tax Tracker',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
darkTheme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
useMaterial3: true,
),
home: const AppRoot(),
),
);
}
}
class AppRoot extends StatelessWidget {
const AppRoot({super.key});
@override
Widget build(BuildContext context) {
return Consumer<AppState>(
builder: (context, appState, _) {
if (appState.isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return const HomeScreen();
},
);
}
}

View file

@ -0,0 +1,39 @@
class FuelEntry {
final String id;
final String vehicleId;
final DateTime date;
final double gallons;
final double pricePerGallon;
final double totalCost;
final String? receiptImagePath;
FuelEntry({
required this.id,
required this.vehicleId,
required this.date,
required this.gallons,
required this.pricePerGallon,
required this.totalCost,
this.receiptImagePath,
});
Map<String, dynamic> toJson() => {
'id': id,
'vehicleId': vehicleId,
'date': date.toIso8601String(),
'gallons': gallons,
'pricePerGallon': pricePerGallon,
'totalCost': totalCost,
'receiptImagePath': receiptImagePath,
};
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry(
id: json['id'] as String,
vehicleId: json['vehicleId'] as String,
date: DateTime.parse(json['date'] as String),
gallons: (json['gallons'] as num).toDouble(),
pricePerGallon: (json['pricePerGallon'] as num).toDouble(),
totalCost: (json['totalCost'] as num).toDouble(),
receiptImagePath: json['receiptImagePath'] as String?,
);
}

48
lib/models/vehicle.dart Normal file
View file

@ -0,0 +1,48 @@
class Vehicle {
final String id;
final String make;
final String model;
final String color;
final String licensePlate;
Vehicle({
required this.id,
required this.make,
required this.model,
required this.color,
required this.licensePlate,
});
String get displayName => '$color $make $model ($licensePlate)';
Vehicle copyWith({
String? make,
String? model,
String? color,
String? licensePlate,
}) {
return Vehicle(
id: id,
make: make ?? this.make,
model: model ?? this.model,
color: color ?? this.color,
licensePlate: licensePlate ?? this.licensePlate,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'make': make,
'model': model,
'color': color,
'licensePlate': licensePlate,
};
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
id: json['id'] as String,
make: json['make'] as String,
model: json['model'] as String,
color: json['color'] as String,
licensePlate: json['licensePlate'] as String,
);
}

View file

@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
/// omit it to create a new one.
class AddEditVehicleScreen extends StatefulWidget {
final Vehicle? vehicle;
const AddEditVehicleScreen({super.key, this.vehicle});
@override
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
}
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _makeController;
late final TextEditingController _modelController;
late final TextEditingController _colorController;
late final TextEditingController _plateController;
bool get _isEditing => widget.vehicle != null;
@override
void initState() {
super.initState();
_makeController = TextEditingController(text: widget.vehicle?.make ?? '');
_modelController = TextEditingController(text: widget.vehicle?.model ?? '');
_colorController = TextEditingController(text: widget.vehicle?.color ?? '');
_plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? '');
}
@override
void dispose() {
_makeController.dispose();
_modelController.dispose();
_colorController.dispose();
_plateController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final appState = context.read<AppState>();
if (_isEditing) {
await appState.updateVehicle(widget.vehicle!.copyWith(
make: _makeController.text.trim(),
model: _modelController.text.trim(),
color: _colorController.text.trim(),
licensePlate: _plateController.text.trim(),
));
} else {
await appState.addVehicle(
make: _makeController.text.trim(),
model: _modelController.text.trim(),
color: _colorController.text.trim(),
licensePlate: _plateController.text.trim(),
);
}
if (mounted) Navigator.of(context).pop();
}
Future<void> _confirmDelete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete vehicle?'),
content: Text(
'This will also delete all fuel entries and receipt photos logged for '
'${widget.vehicle!.displayName}. This cannot be undone.',
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && mounted) {
await context.read<AppState>().deleteVehicle(widget.vehicle!.id);
if (mounted) {
Navigator.of(context)
..pop()
..pop();
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Edit Vehicle' : 'Add Vehicle'),
actions: [
if (_isEditing)
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete vehicle',
onPressed: _confirmDelete,
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _makeController,
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _modelController,
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _colorController,
decoration: const InputDecoration(labelText: 'Color', hintText: 'e.g. Red'),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _plateController,
decoration: const InputDecoration(labelText: 'License Plate'),
textCapitalization: TextCapitalization.characters,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _save,
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
),
],
),
),
);
}
}

View file

@ -0,0 +1,224 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/receipt_parser.dart';
import 'receipt_image_screen.dart';
/// Shown right after a receipt photo is captured and OCR'd. Pre-fills
/// whatever the parser found and lets the user review/correct every field
/// before it's written to the data file, since OCR on a printed receipt is
/// usually good but not perfect.
class ConfirmFuelEntryScreen extends StatefulWidget {
final String vehicleId;
final File imageFile;
final ParsedReceipt parsed;
const ConfirmFuelEntryScreen({
super.key,
required this.vehicleId,
required this.imageFile,
required this.parsed,
});
@override
State<ConfirmFuelEntryScreen> createState() => _ConfirmFuelEntryScreenState();
}
class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _gallonsController;
late final TextEditingController _priceController;
late final TextEditingController _totalController;
DateTime _date = DateTime.now();
bool _saving = false;
@override
void initState() {
super.initState();
_gallonsController = TextEditingController(
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
);
_priceController = TextEditingController(
text: widget.parsed.pricePerGallon?.toStringAsFixed(3) ?? '',
);
_totalController = TextEditingController(
text: widget.parsed.totalCost?.toStringAsFixed(2) ?? '',
);
}
@override
void dispose() {
_gallonsController.dispose();
_priceController.dispose();
_totalController.dispose();
super.dispose();
}
void _calculateTotal() {
final gallons = double.tryParse(_gallonsController.text);
final price = double.tryParse(_priceController.text);
if (gallons != null && price != null) {
setState(() {
_totalController.text = (gallons * price).toStringAsFixed(2);
});
}
}
Future<void> _pickDate() async {
final pickedDate = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime.now().subtract(const Duration(days: 365 * 5)),
lastDate: DateTime.now(),
);
if (pickedDate == null || !mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_date),
);
if (pickedTime == null) return;
setState(() {
_date = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
});
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
await context.read<AppState>().addFuelEntry(
vehicleId: widget.vehicleId,
date: _date,
gallons: double.parse(_gallonsController.text),
pricePerGallon: double.parse(_priceController.text),
totalCost: double.parse(_totalController.text),
receiptImage: widget.imageFile,
);
if (mounted) Navigator.of(context).pop();
} finally {
if (mounted) setState(() => _saving = false);
}
}
String? _requiredDecimal(String? value) {
if (value == null || value.trim().isEmpty) return 'Required';
if (double.tryParse(value) == null) return 'Enter a valid number';
return null;
}
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMMMd().add_jm();
final nothingParsed = widget.parsed.gallons == null &&
widget.parsed.pricePerGallon == null &&
widget.parsed.totalCost == null;
return Scaffold(
appBar: AppBar(title: const Text('Confirm Fuel Entry')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(widget.imageFile, height: 180, fit: BoxFit.cover, width: double.infinity),
),
),
const SizedBox(height: 8),
Text(
'Tap the photo to view it full-screen.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
if (nothingParsed) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: Theme.of(context).colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
"Couldn't automatically read this receipt. Please enter the values manually.",
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
),
),
],
),
),
],
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Date & time'),
subtitle: Text(dateFormat.format(_date)),
trailing: const Icon(Icons.edit_calendar_outlined),
onTap: _pickDate,
),
const SizedBox(height: 8),
TextFormField(
controller: _gallonsController,
decoration: const InputDecoration(labelText: 'Gallons', suffixText: 'gal'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _requiredDecimal,
),
const SizedBox(height: 12),
TextFormField(
controller: _priceController,
decoration: const InputDecoration(labelText: 'Price per gallon', prefixText: '\$'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _requiredDecimal,
),
const SizedBox(height: 12),
TextFormField(
controller: _totalController,
decoration: InputDecoration(
labelText: 'Total cost',
prefixText: '\$',
suffixIcon: IconButton(
icon: const Icon(Icons.calculate_outlined),
tooltip: 'Calculate from gallons × price',
onPressed: _calculateTotal,
),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _requiredDecimal,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save Entry'),
),
],
),
),
);
}
}

View file

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import 'add_edit_vehicle_screen.dart';
import 'settings_screen.dart';
import 'vehicle_detail_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final vehicles = appState.vehicles;
return Scaffold(
appBar: AppBar(
title: const Text('My Vehicles'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
],
),
body: vehicles.isEmpty
? const _EmptyState()
: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: vehicles.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final vehicle = vehicles[index];
final gallons = appState.totalGallonsForVehicle(vehicle.id);
return _VehicleCard(vehicle: vehicle, totalGallons: gallons);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
),
icon: const Icon(Icons.add),
label: const Text('Add Vehicle'),
),
);
}
}
class _VehicleCard extends StatelessWidget {
final Vehicle vehicle;
final double totalGallons;
const _VehicleCard({required this.vehicle, required this.totalGallons});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: CircleAvatar(
child: Text(vehicle.make.isNotEmpty ? vehicle.make[0].toUpperCase() : '?'),
),
title: Text('${vehicle.color} ${vehicle.make} ${vehicle.model}'),
subtitle: Text(
'Plate: ${vehicle.licensePlate}\n${totalGallons.toStringAsFixed(3)} gallons logged',
),
isThreeLine: true,
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_shipping_outlined, size: 64, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 16),
Text(
'No vehicles yet. Add a vehicle to start logging fuel receipts.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}

View file

@ -0,0 +1,27 @@
import 'dart:io';
import 'package:flutter/material.dart';
/// Full-screen, pinch-zoomable view of a saved receipt photo.
class ReceiptImageScreen extends StatelessWidget {
final String imagePath;
const ReceiptImageScreen({super.key, required this.imagePath});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: const Text('Receipt'),
),
body: Center(
child: InteractiveViewer(
child: Image.file(File(imagePath)),
),
),
);
}
}

View file

@ -0,0 +1,105 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _changing = false;
String? _error;
Future<void> _chooseFolder() async {
setState(() => _error = null);
final selectedPath = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Choose a folder for receipts and data',
);
if (selectedPath == null || !mounted) return;
setState(() => _changing = true);
try {
await context.read<AppState>().changeSaveDirectory(selectedPath);
} catch (e) {
setState(() => _error = 'Could not switch to that folder: $e');
} finally {
if (mounted) setState(() => _changing = false);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final saveDir = appState.storage.saveDirectory.path;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Save Location', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Receipt photos and the data file are stored here:',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 4),
SelectableText(
saveDir,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
const SizedBox(height: 16),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
FilledButton.icon(
onPressed: _changing ? null : _chooseFolder,
icon: _changing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.folder_open),
label: const Text('Choose Folder'),
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Summary', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text('${appState.vehicles.length} vehicle(s)'),
Text('${appState.fuelEntries.length} fuel entries'),
Text('${appState.totalGallonsAllVehicles.toStringAsFixed(3)} total gallons logged'),
],
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,188 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../services/app_state.dart';
import '../services/ocr_service.dart';
import '../services/receipt_parser.dart';
import 'add_edit_vehicle_screen.dart';
import 'confirm_fuel_entry_screen.dart';
import 'receipt_image_screen.dart';
class VehicleDetailScreen extends StatelessWidget {
final String vehicleId;
const VehicleDetailScreen({super.key, required this.vehicleId});
Future<void> _captureReceipt(BuildContext context) async {
final picker = ImagePicker();
XFile? photo;
try {
photo = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open camera: $e')),
);
}
return;
}
if (photo == null) return;
final imageFile = File(photo.path);
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
final ocrService = OcrService();
String recognizedText = '';
try {
recognizedText = await ocrService.recognizeText(imageFile);
} catch (_) {
recognizedText = '';
} finally {
ocrService.dispose();
}
final parsed = ReceiptParser.parse(recognizedText);
if (context.mounted) {
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ConfirmFuelEntryScreen(
vehicleId: vehicleId,
imageFile: imageFile,
parsed: parsed,
),
),
);
}
}
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete fuel entry?'),
content: const Text('This will remove the entry and its receipt photo.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && context.mounted) {
await context.read<AppState>().deleteFuelEntry(entry.id);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final vehicle = appState.vehicleById(vehicleId);
if (vehicle == null) {
return const Scaffold(body: Center(child: Text('Vehicle not found')));
}
final entries = appState.entriesForVehicle(vehicleId);
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
final dateFormat = DateFormat.yMMMd().add_jm();
final currencyFormat = NumberFormat.simpleCurrency();
return Scaffold(
appBar: AppBar(
title: Text(vehicle.displayName),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Edit vehicle',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AddEditVehicleScreen(vehicle: vehicle)),
),
),
],
),
body: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Total gallons logged', style: Theme.of(context).textTheme.labelLarge),
Text(
'${totalGallons.toStringAsFixed(3)} gal',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
Expanded(
child: entries.isEmpty
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final entry = entries[index];
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: entry.receiptImagePath != null
? GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(
File(entry.receiptImagePath!),
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
)
: const CircleAvatar(child: Icon(Icons.receipt_long)),
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
subtitle: Text(
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',
),
isThreeLine: true,
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _deleteEntry(context, entry),
),
),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _captureReceipt(context),
icon: const Icon(Icons.camera_alt_outlined),
label: const Text('Log Fuel Receipt'),
),
);
}
}

129
lib/services/app_state.dart Normal file
View file

@ -0,0 +1,129 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'storage_service.dart';
/// Single source of truth for the app's in-memory data (vehicles + fuel
/// entries), backed by [StorageService] for persistence. Screens read from
/// this via Provider and call its mutating methods, which take care of
/// writing through to disk and notifying listeners.
class AppState extends ChangeNotifier {
final StorageService storage = StorageService();
final _uuid = const Uuid();
List<Vehicle> vehicles = [];
List<FuelEntry> fuelEntries = [];
bool isLoading = true;
Future<void> init() async {
await storage.init();
final data = await storage.loadData();
vehicles = data.vehicles;
fuelEntries = data.entries;
isLoading = false;
notifyListeners();
}
Future<void> addVehicle({
required String make,
required String model,
required String color,
required String licensePlate,
}) async {
vehicles.add(Vehicle(
id: _uuid.v4(),
make: make,
model: model,
color: color,
licensePlate: licensePlate,
));
await _persist();
}
Future<void> updateVehicle(Vehicle updated) async {
final index = vehicles.indexWhere((v) => v.id == updated.id);
if (index != -1) {
vehicles[index] = updated;
await _persist();
}
}
Future<void> deleteVehicle(String vehicleId) async {
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) {
await storage.deleteReceiptImage(entry.receiptImagePath);
}
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId);
vehicles.removeWhere((v) => v.id == vehicleId);
await _persist();
}
Future<FuelEntry> addFuelEntry({
required String vehicleId,
required DateTime date,
required double gallons,
required double pricePerGallon,
required double totalCost,
File? receiptImage,
}) async {
final id = _uuid.v4();
String? storedImagePath;
if (receiptImage != null) {
storedImagePath = await storage.storeReceiptImage(receiptImage, id);
}
final entry = FuelEntry(
id: id,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
receiptImagePath: storedImagePath,
);
fuelEntries.add(entry);
await _persist();
return entry;
}
Future<void> deleteFuelEntry(String entryId) async {
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
await storage.deleteReceiptImage(entry.receiptImagePath);
fuelEntries.removeWhere((e) => e.id == entryId);
await _persist();
}
List<FuelEntry> entriesForVehicle(String vehicleId) {
final list = fuelEntries.where((e) => e.vehicleId == vehicleId).toList();
list.sort((a, b) => b.date.compareTo(a.date));
return list;
}
double totalGallonsForVehicle(String vehicleId) => fuelEntries
.where((e) => e.vehicleId == vehicleId)
.fold(0.0, (sum, e) => sum + e.gallons);
double get totalGallonsAllVehicles =>
fuelEntries.fold(0.0, (sum, e) => sum + e.gallons);
Vehicle? vehicleById(String id) {
try {
return vehicles.firstWhere((v) => v.id == id);
} catch (_) {
return null;
}
}
Future<void> changeSaveDirectory(String newPath) async {
await storage.setSaveDirectory(newPath);
notifyListeners();
}
Future<void> _persist() async {
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
notifyListeners();
}
}

View file

@ -0,0 +1,21 @@
import 'dart:io';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
/// Thin wrapper around ML Kit's on-device text recognizer so the rest of
/// the app depends on a single, easily-swappable entry point rather than
/// the ML Kit API directly.
class OcrService {
final TextRecognizer _recognizer =
TextRecognizer(script: TextRecognitionScript.latin);
Future<String> recognizeText(File imageFile) async {
final inputImage = InputImage.fromFile(imageFile);
final result = await _recognizer.processImage(inputImage);
return result.text;
}
void dispose() {
_recognizer.close();
}
}

View file

@ -0,0 +1,105 @@
/// Best-effort values pulled out of OCR'd receipt text. Any field can be
/// null if it couldn't be found, and the confirm screen lets the user fill
/// in or correct whatever the parser got wrong.
class ParsedReceipt {
final double? gallons;
final double? pricePerGallon;
final double? totalCost;
final String rawText;
ParsedReceipt({
this.gallons,
this.pricePerGallon,
this.totalCost,
required this.rawText,
});
}
/// Parses free-form OCR text from a fuel receipt looking for the number of
/// gallons pumped, the price per gallon, and the total amount charged.
///
/// Fuel receipts vary a lot between stations, so this uses a handful of
/// label-based regexes rather than assuming a fixed layout, and falls back
/// to deriving a missing value from the other two when exactly one is
/// missing (total = gallons * price, etc).
class ReceiptParser {
static final _gallonsPatterns = [
RegExp(r'GALLONS?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false),
];
static final _pricePerGallonPatterns = [
RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
caseSensitive: false),
RegExp(r'\bPPG\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
caseSensitive: false),
RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
caseSensitive: false),
];
static final _totalPatterns = [
RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
RegExp(r'SALE\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
RegExp(r'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
RegExp(r'(?<!SUB)\bTOTAL\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2})',
caseSensitive: false),
];
static ParsedReceipt parse(String text) {
final normalized = text.replaceAll(',', '');
final gallons = _firstMatch(_gallonsPatterns, normalized);
final pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
var totalCost = _firstMatch(_totalPatterns, normalized);
final derivedTotal = _deriveMissingValue(
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
);
return ParsedReceipt(
gallons: derivedTotal.gallons,
pricePerGallon: derivedTotal.pricePerGallon,
totalCost: derivedTotal.totalCost,
rawText: text,
);
}
static double? _firstMatch(List<RegExp> patterns, String text) {
for (final pattern in patterns) {
final match = pattern.firstMatch(text);
if (match != null) {
return double.tryParse(match.group(1)!);
}
}
return null;
}
static ({double? gallons, double? pricePerGallon, double? totalCost})
_deriveMissingValue({
required double? gallons,
required double? pricePerGallon,
required double? totalCost,
}) {
final missingCount =
[gallons, pricePerGallon, totalCost].where((v) => v == null).length;
// Only safe to derive when exactly one of the three is missing.
if (missingCount != 1) {
return (gallons: gallons, pricePerGallon: pricePerGallon, totalCost: totalCost);
}
if (totalCost == null && gallons != null && pricePerGallon != null) {
totalCost = double.parse((gallons * pricePerGallon).toStringAsFixed(2));
} else if (gallons == null && totalCost != null && pricePerGallon != null && pricePerGallon > 0) {
gallons = double.parse((totalCost / pricePerGallon).toStringAsFixed(3));
} else if (pricePerGallon == null && totalCost != null && gallons != null && gallons > 0) {
pricePerGallon = double.parse((totalCost / gallons).toStringAsFixed(3));
}
return (gallons: gallons, pricePerGallon: pricePerGallon, totalCost: totalCost);
}
}

View file

@ -0,0 +1,128 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
/// Owns the on-disk layout for the app: a `data.json` file holding vehicles
/// and fuel entries, plus a `receipts/` subfolder holding receipt photos.
/// The parent directory containing both is user-configurable (see
/// [setSaveDirectory]) and persisted across launches via SharedPreferences.
class StorageService {
static const _prefsKey = 'save_directory_path';
static const _dataFileName = 'fuel_tax_data.json';
static const _receiptsFolderName = 'receipts';
late Directory _saveDirectory;
Directory get saveDirectory => _saveDirectory;
Directory get receiptsDirectory =>
Directory(p.join(_saveDirectory.path, _receiptsFolderName));
File get _dataFile => File(p.join(_saveDirectory.path, _dataFileName));
/// Must be called once before any other method. Loads the previously
/// chosen save directory, falling back to the app's own documents
/// directory the first time the app runs.
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final storedPath = prefs.getString(_prefsKey);
if (storedPath != null && await Directory(storedPath).exists()) {
_saveDirectory = Directory(storedPath);
} else {
final docsDir = await getApplicationDocumentsDirectory();
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
}
await _saveDirectory.create(recursive: true);
await receiptsDirectory.create(recursive: true);
}
Future<({List<Vehicle> vehicles, List<FuelEntry> entries})> loadData() async {
if (!await _dataFile.exists()) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
final raw = await _dataFile.readAsString();
if (raw.trim().isEmpty) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
final json = jsonDecode(raw) as Map<String, dynamic>;
final vehicles = (json['vehicles'] as List<dynamic>? ?? [])
.map((v) => Vehicle.fromJson(v as Map<String, dynamic>))
.toList();
final entries = (json['fuelEntries'] as List<dynamic>? ?? [])
.map((e) => FuelEntry.fromJson(e as Map<String, dynamic>))
.toList();
return (vehicles: vehicles, entries: entries);
}
Future<void> saveData({
required List<Vehicle> vehicles,
required List<FuelEntry> entries,
}) async {
final json = {
'vehicles': vehicles.map((v) => v.toJson()).toList(),
'fuelEntries': entries.map((e) => e.toJson()).toList(),
};
await _dataFile.writeAsString(const JsonEncoder.withIndent(' ').convert(json));
}
/// Copies a captured receipt image into the receipts folder and returns
/// the path it was stored at.
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
final ext = p.extension(sourceImage.path);
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
final copied = await sourceImage.copy(destPath);
return copied.path;
}
Future<void> deleteReceiptImage(String? path) async {
if (path == null) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
/// Changes where the data file and receipt images live, moving any
/// existing data/images from the old location into the new one.
Future<void> setSaveDirectory(String newPath) async {
final newDir = Directory(newPath);
await newDir.create(recursive: true);
final newReceiptsDir = Directory(p.join(newPath, _receiptsFolderName));
await newReceiptsDir.create(recursive: true);
final oldDataFile = _dataFile;
final oldReceiptsDir = receiptsDirectory;
if (await oldDataFile.exists() &&
p.equals(oldDataFile.path, p.join(newPath, _dataFileName)) == false) {
await oldDataFile.copy(p.join(newPath, _dataFileName));
}
if (await oldReceiptsDir.exists()) {
await for (final entity in oldReceiptsDir.list()) {
if (entity is File) {
final destPath = p.join(newReceiptsDir.path, p.basename(entity.path));
if (!p.equals(entity.path, destPath)) {
await entity.copy(destPath);
}
}
}
}
_saveDirectory = newDir;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, newPath);
}
}

714
pubspec.lock Normal file
View file

@ -0,0 +1,714 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
url: "https://pub.dev"
source: hosted
version: "0.3.5+4"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev"
source: hosted
version: "0.7.14"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
url: "https://pub.dev"
source: hosted
version: "10.3.10"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
google_mlkit_commons:
dependency: transitive
description:
name: google_mlkit_commons
sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
google_mlkit_text_recognition:
dependency: "direct main"
description:
name: google_mlkit_text_recognition
sha256: "10a8d1a64fa21efda89514f6df88bbb7885be3ae1bfb7a6d229132d802a20d22"
url: "https://pub.dev"
source: hosted
version: "0.15.1"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.dev"
source: hosted
version: "1.2.3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
url: "https://pub.dev"
source: hosted
version: "0.8.13+19"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.3"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
url: "https://pub.dev"
source: hosted
version: "2.4.27"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.44.0"

124
pubspec.yaml Normal file
View file

@ -0,0 +1,124 @@
name: fuel_tax_tracker
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.12.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# State management
provider: ^6.1.2
# Camera capture
image_picker: ^1.1.2
# On-device OCR text recognition
google_mlkit_text_recognition: ^0.15.0
# Directory picker for configurable save location.
# Pinned below 11.x: file_picker 11.0.0-11.0.3 skip applying the Kotlin
# Gradle plugin under AGP 9+ assuming AGP's built-in Kotlin support covers
# it, but that isn't actually wired up yet for library modules in this
# Flutter/AGP combo, so FilePickerPlugin.kt never gets compiled. 10.3.11
# still compiles against compileSdk 36 (matches flutter.compileSdkVersion,
# satisfying flutter_plugin_android_lifecycle's requirement) but applies
# its own Kotlin plugin unconditionally, which works. (10.3.11 itself is
# retracted on pub.dev, hence pinning to 10.3.10.)
file_picker: 10.3.10
# App sandbox paths (default save location, temp files)
path_provider: ^2.1.5
# Persist small settings like chosen save directory
shared_preferences: ^2.3.4
# Unique IDs for vehicles/fuel entries
uuid: ^4.5.1
# Date formatting
intl: ^0.20.2
# Path joining/manipulation helpers
path: ^1.9.0
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

View file

@ -0,0 +1,72 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/receipt_parser.dart';
void main() {
group('ReceiptParser', () {
test('parses a typical gas station receipt', () {
const receipt = '''
SHELL OIL 12345
123 MAIN ST
PUMP 04
GALLONS 12.345
PRICE/GAL \$3.599
FUEL TOTAL \$44.44
THANK YOU
''';
final result = ReceiptParser.parse(receipt);
expect(result.gallons, 12.345);
expect(result.pricePerGallon, 3.599);
expect(result.totalCost, 44.44);
});
test('does not confuse SUBTOTAL with TOTAL', () {
const receipt = '''
SUBTOTAL 10.00
TAX 0.50
TOTAL 10.50
''';
final result = ReceiptParser.parse(receipt);
expect(result.totalCost, 10.50);
});
test('derives total when gallons and price per gallon are known', () {
const receipt = '''
GALLONS 10.000
PPG 3.500
''';
final result = ReceiptParser.parse(receipt);
expect(result.gallons, 10.0);
expect(result.pricePerGallon, 3.5);
expect(result.totalCost, 35.0);
});
test('derives gallons when total and price per gallon are known', () {
const receipt = '''
PRICE PER GALLON \$4.000
TOTAL \$20.00
''';
final result = ReceiptParser.parse(receipt);
expect(result.pricePerGallon, 4.0);
expect(result.totalCost, 20.0);
expect(result.gallons, 5.0);
});
test('returns nulls when nothing recognizable is present', () {
const receipt = 'THANK YOU FOR YOUR PURCHASE';
final result = ReceiptParser.parse(receipt);
expect(result.gallons, isNull);
expect(result.pricePerGallon, isNull);
expect(result.totalCost, isNull);
});
});
}

24
test/widget_test.dart Normal file
View file

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/screens/home_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
void main() {
testWidgets('Shows empty state and add-vehicle action when there are no vehicles',
(WidgetTester tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: HomeScreen()),
),
);
expect(find.text('My Vehicles'), findsOneWidget);
expect(find.text('Add Vehicle'), findsOneWidget);
expect(find.textContaining('No vehicles yet'), findsOneWidget);
});
}