feat(collections): persist active collection and harden member actions

- Persist scanner activeCollectionId via shared_preferences
- Restore last selected collection automatically on scan tab load
- Keep selection valid if collection list changes and store fallback safely
- Harden collection membership service rules:
  - only owners can invite/remove members
  - prevent self-invite duplicates
  - block owner removal
  - block owner leave action with explicit error
- Improve manage-collection UX with pull-to-refresh and owner leave guidance
This commit is contained in:
Lukas Müllner 2026-03-04 10:38:14 +01:00
parent 012ff411a1
commit e4afe7b63c
5 changed files with 215 additions and 116 deletions

View file

@ -321,128 +321,136 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
), ),
], ],
), ),
body: ListView( body: RefreshIndicator(
padding: const EdgeInsets.all(16), onRefresh: _loadMembers,
children: [ child: ListView(
// Description padding: const EdgeInsets.all(16),
if (_collection.description != null && children: [
_collection.description!.isNotEmpty) ...[ if (_collection.description != null &&
Text( _collection.description!.isNotEmpty) ...[
_collection.description!,
style: const TextStyle(
fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 16),
],
// Members section
Row(
children: [
Text( Text(
'Members', _collection.description!,
style: theme.textTheme.titleMedium?.copyWith( style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14, color: AppColors.textSecondary),
),
), ),
const Spacer(), const SizedBox(height: 16),
if (_collection.isOwner)
TextButton.icon(
onPressed: _inviteMember,
icon: const Icon(Icons.person_add, size: 18),
label: const Text('Invite'),
),
], ],
),
const SizedBox(height: 8),
if (_isLoading) Row(
const Center( children: [
child: Padding( Text(
padding: EdgeInsets.all(24), 'Members',
child: CircularProgressIndicator(), style: theme.textTheme.titleMedium?.copyWith(
), fontWeight: FontWeight.w600,
) ),
else ),
...List.generate(_members.length, (i) { const Spacer(),
final m = _members[i]; if (_collection.isOwner)
return Card( TextButton.icon(
margin: const EdgeInsets.only(bottom: 8), onPressed: _inviteMember,
child: ListTile( icon: const Icon(Icons.person_add, size: 18),
leading: CircleAvatar( label: const Text('Invite'),
backgroundColor: m.isOwner ),
? AppColors.orange ],
: AppColors.navy, ),
child: Icon( const SizedBox(height: 8),
m.isOwner ? Icons.star : Icons.person,
color: Colors.white, if (_isLoading)
size: 20, const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
),
)
else
...List.generate(_members.length, (i) {
final member = _members[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor:
member.isOwner ? AppColors.orange : AppColors.navy,
child: Icon(
member.isOwner ? Icons.star : Icons.person,
color: Colors.white,
size: 20,
),
), ),
title: Text(
member.email,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
member.isOwner ? 'Owner' : 'Member',
style: const TextStyle(fontSize: 12),
),
trailing: (!member.isOwner &&
_collection.isOwner &&
member.userId != currentUserId)
? IconButton(
icon: const Icon(Icons.remove_circle_outline,
color: AppColors.error),
onPressed: () => _removeMember(member),
)
: null,
), ),
title: Text( );
m.email, }),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
m.isOwner ? 'Owner' : 'Member',
style: const TextStyle(fontSize: 12),
),
trailing: (!m.isOwner &&
_collection.isOwner &&
m.userId != currentUserId)
? IconButton(
icon: const Icon(Icons.remove_circle_outline,
color: AppColors.error),
onPressed: () => _removeMember(m),
)
: null,
),
);
}),
const SizedBox(height: 32), const SizedBox(height: 32),
const Divider(), const Divider(),
const SizedBox(height: 16), const SizedBox(height: 16),
// Danger zone Text(
Text( 'Danger Zone',
'Danger Zone', style: theme.textTheme.titleMedium?.copyWith(
style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600, color: AppColors.error,
color: AppColors.error,
),
),
const SizedBox(height: 12),
if (!_collection.isOwner)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _leaveCollection,
icon: const Icon(Icons.exit_to_app, color: AppColors.error),
label: const Text('Leave Collection',
style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 14),
),
), ),
), ),
const SizedBox(height: 12),
if (_collection.isOwner) if (!_collection.isOwner)
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _deleteCollection, onPressed: _leaveCollection,
icon: const Icon(Icons.delete_forever, color: AppColors.error), icon: const Icon(Icons.exit_to_app, color: AppColors.error),
label: const Text('Delete Collection', label: const Text('Leave Collection',
style: TextStyle(color: AppColors.error)), style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error), side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
),
),
)
else ...[
const Text(
'As owner, you cannot leave this collection. You can delete it instead.',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
), ),
), ),
), const SizedBox(height: 10),
], SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _deleteCollection,
icon: const Icon(Icons.delete_forever,
color: AppColors.error),
label: const Text('Delete Collection',
style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
],
),
), ),
); );
} }

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../main.dart'; import '../main.dart';
import '../scanner_screen.dart'; import '../scanner_screen.dart';
import '../services/collection_service.dart'; import '../services/collection_service.dart';
@ -12,6 +13,8 @@ class ScanTab extends StatefulWidget {
} }
class ScanTabState extends State<ScanTab> { class ScanTabState extends State<ScanTab> {
static const _activeCollectionPrefKey = 'active_collection_id';
bool _isBusy = false; bool _isBusy = false;
List<Collection> _collections = []; List<Collection> _collections = [];
Collection? _selectedCollection; Collection? _selectedCollection;
@ -28,12 +31,29 @@ class ScanTabState extends State<ScanTab> {
Future<void> _loadCollections() async { Future<void> _loadCollections() async {
try { try {
final list = await CollectionService.getMyCollections(); final list = await CollectionService.getMyCollections();
final prefs = await SharedPreferences.getInstance();
final persistedId = prefs.getString(_activeCollectionPrefKey);
Collection? selected;
if (persistedId != null) {
final matching = list.where((c) => c.id == persistedId);
if (matching.isNotEmpty) {
selected = matching.first;
}
}
selected ??= list.isNotEmpty ? list.first : null;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_collections = list; _collections = list;
_selectedCollection = list.isNotEmpty ? list.first : null; _selectedCollection = selected;
_loadingCollections = false; _loadingCollections = false;
}); });
if (selected != null) {
await prefs.setString(_activeCollectionPrefKey, selected.id);
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _loadingCollections = false); setState(() => _loadingCollections = false);
@ -133,11 +153,7 @@ class ScanTabState extends State<ScanTab> {
)) ))
.toList(), .toList(),
onChanged: (id) { onChanged: (id) {
setState(() { _setActiveCollection(id);
final matching = _collections.where((c) => c.id == id);
_selectedCollection =
matching.isNotEmpty ? matching.first : null;
});
}, },
), ),
), ),
@ -208,6 +224,17 @@ class ScanTabState extends State<ScanTab> {
); );
} }
Future<void> _setActiveCollection(String? id) async {
if (id == null) return;
final matching = _collections.where((c) => c.id == id);
if (matching.isEmpty) return;
setState(() => _selectedCollection = matching.first);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_activeCollectionPrefKey, id);
}
Future<void> _openScanner() async { Future<void> _openScanner() async {
await navigatorKey.currentState!.push<void>( await navigatorKey.currentState!.push<void>(
MaterialPageRoute( MaterialPageRoute(

View file

@ -259,6 +259,21 @@ class CollectionService {
required String collectionId, required String collectionId,
required String email, required String email,
}) async { }) async {
final currentUserId = supabase.auth.currentUser!.id;
final collection = await supabase
.from('collections')
.select('owner_id')
.eq('id', collectionId)
.maybeSingle();
if (collection == null) {
throw Exception('Collection not found.');
}
if (collection['owner_id'] != currentUserId) {
throw Exception('Only the collection owner can invite members.');
}
// Call an RPC to look up the user ID by email. // Call an RPC to look up the user ID by email.
final result = await supabase.rpc('get_user_id_by_email', params: { final result = await supabase.rpc('get_user_id_by_email', params: {
'lookup_email': email.trim().toLowerCase(), 'lookup_email': email.trim().toLowerCase(),
@ -271,6 +286,10 @@ class CollectionService {
final userId = result is List ? result.first['id'] as String : result as String; final userId = result is List ? result.first['id'] as String : result as String;
if (userId == currentUserId) {
throw Exception('You are already in this collection.');
}
// Check if already a member. // Check if already a member.
final existing = await supabase final existing = await supabase
.from('collection_members') .from('collection_members')
@ -295,6 +314,35 @@ class CollectionService {
required String collectionId, required String collectionId,
required String membershipId, required String membershipId,
}) async { }) async {
final currentUserId = supabase.auth.currentUser!.id;
final collection = await supabase
.from('collections')
.select('owner_id')
.eq('id', collectionId)
.maybeSingle();
if (collection == null) {
throw Exception('Collection not found.');
}
if (collection['owner_id'] != currentUserId) {
throw Exception('Only the collection owner can remove members.');
}
final target = await supabase
.from('collection_members')
.select('role, user_id')
.eq('id', membershipId)
.eq('collection_id', collectionId)
.maybeSingle();
if (target == null) {
throw Exception('Member not found.');
}
if (target['role'] == 'owner') {
throw Exception('Collection owner cannot be removed.');
}
await supabase await supabase
.from('collection_members') .from('collection_members')
.delete() .delete()
@ -304,6 +352,21 @@ class CollectionService {
/// Leave a collection (for non-owners). /// Leave a collection (for non-owners).
static Future<void> leave(String collectionId) async { static Future<void> leave(String collectionId) async {
final userId = supabase.auth.currentUser!.id; final userId = supabase.auth.currentUser!.id;
final membership = await supabase
.from('collection_members')
.select('role')
.eq('collection_id', collectionId)
.eq('user_id', userId)
.maybeSingle();
if (membership == null) {
throw Exception('You are not a member of this collection.');
}
if (membership['role'] == 'owner') {
throw Exception('Owner cannot leave. Delete the collection instead.');
}
await supabase await supabase
.from('collection_members') .from('collection_members')
.delete() .delete()

View file

@ -841,7 +841,7 @@ packages:
source: hosted source: hosted
version: "0.28.0" version: "0.28.0"
shared_preferences: shared_preferences:
dependency: transitive dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"

View file

@ -45,6 +45,7 @@ dependencies:
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
image: ^4.2.0 image: ^4.2.0
package_info_plus: ^8.1.3 package_info_plus: ^8.1.3
shared_preferences: ^2.5.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: