feat: implement collections feature with screens for viewing, managing, and creating collections

This commit is contained in:
Lukas Müllner 2026-02-24 16:38:35 +01:00
parent 6fbd4b426e
commit eaf538bf45
7 changed files with 1213 additions and 25 deletions

1
.gitignore vendored
View file

@ -45,3 +45,4 @@ app.*.map.json
/android/app/release
TPB.md
*sql

View 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,
),
),
],
);
}
}

View file

@ -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,

View file

@ -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),

View 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),
),
),
),
],
),
);
}
}

View file

@ -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'),
),
],
);

View 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);
}
}