From 27bf33593e17948cbd214e6b1853bd77e1627181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 10:07:06 +0100 Subject: [PATCH] fix: bootstrap defaults and refine shared collection actions --- lib/main.dart | 51 +++++++++++++++++++++++++- lib/screens/collections_screen.dart | 40 +++++++++++++++++++-- lib/screens/garage_screen.dart | 54 ++++++++++++++++++++++------ lib/screens/home_shell.dart | 52 ++++++++++++++++----------- lib/services/collection_service.dart | 14 +++++--- lib/services/storage_service.dart | 12 +++++-- 6 files changed, 180 insertions(+), 43 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 776a8f7..050b0ba 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'theme/app_theme.dart'; import 'services/collection_service.dart'; +import 'services/main_collection_sync.dart'; import 'screens/login_screen.dart'; import 'screens/home_shell.dart'; @@ -88,6 +90,8 @@ class AuthGate extends StatefulWidget { } class _AuthGateState extends State { + static const _activeCollectionPrefKey = 'active_collection_id'; + bool _isLoading = true; bool _isInPasswordRecoveryFlow = false; Session? _session; @@ -133,12 +137,57 @@ class _AuthGateState extends State { _lastEnsuredUserId = userId; try { - await CollectionService.ensureDefaultCollection(); + final defaultCollectionId = await CollectionService.ensureDefaultCollection(); + await _ensureMainCollectionPreference(defaultCollectionId); } catch (e) { showGlobalSnackBar('Collection setup failed: $e', isError: true); } } + Future _ensureMainCollectionPreference(String? defaultCollectionId) async { + final userId = _session?.user.id; + if (userId == null) return; + + final prefs = await SharedPreferences.getInstance(); + final persisted = prefs.getString(_activeCollectionPrefKey); + + Future hasMembership(String collectionId) async { + final membership = await supabase + .from('collection_members') + .select('id') + .eq('user_id', userId) + .eq('collection_id', collectionId) + .maybeSingle(); + return membership != null; + } + + if (persisted != null && await hasMembership(persisted)) { + return; + } + + String? nextActiveId; + if (defaultCollectionId != null && await hasMembership(defaultCollectionId)) { + nextActiveId = defaultCollectionId; + } else { + final firstMembership = await supabase + .from('collection_members') + .select('collection_id') + .eq('user_id', userId) + .limit(1); + if (firstMembership.isNotEmpty) { + nextActiveId = firstMembership.first['collection_id'] as String; + } + } + + if (nextActiveId == null) { + await prefs.remove(_activeCollectionPrefKey); + return; + } + + await prefs.setString(_activeCollectionPrefKey, nextActiveId); + MainCollectionSync.notifyChanged(); + } + Future _showResetPasswordDialog() async { await showDialog( context: navigatorKey.currentContext!, diff --git a/lib/screens/collections_screen.dart b/lib/screens/collections_screen.dart index a3354ab..c98e6cd 100644 --- a/lib/screens/collections_screen.dart +++ b/lib/screens/collections_screen.dart @@ -15,23 +15,45 @@ class CollectionsScreen extends StatefulWidget { State createState() => CollectionsScreenState(); } -class CollectionsScreenState extends State { +class CollectionsScreenState extends State + with WidgetsBindingObserver { static const _activeCollectionPrefKey = 'active_collection_id'; List _collections = []; bool _isLoading = true; String? _error; String? _activeCollectionId; + DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0); @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _load(); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + refreshIfStale(maxAge: const Duration(seconds: 3)); + } + } + /// Public so other tabs can trigger a refresh. void refresh() => _load(); + void refreshIfStale({Duration maxAge = const Duration(seconds: 10)}) { + if (DateTime.now().difference(_lastLoadedAt) > maxAge) { + _load(); + } + } + Future _load() async { setState(() { _isLoading = true; @@ -44,19 +66,28 @@ class CollectionsScreenState extends State { final persisted = prefs.getString(_activeCollectionPrefKey); String? activeId = persisted; + if (activeId == null && list.isNotEmpty) { + activeId = list.first.id; + } if (activeId != null && !list.any((c) => c.id == activeId)) { activeId = list.isNotEmpty ? list.first.id : null; } + final shouldNotifySync = activeId != null && activeId != persisted; + if (!mounted) return; setState(() { _collections = list; _activeCollectionId = activeId; _isLoading = false; + _lastLoadedAt = DateTime.now(); }); if (activeId != null) { await prefs.setString(_activeCollectionPrefKey, activeId); + if (shouldNotifySync) { + MainCollectionSync.notifyChanged(); + } } } catch (e) { if (!mounted) return; @@ -179,7 +210,9 @@ class CollectionsScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: CustomScrollView( + body: RefreshIndicator( + onRefresh: _load, + child: CustomScrollView( slivers: [ // ── Header ── SliverAppBar( @@ -320,7 +353,8 @@ class CollectionsScreenState extends State { ), ), ), - ], + ], + ), ), floatingActionButton: _collections.isNotEmpty ? FloatingActionButton( diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 28bef84..0d3fd28 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -346,8 +346,9 @@ class GarageScreenState extends State { ), ), OutlinedButton( - onPressed: _selectedIds.isEmpty ? null : _moveSelectedCars, - child: const Text('Move to...'), + onPressed: + _selectedIds.isEmpty ? null : _relocateSelectedCars, + child: Text(widget.isOwner ? 'Move to...' : 'Copy to...'), ), ], ), @@ -392,10 +393,11 @@ class GarageScreenState extends State { }); } - Future _moveSelectedCars() async { + Future _relocateSelectedCars() async { if (_selectedIds.isEmpty) return; try { + final isOwner = widget.isOwner; final collections = await CollectionService.getMyCollections(); if (!mounted) return; @@ -413,7 +415,7 @@ class GarageScreenState extends State { context: context, builder: (_) => StatefulBuilder( builder: (context, setSheetState) => AlertDialog( - title: const Text('Move Selected Cars'), + title: Text(isOwner ? 'Move Selected Cars' : 'Copy Selected Cars'), content: DropdownButtonFormField( initialValue: targetId, decoration: const InputDecoration( @@ -438,7 +440,7 @@ class GarageScreenState extends State { onPressed: targetId == null ? null : () => Navigator.pop(context, true), - child: const Text('Move'), + child: Text(isOwner ? 'Move' : 'Copy'), ), ], ), @@ -447,17 +449,47 @@ class GarageScreenState extends State { if (confirmed != true || targetId == null) return; - await supabase - .from('hotwheels') - .update({'collection_id': targetId}) - .inFilter('id', _selectedIds.toList()); + 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 insertRows = sourceCars.map((car) { + final notes = car['notes'] as String?; + final imagePath = car['user_image_url'] as String?; + return { + 'hw_id': car['hw_id'] as String, + 'user_id': supabase.auth.currentUser!.id, + 'collection_id': targetId, + if (notes != null && notes.trim().isNotEmpty) 'notes': notes, + if (imagePath != null && imagePath.isNotEmpty) + 'user_image_url': imagePath, + }; + }).toList(growable: false); + + if (insertRows.isNotEmpty) { + await supabase.from('hotwheels').insert(insertRows); + } + } if (!mounted) return; - showGlobalSnackBar('${_selectedIds.length} car(s) moved.'); + showGlobalSnackBar( + isOwner + ? '${_selectedIds.length} car(s) moved.' + : '${_selectedIds.length} car(s) copied.', + ); _toggleSelectionMode(false); await _loadCars(reset: true); } catch (e) { - showGlobalSnackBar('Failed to move cars: $e', isError: true); + showGlobalSnackBar( + widget.isOwner ? 'Failed to move cars: $e' : 'Failed to copy cars: $e', + isError: true, + ); } } diff --git a/lib/screens/home_shell.dart b/lib/screens/home_shell.dart index aadc093..306d95a 100644 --- a/lib/screens/home_shell.dart +++ b/lib/screens/home_shell.dart @@ -12,11 +12,10 @@ class HomeShell extends StatefulWidget { } class _HomeShellState extends State { - static const double _swipeVelocityThreshold = 300; - int _currentIndex = 0; final _collectionsKey = GlobalKey(); final _scanKey = GlobalKey(); + late final PageController _pageController; late final List _pages = [ CollectionsScreen(key: _collectionsKey), @@ -24,34 +23,45 @@ class _HomeShellState extends State { const ProfileScreen(), ]; - void _onTabSelected(int i) { - setState(() => _currentIndex = i); + @override + void initState() { + super.initState(); + _pageController = PageController(initialPage: _currentIndex); } - void _handleHorizontalSwipe(DragEndDetails details) { - final velocity = details.primaryVelocity ?? 0; - if (velocity.abs() < _swipeVelocityThreshold) return; + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } - if (velocity < 0 && _currentIndex < _pages.length - 1) { - _onTabSelected(_currentIndex + 1); - return; - } - - if (velocity > 0 && _currentIndex > 0) { - _onTabSelected(_currentIndex - 1); + void _onTabSelected(int i) { + if (_currentIndex == i) return; + setState(() => _currentIndex = i); + _pageController.animateToPage( + i, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + if (i == 0) { + _collectionsKey.currentState?.refreshIfStale(); } } @override Widget build(BuildContext context) { return Scaffold( - body: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragEnd: _handleHorizontalSwipe, - child: IndexedStack( - index: _currentIndex, - children: _pages, - ), + body: PageView( + controller: _pageController, + onPageChanged: (index) { + if (_currentIndex != index) { + setState(() => _currentIndex = index); + } + if (index == 0) { + _collectionsKey.currentState?.refreshIfStale(); + } + }, + children: _pages, ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex, diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart index a7e6b79..a0a9eba 100644 --- a/lib/services/collection_service.dart +++ b/lib/services/collection_service.dart @@ -51,22 +51,26 @@ class CollectionService { /// Ensures the current user has at least one collection membership. /// Creates a default collection on first login. - static Future ensureDefaultCollection() async { + static Future ensureDefaultCollection() async { final userId = supabase.auth.currentUser?.id; - if (userId == null) return; + if (userId == null) return null; final existing = await supabase .from('collection_members') - .select('id') + .select('collection_id') .eq('user_id', userId) .limit(1); - if (existing.isNotEmpty) return; + if (existing.isNotEmpty) { + return existing.first['collection_id'] as String; + } - await create( + final created = await create( name: 'My Garage', description: 'Your default collection', ); + + return created.id; } /// Fetch all collections the current user is a member of, diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ee4b08c..e07b9d9 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -15,6 +15,7 @@ class StorageService { static const int _maxImageBytes = 500 * 1024; static const int _maxWidth = 1080; static const int _signedUrlExpirySeconds = 3600; + static const int _maxCompressionAttempts = 5; static const _signedUrlRefreshBuffer = Duration(minutes: 3); static const _maxSignedUrlCacheEntries = 500; static final Map _signedUrlCache = {}; @@ -114,19 +115,26 @@ class StorageService { : decoded; var quality = 85; + var attempts = 0; Uint8List out = Uint8List.fromList(img.encodeJpg(working, quality: quality)); // First pass: reduce JPEG quality. - while (out.lengthInBytes > _maxImageBytes && quality > 35) { + while (out.lengthInBytes > _maxImageBytes && + quality > 35 && + attempts < _maxCompressionAttempts) { quality -= 5; out = Uint8List.fromList(img.encodeJpg(working, quality: quality)); + attempts += 1; } // Second pass: reduce dimensions progressively if still above the limit. - while (out.lengthInBytes > _maxImageBytes && working.width > 320) { + while (out.lengthInBytes > _maxImageBytes && + working.width > 320 && + attempts < _maxCompressionAttempts) { final nextWidth = (working.width * 0.85).round(); working = img.copyResize(working, width: nextWidth); out = Uint8List.fromList(img.encodeJpg(working, quality: quality)); + attempts += 1; } if (out.lengthInBytes > _maxImageBytes) {