feat: implement collections feature with screens for viewing, managing, and creating collections
This commit is contained in:
parent
6fbd4b426e
commit
eaf538bf45
7 changed files with 1213 additions and 25 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -45,3 +45,4 @@ app.*.map.json
|
|||
/android/app/release
|
||||
|
||||
TPB.md
|
||||
*sql
|
||||
|
|
|
|||
409
lib/screens/collections_screen.dart
Normal file
409
lib/screens/collections_screen.dart
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../main.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'garage_screen.dart';
|
||||
import 'manage_collection_screen.dart';
|
||||
|
||||
/// Lists all collections the current user is a member of.
|
||||
class CollectionsScreen extends StatefulWidget {
|
||||
const CollectionsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CollectionsScreen> createState() => CollectionsScreenState();
|
||||
}
|
||||
|
||||
class CollectionsScreenState extends State<CollectionsScreen> {
|
||||
List<Collection> _collections = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
/// Public so other tabs can trigger a refresh.
|
||||
void refresh() => _load();
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final list = await CollectionService.getMyCollections();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_collections = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createCollection() async {
|
||||
final nameCtrl = TextEditingController();
|
||||
final descCtrl = TextEditingController();
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.add, color: Colors.white, size: 28),
|
||||
),
|
||||
title: const Text('New Collection'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
hintText: 'e.g. Hot Wheels, Matchbox…',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
hintText: 'What is this collection for?',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (nameCtrl.text.trim().isEmpty) return;
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != true) return;
|
||||
|
||||
try {
|
||||
await CollectionService.create(
|
||||
name: nameCtrl.text.trim(),
|
||||
description: descCtrl.text.trim(),
|
||||
);
|
||||
showGlobalSnackBar('Collection created!');
|
||||
_load();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _openCollection(Collection c) {
|
||||
navigatorKey.currentState!.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => GarageScreen(
|
||||
collectionId: c.id,
|
||||
collectionName: c.name,
|
||||
isOwner: c.isOwner,
|
||||
),
|
||||
),
|
||||
).then((_) => _load()); // refresh counts when coming back
|
||||
}
|
||||
|
||||
void _manageCollection(Collection c) {
|
||||
navigatorKey.currentState!.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ManageCollectionScreen(collection: c),
|
||||
),
|
||||
).then((_) => _load());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// ── Header ──
|
||||
SliverAppBar(
|
||||
expandedHeight: 140,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
titlePadding: const EdgeInsets.only(left: 20, bottom: 16),
|
||||
title: Text(
|
||||
'Collections',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 22,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
size: 72,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Content ──
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_error != null)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 56, color: AppColors.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_collections.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.collections_bookmark_outlined,
|
||||
size: 64, color: AppColors.textHint),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No collections yet',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Create your first collection to start tracking!',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _createCollection,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Create Collection'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final c = _collections[index];
|
||||
return _CollectionCard(
|
||||
collection: c,
|
||||
onTap: () => _openCollection(c),
|
||||
onManage: () => _manageCollection(c),
|
||||
);
|
||||
},
|
||||
childCount: _collections.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: _collections.isNotEmpty
|
||||
? FloatingActionButton(
|
||||
onPressed: _createCollection,
|
||||
child: const Icon(Icons.add),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collection Card ──────────────────────────────────────────────────
|
||||
|
||||
class _CollectionCard extends StatelessWidget {
|
||||
final Collection collection;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onManage;
|
||||
|
||||
const _CollectionCard({
|
||||
required this.collection,
|
||||
required this.onTap,
|
||||
required this.onManage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = collection;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: c.isOwner
|
||||
? AppColors.brandGradient
|
||||
: const LinearGradient(
|
||||
colors: [AppColors.navy, AppColors.navyLight],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
c.isOwner
|
||||
? Icons.collections_bookmark
|
||||
: Icons.group,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
c.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_MiniStat(
|
||||
icon: Icons.directions_car,
|
||||
value: '${c.itemCount}'),
|
||||
const SizedBox(width: 16),
|
||||
_MiniStat(
|
||||
icon: Icons.people,
|
||||
value: '${c.memberCount}'),
|
||||
const SizedBox(width: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: c.isOwner
|
||||
? AppColors.orange.withValues(alpha: 0.15)
|
||||
: AppColors.navy.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
c.isOwner ? 'Owner' : 'Member',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: c.isOwner
|
||||
? AppColors.orange
|
||||
: AppColors.navy,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Manage button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined,
|
||||
color: AppColors.textHint),
|
||||
onPressed: onManage,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniStat extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String value;
|
||||
const _MiniStat({required this.icon, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppColors.textHint),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,18 @@ import '../services/storage_service.dart';
|
|||
import '../theme/app_colors.dart';
|
||||
import '../widgets/car_card.dart';
|
||||
|
||||
/// The "My Garage" screen — shows the user's collected cars in a grid.
|
||||
/// The "My Garage" screen — shows a collection's cars in a grid.
|
||||
class GarageScreen extends StatefulWidget {
|
||||
const GarageScreen({super.key});
|
||||
final String collectionId;
|
||||
final String collectionName;
|
||||
final bool isOwner;
|
||||
|
||||
const GarageScreen({
|
||||
super.key,
|
||||
required this.collectionId,
|
||||
required this.collectionName,
|
||||
this.isOwner = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GarageScreen> createState() => GarageScreenState();
|
||||
|
|
@ -44,13 +53,10 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
});
|
||||
|
||||
try {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
|
||||
final data = await supabase
|
||||
.from('hotwheels')
|
||||
.select()
|
||||
.eq('user_id', userId)
|
||||
.eq('collection_id', widget.collectionId)
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
@ -91,7 +97,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
titlePadding:
|
||||
const EdgeInsets.only(left: 20, bottom: 16),
|
||||
title: Text(
|
||||
'My Garage',
|
||||
widget.collectionName,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Poppins',
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'garage_screen.dart';
|
||||
import 'collections_screen.dart';
|
||||
import 'scan_tab.dart';
|
||||
import 'profile_screen.dart';
|
||||
|
||||
|
|
@ -13,19 +13,19 @@ class HomeShell extends StatefulWidget {
|
|||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
int _currentIndex = 0;
|
||||
final _garageKey = GlobalKey<GarageScreenState>();
|
||||
final _collectionsKey = GlobalKey<CollectionsScreenState>();
|
||||
|
||||
late final List<Widget> _pages = <Widget>[
|
||||
GarageScreen(key: _garageKey),
|
||||
CollectionsScreen(key: _collectionsKey),
|
||||
const ScanTab(),
|
||||
const ProfileScreen(),
|
||||
];
|
||||
|
||||
void _onTabSelected(int i) {
|
||||
setState(() => _currentIndex = i);
|
||||
// Refresh garage whenever user switches to it.
|
||||
// Refresh collections whenever user switches to it.
|
||||
if (i == 0) {
|
||||
_garageKey.currentState?.refresh();
|
||||
_collectionsKey.currentState?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,9 +41,9 @@ class _HomeShellState extends State<HomeShell> {
|
|||
onDestinationSelected: _onTabSelected,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.garage_outlined),
|
||||
selectedIcon: Icon(Icons.garage),
|
||||
label: 'Garage',
|
||||
icon: Icon(Icons.collections_bookmark_outlined),
|
||||
selectedIcon: Icon(Icons.collections_bookmark),
|
||||
label: 'Collections',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.qr_code_scanner_outlined),
|
||||
|
|
|
|||
425
lib/screens/manage_collection_screen.dart
Normal file
425
lib/screens/manage_collection_screen.dart
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../main.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// Screen to manage a collection: rename, invite/remove members, delete.
|
||||
class ManageCollectionScreen extends StatefulWidget {
|
||||
final Collection collection;
|
||||
|
||||
const ManageCollectionScreen({super.key, required this.collection});
|
||||
|
||||
@override
|
||||
State<ManageCollectionScreen> createState() =>
|
||||
_ManageCollectionScreenState();
|
||||
}
|
||||
|
||||
class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||
late Collection _collection;
|
||||
List<CollectionMember> _members = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_collection = widget.collection;
|
||||
_loadMembers();
|
||||
}
|
||||
|
||||
Future<void> _loadMembers() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final members =
|
||||
await CollectionService.getMembers(_collection.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_members = members;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
showGlobalSnackBar('Failed to load members: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rename() async {
|
||||
final ctrl = TextEditingController(text: _collection.name);
|
||||
final descCtrl =
|
||||
TextEditingController(text: _collection.description ?? '');
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Rename Collection'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: ctrl,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Description (optional)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (ctrl.text.trim().isEmpty) return;
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != true) return;
|
||||
|
||||
try {
|
||||
await CollectionService.update(
|
||||
collectionId: _collection.id,
|
||||
name: ctrl.text.trim(),
|
||||
description: descCtrl.text.trim(),
|
||||
);
|
||||
setState(() {
|
||||
_collection = Collection(
|
||||
id: _collection.id,
|
||||
name: ctrl.text.trim(),
|
||||
description: descCtrl.text.trim(),
|
||||
ownerId: _collection.ownerId,
|
||||
createdAt: _collection.createdAt,
|
||||
role: _collection.role,
|
||||
itemCount: _collection.itemCount,
|
||||
memberCount: _collection.memberCount,
|
||||
);
|
||||
});
|
||||
showGlobalSnackBar('Collection renamed!');
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _inviteMember() async {
|
||||
final emailCtrl = TextEditingController();
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.person_add, color: Colors.white, size: 28),
|
||||
),
|
||||
title: const Text('Invite Member'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter the email address of the person you want to invite. '
|
||||
'They must already have an account.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: emailCtrl,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email address',
|
||||
hintText: 'user@example.com',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (emailCtrl.text.trim().isEmpty) return;
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('Invite'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != true) return;
|
||||
|
||||
try {
|
||||
await CollectionService.inviteByEmail(
|
||||
collectionId: _collection.id,
|
||||
email: emailCtrl.text.trim(),
|
||||
);
|
||||
showGlobalSnackBar('Member invited!');
|
||||
_loadMembers();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('$e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeMember(CollectionMember member) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Remove Member?'),
|
||||
content: Text(
|
||||
'Remove ${member.email} from this collection?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await CollectionService.removeMember(
|
||||
collectionId: _collection.id,
|
||||
membershipId: member.id,
|
||||
);
|
||||
showGlobalSnackBar('Member removed.');
|
||||
_loadMembers();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _leaveCollection() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Leave Collection?'),
|
||||
content: Text(
|
||||
'You will lose access to "${_collection.name}". '
|
||||
'This action cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
|
||||
child: const Text('Leave'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await CollectionService.leave(_collection.id);
|
||||
showGlobalSnackBar('Left "${_collection.name}".');
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteCollection() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Delete Collection?'),
|
||||
content: Text(
|
||||
'Delete "${_collection.name}" and ALL its items? '
|
||||
'This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
|
||||
child: const Text('Delete Forever'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await CollectionService.delete(_collection.id);
|
||||
showGlobalSnackBar('Collection deleted.');
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Failed: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final currentUserId = supabase.auth.currentUser?.id;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_collection.name),
|
||||
actions: [
|
||||
if (_collection.isOwner)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: 'Rename',
|
||||
onPressed: _rename,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// ── Description ──
|
||||
if (_collection.description != null &&
|
||||
_collection.description!.isNotEmpty) ...[
|
||||
Text(
|
||||
_collection.description!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// ── Members section ──
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Members',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
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)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else
|
||||
...List.generate(_members.length, (i) {
|
||||
final m = _members[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: m.isOwner
|
||||
? AppColors.orange
|
||||
: AppColors.navy,
|
||||
child: Icon(
|
||||
m.isOwner ? Icons.star : Icons.person,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
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 Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Danger zone ──
|
||||
Text(
|
||||
'Danger Zone',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_collection.isOwner)
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:image_picker/image_picker.dart';
|
||||
import '../main.dart';
|
||||
import '../scanner_screen.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
|
|
@ -17,6 +18,30 @@ class ScanTab extends StatefulWidget {
|
|||
|
||||
class _ScanTabState extends State<ScanTab> {
|
||||
bool _isBusy = false;
|
||||
List<Collection> _collections = [];
|
||||
Collection? _selectedCollection;
|
||||
bool _loadingCollections = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCollections();
|
||||
}
|
||||
|
||||
Future<void> _loadCollections() async {
|
||||
try {
|
||||
final list = await CollectionService.getMyCollections();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_collections = list;
|
||||
_selectedCollection = list.isNotEmpty ? list.first : null;
|
||||
_loadingCollections = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingCollections = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -51,7 +76,7 @@ class _ScanTabState extends State<ScanTab> {
|
|||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Point your camera at the model ID on the\npackaging to instantly add it to your garage.',
|
||||
'Point your camera at the model ID on the\npackaging to instantly add it to your collection.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
|
|
@ -59,7 +84,72 @@ class _ScanTabState extends State<ScanTab> {
|
|||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Collection picker ──
|
||||
if (_loadingCollections)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else if (_collections.isEmpty)
|
||||
const Text(
|
||||
'Create a collection first!',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: AppColors.orange.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
value: _selectedCollection?.id,
|
||||
icon: const Icon(Icons.arrow_drop_down,
|
||||
color: AppColors.orange),
|
||||
items: _collections
|
||||
.map((c) => DropdownMenuItem(
|
||||
value: c.id,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
c.isOwner
|
||||
? Icons.collections_bookmark
|
||||
: Icons.group,
|
||||
size: 18,
|
||||
color: AppColors.orange,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
c.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (id) {
|
||||
setState(() {
|
||||
_selectedCollection = _collections
|
||||
.firstWhere((c) => c.id == id);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Scan button (gradient) ──
|
||||
SizedBox(
|
||||
|
|
@ -78,7 +168,9 @@ class _ScanTabState extends State<ScanTab> {
|
|||
],
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isBusy ? null : _openScanner,
|
||||
onPressed: _isBusy || _selectedCollection == null
|
||||
? null
|
||||
: _openScanner,
|
||||
icon: _isBusy
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
|
|
@ -113,7 +205,9 @@ class _ScanTabState extends State<ScanTab> {
|
|||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isBusy ? null : _manualEntry,
|
||||
onPressed: _isBusy || _selectedCollection == null
|
||||
? null
|
||||
: _manualEntry,
|
||||
icon: const Icon(Icons.keyboard),
|
||||
label: const Text('Enter ID Manually'),
|
||||
),
|
||||
|
|
@ -171,13 +265,18 @@ class _ScanTabState extends State<ScanTab> {
|
|||
}
|
||||
|
||||
Future<void> _processHwId(String hwId) async {
|
||||
final collection = _selectedCollection;
|
||||
if (collection == null) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
// Check if this hw_id already exists in the selected collection.
|
||||
final data = await supabase
|
||||
.from('hotwheels')
|
||||
.select()
|
||||
.eq('hw_id', hwId)
|
||||
.eq('collection_id', collection.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
@ -190,9 +289,9 @@ class _ScanTabState extends State<ScanTab> {
|
|||
builder: (_) => AlertDialog(
|
||||
icon: const Icon(Icons.check_circle,
|
||||
color: AppColors.success, size: 48),
|
||||
title: const Text('Already in Garage!'),
|
||||
title: const Text('Already in Collection!'),
|
||||
content:
|
||||
Text('$hwId is already in your collection.'),
|
||||
Text('$hwId is already in "${collection.name}".'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
@ -205,10 +304,14 @@ class _ScanTabState extends State<ScanTab> {
|
|||
// New — offer to add
|
||||
final added = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => _AddCarDialog(hwId: hwId),
|
||||
builder: (_) => _AddCarDialog(
|
||||
hwId: hwId,
|
||||
collectionId: collection.id,
|
||||
collectionName: collection.name,
|
||||
),
|
||||
);
|
||||
if (added == true) {
|
||||
showGlobalSnackBar('$hwId added to your garage! 🎉');
|
||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -221,7 +324,13 @@ class _ScanTabState extends State<ScanTab> {
|
|||
// ── Add Car Dialog (inline, styled) ──────────────────────────────────
|
||||
class _AddCarDialog extends StatefulWidget {
|
||||
final String hwId;
|
||||
const _AddCarDialog({required this.hwId});
|
||||
final String collectionId;
|
||||
final String collectionName;
|
||||
const _AddCarDialog({
|
||||
required this.hwId,
|
||||
required this.collectionId,
|
||||
required this.collectionName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AddCarDialog> createState() => _AddCarDialogState();
|
||||
|
|
@ -263,6 +372,7 @@ class _AddCarDialogState extends State<_AddCarDialog> {
|
|||
await supabase.from('hotwheels').insert({
|
||||
'hw_id': widget.hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
'collection_id': widget.collectionId,
|
||||
});
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
|
|
@ -280,6 +390,7 @@ class _AddCarDialogState extends State<_AddCarDialog> {
|
|||
final row = <String, dynamic>{
|
||||
'hw_id': widget.hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
'collection_id': widget.collectionId,
|
||||
};
|
||||
|
||||
// Optional fields — only include if filled in.
|
||||
|
|
@ -441,7 +552,7 @@ class _AddCarDialogState extends State<_AddCarDialog> {
|
|||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Add to Garage'),
|
||||
: const Text('Add to Collection'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
236
lib/services/collection_service.dart
Normal file
236
lib/services/collection_service.dart
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../main.dart';
|
||||
|
||||
/// Data model for a collection.
|
||||
class Collection {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String ownerId;
|
||||
final DateTime createdAt;
|
||||
final String role; // 'owner' or 'member'
|
||||
final int itemCount;
|
||||
final int memberCount;
|
||||
|
||||
Collection({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.ownerId,
|
||||
required this.createdAt,
|
||||
required this.role,
|
||||
this.itemCount = 0,
|
||||
this.memberCount = 1,
|
||||
});
|
||||
|
||||
bool get isOwner => role == 'owner';
|
||||
}
|
||||
|
||||
/// Member of a collection.
|
||||
class CollectionMember {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String email;
|
||||
final String role;
|
||||
final DateTime joinedAt;
|
||||
|
||||
CollectionMember({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.email,
|
||||
required this.role,
|
||||
required this.joinedAt,
|
||||
});
|
||||
|
||||
bool get isOwner => role == 'owner';
|
||||
}
|
||||
|
||||
/// Service for managing collections and membership.
|
||||
class CollectionService {
|
||||
CollectionService._();
|
||||
|
||||
/// Fetch all collections the current user is a member of,
|
||||
/// including item count and member count.
|
||||
static Future<List<Collection>> getMyCollections() async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
|
||||
// Get memberships with collection data.
|
||||
final memberships = await supabase
|
||||
.from('collection_members')
|
||||
.select('role, collections(id, name, description, owner_id, created_at)')
|
||||
.eq('user_id', userId);
|
||||
|
||||
final collections = <Collection>[];
|
||||
|
||||
for (final m in memberships) {
|
||||
final c = m['collections'] as Map<String, dynamic>;
|
||||
|
||||
// Count items in this collection.
|
||||
final itemCount = await supabase
|
||||
.from('hotwheels')
|
||||
.select('id')
|
||||
.eq('collection_id', c['id'])
|
||||
.count(CountOption.exact);
|
||||
|
||||
// Count members.
|
||||
final memberCount = await supabase
|
||||
.from('collection_members')
|
||||
.select('id')
|
||||
.eq('collection_id', c['id'])
|
||||
.count(CountOption.exact);
|
||||
|
||||
collections.add(Collection(
|
||||
id: c['id'] as String,
|
||||
name: c['name'] as String,
|
||||
description: c['description'] as String?,
|
||||
ownerId: c['owner_id'] as String,
|
||||
createdAt: DateTime.parse(c['created_at'] as String),
|
||||
role: m['role'] as String,
|
||||
itemCount: itemCount.count,
|
||||
memberCount: memberCount.count,
|
||||
));
|
||||
}
|
||||
|
||||
// Sort: owned first, then by name.
|
||||
collections.sort((a, b) {
|
||||
if (a.isOwner && !b.isOwner) return -1;
|
||||
if (!a.isOwner && b.isOwner) return 1;
|
||||
return a.name.compareTo(b.name);
|
||||
});
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
/// Create a new collection. The caller is automatically added as owner.
|
||||
static Future<Collection> create({
|
||||
required String name,
|
||||
String? description,
|
||||
}) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
|
||||
final row = await supabase
|
||||
.from('collections')
|
||||
.insert({
|
||||
'name': name,
|
||||
'owner_id': userId,
|
||||
if (description != null && description.isNotEmpty)
|
||||
'description': description,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
// Add owner as a member.
|
||||
await supabase.from('collection_members').insert({
|
||||
'collection_id': row['id'],
|
||||
'user_id': userId,
|
||||
'role': 'owner',
|
||||
});
|
||||
|
||||
return Collection(
|
||||
id: row['id'] as String,
|
||||
name: row['name'] as String,
|
||||
description: row['description'] as String?,
|
||||
ownerId: userId,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
role: 'owner',
|
||||
itemCount: 0,
|
||||
memberCount: 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a collection's name/description. Owner only.
|
||||
static Future<void> update({
|
||||
required String collectionId,
|
||||
required String name,
|
||||
String? description,
|
||||
}) async {
|
||||
await supabase.from('collections').update({
|
||||
'name': name,
|
||||
'description': description,
|
||||
}).eq('id', collectionId);
|
||||
}
|
||||
|
||||
/// Delete a collection. Owner only. Cascade deletes members & items.
|
||||
static Future<void> delete(String collectionId) async {
|
||||
await supabase.from('collections').delete().eq('id', collectionId);
|
||||
}
|
||||
|
||||
/// Get members of a collection (with emails via RPC).
|
||||
static Future<List<CollectionMember>> getMembers(
|
||||
String collectionId) async {
|
||||
final rows = await supabase.rpc('get_collection_members', params: {
|
||||
'p_collection_id': collectionId,
|
||||
});
|
||||
|
||||
final members = <CollectionMember>[];
|
||||
for (final r in rows) {
|
||||
members.add(CollectionMember(
|
||||
id: r['id'] as String,
|
||||
userId: r['user_id'] as String,
|
||||
email: r['email'] as String? ?? 'unknown',
|
||||
role: r['role'] as String,
|
||||
joinedAt: DateTime.parse(r['created_at'] as String),
|
||||
));
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
/// Invite a user by email. Looks up auth.users via an RPC function,
|
||||
/// then inserts a collection_members row.
|
||||
static Future<void> inviteByEmail({
|
||||
required String collectionId,
|
||||
required String email,
|
||||
}) async {
|
||||
// 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(),
|
||||
});
|
||||
|
||||
if (result == null || (result is List && result.isEmpty)) {
|
||||
throw Exception(
|
||||
'No user found with that email. They must create an account first.');
|
||||
}
|
||||
|
||||
final userId = result is List ? result.first['id'] as String : result as String;
|
||||
|
||||
// Check if already a member.
|
||||
final existing = await supabase
|
||||
.from('collection_members')
|
||||
.select('id')
|
||||
.eq('collection_id', collectionId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing != null) {
|
||||
throw Exception('This user is already a member of this collection.');
|
||||
}
|
||||
|
||||
await supabase.from('collection_members').insert({
|
||||
'collection_id': collectionId,
|
||||
'user_id': userId,
|
||||
'role': 'member',
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a member from a collection.
|
||||
static Future<void> removeMember({
|
||||
required String collectionId,
|
||||
required String membershipId,
|
||||
}) async {
|
||||
await supabase
|
||||
.from('collection_members')
|
||||
.delete()
|
||||
.eq('id', membershipId);
|
||||
}
|
||||
|
||||
/// Leave a collection (for non-owners).
|
||||
static Future<void> leave(String collectionId) async {
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
await supabase
|
||||
.from('collection_members')
|
||||
.delete()
|
||||
.eq('collection_id', collectionId)
|
||||
.eq('user_id', userId);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue