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: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!,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue