Merge branch 'fix/flutter-code-review-findings-2026-03-05' into v3.0
This commit is contained in:
commit
e4f0f73fe8
22 changed files with 1009 additions and 164 deletions
5
.env/flutter_defines.example.json
Normal file
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"
|
||||||
|
}
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -48,6 +48,10 @@ app.*.map.json
|
||||||
/supabase_migration.sql
|
/supabase_migration.sql
|
||||||
/TPB_APP_CHECKLIST.md
|
/TPB_APP_CHECKLIST.md
|
||||||
|
|
||||||
|
# Local run configuration with secrets
|
||||||
|
/.vscode/launch.json
|
||||||
|
/.env/flutter_defines.json
|
||||||
|
|
||||||
# Local testing artifacts
|
# Local testing artifacts
|
||||||
/flutter_*.png
|
/flutter_*.png
|
||||||
/devtools_options.yaml
|
/devtools_options.yaml
|
||||||
44
.vscode/tasks.json
vendored
Normal file
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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
52
CONTRIBUTING.md
Normal file
52
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
- `fix(scanner): prevent duplicate processing on repeated detections`
|
||||||
|
- `perf(collections): coalesce overlapping reload requests`
|
||||||
|
- `docs(readme): add release workflow section`
|
||||||
21
LICENSE
Normal file
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.
|
||||||
199
README.md
199
README.md
|
|
@ -1,38 +1,193 @@
|
||||||
# car64
|
<p align="center">
|
||||||
|
<img src="assets/icon/app_icon.png" alt="car64 logo" width="120" />
|
||||||
|
</p>
|
||||||
|
|
||||||
car64 is a Flutter + Supabase Hot Wheels collector app with a fast scanning workflow, private photo storage, collaborative collections, and community-based catalog validation.
|
<h1 align="center">car64</h1>
|
||||||
|
|
||||||
## Core Features
|
<p align="center">
|
||||||
|
A modern Flutter + Supabase app for tracking and managing die-cast car collections.
|
||||||
|
</p>
|
||||||
|
|
||||||
- Lightning add flow with barcode/OCR scanning and active collection selection.
|
---
|
||||||
- Global catalog (`global_cars`) + personal entries (`hotwheels`) architecture.
|
|
||||||
- Private storage (`car-images`) with signed URL rendering for collection members.
|
|
||||||
- In-app photo compression pipeline (target max 1080px and <500 KB uploads).
|
|
||||||
- Collaborative garages via `collections` + `collection_members`.
|
|
||||||
- Community validation with `car_votes`, `confirmation_count`, and verification state.
|
|
||||||
|
|
||||||
## Local Development
|
## Table of Contents
|
||||||
|
|
||||||
1. Install Flutter (stable) and run `flutter doctor`.
|
- [Overview](#overview)
|
||||||
2. Configure Supabase keys in app configuration.
|
- [Features](#features)
|
||||||
3. Install dependencies:
|
- [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)
|
||||||
|
|
||||||
```bash
|
## Overview
|
||||||
flutter pub get
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Run the app:
|
car64 helps collectors scan model IDs, organize personal and shared collections, and keep a clean catalog with community validation/reporting flows.
|
||||||
|
|
||||||
```bash
|
## Features
|
||||||
flutter run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quality Check
|
- 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` (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
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run analyze
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter analyze
|
flutter analyze
|
||||||
```
|
```
|
||||||
|
|
||||||
|
3. Launch app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
## VS Code Workflows
|
||||||
|
|
||||||
|
### Run / Debug (`launch.json`)
|
||||||
|
|
||||||
|
Use **Run and Debug** with:
|
||||||
|
|
||||||
|
- `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)`
|
||||||
|
|
||||||
|
## 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` for branching, commit style, 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`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
License file is not included in this repository yet.
|
||||||
|
Add your preferred license in the GitHub repo when ready.
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
- Product and backend requirements: `TPB.md`
|
- Product/backend notes: `TPB.md`
|
||||||
|
|
|
||||||
31
SECURITY.md
Normal file
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).
|
||||||
179
lib/main.dart
179
lib/main.dart
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
@ -6,23 +8,44 @@ import 'services/collection_service.dart';
|
||||||
import 'services/main_collection_sync.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: const FlutterAuthClientOptions(
|
authOptions: FlutterAuthClientOptions(
|
||||||
authFlowType: AuthFlowType.implicit,
|
authFlowType: _usePkceAuthFlow
|
||||||
|
? AuthFlowType.pkce
|
||||||
|
: AuthFlowType.implicit,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -35,29 +58,121 @@ 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}) {
|
||||||
final messenger = scaffoldMessengerKey.currentState;
|
_showGlobalMessageOverlay(
|
||||||
if (messenger == null) return;
|
message,
|
||||||
|
type: isError ? GlobalMessageType.error : GlobalMessageType.info,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
messenger
|
void showGlobalSuccess(String message) {
|
||||||
..hideCurrentSnackBar()
|
_showGlobalMessageOverlay(message, type: GlobalMessageType.success);
|
||||||
..showSnackBar(
|
}
|
||||||
SnackBar(
|
|
||||||
content: Text(message),
|
void showGlobalInfo(String message) {
|
||||||
backgroundColor: isError ? Colors.red : null,
|
_showGlobalMessageOverlay(message, type: GlobalMessageType.info);
|
||||||
behavior: SnackBarBehavior.floating,
|
}
|
||||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 96),
|
|
||||||
duration: const Duration(seconds: 3),
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -90,8 +205,6 @@ class AuthGate extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AuthGateState extends State<AuthGate> {
|
class _AuthGateState extends State<AuthGate> {
|
||||||
static const _activeCollectionPrefKey = 'active_collection_id';
|
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isInPasswordRecoveryFlow = false;
|
bool _isInPasswordRecoveryFlow = false;
|
||||||
Session? _session;
|
Session? _session;
|
||||||
|
|
@ -124,7 +237,10 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (error) {
|
||||||
showGlobalSnackBar('Auth error: $error', isError: true);
|
showGlobalError(
|
||||||
|
error,
|
||||||
|
fallback: 'Authentication error. Please sign in again.',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -140,7 +256,10 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
final defaultCollectionId = await CollectionService.ensureDefaultCollection();
|
final defaultCollectionId = await CollectionService.ensureDefaultCollection();
|
||||||
await _ensureMainCollectionPreference(defaultCollectionId);
|
await _ensureMainCollectionPreference(defaultCollectionId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('Collection setup failed: $e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Collection setup failed. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +268,7 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
if (userId == null) return;
|
if (userId == null) return;
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final persisted = prefs.getString(_activeCollectionPrefKey);
|
final persisted = await readActiveCollectionId(prefs, userId: userId);
|
||||||
|
|
||||||
Future<bool> hasMembership(String collectionId) async {
|
Future<bool> hasMembership(String collectionId) async {
|
||||||
final membership = await supabase
|
final membership = await supabase
|
||||||
|
|
@ -180,17 +299,27 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextActiveId == null) {
|
if (nextActiveId == null) {
|
||||||
await prefs.remove(_activeCollectionPrefKey);
|
await clearActiveCollectionId(prefs, userId: userId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prefs.setString(_activeCollectionPrefKey, nextActiveId);
|
await writeActiveCollectionId(
|
||||||
|
prefs,
|
||||||
|
userId: userId,
|
||||||
|
collectionId: nextActiveId,
|
||||||
|
);
|
||||||
MainCollectionSync.notifyChanged();
|
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(),
|
||||||
);
|
);
|
||||||
|
|
@ -245,7 +374,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);
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ 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 'services/collection_service.dart';
|
||||||
import 'theme/app_colors.dart';
|
import 'theme/app_colors.dart';
|
||||||
|
import 'utils/error_utils.dart';
|
||||||
import 'utils/scanner_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).
|
||||||
|
|
@ -36,6 +37,7 @@ class _ScannerScreenState extends State<ScannerScreen>
|
||||||
static const _scanCooldownSuccess = Duration(milliseconds: 1500);
|
static const _scanCooldownSuccess = Duration(milliseconds: 1500);
|
||||||
static const _scanCooldownNoMatch = Duration(milliseconds: 2200);
|
static const _scanCooldownNoMatch = Duration(milliseconds: 2200);
|
||||||
static const _scanCooldownError = Duration(milliseconds: 2600);
|
static const _scanCooldownError = Duration(milliseconds: 2600);
|
||||||
|
static const _scanCooldownNoMatchMax = Duration(milliseconds: 5000);
|
||||||
|
|
||||||
CameraController? _cameraController;
|
CameraController? _cameraController;
|
||||||
late final TextRecognizer _textRecognizer;
|
late final TextRecognizer _textRecognizer;
|
||||||
|
|
@ -51,6 +53,7 @@ class _ScannerScreenState extends State<ScannerScreen>
|
||||||
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
bool _isInitializingCamera = false;
|
bool _isInitializingCamera = false;
|
||||||
String? _cameraError;
|
String? _cameraError;
|
||||||
|
int _consecutiveMisses = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -126,9 +129,15 @@ class _ScannerScreenState extends State<ScannerScreen>
|
||||||
_cameraError = 'Camera unavailable. Please retry.';
|
_cameraError = 'Camera unavailable. Please retry.';
|
||||||
_statusText = 'Camera unavailable';
|
_statusText = 'Camera unavailable';
|
||||||
});
|
});
|
||||||
|
logError('scanner.initCamera', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Camera init failed: $e'),
|
content: Text(
|
||||||
|
userMessageForError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to start camera. Please try again.',
|
||||||
|
),
|
||||||
|
),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -165,7 +174,7 @@ class _ScannerScreenState extends State<ScannerScreen>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Capture a photo, run OCR, and look for a Hot Wheels ID.
|
/// 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;
|
if (_cameraController!.value.isTakingPicture) return;
|
||||||
|
|
@ -195,13 +204,23 @@ class _ScannerScreenState extends State<ScannerScreen>
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (found != null) {
|
if (found != null) {
|
||||||
|
_consecutiveMisses = 0;
|
||||||
_nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess);
|
_nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess);
|
||||||
setState(() => _lastDetected = found);
|
setState(() => _lastDetected = found);
|
||||||
if (widget.onDetected != null) {
|
if (widget.onDetected != null) {
|
||||||
await _submitDetected(found);
|
await _submitDetected(found);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_nextScanAllowedAt = DateTime.now().add(_scanCooldownNoMatch);
|
_consecutiveMisses += 1;
|
||||||
|
final missBackoffMs = (_scanCooldownNoMatch.inMilliseconds +
|
||||||
|
(_consecutiveMisses * 300))
|
||||||
|
.clamp(
|
||||||
|
_scanCooldownNoMatch.inMilliseconds,
|
||||||
|
_scanCooldownNoMatchMax.inMilliseconds,
|
||||||
|
);
|
||||||
|
_nextScanAllowedAt = DateTime.now().add(
|
||||||
|
Duration(milliseconds: missBackoffMs),
|
||||||
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_scanAccepted = false;
|
_scanAccepted = false;
|
||||||
_scanNotFound = true;
|
_scanNotFound = true;
|
||||||
|
|
@ -209,10 +228,20 @@ class _ScannerScreenState extends State<ScannerScreen>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_consecutiveMisses += 1;
|
||||||
_nextScanAllowedAt = DateTime.now().add(_scanCooldownError);
|
_nextScanAllowedAt = DateTime.now().add(_scanCooldownError);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
logError('scanner.capture', e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Scan error: $e'), backgroundColor: Colors.red),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
userMessageForError(
|
||||||
|
e,
|
||||||
|
fallback: 'Scan failed. Please try again.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setState(() => _statusText = 'Scan failed, try again');
|
setState(() => _statusText = 'Scan failed, try again');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -258,8 +287,17 @@ class _ScannerScreenState extends State<ScannerScreen>
|
||||||
}
|
}
|
||||||
} 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('Process error: $e'), backgroundColor: Colors.red),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
userMessageForError(
|
||||||
|
e,
|
||||||
|
fallback: 'Could not process this scan. Please try again.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import '../main.dart';
|
||||||
import '../services/collection_service.dart';
|
import '../services/collection_service.dart';
|
||||||
import '../services/main_collection_sync.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';
|
||||||
|
|
||||||
|
|
@ -17,10 +19,10 @@ class CollectionsScreen extends StatefulWidget {
|
||||||
|
|
||||||
class CollectionsScreenState extends State<CollectionsScreen>
|
class CollectionsScreenState extends State<CollectionsScreen>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
static const _activeCollectionPrefKey = 'active_collection_id';
|
|
||||||
|
|
||||||
List<Collection> _collections = [];
|
List<Collection> _collections = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isLoadInFlight = false;
|
||||||
|
bool _reloadQueued = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
String? _activeCollectionId;
|
String? _activeCollectionId;
|
||||||
DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
@ -61,11 +63,20 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
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 {
|
||||||
var list = await CollectionService.getMyCollections();
|
var list = await CollectionService.getMyCollections();
|
||||||
|
|
@ -73,8 +84,13 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
||||||
await CollectionService.ensureDefaultCollection();
|
await CollectionService.ensureDefaultCollection();
|
||||||
list = await CollectionService.getMyCollections();
|
list = await CollectionService.getMyCollections();
|
||||||
}
|
}
|
||||||
|
final userId = supabase.auth.currentUser?.id;
|
||||||
|
if (userId == null) {
|
||||||
|
throw Exception('You must be signed in to load collections.');
|
||||||
|
}
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final persisted = prefs.getString(_activeCollectionPrefKey);
|
final persisted = await readActiveCollectionId(prefs, userId: userId);
|
||||||
|
|
||||||
String? activeId = persisted;
|
String? activeId = persisted;
|
||||||
if (activeId == null && list.isNotEmpty) {
|
if (activeId == null && list.isNotEmpty) {
|
||||||
|
|
@ -95,7 +111,11 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activeId != null) {
|
if (activeId != null) {
|
||||||
await prefs.setString(_activeCollectionPrefKey, activeId);
|
await writeActiveCollectionId(
|
||||||
|
prefs,
|
||||||
|
userId: userId,
|
||||||
|
collectionId: activeId,
|
||||||
|
);
|
||||||
if (shouldNotifySync) {
|
if (shouldNotifySync) {
|
||||||
MainCollectionSync.notifyChanged();
|
MainCollectionSync.notifyChanged();
|
||||||
}
|
}
|
||||||
|
|
@ -103,9 +123,19 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
||||||
} 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +167,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() ?? '';
|
||||||
|
|
@ -182,10 +212,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.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,12 +243,22 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setActiveCollection(String collectionId) async {
|
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();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_activeCollectionPrefKey, collectionId);
|
await writeActiveCollectionId(
|
||||||
|
prefs,
|
||||||
|
userId: userId,
|
||||||
|
collectionId: collectionId,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _activeCollectionId = collectionId);
|
setState(() => _activeCollectionId = collectionId);
|
||||||
MainCollectionSync.notifyChanged();
|
MainCollectionSync.notifyChanged();
|
||||||
showGlobalSnackBar('Main collection set for scanning.');
|
showGlobalInfo('Main collection set for scanning.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ import '../main.dart';
|
||||||
import '../services/collection_service.dart';
|
import '../services/collection_service.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import '../utils/error_utils.dart';
|
||||||
import '../widgets/car_card.dart';
|
import '../widgets/car_card.dart';
|
||||||
|
|
||||||
/// The "My Garage" screen — shows a collection's cars in a grid.
|
/// The "My Garage" screen — shows a collection's die-cast cars in a grid.
|
||||||
class GarageScreen extends StatefulWidget {
|
class GarageScreen extends StatefulWidget {
|
||||||
final String collectionId;
|
final String collectionId;
|
||||||
final String collectionName;
|
final String collectionName;
|
||||||
|
|
@ -108,21 +109,11 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
.range(from, to);
|
.range(from, to);
|
||||||
|
|
||||||
final rows = List<Map<String, dynamic>>.from(data);
|
final rows = List<Map<String, dynamic>>.from(data);
|
||||||
final withSignedUrls = await Future.wait(
|
|
||||||
rows.map((row) async {
|
|
||||||
final path = row['user_image_url'] as String?;
|
|
||||||
final signed = await StorageService.createSignedUrl(path);
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
'signed_image_url': signed,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_cars = reset ? withSignedUrls : [..._cars, ...withSignedUrls];
|
_cars = reset ? rows : [..._cars, ...rows];
|
||||||
_hasMore = withSignedUrls.length == _pageSize;
|
_hasMore = rows.length == _pageSize;
|
||||||
if (_hasMore) _page += 1;
|
if (_hasMore) _page += 1;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_isLoadingMore = false;
|
_isLoadingMore = false;
|
||||||
|
|
@ -130,10 +121,14 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.toString();
|
_error = userMessageForError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to load cars. Please try again.',
|
||||||
|
);
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_isLoadingMore = false;
|
_isLoadingMore = false;
|
||||||
});
|
});
|
||||||
|
logError('garage.loadCars', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,14 +324,19 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
color: global?['color'] as String?,
|
color: global?['color'] as String?,
|
||||||
isVerified: global?['is_verified'] == true,
|
isVerified: global?['is_verified'] == true,
|
||||||
imageUrl: car['signed_image_url'] as String?,
|
imageUrl: car['signed_image_url'] as String?,
|
||||||
|
imagePath: car['user_image_url'] as String?,
|
||||||
isSelected: _selectedIds.contains(carId),
|
isSelected: _selectedIds.contains(carId),
|
||||||
addedAt: car['created_at'] != null
|
addedAt: car['created_at'] != null
|
||||||
? DateTime.tryParse(car['created_at'])
|
? DateTime.tryParse(car['created_at'])
|
||||||
: null,
|
: null,
|
||||||
onImageError: () => _refreshSignedUrlForCar(carId),
|
onImageError: () => _refreshSignedUrlForCar(carId),
|
||||||
onTap: () => _selectionMode
|
onTap: () {
|
||||||
? _toggleCarSelection(car)
|
if (_selectionMode) {
|
||||||
: _showCarDetails(car),
|
_toggleCarSelection(car);
|
||||||
|
} else {
|
||||||
|
_showCarDetails(car);
|
||||||
|
}
|
||||||
|
},
|
||||||
onLongPress:
|
onLongPress:
|
||||||
widget.isViewer ? null : () => _toggleCarSelection(car),
|
widget.isViewer ? null : () => _toggleCarSelection(car),
|
||||||
);
|
);
|
||||||
|
|
@ -476,33 +476,110 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true || targetId == null) return;
|
if (confirmed != true || targetId == null) return;
|
||||||
|
final targetCollectionId = targetId!;
|
||||||
|
|
||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
await supabase
|
|
||||||
.from('hotwheels')
|
|
||||||
.update({'collection_id': targetId})
|
|
||||||
.inFilter('id', _selectedIds.toList());
|
|
||||||
} else {
|
|
||||||
final sourceCars = _cars
|
final sourceCars = _cars
|
||||||
.where((car) => _selectedIds.contains(car['id'] as int))
|
.where((car) => _selectedIds.contains(car['id'] as int))
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final hwIds = sourceCars
|
||||||
|
.map((car) => car['hw_id'] as String)
|
||||||
|
.toSet()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final existing = await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.select('hw_id')
|
||||||
|
.eq('collection_id', targetCollectionId)
|
||||||
|
.inFilter('hw_id', hwIds);
|
||||||
|
|
||||||
|
final existingHwIds = (existing as List)
|
||||||
|
.map((row) => row['hw_id'] as String)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
final moveableIds = sourceCars
|
||||||
|
.where((car) => !existingHwIds.contains(car['hw_id'] as String))
|
||||||
|
.map((car) => car['id'] as int)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final skippedDuplicates = sourceCars.length - moveableIds.length;
|
||||||
|
if (moveableIds.isEmpty) {
|
||||||
|
showGlobalSnackBar(
|
||||||
|
'All selected cars are already in the target collection.',
|
||||||
|
isError: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.update({'collection_id': targetCollectionId})
|
||||||
|
.inFilter('id', moveableIds);
|
||||||
|
|
||||||
|
if (skippedDuplicates > 0) {
|
||||||
|
showGlobalSnackBar(
|
||||||
|
'$skippedDuplicates car(s) skipped because they already exist in target collection.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final userId = supabase.auth.currentUser?.id;
|
||||||
|
if (userId == null) {
|
||||||
|
throw Exception('You must be signed in to copy cars.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final sourceCars = _cars
|
||||||
|
.where((car) => _selectedIds.contains(car['id'] as int))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final hwIds = sourceCars
|
||||||
|
.map((car) => car['hw_id'] as String)
|
||||||
|
.toSet()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final existing = await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.select('hw_id')
|
||||||
|
.eq('collection_id', targetCollectionId)
|
||||||
|
.inFilter('hw_id', hwIds);
|
||||||
|
final existingHwIds = (existing as List)
|
||||||
|
.map((row) => row['hw_id'] as String)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
final insertRows = sourceCars.map((car) {
|
final insertRows = sourceCars.map((car) {
|
||||||
final notes = car['notes'] as String?;
|
final notes = car['notes'] as String?;
|
||||||
final imagePath = car['user_image_url'] as String?;
|
final imagePath = car['user_image_url'] as String?;
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'hw_id': car['hw_id'] as String,
|
'hw_id': car['hw_id'] as String,
|
||||||
'user_id': supabase.auth.currentUser!.id,
|
'user_id': userId,
|
||||||
'collection_id': targetId,
|
'collection_id': targetCollectionId,
|
||||||
if (notes != null && notes.trim().isNotEmpty) 'notes': notes,
|
if (notes != null && notes.trim().isNotEmpty) 'notes': notes,
|
||||||
if (imagePath != null && imagePath.isNotEmpty)
|
if (imagePath != null && imagePath.isNotEmpty)
|
||||||
'user_image_url': imagePath,
|
'user_image_url': imagePath,
|
||||||
};
|
};
|
||||||
}).toList(growable: false);
|
}).where((row) => !existingHwIds.contains(row['hw_id'] as String)).toList(
|
||||||
|
growable: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final skippedDuplicates = sourceCars.length - insertRows.length;
|
||||||
|
|
||||||
|
if (insertRows.isEmpty) {
|
||||||
|
showGlobalSnackBar(
|
||||||
|
'All selected cars are already in the target collection.',
|
||||||
|
isError: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (insertRows.isNotEmpty) {
|
if (insertRows.isNotEmpty) {
|
||||||
await supabase.from('hotwheels').insert(insertRows);
|
await supabase.from('hotwheels').insert(insertRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skippedDuplicates > 0) {
|
||||||
|
showGlobalSnackBar(
|
||||||
|
'$skippedDuplicates car(s) skipped because they already exist in target collection.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -514,9 +591,11 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
_toggleSelectionMode(false);
|
_toggleSelectionMode(false);
|
||||||
await _loadCars(reset: true);
|
await _loadCars(reset: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar(
|
showGlobalError(
|
||||||
widget.isOwner ? 'Failed to move cars: $e' : 'Failed to copy cars: $e',
|
e,
|
||||||
isError: true,
|
fallback: widget.isOwner
|
||||||
|
? 'Failed to move cars. Please try again.'
|
||||||
|
: 'Failed to copy cars. Please try again.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -545,7 +624,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showCarDetails(Map<String, dynamic> car) {
|
Future<void> _showCarDetails(Map<String, dynamic> car) async {
|
||||||
if (_selectionMode) {
|
if (_selectionMode) {
|
||||||
_toggleCarSelection(car);
|
_toggleCarSelection(car);
|
||||||
return;
|
return;
|
||||||
|
|
@ -559,7 +638,24 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
final verified = global?['is_verified'] == true;
|
final verified = global?['is_verified'] == true;
|
||||||
final confirmations = (global?['confirmation_count'] as num?)?.toInt() ?? 0;
|
final confirmations = (global?['confirmation_count'] as num?)?.toInt() ?? 0;
|
||||||
final notes = car['notes'] as String?;
|
final notes = car['notes'] as String?;
|
||||||
final imageUrl = car['signed_image_url'] as String?;
|
String? imageUrl = car['signed_image_url'] as String?;
|
||||||
|
if (imageUrl == null || imageUrl.isEmpty) {
|
||||||
|
final path = car['user_image_url'] as String?;
|
||||||
|
final signed = await StorageService.createSignedUrl(path);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (signed != null && signed.isNotEmpty) {
|
||||||
|
imageUrl = signed;
|
||||||
|
final index = _cars.indexWhere((c) => c['id'] == car['id']);
|
||||||
|
if (index != -1) {
|
||||||
|
setState(() {
|
||||||
|
_cars[index] = {
|
||||||
|
..._cars[index],
|
||||||
|
'signed_image_url': signed,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -817,7 +913,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
);
|
);
|
||||||
if (xFile == null) return;
|
if (xFile == null) return;
|
||||||
|
|
||||||
showGlobalSnackBar('Uploading photo…');
|
showGlobalInfo('Uploading photo…');
|
||||||
|
|
||||||
final oldPath = car['user_image_url'] as String?;
|
final oldPath = car['user_image_url'] as String?;
|
||||||
String newPath;
|
String newPath;
|
||||||
|
|
@ -828,9 +924,9 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
oldPath: oldPath,
|
oldPath: oldPath,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar(
|
showGlobalError(
|
||||||
'Failed to upload photo. Please try a smaller/clearer image. ($e)',
|
e,
|
||||||
isError: true,
|
fallback: 'Failed to upload photo. Please try a smaller image.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -841,11 +937,14 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
.update({'user_image_url': newPath})
|
.update({'user_image_url': newPath})
|
||||||
.eq('id', car['id']);
|
.eq('id', car['id']);
|
||||||
|
|
||||||
showGlobalSnackBar('Photo updated!');
|
showGlobalSuccess('Photo updated!');
|
||||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||||
_loadCars(reset: true); // refresh grid
|
_loadCars(reset: true); // refresh grid
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('Failed to save: $e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to save photo. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -869,11 +968,14 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
.update(updated)
|
.update(updated)
|
||||||
.eq('id', car['id']);
|
.eq('id', car['id']);
|
||||||
|
|
||||||
showGlobalSnackBar('Car updated!');
|
showGlobalSuccess('Car updated!');
|
||||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||||
_loadCars(reset: true);
|
_loadCars(reset: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('Failed to update: $e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to update car. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -890,7 +992,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (existingVote != null) {
|
if (existingVote != null) {
|
||||||
showGlobalSnackBar('You already confirmed this catalog entry.');
|
showGlobalInfo('You already confirmed this catalog entry.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -899,11 +1001,14 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
showGlobalSnackBar('Thanks! Your validation vote was recorded.');
|
showGlobalSuccess('Thanks! Your validation vote was recorded.');
|
||||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||||
_loadCars(reset: true);
|
_loadCars(reset: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('Failed to submit validation vote: $e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to submit validation vote. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -939,7 +1044,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (existingOpen != null) {
|
if (existingOpen != null) {
|
||||||
showGlobalSnackBar('You already have an open report for this car.');
|
showGlobalInfo('You already have an open report for this car.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -951,9 +1056,12 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
'note': payload.note,
|
'note': payload.note,
|
||||||
});
|
});
|
||||||
|
|
||||||
showGlobalSnackBar('Thanks for reporting. We will review this entry.');
|
showGlobalSuccess('Thanks for reporting. We will review this entry.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('Failed to submit report: $e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to submit report. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1023,16 +1131,51 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
if (targetId == null) return;
|
if (targetId == null) return;
|
||||||
|
|
||||||
if (widget.isOwner) {
|
if (widget.isOwner) {
|
||||||
|
final existing = await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.select('id')
|
||||||
|
.eq('collection_id', targetId)
|
||||||
|
.eq('hw_id', car['hw_id'])
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
showGlobalSnackBar(
|
||||||
|
'${car['hw_id']} is already in the target collection.',
|
||||||
|
isError: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.from('hotwheels')
|
.from('hotwheels')
|
||||||
.update({'collection_id': targetId})
|
.update({'collection_id': targetId})
|
||||||
.eq('id', car['id']);
|
.eq('id', car['id']);
|
||||||
} else {
|
} else {
|
||||||
|
final userId = supabase.auth.currentUser?.id;
|
||||||
|
if (userId == null) {
|
||||||
|
throw Exception('You must be signed in to copy cars.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final existing = await supabase
|
||||||
|
.from('hotwheels')
|
||||||
|
.select('id')
|
||||||
|
.eq('collection_id', targetId)
|
||||||
|
.eq('hw_id', car['hw_id'])
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
showGlobalSnackBar(
|
||||||
|
'${car['hw_id']} is already in the target collection.',
|
||||||
|
isError: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final notes = car['notes'] as String?;
|
final notes = car['notes'] as String?;
|
||||||
final imagePath = car['user_image_url'] as String?;
|
final imagePath = car['user_image_url'] as String?;
|
||||||
await supabase.from('hotwheels').insert({
|
await supabase.from('hotwheels').insert({
|
||||||
'hw_id': car['hw_id'] as String,
|
'hw_id': car['hw_id'] as String,
|
||||||
'user_id': supabase.auth.currentUser!.id,
|
'user_id': userId,
|
||||||
'collection_id': targetId,
|
'collection_id': targetId,
|
||||||
if (notes != null && notes.trim().isNotEmpty) 'notes': notes,
|
if (notes != null && notes.trim().isNotEmpty) 'notes': notes,
|
||||||
if (imagePath != null && imagePath.isNotEmpty)
|
if (imagePath != null && imagePath.isNotEmpty)
|
||||||
|
|
@ -1042,14 +1185,16 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||||
showGlobalSnackBar(widget.isOwner
|
showGlobalSuccess(widget.isOwner
|
||||||
? '${car['hw_id']} moved to another collection.'
|
? '${car['hw_id']} moved to another collection.'
|
||||||
: '${car['hw_id']} copied to another collection.');
|
: '${car['hw_id']} copied to another collection.');
|
||||||
await _loadCars(reset: true);
|
await _loadCars(reset: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar(
|
showGlobalError(
|
||||||
widget.isOwner ? 'Failed to move car: $e' : 'Failed to copy car: $e',
|
e,
|
||||||
isError: true,
|
fallback: widget.isOwner
|
||||||
|
? 'Failed to move car. Please try again.'
|
||||||
|
: 'Failed to copy car. Please try again.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1097,10 +1242,13 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
if (sheetContext.mounted) {
|
if (sheetContext.mounted) {
|
||||||
Navigator.pop(sheetContext); // close bottom sheet
|
Navigator.pop(sheetContext); // close bottom sheet
|
||||||
}
|
}
|
||||||
showGlobalSnackBar('${car['hw_id']} removed from your garage.');
|
showGlobalSuccess('${car['hw_id']} removed from your garage.');
|
||||||
_loadCars(reset: true);
|
_loadCars(reset: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('Failed to remove: $e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to remove car. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1200,7 +1348,7 @@ class _EmptyGarage extends StatelessWidget {
|
||||||
Text(
|
Text(
|
||||||
hasSearch
|
hasSearch
|
||||||
? 'Try a different search term'
|
? 'Try a different search term'
|
||||||
: 'Scan your first Hot Wheels car to get started!',
|
: 'Scan your first die-cast car to get started!',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: AppColors.textHint),
|
style: const TextStyle(color: AppColors.textHint),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class _LoginScreenState extends State<LoginScreen>
|
||||||
email,
|
email,
|
||||||
redirectTo: 'hwcollector://login/recovery',
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,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.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,9 +125,12 @@ 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.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,12 +244,15 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
email: email,
|
email: email,
|
||||||
role: inviteRole,
|
role: inviteRole,
|
||||||
);
|
);
|
||||||
showGlobalSnackBar(
|
showGlobalSuccess(
|
||||||
inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!',
|
inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!',
|
||||||
);
|
);
|
||||||
await _loadMembers();
|
await _loadMembers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('$e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Could not send invitation. Please try again.',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isInviting = false);
|
if (mounted) setState(() => _isInviting = false);
|
||||||
}
|
}
|
||||||
|
|
@ -277,10 +286,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
collectionId: _collection.id,
|
collectionId: _collection.id,
|
||||||
memberUserId: member.userId,
|
memberUserId: member.userId,
|
||||||
);
|
);
|
||||||
showGlobalSnackBar('Member removed.');
|
showGlobalSuccess('Member removed.');
|
||||||
await _loadMembers();
|
await _loadMembers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('Failed: $e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Could not remove member. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,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.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -343,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.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import '../utils/error_utils.dart';
|
||||||
import '../utils/reporting_utils.dart';
|
import '../utils/reporting_utils.dart';
|
||||||
|
|
||||||
class MyReportsScreen extends StatefulWidget {
|
class MyReportsScreen extends StatefulWidget {
|
||||||
|
|
@ -23,7 +24,15 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
|
||||||
|
|
||||||
Future<void> _loadReports() async {
|
Future<void> _loadReports() async {
|
||||||
final user = supabase.auth.currentUser;
|
final user = supabase.auth.currentUser;
|
||||||
if (user == null) return;
|
if (user == null) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_reports = [];
|
||||||
|
_error = 'Please sign in to view reports.';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
|
|
@ -45,9 +54,13 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.toString();
|
_error = userMessageForError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to load reports. Please try again.',
|
||||||
|
);
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
logError('reports.load', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import '../scanner_screen.dart';
|
||||||
import '../services/collection_service.dart';
|
import '../services/collection_service.dart';
|
||||||
import '../services/main_collection_sync.dart';
|
import '../services/main_collection_sync.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import '../utils/preferences_utils.dart';
|
||||||
|
|
||||||
class ScanTab extends StatefulWidget {
|
class ScanTab extends StatefulWidget {
|
||||||
const ScanTab({super.key});
|
const ScanTab({super.key});
|
||||||
|
|
@ -14,7 +15,6 @@ class ScanTab extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScanTabState extends State<ScanTab> {
|
class ScanTabState extends State<ScanTab> {
|
||||||
static const _activeCollectionPrefKey = 'active_collection_id';
|
|
||||||
static const _duplicateCooldown = Duration(seconds: 2);
|
static const _duplicateCooldown = Duration(seconds: 2);
|
||||||
|
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
|
|
@ -50,8 +50,13 @@ class ScanTabState extends State<ScanTab> {
|
||||||
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 Exception('You must be signed in to load collections.');
|
||||||
|
}
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final persistedId = prefs.getString(_activeCollectionPrefKey);
|
final persistedId = await readActiveCollectionId(prefs, userId: userId);
|
||||||
|
|
||||||
Collection? selected;
|
Collection? selected;
|
||||||
if (persistedId != null) {
|
if (persistedId != null) {
|
||||||
|
|
@ -71,12 +76,19 @@ class ScanTabState extends State<ScanTab> {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
await prefs.setString(_activeCollectionPrefKey, selected.id);
|
await writeActiveCollectionId(
|
||||||
|
prefs,
|
||||||
|
userId: userId,
|
||||||
|
collectionId: selected.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _loadingCollections = false);
|
setState(() => _loadingCollections = false);
|
||||||
showGlobalSnackBar('Failed to load collections: $e', isError: true);
|
showGlobalError(
|
||||||
|
e,
|
||||||
|
fallback: 'Failed to load collections. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +116,7 @@ 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(fontSize: 22, fontWeight: FontWeight.w700),
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
@ -379,7 +391,7 @@ class ScanTabState extends State<ScanTab> {
|
||||||
await _addToCollection(collection.id, hwId);
|
await _addToCollection(collection.id, hwId);
|
||||||
await _ensureValidationVote(hwId);
|
await _ensureValidationVote(hwId);
|
||||||
if (!mounted) return false;
|
if (!mounted) return false;
|
||||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
|
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
|
||||||
|
|
@ -403,13 +415,16 @@ class ScanTabState extends State<ScanTab> {
|
||||||
);
|
);
|
||||||
await _addToCollection(collection.id, hwId, notes: discovery.notes);
|
await _addToCollection(collection.id, hwId, notes: discovery.notes);
|
||||||
if (!mounted) return false;
|
if (!mounted) return false;
|
||||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -419,9 +434,14 @@ class ScanTabState extends State<ScanTab> {
|
||||||
String hwId, {
|
String hwId, {
|
||||||
String? notes,
|
String? notes,
|
||||||
}) async {
|
}) async {
|
||||||
|
final userId = supabase.auth.currentUser?.id;
|
||||||
|
if (userId == null) {
|
||||||
|
throw Exception('You must be signed in to add cars.');
|
||||||
|
}
|
||||||
|
|
||||||
await supabase.from('hotwheels').insert({
|
await supabase.from('hotwheels').insert({
|
||||||
'hw_id': hwId,
|
'hw_id': hwId,
|
||||||
'user_id': supabase.auth.currentUser!.id,
|
'user_id': userId,
|
||||||
'collection_id': collectionId,
|
'collection_id': collectionId,
|
||||||
if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(),
|
if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(),
|
||||||
});
|
});
|
||||||
|
|
@ -433,6 +453,11 @@ class ScanTabState extends State<ScanTab> {
|
||||||
String? series,
|
String? series,
|
||||||
int? year,
|
int? year,
|
||||||
}) async {
|
}) async {
|
||||||
|
final userId = supabase.auth.currentUser?.id;
|
||||||
|
if (userId == null) {
|
||||||
|
throw Exception('You must be signed in to create catalog entries.');
|
||||||
|
}
|
||||||
|
|
||||||
final cleanedSeries = series?.trim();
|
final cleanedSeries = series?.trim();
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
'hw_id': hwId,
|
'hw_id': hwId,
|
||||||
|
|
@ -445,12 +470,15 @@ class ScanTabState extends State<ScanTab> {
|
||||||
|
|
||||||
await supabase.from('car_votes').insert({
|
await supabase.from('car_votes').insert({
|
||||||
'hw_id': hwId,
|
'hw_id': hwId,
|
||||||
'user_id': supabase.auth.currentUser!.id,
|
'user_id': userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _ensureValidationVote(String hwId) async {
|
Future<void> _ensureValidationVote(String hwId) async {
|
||||||
final userId = supabase.auth.currentUser!.id;
|
final userId = supabase.auth.currentUser?.id;
|
||||||
|
if (userId == null) {
|
||||||
|
throw Exception('You must be signed in to validate entries.');
|
||||||
|
}
|
||||||
final existingVote = await supabase
|
final existingVote = await supabase
|
||||||
.from('car_votes')
|
.from('car_votes')
|
||||||
.select('id')
|
.select('id')
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,14 @@ class CollectionMember {
|
||||||
class CollectionService {
|
class CollectionService {
|
||||||
CollectionService._();
|
CollectionService._();
|
||||||
|
|
||||||
|
static String _requireUserId() {
|
||||||
|
final userId = supabase.auth.currentUser?.id;
|
||||||
|
if (userId == null) {
|
||||||
|
throw Exception('You must be signed in to perform this action.');
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensures the current user has at least one collection membership.
|
/// Ensures the current user has at least one collection membership.
|
||||||
/// Creates a default collection on first login.
|
/// Creates a default collection on first login.
|
||||||
static Future<String?> ensureDefaultCollection() async {
|
static Future<String?> ensureDefaultCollection() async {
|
||||||
|
|
@ -80,7 +88,7 @@ class CollectionService {
|
||||||
/// 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
|
||||||
|
|
@ -113,11 +121,9 @@ class CollectionService {
|
||||||
});
|
});
|
||||||
memberCounts[collectionId] = (rows as List).length;
|
memberCounts[collectionId] = (rows as List).length;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Keep fallback below when RPC fails.
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fallback member count for collections where RPC did not return data.
|
|
||||||
final unresolvedIds = collectionIdList
|
final unresolvedIds = collectionIdList
|
||||||
.where((id) => !memberCounts.containsKey(id))
|
.where((id) => !memberCounts.containsKey(id))
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|
@ -249,7 +255,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 row = await supabase
|
final row = await supabase
|
||||||
.from('collections')
|
.from('collections')
|
||||||
|
|
@ -287,7 +293,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')
|
||||||
|
|
@ -311,7 +317,7 @@ class CollectionService {
|
||||||
|
|
||||||
/// 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')
|
||||||
|
|
@ -362,7 +368,7 @@ class CollectionService {
|
||||||
throw Exception('Unsupported role "$role".');
|
throw Exception('Unsupported role "$role".');
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentUserId = supabase.auth.currentUser!.id;
|
final currentUserId = _requireUserId();
|
||||||
|
|
||||||
final collection = await supabase
|
final collection = await supabase
|
||||||
.from('collections')
|
.from('collections')
|
||||||
|
|
@ -428,7 +434,7 @@ class CollectionService {
|
||||||
required String collectionId,
|
required String collectionId,
|
||||||
required String memberUserId,
|
required String memberUserId,
|
||||||
}) async {
|
}) async {
|
||||||
final currentUserId = supabase.auth.currentUser!.id;
|
final currentUserId = _requireUserId();
|
||||||
|
|
||||||
final collection = await supabase
|
final collection = await supabase
|
||||||
.from('collections')
|
.from('collections')
|
||||||
|
|
@ -501,7 +507,7 @@ class CollectionService {
|
||||||
|
|
||||||
/// 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
|
final membership = await supabase
|
||||||
.from('collection_members')
|
.from('collection_members')
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import '../main.dart';
|
||||||
/// Handles uploading / deleting car images in Supabase Storage.
|
/// Handles uploading / deleting car images in Supabase Storage.
|
||||||
///
|
///
|
||||||
/// Bucket: `car-images` (private)
|
/// Bucket: `car-images` (private)
|
||||||
/// Path: `{auth.uid()}/{hotwheels.id}.jpg`
|
/// Path: `{auth.uid()}/{entry.id}.jpg`
|
||||||
class StorageService {
|
class StorageService {
|
||||||
StorageService._();
|
StorageService._();
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ class StorageService {
|
||||||
static const _maxSignedUrlCacheEntries = 500;
|
static const _maxSignedUrlCacheEntries = 500;
|
||||||
static final Map<String, _SignedUrlCacheEntry> _signedUrlCache = {};
|
static final Map<String, _SignedUrlCacheEntry> _signedUrlCache = {};
|
||||||
|
|
||||||
/// Upload a car image for a specific hotwheels entry.
|
/// Upload a car image for a specific garage entry.
|
||||||
/// Returns the storage path on success (e.g. `uid/123.jpg`).
|
/// Returns the storage path on success (e.g. `uid/123.jpg`).
|
||||||
static Future<String> uploadCarImage({
|
static Future<String> uploadCarImage({
|
||||||
required File file,
|
required File file,
|
||||||
|
|
@ -31,7 +31,11 @@ class StorageService {
|
||||||
await deleteCarImage(oldPath);
|
await deleteCarImage(oldPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
final userId = supabase.auth.currentUser!.id;
|
final user = supabase.auth.currentUser;
|
||||||
|
if (user == null) {
|
||||||
|
throw Exception('You must be signed in to upload images.');
|
||||||
|
}
|
||||||
|
final userId = user.id;
|
||||||
final path = '$userId/$entryId.jpg';
|
final path = '$userId/$entryId.jpg';
|
||||||
final compressed = await _compressImage(file);
|
final compressed = await _compressImage(file);
|
||||||
|
|
||||||
|
|
|
||||||
37
lib/utils/error_utils.dart
Normal file
37
lib/utils/error_utils.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
String userMessageForError(
|
||||||
|
Object error, {
|
||||||
|
String fallback = 'Something went wrong. Please try again.',
|
||||||
|
}) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/utils/preferences_utils.dart
Normal file
43
lib/utils/preferences_utils.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
const _legacyActiveCollectionPrefKey = 'active_collection_id';
|
||||||
|
|
||||||
|
String activeCollectionPrefKeyForUser(String userId) {
|
||||||
|
return 'active_collection_id_$userId';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> readActiveCollectionId(
|
||||||
|
SharedPreferences prefs, {
|
||||||
|
required String userId,
|
||||||
|
}) async {
|
||||||
|
final scopedKey = activeCollectionPrefKeyForUser(userId);
|
||||||
|
final scopedValue = prefs.getString(scopedKey);
|
||||||
|
if (scopedValue != null && scopedValue.isNotEmpty) {
|
||||||
|
return scopedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final legacyValue = prefs.getString(_legacyActiveCollectionPrefKey);
|
||||||
|
if (legacyValue == null || legacyValue.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prefs.setString(scopedKey, legacyValue);
|
||||||
|
await prefs.remove(_legacyActiveCollectionPrefKey);
|
||||||
|
return legacyValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> writeActiveCollectionId(
|
||||||
|
SharedPreferences prefs, {
|
||||||
|
required String userId,
|
||||||
|
required String collectionId,
|
||||||
|
}) {
|
||||||
|
return prefs.setString(activeCollectionPrefKeyForUser(userId), collectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearActiveCollectionId(
|
||||||
|
SharedPreferences prefs, {
|
||||||
|
required String userId,
|
||||||
|
}) async {
|
||||||
|
await prefs.remove(activeCollectionPrefKeyForUser(userId));
|
||||||
|
await prefs.remove(_legacyActiveCollectionPrefKey);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../services/storage_service.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
/// A styled card for displaying a single Hot Wheels car in the garage.
|
/// A styled card for displaying a single die-cast car in the garage.
|
||||||
class CarCard extends StatelessWidget {
|
class CarCard extends StatelessWidget {
|
||||||
final String hwId;
|
final String hwId;
|
||||||
final String? name;
|
final String? name;
|
||||||
|
|
@ -10,6 +11,7 @@ class CarCard extends StatelessWidget {
|
||||||
final int? year;
|
final int? year;
|
||||||
final String? color;
|
final String? color;
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
|
final String? imagePath;
|
||||||
final bool isVerified;
|
final bool isVerified;
|
||||||
final DateTime? addedAt;
|
final DateTime? addedAt;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
@ -25,6 +27,7 @@ class CarCard extends StatelessWidget {
|
||||||
this.year,
|
this.year,
|
||||||
this.color,
|
this.color,
|
||||||
this.imageUrl,
|
this.imageUrl,
|
||||||
|
this.imagePath,
|
||||||
this.isVerified = false,
|
this.isVerified = false,
|
||||||
this.addedAt,
|
this.addedAt,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
|
@ -37,6 +40,9 @@ class CarCard extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final isDark = theme.brightness == Brightness.dark;
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
final resolvedImageUrl = imageUrl;
|
||||||
|
final hasResolvedUrl = resolvedImageUrl != null && resolvedImageUrl.isNotEmpty;
|
||||||
|
final hasImagePath = imagePath != null && imagePath!.isNotEmpty;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
|
|
@ -66,9 +72,9 @@ class CarCard extends StatelessWidget {
|
||||||
)
|
)
|
||||||
: AppColors.brandGradientSoft,
|
: AppColors.brandGradientSoft,
|
||||||
),
|
),
|
||||||
child: imageUrl != null && imageUrl!.isNotEmpty
|
child: hasResolvedUrl
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl!,
|
imageUrl: resolvedImageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: Duration.zero,
|
fadeOutDuration: Duration.zero,
|
||||||
|
|
@ -77,6 +83,26 @@ class CarCard extends StatelessWidget {
|
||||||
return _PlaceholderIcon(isDark: isDark);
|
return _PlaceholderIcon(isDark: isDark);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
: hasImagePath
|
||||||
|
? FutureBuilder<String?>(
|
||||||
|
future: StorageService.createSignedUrl(imagePath),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final signedUrl = snapshot.data;
|
||||||
|
if (signedUrl == null || signedUrl.isEmpty) {
|
||||||
|
return _PlaceholderIcon(isDark: isDark);
|
||||||
|
}
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: signedUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
|
onImageError?.call();
|
||||||
|
return _PlaceholderIcon(isDark: isDark);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
: _PlaceholderIcon(isDark: isDark),
|
: _PlaceholderIcon(isDark: isDark),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue