fix: bootstrap defaults and refine shared collection actions
This commit is contained in:
parent
fa738b9a96
commit
27bf33593e
6 changed files with 180 additions and 43 deletions
|
|
@ -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!,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -320,7 +353,8 @@ class CollectionsScreenState extends State<CollectionsScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: _collections.isNotEmpty
|
||||
? FloatingActionButton(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
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 <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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,34 +23,45 @@ class _HomeShellState extends State<HomeShell> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue