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,10 +321,11 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
),
],
),
body: ListView(
body: RefreshIndicator(
onRefresh: _loadMembers,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Description
if (_collection.description != null &&
_collection.description!.isNotEmpty) ...[
Text(
@ -335,7 +336,6 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
const SizedBox(height: 16),
],
// Members section
Row(
children: [
Text(
@ -364,35 +364,34 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
)
else
...List.generate(_members.length, (i) {
final m = _members[i];
final member = _members[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: m.isOwner
? AppColors.orange
: AppColors.navy,
backgroundColor:
member.isOwner ? AppColors.orange : AppColors.navy,
child: Icon(
m.isOwner ? Icons.star : Icons.person,
member.isOwner ? Icons.star : Icons.person,
color: Colors.white,
size: 20,
),
),
title: Text(
m.email,
member.email,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
m.isOwner ? 'Owner' : 'Member',
member.isOwner ? 'Owner' : 'Member',
style: const TextStyle(fontSize: 12),
),
trailing: (!m.isOwner &&
trailing: (!member.isOwner &&
_collection.isOwner &&
m.userId != currentUserId)
member.userId != currentUserId)
? IconButton(
icon: const Icon(Icons.remove_circle_outline,
color: AppColors.error),
onPressed: () => _removeMember(m),
onPressed: () => _removeMember(member),
)
: null,
),
@ -403,7 +402,6 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
const Divider(),
const SizedBox(height: 16),
// Danger zone
Text(
'Danger Zone',
style: theme.textTheme.titleMedium?.copyWith(
@ -426,14 +424,22 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
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,
),
if (_collection.isOwner)
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _deleteCollection,
icon: const Icon(Icons.delete_forever, color: AppColors.error),
icon: const Icon(Icons.delete_forever,
color: AppColors.error),
label: const Text('Delete Collection',
style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom(
@ -443,6 +449,8 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
),
),
],
],
),
),
);
}

View file

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

View file

@ -259,6 +259,21 @@ class CollectionService {
required String collectionId,
required String email,
}) 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.
final result = await supabase.rpc('get_user_id_by_email', params: {
'lookup_email': email.trim().toLowerCase(),
@ -271,6 +286,10 @@ class CollectionService {
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.
final existing = await supabase
.from('collection_members')
@ -295,6 +314,35 @@ class CollectionService {
required String collectionId,
required String membershipId,
}) 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
.from('collection_members')
.delete()
@ -304,6 +352,21 @@ class CollectionService {
/// Leave a collection (for non-owners).
static Future<void> leave(String collectionId) async {
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
.from('collection_members')
.delete()

View file

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

View file

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