merge: review fixes and stability improvements
5
.env/flutter_defines.example.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"SUPABASE_URL": "https://your-project.supabase.co",
|
||||
"SUPABASE_ANON_KEY": "your_anon_key",
|
||||
"SUPABASE_USE_PKCE": "true"
|
||||
}
|
||||
15
.gitignore
vendored
|
|
@ -44,5 +44,16 @@ app.*.map.json
|
|||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
TPB.md
|
||||
supabase_migration.sql
|
||||
/TPB.md
|
||||
/supabase_migration.sql
|
||||
/TPB_APP_CHECKLIST.md
|
||||
|
||||
# Local run configuration with secrets
|
||||
/.vscode/launch.json
|
||||
/.env/flutter_defines.json
|
||||
|
||||
# Local testing artifacts
|
||||
/flutter_*.png
|
||||
/devtools_options.yaml
|
||||
|
||||
FINDINGS.md
|
||||
44
.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Flutter Build APK (Release)",
|
||||
"type": "shell",
|
||||
"command": "flutter",
|
||||
"args": [
|
||||
"build",
|
||||
"apk",
|
||||
"--release",
|
||||
"--dart-define-from-file=.env/flutter_defines.json"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Flutter Build App Bundle (Release)",
|
||||
"type": "shell",
|
||||
"command": "flutter",
|
||||
"args": [
|
||||
"build",
|
||||
"appbundle",
|
||||
"--release",
|
||||
"--dart-define-from-file=.env/flutter_defines.json"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Flutter Build iOS IPA (Release)",
|
||||
"type": "shell",
|
||||
"command": "flutter",
|
||||
"args": [
|
||||
"build",
|
||||
"ipa",
|
||||
"--release",
|
||||
"--dart-define-from-file=.env/flutter_defines.json"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
74
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Contributing Guide
|
||||
|
||||
Thanks for contributing to car64.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Create a branch from `main`:
|
||||
- `feature/<short-name>` for features
|
||||
- `fix/<short-name>` for bug fixes
|
||||
- `docs/<short-name>` for documentation updates
|
||||
- Keep pull requests focused and small when possible.
|
||||
- Write clear commit messages (Conventional Commit style is preferred).
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Install Flutter stable and run `flutter doctor`.
|
||||
2. Copy `.env/flutter_defines.example.json` to `.env/flutter_defines.json`.
|
||||
3. Fill in your own Supabase config values.
|
||||
4. Run:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow existing project style and naming patterns.
|
||||
- Prefer small, explicit methods over deeply nested logic.
|
||||
- Preserve backend contract names (`hotwheels`, RPC names, etc.) unless migration is intentional.
|
||||
|
||||
## Testing & Validation
|
||||
|
||||
Before opening a PR:
|
||||
|
||||
- Run `flutter analyze`
|
||||
- Run available tests
|
||||
- Manually test affected flows (scanner, collections, auth, storage upload)
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
- [ ] Scope is clear and focused
|
||||
- [ ] Analyzer passes
|
||||
- [ ] User-facing strings are intentional and reviewed
|
||||
- [ ] No secrets/config values were committed
|
||||
- [ ] README/docs updated if behavior changed
|
||||
|
||||
## Commit Message Examples
|
||||
|
||||
Preferred format:
|
||||
|
||||
```text
|
||||
type(scope): short summary
|
||||
```
|
||||
|
||||
Common types used in this repository:
|
||||
|
||||
- `feat`: new feature
|
||||
- `fix`: bug fix
|
||||
- `perf`: performance improvement
|
||||
- `docs`: documentation-only change
|
||||
- `refactor`: code cleanup without behavior change
|
||||
- `test`: tests added/updated
|
||||
- `chore`: maintenance/tooling/config updates
|
||||
|
||||
Examples:
|
||||
|
||||
- `feat(scanner): add adaptive cooldown for repeated OCR misses`
|
||||
- `fix(garage): prevent duplicate copy into target collection`
|
||||
- `perf(collections): coalesce overlapping refresh requests`
|
||||
- `docs(readme): add VS Code release build task usage`
|
||||
- `refactor(auth): simplify session guard flow in AuthGate`
|
||||
- `test(utils): cover scanner id extraction edge cases`
|
||||
- `chore(vscode): add release build tasks for apk and appbundle`
|
||||
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Lukas Müllner @derkauzigekoala
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
201
README.md
|
|
@ -1,17 +1,198 @@
|
|||
# hwhub
|
||||
<p align="center">
|
||||
<img src="assets/icon/app_icon.png" alt="car64 logo" width="120" />
|
||||
</p>
|
||||
|
||||
A new Flutter project.
|
||||
<h1 align="center">car64</h1>
|
||||
|
||||
<p align="center">
|
||||
A modern Flutter + Supabase app for tracking and managing die-cast car collections.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Configuration](#configuration)
|
||||
- [Getting Started](#getting-started)
|
||||
- [VS Code Workflows](#vs-code-workflows)
|
||||
- [Project Structure (high level)](#project-structure-high-level)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Release Checklist](#release-checklist)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Contributing](#contributing)
|
||||
- [Security Notes](#security-notes)
|
||||
- [License](#license)
|
||||
- [Reference](#reference)
|
||||
|
||||
## Overview
|
||||
|
||||
car64 helps collectors scan model IDs, organize personal and shared collections, and keep a clean catalog with community validation/reporting flows.
|
||||
|
||||
## Features
|
||||
|
||||
- Fast scan workflow (camera OCR + manual entry fallback)
|
||||
- Multi-collection support with member roles (owner/member/viewer)
|
||||
- Private image storage with signed URL access
|
||||
- Collection collaboration and member management
|
||||
- Community validation and issue reporting for catalog entries
|
||||
- Profile/settings flows including password updates and report tracking
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Flutter (Material 3)
|
||||
- Supabase (Auth, PostgREST, Storage, RPC)
|
||||
- Shared Preferences (local settings)
|
||||
- Google ML Kit Text Recognition (scanner)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Flutter SDK (stable)
|
||||
- A Supabase project
|
||||
- For iOS builds: macOS + Xcode
|
||||
|
||||
## Configuration
|
||||
|
||||
Supabase config is required at runtime/build time (no embedded fallback values).
|
||||
|
||||
### Option A: local defines file (recommended)
|
||||
|
||||
Use:
|
||||
|
||||
- `.env/flutter_defines.json` (local, ignored by git)
|
||||
- [.env/flutter_defines.example.json](.env/flutter_defines.example.json) (tracked template)
|
||||
|
||||
Expected shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"SUPABASE_URL": "https://your-project.supabase.co",
|
||||
"SUPABASE_ANON_KEY": "your_anon_key",
|
||||
"SUPABASE_USE_PKCE": "true"
|
||||
}
|
||||
```
|
||||
|
||||
### Option B: direct dart-define flags
|
||||
|
||||
```bash
|
||||
flutter run \
|
||||
--dart-define=SUPABASE_URL=https://your-project.supabase.co \
|
||||
--dart-define=SUPABASE_ANON_KEY=your_anon_key \
|
||||
--dart-define=SUPABASE_USE_PKCE=true
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
1. Install dependencies
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
2. Run analyze
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
3. Launch app
|
||||
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
## VS Code Workflows
|
||||
|
||||
### Run / Debug (`launch.json`)
|
||||
|
||||
Use **Run and Debug** with your local launch config.
|
||||
|
||||
- `Flutter (Supabase Local - Debug)`
|
||||
- `Flutter (Supabase Local - Profile)`
|
||||
- `Flutter (Supabase Local - Release)`
|
||||
|
||||
These configurations read:
|
||||
|
||||
`--dart-define-from-file=.env/flutter_defines.json`
|
||||
|
||||
### Build Tasks (`tasks.json`)
|
||||
|
||||
Use **Terminal → Run Task**:
|
||||
|
||||
- `Flutter Build APK (Release)`
|
||||
- `Flutter Build App Bundle (Release)`
|
||||
- `Flutter Build iOS IPA (Release)`
|
||||
|
||||
Task definition file: [.vscode/tasks.json](.vscode/tasks.json)
|
||||
|
||||
## Project Structure (high level)
|
||||
|
||||
- `lib/screens/` UI screens and flows
|
||||
- `lib/services/` Supabase integration/services
|
||||
- `lib/widgets/` reusable UI components
|
||||
- `lib/utils/` helpers and formatting utilities
|
||||
- `lib/theme/` app theme and colors
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App fails at startup with Supabase config error
|
||||
|
||||
- Ensure `.env/flutter_defines.json` exists locally.
|
||||
- Confirm all required keys are present:
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_ANON_KEY`
|
||||
- `SUPABASE_USE_PKCE`
|
||||
|
||||
### Build task works but app cannot connect to backend
|
||||
|
||||
- Verify the Supabase URL/key pair belong to the same project.
|
||||
- Check Supabase RLS policies and RPC permissions.
|
||||
|
||||
### iOS IPA task fails on Windows
|
||||
|
||||
- `flutter build ipa` requires macOS + Xcode.
|
||||
|
||||
## Release Checklist
|
||||
|
||||
- [ ] `flutter pub get`
|
||||
- [ ] `flutter analyze`
|
||||
- [ ] Manual smoke test on Android
|
||||
- [ ] Manual smoke test on iOS
|
||||
- [ ] Confirm `.env/flutter_defines.json` points to production Supabase
|
||||
- [ ] Build Android `appbundle`
|
||||
- [ ] Build iOS `ipa`
|
||||
- [ ] Verify auth, scan flow, collections, and upload flows
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Optional dark/light theme toggle in settings
|
||||
- [ ] Extended scanner confidence hints and retry UX
|
||||
- [ ] Bulk actions and better collection analytics
|
||||
- [ ] Improved offline behavior for low-connectivity sessions
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for branching strategy, commit message rules, and PR guidelines.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Supabase anon keys are intentionally client-side, but RLS and RPC permissions must be strict.
|
||||
- Sensitive local config files are git-ignored.
|
||||
- User-facing errors are sanitized and shown via global overlays.
|
||||
|
||||
For reporting vulnerabilities, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
See [LICENSE](LICENSE) for details.
|
||||
|
||||
## Reference
|
||||
|
||||
- Contributor guidelines: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Security policy: [SECURITY.md](SECURITY.md)
|
||||
- DB schema export query pack: [db/export_schema.sql](db/export_schema.sql)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
|
|
|
|||
31
SECURITY.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
This project currently supports the latest active branch in this repository.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security issue, please do not open a public issue with exploit details.
|
||||
|
||||
Preferred process:
|
||||
|
||||
1. Share a private report with:
|
||||
- A clear description of the issue
|
||||
- Reproduction steps
|
||||
- Impact assessment
|
||||
- Suggested fix (if available)
|
||||
2. Allow time for triage and remediation before public disclosure.
|
||||
|
||||
## Scope Notes
|
||||
|
||||
- Supabase anon keys are client-side by design and are not secret credentials.
|
||||
- Real protection depends on strict RLS policies, RPC authorization, and server-side validation.
|
||||
- Local config files with runtime values should stay out of version control.
|
||||
|
||||
## Recommended Hardening
|
||||
|
||||
- Keep Supabase keys in local/CI `dart-define` configuration only.
|
||||
- Rotate keys when moving between environments or if misuse is suspected.
|
||||
- Audit RLS policies after every schema/function change.
|
||||
- Sanitize user-facing error messages (avoid leaking backend internals).
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="HW Collector Hub"
|
||||
android:label="car64"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
|
|
|||
BIN
android/app/src/main/res/drawable-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 61 KiB |
BIN
android/app/src/main/res/drawable-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
android/app/src/main/res/drawable-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 29 KiB |
BIN
android/app/src/main/res/drawable-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
android/app/src/main/res/drawable-night-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
android/app/src/main/res/drawable-night-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
android/app/src/main/res/drawable-night-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
android/app/src/main/res/drawable-night-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
|
@ -3,4 +3,7 @@
|
|||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 135 KiB |
BIN
android/app/src/main/res/drawable-night-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 276 KiB |
BIN
android/app/src/main/res/drawable-night-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 458 KiB |
BIN
android/app/src/main/res/drawable-night-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
|
|
@ -3,4 +3,7 @@
|
|||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
|
|||
|
|
@ -3,4 +3,7 @@
|
|||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
|
|||
BIN
android/app/src/main/res/drawable-xhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 103 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 214 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 354 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
|
|
@ -3,4 +3,7 @@
|
|||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
|
|||
|
|
@ -1,5 +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 android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 84 KiB |
|
|
@ -7,6 +7,7 @@
|
|||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#1a1a2e</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#f9a11b</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#00000000</color>
|
||||
<color name="ic_launcher_background">#f9a11b</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 748 KiB After Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.9 MiB |
BIN
assets/img/icon_bg_removed.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 8.8 MiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 433 KiB |
114
db/export_schema.sql
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
-- Run in Supabase SQL Editor or psql and export results as CSV/JSON.
|
||||
|
||||
-- 1) App tables and columns (public only)
|
||||
select
|
||||
c.table_name,
|
||||
c.ordinal_position,
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.udt_name,
|
||||
c.is_nullable,
|
||||
c.column_default
|
||||
from information_schema.columns c
|
||||
join information_schema.tables t
|
||||
on t.table_schema = c.table_schema
|
||||
and t.table_name = c.table_name
|
||||
where c.table_schema = 'public'
|
||||
and t.table_type = 'BASE TABLE'
|
||||
order by c.table_name, c.ordinal_position;
|
||||
|
||||
-- 2) Constraints
|
||||
select
|
||||
rel.relname as table_name,
|
||||
con.conname as constraint_name,
|
||||
case con.contype
|
||||
when 'p' then 'PRIMARY KEY'
|
||||
when 'u' then 'UNIQUE'
|
||||
when 'f' then 'FOREIGN KEY'
|
||||
when 'c' then 'CHECK'
|
||||
else con.contype::text
|
||||
end as constraint_type,
|
||||
pg_get_constraintdef(con.oid, true) as definition
|
||||
from pg_constraint con
|
||||
join pg_class rel on rel.oid = con.conrelid
|
||||
join pg_namespace n on n.oid = rel.relnamespace
|
||||
where n.nspname = 'public'
|
||||
order by rel.relname, con.conname;
|
||||
|
||||
-- 3) Indexes
|
||||
select
|
||||
tablename,
|
||||
indexname,
|
||||
indexdef
|
||||
from pg_indexes
|
||||
where schemaname = 'public'
|
||||
order by tablename, indexname;
|
||||
|
||||
-- 4) Views
|
||||
select
|
||||
c.relname as view_name,
|
||||
case c.relkind when 'v' then 'VIEW' when 'm' then 'MATERIALIZED VIEW' end as view_type,
|
||||
pg_get_viewdef(c.oid, true) as definition
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'public'
|
||||
and c.relkind in ('v', 'm')
|
||||
order by c.relname;
|
||||
|
||||
-- 5) Functions / Procedures
|
||||
select
|
||||
p.proname as routine_name,
|
||||
case p.prokind when 'p' then 'PROCEDURE' else 'FUNCTION' end as routine_type,
|
||||
l.lanname as language,
|
||||
pg_get_function_identity_arguments(p.oid) as args,
|
||||
pg_get_functiondef(p.oid) as definition
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
join pg_language l on l.oid = p.prolang
|
||||
where n.nspname = 'public'
|
||||
order by p.proname;
|
||||
|
||||
-- 6) Triggers
|
||||
select
|
||||
c.relname as table_name,
|
||||
t.tgname as trigger_name,
|
||||
pg_get_triggerdef(t.oid, true) as definition
|
||||
from pg_trigger t
|
||||
join pg_class c on c.oid = t.tgrelid
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'public'
|
||||
and not t.tgisinternal
|
||||
order by c.relname, t.tgname;
|
||||
|
||||
-- 7) RLS status
|
||||
select
|
||||
c.relname as table_name,
|
||||
c.relrowsecurity as rls_enabled,
|
||||
c.relforcerowsecurity as rls_forced
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'public'
|
||||
and c.relkind = 'r'
|
||||
order by c.relname;
|
||||
|
||||
-- 8) RLS policies
|
||||
select
|
||||
tablename,
|
||||
policyname,
|
||||
permissive,
|
||||
roles,
|
||||
cmd,
|
||||
qual,
|
||||
with_check
|
||||
from pg_policies
|
||||
where schemaname = 'public'
|
||||
order by tablename, policyname;
|
||||
|
||||
-- 9) Grants (tables)
|
||||
select
|
||||
table_name,
|
||||
privilege_type,
|
||||
grantee
|
||||
from information_schema.role_table_grants
|
||||
where table_schema = 'public'
|
||||
order by table_name, grantee, privilege_type;
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 63 KiB |
|
|
@ -5,15 +5,48 @@
|
|||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 276 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png
vendored
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 276 KiB |
|
|
@ -38,7 +38,7 @@
|
|||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
<image name="LaunchImage" width="500" height="500"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>HW Collector Hub</string>
|
||||
<string>car64</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>HW Collector Hub</string>
|
||||
<string>car64</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
|
|
|||
253
lib/main.dart
|
|
@ -1,26 +1,55 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'services/collection_service.dart';
|
||||
import 'services/main_collection_sync.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/home_shell.dart';
|
||||
import 'utils/error_utils.dart';
|
||||
import 'utils/preferences_utils.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';
|
||||
const _supabaseAnonKey = 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL';
|
||||
const _supabaseUrl = String.fromEnvironment(
|
||||
'SUPABASE_URL',
|
||||
defaultValue: '',
|
||||
);
|
||||
const _supabaseAnonKey = String.fromEnvironment(
|
||||
'SUPABASE_ANON_KEY',
|
||||
defaultValue: '',
|
||||
);
|
||||
const _usePkceAuthFlow = bool.fromEnvironment(
|
||||
'SUPABASE_USE_PKCE',
|
||||
defaultValue: true,
|
||||
);
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (_supabaseUrl.trim().isEmpty || _supabaseAnonKey.trim().isEmpty) {
|
||||
throw StateError(
|
||||
'Missing Supabase configuration. Provide --dart-define=SUPABASE_URL and '
|
||||
'--dart-define=SUPABASE_ANON_KEY.',
|
||||
);
|
||||
}
|
||||
|
||||
await Supabase.initialize(
|
||||
url: _supabaseUrl,
|
||||
anonKey: _supabaseAnonKey,
|
||||
authOptions: FlutterAuthClientOptions(
|
||||
authFlowType: _usePkceAuthFlow
|
||||
? AuthFlowType.pkce
|
||||
: AuthFlowType.implicit,
|
||||
),
|
||||
);
|
||||
|
||||
runApp(const HWHubApp());
|
||||
runApp(const Car64App());
|
||||
}
|
||||
|
||||
/// Convenience accessor used throughout the app.
|
||||
|
|
@ -29,33 +58,133 @@ final supabase = Supabase.instance.client;
|
|||
/// Global keys so dialogs & snackbars survive widget-tree rebuilds.
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
OverlayEntry? _activeMessageOverlay;
|
||||
Timer? _activeMessageOverlayTimer;
|
||||
|
||||
enum GlobalMessageType { info, success, error }
|
||||
|
||||
/// Show a snackbar safely through the global key.
|
||||
void showGlobalSnackBar(String message, {bool isError = false}) {
|
||||
scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red : null,
|
||||
_showGlobalMessageOverlay(
|
||||
message,
|
||||
type: isError ? GlobalMessageType.error : GlobalMessageType.info,
|
||||
);
|
||||
}
|
||||
|
||||
void showGlobalSuccess(String message) {
|
||||
_showGlobalMessageOverlay(message, type: GlobalMessageType.success);
|
||||
}
|
||||
|
||||
void showGlobalInfo(String message) {
|
||||
_showGlobalMessageOverlay(message, type: GlobalMessageType.info);
|
||||
}
|
||||
|
||||
void _showGlobalMessageOverlay(
|
||||
String message, {
|
||||
required GlobalMessageType type,
|
||||
}) {
|
||||
final overlay = navigatorKey.currentState?.overlay;
|
||||
if (overlay == null) return;
|
||||
|
||||
_activeMessageOverlayTimer?.cancel();
|
||||
_activeMessageOverlay?.remove();
|
||||
|
||||
final backgroundColor = switch (type) {
|
||||
GlobalMessageType.error => Colors.red,
|
||||
GlobalMessageType.success => Colors.green,
|
||||
GlobalMessageType.info => const Color(0xFF1F2937),
|
||||
};
|
||||
final leadingIcon = switch (type) {
|
||||
GlobalMessageType.error => Icons.error_outline,
|
||||
GlobalMessageType.success => Icons.check_circle_outline,
|
||||
GlobalMessageType.info => Icons.info_outline,
|
||||
};
|
||||
|
||||
_activeMessageOverlay = OverlayEntry(
|
||||
builder: (context) {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
return Positioned(
|
||||
top: topPadding + 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: IgnorePointer(
|
||||
ignoring: true,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(leadingIcon, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
overlay.insert(_activeMessageOverlay!);
|
||||
|
||||
_activeMessageOverlayTimer = Timer(const Duration(seconds: 4), () {
|
||||
_activeMessageOverlay?.remove();
|
||||
_activeMessageOverlay = null;
|
||||
_activeMessageOverlayTimer = null;
|
||||
});
|
||||
}
|
||||
|
||||
void showGlobalError(
|
||||
Object error, {
|
||||
String fallback = 'Something went wrong. Please try again.',
|
||||
}) {
|
||||
logError('ui', error);
|
||||
showGlobalSnackBar(
|
||||
userMessageForError(error, fallback: fallback),
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a dialog safely through the global navigator key.
|
||||
Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
return Future<T?>.value(null);
|
||||
}
|
||||
return showDialog<T>(
|
||||
context: navigatorKey.currentContext!,
|
||||
context: context,
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Root App Widget ───────────────────────────────────────────────────
|
||||
class HWHubApp extends StatelessWidget {
|
||||
const HWHubApp({super.key});
|
||||
class Car64App extends StatelessWidget {
|
||||
const Car64App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'HW Collector Hub',
|
||||
title: 'car64',
|
||||
debugShowCheckedModeBanner: false,
|
||||
navigatorKey: navigatorKey,
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
|
|
@ -77,15 +206,19 @@ class AuthGate extends StatefulWidget {
|
|||
|
||||
class _AuthGateState extends State<AuthGate> {
|
||||
bool _isLoading = true;
|
||||
bool _isInPasswordRecoveryFlow = false;
|
||||
Session? _session;
|
||||
String? _lastEnsuredUserId;
|
||||
StreamSubscription<AuthState>? _authStateSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_session = supabase.auth.currentSession;
|
||||
_ensureDefaultCollectionIfNeeded();
|
||||
|
||||
supabase.auth.onAuthStateChange.listen(
|
||||
_authStateSubscription = supabase.auth.onAuthStateChange.listen(
|
||||
(AuthState authState) {
|
||||
if (!mounted) return;
|
||||
|
||||
|
|
@ -97,24 +230,113 @@ class _AuthGateState extends State<AuthGate> {
|
|||
setState(() {});
|
||||
}
|
||||
|
||||
_ensureDefaultCollectionIfNeeded();
|
||||
|
||||
if (authState.event == AuthChangeEvent.passwordRecovery) {
|
||||
setState(() => _isInPasswordRecoveryFlow = true);
|
||||
_showResetPasswordDialog();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
showGlobalSnackBar('Auth error: $error', isError: true);
|
||||
showGlobalError(
|
||||
error,
|
||||
fallback: 'Authentication error. Please sign in again.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authStateSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _ensureDefaultCollectionIfNeeded() async {
|
||||
final userId = _session?.user.id;
|
||||
// Intentional policy: attempt bootstrap once per user per app session.
|
||||
// This avoids repeated retries/toasts when backend issues are transient.
|
||||
// Users can recover on next session or via a manual retry entry point.
|
||||
if (userId == null || userId == _lastEnsuredUserId) return;
|
||||
|
||||
_lastEnsuredUserId = userId;
|
||||
try {
|
||||
final defaultCollectionId = await CollectionService.ensureDefaultCollection();
|
||||
await _ensureMainCollectionPreference(defaultCollectionId);
|
||||
} catch (e) {
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Collection setup failed. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureMainCollectionPreference(String? defaultCollectionId) async {
|
||||
final userId = _session?.user.id;
|
||||
if (userId == null) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final persisted = await readActiveCollectionId(prefs, userId: userId);
|
||||
|
||||
Future<bool> hasMembership(String collectionId) async {
|
||||
final membership = await supabase
|
||||
.from('collection_members')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('collection_id', collectionId)
|
||||
.maybeSingle();
|
||||
return membership != null;
|
||||
}
|
||||
|
||||
if (persisted != null && await hasMembership(persisted)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String? nextActiveId;
|
||||
if (defaultCollectionId != null && await hasMembership(defaultCollectionId)) {
|
||||
nextActiveId = defaultCollectionId;
|
||||
} else {
|
||||
final firstMembership = await supabase
|
||||
.from('collection_members')
|
||||
.select('collection_id')
|
||||
.eq('user_id', userId)
|
||||
.limit(1);
|
||||
if (firstMembership.isNotEmpty) {
|
||||
nextActiveId = firstMembership.first['collection_id'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextActiveId == null) {
|
||||
await clearActiveCollectionId(prefs, userId: userId);
|
||||
return;
|
||||
}
|
||||
|
||||
await writeActiveCollectionId(
|
||||
prefs,
|
||||
userId: userId,
|
||||
collectionId: nextActiveId,
|
||||
);
|
||||
MainCollectionSync.notifyChanged();
|
||||
}
|
||||
|
||||
Future<void> _showResetPasswordDialog() async {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
_isInPasswordRecoveryFlow = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await showDialog<void>(
|
||||
context: navigatorKey.currentContext!,
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _ResetPasswordDialog(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isInPasswordRecoveryFlow = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -124,6 +346,7 @@ class _AuthGateState extends State<AuthGate> {
|
|||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (_isInPasswordRecoveryFlow) return const LoginScreen();
|
||||
return _session != null ? const HomeShell() : const LoginScreen();
|
||||
}
|
||||
}
|
||||
|
|
@ -161,7 +384,7 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
|
|||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showGlobalSnackBar('Password updated successfully!');
|
||||
showGlobalSuccess('Password updated successfully!');
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
|
|
|
|||
|
|
@ -1,89 +1,200 @@
|
|||
import 'dart:async';
|
||||
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 'services/collection_service.dart';
|
||||
import 'theme/app_colors.dart';
|
||||
import 'utils/error_utils.dart';
|
||||
import 'utils/scanner_utils.dart';
|
||||
|
||||
/// Screen that uses the camera to scan text (OCR) from a Hot Wheels package
|
||||
/// Screen that uses the camera to scan text (OCR) from a die-cast package
|
||||
/// and extract the hw_id (e.g. "JKF21").
|
||||
///
|
||||
/// The detected ID is returned via Navigator.pop(context, hwId).
|
||||
class ScannerScreen extends StatefulWidget {
|
||||
const ScannerScreen({super.key});
|
||||
final Future<bool> Function(String hwId)? onDetected;
|
||||
final List<Collection> collections;
|
||||
final String? activeCollectionId;
|
||||
final ValueChanged<String>? onCollectionChanged;
|
||||
|
||||
const ScannerScreen({
|
||||
super.key,
|
||||
this.onDetected,
|
||||
this.collections = const [],
|
||||
this.activeCollectionId,
|
||||
this.onCollectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScannerScreen> createState() => _ScannerScreenState();
|
||||
}
|
||||
|
||||
class _ScannerScreenState extends State<ScannerScreen> {
|
||||
class _ScannerScreenState extends State<ScannerScreen>
|
||||
with WidgetsBindingObserver {
|
||||
static const _autoScanTickInterval = Duration(milliseconds: 700);
|
||||
static const _scanCooldownSuccess = Duration(milliseconds: 1500);
|
||||
static const _scanCooldownNoMatch = Duration(milliseconds: 2200);
|
||||
static const _scanCooldownError = Duration(milliseconds: 2600);
|
||||
static const _scanCooldownNoMatchMax = Duration(milliseconds: 5000);
|
||||
|
||||
CameraController? _cameraController;
|
||||
late final TextRecognizer _textRecognizer;
|
||||
bool _isBusy = false;
|
||||
bool _cameraReady = false;
|
||||
String? _lastDetected;
|
||||
|
||||
// Matches typical Hot Wheels model IDs: 2–5 uppercase letters followed by
|
||||
// 2–4 digits, e.g. JKF21, HCV73, GRX33, FYD83.
|
||||
final _hwIdPattern = RegExp(r'\b([A-Z]{2,5}\d{2,4})\b');
|
||||
bool _scanAccepted = false;
|
||||
bool _scanNotFound = false;
|
||||
String _statusText = 'Ready to scan';
|
||||
String? _activeCollectionId;
|
||||
bool _autoScanEnabled = false;
|
||||
Timer? _autoScanTimer;
|
||||
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
bool _isInitializingCamera = false;
|
||||
String? _cameraError;
|
||||
int _consecutiveMisses = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_activeCollectionId = widget.activeCollectionId;
|
||||
_autoScanEnabled = widget.onDetected != null;
|
||||
_textRecognizer = TextRecognizer();
|
||||
_initCamera();
|
||||
}
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No camera available')),
|
||||
);
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_disposeCamera();
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the first back-facing camera.
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_initCamera(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleCollectionChanged(String? value) {
|
||||
if (value == null) return;
|
||||
setState(() => _activeCollectionId = value);
|
||||
widget.onCollectionChanged?.call(value);
|
||||
}
|
||||
|
||||
Future<void> _initCamera({bool force = false}) async {
|
||||
if (_isInitializingCamera) return;
|
||||
if (_cameraReady && !force) return;
|
||||
|
||||
_isInitializingCamera = true;
|
||||
try {
|
||||
await _disposeCamera();
|
||||
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) {
|
||||
throw const ValidationException('No camera available');
|
||||
}
|
||||
|
||||
final backCamera = cameras.firstWhere(
|
||||
(c) => c.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => cameras.first,
|
||||
);
|
||||
|
||||
_cameraController = CameraController(
|
||||
final controller = CameraController(
|
||||
backCamera,
|
||||
ResolutionPreset.high,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
);
|
||||
|
||||
await _cameraController!.initialize();
|
||||
if (!mounted) return;
|
||||
setState(() => _cameraReady = true);
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
/// Capture a photo, run OCR, and look for a Hot Wheels ID.
|
||||
_cameraController = controller;
|
||||
setState(() {
|
||||
_cameraReady = true;
|
||||
_cameraError = null;
|
||||
_statusText = 'Ready to scan';
|
||||
});
|
||||
_startAutoScanLoop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_cameraReady = false;
|
||||
_cameraError = 'Camera unavailable. Please retry.';
|
||||
_statusText = 'Camera unavailable';
|
||||
});
|
||||
logError('scanner.initCamera', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
userMessageForError(
|
||||
e,
|
||||
fallback: 'Failed to start camera. Please try again.',
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_isInitializingCamera = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disposeCamera() async {
|
||||
_autoScanTimer?.cancel();
|
||||
_autoScanTimer = null;
|
||||
final controller = _cameraController;
|
||||
_cameraController = null;
|
||||
_cameraReady = false;
|
||||
if (controller != null) {
|
||||
await controller.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void _startAutoScanLoop() {
|
||||
_autoScanTimer?.cancel();
|
||||
_autoScanTimer = Timer.periodic(_autoScanTickInterval, (_) {
|
||||
if (!_autoScanEnabled || _isBusy || !_cameraReady || !mounted) return;
|
||||
if (DateTime.now().isBefore(_nextScanAllowedAt)) return;
|
||||
_captureAndScan();
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleAutoScan() {
|
||||
setState(() {
|
||||
_autoScanEnabled = !_autoScanEnabled;
|
||||
_scanNotFound = false;
|
||||
_statusText = _autoScanEnabled ? 'Auto scan enabled' : 'Auto scan paused';
|
||||
});
|
||||
}
|
||||
|
||||
/// Capture a photo, run OCR, and look for a die-cast model ID.
|
||||
Future<void> _captureAndScan() async {
|
||||
if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return;
|
||||
if (_cameraController!.value.isTakingPicture) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
setState(() {
|
||||
_isBusy = true;
|
||||
_scanNotFound = false;
|
||||
_statusText = 'Scanning…';
|
||||
});
|
||||
|
||||
try {
|
||||
final xFile = await _cameraController!.takePicture();
|
||||
final inputImage = InputImage.fromFilePath(xFile.path);
|
||||
final recognized = await _textRecognizer.processImage(inputImage);
|
||||
|
||||
// Search all recognized text blocks for something matching the HW ID pattern.
|
||||
String? found;
|
||||
for (final block in recognized.blocks) {
|
||||
for (final line in block.lines) {
|
||||
final match = _hwIdPattern.firstMatch(line.text.toUpperCase());
|
||||
if (match != null) {
|
||||
found = match.group(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found != null) break;
|
||||
}
|
||||
final found = extractHwIdFromLines(
|
||||
recognized.blocks
|
||||
.expand((block) => block.lines)
|
||||
.map((line) => line.text),
|
||||
);
|
||||
|
||||
// Clean up the temp image.
|
||||
try {
|
||||
|
|
@ -93,34 +204,113 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
if (!mounted) return;
|
||||
|
||||
if (found != null) {
|
||||
_consecutiveMisses = 0;
|
||||
_nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess);
|
||||
setState(() => _lastDetected = found);
|
||||
if (widget.onDetected != null) {
|
||||
await _submitDetected(found);
|
||||
}
|
||||
} else {
|
||||
// Show all detected text so user knows what was seen.
|
||||
final allText = recognized.blocks.map((b) => b.text).join('\n');
|
||||
_consecutiveMisses += 1;
|
||||
final missBackoffMs = (_scanCooldownNoMatch.inMilliseconds +
|
||||
(_consecutiveMisses * 300))
|
||||
.clamp(
|
||||
_scanCooldownNoMatch.inMilliseconds,
|
||||
_scanCooldownNoMatchMax.inMilliseconds,
|
||||
);
|
||||
_nextScanAllowedAt = DateTime.now().add(
|
||||
Duration(milliseconds: missBackoffMs),
|
||||
);
|
||||
setState(() {
|
||||
_scanAccepted = false;
|
||||
_scanNotFound = true;
|
||||
_statusText = 'No HW ID found';
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_consecutiveMisses += 1;
|
||||
_nextScanAllowedAt = DateTime.now().add(_scanCooldownError);
|
||||
if (!mounted) return;
|
||||
logError('scanner.capture', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
allText.isEmpty
|
||||
? 'No text detected — try again closer.'
|
||||
: 'No HW ID found. Detected:\n$allText',
|
||||
userMessageForError(
|
||||
e,
|
||||
fallback: 'Scan failed. Please try again.',
|
||||
),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
setState(() => _statusText = 'Scan failed, try again');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isBusy = false;
|
||||
if (_statusText == 'Scanning…') {
|
||||
_statusText = 'Ready to scan';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitDetected(String hwId) async {
|
||||
if (widget.onDetected == null) {
|
||||
if (mounted) Navigator.of(context).pop(hwId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final keepScanning = await widget.onDetected!(hwId);
|
||||
if (!mounted) return;
|
||||
|
||||
if (keepScanning) {
|
||||
setState(() {
|
||||
_lastDetected = null;
|
||||
_scanAccepted = true;
|
||||
_scanNotFound = false;
|
||||
_statusText = 'Saved. Ready for next scan';
|
||||
});
|
||||
Future<void>.delayed(const Duration(milliseconds: 650), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scanAccepted = false;
|
||||
if (_statusText == 'Saved. Ready for next scan') {
|
||||
_statusText = 'Ready to scan';
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
logError('scanner.submitDetected', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Scan error: $e'), backgroundColor: Colors.red),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
userMessageForError(
|
||||
e,
|
||||
fallback: 'Could not process this scan. Please try again.',
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cameraController?.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_autoScanTimer?.cancel();
|
||||
final controller = _cameraController;
|
||||
_cameraController = null;
|
||||
if (controller != null) {
|
||||
controller.dispose();
|
||||
}
|
||||
_textRecognizer.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -139,9 +329,104 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (widget.collections.isNotEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
value: _activeCollectionId,
|
||||
dropdownColor: AppColors.navy,
|
||||
iconEnabledColor: Colors.white,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
hint: const Text(
|
||||
'Select collection',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
items: widget.collections
|
||||
.map(
|
||||
(c) => DropdownMenuItem<String>(
|
||||
value: c.id,
|
||||
child: Text(
|
||||
c.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: _isBusy ? null : _handleCollectionChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
color: _scanAccepted
|
||||
? AppColors.success.withValues(alpha: 0.12)
|
||||
: _scanNotFound
|
||||
? AppColors.error.withValues(alpha: 0.14)
|
||||
: Colors.black.withValues(alpha: 0.55),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_statusText,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: _scanAccepted
|
||||
? AppColors.success
|
||||
: _scanNotFound
|
||||
? AppColors.error
|
||||
: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.onDetected != null)
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : _toggleAutoScan,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
_autoScanEnabled ? AppColors.success : Colors.white,
|
||||
minimumSize: const Size(0, 26),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
),
|
||||
child: Text(_autoScanEnabled ? 'AUTO ON' : 'AUTO OFF'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// ── Camera preview ──
|
||||
Expanded(
|
||||
child: _cameraReady
|
||||
child: _cameraError != null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.camera_alt_outlined,
|
||||
size: 46, color: AppColors.error),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_cameraError!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _initCamera(force: true),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry Camera'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: _cameraReady
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
|
|
@ -164,7 +449,12 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: AppColors.orange.withValues(alpha: 0.8),
|
||||
color: (_scanAccepted
|
||||
? AppColors.success
|
||||
: _scanNotFound
|
||||
? AppColors.error
|
||||
: AppColors.orange)
|
||||
.withValues(alpha: 0.9),
|
||||
width: 2.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
|
|
@ -229,7 +519,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(_lastDetected),
|
||||
onPressed: _isBusy || _lastDetected == null
|
||||
? null
|
||||
: () => _submitDetected(_lastDetected!),
|
||||
child: const Text('Use This'),
|
||||
),
|
||||
],
|
||||
|
|
@ -272,7 +564,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
_isBusy ? 'Scanning…' : 'Capture & Scan',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
onPressed: _isBusy ? null : _captureAndScan,
|
||||
onPressed: _isBusy || !_cameraReady ? null : _captureAndScan,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
|
|
@ -298,7 +590,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
Navigator.of(context).pop(result);
|
||||
await _submitDetected(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ class AboutScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _AboutScreenState extends State<AboutScreen> {
|
||||
static const _supportEmail = 'support@car64.app';
|
||||
|
||||
PackageInfo? _packageInfo;
|
||||
|
||||
@override
|
||||
|
|
@ -25,9 +27,23 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
if (mounted) setState(() => _packageInfo = info);
|
||||
}
|
||||
|
||||
Future<void> _openUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
Future<void> _contactSupport() async {
|
||||
final uri = Uri(
|
||||
scheme: 'mailto',
|
||||
path: _supportEmail,
|
||||
queryParameters: {
|
||||
'subject': 'car64 Support Request',
|
||||
},
|
||||
);
|
||||
|
||||
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not open mail app. Please email support@car64.app.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -55,17 +71,24 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
gradient: AppColors.brandGradient,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Image.asset(
|
||||
'assets/img/icon_bg_removed.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
Icons.directions_car_filled,
|
||||
color: Colors.white,
|
||||
size: 52,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Text(
|
||||
'HW Collector Hub',
|
||||
'car64',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
|
@ -83,7 +106,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Text(
|
||||
'Track and manage your Hot Wheels collection.',
|
||||
'Track and manage your die-cast car collection.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
|
|
@ -95,29 +118,6 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Links section ──
|
||||
Text(
|
||||
'Links',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
_LinkTile(
|
||||
icon: Icons.coffee,
|
||||
iconColor: const Color(0xFFFFDD00),
|
||||
title: 'Buy Me a Coffee',
|
||||
subtitle: 'Support the development',
|
||||
onTap: () =>
|
||||
_openUrl('https://buymeacoffee.com/derkauzigekoala'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Technical details ──
|
||||
Text(
|
||||
'Technical',
|
||||
|
|
@ -138,6 +138,16 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
value: _packageInfo?.packageName ?? '…',
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _contactSupport,
|
||||
icon: const Icon(Icons.support_agent),
|
||||
label: const Text('Contact Support'),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ── Powered by ──
|
||||
|
|
@ -152,7 +162,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'© 2026 HW Collector Hub',
|
||||
'© 2026 car64',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
|
|
@ -167,49 +177,6 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Link tile ─────────────────────────────────────────────────────────
|
||||
|
||||
class _LinkTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _LinkTile({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.title,
|
||||
required 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: iconColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 22),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
|
||||
trailing: const Icon(Icons.open_in_new, color: AppColors.textHint, size: 18),
|
||||
onTap: onTap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Info row (label : value) ──────────────────────────────────────────
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../main.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/main_collection_sync.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/error_utils.dart';
|
||||
import '../utils/preferences_utils.dart';
|
||||
import 'garage_screen.dart';
|
||||
import 'manage_collection_screen.dart';
|
||||
|
||||
|
|
@ -13,39 +17,127 @@ class CollectionsScreen extends StatefulWidget {
|
|||
State<CollectionsScreen> createState() => CollectionsScreenState();
|
||||
}
|
||||
|
||||
class CollectionsScreenState extends State<CollectionsScreen> {
|
||||
class CollectionsScreenState extends State<CollectionsScreen>
|
||||
with WidgetsBindingObserver {
|
||||
List<Collection> _collections = [];
|
||||
bool _isLoading = true;
|
||||
bool _isLoadInFlight = false;
|
||||
bool _reloadQueued = false;
|
||||
String? _error;
|
||||
String? _activeCollectionId;
|
||||
DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
MainCollectionSync.changeToken.addListener(_handleSyncChanged);
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
MainCollectionSync.changeToken.removeListener(_handleSyncChanged);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSyncChanged() {
|
||||
if (!mounted) return;
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
refreshIfStale(maxAge: const Duration(seconds: 3));
|
||||
}
|
||||
}
|
||||
|
||||
/// Public so other tabs can trigger a refresh.
|
||||
void refresh() => _load();
|
||||
|
||||
Future<void> _load() async {
|
||||
void refreshIfStale({Duration maxAge = const Duration(seconds: 10)}) {
|
||||
if (DateTime.now().difference(_lastLoadedAt) > maxAge) {
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load({bool showLoading = true}) async {
|
||||
if (_isLoadInFlight) {
|
||||
_reloadQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoadInFlight = true;
|
||||
|
||||
if (showLoading) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final list = await CollectionService.getMyCollections();
|
||||
var list = await CollectionService.getMyCollections();
|
||||
if (list.isEmpty && supabase.auth.currentUser != null) {
|
||||
await CollectionService.ensureDefaultCollection();
|
||||
list = await CollectionService.getMyCollections();
|
||||
}
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) {
|
||||
throw const AuthRequiredException(
|
||||
'You must be signed in to load collections.',
|
||||
);
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final persisted = await readActiveCollectionId(prefs, userId: userId);
|
||||
|
||||
String? activeId = persisted;
|
||||
if (activeId == null && list.isNotEmpty) {
|
||||
activeId = list.first.id;
|
||||
}
|
||||
if (activeId != null && !list.any((c) => c.id == activeId)) {
|
||||
activeId = list.isNotEmpty ? list.first.id : null;
|
||||
}
|
||||
|
||||
final shouldNotifySync = activeId != null && activeId != persisted;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_collections = list;
|
||||
_activeCollectionId = activeId;
|
||||
_isLoading = false;
|
||||
_lastLoadedAt = DateTime.now();
|
||||
});
|
||||
|
||||
if (activeId != null) {
|
||||
await writeActiveCollectionId(
|
||||
prefs,
|
||||
userId: userId,
|
||||
collectionId: activeId,
|
||||
);
|
||||
if (shouldNotifySync) {
|
||||
MainCollectionSync.notifyChanged();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_error = userMessageForError(
|
||||
e,
|
||||
fallback: 'Failed to load collections. Please try again.',
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
logError('collections.load', e);
|
||||
} finally {
|
||||
_isLoadInFlight = false;
|
||||
if (_reloadQueued) {
|
||||
_reloadQueued = false;
|
||||
Future<void>.microtask(() => _load(showLoading: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +169,7 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
maxLength: 50,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
hintText: 'e.g. Hot Wheels, Matchbox…',
|
||||
hintText: 'e.g. Die-Cast Cars, Matchbox…',
|
||||
),
|
||||
validator: (value) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
|
|
@ -122,10 +214,13 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
name: nameCtrl.text.trim(),
|
||||
description: descCtrl.text.trim(),
|
||||
);
|
||||
showGlobalSnackBar('Collection created!');
|
||||
showGlobalSuccess('Collection created!');
|
||||
_load();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not create collection. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +230,7 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
builder: (_) => GarageScreen(
|
||||
collectionId: c.id,
|
||||
collectionName: c.name,
|
||||
isOwner: c.isOwner,
|
||||
userRole: c.role,
|
||||
),
|
||||
),
|
||||
).then((_) => _load()); // refresh counts when coming back
|
||||
|
|
@ -149,10 +244,31 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
).then((_) => _load());
|
||||
}
|
||||
|
||||
Future<void> _setActiveCollection(String collectionId) async {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) {
|
||||
showGlobalSnackBar('Please sign in again.', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await writeActiveCollectionId(
|
||||
prefs,
|
||||
userId: userId,
|
||||
collectionId: collectionId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _activeCollectionId = collectionId);
|
||||
MainCollectionSync.notifyChanged();
|
||||
showGlobalInfo('Main collection set for scanning.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// ── Header ──
|
||||
SliverAppBar(
|
||||
|
|
@ -176,8 +292,23 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
),
|
||||
),
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
decoration: BoxDecoration(
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/img/login_bg.jpg'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.3),
|
||||
Colors.black.withValues(alpha: 0.55),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
|
|
@ -186,7 +317,8 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
size: 72,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
color: Colors.white.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -267,6 +399,8 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
final c = _collections[index];
|
||||
return _CollectionCard(
|
||||
collection: c,
|
||||
isActive: _activeCollectionId == c.id,
|
||||
onSetActive: () => _setActiveCollection(c.id),
|
||||
onTap: () => _openCollection(c),
|
||||
onManage: () => _manageCollection(c),
|
||||
);
|
||||
|
|
@ -277,6 +411,7 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: _collections.isNotEmpty
|
||||
? FloatingActionButton(
|
||||
onPressed: _createCollection,
|
||||
|
|
@ -291,11 +426,15 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
|
||||
class _CollectionCard extends StatelessWidget {
|
||||
final Collection collection;
|
||||
final bool isActive;
|
||||
final VoidCallback onSetActive;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onManage;
|
||||
|
||||
const _CollectionCard({
|
||||
required this.collection,
|
||||
required this.isActive,
|
||||
required this.onSetActive,
|
||||
required this.onTap,
|
||||
required this.onManage,
|
||||
});
|
||||
|
|
@ -363,27 +502,64 @@ class _CollectionCard extends StatelessWidget {
|
|||
decoration: BoxDecoration(
|
||||
color: c.isOwner
|
||||
? AppColors.orange.withValues(alpha: 0.15)
|
||||
: c.isViewer
|
||||
? AppColors.textHint.withValues(alpha: 0.15)
|
||||
: AppColors.navy.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
c.isOwner ? 'Owner' : 'Member',
|
||||
c.isOwner
|
||||
? 'Owner'
|
||||
: c.isViewer
|
||||
? 'Viewer'
|
||||
: 'Member',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: c.isOwner
|
||||
? AppColors.orange
|
||||
: c.isViewer
|
||||
? AppColors.textSecondary
|
||||
: AppColors.navy,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isActive)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'Main',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Manage button
|
||||
Column(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: isActive
|
||||
? 'Already main collection'
|
||||
: 'Make main collection',
|
||||
icon: Icon(
|
||||
isActive ? Icons.my_location : Icons.location_searching,
|
||||
color: isActive ? AppColors.success : AppColors.textHint,
|
||||
),
|
||||
onPressed: isActive ? null : onSetActive,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined,
|
||||
color: AppColors.textHint),
|
||||
|
|
@ -391,6 +567,8 @@ class _CollectionCard extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ class _HomeShellState extends State<HomeShell> {
|
|||
int _currentIndex = 0;
|
||||
final _collectionsKey = GlobalKey<CollectionsScreenState>();
|
||||
final _scanKey = GlobalKey<ScanTabState>();
|
||||
final Map<int, DateTime> _lastRefreshed = {};
|
||||
static const _refreshDebounce = Duration(seconds: 30);
|
||||
late final PageController _pageController;
|
||||
|
||||
late final List<Widget> _pages = <Widget>[
|
||||
CollectionsScreen(key: _collectionsKey),
|
||||
|
|
@ -24,24 +23,44 @@ class _HomeShellState extends State<HomeShell> {
|
|||
const ProfileScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTabSelected(int i) {
|
||||
if (_currentIndex == i) return;
|
||||
setState(() => _currentIndex = i);
|
||||
final now = DateTime.now();
|
||||
final last = _lastRefreshed[i];
|
||||
if (last != null && now.difference(last) < _refreshDebounce) return;
|
||||
_lastRefreshed[i] = now;
|
||||
_pageController.animateToPage(
|
||||
i,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
if (i == 0) {
|
||||
_collectionsKey.currentState?.refresh();
|
||||
} else if (i == 1) {
|
||||
_scanKey.currentState?.refresh();
|
||||
_collectionsKey.currentState?.refreshIfStale();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _currentIndex,
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) {
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
}
|
||||
if (index == 0) {
|
||||
_collectionsKey.currentState?.refreshIfStale();
|
||||
}
|
||||
},
|
||||
children: _pages,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||
import '../main.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// Branded login screen matching the HW Collector Hub email style.
|
||||
/// Branded login screen matching the car64 style.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
|
|
@ -76,9 +76,9 @@ class _LoginScreenState extends State<LoginScreen>
|
|||
try {
|
||||
await supabase.auth.resetPasswordForEmail(
|
||||
email,
|
||||
redirectTo: 'hwcollector://login',
|
||||
redirectTo: 'hwcollector://login/recovery',
|
||||
);
|
||||
showGlobalSnackBar('Password reset email sent! Check your inbox.');
|
||||
showGlobalSuccess('Password reset email sent! Check your inbox.');
|
||||
} on AuthException catch (e) {
|
||||
showGlobalSnackBar(e.message, isError: true);
|
||||
}
|
||||
|
|
@ -134,15 +134,22 @@ class _LoginScreenState extends State<LoginScreen>
|
|||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Image.asset(
|
||||
'assets/img/icon_bg_removed.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
Icons.directions_car_filled,
|
||||
size: 44,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'HW COLLECTOR HUB',
|
||||
'CAR64',
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
@ -300,7 +307,7 @@ class _LoginScreenState extends State<LoginScreen>
|
|||
|
||||
// ── Footer ──
|
||||
Text(
|
||||
'© 2026 HW Collector Hub',
|
||||
'© 2026 car64',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
late Collection _collection;
|
||||
List<CollectionMember> _members = [];
|
||||
bool _isLoading = true;
|
||||
bool _isInviting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -39,7 +40,10 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
showGlobalSnackBar('Failed to load members: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Failed to load members. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,18 +125,24 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
memberCount: _collection.memberCount,
|
||||
);
|
||||
});
|
||||
showGlobalSnackBar('Collection renamed!');
|
||||
showGlobalSuccess('Collection renamed!');
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not rename collection. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _inviteMember() async {
|
||||
if (_isInviting) return;
|
||||
final emailCtrl = TextEditingController();
|
||||
String inviteRole = 'member';
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
builder: (_) => StatefulBuilder(
|
||||
builder: (context, setSheetState) => AlertDialog(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
|
|
@ -142,7 +152,10 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
child: const Icon(Icons.person_add, color: Colors.white, size: 28),
|
||||
),
|
||||
title: const Text('Invite Member'),
|
||||
content: Column(
|
||||
content: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
|
|
@ -164,7 +177,39 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
isExpanded: true,
|
||||
initialValue: inviteRole,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Role',
|
||||
prefixIcon: Icon(Icons.security_outlined),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'member', child: Text('Member')),
|
||||
DropdownMenuItem(value: 'viewer', child: Text('Viewer')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setSheetState(() => inviteRole = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
inviteRole == 'viewer'
|
||||
? 'Viewer: read-only access.'
|
||||
: 'Member: can add, copy, edit, and remove cars.',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
|
@ -180,6 +225,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != true) return;
|
||||
|
|
@ -192,14 +238,23 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
}
|
||||
|
||||
try {
|
||||
setState(() => _isInviting = true);
|
||||
await CollectionService.inviteByEmail(
|
||||
collectionId: _collection.id,
|
||||
email: email,
|
||||
role: inviteRole,
|
||||
);
|
||||
showGlobalSnackBar('Member invited!');
|
||||
_loadMembers();
|
||||
showGlobalSuccess(
|
||||
inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!',
|
||||
);
|
||||
await _loadMembers();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('$e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not send invitation. Please try again.',
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isInviting = false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,12 +284,15 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
try {
|
||||
await CollectionService.removeMember(
|
||||
collectionId: _collection.id,
|
||||
membershipId: member.id,
|
||||
memberUserId: member.userId,
|
||||
);
|
||||
showGlobalSnackBar('Member removed.');
|
||||
_loadMembers();
|
||||
showGlobalSuccess('Member removed.');
|
||||
await _loadMembers();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not remove member. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -264,10 +322,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
|
||||
try {
|
||||
await CollectionService.leave(_collection.id);
|
||||
showGlobalSnackBar('Left "${_collection.name}".');
|
||||
showGlobalSuccess('Left "${_collection.name}".');
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not leave collection. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,10 +358,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
|
||||
try {
|
||||
await CollectionService.delete(_collection.id);
|
||||
showGlobalSnackBar('Collection deleted.');
|
||||
showGlobalSuccess('Collection deleted.');
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not delete collection. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -321,10 +385,11 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _loadMembers,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// ── Description ──
|
||||
if (_collection.description != null &&
|
||||
_collection.description!.isNotEmpty) ...[
|
||||
Text(
|
||||
|
|
@ -335,7 +400,6 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// ── Members section ──
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
|
|
@ -347,9 +411,9 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
const Spacer(),
|
||||
if (_collection.isOwner)
|
||||
TextButton.icon(
|
||||
onPressed: _inviteMember,
|
||||
onPressed: _isInviting ? null : _inviteMember,
|
||||
icon: const Icon(Icons.person_add, size: 18),
|
||||
label: const Text('Invite'),
|
||||
label: Text(_isInviting ? 'Inviting…' : 'Invite'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -364,35 +428,34 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
)
|
||||
else
|
||||
...List.generate(_members.length, (i) {
|
||||
final m = _members[i];
|
||||
final member = _members[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: m.isOwner
|
||||
? AppColors.orange
|
||||
: AppColors.navy,
|
||||
backgroundColor:
|
||||
member.isOwner ? AppColors.orange : AppColors.navy,
|
||||
child: Icon(
|
||||
m.isOwner ? Icons.star : Icons.person,
|
||||
member.isOwner ? Icons.star : Icons.person,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
m.email,
|
||||
member.email,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
m.isOwner ? 'Owner' : 'Member',
|
||||
_roleLabel(member.role),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: (!m.isOwner &&
|
||||
trailing: (!member.isOwner &&
|
||||
_collection.isOwner &&
|
||||
m.userId != currentUserId)
|
||||
member.userId != currentUserId)
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline,
|
||||
color: AppColors.error),
|
||||
onPressed: () => _removeMember(m),
|
||||
onPressed: () => _removeMember(member),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
|
@ -403,7 +466,6 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Danger zone ──
|
||||
Text(
|
||||
'Danger Zone',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
|
|
@ -426,14 +488,22 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
const Text(
|
||||
'As owner, you cannot leave this collection. You can delete it instead.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
|
||||
if (_collection.isOwner)
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _deleteCollection,
|
||||
icon: const Icon(Icons.delete_forever, color: AppColors.error),
|
||||
icon: const Icon(Icons.delete_forever,
|
||||
color: AppColors.error),
|
||||
label: const Text('Delete Collection',
|
||||
style: TextStyle(color: AppColors.error)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
|
|
@ -443,7 +513,20 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _roleLabel(String role) {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'Owner';
|
||||
case 'viewer':
|
||||
return 'Viewer (read-only)';
|
||||
default:
|
||||
return 'Member';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
249
lib/screens/my_reports_screen.dart
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../main.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/error_utils.dart';
|
||||
import '../utils/reporting_utils.dart';
|
||||
|
||||
class MyReportsScreen extends StatefulWidget {
|
||||
const MyReportsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MyReportsScreen> createState() => _MyReportsScreenState();
|
||||
}
|
||||
|
||||
class _MyReportsScreenState extends State<MyReportsScreen> {
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
List<Map<String, dynamic>> _reports = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadReports();
|
||||
}
|
||||
|
||||
Future<void> _loadReports() async {
|
||||
final user = supabase.auth.currentUser;
|
||||
if (user == null) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_reports = [];
|
||||
_error = 'Please sign in to view reports.';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final rows = await supabase
|
||||
.from('car_reports')
|
||||
.select('id, hw_id, reason, note, status, created_at, global_cars(name, series)')
|
||||
.eq('reporter_user_id', user.id)
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_reports = List<Map<String, dynamic>>.from(rows);
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = userMessageForError(
|
||||
e,
|
||||
fallback: 'Failed to load reports. Please try again.',
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
logError('reports.load', e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('My Reports'),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _loadReports,
|
||||
child: _buildBody(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
const SizedBox(height: 120),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: AppColors.error, size: 42),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _loadReports,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_reports.isEmpty) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(height: 130),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.flag_outlined, size: 48, color: AppColors.textHint),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'No reports yet',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'When you report a catalog issue, it will appear here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
||||
itemBuilder: (_, index) {
|
||||
final report = _reports[index];
|
||||
final global = report['global_cars'] as Map<String, dynamic>?;
|
||||
final status = (report['status'] as String? ?? 'open').toLowerCase();
|
||||
final createdAt = DateTime.tryParse(report['created_at'] as String? ?? '');
|
||||
final note = report['note'] as String?;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
report['hw_id'] as String? ?? 'Unknown ID',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
_StatusChip(status: status),
|
||||
],
|
||||
),
|
||||
if ((global?['name'] as String?) != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
global?['name'] as String,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Reason: ${report['reason'] as String? ?? 'Unknown'}',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
if (note != null && note.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
note,
|
||||
style: const TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
if (createdAt != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
formatReportDateTime(createdAt),
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
itemCount: _reports.length,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final String status;
|
||||
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalized = normalizeReportStatus(status);
|
||||
final label = reportStatusLabel(normalized);
|
||||
final color = switch (normalized) {
|
||||
ReportStatus.reviewed => Colors.blueGrey,
|
||||
ReportStatus.resolved => AppColors.success,
|
||||
ReportStatus.dismissed => AppColors.error,
|
||||
ReportStatus.open => AppColors.orange,
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import '../main.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'my_reports_screen.dart';
|
||||
|
||||
/// Profile / settings tab.
|
||||
class ProfileScreen extends StatelessWidget {
|
||||
|
|
@ -22,11 +23,26 @@ class ProfileScreen extends StatelessWidget {
|
|||
expandedHeight: 200,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/img/login_bg.jpg',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
child: SafeArea(
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.3),
|
||||
Colors.black.withValues(alpha: 0.55),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
|
@ -61,13 +77,14 @@ class ProfileScreen extends StatelessWidget {
|
|||
'Member since ${_formatDate(createdAt)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -93,6 +110,12 @@ class ProfileScreen extends StatelessWidget {
|
|||
title: 'Change Password',
|
||||
onTap: () => _changePassword(context),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.flag_outlined,
|
||||
title: 'My Reports',
|
||||
subtitle: 'Track report status',
|
||||
onTap: () => _showMyReports(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'App',
|
||||
|
|
@ -105,7 +128,7 @@ class ProfileScreen extends StatelessWidget {
|
|||
_SettingsTile(
|
||||
icon: Icons.info_outline,
|
||||
title: 'About',
|
||||
subtitle: 'HW Collector Hub',
|
||||
subtitle: 'car64',
|
||||
onTap: () => _showAbout(context),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
|
@ -131,7 +154,7 @@ class ProfileScreen extends StatelessWidget {
|
|||
const SizedBox(height: 40),
|
||||
const Center(
|
||||
child: Text(
|
||||
'© 2026 HW Collector Hub',
|
||||
'© 2026 car64',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||
),
|
||||
|
|
@ -185,7 +208,7 @@ class ProfileScreen extends StatelessWidget {
|
|||
UserAttributes(password: pw),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
showGlobalSnackBar('Password updated!');
|
||||
showGlobalSuccess('Password updated!');
|
||||
} on AuthException catch (e) {
|
||||
showGlobalSnackBar(e.message, isError: true);
|
||||
}
|
||||
|
|
@ -202,6 +225,12 @@ class ProfileScreen extends StatelessWidget {
|
|||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMyReports(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const MyReportsScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings tile widget ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../main.dart';
|
||||
import '../scanner_screen.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/main_collection_sync.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/error_utils.dart';
|
||||
import '../utils/preferences_utils.dart';
|
||||
|
||||
/// The "Scan" tab — quick-access view for scanning / adding cars.
|
||||
class ScanTab extends StatefulWidget {
|
||||
const ScanTab({super.key});
|
||||
|
||||
|
|
@ -17,36 +16,81 @@ class ScanTab extends StatefulWidget {
|
|||
}
|
||||
|
||||
class ScanTabState extends State<ScanTab> {
|
||||
static const _duplicateCooldown = Duration(seconds: 2);
|
||||
|
||||
bool _isBusy = false;
|
||||
List<Collection> _collections = [];
|
||||
Collection? _selectedCollection;
|
||||
bool _loadingCollections = true;
|
||||
String? _lastProcessedHwId;
|
||||
DateTime? _lastProcessedAt;
|
||||
|
||||
bool get _canAddToSelectedCollection =>
|
||||
(_selectedCollection?.canModifyCars ?? false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
MainCollectionSync.changeToken.addListener(_handleMainCollectionChanged);
|
||||
_loadCollections();
|
||||
}
|
||||
|
||||
/// Public so HomeShell can trigger a refresh when switching to this tab.
|
||||
@override
|
||||
void dispose() {
|
||||
MainCollectionSync.changeToken.removeListener(_handleMainCollectionChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void refresh() => _loadCollections();
|
||||
|
||||
void _handleMainCollectionChanged() {
|
||||
if (!mounted) return;
|
||||
_loadCollections();
|
||||
}
|
||||
|
||||
Future<void> _loadCollections() async {
|
||||
try {
|
||||
final list = await CollectionService.getMyCollections();
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) {
|
||||
throw const AuthRequiredException(
|
||||
'You must be signed in to load collections.',
|
||||
);
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final persistedId = await readActiveCollectionId(prefs, userId: userId);
|
||||
|
||||
Collection? selected;
|
||||
if (persistedId != null) {
|
||||
final matching = list.where((c) => c.id == persistedId);
|
||||
if (matching.isNotEmpty) {
|
||||
selected = matching.first;
|
||||
}
|
||||
}
|
||||
|
||||
selected ??= list.isNotEmpty ? list.first : null;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_collections = list;
|
||||
_selectedCollection = list.isNotEmpty ? list.first : null;
|
||||
_selectedCollection = selected;
|
||||
_loadingCollections = false;
|
||||
});
|
||||
|
||||
if (selected != null) {
|
||||
await writeActiveCollectionId(
|
||||
prefs,
|
||||
userId: userId,
|
||||
collectionId: selected.id,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingCollections = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to load collections: $e'),
|
||||
),
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Failed to load collections. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +104,6 @@ class ScanTabState extends State<ScanTab> {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// ── Illustration ──
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
|
|
@ -76,15 +119,12 @@ class ScanTabState extends State<ScanTab> {
|
|||
),
|
||||
const SizedBox(height: 28),
|
||||
const Text(
|
||||
'Scan a Hot Wheels Car',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
'Scan a Die-Cast 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 collection.',
|
||||
'Point your camera at the model ID and add cars\ninstantly to your selected collection.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
|
|
@ -93,8 +133,6 @@ class ScanTabState extends State<ScanTab> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Collection picker ──
|
||||
if (_loadingCollections)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
@ -149,19 +187,21 @@ class ScanTabState extends State<ScanTab> {
|
|||
))
|
||||
.toList(),
|
||||
onChanged: (id) {
|
||||
setState(() {
|
||||
final matching =
|
||||
_collections.where((c) => c.id == id);
|
||||
_selectedCollection =
|
||||
matching.isNotEmpty ? matching.first : null;
|
||||
});
|
||||
_setSelectedCollection(id);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Scan button (gradient) ──
|
||||
if (_selectedCollection?.isViewer == true)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Viewer role is read-only. Choose an owner/member collection to add cars.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
|
|
@ -178,7 +218,7 @@ class ScanTabState extends State<ScanTab> {
|
|||
],
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isBusy || _selectedCollection == null
|
||||
onPressed: _isBusy || !_canAddToSelectedCollection
|
||||
? null
|
||||
: _openScanner,
|
||||
icon: _isBusy
|
||||
|
|
@ -210,12 +250,10 @@ class ScanTabState extends State<ScanTab> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Manual entry ──
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isBusy || _selectedCollection == null
|
||||
onPressed: _isBusy || !_canAddToSelectedCollection
|
||||
? null
|
||||
: _manualEntry,
|
||||
icon: const Icon(Icons.keyboard),
|
||||
|
|
@ -229,13 +267,25 @@ class ScanTabState extends State<ScanTab> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _openScanner() async {
|
||||
final hwId = await navigatorKey.currentState!.push<String>(
|
||||
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
||||
);
|
||||
void _setSelectedCollection(String? id) {
|
||||
if (id == null) return;
|
||||
final matching = _collections.where((c) => c.id == id);
|
||||
if (matching.isEmpty) return;
|
||||
|
||||
if (hwId == null || !mounted) return;
|
||||
await _processHwId(hwId);
|
||||
setState(() => _selectedCollection = matching.first);
|
||||
}
|
||||
|
||||
Future<void> _openScanner() async {
|
||||
await navigatorKey.currentState!.push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ScannerScreen(
|
||||
collections: _collections,
|
||||
activeCollectionId: _selectedCollection?.id,
|
||||
onCollectionChanged: _setSelectedCollection,
|
||||
onDetected: (hwId) => _processHwId(hwId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _manualEntry() async {
|
||||
|
|
@ -274,34 +324,39 @@ class ScanTabState extends State<ScanTab> {
|
|||
await _processHwId(result);
|
||||
}
|
||||
|
||||
Future<void> _processHwId(String hwId) async {
|
||||
Future<bool> _processHwId(String hwId) async {
|
||||
final now = DateTime.now();
|
||||
if (_lastProcessedHwId == hwId &&
|
||||
_lastProcessedAt != null &&
|
||||
now.difference(_lastProcessedAt!) < _duplicateCooldown) {
|
||||
return true;
|
||||
}
|
||||
_lastProcessedHwId = hwId;
|
||||
_lastProcessedAt = now;
|
||||
|
||||
final collection = _selectedCollection;
|
||||
if (collection == null) return;
|
||||
if (collection == null) return false;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
// Check if this hw_id already exists in the selected collection.
|
||||
final data = await supabase
|
||||
final existing = await supabase
|
||||
.from('hotwheels')
|
||||
.select()
|
||||
.select('id')
|
||||
.eq('hw_id', hwId)
|
||||
.eq('collection_id', collection.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mounted) return;
|
||||
if (existing != null) {
|
||||
if (!mounted) return false;
|
||||
setState(() => _isBusy = false);
|
||||
|
||||
if (data != null) {
|
||||
// Already in collection
|
||||
await showDialog(
|
||||
context: context,
|
||||
context: navigatorKey.currentContext ?? context,
|
||||
builder: (_) => AlertDialog(
|
||||
icon: const Icon(Icons.check_circle,
|
||||
color: AppColors.success, size: 48),
|
||||
title: const Text('Already in Collection!'),
|
||||
content:
|
||||
Text('$hwId is already in "${collection.name}".'),
|
||||
content: Text('$hwId is already in "${collection.name}".'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
@ -310,49 +365,257 @@ class ScanTabState extends State<ScanTab> {
|
|||
],
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
final globalCar = await supabase
|
||||
.from('global_cars')
|
||||
.select('hw_id, name, series, year, color, is_verified, confirmation_count')
|
||||
.eq('hw_id', hwId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mounted) return false;
|
||||
setState(() => _isBusy = false);
|
||||
|
||||
if (globalCar != null) {
|
||||
final addConfirmed = await showModalBottomSheet<bool>(
|
||||
context: navigatorKey.currentContext ?? context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => _FoundCarSheet(
|
||||
collectionName: collection.name,
|
||||
car: globalCar,
|
||||
),
|
||||
);
|
||||
|
||||
if (addConfirmed == true) {
|
||||
await _addToCollection(collection.id, hwId);
|
||||
await _ensureValidationVote(hwId);
|
||||
if (!mounted) return false;
|
||||
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
|
||||
}
|
||||
} else {
|
||||
// New — offer to add
|
||||
final added = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => _AddCarDialog(
|
||||
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
|
||||
context: navigatorKey.currentContext ?? context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => _NewDiscoverySheet(
|
||||
hwId: hwId,
|
||||
collectionId: collection.id,
|
||||
collectionName: collection.name,
|
||||
),
|
||||
);
|
||||
if (added == true) {
|
||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
||||
|
||||
if (discovery != null) {
|
||||
await _createGlobalCarAndVote(
|
||||
hwId: hwId,
|
||||
name: discovery.name,
|
||||
series: discovery.series,
|
||||
year: discovery.year,
|
||||
);
|
||||
await _addToCollection(collection.id, hwId, notes: discovery.notes);
|
||||
if (!mounted) return false;
|
||||
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
showGlobalSnackBar('DB error: $e', isError: true);
|
||||
}
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not save this car right now. Please try again.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add Car Dialog (inline, styled) ──────────────────────────────────
|
||||
class _AddCarDialog extends StatefulWidget {
|
||||
final String hwId;
|
||||
final String collectionId;
|
||||
Future<void> _addToCollection(
|
||||
String collectionId,
|
||||
String hwId, {
|
||||
String? notes,
|
||||
}) async {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) {
|
||||
throw const AuthRequiredException('You must be signed in to add cars.');
|
||||
}
|
||||
|
||||
await supabase.from('hotwheels').insert({
|
||||
'hw_id': hwId,
|
||||
'user_id': userId,
|
||||
'collection_id': collectionId,
|
||||
if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createGlobalCarAndVote({
|
||||
required String hwId,
|
||||
required String name,
|
||||
String? series,
|
||||
int? year,
|
||||
}) async {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) {
|
||||
throw const AuthRequiredException(
|
||||
'You must be signed in to create catalog entries.',
|
||||
);
|
||||
}
|
||||
|
||||
final cleanedSeries = series?.trim();
|
||||
final payload = <String, dynamic>{
|
||||
'hw_id': hwId,
|
||||
'name': name,
|
||||
'series': (cleanedSeries?.isNotEmpty ?? false) ? cleanedSeries : null,
|
||||
'year': year,
|
||||
}..removeWhere((key, value) => value == null);
|
||||
|
||||
await supabase.from('global_cars').insert(payload);
|
||||
|
||||
await supabase.from('car_votes').insert({
|
||||
'hw_id': hwId,
|
||||
'user_id': userId,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _ensureValidationVote(String hwId) async {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) {
|
||||
throw const AuthRequiredException(
|
||||
'You must be signed in to validate entries.',
|
||||
);
|
||||
}
|
||||
final existingVote = await supabase
|
||||
.from('car_votes')
|
||||
.select('id')
|
||||
.eq('hw_id', hwId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingVote != null) return;
|
||||
|
||||
await supabase.from('car_votes').insert({
|
||||
'hw_id': hwId,
|
||||
'user_id': userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _FoundCarSheet extends StatelessWidget {
|
||||
final String collectionName;
|
||||
const _AddCarDialog({
|
||||
final Map<String, dynamic> car;
|
||||
|
||||
const _FoundCarSheet({required this.collectionName, required this.car});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hwId = car['hw_id'] as String? ?? '???';
|
||||
final name = car['name'] as String? ?? 'Unknown model';
|
||||
final series = car['series'] as String?;
|
||||
final year = car['year'];
|
||||
final verified = car['is_verified'] == true;
|
||||
final confirmations = (car['confirmation_count'] as num?)?.toInt() ?? 0;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Found in Catalog',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('$name ($hwId)', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
if (series != null && series.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('Series: $series'),
|
||||
],
|
||||
if (year != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('Year: $year'),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
verified ? 'Verified by community' : 'Unverified catalog entry',
|
||||
style: TextStyle(
|
||||
color: verified ? AppColors.success : AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$confirmations confirmation${confirmations == 1 ? '' : 's'}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textHint,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('Add to "$collectionName"'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewDiscoveryData {
|
||||
final String name;
|
||||
final String? series;
|
||||
final int? year;
|
||||
final String? notes;
|
||||
|
||||
const _NewDiscoveryData({
|
||||
required this.name,
|
||||
this.series,
|
||||
this.year,
|
||||
this.notes,
|
||||
});
|
||||
}
|
||||
|
||||
class _NewDiscoverySheet extends StatefulWidget {
|
||||
final String hwId;
|
||||
final String collectionName;
|
||||
|
||||
const _NewDiscoverySheet({
|
||||
required this.hwId,
|
||||
required this.collectionId,
|
||||
required this.collectionName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AddCarDialog> createState() => _AddCarDialogState();
|
||||
State<_NewDiscoverySheet> createState() => _NewDiscoverySheetState();
|
||||
}
|
||||
|
||||
class _AddCarDialogState extends State<_AddCarDialog> {
|
||||
class _NewDiscoverySheetState extends State<_NewDiscoverySheet> {
|
||||
final _nameController = TextEditingController();
|
||||
final _seriesController = TextEditingController();
|
||||
final _yearController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
bool _isAdding = false;
|
||||
File? _pickedImage;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -363,208 +626,95 @@ class _AddCarDialogState extends State<_AddCarDialog> {
|
|||
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,
|
||||
'collection_id': widget.collectionId,
|
||||
});
|
||||
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,
|
||||
'collection_id': widget.collectionId,
|
||||
};
|
||||
|
||||
// Optional fields — only include if filled in.
|
||||
void _save() {
|
||||
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 (name.isEmpty) {
|
||||
showGlobalSnackBar('Name is required for a new discovery.', isError: true);
|
||||
return;
|
||||
}
|
||||
if (notes.isNotEmpty) row['notes'] = notes;
|
||||
|
||||
// Upload image if one was taken.
|
||||
if (_pickedImage != null) {
|
||||
final url = await StorageService.uploadCarImage(
|
||||
file: _pickedImage!,
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
Navigator.pop(
|
||||
context,
|
||||
_NewDiscoveryData(
|
||||
name: name,
|
||||
series: _seriesController.text.trim().isEmpty
|
||||
? null
|
||||
: _seriesController.text.trim(),
|
||||
year: int.tryParse(_yearController.text.trim()),
|
||||
notes: _notesController.text.trim().isEmpty
|
||||
? null
|
||||
: _notesController.text.trim(),
|
||||
),
|
||||
);
|
||||
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,
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
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,
|
||||
Text(
|
||||
'New Discovery: ${widget.hwId}',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: 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),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Car Name',
|
||||
labelText: 'Name *',
|
||||
hintText: "e.g. '70 Dodge Charger",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _seriesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Series',
|
||||
hintText: 'e.g. HW Flames',
|
||||
decoration: const InputDecoration(labelText: 'Series'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _yearController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Year',
|
||||
hintText: 'e.g. 2025',
|
||||
decoration: const InputDecoration(labelText: 'Year'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes',
|
||||
hintText: 'Any extra info…',
|
||||
labelText: 'Notes (for your garage entry)',
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: Text('Save & Add to "${widget.collectionName}"'),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isAdding ? null : () => Navigator.pop(context),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
onPressed: _isSaving ? 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../main.dart';
|
||||
import '../utils/error_utils.dart';
|
||||
|
||||
/// Data model for a collection.
|
||||
class Collection {
|
||||
|
|
@ -24,6 +25,9 @@ class Collection {
|
|||
});
|
||||
|
||||
bool get isOwner => role == 'owner';
|
||||
bool get isMember => role == 'member';
|
||||
bool get isViewer => role == 'viewer';
|
||||
bool get canModifyCars => isOwner || isMember;
|
||||
}
|
||||
|
||||
/// Member of a collection.
|
||||
|
|
@ -43,16 +47,65 @@ class CollectionMember {
|
|||
});
|
||||
|
||||
bool get isOwner => role == 'owner';
|
||||
bool get isViewer => role == 'viewer';
|
||||
}
|
||||
|
||||
/// Typed error for collection service operations.
|
||||
class CollectionServiceException extends AppException {
|
||||
const CollectionServiceException(super.message);
|
||||
}
|
||||
|
||||
/// Service for managing collections and membership.
|
||||
class CollectionService {
|
||||
CollectionService._();
|
||||
|
||||
static Never _fail(String message) => throw CollectionServiceException(message);
|
||||
|
||||
@visibleForTesting
|
||||
static String normalizeRole(String role) => role.trim().toLowerCase();
|
||||
|
||||
@visibleForTesting
|
||||
static bool isSupportedInviteRole(String role) {
|
||||
final normalized = normalizeRole(role);
|
||||
return normalized == 'member' || normalized == 'viewer';
|
||||
}
|
||||
|
||||
static String _requireUserId() {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) {
|
||||
_fail('You must be signed in to perform this action.');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
/// Ensures the current user has at least one collection membership.
|
||||
/// Creates a default collection on first login.
|
||||
static Future<String?> ensureDefaultCollection() async {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) return null;
|
||||
|
||||
final existing = await supabase
|
||||
.from('collection_members')
|
||||
.select('collection_id')
|
||||
.eq('user_id', userId)
|
||||
.limit(1);
|
||||
|
||||
if (existing.isNotEmpty) {
|
||||
return existing.first['collection_id'] as String;
|
||||
}
|
||||
|
||||
final created = await create(
|
||||
name: 'My Garage',
|
||||
description: 'Your default collection',
|
||||
);
|
||||
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/// Fetch all collections the current user is a member of,
|
||||
/// including item count and member count.
|
||||
static Future<List<Collection>> getMyCollections() async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
// Get memberships with collection data.
|
||||
final memberships = await supabase
|
||||
|
|
@ -75,25 +128,14 @@ class CollectionService {
|
|||
|
||||
final collectionIdList = collectionIds.toList();
|
||||
|
||||
// Fetch all items for these collections in a single query and count them in memory.
|
||||
final items = await supabase
|
||||
.from('hotwheels')
|
||||
.select('id, collection_id')
|
||||
.inFilter('collection_id', collectionIdList);
|
||||
|
||||
final itemCounts = <String, int>{};
|
||||
for (final item in items) {
|
||||
final collectionId = item['collection_id'] as String;
|
||||
itemCounts[collectionId] = (itemCounts[collectionId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Fetch all members for these collections in a single query and count them in memory.
|
||||
final members = await supabase
|
||||
.from('collection_members')
|
||||
.select('id, collection_id')
|
||||
.inFilter('collection_id', collectionIdList);
|
||||
final itemCounts = await getCollectionItemCounts(collectionIdList);
|
||||
|
||||
final memberCounts = <String, int>{};
|
||||
final members = await supabase
|
||||
.from('collection_members')
|
||||
.select('collection_id')
|
||||
.inFilter('collection_id', collectionIdList);
|
||||
|
||||
for (final member in members) {
|
||||
final collectionId = member['collection_id'] as String;
|
||||
memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1;
|
||||
|
|
@ -127,30 +169,171 @@ class CollectionService {
|
|||
return collections;
|
||||
}
|
||||
|
||||
static Future<Map<String, int>> getCollectionItemCounts(
|
||||
List<String> collectionIds) async {
|
||||
if (collectionIds.isEmpty) return <String, int>{};
|
||||
|
||||
final itemCounts = <String, int>{};
|
||||
try {
|
||||
final rows = await supabase.rpc('get_collection_counts', params: {
|
||||
'p_collection_ids': collectionIds,
|
||||
});
|
||||
|
||||
for (final row in rows as List) {
|
||||
final collectionId = row['collection_id'] as String?;
|
||||
if (collectionId == null) continue;
|
||||
itemCounts[collectionId] = (row['total_count'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CollectionService.getCollectionItemCounts RPC error: $e');
|
||||
}
|
||||
|
||||
final unresolvedIds = collectionIds
|
||||
.where((collectionId) => !itemCounts.containsKey(collectionId))
|
||||
.toList(growable: false);
|
||||
|
||||
if (unresolvedIds.isNotEmpty) {
|
||||
await Future.wait(unresolvedIds.map((collectionId) async {
|
||||
itemCounts[collectionId] = await _countCarsPaginated(collectionId);
|
||||
}));
|
||||
}
|
||||
|
||||
return itemCounts;
|
||||
}
|
||||
|
||||
static Future<({int total, int recent})> getCollectionStats(
|
||||
String collectionId) async {
|
||||
try {
|
||||
final rows = await supabase.rpc('get_collection_counts', params: {
|
||||
'p_collection_ids': [collectionId],
|
||||
});
|
||||
|
||||
if (rows is List && rows.isNotEmpty) {
|
||||
final first = rows.first as Map<String, dynamic>;
|
||||
return (
|
||||
total: (first['total_count'] as num?)?.toInt() ?? 0,
|
||||
recent: (first['recent_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CollectionService.getCollectionStats RPC error: $e');
|
||||
}
|
||||
|
||||
final weekAgoIso = DateTime.now()
|
||||
.subtract(const Duration(days: 7))
|
||||
.toUtc()
|
||||
.toIso8601String();
|
||||
final total = await _countCarsPaginated(collectionId);
|
||||
final recent = await _countCarsPaginated(collectionId, sinceIso: weekAgoIso);
|
||||
return (total: total, recent: recent);
|
||||
}
|
||||
|
||||
static Future<int> _countCarsPaginated(String collectionId,
|
||||
{String? sinceIso}) async {
|
||||
const pageSize = 1000;
|
||||
var from = 0;
|
||||
var total = 0;
|
||||
|
||||
while (true) {
|
||||
dynamic query = supabase
|
||||
.from('hotwheels')
|
||||
.select('id')
|
||||
.eq('collection_id', collectionId)
|
||||
.range(from, from + pageSize - 1);
|
||||
|
||||
if (sinceIso != null) {
|
||||
query = query.gte('created_at', sinceIso);
|
||||
}
|
||||
|
||||
final rows = await query;
|
||||
final count = (rows as List).length;
|
||||
total += count;
|
||||
|
||||
if (count < pageSize) break;
|
||||
from += pageSize;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Create a new collection. The caller is automatically added as owner.
|
||||
static Future<Collection> create({
|
||||
required String name,
|
||||
String? description,
|
||||
}) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
final trimmedDescription = description?.trim();
|
||||
final normalizedDescription =
|
||||
(trimmedDescription != null && trimmedDescription.isNotEmpty)
|
||||
? trimmedDescription
|
||||
: null;
|
||||
|
||||
try {
|
||||
final rpcResult = await supabase.rpc(
|
||||
'create_collection_with_owner',
|
||||
params: {
|
||||
'p_name': name,
|
||||
'p_description': normalizedDescription,
|
||||
},
|
||||
);
|
||||
|
||||
final row = rpcResult is List
|
||||
? (rpcResult.isNotEmpty
|
||||
? rpcResult.first as Map<String, dynamic>
|
||||
: <String, dynamic>{})
|
||||
: rpcResult as Map<String, dynamic>;
|
||||
|
||||
if (row.isNotEmpty) {
|
||||
return Collection(
|
||||
id: row['id'] as String,
|
||||
name: row['name'] as String,
|
||||
description: row['description'] as String?,
|
||||
ownerId: row['owner_id'] as String,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
role: 'owner',
|
||||
itemCount: 0,
|
||||
memberCount: 1,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
final message = e.toString();
|
||||
final rpcUnavailable = message.contains('create_collection_with_owner') &&
|
||||
(message.contains('not found') ||
|
||||
message.contains('does not exist') ||
|
||||
message.contains('PGRST202'));
|
||||
if (!rpcUnavailable) {
|
||||
rethrow;
|
||||
}
|
||||
debugPrint('CollectionService.create RPC unavailable, using fallback: $e');
|
||||
}
|
||||
|
||||
final row = await supabase
|
||||
.from('collections')
|
||||
.insert({
|
||||
'name': name,
|
||||
'owner_id': userId,
|
||||
if (description != null && description.isNotEmpty)
|
||||
'description': description,
|
||||
'description': normalizedDescription,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
// Add owner as a member.
|
||||
try {
|
||||
await supabase.from('collection_members').insert({
|
||||
'collection_id': row['id'],
|
||||
'user_id': userId,
|
||||
'role': 'owner',
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('CollectionService.create member insert failed: $e');
|
||||
try {
|
||||
await supabase.from('collections').delete().eq('id', row['id']);
|
||||
} catch (cleanupError) {
|
||||
debugPrint('CollectionService.create rollback failed: $cleanupError');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return Collection(
|
||||
id: row['id'] as String,
|
||||
|
|
@ -170,7 +353,7 @@ class CollectionService {
|
|||
required String name,
|
||||
String? description,
|
||||
}) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
final collection = await supabase
|
||||
.from('collections')
|
||||
|
|
@ -179,22 +362,27 @@ class CollectionService {
|
|||
.maybeSingle();
|
||||
|
||||
if (collection == null) {
|
||||
throw Exception('Collection not found.');
|
||||
_fail('Collection not found.');
|
||||
}
|
||||
if (collection['owner_id'] != userId) {
|
||||
throw Exception('Only the collection owner can perform this action.');
|
||||
_fail('Only the collection owner can perform this action.');
|
||||
}
|
||||
|
||||
final trimmedDescription = description?.trim();
|
||||
final normalizedDescription =
|
||||
(trimmedDescription != null && trimmedDescription.isNotEmpty)
|
||||
? trimmedDescription
|
||||
: null;
|
||||
|
||||
await supabase.from('collections').update({
|
||||
'name': name,
|
||||
if (description != null && description.isNotEmpty)
|
||||
'description': description,
|
||||
'description': normalizedDescription,
|
||||
}).eq('id', collectionId);
|
||||
}
|
||||
|
||||
/// Delete a collection. Owner only. Cascade deletes members & items.
|
||||
static Future<void> delete(String collectionId) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
final collection = await supabase
|
||||
.from('collections')
|
||||
|
|
@ -203,10 +391,10 @@ class CollectionService {
|
|||
.maybeSingle();
|
||||
|
||||
if (collection == null) {
|
||||
throw Exception('Collection not found.');
|
||||
_fail('Collection not found.');
|
||||
}
|
||||
if (collection['owner_id'] != userId) {
|
||||
throw Exception('Only the collection owner can perform this action.');
|
||||
_fail('Only the collection owner can perform this action.');
|
||||
}
|
||||
|
||||
await supabase.from('collections').delete().eq('id', collectionId);
|
||||
|
|
@ -238,19 +426,44 @@ class CollectionService {
|
|||
static Future<void> inviteByEmail({
|
||||
required String collectionId,
|
||||
required String email,
|
||||
String role = 'member',
|
||||
}) async {
|
||||
final normalizedRole = normalizeRole(role);
|
||||
if (!isSupportedInviteRole(role)) {
|
||||
_fail('Unsupported role "$role".');
|
||||
}
|
||||
|
||||
final currentUserId = _requireUserId();
|
||||
|
||||
final collection = await supabase
|
||||
.from('collections')
|
||||
.select('owner_id')
|
||||
.eq('id', collectionId)
|
||||
.maybeSingle();
|
||||
|
||||
if (collection == null) {
|
||||
_fail('Collection not found.');
|
||||
}
|
||||
if (collection['owner_id'] != currentUserId) {
|
||||
_fail('Only the collection owner can invite members.');
|
||||
}
|
||||
|
||||
// Call an RPC to look up the user ID by email.
|
||||
final result = await supabase.rpc('get_user_id_by_email', params: {
|
||||
'lookup_email': email.trim().toLowerCase(),
|
||||
});
|
||||
|
||||
if (result == null || (result is List && result.isEmpty)) {
|
||||
throw Exception(
|
||||
_fail(
|
||||
'No user found with that email. They must create an account first.');
|
||||
}
|
||||
|
||||
final userId = result is List ? result.first['id'] as String : result as String;
|
||||
|
||||
if (userId == currentUserId) {
|
||||
_fail('You are already in this collection.');
|
||||
}
|
||||
|
||||
// Check if already a member.
|
||||
final existing = await supabase
|
||||
.from('collection_members')
|
||||
|
|
@ -260,30 +473,121 @@ class CollectionService {
|
|||
.maybeSingle();
|
||||
|
||||
if (existing != null) {
|
||||
throw Exception('This user is already a member of this collection.');
|
||||
_fail('This user is already a member of this collection.');
|
||||
}
|
||||
|
||||
try {
|
||||
await supabase.from('collection_members').insert({
|
||||
'collection_id': collectionId,
|
||||
'user_id': userId,
|
||||
'role': 'member',
|
||||
'role': normalizedRole,
|
||||
});
|
||||
} catch (e) {
|
||||
final message = e.toString();
|
||||
if (normalizedRole == 'viewer' &&
|
||||
message.contains('collection_members_role_check')) {
|
||||
_fail(
|
||||
'Viewer role is currently unavailable. Please contact the app administrator.',
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a member from a collection.
|
||||
static Future<void> removeMember({
|
||||
required String collectionId,
|
||||
required String membershipId,
|
||||
required String memberUserId,
|
||||
}) async {
|
||||
final currentUserId = _requireUserId();
|
||||
|
||||
final collection = await supabase
|
||||
.from('collections')
|
||||
.select('owner_id')
|
||||
.eq('id', collectionId)
|
||||
.maybeSingle();
|
||||
|
||||
if (collection == null) {
|
||||
_fail('Collection not found.');
|
||||
}
|
||||
if (collection['owner_id'] != currentUserId) {
|
||||
_fail('Only the collection owner can remove members.');
|
||||
}
|
||||
|
||||
if (memberUserId == collection['owner_id']) {
|
||||
_fail('Collection owner cannot be removed.');
|
||||
}
|
||||
|
||||
final membersBefore = await getMembers(collectionId);
|
||||
final targetMembership =
|
||||
membersBefore.where((member) => member.userId == memberUserId);
|
||||
|
||||
if (targetMembership.isEmpty) {
|
||||
_fail('Member not found in this collection.');
|
||||
}
|
||||
final target = targetMembership.first;
|
||||
if (target.role == 'owner') {
|
||||
_fail('Collection owner cannot be removed.');
|
||||
}
|
||||
|
||||
var deleted = false;
|
||||
try {
|
||||
await supabase.rpc('remove_collection_member', params: {
|
||||
'p_collection_id': collectionId,
|
||||
'p_member_user_id': memberUserId,
|
||||
});
|
||||
deleted = true;
|
||||
} catch (e) {
|
||||
final message = e.toString();
|
||||
final rpcUnavailable = message.contains('remove_collection_member') &&
|
||||
(message.contains('not found') ||
|
||||
message.contains('does not exist') ||
|
||||
message.contains('PGRST202'));
|
||||
|
||||
if (!rpcUnavailable) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('collection_members')
|
||||
.delete()
|
||||
.eq('id', membershipId);
|
||||
.eq('collection_id', collectionId)
|
||||
.eq('user_id', memberUserId);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
_fail('Member removal failed.');
|
||||
}
|
||||
|
||||
final membersAfter = await getMembers(collectionId);
|
||||
final stillExists =
|
||||
membersAfter.any((member) => member.userId == memberUserId);
|
||||
if (stillExists) {
|
||||
_fail(
|
||||
'Member could not be removed. Please try again in a moment.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave a collection (for non-owners).
|
||||
static Future<void> leave(String collectionId) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
final membership = await supabase
|
||||
.from('collection_members')
|
||||
.select('role')
|
||||
.eq('collection_id', collectionId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (membership == null) {
|
||||
_fail('You are not a member of this collection.');
|
||||
}
|
||||
if (membership['role'] == 'owner') {
|
||||
_fail('Owner cannot leave. Delete the collection instead.');
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('collection_members')
|
||||
.delete()
|
||||
|
|
|
|||
11
lib/services/main_collection_sync.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class MainCollectionSync {
|
||||
MainCollectionSync._();
|
||||
|
||||
static final ValueNotifier<int> changeToken = ValueNotifier<int>(0);
|
||||
|
||||
static void notifyChanged() {
|
||||
changeToken.value = changeToken.value + 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +1,173 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../main.dart';
|
||||
import '../utils/error_utils.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.
|
||||
/// Bucket: `car-images` (private)
|
||||
/// Path: `{auth.uid()}/{entry.id}.jpg`
|
||||
class StorageService {
|
||||
StorageService._();
|
||||
|
||||
static const _bucket = 'car-images';
|
||||
static const _uuid = Uuid();
|
||||
static const int _maxImageBytes = 500 * 1024;
|
||||
static const int _maxWidth = 1080;
|
||||
static const int _signedUrlExpirySeconds = 3600;
|
||||
static const int _maxCompressionAttempts = 5;
|
||||
static const _signedUrlRefreshBuffer = Duration(minutes: 3);
|
||||
static const _maxSignedUrlCacheEntries = 500;
|
||||
static final Map<String, _SignedUrlCacheEntry> _signedUrlCache = {};
|
||||
|
||||
/// 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({
|
||||
/// Upload a car image for a specific garage entry.
|
||||
/// Returns the storage path on success (e.g. `uid/123.jpg`).
|
||||
static Future<String> uploadCarImage({
|
||||
required File file,
|
||||
String? oldImageUrl,
|
||||
required int entryId,
|
||||
String? oldPath,
|
||||
}) async {
|
||||
try {
|
||||
// Clean up old image if re-uploading.
|
||||
if (oldImageUrl != null) {
|
||||
await _deleteByUrl(oldImageUrl);
|
||||
final user = supabase.auth.currentUser;
|
||||
if (user == null) {
|
||||
throw const AuthRequiredException(
|
||||
'You must be signed in to upload images.',
|
||||
);
|
||||
}
|
||||
final userId = user.id;
|
||||
final path = '$userId/$entryId.jpg';
|
||||
final compressed = await _compressImage(file);
|
||||
|
||||
final path = 'cars/${_uuid.v4()}.jpg';
|
||||
|
||||
await supabase.storage.from(_bucket).upload(
|
||||
await supabase.storage.from(_bucket).uploadBinary(
|
||||
path,
|
||||
file,
|
||||
compressed,
|
||||
fileOptions: const FileOptions(
|
||||
upsert: true,
|
||||
contentType: 'image/jpeg',
|
||||
),
|
||||
);
|
||||
|
||||
// Return the public URL.
|
||||
final url = supabase.storage.from(_bucket).getPublicUrl(path);
|
||||
return url;
|
||||
_signedUrlCache.remove(path);
|
||||
|
||||
if (oldPath != null && oldPath.isNotEmpty && oldPath != path) {
|
||||
try {
|
||||
await deleteCarImage(oldPath);
|
||||
} catch (e) {
|
||||
debugPrint('StorageService.uploadCarImage error: $e');
|
||||
debugPrint('StorageService.uploadCarImage cleanup error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Generates a temporary signed URL for a private image path.
|
||||
static Future<String?> createSignedUrl(String? path) async {
|
||||
if (path == null || path.isEmpty) return null;
|
||||
|
||||
final now = DateTime.now();
|
||||
final cached = _signedUrlCache[path];
|
||||
if (cached != null &&
|
||||
now.isBefore(cached.expiresAt.subtract(_signedUrlRefreshBuffer))) {
|
||||
return cached.url;
|
||||
}
|
||||
|
||||
try {
|
||||
final signed = await supabase.storage
|
||||
.from(_bucket)
|
||||
.createSignedUrl(path, _signedUrlExpirySeconds);
|
||||
_signedUrlCache[path] = _SignedUrlCacheEntry(
|
||||
url: signed,
|
||||
expiresAt: now.add(const Duration(seconds: _signedUrlExpirySeconds)),
|
||||
);
|
||||
_pruneSignedUrlCache(now);
|
||||
return signed;
|
||||
} catch (e) {
|
||||
debugPrint('StorageService.createSignedUrl 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 {
|
||||
/// Deletes an image using its storage path.
|
||||
static Future<void> deleteCarImage(String? path) async {
|
||||
if (path == null || path.isEmpty) return;
|
||||
_signedUrlCache.remove(path);
|
||||
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');
|
||||
debugPrint('StorageService.deleteCarImage error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static void invalidateSignedUrl(String? path) {
|
||||
if (path == null || path.isEmpty) return;
|
||||
_signedUrlCache.remove(path);
|
||||
}
|
||||
|
||||
static void _pruneSignedUrlCache(DateTime now) {
|
||||
_signedUrlCache.removeWhere((_, entry) => now.isAfter(entry.expiresAt));
|
||||
|
||||
if (_signedUrlCache.length <= _maxSignedUrlCacheEntries) return;
|
||||
final overflow = _signedUrlCache.length - _maxSignedUrlCacheEntries;
|
||||
final keys = _signedUrlCache.keys.take(overflow).toList();
|
||||
for (final key in keys) {
|
||||
_signedUrlCache.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Uint8List> _compressImage(File source) async {
|
||||
final bytes = await source.readAsBytes();
|
||||
return compute(_compressImageBytes, bytes);
|
||||
}
|
||||
|
||||
/// Performs CPU-intensive image decoding, resizing, and JPEG encoding.
|
||||
/// Runs in a background isolate via [compute] to avoid blocking the UI thread.
|
||||
static Uint8List _compressImageBytes(Uint8List bytes) {
|
||||
final decoded = img.decodeImage(bytes);
|
||||
if (decoded == null) {
|
||||
throw const ValidationException('Invalid image file.');
|
||||
}
|
||||
|
||||
img.Image working = decoded.width > _maxWidth
|
||||
? img.copyResize(decoded, width: _maxWidth)
|
||||
: decoded;
|
||||
|
||||
var quality = 85;
|
||||
Uint8List out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
|
||||
|
||||
// First pass: reduce JPEG quality.
|
||||
var qualityAttempts = 0;
|
||||
while (out.lengthInBytes > _maxImageBytes &&
|
||||
quality > 35 &&
|
||||
qualityAttempts < _maxCompressionAttempts) {
|
||||
quality -= 5;
|
||||
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
|
||||
qualityAttempts += 1;
|
||||
}
|
||||
|
||||
// Second pass: reduce dimensions progressively if still above the limit.
|
||||
var dimensionAttempts = 0;
|
||||
while (out.lengthInBytes > _maxImageBytes &&
|
||||
working.width > 320 &&
|
||||
dimensionAttempts < _maxCompressionAttempts) {
|
||||
final nextWidth = (working.width * 0.85).round();
|
||||
working = img.copyResize(working, width: nextWidth);
|
||||
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
|
||||
dimensionAttempts += 1;
|
||||
}
|
||||
|
||||
if (out.lengthInBytes > _maxImageBytes) {
|
||||
throw ValidationException(
|
||||
'Image is too large after compression (${out.lengthInBytes} bytes).',
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class _SignedUrlCacheEntry {
|
||||
final String url;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const _SignedUrlCacheEntry({required this.url, required this.expiresAt});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Brand colours pulled from the HW Collector Hub email template.
|
||||
/// Brand colours aligned with https://www.car64.app.
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// ── Primary gradient (header) ──
|
||||
static const Color orange = Color(0xFFF9A11B);
|
||||
static const Color red = Color(0xFFD40000);
|
||||
// ── Brand (website) ──
|
||||
static const Color orange = Color(0xFF0EA5E9); // keep name for compatibility
|
||||
static const Color red = Color(0xFF38BDF8); // secondary neon tint
|
||||
|
||||
// ── Accent / CTA ──
|
||||
static const Color navy = Color(0xFF003D7A);
|
||||
static const Color navyLight = Color(0xFF0A5BA8);
|
||||
static const Color navy = Color(0xFF0F172A);
|
||||
static const Color navyLight = Color(0xFF1E293B);
|
||||
|
||||
// ── Surfaces ──
|
||||
static const Color backgroundLight = Color(0xFFF4F4F4);
|
||||
static const Color backgroundLight = Color(0xFFF8FAFC);
|
||||
static const Color cardLight = Color(0xFFFFFFFF);
|
||||
static const Color footerGrey = Color(0xFFEEEEEE);
|
||||
static const Color footerGrey = Color(0xFFE2E8F0);
|
||||
|
||||
// ── Text ──
|
||||
static const Color textPrimary = Color(0xFF333333);
|
||||
static const Color textSecondary = Color(0xFF777777);
|
||||
static const Color textHint = Color(0xFF999999);
|
||||
static const Color textPrimary = Color(0xFF0F172A);
|
||||
static const Color textSecondary = Color(0xFF475569);
|
||||
static const Color textHint = Color(0xFF64748B);
|
||||
|
||||
// ── Dark mode surfaces ──
|
||||
static const Color backgroundDark = Color(0xFF1A1A2E);
|
||||
static const Color cardDark = Color(0xFF24243E);
|
||||
static const Color surfaceDark = Color(0xFF2D2D48);
|
||||
static const Color backgroundDark = Color(0xFF0F172A);
|
||||
static const Color cardDark = Color(0xFF111827);
|
||||
static const Color surfaceDark = Color(0xFF1E293B);
|
||||
|
||||
// ── Utility ──
|
||||
static const Color success = Color(0xFF2ECC71);
|
||||
|
|
@ -40,7 +40,7 @@ class AppColors {
|
|||
|
||||
/// A subtler version for cards / chips.
|
||||
static const LinearGradient brandGradientSoft = LinearGradient(
|
||||
colors: [Color(0xFFFFF3E0), Color(0xFFFFEBEE)],
|
||||
colors: [Color(0xFFE0F2FE), Color(0xFFF0F9FF)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// Central theme definition for HW Collector Hub.
|
||||
/// Central theme definition for car64.
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
|
|
|
|||
67
lib/utils/error_utils.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
/// Base app-level exception for expected, user-facing failures.
|
||||
class AppException implements Exception {
|
||||
final String message;
|
||||
|
||||
const AppException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class AuthRequiredException extends AppException {
|
||||
const AuthRequiredException(super.message);
|
||||
}
|
||||
|
||||
class PermissionDeniedException extends AppException {
|
||||
const PermissionDeniedException(super.message);
|
||||
}
|
||||
|
||||
class NotFoundException extends AppException {
|
||||
const NotFoundException(super.message);
|
||||
}
|
||||
|
||||
class ValidationException extends AppException {
|
||||
const ValidationException(super.message);
|
||||
}
|
||||
|
||||
String userMessageForError(
|
||||
Object error, {
|
||||
String fallback = 'Something went wrong. Please try again.',
|
||||
}) {
|
||||
if (error is AppException) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error is AuthException) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
final raw = error.toString();
|
||||
final normalized = raw.toLowerCase();
|
||||
|
||||
if (normalized.contains('socket') ||
|
||||
normalized.contains('network') ||
|
||||
normalized.contains('timeout')) {
|
||||
return 'Network issue. Please check your connection and try again.';
|
||||
}
|
||||
|
||||
if (normalized.contains('permission') || normalized.contains('not allowed')) {
|
||||
return 'You do not have permission for this action.';
|
||||
}
|
||||
|
||||
if (normalized.contains('signed in')) {
|
||||
return 'Please sign in again and retry.';
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
void logError(String scope, Object error, [StackTrace? stackTrace]) {
|
||||
debugPrint('[$scope] $error');
|
||||
if (stackTrace != null) {
|
||||
debugPrint('$stackTrace');
|
||||
}
|
||||
}
|
||||