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