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/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
TPB.md
|
/TPB.md
|
||||||
supabase_migration.sql
|
/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
|
## 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)
|
2. Run analyze
|
||||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
|
||||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
```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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
android:label="HW Collector Hub"
|
android:label="car64"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<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>
|
<item>
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
</layer-list>
|
</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>
|
<item>
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,7 @@
|
||||||
<item>
|
<item>
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
</layer-list>
|
</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>
|
<item>
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<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>
|
</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:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
<item name="android:windowSplashScreenBackground">#1a1a2e</item>
|
<item name="android:windowSplashScreenBackground">#1a1a2e</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
<item name="android:windowSplashScreenBackground">#f9a11b</item>
|
<item name="android:windowSplashScreenBackground">#f9a11b</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#00000000</color>
|
<color name="ic_launcher_background">#f9a11b</color>
|
||||||
</resources>
|
</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",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "LaunchImageDark.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "LaunchImage@2x.png",
|
"filename" : "LaunchImage@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "LaunchImageDark@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "LaunchImage@3x.png",
|
"filename" : "LaunchImage@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "LaunchImageDark@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"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>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="168" height="185"/>
|
<image name="LaunchImage" width="500" height="500"/>
|
||||||
<image name="LaunchBackground" width="1" height="1"/>
|
<image name="LaunchBackground" width="1" height="1"/>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>HW Collector Hub</string>
|
<string>car64</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>HW Collector Hub</string>
|
<string>car64</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|
|
||||||
253
lib/main.dart
|
|
@ -1,26 +1,55 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'services/collection_service.dart';
|
||||||
|
import 'services/main_collection_sync.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
import 'screens/home_shell.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.
|
// Re-export so other files can `import '../main.dart'` for these.
|
||||||
export 'package:supabase_flutter/supabase_flutter.dart'
|
export 'package:supabase_flutter/supabase_flutter.dart'
|
||||||
show AuthException, UserAttributes;
|
show AuthException, UserAttributes;
|
||||||
|
|
||||||
// ── Supabase credentials ──────────────────────────────────────────────
|
// ── Supabase credentials ──────────────────────────────────────────────
|
||||||
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co';
|
const _supabaseUrl = String.fromEnvironment(
|
||||||
const _supabaseAnonKey = 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL';
|
'SUPABASE_URL',
|
||||||
|
defaultValue: '',
|
||||||
|
);
|
||||||
|
const _supabaseAnonKey = String.fromEnvironment(
|
||||||
|
'SUPABASE_ANON_KEY',
|
||||||
|
defaultValue: '',
|
||||||
|
);
|
||||||
|
const _usePkceAuthFlow = bool.fromEnvironment(
|
||||||
|
'SUPABASE_USE_PKCE',
|
||||||
|
defaultValue: true,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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(
|
await Supabase.initialize(
|
||||||
url: _supabaseUrl,
|
url: _supabaseUrl,
|
||||||
anonKey: _supabaseAnonKey,
|
anonKey: _supabaseAnonKey,
|
||||||
|
authOptions: FlutterAuthClientOptions(
|
||||||
|
authFlowType: _usePkceAuthFlow
|
||||||
|
? AuthFlowType.pkce
|
||||||
|
: AuthFlowType.implicit,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
runApp(const HWHubApp());
|
runApp(const Car64App());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience accessor used throughout the app.
|
/// Convenience accessor used throughout the app.
|
||||||
|
|
@ -29,33 +58,133 @@ final supabase = Supabase.instance.client;
|
||||||
/// Global keys so dialogs & snackbars survive widget-tree rebuilds.
|
/// Global keys so dialogs & snackbars survive widget-tree rebuilds.
|
||||||
final navigatorKey = GlobalKey<NavigatorState>();
|
final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
OverlayEntry? _activeMessageOverlay;
|
||||||
|
Timer? _activeMessageOverlayTimer;
|
||||||
|
|
||||||
|
enum GlobalMessageType { info, success, error }
|
||||||
|
|
||||||
/// Show a snackbar safely through the global key.
|
/// Show a snackbar safely through the global key.
|
||||||
void showGlobalSnackBar(String message, {bool isError = false}) {
|
void showGlobalSnackBar(String message, {bool isError = false}) {
|
||||||
scaffoldMessengerKey.currentState?.showSnackBar(
|
_showGlobalMessageOverlay(
|
||||||
SnackBar(
|
message,
|
||||||
content: Text(message),
|
type: isError ? GlobalMessageType.error : GlobalMessageType.info,
|
||||||
backgroundColor: isError ? Colors.red : null,
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// Show a dialog safely through the global navigator key.
|
||||||
Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
|
Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context == null) {
|
||||||
|
return Future<T?>.value(null);
|
||||||
|
}
|
||||||
return showDialog<T>(
|
return showDialog<T>(
|
||||||
context: navigatorKey.currentContext!,
|
context: context,
|
||||||
builder: builder,
|
builder: builder,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Root App Widget ───────────────────────────────────────────────────
|
// ── Root App Widget ───────────────────────────────────────────────────
|
||||||
class HWHubApp extends StatelessWidget {
|
class Car64App extends StatelessWidget {
|
||||||
const HWHubApp({super.key});
|
const Car64App({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'HW Collector Hub',
|
title: 'car64',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||||
|
|
@ -77,15 +206,19 @@ class AuthGate extends StatefulWidget {
|
||||||
|
|
||||||
class _AuthGateState extends State<AuthGate> {
|
class _AuthGateState extends State<AuthGate> {
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isInPasswordRecoveryFlow = false;
|
||||||
Session? _session;
|
Session? _session;
|
||||||
|
String? _lastEnsuredUserId;
|
||||||
|
StreamSubscription<AuthState>? _authStateSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_session = supabase.auth.currentSession;
|
_session = supabase.auth.currentSession;
|
||||||
|
_ensureDefaultCollectionIfNeeded();
|
||||||
|
|
||||||
supabase.auth.onAuthStateChange.listen(
|
_authStateSubscription = supabase.auth.onAuthStateChange.listen(
|
||||||
(AuthState authState) {
|
(AuthState authState) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
|
@ -97,24 +230,113 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ensureDefaultCollectionIfNeeded();
|
||||||
|
|
||||||
if (authState.event == AuthChangeEvent.passwordRecovery) {
|
if (authState.event == AuthChangeEvent.passwordRecovery) {
|
||||||
|
setState(() => _isInPasswordRecoveryFlow = true);
|
||||||
_showResetPasswordDialog();
|
_showResetPasswordDialog();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (error) {
|
||||||
showGlobalSnackBar('Auth error: $error', isError: true);
|
showGlobalError(
|
||||||
|
error,
|
||||||
|
fallback: 'Authentication error. Please sign in again.',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() => _isLoading = false);
|
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 {
|
Future<void> _showResetPasswordDialog() async {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context == null) {
|
||||||
|
_isInPasswordRecoveryFlow = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: navigatorKey.currentContext!,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (_) => const _ResetPasswordDialog(),
|
builder: (_) => const _ResetPasswordDialog(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isInPasswordRecoveryFlow = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -124,6 +346,7 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (_isInPasswordRecoveryFlow) return const LoginScreen();
|
||||||
return _session != null ? const HomeShell() : const LoginScreen();
|
return _session != null ? const HomeShell() : const LoginScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +384,7 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
showGlobalSnackBar('Password updated successfully!');
|
showGlobalSuccess('Password updated successfully!');
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isSaving = false);
|
setState(() => _isSaving = false);
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,200 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||||
|
import 'services/collection_service.dart';
|
||||||
import 'theme/app_colors.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").
|
/// and extract the hw_id (e.g. "JKF21").
|
||||||
///
|
///
|
||||||
/// The detected ID is returned via Navigator.pop(context, hwId).
|
/// The detected ID is returned via Navigator.pop(context, hwId).
|
||||||
class ScannerScreen extends StatefulWidget {
|
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
|
@override
|
||||||
State<ScannerScreen> createState() => _ScannerScreenState();
|
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;
|
CameraController? _cameraController;
|
||||||
late final TextRecognizer _textRecognizer;
|
late final TextRecognizer _textRecognizer;
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
bool _cameraReady = false;
|
bool _cameraReady = false;
|
||||||
String? _lastDetected;
|
String? _lastDetected;
|
||||||
|
bool _scanAccepted = false;
|
||||||
// Matches typical Hot Wheels model IDs: 2–5 uppercase letters followed by
|
bool _scanNotFound = false;
|
||||||
// 2–4 digits, e.g. JKF21, HCV73, GRX33, FYD83.
|
String _statusText = 'Ready to scan';
|
||||||
final _hwIdPattern = RegExp(r'\b([A-Z]{2,5}\d{2,4})\b');
|
String? _activeCollectionId;
|
||||||
|
bool _autoScanEnabled = false;
|
||||||
|
Timer? _autoScanTimer;
|
||||||
|
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
bool _isInitializingCamera = false;
|
||||||
|
String? _cameraError;
|
||||||
|
int _consecutiveMisses = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_activeCollectionId = widget.activeCollectionId;
|
||||||
|
_autoScanEnabled = widget.onDetected != null;
|
||||||
_textRecognizer = TextRecognizer();
|
_textRecognizer = TextRecognizer();
|
||||||
_initCamera();
|
_initCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initCamera() async {
|
@override
|
||||||
final cameras = await availableCameras();
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (cameras.isEmpty) {
|
if (state == AppLifecycleState.inactive ||
|
||||||
if (!mounted) return;
|
state == AppLifecycleState.paused ||
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
state == AppLifecycleState.detached) {
|
||||||
const SnackBar(content: Text('No camera available')),
|
_disposeCamera();
|
||||||
);
|
|
||||||
return;
|
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(
|
final backCamera = cameras.firstWhere(
|
||||||
(c) => c.lensDirection == CameraLensDirection.back,
|
(c) => c.lensDirection == CameraLensDirection.back,
|
||||||
orElse: () => cameras.first,
|
orElse: () => cameras.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
_cameraController = CameraController(
|
final controller = CameraController(
|
||||||
backCamera,
|
backCamera,
|
||||||
ResolutionPreset.high,
|
ResolutionPreset.medium,
|
||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _cameraController!.initialize();
|
await controller.initialize();
|
||||||
if (!mounted) return;
|
if (!mounted) {
|
||||||
setState(() => _cameraReady = true);
|
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 {
|
Future<void> _captureAndScan() async {
|
||||||
if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return;
|
if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return;
|
||||||
|
if (_cameraController!.value.isTakingPicture) return;
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
setState(() {
|
||||||
|
_isBusy = true;
|
||||||
|
_scanNotFound = false;
|
||||||
|
_statusText = 'Scanning…';
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final xFile = await _cameraController!.takePicture();
|
final xFile = await _cameraController!.takePicture();
|
||||||
final inputImage = InputImage.fromFilePath(xFile.path);
|
final inputImage = InputImage.fromFilePath(xFile.path);
|
||||||
final recognized = await _textRecognizer.processImage(inputImage);
|
final recognized = await _textRecognizer.processImage(inputImage);
|
||||||
|
|
||||||
// Search all recognized text blocks for something matching the HW ID pattern.
|
final found = extractHwIdFromLines(
|
||||||
String? found;
|
recognized.blocks
|
||||||
for (final block in recognized.blocks) {
|
.expand((block) => block.lines)
|
||||||
for (final line in block.lines) {
|
.map((line) => line.text),
|
||||||
final match = _hwIdPattern.firstMatch(line.text.toUpperCase());
|
);
|
||||||
if (match != null) {
|
|
||||||
found = match.group(1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found != null) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the temp image.
|
// Clean up the temp image.
|
||||||
try {
|
try {
|
||||||
|
|
@ -93,34 +204,113 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (found != null) {
|
if (found != null) {
|
||||||
|
_consecutiveMisses = 0;
|
||||||
|
_nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess);
|
||||||
setState(() => _lastDetected = found);
|
setState(() => _lastDetected = found);
|
||||||
|
if (widget.onDetected != null) {
|
||||||
|
await _submitDetected(found);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Show all detected text so user knows what was seen.
|
_consecutiveMisses += 1;
|
||||||
final allText = recognized.blocks.map((b) => b.text).join('\n');
|
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
allText.isEmpty
|
userMessageForError(
|
||||||
? 'No text detected — try again closer.'
|
e,
|
||||||
: 'No HW ID found. Detected:\n$allText',
|
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) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
logError('scanner.submitDetected', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cameraController?.dispose();
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_autoScanTimer?.cancel();
|
||||||
|
final controller = _cameraController;
|
||||||
|
_cameraController = null;
|
||||||
|
if (controller != null) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
_textRecognizer.close();
|
_textRecognizer.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -139,9 +329,104 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
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 ──
|
// ── Camera preview ──
|
||||||
Expanded(
|
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(
|
? Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -164,7 +449,12 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
height: 100,
|
height: 100,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
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,
|
width: 2.5,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
|
@ -229,7 +519,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.of(context).pop(_lastDetected),
|
onPressed: _isBusy || _lastDetected == null
|
||||||
|
? null
|
||||||
|
: () => _submitDetected(_lastDetected!),
|
||||||
child: const Text('Use This'),
|
child: const Text('Use This'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -272,7 +564,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
_isBusy ? 'Scanning…' : 'Capture & Scan',
|
_isBusy ? 'Scanning…' : 'Capture & Scan',
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
onPressed: _isBusy ? null : _captureAndScan,
|
onPressed: _isBusy || !_cameraReady ? null : _captureAndScan,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
shadowColor: Colors.transparent,
|
shadowColor: Colors.transparent,
|
||||||
|
|
@ -298,7 +590,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result != null && context.mounted) {
|
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> {
|
class _AboutScreenState extends State<AboutScreen> {
|
||||||
|
static const _supportEmail = 'support@car64.app';
|
||||||
|
|
||||||
PackageInfo? _packageInfo;
|
PackageInfo? _packageInfo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -25,9 +27,23 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||||
if (mounted) setState(() => _packageInfo = info);
|
if (mounted) setState(() => _packageInfo = info);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openUrl(String url) async {
|
Future<void> _contactSupport() async {
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri(
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
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
|
@override
|
||||||
|
|
@ -55,17 +71,24 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||||
gradient: AppColors.brandGradient,
|
gradient: AppColors.brandGradient,
|
||||||
shape: BoxShape.circle,
|
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,
|
Icons.directions_car_filled,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 52,
|
size: 52,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'HW Collector Hub',
|
'car64',
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|
@ -83,7 +106,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Track and manage your Hot Wheels collection.',
|
'Track and manage your die-cast car collection.',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
|
|
@ -95,29 +118,6 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 16),
|
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 ──
|
// ── Technical details ──
|
||||||
Text(
|
Text(
|
||||||
'Technical',
|
'Technical',
|
||||||
|
|
@ -138,6 +138,16 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||||
value: _packageInfo?.packageName ?? '…',
|
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),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// ── Powered by ──
|
// ── Powered by ──
|
||||||
|
|
@ -152,7 +162,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'© 2026 HW Collector Hub',
|
'© 2026 car64',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: AppColors.textHint,
|
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) ──────────────────────────────────────────
|
// ── Info row (label : value) ──────────────────────────────────────────
|
||||||
|
|
||||||
class _InfoRow extends StatelessWidget {
|
class _InfoRow extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../services/collection_service.dart';
|
import '../services/collection_service.dart';
|
||||||
|
import '../services/main_collection_sync.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import '../utils/error_utils.dart';
|
||||||
|
import '../utils/preferences_utils.dart';
|
||||||
import 'garage_screen.dart';
|
import 'garage_screen.dart';
|
||||||
import 'manage_collection_screen.dart';
|
import 'manage_collection_screen.dart';
|
||||||
|
|
||||||
|
|
@ -13,39 +17,127 @@ class CollectionsScreen extends StatefulWidget {
|
||||||
State<CollectionsScreen> createState() => CollectionsScreenState();
|
State<CollectionsScreen> createState() => CollectionsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class CollectionsScreenState extends State<CollectionsScreen> {
|
class CollectionsScreenState extends State<CollectionsScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
List<Collection> _collections = [];
|
List<Collection> _collections = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isLoadInFlight = false;
|
||||||
|
bool _reloadQueued = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
String? _activeCollectionId;
|
||||||
|
DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
MainCollectionSync.changeToken.addListener(_handleSyncChanged);
|
||||||
_load();
|
_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.
|
/// Public so other tabs can trigger a refresh.
|
||||||
void refresh() => _load();
|
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(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_collections = list;
|
_collections = list;
|
||||||
|
_activeCollectionId = activeId;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
_lastLoadedAt = DateTime.now();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (activeId != null) {
|
||||||
|
await writeActiveCollectionId(
|
||||||
|
prefs,
|
||||||
|
userId: userId,
|
||||||
|
collectionId: activeId,
|
||||||
|
);
|
||||||
|
if (shouldNotifySync) {
|
||||||
|
MainCollectionSync.notifyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.toString();
|
_error = userMessageForError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to load collections. Please try again.',
|
||||||
|
);
|
||||||
_isLoading = false;
|
_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,
|
maxLength: 50,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Name',
|
labelText: 'Name',
|
||||||
hintText: 'e.g. Hot Wheels, Matchbox…',
|
hintText: 'e.g. Die-Cast Cars, Matchbox…',
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
final trimmed = value?.trim() ?? '';
|
final trimmed = value?.trim() ?? '';
|
||||||
|
|
@ -122,10 +214,13 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
||||||
name: nameCtrl.text.trim(),
|
name: nameCtrl.text.trim(),
|
||||||
description: descCtrl.text.trim(),
|
description: descCtrl.text.trim(),
|
||||||
);
|
);
|
||||||
showGlobalSnackBar('Collection created!');
|
showGlobalSuccess('Collection created!');
|
||||||
_load();
|
_load();
|
||||||
} catch (e) {
|
} 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(
|
builder: (_) => GarageScreen(
|
||||||
collectionId: c.id,
|
collectionId: c.id,
|
||||||
collectionName: c.name,
|
collectionName: c.name,
|
||||||
isOwner: c.isOwner,
|
userRole: c.role,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).then((_) => _load()); // refresh counts when coming back
|
).then((_) => _load()); // refresh counts when coming back
|
||||||
|
|
@ -149,10 +244,31 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
||||||
).then((_) => _load());
|
).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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// ── Header ──
|
// ── Header ──
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
|
|
@ -176,8 +292,23 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
background: Container(
|
background: Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.brandGradient,
|
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(
|
child: Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
|
|
@ -186,7 +317,8 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.collections_bookmark,
|
Icons.collections_bookmark,
|
||||||
size: 72,
|
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];
|
final c = _collections[index];
|
||||||
return _CollectionCard(
|
return _CollectionCard(
|
||||||
collection: c,
|
collection: c,
|
||||||
|
isActive: _activeCollectionId == c.id,
|
||||||
|
onSetActive: () => _setActiveCollection(c.id),
|
||||||
onTap: () => _openCollection(c),
|
onTap: () => _openCollection(c),
|
||||||
onManage: () => _manageCollection(c),
|
onManage: () => _manageCollection(c),
|
||||||
);
|
);
|
||||||
|
|
@ -277,6 +411,7 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
floatingActionButton: _collections.isNotEmpty
|
floatingActionButton: _collections.isNotEmpty
|
||||||
? FloatingActionButton(
|
? FloatingActionButton(
|
||||||
onPressed: _createCollection,
|
onPressed: _createCollection,
|
||||||
|
|
@ -291,11 +426,15 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
||||||
|
|
||||||
class _CollectionCard extends StatelessWidget {
|
class _CollectionCard extends StatelessWidget {
|
||||||
final Collection collection;
|
final Collection collection;
|
||||||
|
final bool isActive;
|
||||||
|
final VoidCallback onSetActive;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onManage;
|
final VoidCallback onManage;
|
||||||
|
|
||||||
const _CollectionCard({
|
const _CollectionCard({
|
||||||
required this.collection,
|
required this.collection,
|
||||||
|
required this.isActive,
|
||||||
|
required this.onSetActive,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onManage,
|
required this.onManage,
|
||||||
});
|
});
|
||||||
|
|
@ -363,27 +502,64 @@ class _CollectionCard extends StatelessWidget {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: c.isOwner
|
color: c.isOwner
|
||||||
? AppColors.orange.withValues(alpha: 0.15)
|
? AppColors.orange.withValues(alpha: 0.15)
|
||||||
|
: c.isViewer
|
||||||
|
? AppColors.textHint.withValues(alpha: 0.15)
|
||||||
: AppColors.navy.withValues(alpha: 0.1),
|
: AppColors.navy.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
c.isOwner ? 'Owner' : 'Member',
|
c.isOwner
|
||||||
|
? 'Owner'
|
||||||
|
: c.isViewer
|
||||||
|
? 'Viewer'
|
||||||
|
: 'Member',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: c.isOwner
|
color: c.isOwner
|
||||||
? AppColors.orange
|
? AppColors.orange
|
||||||
|
: c.isViewer
|
||||||
|
? AppColors.textSecondary
|
||||||
: AppColors.navy,
|
: 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(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings_outlined,
|
icon: const Icon(Icons.settings_outlined,
|
||||||
color: AppColors.textHint),
|
color: AppColors.textHint),
|
||||||
|
|
@ -391,6 +567,8 @@ class _CollectionCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ class _HomeShellState extends State<HomeShell> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
final _collectionsKey = GlobalKey<CollectionsScreenState>();
|
final _collectionsKey = GlobalKey<CollectionsScreenState>();
|
||||||
final _scanKey = GlobalKey<ScanTabState>();
|
final _scanKey = GlobalKey<ScanTabState>();
|
||||||
final Map<int, DateTime> _lastRefreshed = {};
|
late final PageController _pageController;
|
||||||
static const _refreshDebounce = Duration(seconds: 30);
|
|
||||||
|
|
||||||
late final List<Widget> _pages = <Widget>[
|
late final List<Widget> _pages = <Widget>[
|
||||||
CollectionsScreen(key: _collectionsKey),
|
CollectionsScreen(key: _collectionsKey),
|
||||||
|
|
@ -24,24 +23,44 @@ class _HomeShellState extends State<HomeShell> {
|
||||||
const ProfileScreen(),
|
const ProfileScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _onTabSelected(int i) {
|
void _onTabSelected(int i) {
|
||||||
|
if (_currentIndex == i) return;
|
||||||
setState(() => _currentIndex = i);
|
setState(() => _currentIndex = i);
|
||||||
final now = DateTime.now();
|
_pageController.animateToPage(
|
||||||
final last = _lastRefreshed[i];
|
i,
|
||||||
if (last != null && now.difference(last) < _refreshDebounce) return;
|
duration: const Duration(milliseconds: 260),
|
||||||
_lastRefreshed[i] = now;
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
_collectionsKey.currentState?.refresh();
|
_collectionsKey.currentState?.refreshIfStale();
|
||||||
} else if (i == 1) {
|
|
||||||
_scanKey.currentState?.refresh();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: IndexedStack(
|
body: PageView(
|
||||||
index: _currentIndex,
|
controller: _pageController,
|
||||||
|
onPageChanged: (index) {
|
||||||
|
if (_currentIndex != index) {
|
||||||
|
setState(() => _currentIndex = index);
|
||||||
|
}
|
||||||
|
if (index == 0) {
|
||||||
|
_collectionsKey.currentState?.refreshIfStale();
|
||||||
|
}
|
||||||
|
},
|
||||||
children: _pages,
|
children: _pages,
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../theme/app_colors.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 {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -76,9 +76,9 @@ class _LoginScreenState extends State<LoginScreen>
|
||||||
try {
|
try {
|
||||||
await supabase.auth.resetPasswordForEmail(
|
await supabase.auth.resetPasswordForEmail(
|
||||||
email,
|
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) {
|
} on AuthException catch (e) {
|
||||||
showGlobalSnackBar(e.message, isError: true);
|
showGlobalSnackBar(e.message, isError: true);
|
||||||
}
|
}
|
||||||
|
|
@ -134,15 +134,22 @@ class _LoginScreenState extends State<LoginScreen>
|
||||||
width: 2,
|
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,
|
Icons.directions_car_filled,
|
||||||
size: 44,
|
size: 44,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
'HW COLLECTOR HUB',
|
'CAR64',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 26,
|
fontSize: 26,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -300,7 +307,7 @@ class _LoginScreenState extends State<LoginScreen>
|
||||||
|
|
||||||
// ── Footer ──
|
// ── Footer ──
|
||||||
Text(
|
Text(
|
||||||
'© 2026 HW Collector Hub',
|
'© 2026 car64',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.white.withValues(alpha: 0.5),
|
color: Colors.white.withValues(alpha: 0.5),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
late Collection _collection;
|
late Collection _collection;
|
||||||
List<CollectionMember> _members = [];
|
List<CollectionMember> _members = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isInviting = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -39,7 +40,10 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = false);
|
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,
|
memberCount: _collection.memberCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
showGlobalSnackBar('Collection renamed!');
|
showGlobalSuccess('Collection renamed!');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('Failed: $e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Could not rename collection. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _inviteMember() async {
|
Future<void> _inviteMember() async {
|
||||||
|
if (_isInviting) return;
|
||||||
final emailCtrl = TextEditingController();
|
final emailCtrl = TextEditingController();
|
||||||
|
String inviteRole = 'member';
|
||||||
|
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => AlertDialog(
|
builder: (_) => StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) => AlertDialog(
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
|
|
@ -142,7 +152,10 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
child: const Icon(Icons.person_add, color: Colors.white, size: 28),
|
child: const Icon(Icons.person_add, color: Colors.white, size: 28),
|
||||||
),
|
),
|
||||||
title: const Text('Invite Member'),
|
title: const Text('Invite Member'),
|
||||||
content: Column(
|
content: SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
|
|
@ -164,7 +177,39 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
prefixIcon: Icon(Icons.email_outlined),
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
@ -180,6 +225,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result != true) return;
|
if (result != true) return;
|
||||||
|
|
@ -192,14 +238,23 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setState(() => _isInviting = true);
|
||||||
await CollectionService.inviteByEmail(
|
await CollectionService.inviteByEmail(
|
||||||
collectionId: _collection.id,
|
collectionId: _collection.id,
|
||||||
email: email,
|
email: email,
|
||||||
|
role: inviteRole,
|
||||||
);
|
);
|
||||||
showGlobalSnackBar('Member invited!');
|
showGlobalSuccess(
|
||||||
_loadMembers();
|
inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!',
|
||||||
|
);
|
||||||
|
await _loadMembers();
|
||||||
} catch (e) {
|
} 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 {
|
try {
|
||||||
await CollectionService.removeMember(
|
await CollectionService.removeMember(
|
||||||
collectionId: _collection.id,
|
collectionId: _collection.id,
|
||||||
membershipId: member.id,
|
memberUserId: member.userId,
|
||||||
);
|
);
|
||||||
showGlobalSnackBar('Member removed.');
|
showGlobalSuccess('Member removed.');
|
||||||
_loadMembers();
|
await _loadMembers();
|
||||||
} catch (e) {
|
} 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 {
|
try {
|
||||||
await CollectionService.leave(_collection.id);
|
await CollectionService.leave(_collection.id);
|
||||||
showGlobalSnackBar('Left "${_collection.name}".');
|
showGlobalSuccess('Left "${_collection.name}".');
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
} catch (e) {
|
} 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 {
|
try {
|
||||||
await CollectionService.delete(_collection.id);
|
await CollectionService.delete(_collection.id);
|
||||||
showGlobalSnackBar('Collection deleted.');
|
showGlobalSuccess('Collection deleted.');
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
} catch (e) {
|
} 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),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
// ── Description ──
|
|
||||||
if (_collection.description != null &&
|
if (_collection.description != null &&
|
||||||
_collection.description!.isNotEmpty) ...[
|
_collection.description!.isNotEmpty) ...[
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -335,7 +400,6 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── Members section ──
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -347,9 +411,9 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (_collection.isOwner)
|
if (_collection.isOwner)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _inviteMember,
|
onPressed: _isInviting ? null : _inviteMember,
|
||||||
icon: const Icon(Icons.person_add, size: 18),
|
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
|
else
|
||||||
...List.generate(_members.length, (i) {
|
...List.generate(_members.length, (i) {
|
||||||
final m = _members[i];
|
final member = _members[i];
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: m.isOwner
|
backgroundColor:
|
||||||
? AppColors.orange
|
member.isOwner ? AppColors.orange : AppColors.navy,
|
||||||
: AppColors.navy,
|
|
||||||
child: Icon(
|
child: Icon(
|
||||||
m.isOwner ? Icons.star : Icons.person,
|
member.isOwner ? Icons.star : Icons.person,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
m.email,
|
member.email,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
m.isOwner ? 'Owner' : 'Member',
|
_roleLabel(member.role),
|
||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
trailing: (!m.isOwner &&
|
trailing: (!member.isOwner &&
|
||||||
_collection.isOwner &&
|
_collection.isOwner &&
|
||||||
m.userId != currentUserId)
|
member.userId != currentUserId)
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.remove_circle_outline,
|
icon: const Icon(Icons.remove_circle_outline,
|
||||||
color: AppColors.error),
|
color: AppColors.error),
|
||||||
onPressed: () => _removeMember(m),
|
onPressed: () => _removeMember(member),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
|
@ -403,7 +466,6 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── Danger zone ──
|
|
||||||
Text(
|
Text(
|
||||||
'Danger Zone',
|
'Danger Zone',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
|
@ -426,14 +488,22 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
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(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _deleteCollection,
|
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',
|
label: const Text('Delete Collection',
|
||||||
style: TextStyle(color: AppColors.error)),
|
style: TextStyle(color: AppColors.error)),
|
||||||
style: OutlinedButton.styleFrom(
|
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 '../main.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import 'about_screen.dart';
|
import 'about_screen.dart';
|
||||||
|
import 'my_reports_screen.dart';
|
||||||
|
|
||||||
/// Profile / settings tab.
|
/// Profile / settings tab.
|
||||||
class ProfileScreen extends StatelessWidget {
|
class ProfileScreen extends StatelessWidget {
|
||||||
|
|
@ -22,11 +23,26 @@ class ProfileScreen extends StatelessWidget {
|
||||||
expandedHeight: 200,
|
expandedHeight: 200,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Container(
|
background: Stack(
|
||||||
decoration: const BoxDecoration(
|
fit: StackFit.expand,
|
||||||
gradient: AppColors.brandGradient,
|
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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -61,13 +77,14 @@ class ProfileScreen extends StatelessWidget {
|
||||||
'Member since ${_formatDate(createdAt)}',
|
'Member since ${_formatDate(createdAt)}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
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',
|
title: 'Change Password',
|
||||||
onTap: () => _changePassword(context),
|
onTap: () => _changePassword(context),
|
||||||
),
|
),
|
||||||
|
_SettingsTile(
|
||||||
|
icon: Icons.flag_outlined,
|
||||||
|
title: 'My Reports',
|
||||||
|
subtitle: 'Track report status',
|
||||||
|
onTap: () => _showMyReports(context),
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'App',
|
'App',
|
||||||
|
|
@ -105,7 +128,7 @@ class ProfileScreen extends StatelessWidget {
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
title: 'About',
|
title: 'About',
|
||||||
subtitle: 'HW Collector Hub',
|
subtitle: 'car64',
|
||||||
onTap: () => _showAbout(context),
|
onTap: () => _showAbout(context),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
@ -131,7 +154,7 @@ class ProfileScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
const Center(
|
const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'© 2026 HW Collector Hub',
|
'© 2026 car64',
|
||||||
style:
|
style:
|
||||||
TextStyle(fontSize: 12, color: AppColors.textHint),
|
TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||||
),
|
),
|
||||||
|
|
@ -185,7 +208,7 @@ class ProfileScreen extends StatelessWidget {
|
||||||
UserAttributes(password: pw),
|
UserAttributes(password: pw),
|
||||||
);
|
);
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
showGlobalSnackBar('Password updated!');
|
showGlobalSuccess('Password updated!');
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
showGlobalSnackBar(e.message, isError: true);
|
showGlobalSnackBar(e.message, isError: true);
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +225,12 @@ class ProfileScreen extends StatelessWidget {
|
||||||
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
MaterialPageRoute(builder: (_) => const AboutScreen()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showMyReports(BuildContext context) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const MyReportsScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings tile widget ──────────────────────────────────────────────
|
// ── Settings tile widget ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../scanner_screen.dart';
|
import '../scanner_screen.dart';
|
||||||
import '../services/collection_service.dart';
|
import '../services/collection_service.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/main_collection_sync.dart';
|
||||||
import '../theme/app_colors.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 {
|
class ScanTab extends StatefulWidget {
|
||||||
const ScanTab({super.key});
|
const ScanTab({super.key});
|
||||||
|
|
||||||
|
|
@ -17,36 +16,81 @@ class ScanTab extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScanTabState extends State<ScanTab> {
|
class ScanTabState extends State<ScanTab> {
|
||||||
|
static const _duplicateCooldown = Duration(seconds: 2);
|
||||||
|
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
List<Collection> _collections = [];
|
List<Collection> _collections = [];
|
||||||
Collection? _selectedCollection;
|
Collection? _selectedCollection;
|
||||||
bool _loadingCollections = true;
|
bool _loadingCollections = true;
|
||||||
|
String? _lastProcessedHwId;
|
||||||
|
DateTime? _lastProcessedAt;
|
||||||
|
|
||||||
|
bool get _canAddToSelectedCollection =>
|
||||||
|
(_selectedCollection?.canModifyCars ?? false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
MainCollectionSync.changeToken.addListener(_handleMainCollectionChanged);
|
||||||
_loadCollections();
|
_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 refresh() => _loadCollections();
|
||||||
|
|
||||||
|
void _handleMainCollectionChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadCollections();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadCollections() async {
|
Future<void> _loadCollections() async {
|
||||||
try {
|
try {
|
||||||
final list = await CollectionService.getMyCollections();
|
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;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_collections = list;
|
_collections = list;
|
||||||
_selectedCollection = list.isNotEmpty ? list.first : null;
|
_selectedCollection = selected;
|
||||||
_loadingCollections = false;
|
_loadingCollections = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (selected != null) {
|
||||||
|
await writeActiveCollectionId(
|
||||||
|
prefs,
|
||||||
|
userId: userId,
|
||||||
|
collectionId: selected.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _loadingCollections = false);
|
setState(() => _loadingCollections = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showGlobalError(
|
||||||
SnackBar(
|
e,
|
||||||
content: Text('Failed to load collections: $e'),
|
fallback: 'Failed to load collections. Please try again.',
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +104,6 @@ class ScanTabState extends State<ScanTab> {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// ── Illustration ──
|
|
||||||
Container(
|
Container(
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
|
|
@ -76,15 +119,12 @@ class ScanTabState extends State<ScanTab> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
const Text(
|
const Text(
|
||||||
'Scan a Hot Wheels Car',
|
'Scan a Die-Cast Car',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
const Text(
|
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,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|
@ -93,8 +133,6 @@ class ScanTabState extends State<ScanTab> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ── Collection picker ──
|
|
||||||
if (_loadingCollections)
|
if (_loadingCollections)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 8),
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
|
|
@ -149,19 +187,21 @@ class ScanTabState extends State<ScanTab> {
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (id) {
|
onChanged: (id) {
|
||||||
setState(() {
|
_setSelectedCollection(id);
|
||||||
final matching =
|
|
||||||
_collections.where((c) => c.id == id);
|
|
||||||
_selectedCollection =
|
|
||||||
matching.isNotEmpty ? matching.first : null;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
if (_selectedCollection?.isViewer == true)
|
||||||
// ── Scan button (gradient) ──
|
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(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 56,
|
height: 56,
|
||||||
|
|
@ -178,7 +218,7 @@ class ScanTabState extends State<ScanTab> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isBusy || _selectedCollection == null
|
onPressed: _isBusy || !_canAddToSelectedCollection
|
||||||
? null
|
? null
|
||||||
: _openScanner,
|
: _openScanner,
|
||||||
icon: _isBusy
|
icon: _isBusy
|
||||||
|
|
@ -210,12 +250,10 @@ class ScanTabState extends State<ScanTab> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── Manual entry ──
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _isBusy || _selectedCollection == null
|
onPressed: _isBusy || !_canAddToSelectedCollection
|
||||||
? null
|
? null
|
||||||
: _manualEntry,
|
: _manualEntry,
|
||||||
icon: const Icon(Icons.keyboard),
|
icon: const Icon(Icons.keyboard),
|
||||||
|
|
@ -229,13 +267,25 @@ class ScanTabState extends State<ScanTab> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openScanner() async {
|
void _setSelectedCollection(String? id) {
|
||||||
final hwId = await navigatorKey.currentState!.push<String>(
|
if (id == null) return;
|
||||||
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
final matching = _collections.where((c) => c.id == id);
|
||||||
);
|
if (matching.isEmpty) return;
|
||||||
|
|
||||||
if (hwId == null || !mounted) return;
|
setState(() => _selectedCollection = matching.first);
|
||||||
await _processHwId(hwId);
|
}
|
||||||
|
|
||||||
|
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 {
|
Future<void> _manualEntry() async {
|
||||||
|
|
@ -274,34 +324,39 @@ class ScanTabState extends State<ScanTab> {
|
||||||
await _processHwId(result);
|
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;
|
final collection = _selectedCollection;
|
||||||
if (collection == null) return;
|
if (collection == null) return false;
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if this hw_id already exists in the selected collection.
|
final existing = await supabase
|
||||||
final data = await supabase
|
|
||||||
.from('hotwheels')
|
.from('hotwheels')
|
||||||
.select()
|
.select('id')
|
||||||
.eq('hw_id', hwId)
|
.eq('hw_id', hwId)
|
||||||
.eq('collection_id', collection.id)
|
.eq('collection_id', collection.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (existing != null) {
|
||||||
|
if (!mounted) return false;
|
||||||
setState(() => _isBusy = false);
|
setState(() => _isBusy = false);
|
||||||
|
|
||||||
if (data != null) {
|
|
||||||
// Already in collection
|
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: navigatorKey.currentContext ?? context,
|
||||||
builder: (_) => AlertDialog(
|
builder: (_) => AlertDialog(
|
||||||
icon: const Icon(Icons.check_circle,
|
icon: const Icon(Icons.check_circle,
|
||||||
color: AppColors.success, size: 48),
|
color: AppColors.success, size: 48),
|
||||||
title: const Text('Already in Collection!'),
|
title: const Text('Already in Collection!'),
|
||||||
content:
|
content: Text('$hwId is already in "${collection.name}".'),
|
||||||
Text('$hwId is already in "${collection.name}".'),
|
|
||||||
actions: [
|
actions: [
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
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 {
|
} else {
|
||||||
// New — offer to add
|
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
|
||||||
final added = await showDialog<bool>(
|
context: navigatorKey.currentContext ?? context,
|
||||||
context: context,
|
isScrollControlled: true,
|
||||||
builder: (_) => _AddCarDialog(
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
builder: (_) => _NewDiscoverySheet(
|
||||||
hwId: hwId,
|
hwId: hwId,
|
||||||
collectionId: collection.id,
|
|
||||||
collectionName: collection.name,
|
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) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _isBusy = false);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add Car Dialog (inline, styled) ──────────────────────────────────
|
class _FoundCarSheet extends StatelessWidget {
|
||||||
class _AddCarDialog extends StatefulWidget {
|
|
||||||
final String hwId;
|
|
||||||
final String collectionId;
|
|
||||||
final String collectionName;
|
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.hwId,
|
||||||
required this.collectionId,
|
|
||||||
required this.collectionName,
|
required this.collectionName,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_AddCarDialog> createState() => _AddCarDialogState();
|
State<_NewDiscoverySheet> createState() => _NewDiscoverySheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AddCarDialogState extends State<_AddCarDialog> {
|
class _NewDiscoverySheetState extends State<_NewDiscoverySheet> {
|
||||||
final _nameController = TextEditingController();
|
final _nameController = TextEditingController();
|
||||||
final _seriesController = TextEditingController();
|
final _seriesController = TextEditingController();
|
||||||
final _yearController = TextEditingController();
|
final _yearController = TextEditingController();
|
||||||
final _notesController = TextEditingController();
|
final _notesController = TextEditingController();
|
||||||
bool _isAdding = false;
|
bool _isSaving = false;
|
||||||
File? _pickedImage;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|
@ -363,208 +626,95 @@ class _AddCarDialogState extends State<_AddCarDialog> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImage() async {
|
void _save() {
|
||||||
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.
|
|
||||||
final name = _nameController.text.trim();
|
final name = _nameController.text.trim();
|
||||||
final series = _seriesController.text.trim();
|
if (name.isEmpty) {
|
||||||
final yearStr = _yearController.text.trim();
|
showGlobalSnackBar('Name is required for a new discovery.', isError: true);
|
||||||
final notes = _notesController.text.trim();
|
return;
|
||||||
|
|
||||||
if (name.isNotEmpty) row['name'] = name;
|
|
||||||
if (series.isNotEmpty) row['series'] = series;
|
|
||||||
if (yearStr.isNotEmpty) {
|
|
||||||
final y = int.tryParse(yearStr);
|
|
||||||
if (y != null) row['year'] = y;
|
|
||||||
}
|
}
|
||||||
if (notes.isNotEmpty) row['notes'] = notes;
|
|
||||||
|
|
||||||
// Upload image if one was taken.
|
setState(() => _isSaving = true);
|
||||||
if (_pickedImage != null) {
|
|
||||||
final url = await StorageService.uploadCarImage(
|
Navigator.pop(
|
||||||
file: _pickedImage!,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return Padding(
|
||||||
icon: Container(
|
padding: EdgeInsets.only(
|
||||||
padding: const EdgeInsets.all(12),
|
left: 20,
|
||||||
decoration: const BoxDecoration(
|
right: 20,
|
||||||
gradient: AppColors.brandGradient,
|
top: 20,
|
||||||
shape: BoxShape.circle,
|
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(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// ── Photo picker ──
|
Text(
|
||||||
GestureDetector(
|
'New Discovery: ${widget.hwId}',
|
||||||
onTap: _pickImage,
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
child: Container(
|
fontWeight: FontWeight.w700,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 14),
|
||||||
)
|
|
||||||
: Align(
|
|
||||||
alignment: Alignment.topRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(6),
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 16,
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.close,
|
|
||||||
size: 16, color: Colors.white),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
onPressed: () =>
|
|
||||||
setState(() => _pickedImage = null),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
|
autofocus: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Car Name',
|
labelText: 'Name *',
|
||||||
hintText: "e.g. '70 Dodge Charger",
|
hintText: "e.g. '70 Dodge Charger",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _seriesController,
|
controller: _seriesController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Series'),
|
||||||
labelText: 'Series',
|
|
||||||
hintText: 'e.g. HW Flames',
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _yearController,
|
controller: _yearController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Year'),
|
||||||
labelText: 'Year',
|
|
||||||
hintText: 'e.g. 2025',
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _notesController,
|
controller: _notesController,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Notes',
|
labelText: 'Notes (for your garage entry)',
|
||||||
hintText: 'Any extra info…',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
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: [
|
const SizedBox(height: 8),
|
||||||
TextButton(
|
SizedBox(
|
||||||
onPressed: _isAdding ? null : () => Navigator.pop(context),
|
width: double.infinity,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _isSaving ? null : () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
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 '../main.dart';
|
||||||
|
import '../utils/error_utils.dart';
|
||||||
|
|
||||||
/// Data model for a collection.
|
/// Data model for a collection.
|
||||||
class Collection {
|
class Collection {
|
||||||
|
|
@ -24,6 +25,9 @@ class Collection {
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isOwner => role == 'owner';
|
bool get isOwner => role == 'owner';
|
||||||
|
bool get isMember => role == 'member';
|
||||||
|
bool get isViewer => role == 'viewer';
|
||||||
|
bool get canModifyCars => isOwner || isMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Member of a collection.
|
/// Member of a collection.
|
||||||
|
|
@ -43,16 +47,65 @@ class CollectionMember {
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isOwner => role == 'owner';
|
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.
|
/// Service for managing collections and membership.
|
||||||
class CollectionService {
|
class CollectionService {
|
||||||
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,
|
/// Fetch all collections the current user is a member of,
|
||||||
/// including item count and member count.
|
/// including item count and member count.
|
||||||
static Future<List<Collection>> getMyCollections() async {
|
static Future<List<Collection>> getMyCollections() async {
|
||||||
final userId = supabase.auth.currentUser!.id;
|
final userId = _requireUserId();
|
||||||
|
|
||||||
// Get memberships with collection data.
|
// Get memberships with collection data.
|
||||||
final memberships = await supabase
|
final memberships = await supabase
|
||||||
|
|
@ -75,25 +128,14 @@ class CollectionService {
|
||||||
|
|
||||||
final collectionIdList = collectionIds.toList();
|
final collectionIdList = collectionIds.toList();
|
||||||
|
|
||||||
// Fetch all items for these collections in a single query and count them in memory.
|
final itemCounts = await getCollectionItemCounts(collectionIdList);
|
||||||
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 memberCounts = <String, int>{};
|
final memberCounts = <String, int>{};
|
||||||
|
final members = await supabase
|
||||||
|
.from('collection_members')
|
||||||
|
.select('collection_id')
|
||||||
|
.inFilter('collection_id', collectionIdList);
|
||||||
|
|
||||||
for (final member in members) {
|
for (final member in members) {
|
||||||
final collectionId = member['collection_id'] as String;
|
final collectionId = member['collection_id'] as String;
|
||||||
memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1;
|
memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1;
|
||||||
|
|
@ -127,30 +169,171 @@ class CollectionService {
|
||||||
return collections;
|
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.
|
/// Create a new collection. The caller is automatically added as owner.
|
||||||
static Future<Collection> create({
|
static Future<Collection> create({
|
||||||
required String name,
|
required String name,
|
||||||
String? description,
|
String? description,
|
||||||
}) async {
|
}) 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
|
final row = await supabase
|
||||||
.from('collections')
|
.from('collections')
|
||||||
.insert({
|
.insert({
|
||||||
'name': name,
|
'name': name,
|
||||||
'owner_id': userId,
|
'owner_id': userId,
|
||||||
if (description != null && description.isNotEmpty)
|
'description': normalizedDescription,
|
||||||
'description': description,
|
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
// Add owner as a member.
|
// Add owner as a member.
|
||||||
|
try {
|
||||||
await supabase.from('collection_members').insert({
|
await supabase.from('collection_members').insert({
|
||||||
'collection_id': row['id'],
|
'collection_id': row['id'],
|
||||||
'user_id': userId,
|
'user_id': userId,
|
||||||
'role': 'owner',
|
'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(
|
return Collection(
|
||||||
id: row['id'] as String,
|
id: row['id'] as String,
|
||||||
|
|
@ -170,7 +353,7 @@ class CollectionService {
|
||||||
required String name,
|
required String name,
|
||||||
String? description,
|
String? description,
|
||||||
}) async {
|
}) async {
|
||||||
final userId = supabase.auth.currentUser!.id;
|
final userId = _requireUserId();
|
||||||
|
|
||||||
final collection = await supabase
|
final collection = await supabase
|
||||||
.from('collections')
|
.from('collections')
|
||||||
|
|
@ -179,22 +362,27 @@ class CollectionService {
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (collection == null) {
|
if (collection == null) {
|
||||||
throw Exception('Collection not found.');
|
_fail('Collection not found.');
|
||||||
}
|
}
|
||||||
if (collection['owner_id'] != userId) {
|
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({
|
await supabase.from('collections').update({
|
||||||
'name': name,
|
'name': name,
|
||||||
if (description != null && description.isNotEmpty)
|
'description': normalizedDescription,
|
||||||
'description': description,
|
|
||||||
}).eq('id', collectionId);
|
}).eq('id', collectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a collection. Owner only. Cascade deletes members & items.
|
/// Delete a collection. Owner only. Cascade deletes members & items.
|
||||||
static Future<void> delete(String collectionId) async {
|
static Future<void> delete(String collectionId) async {
|
||||||
final userId = supabase.auth.currentUser!.id;
|
final userId = _requireUserId();
|
||||||
|
|
||||||
final collection = await supabase
|
final collection = await supabase
|
||||||
.from('collections')
|
.from('collections')
|
||||||
|
|
@ -203,10 +391,10 @@ class CollectionService {
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (collection == null) {
|
if (collection == null) {
|
||||||
throw Exception('Collection not found.');
|
_fail('Collection not found.');
|
||||||
}
|
}
|
||||||
if (collection['owner_id'] != userId) {
|
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);
|
await supabase.from('collections').delete().eq('id', collectionId);
|
||||||
|
|
@ -238,19 +426,44 @@ class CollectionService {
|
||||||
static Future<void> inviteByEmail({
|
static Future<void> inviteByEmail({
|
||||||
required String collectionId,
|
required String collectionId,
|
||||||
required String email,
|
required String email,
|
||||||
|
String role = 'member',
|
||||||
}) async {
|
}) 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.
|
// Call an RPC to look up the user ID by email.
|
||||||
final result = await supabase.rpc('get_user_id_by_email', params: {
|
final result = await supabase.rpc('get_user_id_by_email', params: {
|
||||||
'lookup_email': email.trim().toLowerCase(),
|
'lookup_email': email.trim().toLowerCase(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result == null || (result is List && result.isEmpty)) {
|
if (result == null || (result is List && result.isEmpty)) {
|
||||||
throw Exception(
|
_fail(
|
||||||
'No user found with that email. They must create an account first.');
|
'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;
|
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.
|
// Check if already a member.
|
||||||
final existing = await supabase
|
final existing = await supabase
|
||||||
.from('collection_members')
|
.from('collection_members')
|
||||||
|
|
@ -260,30 +473,121 @@ class CollectionService {
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (existing != null) {
|
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({
|
await supabase.from('collection_members').insert({
|
||||||
'collection_id': collectionId,
|
'collection_id': collectionId,
|
||||||
'user_id': userId,
|
'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.
|
/// Remove a member from a collection.
|
||||||
static Future<void> removeMember({
|
static Future<void> removeMember({
|
||||||
required String collectionId,
|
required String collectionId,
|
||||||
required String membershipId,
|
required String memberUserId,
|
||||||
}) async {
|
}) 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
|
await supabase
|
||||||
.from('collection_members')
|
.from('collection_members')
|
||||||
.delete()
|
.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).
|
/// Leave a collection (for non-owners).
|
||||||
static Future<void> leave(String collectionId) async {
|
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
|
await supabase
|
||||||
.from('collection_members')
|
.from('collection_members')
|
||||||
.delete()
|
.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 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
|
import '../utils/error_utils.dart';
|
||||||
|
|
||||||
/// Handles uploading / deleting car images in Supabase Storage.
|
/// Handles uploading / deleting car images in Supabase Storage.
|
||||||
///
|
///
|
||||||
/// Bucket: `car-images` (public, but URLs are unguessable)
|
/// Bucket: `car-images` (private)
|
||||||
/// Path: `cars/{uuid}.jpg` — random UUID per image.
|
/// Path: `{auth.uid()}/{entry.id}.jpg`
|
||||||
///
|
|
||||||
/// Shared garage — any authenticated user can upload / replace / delete.
|
|
||||||
class StorageService {
|
class StorageService {
|
||||||
StorageService._();
|
StorageService._();
|
||||||
|
|
||||||
static const _bucket = 'car-images';
|
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].
|
/// Upload a car image for a specific garage entry.
|
||||||
///
|
/// Returns the storage path on success (e.g. `uid/123.jpg`).
|
||||||
/// If [oldImageUrl] is provided the previous file is deleted first.
|
static Future<String> uploadCarImage({
|
||||||
/// Returns the public URL on success, or `null` on failure.
|
|
||||||
static Future<String?> uploadCarImage({
|
|
||||||
required File file,
|
required File file,
|
||||||
String? oldImageUrl,
|
required int entryId,
|
||||||
|
String? oldPath,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
final user = supabase.auth.currentUser;
|
||||||
// Clean up old image if re-uploading.
|
if (user == null) {
|
||||||
if (oldImageUrl != null) {
|
throw const AuthRequiredException(
|
||||||
await _deleteByUrl(oldImageUrl);
|
'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).uploadBinary(
|
||||||
|
|
||||||
await supabase.storage.from(_bucket).upload(
|
|
||||||
path,
|
path,
|
||||||
file,
|
compressed,
|
||||||
fileOptions: const FileOptions(
|
fileOptions: const FileOptions(
|
||||||
|
upsert: true,
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return the public URL.
|
_signedUrlCache.remove(path);
|
||||||
final url = supabase.storage.from(_bucket).getPublicUrl(path);
|
|
||||||
return url;
|
if (oldPath != null && oldPath.isNotEmpty && oldPath != path) {
|
||||||
|
try {
|
||||||
|
await deleteCarImage(oldPath);
|
||||||
} catch (e) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the image at the given public [imageUrl].
|
/// Deletes an image using its storage path.
|
||||||
static Future<void> deleteCarImage(String? imageUrl) async {
|
static Future<void> deleteCarImage(String? path) async {
|
||||||
if (imageUrl == null || imageUrl.isEmpty) return;
|
if (path == null || path.isEmpty) return;
|
||||||
await _deleteByUrl(imageUrl);
|
_signedUrlCache.remove(path);
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the storage path from a public URL and remove the file.
|
|
||||||
static Future<void> _deleteByUrl(String imageUrl) async {
|
|
||||||
try {
|
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]);
|
await supabase.storage.from(_bucket).remove([path]);
|
||||||
} catch (e) {
|
} 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';
|
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 {
|
class AppColors {
|
||||||
AppColors._();
|
AppColors._();
|
||||||
|
|
||||||
// ── Primary gradient (header) ──
|
// ── Brand (website) ──
|
||||||
static const Color orange = Color(0xFFF9A11B);
|
static const Color orange = Color(0xFF0EA5E9); // keep name for compatibility
|
||||||
static const Color red = Color(0xFFD40000);
|
static const Color red = Color(0xFF38BDF8); // secondary neon tint
|
||||||
|
|
||||||
// ── Accent / CTA ──
|
// ── Accent / CTA ──
|
||||||
static const Color navy = Color(0xFF003D7A);
|
static const Color navy = Color(0xFF0F172A);
|
||||||
static const Color navyLight = Color(0xFF0A5BA8);
|
static const Color navyLight = Color(0xFF1E293B);
|
||||||
|
|
||||||
// ── Surfaces ──
|
// ── Surfaces ──
|
||||||
static const Color backgroundLight = Color(0xFFF4F4F4);
|
static const Color backgroundLight = Color(0xFFF8FAFC);
|
||||||
static const Color cardLight = Color(0xFFFFFFFF);
|
static const Color cardLight = Color(0xFFFFFFFF);
|
||||||
static const Color footerGrey = Color(0xFFEEEEEE);
|
static const Color footerGrey = Color(0xFFE2E8F0);
|
||||||
|
|
||||||
// ── Text ──
|
// ── Text ──
|
||||||
static const Color textPrimary = Color(0xFF333333);
|
static const Color textPrimary = Color(0xFF0F172A);
|
||||||
static const Color textSecondary = Color(0xFF777777);
|
static const Color textSecondary = Color(0xFF475569);
|
||||||
static const Color textHint = Color(0xFF999999);
|
static const Color textHint = Color(0xFF64748B);
|
||||||
|
|
||||||
// ── Dark mode surfaces ──
|
// ── Dark mode surfaces ──
|
||||||
static const Color backgroundDark = Color(0xFF1A1A2E);
|
static const Color backgroundDark = Color(0xFF0F172A);
|
||||||
static const Color cardDark = Color(0xFF24243E);
|
static const Color cardDark = Color(0xFF111827);
|
||||||
static const Color surfaceDark = Color(0xFF2D2D48);
|
static const Color surfaceDark = Color(0xFF1E293B);
|
||||||
|
|
||||||
// ── Utility ──
|
// ── Utility ──
|
||||||
static const Color success = Color(0xFF2ECC71);
|
static const Color success = Color(0xFF2ECC71);
|
||||||
|
|
@ -40,7 +40,7 @@ class AppColors {
|
||||||
|
|
||||||
/// A subtler version for cards / chips.
|
/// A subtler version for cards / chips.
|
||||||
static const LinearGradient brandGradientSoft = LinearGradient(
|
static const LinearGradient brandGradientSoft = LinearGradient(
|
||||||
colors: [Color(0xFFFFF3E0), Color(0xFFFFEBEE)],
|
colors: [Color(0xFFE0F2FE), Color(0xFFF0F9FF)],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
/// Central theme definition for HW Collector Hub.
|
/// Central theme definition for car64.
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||