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
|
||||
/TPB_APP_CHECKLIST.md
|
||||
|
||||
# Local run configuration with secrets
|
||||
/.vscode/launch.json
|
||||
/.env/flutter_defines.json
|
||||
|
||||
# Local testing artifacts
|
||||
/flutter_*.png
|
||||
/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`.
|
||||
2. Configure Supabase keys in app configuration.
|
||||
3. Install dependencies:
|
||||
- [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)
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
## Overview
|
||||
|
||||
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
|
||||
flutter run
|
||||
```
|
||||
## Features
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
- 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).
|
||||
181
lib/main.dart
181
lib/main.dart
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
|
@ -6,23 +8,44 @@ import 'services/collection_service.dart';
|
|||
import 'services/main_collection_sync.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/home_shell.dart';
|
||||
import 'utils/error_utils.dart';
|
||||
import 'utils/preferences_utils.dart';
|
||||
|
||||
// Re-export so other files can `import '../main.dart'` for these.
|
||||
export 'package:supabase_flutter/supabase_flutter.dart'
|
||||
show AuthException, UserAttributes;
|
||||
|
||||
// ── Supabase credentials ──────────────────────────────────────────────
|
||||
const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co';
|
||||
const _supabaseAnonKey = 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL';
|
||||
const _supabaseUrl = String.fromEnvironment(
|
||||
'SUPABASE_URL',
|
||||
defaultValue: '',
|
||||
);
|
||||
const _supabaseAnonKey = String.fromEnvironment(
|
||||
'SUPABASE_ANON_KEY',
|
||||
defaultValue: '',
|
||||
);
|
||||
const _usePkceAuthFlow = bool.fromEnvironment(
|
||||
'SUPABASE_USE_PKCE',
|
||||
defaultValue: true,
|
||||
);
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (_supabaseUrl.trim().isEmpty || _supabaseAnonKey.trim().isEmpty) {
|
||||
throw StateError(
|
||||
'Missing Supabase configuration. Provide --dart-define=SUPABASE_URL and '
|
||||
'--dart-define=SUPABASE_ANON_KEY.',
|
||||
);
|
||||
}
|
||||
|
||||
await Supabase.initialize(
|
||||
url: _supabaseUrl,
|
||||
anonKey: _supabaseAnonKey,
|
||||
authOptions: const FlutterAuthClientOptions(
|
||||
authFlowType: AuthFlowType.implicit,
|
||||
authOptions: FlutterAuthClientOptions(
|
||||
authFlowType: _usePkceAuthFlow
|
||||
? AuthFlowType.pkce
|
||||
: AuthFlowType.implicit,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -35,29 +58,121 @@ final supabase = Supabase.instance.client;
|
|||
/// Global keys so dialogs & snackbars survive widget-tree rebuilds.
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
OverlayEntry? _activeMessageOverlay;
|
||||
Timer? _activeMessageOverlayTimer;
|
||||
|
||||
enum GlobalMessageType { info, success, error }
|
||||
|
||||
/// Show a snackbar safely through the global key.
|
||||
void showGlobalSnackBar(String message, {bool isError = false}) {
|
||||
final messenger = scaffoldMessengerKey.currentState;
|
||||
if (messenger == null) return;
|
||||
_showGlobalMessageOverlay(
|
||||
message,
|
||||
type: isError ? GlobalMessageType.error : GlobalMessageType.info,
|
||||
);
|
||||
}
|
||||
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red : null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 96),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
void showGlobalSuccess(String message) {
|
||||
_showGlobalMessageOverlay(message, type: GlobalMessageType.success);
|
||||
}
|
||||
|
||||
void showGlobalInfo(String message) {
|
||||
_showGlobalMessageOverlay(message, type: GlobalMessageType.info);
|
||||
}
|
||||
|
||||
void _showGlobalMessageOverlay(
|
||||
String message, {
|
||||
required GlobalMessageType type,
|
||||
}) {
|
||||
final overlay = navigatorKey.currentState?.overlay;
|
||||
if (overlay == null) return;
|
||||
|
||||
_activeMessageOverlayTimer?.cancel();
|
||||
_activeMessageOverlay?.remove();
|
||||
|
||||
final backgroundColor = switch (type) {
|
||||
GlobalMessageType.error => Colors.red,
|
||||
GlobalMessageType.success => Colors.green,
|
||||
GlobalMessageType.info => const Color(0xFF1F2937),
|
||||
};
|
||||
final leadingIcon = switch (type) {
|
||||
GlobalMessageType.error => Icons.error_outline,
|
||||
GlobalMessageType.success => Icons.check_circle_outline,
|
||||
GlobalMessageType.info => Icons.info_outline,
|
||||
};
|
||||
|
||||
_activeMessageOverlay = OverlayEntry(
|
||||
builder: (context) {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
return Positioned(
|
||||
top: topPadding + 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: IgnorePointer(
|
||||
ignoring: true,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(leadingIcon, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
overlay.insert(_activeMessageOverlay!);
|
||||
|
||||
_activeMessageOverlayTimer = Timer(const Duration(seconds: 4), () {
|
||||
_activeMessageOverlay?.remove();
|
||||
_activeMessageOverlay = null;
|
||||
_activeMessageOverlayTimer = null;
|
||||
});
|
||||
}
|
||||
|
||||
void showGlobalError(
|
||||
Object error, {
|
||||
String fallback = 'Something went wrong. Please try again.',
|
||||
}) {
|
||||
logError('ui', error);
|
||||
showGlobalSnackBar(
|
||||
userMessageForError(error, fallback: fallback),
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a dialog safely through the global navigator key.
|
||||
Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
return Future<T?>.value(null);
|
||||
}
|
||||
return showDialog<T>(
|
||||
context: navigatorKey.currentContext!,
|
||||
context: context,
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
|
@ -90,8 +205,6 @@ class AuthGate extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _AuthGateState extends State<AuthGate> {
|
||||
static const _activeCollectionPrefKey = 'active_collection_id';
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isInPasswordRecoveryFlow = false;
|
||||
Session? _session;
|
||||
|
|
@ -124,7 +237,10 @@ class _AuthGateState extends State<AuthGate> {
|
|||
}
|
||||
},
|
||||
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();
|
||||
await _ensureMainCollectionPreference(defaultCollectionId);
|
||||
} 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;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final persisted = prefs.getString(_activeCollectionPrefKey);
|
||||
final persisted = await readActiveCollectionId(prefs, userId: userId);
|
||||
|
||||
Future<bool> hasMembership(String collectionId) async {
|
||||
final membership = await supabase
|
||||
|
|
@ -180,17 +299,27 @@ class _AuthGateState extends State<AuthGate> {
|
|||
}
|
||||
|
||||
if (nextActiveId == null) {
|
||||
await prefs.remove(_activeCollectionPrefKey);
|
||||
await clearActiveCollectionId(prefs, userId: userId);
|
||||
return;
|
||||
}
|
||||
|
||||
await prefs.setString(_activeCollectionPrefKey, nextActiveId);
|
||||
await writeActiveCollectionId(
|
||||
prefs,
|
||||
userId: userId,
|
||||
collectionId: nextActiveId,
|
||||
);
|
||||
MainCollectionSync.notifyChanged();
|
||||
}
|
||||
|
||||
Future<void> _showResetPasswordDialog() async {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
_isInPasswordRecoveryFlow = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await showDialog<void>(
|
||||
context: navigatorKey.currentContext!,
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _ResetPasswordDialog(),
|
||||
);
|
||||
|
|
@ -245,7 +374,7 @@ class _ResetPasswordDialogState extends State<_ResetPasswordDialog> {
|
|||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showGlobalSnackBar('Password updated successfully!');
|
||||
showGlobalSuccess('Password updated successfully!');
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import 'package:camera/camera.dart';
|
|||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||
import 'services/collection_service.dart';
|
||||
import 'theme/app_colors.dart';
|
||||
import 'utils/error_utils.dart';
|
||||
import 'utils/scanner_utils.dart';
|
||||
|
||||
/// Screen that uses the camera to scan text (OCR) from a Hot Wheels package
|
||||
/// Screen that uses the camera to scan text (OCR) from a die-cast package
|
||||
/// and extract the hw_id (e.g. "JKF21").
|
||||
///
|
||||
/// The detected ID is returned via Navigator.pop(context, hwId).
|
||||
|
|
@ -36,6 +37,7 @@ class _ScannerScreenState extends State<ScannerScreen>
|
|||
static const _scanCooldownSuccess = Duration(milliseconds: 1500);
|
||||
static const _scanCooldownNoMatch = Duration(milliseconds: 2200);
|
||||
static const _scanCooldownError = Duration(milliseconds: 2600);
|
||||
static const _scanCooldownNoMatchMax = Duration(milliseconds: 5000);
|
||||
|
||||
CameraController? _cameraController;
|
||||
late final TextRecognizer _textRecognizer;
|
||||
|
|
@ -51,6 +53,7 @@ class _ScannerScreenState extends State<ScannerScreen>
|
|||
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
bool _isInitializingCamera = false;
|
||||
String? _cameraError;
|
||||
int _consecutiveMisses = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -126,9 +129,15 @@ class _ScannerScreenState extends State<ScannerScreen>
|
|||
_cameraError = 'Camera unavailable. Please retry.';
|
||||
_statusText = 'Camera unavailable';
|
||||
});
|
||||
logError('scanner.initCamera', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Camera init failed: $e'),
|
||||
content: Text(
|
||||
userMessageForError(
|
||||
e,
|
||||
fallback: 'Failed to start camera. Please try again.',
|
||||
),
|
||||
),
|
||||
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 {
|
||||
if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return;
|
||||
if (_cameraController!.value.isTakingPicture) return;
|
||||
|
|
@ -195,13 +204,23 @@ class _ScannerScreenState extends State<ScannerScreen>
|
|||
if (!mounted) return;
|
||||
|
||||
if (found != null) {
|
||||
_consecutiveMisses = 0;
|
||||
_nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess);
|
||||
setState(() => _lastDetected = found);
|
||||
if (widget.onDetected != null) {
|
||||
await _submitDetected(found);
|
||||
}
|
||||
} else {
|
||||
_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(() {
|
||||
_scanAccepted = false;
|
||||
_scanNotFound = true;
|
||||
|
|
@ -209,10 +228,20 @@ class _ScannerScreenState extends State<ScannerScreen>
|
|||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_consecutiveMisses += 1;
|
||||
_nextScanAllowedAt = DateTime.now().add(_scanCooldownError);
|
||||
if (!mounted) return;
|
||||
logError('scanner.capture', e);
|
||||
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');
|
||||
} finally {
|
||||
|
|
@ -258,8 +287,17 @@ class _ScannerScreenState extends State<ScannerScreen>
|
|||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
logError('scanner.submitDetected', e);
|
||||
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),
|
||||
Center(
|
||||
child: Text(
|
||||
'Track and manage your Hot Wheels collection.',
|
||||
'Track and manage your die-cast car collection.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import '../main.dart';
|
|||
import '../services/collection_service.dart';
|
||||
import '../services/main_collection_sync.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/error_utils.dart';
|
||||
import '../utils/preferences_utils.dart';
|
||||
import 'garage_screen.dart';
|
||||
import 'manage_collection_screen.dart';
|
||||
|
||||
|
|
@ -17,10 +19,10 @@ class CollectionsScreen extends StatefulWidget {
|
|||
|
||||
class CollectionsScreenState extends State<CollectionsScreen>
|
||||
with WidgetsBindingObserver {
|
||||
static const _activeCollectionPrefKey = 'active_collection_id';
|
||||
|
||||
List<Collection> _collections = [];
|
||||
bool _isLoading = true;
|
||||
bool _isLoadInFlight = false;
|
||||
bool _reloadQueued = false;
|
||||
String? _error;
|
||||
String? _activeCollectionId;
|
||||
DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
|
@ -61,11 +63,20 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
Future<void> _load({bool showLoading = true}) async {
|
||||
if (_isLoadInFlight) {
|
||||
_reloadQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoadInFlight = true;
|
||||
|
||||
if (showLoading) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
var list = await CollectionService.getMyCollections();
|
||||
|
|
@ -73,8 +84,13 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
|||
await CollectionService.ensureDefaultCollection();
|
||||
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 persisted = prefs.getString(_activeCollectionPrefKey);
|
||||
final persisted = await readActiveCollectionId(prefs, userId: userId);
|
||||
|
||||
String? activeId = persisted;
|
||||
if (activeId == null && list.isNotEmpty) {
|
||||
|
|
@ -95,7 +111,11 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
|||
});
|
||||
|
||||
if (activeId != null) {
|
||||
await prefs.setString(_activeCollectionPrefKey, activeId);
|
||||
await writeActiveCollectionId(
|
||||
prefs,
|
||||
userId: userId,
|
||||
collectionId: activeId,
|
||||
);
|
||||
if (shouldNotifySync) {
|
||||
MainCollectionSync.notifyChanged();
|
||||
}
|
||||
|
|
@ -103,9 +123,19 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_error = userMessageForError(
|
||||
e,
|
||||
fallback: 'Failed to load collections. Please try again.',
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
logError('collections.load', e);
|
||||
} finally {
|
||||
_isLoadInFlight = false;
|
||||
if (_reloadQueued) {
|
||||
_reloadQueued = false;
|
||||
Future<void>.microtask(() => _load(showLoading: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +167,7 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
|||
maxLength: 50,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
hintText: 'e.g. Hot Wheels, Matchbox…',
|
||||
hintText: 'e.g. Die-Cast Cars, Matchbox…',
|
||||
),
|
||||
validator: (value) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
|
|
@ -182,10 +212,13 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
|||
name: nameCtrl.text.trim(),
|
||||
description: descCtrl.text.trim(),
|
||||
);
|
||||
showGlobalSnackBar('Collection created!');
|
||||
showGlobalSuccess('Collection created!');
|
||||
_load();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not create collection. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,12 +243,22 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
|||
}
|
||||
|
||||
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 prefs.setString(_activeCollectionPrefKey, collectionId);
|
||||
await writeActiveCollectionId(
|
||||
prefs,
|
||||
userId: userId,
|
||||
collectionId: collectionId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _activeCollectionId = collectionId);
|
||||
MainCollectionSync.notifyChanged();
|
||||
showGlobalSnackBar('Main collection set for scanning.');
|
||||
showGlobalInfo('Main collection set for scanning.');
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import '../main.dart';
|
|||
import '../services/collection_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/error_utils.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 {
|
||||
final String collectionId;
|
||||
final String collectionName;
|
||||
|
|
@ -108,21 +109,11 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
.range(from, to);
|
||||
|
||||
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;
|
||||
setState(() {
|
||||
_cars = reset ? withSignedUrls : [..._cars, ...withSignedUrls];
|
||||
_hasMore = withSignedUrls.length == _pageSize;
|
||||
_cars = reset ? rows : [..._cars, ...rows];
|
||||
_hasMore = rows.length == _pageSize;
|
||||
if (_hasMore) _page += 1;
|
||||
_isLoading = false;
|
||||
_isLoadingMore = false;
|
||||
|
|
@ -130,10 +121,14 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_error = userMessageForError(
|
||||
e,
|
||||
fallback: 'Failed to load cars. Please try again.',
|
||||
);
|
||||
_isLoading = false;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
logError('garage.loadCars', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,14 +324,19 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
color: global?['color'] as String?,
|
||||
isVerified: global?['is_verified'] == true,
|
||||
imageUrl: car['signed_image_url'] as String?,
|
||||
imagePath: car['user_image_url'] as String?,
|
||||
isSelected: _selectedIds.contains(carId),
|
||||
addedAt: car['created_at'] != null
|
||||
? DateTime.tryParse(car['created_at'])
|
||||
: null,
|
||||
onImageError: () => _refreshSignedUrlForCar(carId),
|
||||
onTap: () => _selectionMode
|
||||
? _toggleCarSelection(car)
|
||||
: _showCarDetails(car),
|
||||
onTap: () {
|
||||
if (_selectionMode) {
|
||||
_toggleCarSelection(car);
|
||||
} else {
|
||||
_showCarDetails(car);
|
||||
}
|
||||
},
|
||||
onLongPress:
|
||||
widget.isViewer ? null : () => _toggleCarSelection(car),
|
||||
);
|
||||
|
|
@ -476,33 +476,110 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
);
|
||||
|
||||
if (confirmed != true || targetId == null) return;
|
||||
final targetCollectionId = targetId!;
|
||||
|
||||
if (isOwner) {
|
||||
await supabase
|
||||
.from('hotwheels')
|
||||
.update({'collection_id': targetId})
|
||||
.inFilter('id', _selectedIds.toList());
|
||||
} else {
|
||||
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 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 notes = car['notes'] as String?;
|
||||
final imagePath = car['user_image_url'] as String?;
|
||||
return <String, dynamic>{
|
||||
'hw_id': car['hw_id'] as String,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
'collection_id': targetId,
|
||||
'user_id': userId,
|
||||
'collection_id': targetCollectionId,
|
||||
if (notes != null && notes.trim().isNotEmpty) 'notes': notes,
|
||||
if (imagePath != null && imagePath.isNotEmpty)
|
||||
'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) {
|
||||
await supabase.from('hotwheels').insert(insertRows);
|
||||
}
|
||||
|
||||
if (skippedDuplicates > 0) {
|
||||
showGlobalSnackBar(
|
||||
'$skippedDuplicates car(s) skipped because they already exist in target collection.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
@ -514,9 +591,11 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
_toggleSelectionMode(false);
|
||||
await _loadCars(reset: true);
|
||||
} catch (e) {
|
||||
showGlobalSnackBar(
|
||||
widget.isOwner ? 'Failed to move cars: $e' : 'Failed to copy cars: $e',
|
||||
isError: true,
|
||||
showGlobalError(
|
||||
e,
|
||||
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) {
|
||||
_toggleCarSelection(car);
|
||||
return;
|
||||
|
|
@ -559,7 +638,24 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
final verified = global?['is_verified'] == true;
|
||||
final confirmations = (global?['confirmation_count'] as num?)?.toInt() ?? 0;
|
||||
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(
|
||||
context: context,
|
||||
|
|
@ -817,7 +913,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
);
|
||||
if (xFile == null) return;
|
||||
|
||||
showGlobalSnackBar('Uploading photo…');
|
||||
showGlobalInfo('Uploading photo…');
|
||||
|
||||
final oldPath = car['user_image_url'] as String?;
|
||||
String newPath;
|
||||
|
|
@ -828,9 +924,9 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
oldPath: oldPath,
|
||||
);
|
||||
} catch (e) {
|
||||
showGlobalSnackBar(
|
||||
'Failed to upload photo. Please try a smaller/clearer image. ($e)',
|
||||
isError: true,
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Failed to upload photo. Please try a smaller image.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -841,11 +937,14 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
.update({'user_image_url': newPath})
|
||||
.eq('id', car['id']);
|
||||
|
||||
showGlobalSnackBar('Photo updated!');
|
||||
showGlobalSuccess('Photo updated!');
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
_loadCars(reset: true); // refresh grid
|
||||
} 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)
|
||||
.eq('id', car['id']);
|
||||
|
||||
showGlobalSnackBar('Car updated!');
|
||||
showGlobalSuccess('Car updated!');
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
_loadCars(reset: true);
|
||||
} 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();
|
||||
|
||||
if (existingVote != null) {
|
||||
showGlobalSnackBar('You already confirmed this catalog entry.');
|
||||
showGlobalInfo('You already confirmed this catalog entry.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -899,11 +1001,14 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
'user_id': user.id,
|
||||
});
|
||||
|
||||
showGlobalSnackBar('Thanks! Your validation vote was recorded.');
|
||||
showGlobalSuccess('Thanks! Your validation vote was recorded.');
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
_loadCars(reset: true);
|
||||
} 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();
|
||||
|
||||
if (existingOpen != null) {
|
||||
showGlobalSnackBar('You already have an open report for this car.');
|
||||
showGlobalInfo('You already have an open report for this car.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -951,9 +1056,12 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
'note': payload.note,
|
||||
});
|
||||
|
||||
showGlobalSnackBar('Thanks for reporting. We will review this entry.');
|
||||
showGlobalSuccess('Thanks for reporting. We will review this entry.');
|
||||
} 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 (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
|
||||
.from('hotwheels')
|
||||
.update({'collection_id': targetId})
|
||||
.eq('id', car['id']);
|
||||
} 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 imagePath = car['user_image_url'] as String?;
|
||||
await supabase.from('hotwheels').insert({
|
||||
'hw_id': car['hw_id'] as String,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
'user_id': userId,
|
||||
'collection_id': targetId,
|
||||
if (notes != null && notes.trim().isNotEmpty) 'notes': notes,
|
||||
if (imagePath != null && imagePath.isNotEmpty)
|
||||
|
|
@ -1042,14 +1185,16 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
|
||||
if (!mounted) return;
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
showGlobalSnackBar(widget.isOwner
|
||||
showGlobalSuccess(widget.isOwner
|
||||
? '${car['hw_id']} moved to another collection.'
|
||||
: '${car['hw_id']} copied to another collection.');
|
||||
await _loadCars(reset: true);
|
||||
} catch (e) {
|
||||
showGlobalSnackBar(
|
||||
widget.isOwner ? 'Failed to move car: $e' : 'Failed to copy car: $e',
|
||||
isError: true,
|
||||
showGlobalError(
|
||||
e,
|
||||
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) {
|
||||
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);
|
||||
} 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(
|
||||
hasSearch
|
||||
? '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,
|
||||
style: const TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class _LoginScreenState extends State<LoginScreen>
|
|||
email,
|
||||
redirectTo: 'hwcollector://login/recovery',
|
||||
);
|
||||
showGlobalSnackBar('Password reset email sent! Check your inbox.');
|
||||
showGlobalSuccess('Password reset email sent! Check your inbox.');
|
||||
} on AuthException catch (e) {
|
||||
showGlobalSnackBar(e.message, isError: true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
showGlobalSnackBar('Failed to load members: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Failed to load members. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,9 +125,12 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
memberCount: _collection.memberCount,
|
||||
);
|
||||
});
|
||||
showGlobalSnackBar('Collection renamed!');
|
||||
showGlobalSuccess('Collection renamed!');
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not rename collection. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,12 +244,15 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
email: email,
|
||||
role: inviteRole,
|
||||
);
|
||||
showGlobalSnackBar(
|
||||
showGlobalSuccess(
|
||||
inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!',
|
||||
);
|
||||
await _loadMembers();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('$e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not send invitation. Please try again.',
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isInviting = false);
|
||||
}
|
||||
|
|
@ -277,10 +286,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
collectionId: _collection.id,
|
||||
memberUserId: member.userId,
|
||||
);
|
||||
showGlobalSnackBar('Member removed.');
|
||||
showGlobalSuccess('Member removed.');
|
||||
await _loadMembers();
|
||||
} 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 {
|
||||
await CollectionService.leave(_collection.id);
|
||||
showGlobalSnackBar('Left "${_collection.name}".');
|
||||
showGlobalSuccess('Left "${_collection.name}".');
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not leave collection. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -343,10 +358,13 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
|
||||
try {
|
||||
await CollectionService.delete(_collection.id);
|
||||
showGlobalSnackBar('Collection deleted.');
|
||||
showGlobalSuccess('Collection deleted.');
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not delete collection. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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 {
|
||||
|
|
@ -23,7 +24,15 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
|
|||
|
||||
Future<void> _loadReports() async {
|
||||
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(() {
|
||||
_isLoading = true;
|
||||
|
|
@ -45,9 +54,13 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_error = userMessageForError(
|
||||
e,
|
||||
fallback: 'Failed to load reports. Please try again.',
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
logError('reports.load', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ class ProfileScreen extends StatelessWidget {
|
|||
UserAttributes(password: pw),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
showGlobalSnackBar('Password updated!');
|
||||
showGlobalSuccess('Password updated!');
|
||||
} on AuthException catch (e) {
|
||||
showGlobalSnackBar(e.message, isError: true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../scanner_screen.dart';
|
|||
import '../services/collection_service.dart';
|
||||
import '../services/main_collection_sync.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/preferences_utils.dart';
|
||||
|
||||
class ScanTab extends StatefulWidget {
|
||||
const ScanTab({super.key});
|
||||
|
|
@ -14,7 +15,6 @@ class ScanTab extends StatefulWidget {
|
|||
}
|
||||
|
||||
class ScanTabState extends State<ScanTab> {
|
||||
static const _activeCollectionPrefKey = 'active_collection_id';
|
||||
static const _duplicateCooldown = Duration(seconds: 2);
|
||||
|
||||
bool _isBusy = false;
|
||||
|
|
@ -50,8 +50,13 @@ class ScanTabState extends State<ScanTab> {
|
|||
Future<void> _loadCollections() async {
|
||||
try {
|
||||
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 persistedId = prefs.getString(_activeCollectionPrefKey);
|
||||
final persistedId = await readActiveCollectionId(prefs, userId: userId);
|
||||
|
||||
Collection? selected;
|
||||
if (persistedId != null) {
|
||||
|
|
@ -71,12 +76,19 @@ class ScanTabState extends State<ScanTab> {
|
|||
});
|
||||
|
||||
if (selected != null) {
|
||||
await prefs.setString(_activeCollectionPrefKey, selected.id);
|
||||
await writeActiveCollectionId(
|
||||
prefs,
|
||||
userId: userId,
|
||||
collectionId: selected.id,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
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 Text(
|
||||
'Scan a Hot Wheels Car',
|
||||
'Scan a Die-Cast Car',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
|
@ -379,7 +391,7 @@ class ScanTabState extends State<ScanTab> {
|
|||
await _addToCollection(collection.id, hwId);
|
||||
await _ensureValidationVote(hwId);
|
||||
if (!mounted) return false;
|
||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
||||
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
|
||||
}
|
||||
} else {
|
||||
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
|
||||
|
|
@ -403,13 +415,16 @@ class ScanTabState extends State<ScanTab> {
|
|||
);
|
||||
await _addToCollection(collection.id, hwId, notes: discovery.notes);
|
||||
if (!mounted) return false;
|
||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
||||
showGlobalSuccess('$hwId added to "${collection.name}"! 🎉');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
showGlobalSnackBar('DB error: $e', isError: true);
|
||||
showGlobalError(
|
||||
e,
|
||||
fallback: 'Could not save this car right now. Please try again.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -419,9 +434,14 @@ class ScanTabState extends State<ScanTab> {
|
|||
String hwId, {
|
||||
String? notes,
|
||||
}) 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({
|
||||
'hw_id': hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
'user_id': userId,
|
||||
'collection_id': collectionId,
|
||||
if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(),
|
||||
});
|
||||
|
|
@ -433,6 +453,11 @@ class ScanTabState extends State<ScanTab> {
|
|||
String? series,
|
||||
int? year,
|
||||
}) 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 payload = <String, dynamic>{
|
||||
'hw_id': hwId,
|
||||
|
|
@ -445,12 +470,15 @@ class ScanTabState extends State<ScanTab> {
|
|||
|
||||
await supabase.from('car_votes').insert({
|
||||
'hw_id': hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
'user_id': userId,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
.from('car_votes')
|
||||
.select('id')
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ class CollectionMember {
|
|||
class 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.
|
||||
/// Creates a default collection on first login.
|
||||
static Future<String?> ensureDefaultCollection() async {
|
||||
|
|
@ -80,7 +88,7 @@ class CollectionService {
|
|||
/// Fetch all collections the current user is a member of,
|
||||
/// including item count and member count.
|
||||
static Future<List<Collection>> getMyCollections() async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
// Get memberships with collection data.
|
||||
final memberships = await supabase
|
||||
|
|
@ -113,11 +121,9 @@ class CollectionService {
|
|||
});
|
||||
memberCounts[collectionId] = (rows as List).length;
|
||||
} catch (_) {
|
||||
// Keep fallback below when RPC fails.
|
||||
}
|
||||
}));
|
||||
|
||||
// Fallback member count for collections where RPC did not return data.
|
||||
final unresolvedIds = collectionIdList
|
||||
.where((id) => !memberCounts.containsKey(id))
|
||||
.toList(growable: false);
|
||||
|
|
@ -249,7 +255,7 @@ class CollectionService {
|
|||
required String name,
|
||||
String? description,
|
||||
}) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
final row = await supabase
|
||||
.from('collections')
|
||||
|
|
@ -287,7 +293,7 @@ class CollectionService {
|
|||
required String name,
|
||||
String? description,
|
||||
}) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
final collection = await supabase
|
||||
.from('collections')
|
||||
|
|
@ -311,7 +317,7 @@ class CollectionService {
|
|||
|
||||
/// Delete a collection. Owner only. Cascade deletes members & items.
|
||||
static Future<void> delete(String collectionId) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
final collection = await supabase
|
||||
.from('collections')
|
||||
|
|
@ -362,7 +368,7 @@ class CollectionService {
|
|||
throw Exception('Unsupported role "$role".');
|
||||
}
|
||||
|
||||
final currentUserId = supabase.auth.currentUser!.id;
|
||||
final currentUserId = _requireUserId();
|
||||
|
||||
final collection = await supabase
|
||||
.from('collections')
|
||||
|
|
@ -428,7 +434,7 @@ class CollectionService {
|
|||
required String collectionId,
|
||||
required String memberUserId,
|
||||
}) async {
|
||||
final currentUserId = supabase.auth.currentUser!.id;
|
||||
final currentUserId = _requireUserId();
|
||||
|
||||
final collection = await supabase
|
||||
.from('collections')
|
||||
|
|
@ -501,7 +507,7 @@ class CollectionService {
|
|||
|
||||
/// Leave a collection (for non-owners).
|
||||
static Future<void> leave(String collectionId) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final userId = _requireUserId();
|
||||
|
||||
final membership = await supabase
|
||||
.from('collection_members')
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import '../main.dart';
|
|||
/// Handles uploading / deleting car images in Supabase Storage.
|
||||
///
|
||||
/// Bucket: `car-images` (private)
|
||||
/// Path: `{auth.uid()}/{hotwheels.id}.jpg`
|
||||
/// Path: `{auth.uid()}/{entry.id}.jpg`
|
||||
class StorageService {
|
||||
StorageService._();
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ class StorageService {
|
|||
static const _maxSignedUrlCacheEntries = 500;
|
||||
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`).
|
||||
static Future<String> uploadCarImage({
|
||||
required File file,
|
||||
|
|
@ -31,7 +31,11 @@ class StorageService {
|
|||
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 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:cached_network_image/cached_network_image.dart';
|
||||
import '../services/storage_service.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 {
|
||||
final String hwId;
|
||||
final String? name;
|
||||
|
|
@ -10,6 +11,7 @@ class CarCard extends StatelessWidget {
|
|||
final int? year;
|
||||
final String? color;
|
||||
final String? imageUrl;
|
||||
final String? imagePath;
|
||||
final bool isVerified;
|
||||
final DateTime? addedAt;
|
||||
final VoidCallback? onTap;
|
||||
|
|
@ -25,6 +27,7 @@ class CarCard extends StatelessWidget {
|
|||
this.year,
|
||||
this.color,
|
||||
this.imageUrl,
|
||||
this.imagePath,
|
||||
this.isVerified = false,
|
||||
this.addedAt,
|
||||
this.onTap,
|
||||
|
|
@ -37,6 +40,9 @@ class CarCard extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final resolvedImageUrl = imageUrl;
|
||||
final hasResolvedUrl = resolvedImageUrl != null && resolvedImageUrl.isNotEmpty;
|
||||
final hasImagePath = imagePath != null && imagePath!.isNotEmpty;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
|
|
@ -66,9 +72,9 @@ class CarCard extends StatelessWidget {
|
|||
)
|
||||
: AppColors.brandGradientSoft,
|
||||
),
|
||||
child: imageUrl != null && imageUrl!.isNotEmpty
|
||||
child: hasResolvedUrl
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl!,
|
||||
imageUrl: resolvedImageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
|
|
@ -77,7 +83,27 @@ class CarCard extends StatelessWidget {
|
|||
return _PlaceholderIcon(isDark: isDark);
|
||||
},
|
||||
)
|
||||
: _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),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue