feat(v1.1): complete UI overhaul with branded theme, garage, and image uploads
- Custom Material 3 theme with Hot Wheels branding (orange/red gradient, navy accents) - Locally bundled Poppins font (Regular, Medium, SemiBold, Bold) - Redesigned login screen with full-bleed background image and frosted glass form - Bottom navigation shell: My Garage / Scan / Profile tabs - My Garage screen with grid view, search, stats, detail bottom sheet - Edit and delete car details from the detail sheet - Skip button for quick-add with minimal info - Camera photo upload to Supabase Storage (UUID-based unguessable paths) - Change/add photo from car detail view - Profile screen with change password, about dialog, sign out - Scan tab with camera scanner and manual entry - Styled scanner screen with crosshair overlay and gradient buttons - Custom app icon with transparent background and adaptive icon support - Native splash screen with brand colors - Auto-refresh garage on tab switch
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/drawable-night-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -0,0 +1,6 @@
|
|||
<?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>
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/drawable-night/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -0,0 +1,6 @@
|
|||
<?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>
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/drawable-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,6 @@
|
|||
<?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>
|
||||
</layer-list>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 163 KiB |
BIN
android/app/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
|
|
@ -1,12 +1,6 @@
|
|||
<?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>
|
||||
</layer-list>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 77 KiB |
20
android/app/src/main/res/values-night-v31/styles.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?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">#1a1a2e</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
|
||||
|
|
|
|||
20
android/app/src/main/res/values-v31/styles.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?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">#f9a11b</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>
|
||||
4
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#f9a11b</color>
|
||||
</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/fonts/Poppins-Bold.ttf
Normal file
BIN
assets/fonts/Poppins-Medium.ttf
Normal file
BIN
assets/fonts/Poppins-Regular.ttf
Normal file
BIN
assets/fonts/Poppins-SemiBold.ttf
Normal file
BIN
assets/icon/app_icon.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/icon/app_icon_foreground.png
Normal file
|
After Width: | Height: | Size: 748 KiB |
BIN
assets/icon/app_icon_ios.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/img/loadingScreenMainScreen.png
Normal file
|
After Width: | Height: | Size: 8.8 MiB |
BIN
assets/img/login_bg.jpg
Normal file
|
After Width: | Height: | Size: 181 KiB |
|
|
@ -431,7 +431,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++";
|
||||
|
|
@ -488,7 +488,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: 1.4 MiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 51 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,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@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: 69 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 69 B |
|
|
@ -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>
|
||||
|
|
@ -33,5 +39,6 @@
|
|||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
|
|
|||
|
|
@ -66,5 +66,7 @@
|
|||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
335
lib/main.dart
|
|
@ -1,6 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'scanner_screen.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/home_shell.dart';
|
||||
|
||||
// Re-export so other files can `import '../main.dart'` for these.
|
||||
export 'package:supabase_flutter/supabase_flutter.dart'
|
||||
show AuthException, UserAttributes;
|
||||
|
||||
// ── Supabase credentials ──────────────────────────────────────────────
|
||||
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co';
|
||||
|
|
@ -49,22 +55,19 @@ class HWHubApp extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'HW Hub',
|
||||
title: 'HW Collector Hub',
|
||||
debugShowCheckedModeBanner: false,
|
||||
navigatorKey: navigatorKey,
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: ThemeMode.system,
|
||||
home: const AuthGate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth Gate ─────────────────────────────────────────────────────────
|
||||
// Listens to Supabase auth state changes (including deep-link callbacks
|
||||
// for password resets) and routes to Login or Home.
|
||||
class AuthGate extends StatefulWidget {
|
||||
const AuthGate({super.key});
|
||||
|
||||
|
|
@ -80,12 +83,8 @@ class _AuthGateState extends State<AuthGate> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 1. Check for an existing session on cold start.
|
||||
_session = supabase.auth.currentSession;
|
||||
|
||||
// 2. Listen for auth state changes — but only rebuild when login
|
||||
// status actually changes (signed-in ↔ signed-out), NOT on
|
||||
// every token refresh, to avoid tearing down open dialogs.
|
||||
supabase.auth.onAuthStateChange.listen(
|
||||
(AuthState authState) {
|
||||
if (!mounted) return;
|
||||
|
|
@ -94,13 +93,10 @@ class _AuthGateState extends State<AuthGate> {
|
|||
final isLoggedIn = authState.session != null;
|
||||
_session = authState.session;
|
||||
|
||||
// Only rebuild the tree when login state actually flips.
|
||||
if (wasLoggedIn != isLoggedIn) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
// If the user just clicked a password-reset link from their email,
|
||||
// Supabase fires a PASSWORD_RECOVERY event.
|
||||
if (authState.event == AuthChangeEvent.passwordRecovery) {
|
||||
_showResetPasswordDialog();
|
||||
}
|
||||
|
|
@ -110,12 +106,9 @@ class _AuthGateState extends State<AuthGate> {
|
|||
},
|
||||
);
|
||||
|
||||
// Done checking – remove the loading state.
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
/// Shows a dialog so the user can type a new password after clicking
|
||||
/// the "Reset Password" link from their email.
|
||||
Future<void> _showResetPasswordDialog() async {
|
||||
await showDialog<void>(
|
||||
context: navigatorKey.currentContext!,
|
||||
|
|
@ -131,12 +124,7 @@ class _AuthGateState extends State<AuthGate> {
|
|||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_session != null) {
|
||||
return const HomeScreen();
|
||||
}
|
||||
|
||||
return const LoginScreen();
|
||||
return _session != null ? const HomeShell() : const LoginScreen();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,10 +176,7 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
|
|||
content: TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
decoration: const InputDecoration(labelText: 'New password'),
|
||||
onSubmitted: (_) => _save(),
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -213,297 +198,3 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Login Screen ──────────────────────────────────────────────────────
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
/// Sign in with email + password.
|
||||
Future<void> _signIn() async {
|
||||
final email = _emailController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
if (email.isEmpty || password.isEmpty) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await supabase.auth.signInWithPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
// AuthGate listener will pick up the new session automatically.
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
showGlobalSnackBar(e.message, isError: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a password-reset email. The link in the email uses the
|
||||
/// hwcollector://login deep link so it opens back in the app,
|
||||
/// which triggers the PASSWORD_RECOVERY event in AuthGate.
|
||||
Future<void> _forgotPassword() async {
|
||||
final email = _emailController.text.trim();
|
||||
if (email.isEmpty) {
|
||||
showGlobalSnackBar('Enter your email first.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await supabase.auth.resetPasswordForEmail(
|
||||
email,
|
||||
redirectTo: 'hwcollector://login',
|
||||
);
|
||||
showGlobalSnackBar('Password reset email sent! Check your inbox.');
|
||||
} on AuthException catch (e) {
|
||||
showGlobalSnackBar(e.message, isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.directions_car, size: 72, color: Colors.deepPurple),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Hot Wheels Tracker',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _signIn(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: _forgotPassword,
|
||||
child: const Text('Forgot Password?'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _signIn,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Sign In'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Home Screen ───────────────────────────────────────────────────────
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
bool _isBusy = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = supabase.auth.currentUser;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Garage is Online! 🏎️'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () async {
|
||||
await supabase.auth.signOut();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Signed in as ${user?.email ?? 'unknown'}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Icon(Icons.directions_car, size: 80, color: Colors.deepPurple),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Scan or enter a Hot Wheels ID\nto check your collection.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('Scan'),
|
||||
onPressed: _isBusy ? null : _openScanner,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens the scanner, gets the hw_id, then queries the DB.
|
||||
Future<void> _openScanner() async {
|
||||
final hwId = await navigatorKey.currentState!.push<String>(
|
||||
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
||||
);
|
||||
|
||||
if (hwId == null) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final data = await supabase
|
||||
.from('hotwheels')
|
||||
.select()
|
||||
.eq('hw_id', hwId)
|
||||
.maybeSingle();
|
||||
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
|
||||
if (data != null) {
|
||||
await showGlobalDialog(
|
||||
builder: (_) => _AlreadyExistsDialog(hwId: hwId),
|
||||
);
|
||||
} else {
|
||||
final added = await showGlobalDialog<bool>(
|
||||
builder: (_) => _AddCarDialog(hwId: hwId),
|
||||
);
|
||||
if (added == true) {
|
||||
showGlobalSnackBar('$hwId added to your collection! 🎉');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
showGlobalSnackBar('DB error: $e', isError: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── "Already in Collection" Dialog ────────────────────────────────────
|
||||
class _AlreadyExistsDialog extends StatelessWidget {
|
||||
final String hwId;
|
||||
const _AlreadyExistsDialog({required this.hwId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||
title: const Text('Already in Collection!'),
|
||||
content: Text('$hwId is already in your shared garage.'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Got it'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── "Add to Collection" Dialog ────────────────────────────────────────
|
||||
class _AddCarDialog extends StatefulWidget {
|
||||
final String hwId;
|
||||
const _AddCarDialog({required this.hwId});
|
||||
|
||||
@override
|
||||
State<_AddCarDialog> createState() => _AddCarDialogState();
|
||||
}
|
||||
|
||||
class _AddCarDialogState extends State<_AddCarDialog> {
|
||||
bool _isAdding = false;
|
||||
|
||||
Future<void> _addCar() async {
|
||||
setState(() => _isAdding = true);
|
||||
|
||||
try {
|
||||
await supabase.from('hotwheels').insert({
|
||||
'hw_id': widget.hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
});
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isAdding = false);
|
||||
showGlobalSnackBar('Failed to add: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.add_circle_outline, color: Colors.deepPurple, size: 48),
|
||||
title: const Text('New Car Found!'),
|
||||
content: Text('${widget.hwId} is not in your collection yet.\nAdd it now?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isAdding ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _isAdding ? null : _addCar,
|
||||
child: _isAdding
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Add to Collection'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||
import 'theme/app_colors.dart';
|
||||
|
||||
/// Screen that uses the camera to scan text (OCR) from a Hot Wheels package
|
||||
/// and extract the hw_id (e.g. "JKF21").
|
||||
|
|
@ -127,13 +128,24 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Scan Hot Wheels ID')),
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Scan HW ID',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
backgroundColor: Colors.black38,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Camera preview ──
|
||||
Expanded(
|
||||
child: _cameraReady
|
||||
? ClipRect(
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipRect(
|
||||
child: SizedBox.expand(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
|
|
@ -144,6 +156,32 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Scan overlay / crosshair
|
||||
Center(
|
||||
child: Container(
|
||||
width: 240,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: AppColors.orange.withValues(alpha: 0.8),
|
||||
width: 2.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Align ID here',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
|
|
@ -152,21 +190,47 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
if (_lastDetected != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Colors.green.shade50,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.1),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: AppColors.success.withValues(alpha: 0.4),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.green),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check, color: AppColors.success, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Detected: $_lastDetected',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Detected', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
Text(
|
||||
_lastDetected!,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(_lastDetected),
|
||||
child: const Text('Use'),
|
||||
child: const Text('Use This'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -178,27 +242,43 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Manual entry button
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.keyboard),
|
||||
label: const Text('Enter manually'),
|
||||
label: const Text('Manual'),
|
||||
onPressed: () => _showManualEntry(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Capture / scan button
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: _isBusy ? null : AppColors.brandGradient,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
icon: _isBusy
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.camera_alt),
|
||||
label: Text(_isBusy ? 'Scanning...' : 'Scan'),
|
||||
: const Icon(Icons.camera_alt, color: Colors.white),
|
||||
label: Text(
|
||||
_isBusy ? 'Scanning…' : 'Capture & Scan',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
onPressed: _isBusy ? null : _captureAndScan,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -255,7 +335,6 @@ class _ManualEntryDialogState extends State<_ManualEntryDialog> {
|
|||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'e.g. JKF21',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
|
|
|
|||
790
lib/screens/garage_screen.dart
Normal file
|
|
@ -0,0 +1,790 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../main.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/car_card.dart';
|
||||
|
||||
/// The "My Garage" screen — shows the user's collected cars in a grid.
|
||||
class GarageScreen extends StatefulWidget {
|
||||
const GarageScreen({super.key});
|
||||
|
||||
@override
|
||||
State<GarageScreen> createState() => GarageScreenState();
|
||||
}
|
||||
|
||||
class GarageScreenState extends State<GarageScreen> {
|
||||
List<Map<String, dynamic>> _cars = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String _searchQuery = '';
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCars();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Public method so other screens can trigger a refresh.
|
||||
void refresh() => _loadCars();
|
||||
|
||||
Future<void> _loadCars() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
|
||||
final data = await supabase
|
||||
.from('hotwheels')
|
||||
.select()
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_cars = List<Map<String, dynamic>>.from(data);
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _filteredCars {
|
||||
if (_searchQuery.isEmpty) return _cars;
|
||||
final q = _searchQuery.toLowerCase();
|
||||
return _cars.where((car) {
|
||||
final id = (car['hw_id'] as String? ?? '').toLowerCase();
|
||||
final name = (car['name'] as String? ?? '').toLowerCase();
|
||||
final series = (car['series'] as String? ?? '').toLowerCase();
|
||||
return id.contains(q) || name.contains(q) || series.contains(q);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// ── Header ──
|
||||
SliverAppBar(
|
||||
expandedHeight: 140,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
titlePadding:
|
||||
const EdgeInsets.only(left: 20, bottom: 16),
|
||||
title: Text(
|
||||
'My Garage',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 22,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
child: Icon(
|
||||
Icons.directions_car_filled,
|
||||
size: 72,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Stats bar ──
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_StatChip(
|
||||
icon: Icons.directions_car,
|
||||
label: '${_cars.length}',
|
||||
subtitle: 'Total Cars',
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_StatChip(
|
||||
icon: Icons.new_releases,
|
||||
label: _recentCount(),
|
||||
subtitle: 'This Week',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Search bar ──
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (v) => setState(() => _searchQuery = v),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search by ID, name, or series…',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _searchQuery = '');
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Content ──
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_error != null)
|
||||
SliverFillRemaining(
|
||||
child: _ErrorView(
|
||||
message: _error!,
|
||||
onRetry: _loadCars,
|
||||
),
|
||||
)
|
||||
else if (_filteredCars.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _EmptyGarage(hasSearch: _searchQuery.isNotEmpty),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 100),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.72,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final car = _filteredCars[index];
|
||||
return CarCard(
|
||||
hwId: car['hw_id'] as String? ?? '???',
|
||||
name: car['name'] as String?,
|
||||
series: car['series'] as String?,
|
||||
year: car['year'] as int?,
|
||||
color: car['color'] as String?,
|
||||
imageUrl: car['image_url'] as String?,
|
||||
addedAt: car['created_at'] != null
|
||||
? DateTime.tryParse(car['created_at'])
|
||||
: null,
|
||||
onTap: () => _showCarDetails(car),
|
||||
);
|
||||
},
|
||||
childCount: _filteredCars.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _recentCount() {
|
||||
final weekAgo = DateTime.now().subtract(const Duration(days: 7));
|
||||
final count = _cars.where((c) {
|
||||
final ts = c['created_at'];
|
||||
if (ts == null) return false;
|
||||
final d = DateTime.tryParse(ts.toString());
|
||||
return d != null && d.isAfter(weekAgo);
|
||||
}).length;
|
||||
return '$count';
|
||||
}
|
||||
|
||||
void _showCarDetails(Map<String, dynamic> car) {
|
||||
final hwId = car['hw_id'] as String? ?? '???';
|
||||
final name = car['name'] as String?;
|
||||
final series = car['series'] as String?;
|
||||
final year = car['year'] as int?;
|
||||
final notes = car['notes'] as String?;
|
||||
final imageUrl = car['image_url'] as String?;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.55,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.85,
|
||||
expand: false,
|
||||
builder: (context, scrollController) => ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
// Drag handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Car image ──
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 10,
|
||||
child: imageUrl != null && imageUrl.isNotEmpty
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(),
|
||||
)
|
||||
: _imagePlaceholder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Change / Add photo button
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _updatePhoto(car, context),
|
||||
icon: Icon(
|
||||
imageUrl != null ? Icons.camera_alt : Icons.add_a_photo,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(imageUrl != null ? 'Change Photo' : 'Add Photo'),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// ID badge
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
hwId,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (name != null && name.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (series != null && series.isNotEmpty)
|
||||
_DetailRow(icon: Icons.collections, label: 'Series', value: series),
|
||||
if (year != null)
|
||||
_DetailRow(icon: Icons.calendar_today, label: 'Year', value: '$year'),
|
||||
if (notes != null && notes.isNotEmpty)
|
||||
_DetailRow(icon: Icons.notes, label: 'Notes', value: notes),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Edit & Delete buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _editCar(car, context),
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text('Edit Details'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _deleteCar(car, context),
|
||||
icon: const Icon(Icons.delete_outline, color: AppColors.error),
|
||||
label: const Text('Remove',
|
||||
style: TextStyle(color: AppColors.error)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imagePlaceholder() {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Container(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.backgroundLight,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.directions_car_filled,
|
||||
size: 48,
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.15)
|
||||
: AppColors.orange.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Take a new photo and update the image_url for this car.
|
||||
Future<void> _updatePhoto(
|
||||
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||
final picker = ImagePicker();
|
||||
final xFile = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 800,
|
||||
maxHeight: 800,
|
||||
imageQuality: 60,
|
||||
);
|
||||
if (xFile == null) return;
|
||||
|
||||
showGlobalSnackBar('Uploading photo…');
|
||||
|
||||
final oldImageUrl = car['image_url'] as String?;
|
||||
final url = await StorageService.uploadCarImage(
|
||||
file: File(xFile.path),
|
||||
oldImageUrl: oldImageUrl,
|
||||
);
|
||||
|
||||
if (url == null) {
|
||||
showGlobalSnackBar('Failed to upload photo.', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from('hotwheels')
|
||||
.update({'image_url': url})
|
||||
.eq('id', car['id']);
|
||||
|
||||
showGlobalSnackBar('Photo updated!');
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
_loadCars(); // refresh grid
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed to save: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an edit dialog for this car, then update Supabase.
|
||||
Future<void> _editCar(
|
||||
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||
final updated = await showDialog<Map<String, dynamic>>(
|
||||
context: sheetContext,
|
||||
builder: (_) => _EditCarDialog(car: car),
|
||||
);
|
||||
if (updated == null) return;
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from('hotwheels')
|
||||
.update(updated)
|
||||
.eq('id', car['id']);
|
||||
|
||||
showGlobalSnackBar('Car updated!');
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
_loadCars();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed to update: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteCar(
|
||||
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||
if (!sheetContext.mounted) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: sheetContext,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Remove Car?'),
|
||||
content: Text(
|
||||
'Remove ${car['hw_id']} from your garage? This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(sheetContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(sheetContext, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
// Delete image from storage first (best-effort).
|
||||
await StorageService.deleteCarImage(car['image_url'] as String?);
|
||||
|
||||
await supabase
|
||||
.from('hotwheels')
|
||||
.delete()
|
||||
.eq('id', car['id']);
|
||||
|
||||
if (!mounted) return;
|
||||
if (sheetContext.mounted) {
|
||||
Navigator.pop(sheetContext); // close bottom sheet
|
||||
}
|
||||
showGlobalSnackBar('${car['hw_id']} removed from your garage.');
|
||||
_loadCars();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed to remove: $e', isError: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper widgets ──────────────────────────────────────────────────
|
||||
|
||||
class _StatChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String subtitle;
|
||||
const _StatChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardTheme.color,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.orange.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 20, color: AppColors.orange),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyGarage extends StatelessWidget {
|
||||
final bool hasSearch;
|
||||
const _EmptyGarage({required this.hasSearch});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
hasSearch ? Icons.search_off : Icons.garage_outlined,
|
||||
size: 64,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
hasSearch ? 'No cars match your search' : 'Your garage is empty',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
hasSearch
|
||||
? 'Try a different search term'
|
||||
: 'Scan your first Hot Wheels car to get started!',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
const _ErrorView({required this.message, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 56, color: AppColors.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit Car Dialog ─────────────────────────────────────────────────
|
||||
class _EditCarDialog extends StatefulWidget {
|
||||
final Map<String, dynamic> car;
|
||||
const _EditCarDialog({required this.car});
|
||||
|
||||
@override
|
||||
State<_EditCarDialog> createState() => _EditCarDialogState();
|
||||
}
|
||||
|
||||
class _EditCarDialogState extends State<_EditCarDialog> {
|
||||
late final TextEditingController _nameCtrl;
|
||||
late final TextEditingController _seriesCtrl;
|
||||
late final TextEditingController _yearCtrl;
|
||||
late final TextEditingController _notesCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.car['name'] as String? ?? '');
|
||||
_seriesCtrl =
|
||||
TextEditingController(text: widget.car['series'] as String? ?? '');
|
||||
final year = widget.car['year'];
|
||||
_yearCtrl = TextEditingController(text: year != null ? '$year' : '');
|
||||
_notesCtrl =
|
||||
TextEditingController(text: widget.car['notes'] as String? ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_seriesCtrl.dispose();
|
||||
_yearCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final updates = <String, dynamic>{};
|
||||
|
||||
final name = _nameCtrl.text.trim();
|
||||
final series = _seriesCtrl.text.trim();
|
||||
final yearStr = _yearCtrl.text.trim();
|
||||
final notes = _notesCtrl.text.trim();
|
||||
|
||||
updates['name'] = name.isEmpty ? null : name;
|
||||
updates['series'] = series.isEmpty ? null : series;
|
||||
updates['year'] = yearStr.isEmpty ? null : int.tryParse(yearStr);
|
||||
updates['notes'] = notes.isEmpty ? null : notes;
|
||||
|
||||
Navigator.pop(context, updates);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hwId = widget.car['hw_id'] as String? ?? '???';
|
||||
return AlertDialog(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.edit, color: Colors.white, size: 28),
|
||||
),
|
||||
title: Text('Edit $hwId'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Car Name',
|
||||
hintText: "e.g. '70 Dodge Charger",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _seriesCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Series',
|
||||
hintText: 'e.g. HW Flames',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _yearCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Year',
|
||||
hintText: 'e.g. 2025',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _notesCtrl,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes',
|
||||
hintText: 'Any extra info…',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
const _DetailRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.textHint),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'$label: ',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/screens/home_shell.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'garage_screen.dart';
|
||||
import 'scan_tab.dart';
|
||||
import 'profile_screen.dart';
|
||||
|
||||
/// Bottom-navigation shell that hosts the three main tabs.
|
||||
class HomeShell extends StatefulWidget {
|
||||
const HomeShell({super.key});
|
||||
|
||||
@override
|
||||
State<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
int _currentIndex = 0;
|
||||
final _garageKey = GlobalKey<GarageScreenState>();
|
||||
|
||||
late final List<Widget> _pages = <Widget>[
|
||||
GarageScreen(key: _garageKey),
|
||||
const ScanTab(),
|
||||
const ProfileScreen(),
|
||||
];
|
||||
|
||||
void _onTabSelected(int i) {
|
||||
setState(() => _currentIndex = i);
|
||||
// Refresh garage whenever user switches to it.
|
||||
if (i == 0) {
|
||||
_garageKey.currentState?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: _pages,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _onTabSelected,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.garage_outlined),
|
||||
selectedIcon: Icon(Icons.garage),
|
||||
label: 'Garage',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.qr_code_scanner_outlined),
|
||||
selectedIcon: Icon(Icons.qr_code_scanner),
|
||||
label: 'Scan',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: 'Profile',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
342
lib/screens/login_screen.dart
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../main.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// Branded login screen matching the HW Collector Hub email style.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
late final AnimationController _fadeController;
|
||||
late final Animation<double> _fadeAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fadeController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
_fadeAnim = CurvedAnimation(
|
||||
parent: _fadeController,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
_fadeController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fadeController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ── Auth actions ─────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _signIn() async {
|
||||
final email = _emailController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
if (email.isEmpty || password.isEmpty) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await supabase.auth.signInWithPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
showGlobalSnackBar(e.message, isError: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _forgotPassword() async {
|
||||
final email = _emailController.text.trim();
|
||||
if (email.isEmpty) {
|
||||
showGlobalSnackBar('Enter your email first.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await supabase.auth.resetPasswordForEmail(
|
||||
email,
|
||||
redirectTo: 'hwcollector://login',
|
||||
);
|
||||
showGlobalSnackBar('Password reset email sent! Check your inbox.');
|
||||
} on AuthException catch (e) {
|
||||
showGlobalSnackBar(e.message, isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// ── Full-screen background image ──
|
||||
Image.asset(
|
||||
'assets/img/login_bg.jpg',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
|
||||
// ── Dark overlay for readability ──
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.45),
|
||||
),
|
||||
|
||||
// ── Scrollable content ──
|
||||
SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: bottom),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: MediaQuery.of(context).size.height,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: topPadding + 40),
|
||||
|
||||
// ── Brand title ──
|
||||
FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.directions_car_filled,
|
||||
size: 44,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'HW COLLECTOR HUB',
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
letterSpacing: 3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Track your collection',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 36),
|
||||
|
||||
// ── Frosted glass form card ──
|
||||
FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Email
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: _glassInputDecoration(
|
||||
label: 'Email',
|
||||
icon: Icons.email_outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Password
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _signIn(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: _glassInputDecoration(
|
||||
label: 'Password',
|
||||
icon: Icons.lock_outline,
|
||||
).copyWith(
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
color: Colors.white70,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword =
|
||||
!_obscurePassword),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Forgot password
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: _forgotPassword,
|
||||
child: Text(
|
||||
'Forgot Password?',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Sign-in button (gradient)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.orange
|
||||
.withValues(alpha: 0.4),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _signIn,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(50),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Start Your Engines!',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ── Footer ──
|
||||
Text(
|
||||
'© 2026 HW Collector Hub',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Input decoration styled for the frosted-glass card.
|
||||
InputDecoration _glassInputDecoration({
|
||||
required String label,
|
||||
required IconData icon,
|
||||
}) {
|
||||
return InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
|
||||
prefixIcon: Icon(icon, color: Colors.white70),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.08),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.15)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: AppColors.orange, width: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// _GradientHeader is no longer needed — removed.
|
||||
265
lib/screens/profile_screen.dart
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../main.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// Profile / settings tab.
|
||||
class ProfileScreen extends StatelessWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = supabase.auth.currentUser;
|
||||
final email = user?.email ?? 'unknown';
|
||||
final createdAt = user?.createdAt;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// ── Header ──
|
||||
SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
// Avatar
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
size: 44,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
email,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white.withValues(alpha: 0.95),
|
||||
),
|
||||
),
|
||||
if (createdAt != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Member since ${_formatDate(createdAt)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Settings list ──
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Account',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: Icons.lock_outline,
|
||||
title: 'Change Password',
|
||||
onTap: () => _changePassword(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'App',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: Icons.info_outline,
|
||||
title: 'About',
|
||||
subtitle: 'HW Collector Hub v1.1',
|
||||
onTap: () => _showAbout(context),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ── Sign out ──
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await supabase.auth.signOut();
|
||||
},
|
||||
icon: const Icon(Icons.logout, color: AppColors.error),
|
||||
label: const Text(
|
||||
'Sign Out',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppColors.error),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const Center(
|
||||
child: Text(
|
||||
'© 2026 HW Collector Hub',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDate(String iso) {
|
||||
final d = DateTime.tryParse(iso);
|
||||
if (d == null) return iso;
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
return '${months[d.month - 1]} ${d.year}';
|
||||
}
|
||||
|
||||
void _changePassword(BuildContext context) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Change Password'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New password',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final pw = controller.text.trim();
|
||||
if (pw.length < 6) {
|
||||
showGlobalSnackBar('Password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await supabase.auth.updateUser(
|
||||
UserAttributes(password: pw),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
showGlobalSnackBar('Password updated!');
|
||||
} on AuthException catch (e) {
|
||||
showGlobalSnackBar(e.message, isError: true);
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAbout(BuildContext context) {
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'HW Collector Hub',
|
||||
applicationVersion: '1.1.0',
|
||||
applicationIcon: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.directions_car_filled,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
const Text('Track and manage your Hot Wheels collection.'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings tile widget ──────────────────────────────────────────────
|
||||
|
||||
class _SettingsTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SettingsTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, color: AppColors.orange, size: 22),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: subtitle != null
|
||||
? Text(subtitle!, style: const TextStyle(fontSize: 12))
|
||||
: null,
|
||||
trailing: const Icon(Icons.chevron_right, color: AppColors.textHint),
|
||||
onTap: onTap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
449
lib/screens/scan_tab.dart
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../main.dart';
|
||||
import '../scanner_screen.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// The "Scan" tab — quick-access view for scanning / adding cars.
|
||||
class ScanTab extends StatefulWidget {
|
||||
const ScanTab({super.key});
|
||||
|
||||
@override
|
||||
State<ScanTab> createState() => _ScanTabState();
|
||||
}
|
||||
|
||||
class _ScanTabState extends State<ScanTab> {
|
||||
bool _isBusy = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// ── Illustration ──
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.brandGradientSoft,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.qr_code_scanner,
|
||||
size: 56,
|
||||
color: AppColors.orange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
const Text(
|
||||
'Scan a Hot Wheels Car',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Point your camera at the model ID on the\npackaging to instantly add it to your garage.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
|
||||
// ── Scan button (gradient) ──
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.orange.withValues(alpha: 0.35),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isBusy ? null : _openScanner,
|
||||
icon: _isBusy
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.camera_alt, color: Colors.white),
|
||||
label: Text(
|
||||
_isBusy ? 'Processing…' : 'Open Scanner',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Manual entry ──
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isBusy ? null : _manualEntry,
|
||||
icon: const Icon(Icons.keyboard),
|
||||
label: const Text('Enter ID Manually'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openScanner() async {
|
||||
final hwId = await navigatorKey.currentState!.push<String>(
|
||||
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
||||
);
|
||||
|
||||
if (hwId == null || !mounted) return;
|
||||
await _processHwId(hwId);
|
||||
}
|
||||
|
||||
Future<void> _manualEntry() async {
|
||||
final controller = TextEditingController();
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Enter HW ID'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: const InputDecoration(hintText: 'e.g. JKF21'),
|
||||
onSubmitted: (v) {
|
||||
final val = v.trim().toUpperCase();
|
||||
if (val.isNotEmpty) Navigator.pop(context, val);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final val = controller.text.trim().toUpperCase();
|
||||
if (val.isNotEmpty) Navigator.pop(context, val);
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result == null || !mounted) return;
|
||||
await _processHwId(result);
|
||||
}
|
||||
|
||||
Future<void> _processHwId(String hwId) async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final data = await supabase
|
||||
.from('hotwheels')
|
||||
.select()
|
||||
.eq('hw_id', hwId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = false);
|
||||
|
||||
if (data != null) {
|
||||
// Already in collection
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
icon: const Icon(Icons.check_circle,
|
||||
color: AppColors.success, size: 48),
|
||||
title: const Text('Already in Garage!'),
|
||||
content:
|
||||
Text('$hwId is already in your collection.'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Got it'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// New — offer to add
|
||||
final added = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => _AddCarDialog(hwId: hwId),
|
||||
);
|
||||
if (added == true) {
|
||||
showGlobalSnackBar('$hwId added to your garage! 🎉');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
showGlobalSnackBar('DB error: $e', isError: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add Car Dialog (inline, styled) ──────────────────────────────────
|
||||
class _AddCarDialog extends StatefulWidget {
|
||||
final String hwId;
|
||||
const _AddCarDialog({required this.hwId});
|
||||
|
||||
@override
|
||||
State<_AddCarDialog> createState() => _AddCarDialogState();
|
||||
}
|
||||
|
||||
class _AddCarDialogState extends State<_AddCarDialog> {
|
||||
final _nameController = TextEditingController();
|
||||
final _seriesController = TextEditingController();
|
||||
final _yearController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
bool _isAdding = false;
|
||||
File? _pickedImage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_seriesController.dispose();
|
||||
_yearController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final picker = ImagePicker();
|
||||
final xFile = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 800,
|
||||
maxHeight: 800,
|
||||
imageQuality: 60,
|
||||
);
|
||||
if (xFile != null && mounted) {
|
||||
setState(() => _pickedImage = File(xFile.path));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _quickAdd() async {
|
||||
setState(() => _isAdding = true);
|
||||
try {
|
||||
await supabase.from('hotwheels').insert({
|
||||
'hw_id': widget.hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
});
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isAdding = false);
|
||||
showGlobalSnackBar('Failed to add: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _add() async {
|
||||
setState(() => _isAdding = true);
|
||||
|
||||
try {
|
||||
final row = <String, dynamic>{
|
||||
'hw_id': widget.hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
};
|
||||
|
||||
// Optional fields — only include if filled in.
|
||||
final name = _nameController.text.trim();
|
||||
final series = _seriesController.text.trim();
|
||||
final yearStr = _yearController.text.trim();
|
||||
final notes = _notesController.text.trim();
|
||||
|
||||
if (name.isNotEmpty) row['name'] = name;
|
||||
if (series.isNotEmpty) row['series'] = series;
|
||||
if (yearStr.isNotEmpty) {
|
||||
final y = int.tryParse(yearStr);
|
||||
if (y != null) row['year'] = y;
|
||||
}
|
||||
if (notes.isNotEmpty) row['notes'] = notes;
|
||||
|
||||
// Upload image if one was taken.
|
||||
if (_pickedImage != null) {
|
||||
final url = await StorageService.uploadCarImage(
|
||||
file: _pickedImage!,
|
||||
);
|
||||
if (url != null) row['image_url'] = url;
|
||||
}
|
||||
|
||||
await supabase.from('hotwheels').insert(row);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isAdding = false);
|
||||
showGlobalSnackBar('Failed to add: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child:
|
||||
const Icon(Icons.add, color: Colors.white, size: 28),
|
||||
),
|
||||
title: Text('Add ${widget.hwId}'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Photo picker ──
|
||||
GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: AppColors.orange.withValues(alpha: 0.4),
|
||||
width: 1.5,
|
||||
),
|
||||
image: _pickedImage != null
|
||||
? DecorationImage(
|
||||
image: FileImage(_pickedImage!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: _pickedImage == null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_a_photo,
|
||||
size: 36,
|
||||
color: AppColors.orange.withValues(alpha: 0.6)),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Tap to take a photo',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.black54,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close,
|
||||
size: 16, color: Colors.white),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () =>
|
||||
setState(() => _pickedImage = null),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Car Name',
|
||||
hintText: "e.g. '70 Dodge Charger",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _seriesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Series',
|
||||
hintText: 'e.g. HW Flames',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _yearController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Year',
|
||||
hintText: 'e.g. 2025',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes',
|
||||
hintText: 'Any extra info…',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isAdding ? null : () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: _isAdding ? null : _quickAdd,
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _isAdding ? null : _add,
|
||||
child: _isAdding
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Add to Garage'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
72
lib/services/storage_service.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../main.dart';
|
||||
|
||||
/// Handles uploading / deleting car images in Supabase Storage.
|
||||
///
|
||||
/// Bucket: `car-images` (public, but URLs are unguessable)
|
||||
/// Path: `cars/{uuid}.jpg` — random UUID per image.
|
||||
///
|
||||
/// Shared garage — any authenticated user can upload / replace / delete.
|
||||
class StorageService {
|
||||
StorageService._();
|
||||
|
||||
static const _bucket = 'car-images';
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Upload a photo from [file].
|
||||
///
|
||||
/// If [oldImageUrl] is provided the previous file is deleted first.
|
||||
/// Returns the public URL on success, or `null` on failure.
|
||||
static Future<String?> uploadCarImage({
|
||||
required File file,
|
||||
String? oldImageUrl,
|
||||
}) async {
|
||||
try {
|
||||
// Clean up old image if re-uploading.
|
||||
if (oldImageUrl != null) {
|
||||
await _deleteByUrl(oldImageUrl);
|
||||
}
|
||||
|
||||
final path = 'cars/${_uuid.v4()}.jpg';
|
||||
|
||||
await supabase.storage.from(_bucket).upload(
|
||||
path,
|
||||
file,
|
||||
fileOptions: const FileOptions(
|
||||
contentType: 'image/jpeg',
|
||||
),
|
||||
);
|
||||
|
||||
// Return the public URL.
|
||||
final url = supabase.storage.from(_bucket).getPublicUrl(path);
|
||||
return url;
|
||||
} catch (e) {
|
||||
debugPrint('StorageService.uploadCarImage error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the image at the given public [imageUrl].
|
||||
static Future<void> deleteCarImage(String? imageUrl) async {
|
||||
if (imageUrl == null || imageUrl.isEmpty) return;
|
||||
await _deleteByUrl(imageUrl);
|
||||
}
|
||||
|
||||
/// Extract the storage path from a public URL and remove the file.
|
||||
static Future<void> _deleteByUrl(String imageUrl) async {
|
||||
try {
|
||||
// Public URLs look like:
|
||||
// .../storage/v1/object/public/car-images/cars/<uuid>.jpg
|
||||
final marker = '/object/public/$_bucket/';
|
||||
final idx = imageUrl.indexOf(marker);
|
||||
if (idx == -1) return;
|
||||
final path = imageUrl.substring(idx + marker.length);
|
||||
await supabase.storage.from(_bucket).remove([path]);
|
||||
} catch (e) {
|
||||
debugPrint('StorageService._deleteByUrl error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
47
lib/theme/app_colors.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Brand colours pulled from the HW Collector Hub email template.
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// ── Primary gradient (header) ──
|
||||
static const Color orange = Color(0xFFF9A11B);
|
||||
static const Color red = Color(0xFFD40000);
|
||||
|
||||
// ── Accent / CTA ──
|
||||
static const Color navy = Color(0xFF003D7A);
|
||||
static const Color navyLight = Color(0xFF0A5BA8);
|
||||
|
||||
// ── Surfaces ──
|
||||
static const Color backgroundLight = Color(0xFFF4F4F4);
|
||||
static const Color cardLight = Color(0xFFFFFFFF);
|
||||
static const Color footerGrey = Color(0xFFEEEEEE);
|
||||
|
||||
// ── Text ──
|
||||
static const Color textPrimary = Color(0xFF333333);
|
||||
static const Color textSecondary = Color(0xFF777777);
|
||||
static const Color textHint = Color(0xFF999999);
|
||||
|
||||
// ── Dark mode surfaces ──
|
||||
static const Color backgroundDark = Color(0xFF1A1A2E);
|
||||
static const Color cardDark = Color(0xFF24243E);
|
||||
static const Color surfaceDark = Color(0xFF2D2D48);
|
||||
|
||||
// ── Utility ──
|
||||
static const Color success = Color(0xFF2ECC71);
|
||||
static const Color error = Color(0xFFE74C3C);
|
||||
|
||||
/// The brand gradient used for headers, buttons, etc.
|
||||
static const LinearGradient brandGradient = LinearGradient(
|
||||
colors: [orange, red],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
/// A subtler version for cards / chips.
|
||||
static const LinearGradient brandGradientSoft = LinearGradient(
|
||||
colors: [Color(0xFFFFF3E0), Color(0xFFFFEBEE)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
}
|
||||
347
lib/theme/app_theme.dart
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// Central theme definition for HW Collector Hub.
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
// ─────────────────────────── LIGHT ───────────────────────────────────
|
||||
static ThemeData get light {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: AppColors.orange,
|
||||
primary: AppColors.orange,
|
||||
secondary: AppColors.navy,
|
||||
surface: AppColors.cardLight,
|
||||
error: AppColors.error,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
fontFamily: 'Poppins',
|
||||
scaffoldBackgroundColor: AppColors.backgroundLight,
|
||||
|
||||
// ── AppBar ──
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Cards ──
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.cardLight,
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
),
|
||||
|
||||
// ── Elevated buttons ──
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.navy,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Outlined buttons ──
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.navy,
|
||||
side: const BorderSide(color: AppColors.navy),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Text buttons ──
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.navy,
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Input fields ──
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide:
|
||||
const BorderSide(color: AppColors.navy, width: 2),
|
||||
),
|
||||
labelStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
hintStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Bottom Navigation ──
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
indicatorColor: AppColors.orange.withValues(alpha: 0.15),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
color: AppColors.orange,
|
||||
);
|
||||
}
|
||||
return const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
}),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const IconThemeData(color: AppColors.orange);
|
||||
}
|
||||
return const IconThemeData(color: AppColors.textSecondary);
|
||||
}),
|
||||
),
|
||||
|
||||
// ── FAB ──
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: AppColors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
shape: StadiumBorder(),
|
||||
),
|
||||
|
||||
// ── Dialogs ──
|
||||
dialogTheme: DialogThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
titleTextStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Snackbar ──
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentTextStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Divider ──
|
||||
dividerTheme: DividerThemeData(
|
||||
color: Colors.grey.shade200,
|
||||
thickness: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────── DARK ────────────────────────────────────
|
||||
static ThemeData get dark {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: AppColors.orange,
|
||||
brightness: Brightness.dark,
|
||||
primary: AppColors.orange,
|
||||
secondary: AppColors.navyLight,
|
||||
surface: AppColors.cardDark,
|
||||
error: AppColors.error,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
fontFamily: 'Poppins',
|
||||
scaffoldBackgroundColor: AppColors.backgroundDark,
|
||||
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.cardDark,
|
||||
elevation: 4,
|
||||
shadowColor: Colors.black26,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.orange,
|
||||
side: const BorderSide(color: AppColors.orange),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.orange,
|
||||
),
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceDark,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide:
|
||||
const BorderSide(color: AppColors.orange, width: 2),
|
||||
),
|
||||
labelStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: AppColors.cardDark,
|
||||
indicatorColor: AppColors.orange.withValues(alpha: 0.2),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
color: AppColors.orange,
|
||||
);
|
||||
}
|
||||
return const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
);
|
||||
}),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const IconThemeData(color: AppColors.orange);
|
||||
}
|
||||
return const IconThemeData(color: AppColors.textHint);
|
||||
}),
|
||||
),
|
||||
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: AppColors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
shape: StadiumBorder(),
|
||||
),
|
||||
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: AppColors.cardDark,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
titleTextStyle: const TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.surfaceDark,
|
||||
thickness: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
156
lib/widgets/car_card.dart
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// A styled card for displaying a single Hot Wheels car in the garage.
|
||||
class CarCard extends StatelessWidget {
|
||||
final String hwId;
|
||||
final String? name;
|
||||
final String? series;
|
||||
final int? year;
|
||||
final String? color;
|
||||
final String? imageUrl;
|
||||
final DateTime? addedAt;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const CarCard({
|
||||
super.key,
|
||||
required this.hwId,
|
||||
this.name,
|
||||
this.series,
|
||||
this.year,
|
||||
this.color,
|
||||
this.imageUrl,
|
||||
this.addedAt,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Image / placeholder area ──
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 10,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: isDark
|
||||
? LinearGradient(
|
||||
colors: [
|
||||
AppColors.surfaceDark,
|
||||
AppColors.cardDark,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
)
|
||||
: AppColors.brandGradientSoft,
|
||||
),
|
||||
child: imageUrl != null && imageUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_PlaceholderIcon(isDark: isDark),
|
||||
)
|
||||
: _PlaceholderIcon(isDark: isDark),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Info area ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// HW ID badge
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.orange.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
hwId,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.orange,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (name != null && name!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
name!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (series != null && series!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
series!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (year != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_today,
|
||||
size: 12, color: AppColors.textHint),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$year',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder icon when no image is available.
|
||||
class _PlaceholderIcon extends StatelessWidget {
|
||||
final bool isDark;
|
||||
const _PlaceholderIcon({required this.isDark});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.directions_car_filled,
|
||||
size: 48,
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.15)
|
||||
: AppColors.orange.withValues(alpha: 0.3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,13 @@ import Foundation
|
|||
import app_links
|
||||
import file_selector_macos
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
|
|
|||
224
pubspec.lock
|
|
@ -9,6 +9,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ansicolor
|
||||
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
app_links:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -41,6 +49,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -57,6 +81,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cached_network_image
|
||||
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
cached_network_image_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_platform_interface
|
||||
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
cached_network_image_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_web
|
||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
camera:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -105,6 +153,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -153,6 +217,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -246,6 +318,22 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
@ -254,6 +342,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_native_splash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.7"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -328,6 +424,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -344,6 +448,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -408,6 +520,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
jwt_decode:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -504,6 +632,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: octo_image
|
||||
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -608,6 +744,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -632,6 +776,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
postgrest:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -741,6 +893,46 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+2"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -797,6 +989,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -821,6 +1021,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_io
|
||||
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -885,6 +1093,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -933,6 +1149,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
63
pubspec.yaml
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
version: 1.1.0+2
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
|
@ -39,6 +39,11 @@ dependencies:
|
|||
google_mlkit_text_recognition: ^0.14.0
|
||||
image_picker: ^1.1.2
|
||||
permission_handler: ^11.3.1
|
||||
flutter_native_splash: ^2.4.6
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
intl: ^0.20.2
|
||||
cached_network_image: ^3.4.1
|
||||
uuid: ^4.5.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
@ -62,33 +67,35 @@ flutter:
|
|||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- assets/fonts/
|
||||
- assets/img/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
fonts:
|
||||
- family: Poppins
|
||||
fonts:
|
||||
- asset: assets/fonts/Poppins-Regular.ttf
|
||||
weight: 400
|
||||
- asset: assets/fonts/Poppins-Medium.ttf
|
||||
weight: 500
|
||||
- asset: assets/fonts/Poppins-SemiBold.ttf
|
||||
weight: 600
|
||||
- asset: assets/fonts/Poppins-Bold.ttf
|
||||
weight: 700
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
# ── Splash Screen config ──
|
||||
flutter_native_splash:
|
||||
color: "#f9a11b"
|
||||
color_dark: "#1a1a2e"
|
||||
android_12:
|
||||
color: "#f9a11b"
|
||||
color_dark: "#1a1a2e"
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
# ── Launcher Icon config ──
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: "assets/icon/app_icon.png"
|
||||
image_path_ios: "assets/icon/app_icon_ios.png"
|
||||
adaptive_icon_background: "#f9a11b"
|
||||
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!DOCTYPE html><html><head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
|
@ -27,10 +25,82 @@
|
|||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
|
||||
<title>hwhub</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
height: 100%
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background-color: #f9a11b;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.contain {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.stretch {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
}
|
||||
|
||||
.cover {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, 0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.bottomLeft {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.bottomRight {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script id="splash-screen-script">
|
||||
function removeSplashFromWeb() {
|
||||
document.getElementById("splash")?.remove();
|
||||
document.getElementById("splash-branding")?.remove();
|
||||
document.body.style.background = "transparent";
|
||||
}
|
||||
</script>
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
|
|
@ -41,6 +111,7 @@
|
|||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
<script src="flutter_bootstrap.js" async=""></script>
|
||||
|
||||
|
||||
</body></html>
|
||||