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/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8377968..f70a53e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.derkauzigekoala.hwhub" + namespace = "com.dltw.derkauzigekoala.hwhub" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.derkauzigekoala.hwhub" + applicationId = "com.dltw.derkauzigekoala.hwhub" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/kotlin/com/derkauzigekoala/hwhub/MainActivity.kt b/android/app/src/main/kotlin/com/dltw/derkauzigekoala/hwhub/MainActivity.kt similarity index 70% rename from android/app/src/main/kotlin/com/derkauzigekoala/hwhub/MainActivity.kt rename to android/app/src/main/kotlin/com/dltw/derkauzigekoala/hwhub/MainActivity.kt index b6bc733..4745079 100644 --- a/android/app/src/main/kotlin/com/derkauzigekoala/hwhub/MainActivity.kt +++ b/android/app/src/main/kotlin/com/dltw/derkauzigekoala/hwhub/MainActivity.kt @@ -1,4 +1,4 @@ -package com.derkauzigekoala.hwhub +package com.dltw.derkauzigekoala.hwhub import io.flutter.embedding.android.FlutterActivity diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 0000000..40efc80 Binary files /dev/null and b/flutter_01.png differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 94423af..bbc2b70 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -372,7 +372,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub; + PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -388,7 +388,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -405,7 +405,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -420,7 +420,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub; + PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -573,7 +573,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub; + PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 75726b1..1890f57 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -33,7 +33,6 @@ class _AboutScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; final version = _packageInfo != null ? 'v${_packageInfo!.version} (${_packageInfo!.buildNumber})' : '…'; diff --git a/lib/screens/collections_screen.dart b/lib/screens/collections_screen.dart new file mode 100644 index 0000000..f64b8b7 --- /dev/null +++ b/lib/screens/collections_screen.dart @@ -0,0 +1,422 @@ +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 formKey = GlobalKey(); + 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: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: nameCtrl, + autofocus: true, + maxLength: 50, + decoration: const InputDecoration( + labelText: 'Name', + hintText: 'e.g. Hot Wheels, Matchbox…', + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return 'Name is required'; + if (trimmed.length < 2) return 'Name must be at least 2 characters'; + if (trimmed.length > 50) return 'Name must be 50 characters or fewer'; + return null; + }, + ), + 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 (formKey.currentState!.validate()) { + 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..e833375 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,27 @@ class HomeShell extends StatefulWidget { class _HomeShellState extends State { int _currentIndex = 0; - final _garageKey = GlobalKey(); + final _collectionsKey = GlobalKey(); + final _scanKey = GlobalKey(); + final Map _lastRefreshed = {}; + static const _refreshDebounce = Duration(seconds: 30); late final List _pages = [ - GarageScreen(key: _garageKey), - const ScanTab(), + CollectionsScreen(key: _collectionsKey), + ScanTab(key: _scanKey), const ProfileScreen(), ]; void _onTabSelected(int i) { setState(() => _currentIndex = i); - // Refresh garage whenever user switches to it. + final now = DateTime.now(); + final last = _lastRefreshed[i]; + if (last != null && now.difference(last) < _refreshDebounce) return; + _lastRefreshed[i] = now; if (i == 0) { - _garageKey.currentState?.refresh(); + _collectionsKey.currentState?.refresh(); + } else if (i == 1) { + _scanKey.currentState?.refresh(); } } @@ -41,9 +49,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..1614e54 --- /dev/null +++ b/lib/screens/manage_collection_screen.dart @@ -0,0 +1,449 @@ +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 formKey = GlobalKey(); + 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: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: ctrl, + autofocus: true, + maxLength: 50, + decoration: const InputDecoration(labelText: 'Name'), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return 'Name is required'; + if (trimmed.length < 2) return 'Name must be at least 2 characters'; + if (trimmed.length > 50) return 'Name must be 50 characters or fewer'; + return null; + }, + ), + 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 (formKey.currentState!.validate()) { + Navigator.pop(context, true); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + + if (result != true) return; + + final trimmedDesc = descCtrl.text.trim(); + final String? description = + trimmedDesc.isEmpty ? null : trimmedDesc; + + try { + await CollectionService.update( + collectionId: _collection.id, + name: ctrl.text.trim(), + description: description, + ); + setState(() { + _collection = Collection( + id: _collection.id, + name: ctrl.text.trim(), + description: description, + 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; + + final email = emailCtrl.text.trim(); + final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + if (!emailRegex.hasMatch(email)) { + showGlobalSnackBar('Please enter a valid email address.', isError: true); + return; + } + + try { + await CollectionService.inviteByEmail( + collectionId: _collection.id, + email: email, + ); + 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..a77b0e4 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'; @@ -12,11 +13,43 @@ class ScanTab extends StatefulWidget { const ScanTab({super.key}); @override - State createState() => _ScanTabState(); + State createState() => ScanTabState(); } -class _ScanTabState extends State { +class ScanTabState extends State { bool _isBusy = false; + List _collections = []; + Collection? _selectedCollection; + bool _loadingCollections = true; + + @override + void initState() { + super.initState(); + _loadCollections(); + } + + /// Public so HomeShell can trigger a refresh when switching to this tab. + void refresh() => _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); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to load collections: $e'), + ), + ); + } + } @override Widget build(BuildContext context) { @@ -51,7 +84,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 +92,74 @@ 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(() { + final matching = + _collections.where((c) => c.id == id); + _selectedCollection = + matching.isNotEmpty ? matching.first : null; + }); + }, + ), + ), + ), + const SizedBox(height: 24), // ── Scan button (gradient) ── SizedBox( @@ -78,7 +178,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 +215,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 +275,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 +299,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 +314,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 +334,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 +382,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 +400,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 +562,7 @@ class _AddCarDialogState extends State<_AddCarDialog> { height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Add to Garage'), + : const Text('Add'), ), ], ); diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart new file mode 100644 index 0000000..a22e57b --- /dev/null +++ b/lib/services/collection_service.dart @@ -0,0 +1,293 @@ +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); + + // If the user has no memberships, return early. + if (memberships.isEmpty) { + return []; + } + + // Collect all distinct collection IDs from memberships. + final collectionIds = {}; + for (final m in memberships) { + final c = m['collections'] as Map; + final collectionId = c['id'] as String; + collectionIds.add(collectionId); + } + + final collectionIdList = collectionIds.toList(); + + // Fetch all items for these collections in a single query and count them in memory. + final items = await supabase + .from('hotwheels') + .select('id, collection_id') + .in_('collection_id', collectionIdList); + + final itemCounts = {}; + for (final item in items) { + final collectionId = item['collection_id'] as String; + itemCounts[collectionId] = (itemCounts[collectionId] ?? 0) + 1; + } + + // Fetch all members for these collections in a single query and count them in memory. + final members = await supabase + .from('collection_members') + .select('id, collection_id') + .in_('collection_id', collectionIdList); + + final memberCounts = {}; + for (final member in members) { + final collectionId = member['collection_id'] as String; + memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1; + } + + final collections = []; + + for (final m in memberships) { + final c = m['collections'] as Map; + final collectionId = c['id'] as String; + + collections.add(Collection( + id: collectionId, + 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: itemCounts[collectionId] ?? 0, + memberCount: memberCounts[collectionId] ?? 1, + )); + } + + // 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 { + final userId = supabase.auth.currentUser!.id; + + final collection = await supabase + .from('collections') + .select('owner_id') + .eq('id', collectionId) + .maybeSingle(); + + if (collection == null) { + throw Exception('Collection not found.'); + } + if (collection['owner_id'] != userId) { + throw Exception('Only the collection owner can perform this action.'); + } + + await supabase.from('collections').update({ + 'name': name, + if (description != null && description.isNotEmpty) + 'description': description, + }).eq('id', collectionId); + } + + /// Delete a collection. Owner only. Cascade deletes members & items. + static Future delete(String collectionId) async { + final userId = supabase.auth.currentUser!.id; + + final collection = await supabase + .from('collections') + .select('owner_id') + .eq('id', collectionId) + .maybeSingle(); + + if (collection == null) { + throw Exception('Collection not found.'); + } + if (collection['owner_id'] != userId) { + throw Exception('Only the collection owner can perform this action.'); + } + + 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); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index fbfbf4e..b46c0a5 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "hwhub") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.derkauzigekoala.hwhub") +set(APPLICATION_ID "com.dltw.derkauzigekoala.hwhub") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 0f8f2ed..a582a12 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -385,7 +385,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hwhub.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hwhub"; @@ -399,7 +399,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hwhub.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hwhub"; @@ -413,7 +413,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hwhub.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hwhub"; diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index bf0dd59..db314bd 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = hwhub // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub +PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.