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: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<AuthGate> {
static const _activeCollectionPrefKey = 'active_collection_id';
bool _isLoading = true;
bool _isInPasswordRecoveryFlow = false;
Session? _session;
@ -133,12 +137,57 @@ class _AuthGateState extends State<AuthGate> {
_lastEnsuredUserId = userId;
try {
await CollectionService.ensureDefaultCollection();
final defaultCollectionId = await CollectionService.ensureDefaultCollection();
await _ensureMainCollectionPreference(defaultCollectionId);
} catch (e) {
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 {
await showDialog<void>(
context: navigatorKey.currentContext!,

View file

@ -15,23 +15,45 @@ class CollectionsScreen extends StatefulWidget {
State<CollectionsScreen> createState() => CollectionsScreenState();
}
class CollectionsScreenState extends State<CollectionsScreen> {
class CollectionsScreenState extends State<CollectionsScreen>
with WidgetsBindingObserver {
static const _activeCollectionPrefKey = 'active_collection_id';
List<Collection> _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<void> _load() async {
setState(() {
_isLoading = true;
@ -44,19 +66,28 @@ class CollectionsScreenState extends State<CollectionsScreen> {
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<CollectionsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
body: RefreshIndicator(
onRefresh: _load,
child: CustomScrollView(
slivers: [
// Header
SliverAppBar(
@ -322,6 +355,7 @@ class CollectionsScreenState extends State<CollectionsScreen> {
),
],
),
),
floatingActionButton: _collections.isNotEmpty
? FloatingActionButton(
onPressed: _createCollection,

View file

@ -346,8 +346,9 @@ class GarageScreenState extends State<GarageScreen> {
),
),
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<GarageScreen> {
});
}
Future<void> _moveSelectedCars() async {
Future<void> _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<GarageScreen> {
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<String>(
initialValue: targetId,
decoration: const InputDecoration(
@ -438,7 +440,7 @@ class GarageScreenState extends State<GarageScreen> {
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<GarageScreen> {
if (confirmed != true || targetId == null) return;
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 <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;
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,
);
}
}

View file

@ -12,11 +12,10 @@ class HomeShell extends StatefulWidget {
}
class _HomeShellState extends State<HomeShell> {
static const double _swipeVelocityThreshold = 300;
int _currentIndex = 0;
final _collectionsKey = GlobalKey<CollectionsScreenState>();
final _scanKey = GlobalKey<ScanTabState>();
late final PageController _pageController;
late final List<Widget> _pages = <Widget>[
CollectionsScreen(key: _collectionsKey),
@ -24,35 +23,46 @@ class _HomeShellState extends State<HomeShell> {
const ProfileScreen(),
];
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onTabSelected(int i) {
if (_currentIndex == i) return;
setState(() => _currentIndex = i);
}
void _handleHorizontalSwipe(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0;
if (velocity.abs() < _swipeVelocityThreshold) return;
if (velocity < 0 && _currentIndex < _pages.length - 1) {
_onTabSelected(_currentIndex + 1);
return;
}
if (velocity > 0 && _currentIndex > 0) {
_onTabSelected(_currentIndex - 1);
_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,
body: PageView(
controller: _pageController,
onPageChanged: (index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
}
if (index == 0) {
_collectionsKey.currentState?.refreshIfStale();
}
},
children: _pages,
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _onTabSelected,

View file

@ -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<void> ensureDefaultCollection() async {
static Future<String?> 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,

View file

@ -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<String, _SignedUrlCacheEntry> _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) {