From eaf538bf451e9a1ec013c526ec6b9b9fc2c0e53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Tue, 24 Feb 2026 16:38:35 +0100 Subject: [PATCH] feat: implement collections feature with screens for viewing, managing, and creating collections --- .gitignore | 1 + lib/screens/collections_screen.dart | 409 +++++++++++++++++++++ lib/screens/garage_screen.dart | 20 +- lib/screens/home_shell.dart | 16 +- lib/screens/manage_collection_screen.dart | 425 ++++++++++++++++++++++ lib/screens/scan_tab.dart | 131 ++++++- lib/services/collection_service.dart | 236 ++++++++++++ 7 files changed, 1213 insertions(+), 25 deletions(-) create mode 100644 lib/screens/collections_screen.dart create mode 100644 lib/screens/manage_collection_screen.dart create mode 100644 lib/services/collection_service.dart diff --git a/.gitignore b/.gitignore index f815956..84c1439 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ app.*.map.json /android/app/release TPB.md +*sql diff --git a/lib/screens/collections_screen.dart b/lib/screens/collections_screen.dart new file mode 100644 index 0000000..ef24d59 --- /dev/null +++ b/lib/screens/collections_screen.dart @@ -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 createState() => CollectionsScreenState(); +} + +class CollectionsScreenState extends State { + List _collections = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + /// Public so other tabs can trigger a refresh. + void refresh() => _load(); + + Future _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 _createCollection() async { + final nameCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + + final result = await showDialog( + 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, + ), + ), + ], + ); + } +} diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 6eb2a37..63d2c3e 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -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 createState() => GarageScreenState(); @@ -44,13 +53,10 @@ class GarageScreenState extends State { }); 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 { titlePadding: const EdgeInsets.only(left: 20, bottom: 16), title: Text( - 'My Garage', + widget.collectionName, style: TextStyle( fontFamily: 'Poppins', fontWeight: FontWeight.w700, diff --git a/lib/screens/home_shell.dart b/lib/screens/home_shell.dart index d90343c..22d4a2b 100644 --- a/lib/screens/home_shell.dart +++ b/lib/screens/home_shell.dart @@ -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 { int _currentIndex = 0; - final _garageKey = GlobalKey(); + final _collectionsKey = GlobalKey(); late final List _pages = [ - 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 { 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), diff --git a/lib/screens/manage_collection_screen.dart b/lib/screens/manage_collection_screen.dart new file mode 100644 index 0000000..27976be --- /dev/null +++ b/lib/screens/manage_collection_screen.dart @@ -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 createState() => + _ManageCollectionScreenState(); +} + +class _ManageCollectionScreenState extends State { + late Collection _collection; + List _members = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _collection = widget.collection; + _loadMembers(); + } + + Future _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 _rename() async { + final ctrl = TextEditingController(text: _collection.name); + final descCtrl = + TextEditingController(text: _collection.description ?? ''); + + final result = await showDialog( + 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 _inviteMember() async { + final emailCtrl = TextEditingController(); + + final result = await showDialog( + 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 _removeMember(CollectionMember member) async { + final confirmed = await showDialog( + 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 _leaveCollection() async { + final confirmed = await showDialog( + 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 _deleteCollection() async { + final confirmed = await showDialog( + 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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/scan_tab.dart b/lib/screens/scan_tab.dart index 1b3d9f0..feaf62c 100644 --- a/lib/screens/scan_tab.dart +++ b/lib/screens/scan_tab.dart @@ -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 { bool _isBusy = false; + List _collections = []; + Collection? _selectedCollection; + bool _loadingCollections = true; + + @override + void initState() { + super.initState(); + _loadCollections(); + } + + Future _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 { ), 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 { 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( + 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 { ], ), 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 { 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 { } Future _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 { 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 { // New — offer to add final added = await showDialog( 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 { // ── 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 = { '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'), ), ], ); diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart new file mode 100644 index 0000000..23d4efd --- /dev/null +++ b/lib/services/collection_service.dart @@ -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> 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 = []; + + for (final m in memberships) { + final c = m['collections'] as Map; + + // 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 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 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 delete(String collectionId) async { + await supabase.from('collections').delete().eq('id', collectionId); + } + + /// Get members of a collection (with emails via RPC). + static Future> getMembers( + String collectionId) async { + final rows = await supabase.rpc('get_collection_members', params: { + 'p_collection_id': collectionId, + }); + + final members = []; + 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 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 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 leave(String collectionId) async { + final userId = supabase.auth.currentUser!.id; + await supabase + .from('collection_members') + .delete() + .eq('collection_id', collectionId) + .eq('user_id', userId); + } +}