diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 138b879..1d00f17 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - HW Collector Hub + car64 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - HW Collector Hub + car64 CFBundlePackageType APPL CFBundleShortVersionString diff --git a/lib/main.dart b/lib/main.dart index a8b3e07..bef4ff8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { anonKey: _supabaseAnonKey, ); - runApp(const HWHubApp()); + runApp(const Car64App()); } /// Convenience accessor used throughout the app. @@ -49,13 +50,13 @@ Future showGlobalDialog({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 { 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 { setState(() {}); } + _ensureDefaultCollectionIfNeeded(); + if (authState.event == AuthChangeEvent.passwordRecovery) { _showResetPasswordDialog(); } @@ -109,6 +114,18 @@ class _AuthGateState extends State { setState(() => _isLoading = false); } + Future _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 _showResetPasswordDialog() async { await showDialog( context: navigatorKey.currentContext!, diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 1890f57..adc232a 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -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 { if (mounted) setState(() => _packageInfo = info); } - Future _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 { 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 { 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 { ), 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 { } } -// ── 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 { diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 63d2c3e..f2a94c6 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -55,13 +55,26 @@ class GarageScreenState extends State { 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>.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>.from(data); + _cars = withSignedUrls; _isLoading = false; }); } catch (e) { @@ -77,9 +90,10 @@ class GarageScreenState extends State { if (_searchQuery.isEmpty) return _cars; final q = _searchQuery.toLowerCase(); return _cars.where((car) { + final global = car['global_cars'] as Map?; 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 { delegate: SliverChildBuilderDelegate( (context, index) { final car = _filteredCars[index]; + final global = car['global_cars'] as Map?; 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 { } void _showCarDetails(Map car) { + final global = car['global_cars'] as Map?; 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 { 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,13 +406,20 @@ class GarageScreenState extends State { final isDark = Theme.of(context).brightness == Brightness.dark; return Container( color: isDark ? AppColors.surfaceDark : AppColors.backgroundLight, - child: Center( - child: Icon( - Icons.directions_car_filled, - size: 48, - color: isDark - ? Colors.white.withValues(alpha: 0.15) - : AppColors.orange.withValues(alpha: 0.25), + 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, + color: isDark + ? Colors.white.withValues(alpha: 0.15) + : AppColors.orange.withValues(alpha: 0.25), + ), + ), ), ), ); @@ -410,13 +439,14 @@ class GarageScreenState extends State { 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 { 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 { 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 = {}; - 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…', diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 34e23f2..44711d3 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -142,7 +142,7 @@ class _LoginScreenState extends State ), 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 // ── Footer ── Text( - '© 2026 HW Collector Hub', + '© 2026 car64', style: TextStyle( fontSize: 12, color: Colors.white.withValues(alpha: 0.5), diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 13e96cf..a128d8e 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -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), ), diff --git a/lib/screens/scan_tab.dart b/lib/screens/scan_tab.dart index a77b0e4..73ac8a0 100644 --- a/lib/screens/scan_tab.dart +++ b/lib/screens/scan_tab.dart @@ -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 { _loadCollections(); } - /// Public so HomeShell can trigger a refresh when switching to this tab. void refresh() => _loadCollections(); Future _loadCollections() async { @@ -43,11 +37,7 @@ class ScanTabState extends State { } 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 { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // ── Illustration ── Container( width: 120, height: 120, @@ -77,14 +66,11 @@ class ScanTabState extends State { 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 { ), ), const SizedBox(height: 24), - - // ── Collection picker ── if (_loadingCollections) const Padding( padding: EdgeInsets.symmetric(vertical: 8), @@ -150,8 +134,7 @@ class ScanTabState extends State { .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 { ), ), const SizedBox(height: 24), - - // ── Scan button (gradient) ── SizedBox( width: double.infinity, height: 56, @@ -210,8 +191,6 @@ class ScanTabState extends State { ), ), const SizedBox(height: 16), - - // ── Manual entry ── SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -281,27 +260,23 @@ class ScanTabState extends State { setState(() => _isBusy = true); try { - // Check if this hw_id already exists in the selected collection. - final data = await supabase + final existing = await supabase .from('hotwheels') - .select() + .select('id') .eq('hw_id', hwId) .eq('collection_id', collection.id) .maybeSingle(); - if (!mounted) return; - setState(() => _isBusy = false); - - if (data != null) { - // Already in collection + if (existing != null) { + if (!mounted) return; + setState(() => _isBusy = false); 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 { ], ), ); - } else { - // New — offer to add - final added = await showDialog( + 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( 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 { showGlobalSnackBar('DB error: $e', isError: true); } } + + Future _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 _createGlobalCarAndVote({ + required String hwId, + required String name, + String? series, + int? year, + }) async { + final cleanedSeries = series?.trim(); + final payload = { + '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 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 _pickImage() async { - final picker = ImagePicker(); - final xFile = await picker.pickImage( - source: ImageSource.camera, - maxWidth: 800, - maxHeight: 800, - imageQuality: 60, + void _save() { + final name = _nameController.text.trim(); + if (name.isEmpty) { + showGlobalSnackBar('Name is required for a new discovery.', isError: true); + return; + } + + 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 (xFile != null && mounted) { - setState(() => _pickedImage = File(xFile.path)); - } - } - - Future _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 _add() async { - setState(() => _isAdding = true); - - try { - final row = { - 'hw_id': widget.hwId, - 'user_id': supabase.auth.currentUser!.id, - 'collection_id': widget.collectionId, - }; - - // Optional fields — only include if filled in. - final name = _nameController.text.trim(); - final 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 (notes.isNotEmpty) row['notes'] = notes; - - // Upload image if one was taken. - if (_pickedImage != null) { - final url = await StorageService.uploadCarImage( - file: _pickedImage!, - ); - 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, - ), - child: - const Icon(Icons.add, color: Colors.white, size: 28), + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, ), - 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: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'New Discovery: ${widget.hwId}', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), - 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, - ), - ), - ], - ) - : 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: 14), + TextField( + controller: _nameController, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Name *', + hintText: "e.g. '70 Dodge Charger", ), - const SizedBox(height: 16), - TextField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Car Name', - hintText: "e.g. '70 Dodge Charger", - ), + ), + const SizedBox(height: 10), + TextField( + controller: _seriesController, + decoration: const InputDecoration(labelText: 'Series'), + ), + const SizedBox(height: 10), + TextField( + controller: _yearController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Year'), + ), + const SizedBox(height: 10), + TextField( + controller: _notesController, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Notes (for your garage entry)', ), - const SizedBox(height: 12), - TextField( - controller: _seriesController, - decoration: const InputDecoration( - labelText: 'Series', - hintText: 'e.g. HW Flames', - ), + ), + 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}"'), ), - const SizedBox(height: 12), - TextField( - controller: _yearController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Year', - hintText: 'e.g. 2025', - ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: _isSaving ? null : () => Navigator.pop(context), + child: const Text('Cancel'), ), - const SizedBox(height: 12), - TextField( - controller: _notesController, - maxLines: 2, - decoration: const InputDecoration( - labelText: 'Notes', - hintText: 'Any extra info…', - ), - ), - ], - ), + ), + ], ), - actions: [ - TextButton( - onPressed: _isAdding ? 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'), - ), - ], ); } } diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart index 197d250..dd2df56 100644 --- a/lib/services/collection_service.dart +++ b/lib/services/collection_service.dart @@ -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 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> getMyCollections() async { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 046852d..d8b37ae 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -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 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 deleteCarImage(String? imageUrl) async { - if (imageUrl == null || imageUrl.isEmpty) return; - await _deleteByUrl(imageUrl); - } - - /// Extract the storage path from a public URL and remove the file. - static Future _deleteByUrl(String imageUrl) async { + /// Generates a temporary signed URL for a private image path. + static Future createSignedUrl(String? path) async { + if (path == null || path.isEmpty) return null; try { - // Public URLs look like: - // .../storage/v1/object/public/car-images/cars/.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]); + return await supabase.storage + .from(_bucket) + .createSignedUrl(path, _signedUrlExpirySeconds); } catch (e) { - debugPrint('StorageService._deleteByUrl error: $e'); + debugPrint('StorageService.createSignedUrl error: $e'); + return null; } } + + /// Deletes an image using its storage path. + static Future deleteCarImage(String? path) async { + if (path == null || path.isEmpty) return; + try { + await supabase.storage.from(_bucket).remove([path]); + } catch (e) { + debugPrint('StorageService.deleteCarImage error: $e'); + } + } + + static Future _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; + } } diff --git a/lib/widgets/car_card.dart b/lib/widgets/car_card.dart index 6620f47..82979fc 100644 --- a/lib/widgets/car_card.dart +++ b/lib/widgets/car_card.dart @@ -58,7 +58,7 @@ class CarCard extends StatelessWidget { imageUrl!, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => - _PlaceholderIcon(isDark: isDark), + _PlaceholderIcon(isDark: isDark), ) : _PlaceholderIcon(isDark: isDark), ), @@ -143,13 +143,20 @@ class _PlaceholderIcon extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Icon( - Icons.directions_car_filled, - size: 48, - color: isDark - ? Colors.white.withValues(alpha: 0.15) - : AppColors.orange.withValues(alpha: 0.3), + 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, + color: isDark + ? Colors.white.withValues(alpha: 0.15) + : AppColors.orange.withValues(alpha: 0.3), + ), + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index 5665c72..83de094 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index c76ec81..e2711b8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/web/index.html b/web/index.html index d0b8f49..4a4b5fa 100644 --- a/web/index.html +++ b/web/index.html @@ -21,13 +21,13 @@ - + - hwhub + car64