A whole bunch of changes
|
|
@ -64,5 +64,13 @@
|
|||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- Lets url_launcher open the Missouri DOR forms link on the
|
||||
Reports tab — without this, canLaunchUrl/launchUrl can't see
|
||||
any browser app on Android 11+ due to package visibility
|
||||
restrictions. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
|
|||
BIN
android/app/src/main/res/drawable-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
android/app/src/main/res/drawable-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
android/app/src/main/res/drawable-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
android/app/src/main/res/drawable-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
android/app/src/main/res/drawable-night-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
android/app/src/main/res/drawable-night-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
android/app/src/main/res/drawable-night-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
android/app/src/main/res/drawable-night-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
android/app/src/main/res/drawable-night-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 112 KiB |
BIN
android/app/src/main/res/drawable-night-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 232 KiB |
BIN
android/app/src/main/res/drawable-night-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 385 KiB |
BIN
android/app/src/main/res/drawable-night-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
android/app/src/main/res/drawable-night/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/drawable-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,9 @@
|
|||
<?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> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
|
|||
BIN
android/app/src/main/res/drawable-xhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 385 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
android/app/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,9 @@
|
|||
<?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> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 46 KiB |
22
android/app/src/main/res/values-night-v31/styles.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?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">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#101110</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#101110</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>
|
||||
|
|
@ -5,6 +5,10 @@
|
|||
<!-- 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>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</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
|
||||
|
|
|
|||
22
android/app/src/main/res/values-v31/styles.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?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">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#24603A</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#24603A</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>
|
||||
|
|
@ -5,6 +5,10 @@
|
|||
<!-- 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>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</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
|
||||
|
|
|
|||
BIN
assets/icon/app_icon.png
Normal file
|
After Width: | Height: | Size: 803 KiB |
BIN
assets/icon/hero_icon.png
Normal file
|
After Width: | Height: | Size: 530 KiB |
BIN
assets/splash/splash_logo.png
Normal file
|
After Width: | Height: | Size: 697 KiB |
BIN
assets/splash/splash_logo_android12.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
assets/vin_help/door_jamb.jpg
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
assets/vin_help/windshield.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -441,7 +441,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
@ -498,7 +498,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
|
|||
|
|
@ -1,122 +1 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
{"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":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"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":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@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"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 725 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 32 KiB |
22
ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "background.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "darkbackground.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,23 +1,56 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 421 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png
vendored
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 421 KiB |
|
|
@ -16,13 +16,19 @@
|
|||
<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>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||
<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"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
|
|
@ -32,6 +38,7 @@
|
|||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
<image name="LaunchImage" width="1024" height="1024"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?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>
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
|
|
@ -106,5 +106,7 @@
|
|||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/main_shell.dart';
|
||||
import 'services/app_state.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
|
|
@ -16,12 +16,15 @@ class FuelTaxTrackerApp extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => AppState()..init(),
|
||||
child: MaterialApp(
|
||||
child: Consumer<AppState>(
|
||||
builder: (context, appState, _) => MaterialApp(
|
||||
title: 'Fuel Tax Tracker',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: appState.themeMode,
|
||||
home: const AppRoot(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -64,7 +67,7 @@ class AppRoot extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
return const HomeScreen();
|
||||
return const MainShell();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,19 @@ import '../widgets/image_source_sheet.dart';
|
|||
import 'vehicle_detail_screen.dart';
|
||||
|
||||
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
|
||||
/// omit it to create a new one.
|
||||
/// omit it to create a new one. [promptToCreateForReceipt] is set when this
|
||||
/// screen was reached because the user tried to log a receipt with no
|
||||
/// vehicle to attach it to yet — it shows an explanatory dialog on arrival
|
||||
/// so that's clear, rather than silently landing on the add-vehicle form.
|
||||
class AddEditVehicleScreen extends StatefulWidget {
|
||||
final Vehicle? vehicle;
|
||||
final bool promptToCreateForReceipt;
|
||||
|
||||
const AddEditVehicleScreen({super.key, this.vehicle});
|
||||
const AddEditVehicleScreen({
|
||||
super.key,
|
||||
this.vehicle,
|
||||
this.promptToCreateForReceipt = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
|
||||
|
|
@ -39,6 +47,24 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
super.initState();
|
||||
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
|
||||
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
|
||||
|
||||
if (widget.promptToCreateForReceipt) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: const Text('Create a vehicle to attach receipt'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -48,8 +74,46 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void _showVinLocationHelp() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Where to find the VIN'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"It's usually on a sticker in the driver's side door jamb, or on a "
|
||||
'small plate at the base of the windshield.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset('assets/vin_help/door_jamb.jpg'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset('assets/vin_help/windshield.jpg'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _scanVin() async {
|
||||
final source = await chooseImageSource(context);
|
||||
final source = await chooseImageSource(
|
||||
context,
|
||||
heading: 'VIN From',
|
||||
onInfoTap: _showVinLocationHelp,
|
||||
);
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
final picker = ImagePicker();
|
||||
|
|
@ -89,13 +153,35 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
await _checkForExistingVin();
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Couldn't read a VIN from that photo. Please enter it manually."),
|
||||
SnackBar(
|
||||
content: const Text("Couldn't read a VIN from that photo. Please enter it manually."),
|
||||
duration: const Duration(seconds: 8),
|
||||
action: recognizedText.trim().isEmpty
|
||||
? null
|
||||
// Lets you see exactly what the on-device OCR read, rather
|
||||
// than only knowing "no VIN was found in it" — the
|
||||
// difference between "OCR misread a character" and "OCR
|
||||
// read it fine but the VIN pattern itself needs a fix"
|
||||
// isn't visible any other way.
|
||||
: SnackBarAction(label: 'Show Text', onPressed: () => _showRecognizedText(recognizedText)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showRecognizedText(String text) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Recognized Text'),
|
||||
content: SingleChildScrollView(child: SelectableText(text)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Field-level counterpart to [_checkForExistingVin]'s dialog: catches
|
||||
/// "Required" as before, plus flags the field itself as invalid when the
|
||||
/// VIN belongs to another active vehicle — excluding this vehicle's own
|
||||
|
|
@ -160,24 +246,30 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
_error = null;
|
||||
});
|
||||
|
||||
final vin = _vinController.text.trim();
|
||||
final nickname = _nicknameController.text.trim();
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
try {
|
||||
if (_isEditing) {
|
||||
await appState.updateVehicle(widget.vehicle!.copyWith(
|
||||
vin: _vinController.text.trim(),
|
||||
vin: vin,
|
||||
nickname: nickname.isEmpty ? null : nickname,
|
||||
clearNickname: nickname.isEmpty,
|
||||
));
|
||||
} else {
|
||||
await appState.addVehicle(
|
||||
vin: _vinController.text.trim(),
|
||||
nickname: nickname.isEmpty ? null : nickname,
|
||||
);
|
||||
await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname);
|
||||
}
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
if (mounted) {
|
||||
// Reached from "log a receipt with no vehicle yet" — once the
|
||||
// vehicle exists, continue straight into the receipt capture flow
|
||||
// the user was actually trying to do, rather than just landing
|
||||
// back on an (again) empty Receipts tab. The caller does the
|
||||
// continuing; this just hands back which vehicle to do it for.
|
||||
final continueToReceipt = !_isEditing && widget.promptToCreateForReceipt;
|
||||
Navigator.of(context).pop(continueToReceipt ? appState.vehicleByVin(vin)?.id : null);
|
||||
}
|
||||
} on DuplicateVinException catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
|
|
|
|||
550
lib/screens/data_settings_screen.dart
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/cloud/cloud_storage_provider.dart';
|
||||
import 'cloud_folder_browser_screen.dart';
|
||||
|
||||
/// "Data" settings submenu, reached from [SettingsScreen]: cloud storage
|
||||
/// connection, the two photo-handling switches, and an Advanced section
|
||||
/// (stale sync lock timeout, plus the destructive purge actions — grouped
|
||||
/// there rather than given their own top-level "Danger Zone" so they sit
|
||||
/// behind the same disclosure as other rarely-touched settings).
|
||||
class DataSettingsScreen extends StatefulWidget {
|
||||
const DataSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DataSettingsScreen> createState() => _DataSettingsScreenState();
|
||||
}
|
||||
|
||||
class _DataSettingsScreenState extends State<DataSettingsScreen> {
|
||||
static final _dateFormat = DateFormat.yMMMd();
|
||||
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
DateTime? _purgeRangeStart;
|
||||
DateTime? _purgeRangeEnd;
|
||||
String? _purgeRangeError;
|
||||
|
||||
Future<void> _connect(CloudProviderId id) async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectProvider(id);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not sign in: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectManual(CloudProviderId id, String providerName) async {
|
||||
final credentials = await showDialog<_WebDavCredentials>(
|
||||
context: context,
|
||||
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
|
||||
);
|
||||
if (credentials == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectProviderWithCredentials(
|
||||
id,
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not connect: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().disconnectCloud();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _chooseFolder() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _syncNow() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().syncNow();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared warning-dialog shell for both purge actions — [message] is the
|
||||
/// caller's job to make specific and unambiguous, since this is the only
|
||||
/// thing standing between the user and an unrecoverable delete.
|
||||
Future<bool> _confirmPurge({required String title, required String message}) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||
),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
/// Extra context appended to a purge warning when a cloud account is
|
||||
/// connected — a purely local delete would otherwise just get silently
|
||||
/// re-imported from the remote copy on the very next sync, which isn't
|
||||
/// obvious from the app's normal behavior.
|
||||
String _cloudSyncCaveat(AppState appState) {
|
||||
if (!appState.isCloudConnected) return '';
|
||||
return '\n\nThis device is connected to ${appState.activeProvider!.displayName}. The '
|
||||
'deletion will be pushed there on the next sync, same as any other delete.';
|
||||
}
|
||||
|
||||
Future<void> _purgeAllData() async {
|
||||
final appState = context.read<AppState>();
|
||||
final confirmed = await _confirmPurge(
|
||||
title: 'Purge all data?',
|
||||
message: 'This permanently deletes every vehicle, fuel entry, and receipt photo on '
|
||||
"this device — starting completely fresh. This can't be undone."
|
||||
'${_cloudSyncCaveat(appState)}',
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await appState.purgeAllData();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('All data purged.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickPurgeRangeDate({required bool isStart}) async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: (isStart ? _purgeRangeStart : _purgeRangeEnd) ?? now,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: now,
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() {
|
||||
_purgeRangeError = null;
|
||||
if (isStart) {
|
||||
_purgeRangeStart = picked;
|
||||
} else {
|
||||
_purgeRangeEnd = picked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _purgeDataInRange() async {
|
||||
final start = _purgeRangeStart;
|
||||
final end = _purgeRangeEnd;
|
||||
if (start == null || end == null) return;
|
||||
if (end.isBefore(start)) {
|
||||
setState(() => _purgeRangeError = 'End date must be on or after the start date.');
|
||||
return;
|
||||
}
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
final confirmed = await _confirmPurge(
|
||||
title: 'Purge data in range?',
|
||||
message: 'This permanently deletes every fuel entry and receipt photo dated from '
|
||||
'${_dateFormat.format(start)} to ${_dateFormat.format(end)}, across all '
|
||||
"vehicles. Vehicles themselves aren't affected. This can't be undone."
|
||||
'${_cloudSyncCaveat(appState)}',
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await appState.purgeFuelEntriesInRange(start, end);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_purgeRangeStart = null;
|
||||
_purgeRangeEnd = null;
|
||||
_purgeRangeError = null;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Fuel entries in range purged.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Data')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (!appState.isCloudConnected) ...[
|
||||
Text(
|
||||
'Connect a cloud storage account to share vehicles and fuel '
|
||||
'receipts with other people, and to keep a backup off this device.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null) ...[
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final provider in appState.availableProviders)
|
||||
FilledButton.icon(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => provider is ManualCredentialCloudStorageProvider
|
||||
? _connectManual(provider.id, provider.displayName)
|
||||
: _connect(provider.id),
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text('Connect ${provider.displayName}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
appState.cloudFolderPath == null
|
||||
? 'No folder selected yet.'
|
||||
: 'Folder: ${appState.cloudFolderPath}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _chooseFolder,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: Text(appState.cloudFolderPath == null
|
||||
? 'Choose Folder'
|
||||
: 'Change Folder'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: (_busy || appState.cloudFolderPath == null)
|
||||
? null
|
||||
: _syncNow,
|
||||
icon: appState.isSyncing
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.sync),
|
||||
label: const Text('Sync Now'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _disconnect,
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (appState.lastSyncedAt != null)
|
||||
Text(
|
||||
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (appState.lastSyncError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Last sync failed: ${appState.lastSyncError}',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Keep photos on this phone after syncing'),
|
||||
subtitle: const Text(
|
||||
'Otherwise, a receipt photo is removed from this device once '
|
||||
"it's safely uploaded to the cloud.",
|
||||
),
|
||||
value: appState.keepReceiptPhotosLocally,
|
||||
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Keep max quality images'),
|
||||
subtitle: const Text(
|
||||
'Otherwise, receipt photos are downscaled to a reasonable size '
|
||||
'before storing — smaller cloud storage and faster syncs, with '
|
||||
'no loss of legibility for a printed receipt.',
|
||||
),
|
||||
value: appState.keepMaxQualityReceiptPhotos,
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ExpansionTile(
|
||||
title: const Text('Advanced'),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"If another device disconnects mid-sync without releasing its lock, "
|
||||
"this is how long to wait before treating it as abandoned and clearing "
|
||||
"it so sync can continue.",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Slider(
|
||||
value: appState.staleLockMinutes.toDouble(),
|
||||
min: minStaleLockMinutes.toDouble(),
|
||||
max: maxStaleLockMinutes.toDouble(),
|
||||
divisions: maxStaleLockMinutes - minStaleLockMinutes,
|
||||
label: '${appState.staleLockMinutes} min',
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setStaleLockMinutes(value.round()),
|
||||
),
|
||||
Text('${appState.staleLockMinutes} minute(s)'),
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
'Danger Zone',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Purge All Data', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Deletes every vehicle, fuel entry, and receipt photo on this device '
|
||||
'and starts completely fresh.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _purgeAllData,
|
||||
icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
|
||||
label: Text(
|
||||
'Purge All Data',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text('Purge Data by Date Range', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Deletes fuel entries and receipt photos dated within a range, across '
|
||||
'all vehicles. Vehicles themselves are kept.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: true),
|
||||
child: Text(
|
||||
_purgeRangeStart == null
|
||||
? 'Start date'
|
||||
: _dateFormat.format(_purgeRangeStart!),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: false),
|
||||
child: Text(
|
||||
_purgeRangeEnd == null ? 'End date' : _dateFormat.format(_purgeRangeEnd!),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_purgeRangeError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_purgeRangeError!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: (_busy || _purgeRangeStart == null || _purgeRangeEnd == null)
|
||||
? null
|
||||
: _purgeDataInRange,
|
||||
icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
|
||||
label: Text(
|
||||
'Purge Range',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WebDavCredentials {
|
||||
final String serverUrl;
|
||||
final String username;
|
||||
final String password;
|
||||
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
|
||||
}
|
||||
|
||||
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
|
||||
/// needs, since (unlike the OAuth providers) there's no browser flow to
|
||||
/// gather these instead.
|
||||
class _WebDavCredentialsDialog extends StatefulWidget {
|
||||
final String providerName;
|
||||
const _WebDavCredentialsDialog({required this.providerName});
|
||||
|
||||
@override
|
||||
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
|
||||
}
|
||||
|
||||
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _serverController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
Navigator.of(context).pop(_WebDavCredentials(
|
||||
serverUrl: _serverController.text.trim(),
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Connect ${widget.providerName}'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _serverController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(labelText: 'Username'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Connect'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
186
lib/screens/edit_fuel_entry_screen.dart
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../widgets/receipt_thumbnail.dart';
|
||||
|
||||
/// Lets the user correct the *data* logged about a receipt (date, gallons,
|
||||
/// price/gal, total cost) — not the receipt photo itself, which is shown
|
||||
/// read-only up top (tap it to view full-screen, same as everywhere else
|
||||
/// it's shown) and can't be replaced from here.
|
||||
class EditFuelEntryScreen extends StatefulWidget {
|
||||
final FuelEntry entry;
|
||||
|
||||
const EditFuelEntryScreen({super.key, required this.entry});
|
||||
|
||||
@override
|
||||
State<EditFuelEntryScreen> createState() => _EditFuelEntryScreenState();
|
||||
}
|
||||
|
||||
class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _gallonsController;
|
||||
late final TextEditingController _priceController;
|
||||
late final TextEditingController _totalController;
|
||||
late DateTime _date;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_date = widget.entry.date;
|
||||
_gallonsController = TextEditingController(text: widget.entry.gallons.toStringAsFixed(3));
|
||||
_priceController = TextEditingController(text: widget.entry.pricePerGallon.toStringAsFixed(3));
|
||||
_totalController = TextEditingController(text: widget.entry.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 {
|
||||
// initialDate must fall within [firstDate, lastDate] or the picker
|
||||
// throws — widen the bounds to cover _date in case it's already
|
||||
// outside the normal 5-years-back-to-today window.
|
||||
final today = DateTime.now();
|
||||
final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
|
||||
? _date
|
||||
: today.subtract(const Duration(days: 365 * 5));
|
||||
final lastDate = _date.isAfter(today) ? _date : today;
|
||||
|
||||
final pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
);
|
||||
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>().updateFuelEntry(
|
||||
entryId: widget.entry.id,
|
||||
date: _date,
|
||||
gallons: double.parse(_gallonsController.text),
|
||||
pricePerGallon: double.parse(_priceController.text),
|
||||
totalCost: double.parse(_totalController.text),
|
||||
);
|
||||
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();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Edit Fuel Entry')),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Center(
|
||||
child: ReceiptThumbnail(entry: widget.entry, size: 180),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap the photo to view it full-screen. The photo itself can\'t '
|
||||
"be changed here — only the data below can.",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
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 Changes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,10 @@ import 'package:provider/provider.dart';
|
|||
import '../models/vehicle.dart';
|
||||
import '../services/app_state.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'report_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'vehicle_detail_screen.dart';
|
||||
|
||||
/// The "Vehicles" tab body — Settings and Reports are reached via the
|
||||
/// bottom nav now (see [MainShell]), not from icons here.
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
|
|
@ -18,20 +18,14 @@ class HomeScreen extends StatelessWidget {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('My Vehicles'),
|
||||
title: const Text('Vehicles'),
|
||||
actionsPadding: const EdgeInsets.only(right: 20),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.summarize_outlined),
|
||||
tooltip: 'Fuel Report',
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'Add Vehicle',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const ReportScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -48,13 +42,6 @@ class HomeScreen extends StatelessWidget {
|
|||
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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -72,19 +59,44 @@ class _VehicleCard extends StatelessWidget {
|
|||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
|
||||
title: Text(vehicle.displayLabel),
|
||||
// Only repeat the VIN here when the title is already showing the
|
||||
// nickname instead — otherwise the title (falling back to the VIN
|
||||
// when there's no nickname) would show it twice.
|
||||
subtitle: Text(hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine),
|
||||
isThreeLine: hasNickname,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
// Row cross-axis defaults to center, so the chevron sits
|
||||
// vertically centered against the title+subtitle block even when
|
||||
// the nickname line makes that block taller — ListTile's
|
||||
// isThreeLine forces leading/trailing to the top instead, per
|
||||
// Material spec, which is what pinned it to the top before.
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(vehicle.displayLabel, style: Theme.of(context).textTheme.titleMedium),
|
||||
// Only repeat the VIN here when the title is already
|
||||
// showing the nickname instead — otherwise the title
|
||||
// (falling back to the VIN when there's no nickname)
|
||||
// would show it twice.
|
||||
Text(
|
||||
hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
91
lib/screens/main_shell.dart
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'home_screen.dart';
|
||||
import 'receipts_screen.dart';
|
||||
import 'report_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
/// App root once loaded: a persistent bottom nav bar over the four main
|
||||
/// sections — Receipts (all vehicles), Vehicles (the vehicle list),
|
||||
/// Reports, and Settings. Each tab keeps its own [Scaffold]/[AppBar]; this
|
||||
/// shell only owns the [PageView]/[NavigationBar] and which tab is
|
||||
/// selected.
|
||||
///
|
||||
/// A right-to-left swipe on the body advances to the next tab (and
|
||||
/// left-to-right goes back) with the same sliding animation tapping a
|
||||
/// [NavigationDestination] uses — both go through [_goToTab], which is the
|
||||
/// only thing that moves [_pageController]. [AutomaticKeepAliveClientMixin]
|
||||
/// on the three stateful tab screens (Receipts/Report/Settings) is what
|
||||
/// keeps each tab's state — scroll position, toggles, in-progress form
|
||||
/// fields — alive when [PageView] builds/tears down pages outside its
|
||||
/// cache extent, the same guarantee the previous [IndexedStack]-based
|
||||
/// version gave "for free".
|
||||
class MainShell extends StatefulWidget {
|
||||
const MainShell({super.key});
|
||||
|
||||
@override
|
||||
State<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends State<MainShell> {
|
||||
int _index = 0;
|
||||
final _pageController = PageController();
|
||||
|
||||
static const _tabs = [
|
||||
ReceiptsScreen(),
|
||||
HomeScreen(),
|
||||
ReportScreen(),
|
||||
SettingsScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _goToTab(int index) {
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 280),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) => setState(() => _index = index),
|
||||
children: _tabs,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: _goToTab,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
selectedIcon: Icon(Icons.receipt_long),
|
||||
label: 'Receipts',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.directions_car_outlined),
|
||||
selectedIcon: Icon(Icons.directions_car),
|
||||
label: 'Vehicles',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.summarize_outlined),
|
||||
selectedIcon: Icon(Icons.summarize),
|
||||
label: 'Reports',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/screens/receipt_detail_screen.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../widgets/receipt_thumbnail.dart';
|
||||
import 'edit_fuel_entry_screen.dart';
|
||||
|
||||
/// The single place to view a receipt's photo and its logged data
|
||||
/// together, with edit/delete for the entry — reached by tapping a receipt
|
||||
/// row on either the Receipts tab or a vehicle's detail page. Looks up
|
||||
/// [entryId] fresh from [AppState] on every build (rather than taking a
|
||||
/// [FuelEntry] directly) so it reflects an edit made via
|
||||
/// [EditFuelEntryScreen] as soon as that screen is popped back to this one.
|
||||
class ReceiptDetailScreen extends StatelessWidget {
|
||||
final String entryId;
|
||||
|
||||
const ReceiptDetailScreen({super.key, required this.entryId});
|
||||
|
||||
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);
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final entry = appState.fuelEntryById(entryId);
|
||||
|
||||
if (entry == null) {
|
||||
return const Scaffold(body: Center(child: Text('Receipt not found')));
|
||||
}
|
||||
|
||||
final vehicle = appState.vehicleById(entry.vehicleId);
|
||||
final dateFormat = DateFormat.yMMMd().add_jm();
|
||||
final currencyFormat = NumberFormat.simpleCurrency();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Receipt'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Edit fuel entry',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => EditFuelEntryScreen(entry: entry)),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Delete fuel entry',
|
||||
onPressed: () => _deleteEntry(context, entry),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Center(child: ReceiptThumbnail(entry: entry, size: 240)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap the photo to view it full-screen.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (vehicle != null) ...[
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.directions_car_outlined),
|
||||
title: Text(vehicle.displayLabel),
|
||||
subtitle: Text('VIN: ${vehicle.vin}'),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
],
|
||||
_DetailRow(label: 'Date & time', value: dateFormat.format(entry.date)),
|
||||
_DetailRow(label: 'Gallons', value: '${entry.gallons.toStringAsFixed(3)} gal'),
|
||||
_DetailRow(label: 'Price per gallon', value: currencyFormat.format(entry.pricePerGallon)),
|
||||
_DetailRow(label: 'Total cost', value: currencyFormat.format(entry.totalCost)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _DetailRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
Text(value, style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
332
lib/screens/receipts_screen.dart
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/hero_banner.dart';
|
||||
import '../widgets/receipt_capture.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'receipt_detail_screen.dart';
|
||||
|
||||
/// The three summary periods selectable below the hero totals — the
|
||||
/// totals and the "Recent Activity" list below are both filtered to
|
||||
/// whichever one is currently selected.
|
||||
enum _DateRange {
|
||||
currentMonth('Current Month'),
|
||||
last3Months('Last 3 Months'),
|
||||
yearToDate('Year to Date');
|
||||
|
||||
final String label;
|
||||
const _DateRange(this.label);
|
||||
|
||||
/// Inclusive start of this range, relative to [now]. There's no
|
||||
/// explicit end — an entry just needs to not be *before* this to count,
|
||||
/// since receipts are never dated in the future in practice.
|
||||
DateTime startDate(DateTime now) {
|
||||
switch (this) {
|
||||
case _DateRange.currentMonth:
|
||||
return DateTime(now.year, now.month, 1);
|
||||
case _DateRange.last3Months:
|
||||
// Current month plus the two before it, from the 1st of the
|
||||
// earliest — DateTime normalizes a zero/negative month across a
|
||||
// year boundary correctly (e.g. month 0 becomes December prior year).
|
||||
return DateTime(now.year, now.month - 2, 1);
|
||||
case _DateRange.yearToDate:
|
||||
return DateTime(now.year, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The "Receipts" tab: every fuel entry across every vehicle, newest
|
||||
/// first, styled after the theme mockups' receipt-focused layout — a
|
||||
/// hero total, a date-range filter, then a flat, divided list of receipts
|
||||
/// (date + amount + which vehicle) for that range, each tapping straight
|
||||
/// through to that receipt's photo rather than to the vehicle it belongs to.
|
||||
class ReceiptsScreen extends StatefulWidget {
|
||||
const ReceiptsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ReceiptsScreen> createState() => _ReceiptsScreenState();
|
||||
}
|
||||
|
||||
class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAliveClientMixin {
|
||||
_DateRange _range = _DateRange.yearToDate;
|
||||
|
||||
// Keeps this tab's state (the selected date range) alive when MainShell's
|
||||
// PageView builds/tears down tabs outside its cache extent as the user
|
||||
// swipes between them.
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
Future<void> _addReceipt(BuildContext context) async {
|
||||
final vehicles = context.read<AppState>().vehicles;
|
||||
if (vehicles.isEmpty) {
|
||||
final newVehicleId = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AddEditVehicleScreen(promptToCreateForReceipt: true),
|
||||
),
|
||||
);
|
||||
// Creating the vehicle was only ever a means to an end here — continue
|
||||
// straight into the receipt capture the user actually came here for,
|
||||
// rather than dropping them back on a Receipts tab that's still empty.
|
||||
if (newVehicleId != null && context.mounted) {
|
||||
await captureReceiptForVehicle(context, newVehicleId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (vehicles.length == 1) {
|
||||
await captureReceiptForVehicle(context, vehicles.first.id);
|
||||
return;
|
||||
}
|
||||
|
||||
final chosen = await showModalBottomSheet<Vehicle>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text('Which vehicle is this receipt for?'),
|
||||
),
|
||||
for (final vehicle in vehicles)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.directions_car_outlined),
|
||||
title: Text(vehicle.displayLabel),
|
||||
onTap: () => Navigator.of(context).pop(vehicle),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (chosen != null && context.mounted) {
|
||||
await captureReceiptForVehicle(context, chosen.id);
|
||||
}
|
||||
}
|
||||
|
||||
void _openReceipt(BuildContext context, FuelEntry entry) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => ReceiptDetailScreen(entryId: entry.id)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final appState = context.watch<AppState>();
|
||||
final rangeStart = _range.startDate(DateTime.now());
|
||||
final entries = appState.fuelEntries.where((e) => !e.date.isBefore(rangeStart)).toList()
|
||||
..sort((a, b) => b.date.compareTo(a.date));
|
||||
final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
|
||||
final totalGallons = entries.fold(0.0, (sum, e) => sum + e.gallons);
|
||||
final dateFormat = DateFormat.yMMMd();
|
||||
final currencyFormat = NumberFormat.simpleCurrency();
|
||||
final gallonsFormat = NumberFormat('#,##0.000');
|
||||
// The hero stat is a big headline number, not a precise figure — round
|
||||
// to whole gallons there rather than showing the same 3-decimal
|
||||
// precision the per-entry rows use.
|
||||
final heroGallonsFormat = NumberFormat('#,##0');
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Receipts'),
|
||||
actionsPadding: const EdgeInsets.only(right: 20),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'Log Fuel Receipt',
|
||||
onPressed: () => _addReceipt(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
children: [
|
||||
_HeroTotal(
|
||||
totalCost: totalCost,
|
||||
totalGallons: totalGallons,
|
||||
currencyFormat: currencyFormat,
|
||||
gallonsFormat: heroGallonsFormat,
|
||||
selectedRange: _range,
|
||||
onRangeSelected: (range) => setState(() => _range = range),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 14, 2, 8),
|
||||
child: Text(
|
||||
'Recent Activity',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (entries.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('No fuel entries in this range.'),
|
||||
)
|
||||
else
|
||||
for (var i = 0; i < entries.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
_ReceiptRow(
|
||||
entry: entries[i],
|
||||
vehicle: appState.vehicleById(entries[i].vehicleId),
|
||||
dateFormat: dateFormat,
|
||||
currencyFormat: currencyFormat,
|
||||
gallonsFormat: gallonsFormat,
|
||||
onTap: () => _openReceipt(context, entries[i]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sits inside the hero banner (on the gradient, not the plain scaffold
|
||||
/// background), so selected/unselected use the hero's own white/translucent
|
||||
/// text colors rather than the surface-based colorScheme ones.
|
||||
class _DateRangeSelector extends StatelessWidget {
|
||||
final _DateRange selected;
|
||||
final ValueChanged<_DateRange> onSelected;
|
||||
|
||||
const _DateRangeSelector({required this.selected, required this.onSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
for (final range in _DateRange.values)
|
||||
GestureDetector(
|
||||
onTap: () => onSelected(range),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
range.label,
|
||||
style: TextStyle(
|
||||
color: range == selected
|
||||
? AppTheme.heroText
|
||||
: AppTheme.heroTextSecondary(context),
|
||||
fontWeight: range == selected ? FontWeight.w800 : FontWeight.w600,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroTotal extends StatelessWidget {
|
||||
final double totalCost;
|
||||
final double totalGallons;
|
||||
final NumberFormat currencyFormat;
|
||||
final NumberFormat gallonsFormat;
|
||||
final _DateRange selectedRange;
|
||||
final ValueChanged<_DateRange> onRangeSelected;
|
||||
|
||||
const _HeroTotal({
|
||||
required this.totalCost,
|
||||
required this.totalGallons,
|
||||
required this.currencyFormat,
|
||||
required this.gallonsFormat,
|
||||
required this.selectedRange,
|
||||
required this.onRangeSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HeroBanner(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
HeroStatsRow(
|
||||
gallonsValue: gallonsFormat.format(totalGallons),
|
||||
costValue: currencyFormat.format(totalCost),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
_DateRangeSelector(selected: selectedRange, onSelected: onRangeSelected),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReceiptRow extends StatelessWidget {
|
||||
final FuelEntry entry;
|
||||
final Vehicle? vehicle;
|
||||
final DateFormat dateFormat;
|
||||
final NumberFormat currencyFormat;
|
||||
final NumberFormat gallonsFormat;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ReceiptRow({
|
||||
required this.entry,
|
||||
required this.vehicle,
|
||||
required this.dateFormat,
|
||||
required this.currencyFormat,
|
||||
required this.gallonsFormat,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
dateFormat.format(entry.date),
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${gallonsFormat.format(entry.gallons)} gal',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
currencyFormat.format(entry.totalCost),
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
vehicle?.displayLabel ?? 'Unknown vehicle',
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,24 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/fuel_report.dart';
|
||||
import '../services/fuel_report_images.dart';
|
||||
import '../services/fuel_report_pdf.dart';
|
||||
|
||||
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
|
||||
/// file for the user — it's a state form, submitted to the state — so the
|
||||
/// Reports tab just links out to where to get it rather than trying to
|
||||
/// reproduce or embed it.
|
||||
final _refundFormUrl = Uri.parse(
|
||||
'https://dor.mo.gov/forms/?formName=Motor+Fuel+Refund+Claim&category=&year=99',
|
||||
);
|
||||
|
||||
class ReportScreen extends StatefulWidget {
|
||||
const ReportScreen({super.key});
|
||||
|
||||
|
|
@ -14,13 +26,30 @@ class ReportScreen extends StatefulWidget {
|
|||
State<ReportScreen> createState() => _ReportScreenState();
|
||||
}
|
||||
|
||||
class _ReportScreenState extends State<ReportScreen> {
|
||||
class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClientMixin {
|
||||
static final _dateFormat = DateFormat.yMMMd();
|
||||
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
bool _includeReceiptImages = false;
|
||||
bool _includeLineItems = false;
|
||||
bool _busy = false;
|
||||
|
||||
/// Which vehicles (by id) to include in the report — checkboxes next to
|
||||
/// each vehicle below the date range. [_seenVehicleIds] tracks every
|
||||
/// vehicle a checkbox has ever been shown for, so a vehicle newly
|
||||
/// appearing in range (first time it's seen) defaults to selected without
|
||||
/// clobbering a selection the user already made for one seen before (e.g.
|
||||
/// after picking a different range and back).
|
||||
final Set<String> _selectedVehicleIds = {};
|
||||
final Set<String> _seenVehicleIds = {};
|
||||
|
||||
// Keeps this tab's state (chosen date range, include-image/line-item
|
||||
// toggles) alive when MainShell's PageView builds/tears down tabs
|
||||
// outside its cache extent as the user swipes between them.
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
|
|
@ -39,10 +68,39 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
});
|
||||
}
|
||||
|
||||
Future<Uint8List> _buildPdfBytes(FuelReport report) async {
|
||||
var receiptImages = const <String, Uint8List>{};
|
||||
if (_includeReceiptImages) {
|
||||
final loaded = await loadReceiptImageBytes(
|
||||
report: report,
|
||||
activeProvider: context.read<AppState>().activeProvider,
|
||||
);
|
||||
receiptImages = loaded.bytesByEntryId;
|
||||
if (loaded.failedEntries.isNotEmpty && mounted) {
|
||||
final count = loaded.failedEntries.length;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"$count receipt photo${count == 1 ? '' : 's'} couldn't be included — offline, "
|
||||
'not signed in to the provider that has it, or the remote file is gone.',
|
||||
),
|
||||
duration: const Duration(seconds: 6),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return buildFuelReportPdf(
|
||||
report,
|
||||
includeLineItems: _includeLineItems,
|
||||
includeReceiptImages: _includeReceiptImages,
|
||||
receiptImages: receiptImages,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _share(FuelReport report) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final bytes = await buildFuelReportPdf(report);
|
||||
final bytes = await _buildPdfBytes(report);
|
||||
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
|
|
@ -52,31 +110,54 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
Future<void> _print(FuelReport report) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final bytes = await buildFuelReportPdf(report);
|
||||
final bytes = await _buildPdfBytes(report);
|
||||
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openRefundFormLink() async {
|
||||
final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Couldn't open the link.")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
String? rangeError;
|
||||
FuelReport? report;
|
||||
FuelReport? rawReport;
|
||||
if (_startDate != null && _endDate != null) {
|
||||
if (_endDate!.isBefore(_startDate!)) {
|
||||
rangeError = 'End date must be on or after the start date.';
|
||||
} else {
|
||||
report = buildFuelReport(
|
||||
rawReport = buildFuelReport(
|
||||
vehicles: appState.vehicles,
|
||||
fuelEntries: appState.fuelEntries,
|
||||
startDate: _startDate!,
|
||||
endDate: _endDate!,
|
||||
);
|
||||
for (final row in rawReport.rows) {
|
||||
if (_seenVehicleIds.add(row.vehicle.id)) {
|
||||
_selectedVehicleIds.add(row.vehicle.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final report = rawReport == null
|
||||
? null
|
||||
: FuelReport(
|
||||
startDate: rawReport.startDate,
|
||||
endDate: rawReport.endDate,
|
||||
rows: rawReport.rows.where((r) => _selectedVehicleIds.contains(r.vehicle.id)).toList(),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Fuel Report')),
|
||||
|
|
@ -104,38 +185,67 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
const SizedBox(height: 8),
|
||||
Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (report == null)
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Include Receipt Images'),
|
||||
subtitle: const Text(
|
||||
'Every receipt photo in the date range, grouped by vehicle and '
|
||||
'sorted by date — packed as many to a page as will fit.',
|
||||
),
|
||||
value: _includeReceiptImages,
|
||||
onChanged: (value) => setState(() => _includeReceiptImages = value),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Include Receipt Line Items'),
|
||||
subtitle: const Text(
|
||||
'Every receipt as its own line (date, gallons, price/gal, '
|
||||
'cost), grouped by vehicle and sorted by date.',
|
||||
),
|
||||
value: _includeLineItems,
|
||||
onChanged: (value) => setState(() => _includeLineItems = value),
|
||||
),
|
||||
if (_includeReceiptImages && _includeLineItems)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'Each line item and its receipt photo will be tagged with a '
|
||||
'matching number.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (rawReport == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('Choose a start and end date to generate a report.'),
|
||||
)
|
||||
else if (report.rows.isEmpty)
|
||||
else if (rawReport.rows.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('No vehicles had a receipted fuel entry in that date range.'),
|
||||
)
|
||||
else ...[
|
||||
for (final row in report.rows)
|
||||
Card(
|
||||
child: ListTile(
|
||||
title: Text(row.vehicle.displayLabel),
|
||||
subtitle: Text(
|
||||
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
|
||||
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('${row.totalGallons.toStringAsFixed(3)} gal'),
|
||||
Text(NumberFormat.simpleCurrency().format(row.totalCost)),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final row in rawReport.rows)
|
||||
_VehicleSelectionCard(
|
||||
row: row,
|
||||
selected: _selectedVehicleIds.contains(row.vehicle.id),
|
||||
onTap: () => setState(() {
|
||||
if (_selectedVehicleIds.contains(row.vehicle.id)) {
|
||||
_selectedVehicleIds.remove(row.vehicle.id);
|
||||
} else {
|
||||
_selectedVehicleIds.add(row.vehicle.id);
|
||||
}
|
||||
}),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
if (report!.rows.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('No vehicles selected — tap at least one above to build a report.'),
|
||||
)
|
||||
else ...[
|
||||
ListTile(
|
||||
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
trailing: Text(
|
||||
|
|
@ -149,7 +259,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : () => _share(report!),
|
||||
onPressed: _busy ? null : () => _share(report),
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
label: const Text('Share'),
|
||||
),
|
||||
|
|
@ -157,7 +267,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy ? null : () => _print(report!),
|
||||
onPressed: _busy ? null : () => _print(report),
|
||||
icon: const Icon(Icons.print_outlined),
|
||||
label: const Text('Print'),
|
||||
),
|
||||
|
|
@ -166,6 +276,110 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Need the refund claim form?', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"This app tracks your fuel purchases, but the actual refund claim is "
|
||||
"filed with the state. Get the official form from Missouri's "
|
||||
'Department of Revenue website.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: _openRefundFormLink,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.open_in_new, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Get the Motor Fuel Refund Claim form',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-vehicle row in the vehicle-selection list — tapping the whole
|
||||
/// card toggles [selected], indicated purely by color rather than a
|
||||
/// checkbox: [selected] uses the same primary/on-primary theming as the
|
||||
/// "Share" [FilledButton] below it, [!selected] looks like a plain
|
||||
/// unselected [Card] (default surface color, normal text colors).
|
||||
class _VehicleSelectionCard extends StatelessWidget {
|
||||
final VehicleReportRow row;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _VehicleSelectionCard({required this.row, required this.selected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface;
|
||||
final foregroundVariant = selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant;
|
||||
|
||||
return Card(
|
||||
color: selected ? colorScheme.primary : null,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
row.vehicle.displayLabel,
|
||||
style: TextStyle(color: foreground, fontWeight: FontWeight.w600, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
|
||||
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
|
||||
style: TextStyle(color: foregroundVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('${row.totalGallons.toStringAsFixed(3)} gal', style: TextStyle(color: foreground)),
|
||||
Text(
|
||||
NumberFormat.simpleCurrency().format(row.totalCost),
|
||||
style: TextStyle(color: foreground),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,16 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/cloud/cloud_storage_provider.dart';
|
||||
import 'cloud_folder_browser_screen.dart';
|
||||
import 'data_settings_screen.dart';
|
||||
import 'ui_settings_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
/// The "Settings" tab: a landing menu into submenus — [UiSettingsScreen]
|
||||
/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling,
|
||||
/// advanced/purge) — plus a read-only summary of what's stored locally.
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _connect(CloudProviderId id) async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectProvider(id);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not sign in: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectManual(CloudProviderId id, String providerName) async {
|
||||
final credentials = await showDialog<_WebDavCredentials>(
|
||||
context: context,
|
||||
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
|
||||
);
|
||||
if (credentials == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectProviderWithCredentials(
|
||||
id,
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not connect: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().disconnectCloud();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _chooseFolder() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _syncNow() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().syncNow();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
|
@ -90,153 +21,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (!appState.isCloudConnected) ...[
|
||||
Text(
|
||||
'Connect a cloud storage account to share vehicles and fuel '
|
||||
'receipts with other people, and to keep a backup off this device.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null) ...[
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final provider in appState.availableProviders)
|
||||
FilledButton.icon(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => provider is ManualCredentialCloudStorageProvider
|
||||
? _connectManual(provider.id, provider.displayName)
|
||||
: _connect(provider.id),
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text('Connect ${provider.displayName}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
appState.cloudFolderPath == null
|
||||
? 'No folder selected yet.'
|
||||
: 'Folder: ${appState.cloudFolderPath}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _chooseFolder,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: Text(appState.cloudFolderPath == null
|
||||
? 'Choose Folder'
|
||||
: 'Change Folder'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: (_busy || appState.cloudFolderPath == null)
|
||||
? null
|
||||
: _syncNow,
|
||||
icon: appState.isSyncing
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.sync),
|
||||
label: const Text('Sync Now'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _disconnect,
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (appState.lastSyncedAt != null)
|
||||
Text(
|
||||
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (appState.lastSyncError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Last sync failed: ${appState.lastSyncError}',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.palette_outlined),
|
||||
title: const Text('UI'),
|
||||
subtitle: const Text('Appearance'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UiSettingsScreen()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Keep photos on this phone after syncing'),
|
||||
subtitle: const Text(
|
||||
'Otherwise, a receipt photo is removed from this device once '
|
||||
"it's safely uploaded to the cloud.",
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.storage_outlined),
|
||||
title: const Text('Data'),
|
||||
subtitle: const Text('Cloud storage, photo syncing, advanced'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
|
||||
),
|
||||
value: appState.keepReceiptPhotosLocally,
|
||||
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Keep max quality images'),
|
||||
subtitle: const Text(
|
||||
'Otherwise, receipt photos are downscaled to a reasonable size '
|
||||
'before storing — smaller cloud storage and faster syncs, with '
|
||||
'no loss of legibility for a printed receipt.',
|
||||
),
|
||||
value: appState.keepMaxQualityReceiptPhotos,
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ExpansionTile(
|
||||
title: const Text('Advanced'),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"If another device disconnects mid-sync without releasing its lock, "
|
||||
"this is how long to wait before treating it as abandoned and clearing "
|
||||
"it so sync can continue.",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Slider(
|
||||
value: appState.staleLockMinutes.toDouble(),
|
||||
min: minStaleLockMinutes.toDouble(),
|
||||
max: maxStaleLockMinutes.toDouble(),
|
||||
divisions: maxStaleLockMinutes - minStaleLockMinutes,
|
||||
label: '${appState.staleLockMinutes} min',
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setStaleLockMinutes(value.round()),
|
||||
),
|
||||
Text('${appState.staleLockMinutes} minute(s)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
|
@ -260,93 +64,3 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WebDavCredentials {
|
||||
final String serverUrl;
|
||||
final String username;
|
||||
final String password;
|
||||
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
|
||||
}
|
||||
|
||||
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
|
||||
/// needs, since (unlike the OAuth providers) there's no browser flow to
|
||||
/// gather these instead.
|
||||
class _WebDavCredentialsDialog extends StatefulWidget {
|
||||
final String providerName;
|
||||
const _WebDavCredentialsDialog({required this.providerName});
|
||||
|
||||
@override
|
||||
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
|
||||
}
|
||||
|
||||
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _serverController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
Navigator.of(context).pop(_WebDavCredentials(
|
||||
serverUrl: _serverController.text.trim(),
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Connect ${widget.providerName}'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _serverController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(labelText: 'Username'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Connect'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
lib/screens/ui_settings_screen.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
|
||||
/// "UI" settings submenu, reached from [SettingsScreen] — currently just
|
||||
/// Appearance (theme), but its own screen so more display-related settings
|
||||
/// have somewhere to go without crowding the Settings landing page.
|
||||
class UiSettingsScreen extends StatelessWidget {
|
||||
const UiSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('UI')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Appearance', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(value: ThemeMode.system, label: Text('System')),
|
||||
ButtonSegment(value: ThemeMode.light, label: Text('Light')),
|
||||
ButtonSegment(value: ThemeMode.dark, label: Text('Dark')),
|
||||
],
|
||||
selected: {appState.themeMode},
|
||||
onSelectionChanged: (selection) =>
|
||||
context.read<AppState>().setThemeMode(selection.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,174 +1,66 @@
|
|||
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 '../widgets/image_source_sheet.dart';
|
||||
import '../widgets/hero_banner.dart';
|
||||
import '../widgets/receipt_capture.dart';
|
||||
import '../widgets/receipt_thumbnail.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'confirm_fuel_entry_screen.dart';
|
||||
import 'receipt_image_screen.dart';
|
||||
import 'receipt_detail_screen.dart';
|
||||
|
||||
/// Long edge and JPEG quality a receipt photo is downscaled to unless
|
||||
/// "keep max quality" is on — a receipt is a photo of small printed text,
|
||||
/// not something that benefits from a multi-megapixel original, and this
|
||||
/// keeps typical files in the low hundreds of KB instead of several MB.
|
||||
const receiptImageMaxDimension = 1600.0;
|
||||
const receiptImageQuality = 70;
|
||||
|
||||
class VehicleDetailScreen extends StatelessWidget {
|
||||
/// A swipeable [PageView] over every vehicle (in the same order as the
|
||||
/// Vehicles tab's list), starting on [vehicleId] — sliding left/right moves
|
||||
/// to the next/previous vehicle, the same "modern navigation" swipe
|
||||
/// [MainShell] uses for the bottom-nav tabs. Each page is a full,
|
||||
/// independent [_VehicleDetailPage] (own AppBar/FAB), so this widget only
|
||||
/// owns the [PageController].
|
||||
class VehicleDetailScreen extends StatefulWidget {
|
||||
final String vehicleId;
|
||||
|
||||
const VehicleDetailScreen({super.key, required this.vehicleId});
|
||||
|
||||
Future<void> _captureReceipt(BuildContext context) async {
|
||||
final source = await chooseImageSource(context);
|
||||
if (source == null || !context.mounted) return;
|
||||
@override
|
||||
State<VehicleDetailScreen> createState() => _VehicleDetailScreenState();
|
||||
}
|
||||
|
||||
final keepMaxQuality = context.read<AppState>().keepMaxQualityReceiptPhotos;
|
||||
final picker = ImagePicker();
|
||||
XFile? photo;
|
||||
try {
|
||||
photo = await picker.pickImage(
|
||||
source: source,
|
||||
imageQuality: keepMaxQuality ? null : receiptImageQuality,
|
||||
maxWidth: keepMaxQuality ? null : receiptImageMaxDimension,
|
||||
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open $sourceLabel: $e')),
|
||||
class _VehicleDetailScreenState extends State<VehicleDetailScreen> {
|
||||
late final PageController _pageController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final vehicles = context.read<AppState>().vehicles;
|
||||
final initialIndex = vehicles.indexWhere((v) => v.id == widget.vehicleId);
|
||||
_pageController = PageController(initialPage: initialIndex < 0 ? 0 : initialIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vehicles = context.watch<AppState>().vehicles;
|
||||
|
||||
if (vehicles.isEmpty) {
|
||||
return const Scaffold(body: Center(child: Text('Vehicle not found')));
|
||||
}
|
||||
|
||||
return PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: vehicles.length,
|
||||
itemBuilder: (context, index) => _VehicleDetailPage(vehicleId: vehicles[index].id),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (photo == null) return;
|
||||
}
|
||||
|
||||
final imageFile = File(photo.path);
|
||||
class _VehicleDetailPage extends StatelessWidget {
|
||||
final String vehicleId;
|
||||
|
||||
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) return;
|
||||
Navigator.of(context).pop(); // close the OCR loading spinner
|
||||
|
||||
final state = parsed.state;
|
||||
if (state != null && state != 'MO') {
|
||||
final proceed = await _confirmNonMissouriPurchase(context, state);
|
||||
if (!proceed || !context.mounted) return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ConfirmFuelEntryScreen(
|
||||
vehicleId: vehicleId,
|
||||
imageFile: imageFile,
|
||||
parsed: parsed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
|
||||
/// warn if the receipt's address is somewhere else, and let the user
|
||||
/// decide whether to log it anyway (e.g. it might still be worth
|
||||
/// tracking for other reasons even if it won't qualify for a refund).
|
||||
Future<bool> _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
|
||||
final stateName = usStateNames[stateCode] ?? stateCode;
|
||||
final proceed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Non-Missouri Purchase'),
|
||||
content: Text(
|
||||
'This receipt looks like it\'s from a gas station in $stateName. '
|
||||
'The Missouri fuel tax refund only applies to fuel purchased in '
|
||||
'Missouri.\n\nDo you still want to add this entry?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')),
|
||||
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')),
|
||||
],
|
||||
),
|
||||
);
|
||||
return proceed ?? false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildReceiptLeading(BuildContext context, FuelEntry entry) {
|
||||
if (entry.receiptImagePath != null) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.file(
|
||||
File(entry.receiptImagePath!),
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (entry.isReceiptUploadedToDrive) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId),
|
||||
),
|
||||
),
|
||||
child: const CircleAvatar(child: Icon(Icons.cloud_outlined)),
|
||||
);
|
||||
}
|
||||
return const CircleAvatar(child: Icon(Icons.receipt_long));
|
||||
}
|
||||
const _VehicleDetailPage({required this.vehicleId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -181,8 +73,12 @@ class VehicleDetailScreen extends StatelessWidget {
|
|||
|
||||
final entries = appState.entriesForVehicle(vehicleId);
|
||||
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
|
||||
final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
|
||||
final dateFormat = DateFormat.yMMMd().add_jm();
|
||||
final currencyFormat = NumberFormat.simpleCurrency();
|
||||
// The hero stat is a big headline number, not a precise figure — round
|
||||
// to whole gallons there, matching the Receipts tab's hero banner.
|
||||
final heroGallonsFormat = NumberFormat('#,##0');
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -199,42 +95,74 @@ class VehicleDetailScreen extends StatelessWidget {
|
|||
),
|
||||
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,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: HeroBanner(
|
||||
child: HeroStatsRow(
|
||||
gallonsValue: heroGallonsFormat.format(totalGallons),
|
||||
costValue: currencyFormat.format(totalCost),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: entries.isEmpty
|
||||
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
|
||||
: ListView.separated(
|
||||
// Keyed by vehicle so each vehicle's scroll position is
|
||||
// remembered independently as the user swipes between
|
||||
// pages — PageView.builder can dispose an off-screen
|
||||
// page's element, which would otherwise reset it.
|
||||
key: PageStorageKey('vehicle-entries-$vehicleId'),
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
void openDetail() => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReceiptDetailScreen(entryId: entry.id),
|
||||
),
|
||||
);
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: _buildReceiptLeading(context, entry),
|
||||
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
|
||||
subtitle: Text(
|
||||
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',
|
||||
child: InkWell(
|
||||
onTap: openDetail,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
// Row cross-axis defaults to center, so the
|
||||
// thumbnail sits vertically centered against the
|
||||
// title+subtitle block even though it's three
|
||||
// lines tall — ListTile's isThreeLine would
|
||||
// instead force leading/trailing to the top, per
|
||||
// Material spec (the same issue the vehicle list
|
||||
// cards had).
|
||||
child: Row(
|
||||
children: [
|
||||
ReceiptThumbnail(entry: entry, onTap: openDetail),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${entry.gallons.toStringAsFixed(3)} gal • '
|
||||
'${currencyFormat.format(entry.totalCost)}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
'${currencyFormat.format(entry.pricePerGallon)}/gal\n'
|
||||
'${dateFormat.format(entry.date)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => _deleteEntry(context, entry),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -244,7 +172,7 @@ class VehicleDetailScreen extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _captureReceipt(context),
|
||||
onPressed: () => captureReceiptForVehicle(context, vehicleId),
|
||||
icon: const Icon(Icons.camera_alt_outlined),
|
||||
label: const Text('Log Fuel Receipt'),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show ThemeMode;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ const _prefsKeyCloudFolderPath = 'cloud_folder_path';
|
|||
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
|
||||
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
||||
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
||||
const _prefsKeyThemeMode = 'theme_mode';
|
||||
|
||||
const defaultStaleLockMinutes = 10;
|
||||
const minStaleLockMinutes = 1;
|
||||
|
|
@ -85,6 +87,8 @@ class AppState extends ChangeNotifier {
|
|||
/// the original camera/gallery image is kept as-is.
|
||||
bool keepMaxQualityReceiptPhotos = false;
|
||||
|
||||
ThemeMode themeMode = ThemeMode.system;
|
||||
|
||||
/// Set if [init] fails. The UI shows this (with a retry option) instead
|
||||
/// of spinning forever — an unhandled exception here previously left
|
||||
/// `isLoading` stuck at true with no feedback at all.
|
||||
|
|
@ -109,6 +113,10 @@ class AppState extends ChangeNotifier {
|
|||
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
|
||||
keepMaxQualityReceiptPhotos =
|
||||
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
|
||||
themeMode = ThemeMode.values.firstWhere(
|
||||
(mode) => mode.name == prefs.getString(_prefsKeyThemeMode),
|
||||
orElse: () => ThemeMode.system,
|
||||
);
|
||||
} catch (e) {
|
||||
initError = e;
|
||||
isLoading = false;
|
||||
|
|
@ -289,6 +297,13 @@ class AppState extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
themeMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyThemeMode, mode.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
|
||||
/// Settings "Advanced" section, which restricts the picker to that range
|
||||
/// anyway; this is a defensive backstop for any other caller.
|
||||
|
|
@ -373,6 +388,34 @@ class AppState extends ChangeNotifier {
|
|||
return entry;
|
||||
}
|
||||
|
||||
/// Corrects the logged values (date, gallons, price/gal, total cost) for
|
||||
/// an existing fuel entry — the receipt photo itself isn't editable here,
|
||||
/// only the data recorded about it (e.g. fixing a misread OCR value).
|
||||
/// [entryId]'s receipt photo reference (local path and/or cloud file id)
|
||||
/// carries over untouched.
|
||||
Future<void> updateFuelEntry({
|
||||
required String entryId,
|
||||
required DateTime date,
|
||||
required double gallons,
|
||||
required double pricePerGallon,
|
||||
required double totalCost,
|
||||
}) async {
|
||||
final existing = fuelEntries.firstWhere((e) => e.id == entryId);
|
||||
final updated = FuelEntry(
|
||||
id: existing.id,
|
||||
vehicleId: existing.vehicleId,
|
||||
date: date,
|
||||
gallons: gallons,
|
||||
pricePerGallon: pricePerGallon,
|
||||
totalCost: totalCost,
|
||||
receiptImagePath: existing.receiptImagePath,
|
||||
receiptDriveFileId: existing.receiptDriveFileId,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await database.saveFuelEntry(updated);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> deleteFuelEntry(String entryId) async {
|
||||
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
||||
await database.deleteReceiptImageFile(entry.receiptImagePath);
|
||||
|
|
@ -380,6 +423,30 @@ class AppState extends ChangeNotifier {
|
|||
await _persist();
|
||||
}
|
||||
|
||||
/// Wipes every vehicle, fuel entry, and local receipt photo — "starting
|
||||
/// completely fresh". Uses the same soft-delete tombstones as any other
|
||||
/// delete rather than a hard SQL `DELETE`, so if a cloud sync is
|
||||
/// connected, the deletion pushes on next sync instead of the old data
|
||||
/// just getting silently re-imported from the remote copy. This is purely
|
||||
/// local + whatever propagates through sync — it never reaches into the
|
||||
/// cloud folder directly to delete anything sitting there.
|
||||
Future<void> purgeAllData() async {
|
||||
await database.purgeAllData(DateTime.now().toUtc());
|
||||
await _persist();
|
||||
}
|
||||
|
||||
/// Wipes every fuel entry (and its local receipt photo) dated within
|
||||
/// [start]–[end] inclusive, across all vehicles. Vehicles themselves are
|
||||
/// left alone. Same soft-delete/sync-propagation reasoning as
|
||||
/// [purgeAllData].
|
||||
Future<void> purgeFuelEntriesInRange(DateTime start, DateTime end) async {
|
||||
final localPaths = await database.purgeFuelEntriesInRange(start, end, DateTime.now().toUtc());
|
||||
for (final path in localPaths) {
|
||||
await database.deleteReceiptImageFile(path);
|
||||
}
|
||||
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));
|
||||
|
|
@ -401,6 +468,14 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
FuelEntry? fuelEntryById(String id) {
|
||||
try {
|
||||
return fuelEntries.firstWhere((e) => e.id == id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact match against an active vehicle's VIN — same comparison
|
||||
/// [DatabaseService.vinExists] does, not case-insensitive, since VIN
|
||||
/// isn't normalized to any particular case on manual entry (only OCR
|
||||
|
|
|
|||
|
|
@ -267,6 +267,67 @@ class DatabaseService {
|
|||
return copied.path;
|
||||
}
|
||||
|
||||
/// Soft-deletes every active vehicle and fuel entry — as if the user had
|
||||
/// deleted each one individually (see [softDeleteVehicle]/
|
||||
/// [softDeleteFuelEntry] for why these aren't real `DELETE`s: the
|
||||
/// tombstones are what let the deletion propagate to other synced devices
|
||||
/// on the next sync, rather than the rows just being silently re-imported
|
||||
/// from the cloud copy). Also empties the local receipts folder outright
|
||||
/// rather than deleting file-by-file, to catch any orphaned file a
|
||||
/// tracked path might have missed.
|
||||
Future<void> purgeAllData(DateTime deletedAt) async {
|
||||
final ts = deletedAt.millisecondsSinceEpoch;
|
||||
await _db.update(
|
||||
'fuel_entries',
|
||||
{'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
|
||||
where: 'deleted_at IS NULL',
|
||||
);
|
||||
await _db.update(
|
||||
'vehicles',
|
||||
{'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
|
||||
where: 'deleted_at IS NULL',
|
||||
);
|
||||
|
||||
if (await receiptsDirectory.exists()) {
|
||||
await receiptsDirectory.delete(recursive: true);
|
||||
}
|
||||
await receiptsDirectory.create(recursive: true);
|
||||
}
|
||||
|
||||
/// Soft-deletes every active fuel entry (across all vehicles) whose
|
||||
/// [FuelEntry.date] falls within [start]–[end] inclusive, and returns
|
||||
/// their local receipt image paths so the caller can clean those files up
|
||||
/// too. Vehicles are left untouched — a date range only makes sense
|
||||
/// against the fuel entries logged against them, not the vehicles
|
||||
/// themselves.
|
||||
Future<List<String>> purgeFuelEntriesInRange(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
DateTime deletedAt,
|
||||
) async {
|
||||
final rangeStart = DateTime(start.year, start.month, start.day).millisecondsSinceEpoch;
|
||||
final rangeEndExclusive =
|
||||
DateTime(end.year, end.month, end.day).add(const Duration(days: 1)).millisecondsSinceEpoch;
|
||||
final ts = deletedAt.millisecondsSinceEpoch;
|
||||
|
||||
final rows = await _db.query(
|
||||
'fuel_entries',
|
||||
where: 'deleted_at IS NULL AND date >= ? AND date < ?',
|
||||
whereArgs: [rangeStart, rangeEndExclusive],
|
||||
);
|
||||
final localPaths =
|
||||
rows.map((r) => r['receipt_image_path'] as String?).whereType<String>().toList();
|
||||
|
||||
await _db.update(
|
||||
'fuel_entries',
|
||||
{'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
|
||||
where: 'deleted_at IS NULL AND date >= ? AND date < ?',
|
||||
whereArgs: [rangeStart, rangeEndExclusive],
|
||||
);
|
||||
|
||||
return localPaths;
|
||||
}
|
||||
|
||||
Future<void> deleteReceiptImageFile(String? path) async {
|
||||
if (path == null) return;
|
||||
final file = File(path);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,55 @@
|
|||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
|
||||
/// One vehicle's totals within a [FuelReport]'s date range.
|
||||
/// One vehicle's totals — and, for the line-items/receipt-images report
|
||||
/// sections, its individual entries — within a [FuelReport]'s date range.
|
||||
/// [entries] is sorted oldest-first, the natural order for a ledger of
|
||||
/// line items building up to a total.
|
||||
class VehicleReportRow {
|
||||
final Vehicle vehicle;
|
||||
final int entryCount;
|
||||
final List<FuelEntry> entries;
|
||||
final double totalGallons;
|
||||
final double totalCost;
|
||||
|
||||
VehicleReportRow({
|
||||
required this.vehicle,
|
||||
required this.entryCount,
|
||||
required this.entries,
|
||||
required this.totalGallons,
|
||||
required this.totalCost,
|
||||
});
|
||||
|
||||
int get entryCount => entries.length;
|
||||
}
|
||||
|
||||
/// One entry paired with the vehicle it belongs to — [FuelEntry] only knows
|
||||
/// [FuelEntry.vehicleId], not the [Vehicle] itself, so anything that needs
|
||||
/// to show both together (like [FuelReport.allEntries], for a combined
|
||||
/// cross-vehicle list) needs this pairing.
|
||||
class ReportEntry {
|
||||
final FuelEntry entry;
|
||||
final Vehicle vehicle;
|
||||
|
||||
ReportEntry({required this.entry, required this.vehicle});
|
||||
}
|
||||
|
||||
/// Every qualifying entry in a single calendar month, across every vehicle
|
||||
/// in the report — the "Gallons by month" breakdown is this, one per
|
||||
/// month present in [FuelReport.monthlyTotals].
|
||||
class MonthlyTotal {
|
||||
/// The first of the month, e.g. `DateTime(2026, 8)` for August 2026.
|
||||
final DateTime month;
|
||||
final int fillCount;
|
||||
final double totalGallons;
|
||||
final double totalCost;
|
||||
|
||||
MonthlyTotal({
|
||||
required this.month,
|
||||
required this.fillCount,
|
||||
required this.totalGallons,
|
||||
required this.totalCost,
|
||||
});
|
||||
|
||||
double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
|
||||
}
|
||||
|
||||
/// Per-vehicle fuel totals for entries with a receipt, dated within
|
||||
|
|
@ -27,6 +63,47 @@ class FuelReport {
|
|||
|
||||
double get totalGallons => rows.fold(0.0, (sum, r) => sum + r.totalGallons);
|
||||
double get totalCost => rows.fold(0.0, (sum, r) => sum + r.totalCost);
|
||||
int get fillCount => rows.fold(0, (sum, r) => sum + r.entryCount);
|
||||
double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
|
||||
|
||||
/// Every entry across every vehicle, oldest first — the natural order for
|
||||
/// a combined "purchase detail" ledger that isn't grouped by vehicle.
|
||||
List<ReportEntry> get allEntries {
|
||||
final list = [
|
||||
for (final row in rows)
|
||||
for (final entry in row.entries) ReportEntry(entry: entry, vehicle: row.vehicle),
|
||||
];
|
||||
list.sort((a, b) => a.entry.date.compareTo(b.entry.date));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// [allEntries] grouped by calendar month, in chronological order — the
|
||||
/// "Purchase detail" section's grouping, and the source data for
|
||||
/// [monthlyTotals].
|
||||
Map<DateTime, List<ReportEntry>> get entriesByMonth {
|
||||
final map = <DateTime, List<ReportEntry>>{};
|
||||
for (final reportEntry in allEntries) {
|
||||
final month = DateTime(reportEntry.entry.date.year, reportEntry.entry.date.month);
|
||||
(map[month] ??= []).add(reportEntry);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// One [MonthlyTotal] per calendar month with qualifying entries, oldest
|
||||
/// first — the "Gallons by month" chart/table's data.
|
||||
List<MonthlyTotal> get monthlyTotals {
|
||||
final byMonth = entriesByMonth;
|
||||
final months = byMonth.keys.toList()..sort();
|
||||
return [
|
||||
for (final month in months)
|
||||
MonthlyTotal(
|
||||
month: month,
|
||||
fillCount: byMonth[month]!.length,
|
||||
totalGallons: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.gallons),
|
||||
totalCost: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.totalCost),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a per-vehicle fuel summary for [startDate]–[endDate] (inclusive,
|
||||
|
|
@ -68,11 +145,12 @@ FuelReport buildFuelReport({
|
|||
// No matching (active) vehicle — e.g. deleted since — nothing sane to
|
||||
// attribute these entries to.
|
||||
if (vehicle == null) continue;
|
||||
final sortedEntries = entry.value.toList()..sort((a, b) => a.date.compareTo(b.date));
|
||||
rows.add(VehicleReportRow(
|
||||
vehicle: vehicle,
|
||||
entryCount: entry.value.length,
|
||||
totalGallons: entry.value.fold(0.0, (sum, e) => sum + e.gallons),
|
||||
totalCost: entry.value.fold(0.0, (sum, e) => sum + e.totalCost),
|
||||
entries: sortedEntries,
|
||||
totalGallons: sortedEntries.fold(0.0, (sum, e) => sum + e.gallons),
|
||||
totalCost: sortedEntries.fold(0.0, (sum, e) => sum + e.totalCost),
|
||||
));
|
||||
}
|
||||
rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel));
|
||||
|
|
|
|||
70
lib/services/fuel_report_images.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import 'cloud/cloud_storage_provider.dart';
|
||||
import 'fuel_report.dart';
|
||||
|
||||
/// Result of [loadReceiptImageBytes]: the photo bytes that were resolved,
|
||||
/// plus which entries couldn't be — e.g. because the receipt was uploaded
|
||||
/// through a *different* cloud provider than the one currently connected
|
||||
/// (its stored reference is meaningless to the active provider's API), or
|
||||
/// the entry is offline/the remote file's since been deleted. Surfacing
|
||||
/// [failedEntries] instead of only ever silently dropping them is what
|
||||
/// makes that distinguishable from "there was never a receipt at all".
|
||||
class ReceiptImageLoadResult {
|
||||
final Map<String, Uint8List> bytesByEntryId;
|
||||
final List<FuelEntry> failedEntries;
|
||||
|
||||
ReceiptImageLoadResult({required this.bytesByEntryId, required this.failedEntries});
|
||||
}
|
||||
|
||||
/// Reads (or downloads) the receipt photo for every entry in [report].
|
||||
///
|
||||
/// Prefers the local copy when one's still on-device; falls back to
|
||||
/// downloading via [activeProvider] for entries that have already been
|
||||
/// uploaded and cleared locally. An entry that fails to download (offline,
|
||||
/// revoked cloud access, since-deleted remote file, a reference belonging
|
||||
/// to a different provider than the one currently connected, ...) is left
|
||||
/// out of [ReceiptImageLoadResult.bytesByEntryId] and reported in
|
||||
/// [ReceiptImageLoadResult.failedEntries] instead — one missing receipt
|
||||
/// photo shouldn't block the rest of the document, but it also shouldn't
|
||||
/// vanish without a trace.
|
||||
Future<ReceiptImageLoadResult> loadReceiptImageBytes({
|
||||
required FuelReport report,
|
||||
required CloudStorageProvider? activeProvider,
|
||||
}) async {
|
||||
final bytesByEntryId = <String, Uint8List>{};
|
||||
final failedEntries = <FuelEntry>[];
|
||||
CloudStorageSession? session;
|
||||
try {
|
||||
for (final row in report.rows) {
|
||||
for (final entry in row.entries) {
|
||||
final localPath = entry.receiptImagePath;
|
||||
if (localPath != null) {
|
||||
final file = File(localPath);
|
||||
if (await file.exists()) {
|
||||
bytesByEntryId[entry.id] = Uint8List.fromList(await file.readAsBytes());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
final driveFileId = entry.receiptDriveFileId;
|
||||
if (driveFileId == null || activeProvider == null || !activeProvider.isSignedIn) {
|
||||
failedEntries.add(entry);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
session ??= activeProvider.beginSession();
|
||||
final bytes = await session.downloadFileBytes(driveFileId);
|
||||
bytesByEntryId[entry.id] = Uint8List.fromList(bytes);
|
||||
} catch (_) {
|
||||
failedEntries.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
session?.close();
|
||||
}
|
||||
return ReceiptImageLoadResult(bytesByEntryId: bytesByEntryId, failedEntries: failedEntries);
|
||||
}
|
||||
|
|
@ -7,75 +7,594 @@ import 'package:pdf/widgets.dart' as pw;
|
|||
import 'fuel_report.dart';
|
||||
|
||||
final _dateFormat = DateFormat.yMMMd();
|
||||
final _dateTimeFormat = DateFormat.yMMMd().add_jm();
|
||||
final _monthFormat = DateFormat('MMM');
|
||||
final _monthHeadingFormat = DateFormat.yMMMM();
|
||||
final _dayMonthFormat = DateFormat('d MMM');
|
||||
final _generatedFormat = DateFormat('d MMM yyyy');
|
||||
final _currencyFormat = NumberFormat.simpleCurrency();
|
||||
|
||||
/// Renders [report] as a single-page-friendly PDF: a per-vehicle table
|
||||
/// (VIN, nickname, entry count, gallons, total cost) plus a grand-total
|
||||
/// row, suitable for attaching to a fuel tax refund submission or handing
|
||||
/// to a printer.
|
||||
Future<Uint8List> buildFuelReportPdf(FuelReport report) async {
|
||||
// Palette lifted from the report design mockup — kept in one place so
|
||||
// every section (banner, cards, tables, watermark) draws from the same
|
||||
// set of colors rather than each picking its own.
|
||||
final _bannerBackground = PdfColor.fromHex('#EAF3DE');
|
||||
final _bannerText = PdfColor.fromHex('#27500A');
|
||||
final _textPrimary = PdfColor.fromHex('#1F1E1C');
|
||||
final _textMuted = PdfColor.fromHex('#6B6A64');
|
||||
final _textFaint = PdfColor.fromHex('#94938C');
|
||||
final _borderLight = PdfColor.fromHex('#E5E3DB');
|
||||
final _borderStrong = PdfColor.fromHex('#CFCDC4');
|
||||
final _accentGreen = PdfColor.fromHex('#639922');
|
||||
final _accentGreenDark = PdfColor.fromHex('#3B6D11');
|
||||
final _cardBackground = PdfColor.fromHex('#F7F6F1');
|
||||
|
||||
final _tableBorder = pw.TableBorder.all(color: _borderLight, width: 0.5);
|
||||
final _tableHeaderDecoration = pw.BoxDecoration(color: PdfColor.fromHex('#EFEEE7'));
|
||||
final _tableEvenRowDecoration = const pw.BoxDecoration(color: PdfColors.white);
|
||||
final _tableOddRowDecoration = pw.BoxDecoration(color: _cardBackground);
|
||||
final _sectionHeadingStyle = pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary);
|
||||
|
||||
/// Renders [report] as a PDF, styled after the app's report design mockup:
|
||||
/// a branded letterhead banner, a headline total, three at-a-glance stat
|
||||
/// cards, a "gallons by month" bar chart + table, and a by-vehicle
|
||||
/// breakdown. When requested, a "Line Items" section (a table per vehicle)
|
||||
/// and/or a "Receipt Images" section (photos per vehicle, sized to
|
||||
/// actually be legible — not a small inline thumbnail) follow, each
|
||||
/// grouped by vehicle and sorted oldest-first within it. When both are on,
|
||||
/// each line item and its receipt photo are tagged with the same number
|
||||
/// (assigned once, continuing across vehicles) so the two can be matched
|
||||
/// up by eye despite living in separate sections.
|
||||
///
|
||||
/// [receiptImages] supplies the actual photo bytes, keyed by
|
||||
/// [FuelEntry.id] — see `loadReceiptImageBytes`; an entry with no entry in
|
||||
/// that map (couldn't be read locally or downloaded) is left out of the
|
||||
/// images section rather than shown as a broken image.
|
||||
Future<Uint8List> buildFuelReportPdf(
|
||||
FuelReport report, {
|
||||
bool includeLineItems = false,
|
||||
bool includeReceiptImages = false,
|
||||
Map<String, Uint8List> receiptImages = const {},
|
||||
}) async {
|
||||
final doc = pw.Document();
|
||||
final generatedAt = DateTime.now();
|
||||
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.letter,
|
||||
header: (context) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text(
|
||||
'${_dateFormat.format(report.startDate)} – ${_dateFormat.format(report.endDate)}',
|
||||
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
|
||||
margin: pw.EdgeInsets.zero,
|
||||
header: (context) => _buildBanner(generatedAt),
|
||||
footer: (context) => _buildFooter(report, context),
|
||||
build: (context) => _buildBody(
|
||||
report,
|
||||
includeLineItems: includeLineItems,
|
||||
includeReceiptImages: includeReceiptImages,
|
||||
receiptImages: receiptImages,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
/// A *flat* list of top-level widgets — deliberately not one big [pw.Column]
|
||||
/// wrapping everything. [pw.MultiPage] paginates by walking its `build:`
|
||||
/// list one top-level item at a time, resetting its "stuck" bookkeeping
|
||||
/// only once each one fully finishes; a [pw.Table]/[pw.Wrap] (both of which
|
||||
/// span pages on their own, e.g. a long line-items table or a big receipt
|
||||
/// photo grid) nested *inside* one enclosing spanning [pw.Column] instead
|
||||
/// of being its own top-level entry defeats that bookkeeping and throws
|
||||
/// `TooManyPagesException` on anything long enough to need several page
|
||||
/// breaks — even though the content itself is perfectly valid.
|
||||
List<pw.Widget> _buildBody(
|
||||
FuelReport report, {
|
||||
required bool includeLineItems,
|
||||
required bool includeReceiptImages,
|
||||
required Map<String, Uint8List> receiptImages,
|
||||
}) {
|
||||
pw.Widget pad(pw.Widget child) =>
|
||||
pw.Padding(padding: const pw.EdgeInsets.symmetric(horizontal: 36), child: child);
|
||||
|
||||
if (report.rows.isEmpty) {
|
||||
return [
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
|
||||
child: _buildTitleBlock(report),
|
||||
),
|
||||
pad(pw.Text('No vehicles had a receipted fuel entry in this date range.')),
|
||||
];
|
||||
}
|
||||
|
||||
final widgets = <pw.Widget>[
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
|
||||
child: _buildTitleBlock(report),
|
||||
),
|
||||
pad(_buildHeroStat(report)),
|
||||
pw.SizedBox(height: 22),
|
||||
pad(_buildStatCardsRow(report)),
|
||||
pw.SizedBox(height: 26),
|
||||
pad(_buildMonthlySection(report)),
|
||||
pw.SizedBox(height: 26),
|
||||
pad(_buildVehicleSection(report)),
|
||||
];
|
||||
|
||||
if (includeLineItems || includeReceiptImages) {
|
||||
widgets.add(pw.SizedBox(height: 26));
|
||||
widgets.addAll(
|
||||
_buildLineItemsAndImagesSections(
|
||||
report,
|
||||
includeLineItems: includeLineItems,
|
||||
includeReceiptImages: includeReceiptImages,
|
||||
receiptImages: receiptImages,
|
||||
).map(pad),
|
||||
);
|
||||
}
|
||||
|
||||
widgets.add(pw.SizedBox(height: 8));
|
||||
return widgets;
|
||||
}
|
||||
|
||||
pw.Widget _buildBanner(DateTime generatedAt) {
|
||||
// The watermark lives here, inside the banner's own bounded height,
|
||||
// rather than overlaid on the flowing body content below — a Stack
|
||||
// wrapping unbounded/multi-page content doesn't paginate (MultiPage
|
||||
// needs a SpanningWidget or a fixed-size child for that), so scoping it
|
||||
// to this fixed-height strip is what keeps it safe across page breaks.
|
||||
return pw.Container(
|
||||
color: _bannerBackground,
|
||||
child: pw.Stack(
|
||||
children: [
|
||||
pw.Positioned(
|
||||
right: 12,
|
||||
top: -14,
|
||||
child: pw.SizedBox(width: 60, height: 60, child: _buildWatermark(alpha: 0.14)),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 36, vertical: 14),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Show Me The Fuel Refund',
|
||||
style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold, color: _bannerText),
|
||||
),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: PdfColors.white,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Text(
|
||||
'Generated ${_generatedFormat.format(generatedAt)}',
|
||||
style: pw.TextStyle(fontSize: 10, color: _bannerText),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
build: (context) => [
|
||||
if (report.rows.isEmpty)
|
||||
pw.Text('No vehicles had a receipted fuel entry in this date range.')
|
||||
else
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildFooter(FuelReport report, pw.Context context) {
|
||||
return pw.Container(
|
||||
padding: const pw.EdgeInsets.fromLTRB(36, 8, 36, 20),
|
||||
decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderStrong, width: 0.5))),
|
||||
margin: const pw.EdgeInsets.only(top: 6),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'${report.fillCount} purchase${report.fillCount == 1 ? '' : 's'} · '
|
||||
'${report.rows.length} vehicle${report.rows.length == 1 ? '' : 's'} · '
|
||||
'${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}',
|
||||
style: pw.TextStyle(fontSize: 9, color: _textFaint),
|
||||
),
|
||||
pw.Text(
|
||||
'Page ${context.pageNumber} of ${context.pagesCount}',
|
||||
style: pw.TextStyle(fontSize: 9, color: _textFaint),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A faint brand-colored fuel-can silhouette — purely decorative,
|
||||
/// matching the report mockup's watermark shape.
|
||||
pw.Widget _buildWatermark({required double alpha}) {
|
||||
const svg = '''
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12,8 L76,8 L79,22 L73,33 L83,47 L78,58 L84,67 L86,89 L77,89 L77,70 L20,70 L20,30 Z" fill="#639922"/>
|
||||
</svg>
|
||||
''';
|
||||
return pw.SvgImage(svg: svg, colorFilter: _accentGreen.withAlpha(alpha));
|
||||
}
|
||||
|
||||
pw.Widget _buildTitleBlock(FuelReport report) {
|
||||
final months = report.monthlyTotals.length;
|
||||
final vehicles = report.rows.length;
|
||||
final fills = report.fillCount;
|
||||
final subtitle = StringBuffer('${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}');
|
||||
if (report.rows.isNotEmpty) {
|
||||
subtitle.write(
|
||||
' · $months month${months == 1 ? '' : 's'} · $vehicles vehicle${vehicles == 1 ? '' : 's'} · '
|
||||
'$fills fill-up${fills == 1 ? '' : 's'}',
|
||||
);
|
||||
}
|
||||
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.only(bottom: 20),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Text(subtitle.toString(), style: pw.TextStyle(fontSize: 12, color: _textMuted)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildHeroStat(FuelReport report) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Total fuel purchased', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||
children: [
|
||||
pw.Text(
|
||||
report.totalGallons.toStringAsFixed(3),
|
||||
style: pw.TextStyle(fontSize: 34, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
|
||||
),
|
||||
pw.SizedBox(width: 6),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.only(bottom: 5),
|
||||
child: pw.Text('gal', style: pw.TextStyle(fontSize: 13, color: _accentGreenDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildStatCardsRow(FuelReport report) {
|
||||
pw.Widget statCard(String label, String value) => pw.Expanded(
|
||||
child: pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: _cardBackground,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(label, style: pw.TextStyle(fontSize: 10, color: _textMuted)),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Text(value, style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return pw.Row(
|
||||
children: [
|
||||
statCard('Spent at the pump', _currencyFormat.format(report.totalCost)),
|
||||
pw.SizedBox(width: 10),
|
||||
statCard('Avg price/gal', _currencyFormat.format(report.avgPricePerGallon)),
|
||||
pw.SizedBox(width: 10),
|
||||
statCard('Fill-ups logged', report.fillCount.toString()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildMonthlySection(FuelReport report) {
|
||||
final months = report.monthlyTotals;
|
||||
if (months.isEmpty) return pw.SizedBox();
|
||||
|
||||
const chartHeight = 64.0;
|
||||
final maxGallons = months.fold(0.0, (max, m) => m.totalGallons > max ? m.totalGallons : max);
|
||||
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Gallons by month', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
|
||||
pw.SizedBox(height: 8),
|
||||
if (months.length > 1) ...[
|
||||
pw.SizedBox(
|
||||
height: chartHeight,
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final month in months)
|
||||
pw.Expanded(
|
||||
child: pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 3),
|
||||
child: pw.Container(
|
||||
height: maxGallons == 0
|
||||
? 2
|
||||
: (chartHeight * (month.totalGallons / maxGallons)).clamp(2, chartHeight),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: _accentGreen,
|
||||
borderRadius: const pw.BorderRadius.only(
|
||||
topLeft: pw.Radius.circular(2),
|
||||
topRight: pw.Radius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Row(
|
||||
children: [
|
||||
for (final month in months)
|
||||
pw.Expanded(
|
||||
child: pw.Center(
|
||||
child: pw.Text(_monthFormat.format(month.month), style: pw.TextStyle(fontSize: 9, color: _textFaint)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
],
|
||||
pw.TableHelper.fromTextArray(
|
||||
headers: ['Vehicle', 'VIN', 'Entries', 'Gallons', 'Total Cost'],
|
||||
headers: ['Month', 'Fills', 'Gallons', 'Spent', 'Avg/gal'],
|
||||
data: [
|
||||
for (final row in report.rows)
|
||||
for (final month in months)
|
||||
[
|
||||
row.vehicle.nickname?.trim().isNotEmpty == true ? row.vehicle.nickname! : '—',
|
||||
row.vehicle.vin,
|
||||
row.entryCount.toString(),
|
||||
row.totalGallons.toStringAsFixed(3),
|
||||
_currencyFormat.format(row.totalCost),
|
||||
_monthHeadingFormat.format(month.month),
|
||||
month.fillCount.toString(),
|
||||
month.totalGallons.toStringAsFixed(3),
|
||||
_currencyFormat.format(month.totalCost),
|
||||
_currencyFormat.format(month.avgPricePerGallon),
|
||||
],
|
||||
],
|
||||
cellAlignments: const {
|
||||
1: pw.Alignment.centerRight,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
4: pw.Alignment.centerRight,
|
||||
},
|
||||
headerAlignments: const {
|
||||
1: pw.Alignment.centerRight,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
4: pw.Alignment.centerRight,
|
||||
},
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
|
||||
cellStyle: const pw.TextStyle(fontSize: 10),
|
||||
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
border: _tableBorder,
|
||||
headerDecoration: _tableHeaderDecoration,
|
||||
rowDecoration: _tableEvenRowDecoration,
|
||||
oddRowDecoration: _tableOddRowDecoration,
|
||||
),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Divider(),
|
||||
if (months.length > 1) ...[
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10)),
|
||||
pw.Text(
|
||||
'${report.totalGallons.toStringAsFixed(3)} gal · ${_currencyFormat.format(report.totalCost)}',
|
||||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
'${report.fillCount} · ${report.totalGallons.toStringAsFixed(3)} gal · '
|
||||
'${_currencyFormat.format(report.totalCost)} · ${_currencyFormat.format(report.avgPricePerGallon)}',
|
||||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return doc.save();
|
||||
pw.Widget _buildVehicleSection(FuelReport report) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('By vehicle', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
|
||||
for (final row in report.rows)
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderLight, width: 0.5))),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Expanded(
|
||||
flex: 2,
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text(row.vehicle.vin, style: pw.TextStyle(fontSize: 9, color: _textFaint)),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: pw.Text(
|
||||
'${row.entryCount} fill${row.entryCount == 1 ? '' : 's'}',
|
||||
textAlign: pw.TextAlign.right,
|
||||
style: pw.TextStyle(fontSize: 11, color: _textMuted),
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: pw.Text(
|
||||
_currencyFormat.format(row.totalCost),
|
||||
textAlign: pw.TextAlign.right,
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: pw.Text(
|
||||
'${row.totalGallons.toStringAsFixed(3)} gal',
|
||||
textAlign: pw.TextAlign.right,
|
||||
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Item numbers (when both [includeLineItems] and [includeReceiptImages]
|
||||
/// are on) are assigned once, in report order — vehicle by vehicle,
|
||||
/// oldest-entry-first within each — and reused by both the "Line Items"
|
||||
/// table and the "Receipt Images" section below it, so a photo and its
|
||||
/// line item carry the same number despite living in separate sections.
|
||||
List<pw.Widget> _buildLineItemsAndImagesSections(
|
||||
FuelReport report, {
|
||||
required bool includeLineItems,
|
||||
required bool includeReceiptImages,
|
||||
required Map<String, Uint8List> receiptImages,
|
||||
}) {
|
||||
final showItemNumbers = includeLineItems && includeReceiptImages;
|
||||
final itemNumbers = <String, int>{};
|
||||
var nextNumber = 1;
|
||||
for (final row in report.rows) {
|
||||
for (final entry in row.entries) {
|
||||
itemNumbers[entry.id] = nextNumber++;
|
||||
}
|
||||
}
|
||||
|
||||
final widgets = <pw.Widget>[];
|
||||
|
||||
if (includeLineItems) {
|
||||
widgets.add(pw.Text('Line Items', style: _sectionHeadingStyle));
|
||||
for (final row in report.rows) {
|
||||
widgets.add(pw.SizedBox(height: 14));
|
||||
widgets.add(_buildVehicleSubheading(row));
|
||||
widgets.add(pw.SizedBox(height: 6));
|
||||
widgets.add(_buildLineItemsTable(row, showItemNumbers: showItemNumbers, itemNumbers: itemNumbers));
|
||||
}
|
||||
}
|
||||
|
||||
if (includeReceiptImages) {
|
||||
final rowsWithImages = report.rows.where((row) => row.entries.any((e) => receiptImages.containsKey(e.id)));
|
||||
if (rowsWithImages.isNotEmpty) {
|
||||
if (includeLineItems) widgets.add(pw.SizedBox(height: 26));
|
||||
widgets.add(pw.Text('Receipt Images', style: _sectionHeadingStyle));
|
||||
for (final row in rowsWithImages) {
|
||||
widgets.add(pw.SizedBox(height: 14));
|
||||
widgets.add(_buildVehicleSubheading(row));
|
||||
widgets.add(pw.SizedBox(height: 8));
|
||||
widgets.add(_buildReceiptImagesWrap(
|
||||
row,
|
||||
receiptImages,
|
||||
showItemNumbers: showItemNumbers,
|
||||
itemNumbers: itemNumbers,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
pw.Widget _buildVehicleSubheading(VehicleReportRow row) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text('VIN: ${row.vehicle.vin}', style: pw.TextStyle(fontSize: 9, color: _textFaint)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildLineItemsTable(
|
||||
VehicleReportRow row, {
|
||||
required bool showItemNumbers,
|
||||
required Map<String, int> itemNumbers,
|
||||
}) {
|
||||
return pw.TableHelper.fromTextArray(
|
||||
headers: [
|
||||
if (showItemNumbers) '#',
|
||||
'Date',
|
||||
'Gallons',
|
||||
'Price/Gal',
|
||||
'Total Cost',
|
||||
],
|
||||
data: [
|
||||
for (final entry in row.entries)
|
||||
[
|
||||
if (showItemNumbers) itemNumbers[entry.id].toString(),
|
||||
_dateTimeFormat.format(entry.date),
|
||||
entry.gallons.toStringAsFixed(3),
|
||||
_currencyFormat.format(entry.pricePerGallon),
|
||||
_currencyFormat.format(entry.totalCost),
|
||||
],
|
||||
],
|
||||
cellAlignments: showItemNumbers
|
||||
? const {
|
||||
0: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
4: pw.Alignment.centerRight,
|
||||
}
|
||||
: const {
|
||||
1: pw.Alignment.centerRight,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
},
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
|
||||
cellStyle: const pw.TextStyle(fontSize: 10),
|
||||
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
border: _tableBorder,
|
||||
headerDecoration: _tableHeaderDecoration,
|
||||
rowDecoration: _tableEvenRowDecoration,
|
||||
oddRowDecoration: _tableOddRowDecoration,
|
||||
);
|
||||
}
|
||||
|
||||
/// 140×140 — comfortably legible (the previous design's inline thumbnails,
|
||||
/// at ~34×44, were too small to make out) while still fitting several per
|
||||
/// row on a letter-width page.
|
||||
const _receiptImageSize = 140.0;
|
||||
|
||||
pw.Widget _buildReceiptImagesWrap(
|
||||
VehicleReportRow row,
|
||||
Map<String, Uint8List> receiptImages, {
|
||||
required bool showItemNumbers,
|
||||
required Map<String, int> itemNumbers,
|
||||
}) {
|
||||
return pw.Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
for (final entry in row.entries)
|
||||
if (receiptImages[entry.id] case final bytes?)
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.Container(
|
||||
width: _receiptImageSize,
|
||||
height: _receiptImageSize,
|
||||
decoration: pw.BoxDecoration(
|
||||
color: _cardBackground,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
|
||||
border: pw.Border.all(color: _borderLight, width: 0.5),
|
||||
),
|
||||
child: pw.ClipRRect(
|
||||
horizontalRadius: 6,
|
||||
verticalRadius: 6,
|
||||
child: pw.Image(pw.MemoryImage(bytes), fit: pw.BoxFit.cover),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
showItemNumbers
|
||||
? '#${itemNumbers[entry.id]} · ${_dayMonthFormat.format(entry.date)}'
|
||||
: _dayMonthFormat.format(entry.date),
|
||||
style: pw.TextStyle(fontSize: 9, color: _textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// `fuel-report-<start>-<end>.pdf`, e.g. `fuel-report-20260101-20260331.pdf`.
|
||||
|
|
|
|||
|
|
@ -2,18 +2,24 @@
|
|||
/// sticker, dashboard plate, title, etc).
|
||||
///
|
||||
/// Real VINs never contain the letters I, O, or Q (they're excluded from
|
||||
/// the standard specifically so they can't be confused with 1 and 0), so
|
||||
/// requiring that charset both matches genuine VINs and rules out a lot of
|
||||
/// incidental 17-character noise elsewhere on the sticker (barcodes text,
|
||||
/// weight ratings, date codes, etc).
|
||||
/// the standard specifically so they can't be confused with 1, 0, and 9 —
|
||||
/// which is exactly the kind of confusion OCR itself is prone to: a
|
||||
/// genuine VIN's "0" is sometimes read back as "O"). So rather than reject
|
||||
/// a candidate for containing an I or O, [_normalize] treats it as a
|
||||
/// misread digit and corrects it — Q is left alone, since it doesn't
|
||||
/// closely resemble any digit and so is more likely to mean the candidate
|
||||
/// isn't a VIN at all (e.g. noise from elsewhere on the sticker).
|
||||
class VinParser {
|
||||
// V\.?\s*I\.?\s*N tolerates the "V.I.N" (period between each letter)
|
||||
// styling common on manufacturer compliance/data plates, not just the
|
||||
// plain "VIN" a door-jamb sticker or title usually uses.
|
||||
static final _labeledVinPattern =
|
||||
RegExp(r'VIN[:\s]*([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
|
||||
RegExp(r'V\.?\s*I\.?\s*N[:.\s]*([A-PR-Z0-9]{17})\b', caseSensitive: false);
|
||||
|
||||
// \b on both sides matters: since digits and letters are both "word"
|
||||
// characters, this only matches a maximal run of exactly 17 eligible
|
||||
// characters — not a 17-character slice out of an 18+ character run.
|
||||
static final _bareVinPattern = RegExp(r'\b([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
|
||||
static final _bareVinPattern = RegExp(r'\b([A-PR-Z0-9]{17})\b', caseSensitive: false);
|
||||
|
||||
/// Returns the VIN in uppercase, or null if nothing matching the VIN
|
||||
/// charset/length was found. A labeled "VIN: ..." match is preferred
|
||||
|
|
@ -21,11 +27,24 @@ class VinParser {
|
|||
/// one candidate (e.g. also a 17-digit tire/parts barcode number).
|
||||
static String? parse(String text) {
|
||||
final labeled = _labeledVinPattern.firstMatch(text);
|
||||
if (labeled != null) return labeled.group(1)!.toUpperCase();
|
||||
if (labeled != null) return _normalize(labeled.group(1)!);
|
||||
|
||||
final bare = _bareVinPattern.firstMatch(text);
|
||||
if (bare != null) return bare.group(1)!.toUpperCase();
|
||||
if (bare != null) return _normalize(bare.group(1)!);
|
||||
|
||||
// Last resort: OCR sometimes splits a VIN across a stray space (e.g.
|
||||
// an etched/curved surface gets read as two separate text regions) —
|
||||
// retry once against the text with all whitespace removed. Tried last,
|
||||
// after both whitespace-preserving attempts, since collapsing
|
||||
// whitespace elsewhere in a busy label risks gluing unrelated text
|
||||
// together into a spurious 17-character run.
|
||||
final stripped = text.replaceAll(RegExp(r'\s+'), '');
|
||||
final strippedMatch = _bareVinPattern.firstMatch(stripped);
|
||||
if (strippedMatch != null) return _normalize(strippedMatch.group(1)!);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _normalize(String candidate) =>
|
||||
candidate.toUpperCase().replaceAll('O', '0').replaceAll('I', '1');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,20 @@ class AppTheme {
|
|||
static const _darkOutline = Color(0xFF7E877F); // --text-3
|
||||
static const _darkOutlineVariant = Color(0xFF262926); // --line
|
||||
|
||||
// --- Hero card (used by ReceiptsScreen) ---
|
||||
static const heroGradientLight = [Color(0xFF316F44), Color(0xFF1B4527)]; // --hero-from/to
|
||||
static const heroGradientDark = [Color(0xFF2E6B41), Color(0xFF17331F)];
|
||||
static const heroText = Colors.white; // --hero-text (same in both themes)
|
||||
static const heroTextSecondaryLight = Color(0xC7FFFFFF); // --hero-text-2, 78% white
|
||||
static const heroTextSecondaryDark = Color(0xB8FFFFFF); // --hero-text-2, 72% white
|
||||
|
||||
/// The hero card's gradient colors for whichever brightness is active.
|
||||
static List<Color> heroGradient(BuildContext context) =>
|
||||
Theme.of(context).brightness == Brightness.dark ? heroGradientDark : heroGradientLight;
|
||||
|
||||
static Color heroTextSecondary(BuildContext context) =>
|
||||
Theme.of(context).brightness == Brightness.dark ? heroTextSecondaryDark : heroTextSecondaryLight;
|
||||
|
||||
static ThemeData get light {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: _lightPrimary,
|
||||
|
|
@ -91,6 +105,7 @@ class AppTheme {
|
|||
backgroundColor: colorScheme.surface,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
centerTitle: true,
|
||||
titleTextStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 18,
|
||||
|
|
|
|||
108
lib/widgets/hero_banner.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Green gradient hero banner background shared by every screen that shows
|
||||
/// a totals summary (Receipts, Vehicle Detail) — callers supply their own
|
||||
/// stat row / controls as [child], keeping the container/gradient styling
|
||||
/// itself in one place.
|
||||
class HeroBanner extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const HeroBanner({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: AppTheme.heroGradient(context),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single label/value pair inside a [HeroBanner], e.g. "Total Gallons".
|
||||
class HeroStat extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool alignEnd;
|
||||
|
||||
const HeroStat({super.key, required this.label, required this.value, required this.alignEnd});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textAlign = alignEnd ? TextAlign.end : TextAlign.start;
|
||||
return Column(
|
||||
crossAxisAlignment: alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(
|
||||
color: AppTheme.heroTextSecondary(context),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
textAlign: textAlign,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.heroText,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon + two-stat row shared by every hero banner: gallons on the
|
||||
/// left, the hero icon in the middle, cost on the right.
|
||||
class HeroStatsRow extends StatelessWidget {
|
||||
final String gallonsValue;
|
||||
final String costValue;
|
||||
final String gallonsLabel;
|
||||
final String costLabel;
|
||||
|
||||
const HeroStatsRow({
|
||||
super.key,
|
||||
required this.gallonsValue,
|
||||
required this.costValue,
|
||||
this.gallonsLabel = 'Total Gallons',
|
||||
this.costLabel = 'Total Fuel Cost',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: HeroStat(label: gallonsLabel, value: gallonsValue, alignEnd: false)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
// Tall enough to extend past the bottom of the label+value text
|
||||
// next to it, not just match the label's height — width scales
|
||||
// automatically from the source's own aspect ratio since only
|
||||
// height is given.
|
||||
child: Image.asset(
|
||||
'assets/icon/hero_icon.png',
|
||||
height: 58,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
Expanded(child: HeroStat(label: costLabel, value: costValue, alignEnd: true)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,13 +4,46 @@ import 'package:image_picker/image_picker.dart';
|
|||
/// Bottom sheet letting the user pick a photo from the camera or their
|
||||
/// existing library. Shared by any screen that needs to snap or choose a
|
||||
/// photo (receipt capture, VIN scanning) so the choice looks and behaves
|
||||
/// the same everywhere.
|
||||
Future<ImageSource?> chooseImageSource(BuildContext context) {
|
||||
/// the same everywhere. Pass [heading] to label what the photo is for
|
||||
/// (e.g. receipt capture passes "Receipt From") — omitted by callers where
|
||||
/// that's already obvious from context. [onInfoTap], if given, adds an
|
||||
/// info button next to [heading] (e.g. VIN scanning uses it to explain
|
||||
/// where to find a VIN); ignored if [heading] is null, since there'd be
|
||||
/// nowhere to put it.
|
||||
Future<ImageSource?> chooseImageSource(
|
||||
BuildContext context, {
|
||||
String? heading,
|
||||
VoidCallback? onInfoTap,
|
||||
}) {
|
||||
return showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
if (heading != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
heading,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (onInfoTap != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
iconSize: 20,
|
||||
tooltip: 'Where do I find this?',
|
||||
onPressed: onInfoTap,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined),
|
||||
title: const Text('Take Photo'),
|
||||
|
|
|
|||