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
|
/android/app/release
|
||||||
|
|
||||||
TPB.md
|
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 '../theme/app_colors.dart';
|
||||||
import '../widgets/car_card.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 {
|
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
|
@override
|
||||||
State<GarageScreen> createState() => GarageScreenState();
|
State<GarageScreen> createState() => GarageScreenState();
|
||||||
|
|
@ -44,13 +53,10 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final userId = supabase.auth.currentUser?.id;
|
|
||||||
if (userId == null) return;
|
|
||||||
|
|
||||||
final data = await supabase
|
final data = await supabase
|
||||||
.from('hotwheels')
|
.from('hotwheels')
|
||||||
.select()
|
.select()
|
||||||
.eq('user_id', userId)
|
.eq('collection_id', widget.collectionId)
|
||||||
.order('created_at', ascending: false);
|
.order('created_at', ascending: false);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -91,7 +97,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
titlePadding:
|
titlePadding:
|
||||||
const EdgeInsets.only(left: 20, bottom: 16),
|
const EdgeInsets.only(left: 20, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'My Garage',
|
widget.collectionName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'Poppins',
|
fontFamily: 'Poppins',
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'garage_screen.dart';
|
import 'collections_screen.dart';
|
||||||
import 'scan_tab.dart';
|
import 'scan_tab.dart';
|
||||||
import 'profile_screen.dart';
|
import 'profile_screen.dart';
|
||||||
|
|
||||||
|
|
@ -13,19 +13,19 @@ class HomeShell extends StatefulWidget {
|
||||||
|
|
||||||
class _HomeShellState extends State<HomeShell> {
|
class _HomeShellState extends State<HomeShell> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
final _garageKey = GlobalKey<GarageScreenState>();
|
final _collectionsKey = GlobalKey<CollectionsScreenState>();
|
||||||
|
|
||||||
late final List<Widget> _pages = <Widget>[
|
late final List<Widget> _pages = <Widget>[
|
||||||
GarageScreen(key: _garageKey),
|
CollectionsScreen(key: _collectionsKey),
|
||||||
const ScanTab(),
|
const ScanTab(),
|
||||||
const ProfileScreen(),
|
const ProfileScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
void _onTabSelected(int i) {
|
void _onTabSelected(int i) {
|
||||||
setState(() => _currentIndex = i);
|
setState(() => _currentIndex = i);
|
||||||
// Refresh garage whenever user switches to it.
|
// Refresh collections whenever user switches to it.
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
_garageKey.currentState?.refresh();
|
_collectionsKey.currentState?.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,9 +41,9 @@ class _HomeShellState extends State<HomeShell> {
|
||||||
onDestinationSelected: _onTabSelected,
|
onDestinationSelected: _onTabSelected,
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.garage_outlined),
|
icon: Icon(Icons.collections_bookmark_outlined),
|
||||||
selectedIcon: Icon(Icons.garage),
|
selectedIcon: Icon(Icons.collections_bookmark),
|
||||||
label: 'Garage',
|
label: 'Collections',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.qr_code_scanner_outlined),
|
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 'package:image_picker/image_picker.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../scanner_screen.dart';
|
import '../scanner_screen.dart';
|
||||||
|
import '../services/collection_service.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
|
@ -17,6 +18,30 @@ class ScanTab extends StatefulWidget {
|
||||||
|
|
||||||
class _ScanTabState extends State<ScanTab> {
|
class _ScanTabState extends State<ScanTab> {
|
||||||
bool _isBusy = false;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -51,7 +76,7 @@ class _ScanTabState extends State<ScanTab> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
const Text(
|
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,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|
@ -59,7 +84,72 @@ class _ScanTabState extends State<ScanTab> {
|
||||||
height: 1.5,
|
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) ──
|
// ── Scan button (gradient) ──
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|
@ -78,7 +168,9 @@ class _ScanTabState extends State<ScanTab> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isBusy ? null : _openScanner,
|
onPressed: _isBusy || _selectedCollection == null
|
||||||
|
? null
|
||||||
|
: _openScanner,
|
||||||
icon: _isBusy
|
icon: _isBusy
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 22,
|
width: 22,
|
||||||
|
|
@ -113,7 +205,9 @@ class _ScanTabState extends State<ScanTab> {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _isBusy ? null : _manualEntry,
|
onPressed: _isBusy || _selectedCollection == null
|
||||||
|
? null
|
||||||
|
: _manualEntry,
|
||||||
icon: const Icon(Icons.keyboard),
|
icon: const Icon(Icons.keyboard),
|
||||||
label: const Text('Enter ID Manually'),
|
label: const Text('Enter ID Manually'),
|
||||||
),
|
),
|
||||||
|
|
@ -171,13 +265,18 @@ class _ScanTabState extends State<ScanTab> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _processHwId(String hwId) async {
|
Future<void> _processHwId(String hwId) async {
|
||||||
|
final collection = _selectedCollection;
|
||||||
|
if (collection == null) return;
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Check if this hw_id already exists in the selected collection.
|
||||||
final data = await supabase
|
final data = await supabase
|
||||||
.from('hotwheels')
|
.from('hotwheels')
|
||||||
.select()
|
.select()
|
||||||
.eq('hw_id', hwId)
|
.eq('hw_id', hwId)
|
||||||
|
.eq('collection_id', collection.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -190,9 +289,9 @@ class _ScanTabState extends State<ScanTab> {
|
||||||
builder: (_) => AlertDialog(
|
builder: (_) => AlertDialog(
|
||||||
icon: const Icon(Icons.check_circle,
|
icon: const Icon(Icons.check_circle,
|
||||||
color: AppColors.success, size: 48),
|
color: AppColors.success, size: 48),
|
||||||
title: const Text('Already in Garage!'),
|
title: const Text('Already in Collection!'),
|
||||||
content:
|
content:
|
||||||
Text('$hwId is already in your collection.'),
|
Text('$hwId is already in "${collection.name}".'),
|
||||||
actions: [
|
actions: [
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
|
|
@ -205,10 +304,14 @@ class _ScanTabState extends State<ScanTab> {
|
||||||
// New — offer to add
|
// New — offer to add
|
||||||
final added = await showDialog<bool>(
|
final added = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => _AddCarDialog(hwId: hwId),
|
builder: (_) => _AddCarDialog(
|
||||||
|
hwId: hwId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
collectionName: collection.name,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (added == true) {
|
if (added == true) {
|
||||||
showGlobalSnackBar('$hwId added to your garage! 🎉');
|
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -221,7 +324,13 @@ class _ScanTabState extends State<ScanTab> {
|
||||||
// ── Add Car Dialog (inline, styled) ──────────────────────────────────
|
// ── Add Car Dialog (inline, styled) ──────────────────────────────────
|
||||||
class _AddCarDialog extends StatefulWidget {
|
class _AddCarDialog extends StatefulWidget {
|
||||||
final String hwId;
|
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
|
@override
|
||||||
State<_AddCarDialog> createState() => _AddCarDialogState();
|
State<_AddCarDialog> createState() => _AddCarDialogState();
|
||||||
|
|
@ -263,6 +372,7 @@ class _AddCarDialogState extends State<_AddCarDialog> {
|
||||||
await supabase.from('hotwheels').insert({
|
await supabase.from('hotwheels').insert({
|
||||||
'hw_id': widget.hwId,
|
'hw_id': widget.hwId,
|
||||||
'user_id': supabase.auth.currentUser!.id,
|
'user_id': supabase.auth.currentUser!.id,
|
||||||
|
'collection_id': widget.collectionId,
|
||||||
});
|
});
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.pop(context, true);
|
Navigator.pop(context, true);
|
||||||
|
|
@ -280,6 +390,7 @@ class _AddCarDialogState extends State<_AddCarDialog> {
|
||||||
final row = <String, dynamic>{
|
final row = <String, dynamic>{
|
||||||
'hw_id': widget.hwId,
|
'hw_id': widget.hwId,
|
||||||
'user_id': supabase.auth.currentUser!.id,
|
'user_id': supabase.auth.currentUser!.id,
|
||||||
|
'collection_id': widget.collectionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional fields — only include if filled in.
|
// Optional fields — only include if filled in.
|
||||||
|
|
@ -441,7 +552,7 @@ class _AddCarDialogState extends State<_AddCarDialog> {
|
||||||
height: 18,
|
height: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
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