fix: bootstrap defaults and refine shared collection actions

This commit is contained in:
Lukas Müllner 2026-03-05 10:07:06 +01:00
parent fa738b9a96
commit 27bf33593e
6 changed files with 180 additions and 43 deletions

View file

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'services/collection_service.dart'; import 'services/collection_service.dart';
import 'services/main_collection_sync.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/home_shell.dart'; import 'screens/home_shell.dart';
@ -88,6 +90,8 @@ class AuthGate extends StatefulWidget {
} }
class _AuthGateState extends State<AuthGate> { class _AuthGateState extends State<AuthGate> {
static const _activeCollectionPrefKey = 'active_collection_id';
bool _isLoading = true; bool _isLoading = true;
bool _isInPasswordRecoveryFlow = false; bool _isInPasswordRecoveryFlow = false;
Session? _session; Session? _session;
@ -133,12 +137,57 @@ class _AuthGateState extends State<AuthGate> {
_lastEnsuredUserId = userId; _lastEnsuredUserId = userId;
try { try {
await CollectionService.ensureDefaultCollection(); final defaultCollectionId = await CollectionService.ensureDefaultCollection();
await _ensureMainCollectionPreference(defaultCollectionId);
} catch (e) { } catch (e) {
showGlobalSnackBar('Collection setup failed: $e', isError: true); showGlobalSnackBar('Collection setup failed: $e', isError: true);
} }
} }
Future<void> _ensureMainCollectionPreference(String? defaultCollectionId) async {
final userId = _session?.user.id;
if (userId == null) return;
final prefs = await SharedPreferences.getInstance();
final persisted = prefs.getString(_activeCollectionPrefKey);
Future<bool> 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<void> _showResetPasswordDialog() async { Future<void> _showResetPasswordDialog() async {
await showDialog<void>( await showDialog<void>(
context: navigatorKey.currentContext!, context: navigatorKey.currentContext!,

View file

@ -15,23 +15,45 @@ class CollectionsScreen extends StatefulWidget {
State<CollectionsScreen> createState() => CollectionsScreenState(); State<CollectionsScreen> createState() => CollectionsScreenState();
} }
class CollectionsScreenState extends State<CollectionsScreen> { class CollectionsScreenState extends State<CollectionsScreen>
with WidgetsBindingObserver {
static const _activeCollectionPrefKey = 'active_collection_id'; static const _activeCollectionPrefKey = 'active_collection_id';
List<Collection> _collections = []; List<Collection> _collections = [];
bool _isLoading = true; bool _isLoading = true;
String? _error; String? _error;
String? _activeCollectionId; String? _activeCollectionId;
DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_load(); _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. /// Public so other tabs can trigger a refresh.
void refresh() => _load(); void refresh() => _load();
void refreshIfStale({Duration maxAge = const Duration(seconds: 10)}) {
if (DateTime.now().difference(_lastLoadedAt) > maxAge) {
_load();
}
}
Future<void> _load() async { Future<void> _load() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@ -44,19 +66,28 @@ class CollectionsScreenState extends State<CollectionsScreen> {
final persisted = prefs.getString(_activeCollectionPrefKey); final persisted = prefs.getString(_activeCollectionPrefKey);
String? activeId = persisted; String? activeId = persisted;
if (activeId == null && list.isNotEmpty) {
activeId = list.first.id;
}
if (activeId != null && !list.any((c) => c.id == activeId)) { if (activeId != null && !list.any((c) => c.id == activeId)) {
activeId = list.isNotEmpty ? list.first.id : null; activeId = list.isNotEmpty ? list.first.id : null;
} }
final shouldNotifySync = activeId != null && activeId != persisted;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_collections = list; _collections = list;
_activeCollectionId = activeId; _activeCollectionId = activeId;
_isLoading = false; _isLoading = false;
_lastLoadedAt = DateTime.now();
}); });
if (activeId != null) { if (activeId != null) {
await prefs.setString(_activeCollectionPrefKey, activeId); await prefs.setString(_activeCollectionPrefKey, activeId);
if (shouldNotifySync) {
MainCollectionSync.notifyChanged();
}
} }
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@ -179,7 +210,9 @@ class CollectionsScreenState extends State<CollectionsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: CustomScrollView( body: RefreshIndicator(
onRefresh: _load,
child: CustomScrollView(
slivers: [ slivers: [
// Header // Header
SliverAppBar( SliverAppBar(
@ -322,6 +355,7 @@ class CollectionsScreenState extends State<CollectionsScreen> {
), ),
], ],
), ),
),
floatingActionButton: _collections.isNotEmpty floatingActionButton: _collections.isNotEmpty
? FloatingActionButton( ? FloatingActionButton(
onPressed: _createCollection, onPressed: _createCollection,

View file

@ -346,8 +346,9 @@ class GarageScreenState extends State<GarageScreen> {
), ),
), ),
OutlinedButton( OutlinedButton(
onPressed: _selectedIds.isEmpty ? null : _moveSelectedCars, onPressed:
child: const Text('Move to...'), _selectedIds.isEmpty ? null : _relocateSelectedCars,
child: Text(widget.isOwner ? 'Move to...' : 'Copy to...'),
), ),
], ],
), ),
@ -392,10 +393,11 @@ class GarageScreenState extends State<GarageScreen> {
}); });
} }
Future<void> _moveSelectedCars() async { Future<void> _relocateSelectedCars() async {
if (_selectedIds.isEmpty) return; if (_selectedIds.isEmpty) return;
try { try {
final isOwner = widget.isOwner;
final collections = await CollectionService.getMyCollections(); final collections = await CollectionService.getMyCollections();
if (!mounted) return; if (!mounted) return;
@ -413,7 +415,7 @@ class GarageScreenState extends State<GarageScreen> {
context: context, context: context,
builder: (_) => StatefulBuilder( builder: (_) => StatefulBuilder(
builder: (context, setSheetState) => AlertDialog( builder: (context, setSheetState) => AlertDialog(
title: const Text('Move Selected Cars'), title: Text(isOwner ? 'Move Selected Cars' : 'Copy Selected Cars'),
content: DropdownButtonFormField<String>( content: DropdownButtonFormField<String>(
initialValue: targetId, initialValue: targetId,
decoration: const InputDecoration( decoration: const InputDecoration(
@ -438,7 +440,7 @@ class GarageScreenState extends State<GarageScreen> {
onPressed: targetId == null onPressed: targetId == null
? null ? null
: () => Navigator.pop(context, true), : () => Navigator.pop(context, true),
child: const Text('Move'), child: Text(isOwner ? 'Move' : 'Copy'),
), ),
], ],
), ),
@ -447,17 +449,47 @@ class GarageScreenState extends State<GarageScreen> {
if (confirmed != true || targetId == null) return; if (confirmed != true || targetId == null) return;
if (isOwner) {
await supabase await supabase
.from('hotwheels') .from('hotwheels')
.update({'collection_id': targetId}) .update({'collection_id': targetId})
.inFilter('id', _selectedIds.toList()); .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 <String, dynamic>{
'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; if (!mounted) return;
showGlobalSnackBar('${_selectedIds.length} car(s) moved.'); showGlobalSnackBar(
isOwner
? '${_selectedIds.length} car(s) moved.'
: '${_selectedIds.length} car(s) copied.',
);
_toggleSelectionMode(false); _toggleSelectionMode(false);
await _loadCars(reset: true); await _loadCars(reset: true);
} catch (e) { } 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,
);
} }
} }

View file

@ -12,11 +12,10 @@ class HomeShell extends StatefulWidget {
} }
class _HomeShellState extends State<HomeShell> { class _HomeShellState extends State<HomeShell> {
static const double _swipeVelocityThreshold = 300;
int _currentIndex = 0; int _currentIndex = 0;
final _collectionsKey = GlobalKey<CollectionsScreenState>(); final _collectionsKey = GlobalKey<CollectionsScreenState>();
final _scanKey = GlobalKey<ScanTabState>(); final _scanKey = GlobalKey<ScanTabState>();
late final PageController _pageController;
late final List<Widget> _pages = <Widget>[ late final List<Widget> _pages = <Widget>[
CollectionsScreen(key: _collectionsKey), CollectionsScreen(key: _collectionsKey),
@ -24,35 +23,46 @@ class _HomeShellState extends State<HomeShell> {
const ProfileScreen(), const ProfileScreen(),
]; ];
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onTabSelected(int i) { void _onTabSelected(int i) {
if (_currentIndex == i) return;
setState(() => _currentIndex = i); setState(() => _currentIndex = i);
} _pageController.animateToPage(
i,
void _handleHorizontalSwipe(DragEndDetails details) { duration: const Duration(milliseconds: 260),
final velocity = details.primaryVelocity ?? 0; curve: Curves.easeOutCubic,
if (velocity.abs() < _swipeVelocityThreshold) return; );
if (i == 0) {
if (velocity < 0 && _currentIndex < _pages.length - 1) { _collectionsKey.currentState?.refreshIfStale();
_onTabSelected(_currentIndex + 1);
return;
}
if (velocity > 0 && _currentIndex > 0) {
_onTabSelected(_currentIndex - 1);
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: GestureDetector( body: PageView(
behavior: HitTestBehavior.translucent, controller: _pageController,
onHorizontalDragEnd: _handleHorizontalSwipe, onPageChanged: (index) {
child: IndexedStack( if (_currentIndex != index) {
index: _currentIndex, setState(() => _currentIndex = index);
}
if (index == 0) {
_collectionsKey.currentState?.refreshIfStale();
}
},
children: _pages, children: _pages,
), ),
),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex, selectedIndex: _currentIndex,
onDestinationSelected: _onTabSelected, onDestinationSelected: _onTabSelected,

View file

@ -51,22 +51,26 @@ class CollectionService {
/// Ensures the current user has at least one collection membership. /// Ensures the current user has at least one collection membership.
/// Creates a default collection on first login. /// Creates a default collection on first login.
static Future<void> ensureDefaultCollection() async { static Future<String?> ensureDefaultCollection() async {
final userId = supabase.auth.currentUser?.id; final userId = supabase.auth.currentUser?.id;
if (userId == null) return; if (userId == null) return null;
final existing = await supabase final existing = await supabase
.from('collection_members') .from('collection_members')
.select('id') .select('collection_id')
.eq('user_id', userId) .eq('user_id', userId)
.limit(1); .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', name: 'My Garage',
description: 'Your default collection', description: 'Your default collection',
); );
return created.id;
} }
/// Fetch all collections the current user is a member of, /// Fetch all collections the current user is a member of,

View file

@ -15,6 +15,7 @@ class StorageService {
static const int _maxImageBytes = 500 * 1024; static const int _maxImageBytes = 500 * 1024;
static const int _maxWidth = 1080; static const int _maxWidth = 1080;
static const int _signedUrlExpirySeconds = 3600; static const int _signedUrlExpirySeconds = 3600;
static const int _maxCompressionAttempts = 5;
static const _signedUrlRefreshBuffer = Duration(minutes: 3); static const _signedUrlRefreshBuffer = Duration(minutes: 3);
static const _maxSignedUrlCacheEntries = 500; static const _maxSignedUrlCacheEntries = 500;
static final Map<String, _SignedUrlCacheEntry> _signedUrlCache = {}; static final Map<String, _SignedUrlCacheEntry> _signedUrlCache = {};
@ -114,19 +115,26 @@ class StorageService {
: decoded; : decoded;
var quality = 85; var quality = 85;
var attempts = 0;
Uint8List out = Uint8List.fromList(img.encodeJpg(working, quality: quality)); Uint8List out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
// First pass: reduce JPEG quality. // First pass: reduce JPEG quality.
while (out.lengthInBytes > _maxImageBytes && quality > 35) { while (out.lengthInBytes > _maxImageBytes &&
quality > 35 &&
attempts < _maxCompressionAttempts) {
quality -= 5; quality -= 5;
out = Uint8List.fromList(img.encodeJpg(working, quality: quality)); out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
attempts += 1;
} }
// Second pass: reduce dimensions progressively if still above the limit. // 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(); final nextWidth = (working.width * 0.85).round();
working = img.copyResize(working, width: nextWidth); working = img.copyResize(working, width: nextWidth);
out = Uint8List.fromList(img.encodeJpg(working, quality: quality)); out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
attempts += 1;
} }
if (out.lengthInBytes > _maxImageBytes) { if (out.lengthInBytes > _maxImageBytes) {