From bebd21097d8366b5a3100a7ddbace78d3a4196b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Thu, 5 Mar 2026 10:30:40 +0100 Subject: [PATCH] fix: enforce role-based collection actions and instant defaults --- lib/screens/collections_screen.dart | 29 +++- lib/screens/garage_screen.dart | 190 +++++++++++++++------- lib/screens/manage_collection_screen.dart | 39 ++++- lib/screens/scan_tab.dart | 16 +- lib/services/collection_service.dart | 12 +- 5 files changed, 213 insertions(+), 73 deletions(-) diff --git a/lib/screens/collections_screen.dart b/lib/screens/collections_screen.dart index c98e6cd..6e387aa 100644 --- a/lib/screens/collections_screen.dart +++ b/lib/screens/collections_screen.dart @@ -29,15 +29,22 @@ class CollectionsScreenState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + MainCollectionSync.changeToken.addListener(_handleSyncChanged); _load(); } @override void dispose() { + MainCollectionSync.changeToken.removeListener(_handleSyncChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); } + void _handleSyncChanged() { + if (!mounted) return; + _load(); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { @@ -61,7 +68,11 @@ class CollectionsScreenState extends State }); try { - final list = await CollectionService.getMyCollections(); + var list = await CollectionService.getMyCollections(); + if (list.isEmpty && supabase.auth.currentUser != null) { + await CollectionService.ensureDefaultCollection(); + list = await CollectionService.getMyCollections(); + } final prefs = await SharedPreferences.getInstance(); final persisted = prefs.getString(_activeCollectionPrefKey); @@ -184,7 +195,7 @@ class CollectionsScreenState extends State builder: (_) => GarageScreen( collectionId: c.id, collectionName: c.name, - isOwner: c.isOwner, + userRole: c.role, ), ), ).then((_) => _load()); // refresh counts when coming back @@ -446,17 +457,25 @@ class _CollectionCard extends StatelessWidget { decoration: BoxDecoration( color: c.isOwner ? AppColors.orange.withValues(alpha: 0.15) - : AppColors.navy.withValues(alpha: 0.1), + : c.isViewer + ? AppColors.textHint.withValues(alpha: 0.15) + : AppColors.navy.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), ), child: Text( - c.isOwner ? 'Owner' : 'Member', + c.isOwner + ? 'Owner' + : c.isViewer + ? 'Viewer' + : 'Member', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: c.isOwner ? AppColors.orange - : AppColors.navy, + : c.isViewer + ? AppColors.textSecondary + : AppColors.navy, ), ), ), diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 0d3fd28..6089289 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -13,15 +13,20 @@ import '../widgets/car_card.dart'; class GarageScreen extends StatefulWidget { final String collectionId; final String collectionName; - final bool isOwner; + final String userRole; const GarageScreen({ super.key, required this.collectionId, required this.collectionName, - this.isOwner = true, + this.userRole = 'owner', }); + bool get isOwner => userRole == 'owner'; + bool get isMember => userRole == 'member'; + bool get isViewer => userRole == 'viewer'; + bool get canModifyCars => isOwner || isMember; + @override State createState() => GarageScreenState(); } @@ -154,13 +159,13 @@ class GarageScreenState extends State { expandedHeight: 140, pinned: true, actions: [ - if (_selectionMode) + if (_selectionMode && !widget.isViewer) IconButton( tooltip: 'Cancel Selection', onPressed: () => _toggleSelectionMode(false), icon: const Icon(Icons.close), ) - else + else if (!widget.isViewer) IconButton( tooltip: 'Select Cars', onPressed: () => _toggleSelectionMode(true), @@ -306,7 +311,8 @@ class GarageScreenState extends State { onTap: () => _selectionMode ? _toggleCarSelection(car) : _showCarDetails(car), - onLongPress: () => _toggleCarSelection(car), + onLongPress: + widget.isViewer ? null : () => _toggleCarSelection(car), ); }, childCount: _filteredCars.length, @@ -324,6 +330,7 @@ class GarageScreenState extends State { ), ), bottomNavigationBar: _selectionMode + && !widget.isViewer ? SafeArea( top: false, child: Container( @@ -370,6 +377,7 @@ class GarageScreenState extends State { } void _toggleSelectionMode(bool enabled) { + if (widget.isViewer && enabled) return; setState(() { _selectionMode = enabled; if (!enabled) { @@ -379,6 +387,7 @@ class GarageScreenState extends State { } void _toggleCarSelection(Map car) { + if (widget.isViewer) return; final id = car['id'] as int; setState(() { _selectionMode = true; @@ -395,6 +404,10 @@ class GarageScreenState extends State { Future _relocateSelectedCars() async { if (_selectedIds.isEmpty) return; + if (widget.isViewer) { + showGlobalSnackBar('Viewer role is read-only for this collection.'); + return; + } try { final isOwner = widget.isOwner; @@ -593,25 +606,26 @@ class GarageScreenState extends State { const SizedBox(height: 8), // Change / Add photo button - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () => _updatePhoto(car, context), - icon: Icon( - (car['user_image_url'] as String?) != null - ? Icons.camera_alt - : Icons.add_a_photo, - size: 18, - ), - label: Text( - (car['user_image_url'] as String?) != null - ? 'Change Photo' - : 'Add Photo', + if (widget.canModifyCars) ...[ + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => _updatePhoto(car, context), + icon: Icon( + (car['user_image_url'] as String?) != null + ? Icons.camera_alt + : Icons.add_a_photo, + size: 18, + ), + label: Text( + (car['user_image_url'] as String?) != null + ? 'Change Photo' + : 'Add Photo', + ), ), ), - ), - - const SizedBox(height: 4), + const SizedBox(height: 4), + ], // ID badge Align( @@ -693,41 +707,55 @@ class GarageScreenState extends State { const SizedBox(height: 12), - // Edit & Delete buttons - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => _moveSingleCar(car, context), - icon: const Icon(Icons.drive_file_move_outline, size: 18), - label: const Text('Move'), + if (widget.canModifyCars) ...[ + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _relocateSingleCar(car, context), + icon: Icon( + widget.isOwner + ? Icons.drive_file_move_outline + : Icons.copy_outlined, + size: 18, + ), + label: Text(widget.isOwner ? 'Move' : 'Copy'), + ), ), - ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton.icon( - onPressed: () => _editCar(car, context), - icon: const Icon(Icons.edit, size: 18), - label: const Text('Edit'), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _editCar(car, context), + icon: const Icon(Icons.edit, size: 18), + label: const Text('Edit'), + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _deleteCar(car, context), + icon: const Icon(Icons.delete_outline, color: AppColors.error), + label: const Text( + 'Remove', + style: TextStyle(color: AppColors.error), + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppColors.error), ), - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _deleteCar(car, context), - icon: const Icon(Icons.delete_outline, color: AppColors.error), - label: const Text( - 'Remove', - style: TextStyle(color: AppColors.error), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: AppColors.error), ), ), - ), + ] else + const Text( + 'Viewer access: read-only', + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), ], ), ), @@ -760,6 +788,11 @@ class GarageScreenState extends State { /// Take a new photo and update the image_url for this car. Future _updatePhoto( Map car, BuildContext sheetContext) async { + if (!widget.canModifyCars) { + showGlobalSnackBar('Viewer role is read-only for this collection.'); + return; + } + final picker = ImagePicker(); final xFile = await picker.pickImage( source: ImageSource.camera, @@ -804,6 +837,11 @@ class GarageScreenState extends State { /// Open an edit dialog for this car, then update Supabase. Future _editCar( Map car, BuildContext sheetContext) async { + if (!widget.canModifyCars) { + showGlobalSnackBar('Viewer role is read-only for this collection.'); + return; + } + final updated = await showDialog>( context: sheetContext, builder: (_) => _EditCarDialog(car: car), @@ -922,7 +960,7 @@ class GarageScreenState extends State { context: context, builder: (_) => StatefulBuilder( builder: (context, setSheetState) => AlertDialog( - title: const Text('Move Car'), + title: Text(widget.isOwner ? 'Move Car' : 'Copy Car'), content: DropdownButtonFormField( initialValue: targetId, decoration: const InputDecoration( @@ -947,7 +985,7 @@ class GarageScreenState extends State { onPressed: targetId == null ? null : () => Navigator.pop(context, true), - child: const Text('Move'), + child: Text(widget.isOwner ? 'Move' : 'Copy'), ), ], ), @@ -958,28 +996,56 @@ class GarageScreenState extends State { return targetId; } - Future _moveSingleCar( + Future _relocateSingleCar( Map car, BuildContext sheetContext) async { + if (widget.isViewer) { + showGlobalSnackBar('Viewer role is read-only for this collection.'); + return; + } + try { final targetId = await _pickTargetCollection(); if (targetId == null) return; - await supabase - .from('hotwheels') - .update({'collection_id': targetId}) - .eq('id', car['id']); + if (widget.isOwner) { + await supabase + .from('hotwheels') + .update({'collection_id': targetId}) + .eq('id', car['id']); + } else { + final notes = car['notes'] as String?; + final imagePath = car['user_image_url'] as String?; + await supabase.from('hotwheels').insert({ + 'hw_id': car['hw_id'] as String, + 'user_id': supabase.auth.currentUser!.id, + 'collection_id': targetId, + if (notes != null && notes.trim().isNotEmpty) 'notes': notes, + if (imagePath != null && imagePath.isNotEmpty) + 'user_image_url': imagePath, + }); + } if (!mounted) return; if (sheetContext.mounted) Navigator.pop(sheetContext); - showGlobalSnackBar('${car['hw_id']} moved to another collection.'); + showGlobalSnackBar(widget.isOwner + ? '${car['hw_id']} moved to another collection.' + : '${car['hw_id']} copied to another collection.'); await _loadCars(reset: true); } catch (e) { - showGlobalSnackBar('Failed to move car: $e', isError: true); + showGlobalSnackBar( + widget.isOwner ? 'Failed to move car: $e' : 'Failed to copy car: $e', + isError: true, + ); } } Future _deleteCar( Map car, BuildContext sheetContext) async { + if (!widget.canModifyCars) { + showGlobalSnackBar('Viewer role is read-only for this collection.'); + return; + } + if (!sheetContext.mounted) return; final confirmed = await showDialog( context: sheetContext, diff --git a/lib/screens/manage_collection_screen.dart b/lib/screens/manage_collection_screen.dart index c8c2bbc..ecc2ea4 100644 --- a/lib/screens/manage_collection_screen.dart +++ b/lib/screens/manage_collection_screen.dart @@ -131,10 +131,12 @@ class _ManageCollectionScreenState extends State { Future _inviteMember() async { if (_isInviting) return; final emailCtrl = TextEditingController(); + String inviteRole = 'member'; final result = await showDialog( context: context, - builder: (_) => AlertDialog( + builder: (_) => StatefulBuilder( + builder: (context, setSheetState) => AlertDialog( icon: Container( padding: const EdgeInsets.all(12), decoration: const BoxDecoration( @@ -166,6 +168,22 @@ class _ManageCollectionScreenState extends State { prefixIcon: Icon(Icons.email_outlined), ), ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: inviteRole, + decoration: const InputDecoration( + labelText: 'Role', + prefixIcon: Icon(Icons.security_outlined), + ), + items: const [ + DropdownMenuItem(value: 'member', child: Text('Member (can add/copy/edit)')), + DropdownMenuItem(value: 'viewer', child: Text('Viewer (read-only)')), + ], + onChanged: (value) { + if (value == null) return; + setSheetState(() => inviteRole = value); + }, + ), ], ), actions: [ @@ -181,6 +199,7 @@ class _ManageCollectionScreenState extends State { child: const Text('Invite'), ), ], + ), ), ); @@ -198,8 +217,11 @@ class _ManageCollectionScreenState extends State { await CollectionService.inviteByEmail( collectionId: _collection.id, email: email, + role: inviteRole, + ); + showGlobalSnackBar( + inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!', ); - showGlobalSnackBar('Member invited!'); await _loadMembers(); } catch (e) { showGlobalSnackBar('$e', isError: true); @@ -387,7 +409,7 @@ class _ManageCollectionScreenState extends State { style: const TextStyle(fontWeight: FontWeight.w500), ), subtitle: Text( - member.isOwner ? 'Owner' : 'Member', + _roleLabel(member.role), style: const TextStyle(fontSize: 12), ), trailing: (!member.isOwner && @@ -459,4 +481,15 @@ class _ManageCollectionScreenState extends State { ), ); } + + String _roleLabel(String role) { + switch (role) { + case 'owner': + return 'Owner'; + case 'viewer': + return 'Viewer (read-only)'; + default: + return 'Member'; + } + } } diff --git a/lib/screens/scan_tab.dart b/lib/screens/scan_tab.dart index 6281fdd..778d243 100644 --- a/lib/screens/scan_tab.dart +++ b/lib/screens/scan_tab.dart @@ -24,6 +24,9 @@ class ScanTabState extends State { String? _lastProcessedHwId; DateTime? _lastProcessedAt; + bool get _canAddToSelectedCollection => + (_selectedCollection?.canModifyCars ?? false); + @override void initState() { super.initState(); @@ -175,6 +178,15 @@ class ScanTabState extends State { ), ), const SizedBox(height: 24), + if (_selectedCollection?.isViewer == true) + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text( + 'Viewer role is read-only. Choose an owner/member collection to add cars.', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.textSecondary), + ), + ), SizedBox( width: double.infinity, height: 56, @@ -191,7 +203,7 @@ class ScanTabState extends State { ], ), child: ElevatedButton.icon( - onPressed: _isBusy || _selectedCollection == null + onPressed: _isBusy || !_canAddToSelectedCollection ? null : _openScanner, icon: _isBusy @@ -226,7 +238,7 @@ class ScanTabState extends State { SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: _isBusy || _selectedCollection == null + onPressed: _isBusy || !_canAddToSelectedCollection ? null : _manualEntry, icon: const Icon(Icons.keyboard), diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart index a0a9eba..497067b 100644 --- a/lib/services/collection_service.dart +++ b/lib/services/collection_service.dart @@ -24,6 +24,9 @@ class Collection { }); bool get isOwner => role == 'owner'; + bool get isMember => role == 'member'; + bool get isViewer => role == 'viewer'; + bool get canModifyCars => isOwner || isMember; } /// Member of a collection. @@ -43,6 +46,7 @@ class CollectionMember { }); bool get isOwner => role == 'owner'; + bool get isViewer => role == 'viewer'; } /// Service for managing collections and membership. @@ -262,7 +266,13 @@ class CollectionService { static Future inviteByEmail({ required String collectionId, required String email, + String role = 'member', }) async { + final normalizedRole = role.trim().toLowerCase(); + if (normalizedRole != 'member' && normalizedRole != 'viewer') { + throw Exception('Unsupported role "$role".'); + } + final currentUserId = supabase.auth.currentUser!.id; final collection = await supabase @@ -309,7 +319,7 @@ class CollectionService { await supabase.from('collection_members').insert({ 'collection_id': collectionId, 'user_id': userId, - 'role': 'member', + 'role': normalizedRole, }); }