feat(v3.0): rebrand to car64 and align app flow with TPB schema

- Rename installed app display name to car64 (Android/iOS/Web titles)
- Remove Buy Me a Coffee link from About screen
- Update app version to 3.0.0+1
- Migrate scanner workflow to catalog-first flow:
  - look up in global_cars
  - show Found in Catalog bottom sheet for known cars
  - show New Discovery bottom sheet and insert into global_cars + car_votes
  - insert only into hotwheels for collection entries
- Align garage reads to TPB schema by joining global_cars
- Switch image field usage from image_url to user_image_url
- Implement private-storage image service:
  - upload path auth.uid()/hotwheels.id.jpg
  - signed URL generation (1h) for display
  - in-app compression pipeline targeting <=500KB and max 1080px width
- Use local brand fallback image assets/img/icon_bg_removed.png for missing car photos
- Restrict edit dialog to personal notes (hotwheels) instead of global car metadata
- Ensure first login auto-creates a default collection and owner membership
This commit is contained in:
Lukas Müllner 2026-03-04 10:12:12 +01:00
parent 2c6ae514fb
commit d8dbc2573a
16 changed files with 462 additions and 451 deletions

View file

@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="HW Collector Hub"
android:label="car64"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>HW Collector Hub</string>
<string>car64</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>HW Collector Hub</string>
<string>car64</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'theme/app_theme.dart';
import 'services/collection_service.dart';
import 'screens/login_screen.dart';
import 'screens/home_shell.dart';
@ -20,7 +21,7 @@ Future<void> main() async {
anonKey: _supabaseAnonKey,
);
runApp(const HWHubApp());
runApp(const Car64App());
}
/// Convenience accessor used throughout the app.
@ -49,13 +50,13 @@ Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
}
// Root App Widget
class HWHubApp extends StatelessWidget {
const HWHubApp({super.key});
class Car64App extends StatelessWidget {
const Car64App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'HW Collector Hub',
title: 'car64',
debugShowCheckedModeBanner: false,
navigatorKey: navigatorKey,
scaffoldMessengerKey: scaffoldMessengerKey,
@ -78,12 +79,14 @@ class AuthGate extends StatefulWidget {
class _AuthGateState extends State<AuthGate> {
bool _isLoading = true;
Session? _session;
String? _lastEnsuredUserId;
@override
void initState() {
super.initState();
_session = supabase.auth.currentSession;
_ensureDefaultCollectionIfNeeded();
supabase.auth.onAuthStateChange.listen(
(AuthState authState) {
@ -97,6 +100,8 @@ class _AuthGateState extends State<AuthGate> {
setState(() {});
}
_ensureDefaultCollectionIfNeeded();
if (authState.event == AuthChangeEvent.passwordRecovery) {
_showResetPasswordDialog();
}
@ -109,6 +114,18 @@ class _AuthGateState extends State<AuthGate> {
setState(() => _isLoading = false);
}
Future<void> _ensureDefaultCollectionIfNeeded() async {
final userId = _session?.user.id;
if (userId == null || userId == _lastEnsuredUserId) return;
_lastEnsuredUserId = userId;
try {
await CollectionService.ensureDefaultCollection();
} catch (e) {
showGlobalSnackBar('Collection setup failed: $e', isError: true);
}
}
Future<void> _showResetPasswordDialog() async {
await showDialog<void>(
context: navigatorKey.currentContext!,

View file

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../theme/app_colors.dart';
/// Full-page About screen with app info, version, and links.
@ -25,11 +24,6 @@ class _AboutScreenState extends State<AboutScreen> {
if (mounted) setState(() => _packageInfo = info);
}
Future<void> _openUrl(String url) async {
final uri = Uri.parse(url);
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -65,7 +59,7 @@ class _AboutScreenState extends State<AboutScreen> {
const SizedBox(height: 16),
Center(
child: Text(
'HW Collector Hub',
'car64',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@ -95,29 +89,6 @@ class _AboutScreenState extends State<AboutScreen> {
const Divider(),
const SizedBox(height: 16),
// Links section
Text(
'Links',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
_LinkTile(
icon: Icons.coffee,
iconColor: const Color(0xFFFFDD00),
title: 'Buy Me a Coffee',
subtitle: 'Support the development',
onTap: () =>
_openUrl('https://buymeacoffee.com/derkauzigekoala'),
),
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 16),
// Technical details
Text(
'Technical',
@ -152,7 +123,7 @@ class _AboutScreenState extends State<AboutScreen> {
),
const SizedBox(height: 4),
Text(
'© 2026 HW Collector Hub',
'© 2026 car64',
style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.textHint,
),
@ -167,49 +138,6 @@ class _AboutScreenState extends State<AboutScreen> {
}
}
// Link tile
class _LinkTile extends StatelessWidget {
final IconData icon;
final Color iconColor;
final String title;
final String subtitle;
final VoidCallback onTap;
const _LinkTile({
required this.icon,
required this.iconColor,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: iconColor, size: 22),
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
trailing: const Icon(Icons.open_in_new, color: AppColors.textHint, size: 18),
onTap: onTap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
);
}
}
// Info row (label : value)
class _InfoRow extends StatelessWidget {

View file

@ -55,13 +55,26 @@ class GarageScreenState extends State<GarageScreen> {
try {
final data = await supabase
.from('hotwheels')
.select()
.select(
'id, created_at, hw_id, notes, user_image_url, global_cars(name, series, year, color)')
.eq('collection_id', widget.collectionId)
.order('created_at', ascending: false);
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 = List<Map<String, dynamic>>.from(data);
_cars = withSignedUrls;
_isLoading = false;
});
} catch (e) {
@ -77,9 +90,10 @@ class GarageScreenState extends State<GarageScreen> {
if (_searchQuery.isEmpty) return _cars;
final q = _searchQuery.toLowerCase();
return _cars.where((car) {
final global = car['global_cars'] as Map<String, dynamic>?;
final id = (car['hw_id'] as String? ?? '').toLowerCase();
final name = (car['name'] as String? ?? '').toLowerCase();
final series = (car['series'] as String? ?? '').toLowerCase();
final name = (global?['name'] as String? ?? '').toLowerCase();
final series = (global?['series'] as String? ?? '').toLowerCase();
return id.contains(q) || name.contains(q) || series.contains(q);
}).toList();
}
@ -205,13 +219,14 @@ class GarageScreenState extends State<GarageScreen> {
delegate: SliverChildBuilderDelegate(
(context, index) {
final car = _filteredCars[index];
final global = car['global_cars'] as Map<String, dynamic>?;
return CarCard(
hwId: car['hw_id'] as String? ?? '???',
name: car['name'] as String?,
series: car['series'] as String?,
year: car['year'] as int?,
color: car['color'] as String?,
imageUrl: car['image_url'] as String?,
name: global?['name'] as String?,
series: global?['series'] as String?,
year: global?['year'] as int?,
color: global?['color'] as String?,
imageUrl: car['signed_image_url'] as String?,
addedAt: car['created_at'] != null
? DateTime.tryParse(car['created_at'])
: null,
@ -239,12 +254,13 @@ class GarageScreenState extends State<GarageScreen> {
}
void _showCarDetails(Map<String, dynamic> car) {
final global = car['global_cars'] as Map<String, dynamic>?;
final hwId = car['hw_id'] as String? ?? '???';
final name = car['name'] as String?;
final series = car['series'] as String?;
final year = car['year'] as int?;
final name = global?['name'] as String?;
final series = global?['series'] as String?;
final year = global?['year'] as int?;
final notes = car['notes'] as String?;
final imageUrl = car['image_url'] as String?;
final imageUrl = car['signed_image_url'] as String?;
showModalBottomSheet(
context: context,
@ -296,10 +312,16 @@ class GarageScreenState extends State<GarageScreen> {
child: TextButton.icon(
onPressed: () => _updatePhoto(car, context),
icon: Icon(
imageUrl != null ? Icons.camera_alt : Icons.add_a_photo,
(car['user_image_url'] as String?) != null
? Icons.camera_alt
: Icons.add_a_photo,
size: 18,
),
label: Text(imageUrl != null ? 'Change Photo' : 'Add Photo'),
label: Text(
(car['user_image_url'] as String?) != null
? 'Change Photo'
: 'Add Photo',
),
),
),
@ -384,7 +406,12 @@ class GarageScreenState extends State<GarageScreen> {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
color: isDark ? AppColors.surfaceDark : AppColors.backgroundLight,
child: Center(
child: Padding(
padding: const EdgeInsets.all(18),
child: Image.asset(
'assets/img/icon_bg_removed.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Center(
child: Icon(
Icons.directions_car_filled,
size: 48,
@ -393,6 +420,8 @@ class GarageScreenState extends State<GarageScreen> {
: AppColors.orange.withValues(alpha: 0.25),
),
),
),
),
);
}
@ -410,13 +439,14 @@ class GarageScreenState extends State<GarageScreen> {
showGlobalSnackBar('Uploading photo…');
final oldImageUrl = car['image_url'] as String?;
final url = await StorageService.uploadCarImage(
final oldPath = car['user_image_url'] as String?;
final newPath = await StorageService.uploadCarImage(
file: File(xFile.path),
oldImageUrl: oldImageUrl,
entryId: car['id'] as int,
oldPath: oldPath,
);
if (url == null) {
if (newPath == null) {
showGlobalSnackBar('Failed to upload photo.', isError: true);
return;
}
@ -424,7 +454,7 @@ class GarageScreenState extends State<GarageScreen> {
try {
await supabase
.from('hotwheels')
.update({'image_url': url})
.update({'user_image_url': newPath})
.eq('id', car['id']);
showGlobalSnackBar('Photo updated!');
@ -485,7 +515,7 @@ class GarageScreenState extends State<GarageScreen> {
try {
// Delete image from storage first (best-effort).
await StorageService.deleteCarImage(car['image_url'] as String?);
await StorageService.deleteCarImage(car['user_image_url'] as String?);
await supabase
.from('hotwheels')
@ -651,28 +681,17 @@ class _EditCarDialog extends StatefulWidget {
}
class _EditCarDialogState extends State<_EditCarDialog> {
late final TextEditingController _nameCtrl;
late final TextEditingController _seriesCtrl;
late final TextEditingController _yearCtrl;
late final TextEditingController _notesCtrl;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.car['name'] as String? ?? '');
_seriesCtrl =
TextEditingController(text: widget.car['series'] as String? ?? '');
final year = widget.car['year'];
_yearCtrl = TextEditingController(text: year != null ? '$year' : '');
_notesCtrl =
TextEditingController(text: widget.car['notes'] as String? ?? '');
}
@override
void dispose() {
_nameCtrl.dispose();
_seriesCtrl.dispose();
_yearCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
@ -680,14 +699,8 @@ class _EditCarDialogState extends State<_EditCarDialog> {
void _save() {
final updates = <String, dynamic>{};
final name = _nameCtrl.text.trim();
final series = _seriesCtrl.text.trim();
final yearStr = _yearCtrl.text.trim();
final notes = _notesCtrl.text.trim();
updates['name'] = name.isEmpty ? null : name;
updates['series'] = series.isEmpty ? null : series;
updates['year'] = yearStr.isEmpty ? null : int.tryParse(yearStr);
updates['notes'] = notes.isEmpty ? null : notes;
Navigator.pop(context, updates);
@ -710,34 +723,9 @@ class _EditCarDialogState extends State<_EditCarDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Car Name',
hintText: "e.g. '70 Dodge Charger",
),
),
const SizedBox(height: 12),
TextField(
controller: _seriesCtrl,
decoration: const InputDecoration(
labelText: 'Series',
hintText: 'e.g. HW Flames',
),
),
const SizedBox(height: 12),
TextField(
controller: _yearCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Year',
hintText: 'e.g. 2025',
),
),
const SizedBox(height: 12),
TextField(
controller: _notesCtrl,
maxLines: 2,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Notes',
hintText: 'Any extra info…',

View file

@ -142,7 +142,7 @@ class _LoginScreenState extends State<LoginScreen>
),
const SizedBox(height: 16),
const Text(
'HW COLLECTOR HUB',
'CAR64',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
@ -300,7 +300,7 @@ class _LoginScreenState extends State<LoginScreen>
// Footer
Text(
'© 2026 HW Collector Hub',
'© 2026 car64',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.5),

View file

@ -105,7 +105,7 @@ class ProfileScreen extends StatelessWidget {
_SettingsTile(
icon: Icons.info_outline,
title: 'About',
subtitle: 'HW Collector Hub',
subtitle: 'car64',
onTap: () => _showAbout(context),
),
const SizedBox(height: 32),
@ -131,7 +131,7 @@ class ProfileScreen extends StatelessWidget {
const SizedBox(height: 40),
const Center(
child: Text(
'© 2026 HW Collector Hub',
'© 2026 car64',
style:
TextStyle(fontSize: 12, color: AppColors.textHint),
),

View file

@ -1,14 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../main.dart';
import '../scanner_screen.dart';
import '../services/collection_service.dart';
import '../services/storage_service.dart';
import '../theme/app_colors.dart';
/// The "Scan" tab quick-access view for scanning / adding cars.
class ScanTab extends StatefulWidget {
const ScanTab({super.key});
@ -28,7 +23,6 @@ class ScanTabState extends State<ScanTab> {
_loadCollections();
}
/// Public so HomeShell can trigger a refresh when switching to this tab.
void refresh() => _loadCollections();
Future<void> _loadCollections() async {
@ -43,11 +37,7 @@ class ScanTabState extends State<ScanTab> {
} catch (e) {
if (!mounted) return;
setState(() => _loadingCollections = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to load collections: $e'),
),
);
showGlobalSnackBar('Failed to load collections: $e', isError: true);
}
}
@ -60,7 +50,6 @@ class ScanTabState extends State<ScanTab> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Illustration
Container(
width: 120,
height: 120,
@ -77,14 +66,11 @@ class ScanTabState extends State<ScanTab> {
const SizedBox(height: 28),
const Text(
'Scan a Hot Wheels Car',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
),
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(height: 10),
const Text(
'Point your camera at the model ID on the\npackaging to instantly add it to your collection.',
'Point your camera at the model ID and add cars\ninstantly to your selected collection.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
@ -93,8 +79,6 @@ class ScanTabState extends State<ScanTab> {
),
),
const SizedBox(height: 24),
// Collection picker
if (_loadingCollections)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
@ -150,8 +134,7 @@ class ScanTabState extends State<ScanTab> {
.toList(),
onChanged: (id) {
setState(() {
final matching =
_collections.where((c) => c.id == id);
final matching = _collections.where((c) => c.id == id);
_selectedCollection =
matching.isNotEmpty ? matching.first : null;
});
@ -160,8 +143,6 @@ class ScanTabState extends State<ScanTab> {
),
),
const SizedBox(height: 24),
// Scan button (gradient)
SizedBox(
width: double.infinity,
height: 56,
@ -210,8 +191,6 @@ class ScanTabState extends State<ScanTab> {
),
),
const SizedBox(height: 16),
// Manual entry
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
@ -281,27 +260,23 @@ class ScanTabState extends State<ScanTab> {
setState(() => _isBusy = true);
try {
// Check if this hw_id already exists in the selected collection.
final data = await supabase
final existing = await supabase
.from('hotwheels')
.select()
.select('id')
.eq('hw_id', hwId)
.eq('collection_id', collection.id)
.maybeSingle();
if (existing != null) {
if (!mounted) return;
setState(() => _isBusy = false);
if (data != null) {
// Already in collection
await showDialog(
context: context,
builder: (_) => AlertDialog(
icon: const Icon(Icons.check_circle,
color: AppColors.success, size: 48),
title: const Text('Already in Collection!'),
content:
Text('$hwId is already in "${collection.name}".'),
content: Text('$hwId is already in "${collection.name}".'),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
@ -310,17 +285,58 @@ class ScanTabState extends State<ScanTab> {
],
),
);
} else {
// New offer to add
final added = await showDialog<bool>(
return;
}
final globalCar = await supabase
.from('global_cars')
.select('hw_id, name, series, year, color, is_verified')
.eq('hw_id', hwId)
.maybeSingle();
if (!mounted) return;
setState(() => _isBusy = false);
if (globalCar != null) {
final addConfirmed = await showModalBottomSheet<bool>(
context: context,
builder: (_) => _AddCarDialog(
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => _FoundCarSheet(
collectionName: collection.name,
car: globalCar,
),
);
if (addConfirmed == true) {
await _addToCollection(collection.id, hwId);
if (!mounted) return;
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
}
} else {
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => _NewDiscoverySheet(
hwId: hwId,
collectionId: collection.id,
collectionName: collection.name,
),
);
if (added == true) {
if (discovery != null) {
await _createGlobalCarAndVote(
hwId: hwId,
name: discovery.name,
series: discovery.series,
year: discovery.year,
);
await _addToCollection(collection.id, hwId, notes: discovery.notes);
if (!mounted) return;
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
}
}
@ -329,30 +345,148 @@ class ScanTabState extends State<ScanTab> {
showGlobalSnackBar('DB error: $e', isError: true);
}
}
Future<void> _addToCollection(
String collectionId,
String hwId, {
String? notes,
}) async {
await supabase.from('hotwheels').insert({
'hw_id': hwId,
'user_id': supabase.auth.currentUser!.id,
'collection_id': collectionId,
if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(),
});
}
Future<void> _createGlobalCarAndVote({
required String hwId,
required String name,
String? series,
int? year,
}) async {
final cleanedSeries = series?.trim();
final payload = <String, dynamic>{
'hw_id': hwId,
'name': name,
'series': (cleanedSeries?.isNotEmpty ?? false) ? cleanedSeries : null,
'year': year,
}..removeWhere((key, value) => value == null);
await supabase.from('global_cars').insert(payload);
await supabase.from('car_votes').insert({
'hw_id': hwId,
'user_id': supabase.auth.currentUser!.id,
});
}
}
// Add Car Dialog (inline, styled)
class _AddCarDialog extends StatefulWidget {
final String hwId;
final String collectionId;
class _FoundCarSheet extends StatelessWidget {
final String collectionName;
const _AddCarDialog({
final Map<String, dynamic> car;
const _FoundCarSheet({required this.collectionName, required this.car});
@override
Widget build(BuildContext context) {
final hwId = car['hw_id'] as String? ?? '???';
final name = car['name'] as String? ?? 'Unknown model';
final series = car['series'] as String?;
final year = car['year'];
final verified = car['is_verified'] == true;
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Found in Catalog',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text('$name ($hwId)', style: const TextStyle(fontWeight: FontWeight.w600)),
if (series != null && series.isNotEmpty) ...[
const SizedBox(height: 4),
Text('Series: $series'),
],
if (year != null) ...[
const SizedBox(height: 4),
Text('Year: $year'),
],
const SizedBox(height: 6),
Text(
verified ? 'Verified by community' : 'Unverified catalog entry',
style: TextStyle(
color: verified ? AppColors.success : AppColors.textSecondary,
fontSize: 12,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => Navigator.pop(context, true),
icon: const Icon(Icons.add),
label: Text('Add to "$collectionName"'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
),
],
),
);
}
}
class _NewDiscoveryData {
final String name;
final String? series;
final int? year;
final String? notes;
const _NewDiscoveryData({
required this.name,
this.series,
this.year,
this.notes,
});
}
class _NewDiscoverySheet extends StatefulWidget {
final String hwId;
final String collectionName;
const _NewDiscoverySheet({
required this.hwId,
required this.collectionId,
required this.collectionName,
});
@override
State<_AddCarDialog> createState() => _AddCarDialogState();
State<_NewDiscoverySheet> createState() => _NewDiscoverySheetState();
}
class _AddCarDialogState extends State<_AddCarDialog> {
class _NewDiscoverySheetState extends State<_NewDiscoverySheet> {
final _nameController = TextEditingController();
final _seriesController = TextEditingController();
final _yearController = TextEditingController();
final _notesController = TextEditingController();
bool _isAdding = false;
File? _pickedImage;
bool _isSaving = false;
@override
void dispose() {
@ -363,208 +497,95 @@ class _AddCarDialogState extends State<_AddCarDialog> {
super.dispose();
}
Future<void> _pickImage() async {
final picker = ImagePicker();
final xFile = await picker.pickImage(
source: ImageSource.camera,
maxWidth: 800,
maxHeight: 800,
imageQuality: 60,
);
if (xFile != null && mounted) {
setState(() => _pickedImage = File(xFile.path));
}
}
Future<void> _quickAdd() async {
setState(() => _isAdding = true);
try {
await supabase.from('hotwheels').insert({
'hw_id': widget.hwId,
'user_id': supabase.auth.currentUser!.id,
'collection_id': widget.collectionId,
});
if (!mounted) return;
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
setState(() => _isAdding = false);
showGlobalSnackBar('Failed to add: $e', isError: true);
}
}
Future<void> _add() async {
setState(() => _isAdding = true);
try {
final row = <String, dynamic>{
'hw_id': widget.hwId,
'user_id': supabase.auth.currentUser!.id,
'collection_id': widget.collectionId,
};
// Optional fields only include if filled in.
void _save() {
final name = _nameController.text.trim();
final series = _seriesController.text.trim();
final yearStr = _yearController.text.trim();
final notes = _notesController.text.trim();
if (name.isNotEmpty) row['name'] = name;
if (series.isNotEmpty) row['series'] = series;
if (yearStr.isNotEmpty) {
final y = int.tryParse(yearStr);
if (y != null) row['year'] = y;
if (name.isEmpty) {
showGlobalSnackBar('Name is required for a new discovery.', isError: true);
return;
}
if (notes.isNotEmpty) row['notes'] = notes;
// Upload image if one was taken.
if (_pickedImage != null) {
final url = await StorageService.uploadCarImage(
file: _pickedImage!,
setState(() => _isSaving = true);
Navigator.pop(
context,
_NewDiscoveryData(
name: name,
series: _seriesController.text.trim().isEmpty
? null
: _seriesController.text.trim(),
year: int.tryParse(_yearController.text.trim()),
notes: _notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
),
);
if (url != null) row['image_url'] = url;
}
await supabase.from('hotwheels').insert(row);
if (!mounted) return;
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
setState(() => _isAdding = false);
showGlobalSnackBar('Failed to add: $e', isError: true);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
icon: Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
gradient: AppColors.brandGradient,
shape: BoxShape.circle,
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child:
const Icon(Icons.add, color: Colors.white, size: 28),
),
title: Text('Add ${widget.hwId}'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Photo picker
GestureDetector(
onTap: _pickImage,
child: Container(
width: double.infinity,
height: 140,
decoration: BoxDecoration(
color: AppColors.backgroundLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: AppColors.orange.withValues(alpha: 0.4),
width: 1.5,
),
image: _pickedImage != null
? DecorationImage(
image: FileImage(_pickedImage!),
fit: BoxFit.cover,
)
: null,
),
child: _pickedImage == null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_a_photo,
size: 36,
color: AppColors.orange.withValues(alpha: 0.6)),
const SizedBox(height: 8),
const Text(
'Tap to take a photo',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
Text(
'New Discovery: ${widget.hwId}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
)
: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(6),
child: CircleAvatar(
radius: 16,
backgroundColor: Colors.black54,
child: IconButton(
icon: const Icon(Icons.close,
size: 16, color: Colors.white),
padding: EdgeInsets.zero,
onPressed: () =>
setState(() => _pickedImage = null),
),
),
),
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 14),
TextField(
controller: _nameController,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Car Name',
labelText: 'Name *',
hintText: "e.g. '70 Dodge Charger",
),
),
const SizedBox(height: 12),
const SizedBox(height: 10),
TextField(
controller: _seriesController,
decoration: const InputDecoration(
labelText: 'Series',
hintText: 'e.g. HW Flames',
decoration: const InputDecoration(labelText: 'Series'),
),
),
const SizedBox(height: 12),
const SizedBox(height: 10),
TextField(
controller: _yearController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Year',
hintText: 'e.g. 2025',
decoration: const InputDecoration(labelText: 'Year'),
),
),
const SizedBox(height: 12),
const SizedBox(height: 10),
TextField(
controller: _notesController,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Notes',
hintText: 'Any extra info…',
labelText: 'Notes (for your garage entry)',
),
),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _save,
icon: const Icon(Icons.save_outlined),
label: Text('Save & Add to "${widget.collectionName}"'),
),
),
actions: [
TextButton(
onPressed: _isAdding ? null : () => Navigator.pop(context),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: _isSaving ? null : () => Navigator.pop(context),
child: const Text('Cancel'),
),
OutlinedButton(
onPressed: _isAdding ? null : _quickAdd,
child: const Text('Skip'),
),
ElevatedButton(
onPressed: _isAdding ? null : _add,
child: _isAdding
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Add'),
),
],
),
);
}
}

View file

@ -49,6 +49,26 @@ class CollectionMember {
class CollectionService {
CollectionService._();
/// Ensures the current user has at least one collection membership.
/// Creates a default collection on first login.
static Future<void> ensureDefaultCollection() async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) return;
final existing = await supabase
.from('collection_members')
.select('id')
.eq('user_id', userId)
.limit(1);
if (existing.isNotEmpty) return;
await create(
name: 'My Garage',
description: 'Your default collection',
);
}
/// Fetch all collections the current user is a member of,
/// including item count and member count.
static Future<List<Collection>> getMyCollections() async {

View file

@ -1,72 +1,103 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../main.dart';
/// Handles uploading / deleting car images in Supabase Storage.
///
/// Bucket: `car-images` (public, but URLs are unguessable)
/// Path: `cars/{uuid}.jpg` random UUID per image.
///
/// Shared garage any authenticated user can upload / replace / delete.
/// Bucket: `car-images` (private)
/// Path: `{auth.uid()}/{hotwheels.id}.jpg`
class StorageService {
StorageService._();
static const _bucket = 'car-images';
static const _uuid = Uuid();
static const int _maxImageBytes = 500 * 1024;
static const int _maxWidth = 1080;
static const int _signedUrlExpirySeconds = 3600;
/// Upload a photo from [file].
///
/// If [oldImageUrl] is provided the previous file is deleted first.
/// Returns the public URL on success, or `null` on failure.
/// Upload a car image for a specific hotwheels entry.
/// Returns the storage path on success (e.g. `uid/123.jpg`).
static Future<String?> uploadCarImage({
required File file,
String? oldImageUrl,
required int entryId,
String? oldPath,
}) async {
try {
// Clean up old image if re-uploading.
if (oldImageUrl != null) {
await _deleteByUrl(oldImageUrl);
if (oldPath != null && oldPath.isNotEmpty) {
await deleteCarImage(oldPath);
}
final path = 'cars/${_uuid.v4()}.jpg';
final userId = supabase.auth.currentUser!.id;
final path = '$userId/$entryId.jpg';
final compressed = await _compressImage(file);
await supabase.storage.from(_bucket).upload(
await supabase.storage.from(_bucket).uploadBinary(
path,
file,
compressed,
fileOptions: const FileOptions(
upsert: true,
contentType: 'image/jpeg',
),
);
// Return the public URL.
final url = supabase.storage.from(_bucket).getPublicUrl(path);
return url;
return path;
} catch (e) {
debugPrint('StorageService.uploadCarImage error: $e');
return null;
}
}
/// Delete the image at the given public [imageUrl].
static Future<void> deleteCarImage(String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return;
await _deleteByUrl(imageUrl);
/// Generates a temporary signed URL for a private image path.
static Future<String?> createSignedUrl(String? path) async {
if (path == null || path.isEmpty) return null;
try {
return await supabase.storage
.from(_bucket)
.createSignedUrl(path, _signedUrlExpirySeconds);
} catch (e) {
debugPrint('StorageService.createSignedUrl error: $e');
return null;
}
}
/// Extract the storage path from a public URL and remove the file.
static Future<void> _deleteByUrl(String imageUrl) async {
/// Deletes an image using its storage path.
static Future<void> deleteCarImage(String? path) async {
if (path == null || path.isEmpty) return;
try {
// Public URLs look like:
// .../storage/v1/object/public/car-images/cars/<uuid>.jpg
final marker = '/object/public/$_bucket/';
final idx = imageUrl.indexOf(marker);
if (idx == -1) return;
final path = imageUrl.substring(idx + marker.length);
await supabase.storage.from(_bucket).remove([path]);
} catch (e) {
debugPrint('StorageService._deleteByUrl error: $e');
debugPrint('StorageService.deleteCarImage error: $e');
}
}
static Future<Uint8List> _compressImage(File source) async {
final bytes = await source.readAsBytes();
final decoded = img.decodeImage(bytes);
if (decoded == null) {
throw Exception('Invalid image file.');
}
final resized = decoded.width > _maxWidth
? img.copyResize(decoded, width: _maxWidth)
: decoded;
var quality = 85;
Uint8List out = Uint8List.fromList(img.encodeJpg(resized, quality: quality));
while (out.lengthInBytes > _maxImageBytes && quality > 35) {
quality -= 10;
out = Uint8List.fromList(img.encodeJpg(resized, quality: quality));
}
if (out.lengthInBytes > _maxImageBytes) {
final reduced = img.copyResize(
resized,
width: (resized.width * 0.8).round(),
);
out = Uint8List.fromList(img.encodeJpg(reduced, quality: 45));
}
return out;
}
}

View file

@ -143,7 +143,12 @@ class _PlaceholderIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
return Padding(
padding: const EdgeInsets.all(14),
child: Image.asset(
'assets/img/icon_bg_removed.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Center(
child: Icon(
Icons.directions_car_filled,
size: 48,
@ -151,6 +156,8 @@ class _PlaceholderIcon extends StatelessWidget {
? Colors.white.withValues(alpha: 0.15)
: AppColors.orange.withValues(alpha: 0.3),
),
),
),
);
}
}

View file

@ -449,7 +449,7 @@ packages:
source: hosted
version: "4.1.2"
image:
dependency: transitive
dependency: "direct main"
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
@ -1046,7 +1046,7 @@ packages:
source: hosted
version: "2.3.1"
url_launcher:
dependency: "direct main"
dependency: transitive
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
@ -1110,7 +1110,7 @@ packages:
source: hosted
version: "3.1.5"
uuid:
dependency: "direct main"
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"

View file

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2.0.0+1
version: 3.0.0+1
environment:
sdk: ^3.11.0
@ -43,8 +43,7 @@ dependencies:
flutter_launcher_icons: ^0.14.3
intl: ^0.20.2
cached_network_image: ^3.4.1
uuid: ^4.5.1
url_launcher: ^6.3.1
image: ^4.2.0
package_info_plus: ^8.1.3
dev_dependencies:

View file

@ -21,13 +21,13 @@
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="hwhub">
<meta name="apple-mobile-web-app-title" content="car64">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png">
<title>hwhub</title>
<title>car64</title>
<link rel="manifest" href="manifest.json">
<style id="splash-screen-style">
html {

View file

@ -1,6 +1,6 @@
{
"name": "hwhub",
"short_name": "hwhub",
"name": "car64",
"short_name": "car64",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",