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:
parent
012ff411a1
commit
e4afe7b63c
5 changed files with 215 additions and 116 deletions
|
|
@ -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> {
|
|||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -841,7 +841,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue