fix: enforce role-based collection actions and instant defaults
This commit is contained in:
parent
27bf33593e
commit
bebd21097d
5 changed files with 213 additions and 73 deletions
|
|
@ -29,15 +29,22 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
|||
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<CollectionsScreen>
|
|||
});
|
||||
|
||||
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<CollectionsScreen>
|
|||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<GarageScreen> createState() => GarageScreenState();
|
||||
}
|
||||
|
|
@ -154,13 +159,13 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
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<GarageScreen> {
|
|||
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<GarageScreen> {
|
|||
),
|
||||
),
|
||||
bottomNavigationBar: _selectionMode
|
||||
&& !widget.isViewer
|
||||
? SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
|
|
@ -370,6 +377,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
}
|
||||
|
||||
void _toggleSelectionMode(bool enabled) {
|
||||
if (widget.isViewer && enabled) return;
|
||||
setState(() {
|
||||
_selectionMode = enabled;
|
||||
if (!enabled) {
|
||||
|
|
@ -379,6 +387,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
}
|
||||
|
||||
void _toggleCarSelection(Map<String, dynamic> car) {
|
||||
if (widget.isViewer) return;
|
||||
final id = car['id'] as int;
|
||||
setState(() {
|
||||
_selectionMode = true;
|
||||
|
|
@ -395,6 +404,10 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
|
||||
Future<void> _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<GarageScreen> {
|
|||
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<GarageScreen> {
|
|||
|
||||
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<GarageScreen> {
|
|||
/// Take a new photo and update the image_url for this car.
|
||||
Future<void> _updatePhoto(
|
||||
Map<String, dynamic> 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<GarageScreen> {
|
|||
/// Open an edit dialog for this car, then update Supabase.
|
||||
Future<void> _editCar(
|
||||
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||
if (!widget.canModifyCars) {
|
||||
showGlobalSnackBar('Viewer role is read-only for this collection.');
|
||||
return;
|
||||
}
|
||||
|
||||
final updated = await showDialog<Map<String, dynamic>>(
|
||||
context: sheetContext,
|
||||
builder: (_) => _EditCarDialog(car: car),
|
||||
|
|
@ -922,7 +960,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
context: context,
|
||||
builder: (_) => StatefulBuilder(
|
||||
builder: (context, setSheetState) => AlertDialog(
|
||||
title: const Text('Move Car'),
|
||||
title: Text(widget.isOwner ? 'Move Car' : 'Copy Car'),
|
||||
content: DropdownButtonFormField<String>(
|
||||
initialValue: targetId,
|
||||
decoration: const InputDecoration(
|
||||
|
|
@ -947,7 +985,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
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<GarageScreen> {
|
|||
return targetId;
|
||||
}
|
||||
|
||||
Future<void> _moveSingleCar(
|
||||
Future<void> _relocateSingleCar(
|
||||
Map<String, dynamic> 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<void> _deleteCar(
|
||||
Map<String, dynamic> 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<bool>(
|
||||
context: sheetContext,
|
||||
|
|
|
|||
|
|
@ -131,10 +131,12 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
Future<void> _inviteMember() async {
|
||||
if (_isInviting) return;
|
||||
final emailCtrl = TextEditingController();
|
||||
String inviteRole = 'member';
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
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<ManageCollectionScreen> {
|
|||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
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<ManageCollectionScreen> {
|
|||
child: const Text('Invite'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -198,8 +217,11 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
|||
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<ManageCollectionScreen> {
|
|||
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<ManageCollectionScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _roleLabel(String role) {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'Owner';
|
||||
case 'viewer':
|
||||
return 'Viewer (read-only)';
|
||||
default:
|
||||
return 'Member';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ class ScanTabState extends State<ScanTab> {
|
|||
String? _lastProcessedHwId;
|
||||
DateTime? _lastProcessedAt;
|
||||
|
||||
bool get _canAddToSelectedCollection =>
|
||||
(_selectedCollection?.canModifyCars ?? false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -175,6 +178,15 @@ class ScanTabState extends State<ScanTab> {
|
|||
),
|
||||
),
|
||||
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<ScanTab> {
|
|||
],
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isBusy || _selectedCollection == null
|
||||
onPressed: _isBusy || !_canAddToSelectedCollection
|
||||
? null
|
||||
: _openScanner,
|
||||
icon: _isBusy
|
||||
|
|
@ -226,7 +238,7 @@ class ScanTabState extends State<ScanTab> {
|
|||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isBusy || _selectedCollection == null
|
||||
onPressed: _isBusy || !_canAddToSelectedCollection
|
||||
? null
|
||||
: _manualEntry,
|
||||
icon: const Icon(Icons.keyboard),
|
||||
|
|
|
|||
|
|
@ -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<void> 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue