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() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
MainCollectionSync.changeToken.addListener(_handleSyncChanged);
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
MainCollectionSync.changeToken.removeListener(_handleSyncChanged);
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleSyncChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
|
@ -61,7 +68,11 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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 prefs = await SharedPreferences.getInstance();
|
||||||
final persisted = prefs.getString(_activeCollectionPrefKey);
|
final persisted = prefs.getString(_activeCollectionPrefKey);
|
||||||
|
|
||||||
|
|
@ -184,7 +195,7 @@ class CollectionsScreenState extends State<CollectionsScreen>
|
||||||
builder: (_) => GarageScreen(
|
builder: (_) => GarageScreen(
|
||||||
collectionId: c.id,
|
collectionId: c.id,
|
||||||
collectionName: c.name,
|
collectionName: c.name,
|
||||||
isOwner: c.isOwner,
|
userRole: c.role,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).then((_) => _load()); // refresh counts when coming back
|
).then((_) => _load()); // refresh counts when coming back
|
||||||
|
|
@ -446,17 +457,25 @@ class _CollectionCard extends StatelessWidget {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: c.isOwner
|
color: c.isOwner
|
||||||
? AppColors.orange.withValues(alpha: 0.15)
|
? 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),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
c.isOwner ? 'Owner' : 'Member',
|
c.isOwner
|
||||||
|
? 'Owner'
|
||||||
|
: c.isViewer
|
||||||
|
? 'Viewer'
|
||||||
|
: 'Member',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: c.isOwner
|
color: c.isOwner
|
||||||
? AppColors.orange
|
? AppColors.orange
|
||||||
: AppColors.navy,
|
: c.isViewer
|
||||||
|
? AppColors.textSecondary
|
||||||
|
: AppColors.navy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,20 @@ import '../widgets/car_card.dart';
|
||||||
class GarageScreen extends StatefulWidget {
|
class GarageScreen extends StatefulWidget {
|
||||||
final String collectionId;
|
final String collectionId;
|
||||||
final String collectionName;
|
final String collectionName;
|
||||||
final bool isOwner;
|
final String userRole;
|
||||||
|
|
||||||
const GarageScreen({
|
const GarageScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.collectionId,
|
required this.collectionId,
|
||||||
required this.collectionName,
|
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
|
@override
|
||||||
State<GarageScreen> createState() => GarageScreenState();
|
State<GarageScreen> createState() => GarageScreenState();
|
||||||
}
|
}
|
||||||
|
|
@ -154,13 +159,13 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
expandedHeight: 140,
|
expandedHeight: 140,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
actions: [
|
actions: [
|
||||||
if (_selectionMode)
|
if (_selectionMode && !widget.isViewer)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Cancel Selection',
|
tooltip: 'Cancel Selection',
|
||||||
onPressed: () => _toggleSelectionMode(false),
|
onPressed: () => _toggleSelectionMode(false),
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
)
|
)
|
||||||
else
|
else if (!widget.isViewer)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Select Cars',
|
tooltip: 'Select Cars',
|
||||||
onPressed: () => _toggleSelectionMode(true),
|
onPressed: () => _toggleSelectionMode(true),
|
||||||
|
|
@ -306,7 +311,8 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
onTap: () => _selectionMode
|
onTap: () => _selectionMode
|
||||||
? _toggleCarSelection(car)
|
? _toggleCarSelection(car)
|
||||||
: _showCarDetails(car),
|
: _showCarDetails(car),
|
||||||
onLongPress: () => _toggleCarSelection(car),
|
onLongPress:
|
||||||
|
widget.isViewer ? null : () => _toggleCarSelection(car),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: _filteredCars.length,
|
childCount: _filteredCars.length,
|
||||||
|
|
@ -324,6 +330,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: _selectionMode
|
bottomNavigationBar: _selectionMode
|
||||||
|
&& !widget.isViewer
|
||||||
? SafeArea(
|
? SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -370,6 +377,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleSelectionMode(bool enabled) {
|
void _toggleSelectionMode(bool enabled) {
|
||||||
|
if (widget.isViewer && enabled) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectionMode = enabled;
|
_selectionMode = enabled;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
|
@ -379,6 +387,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleCarSelection(Map<String, dynamic> car) {
|
void _toggleCarSelection(Map<String, dynamic> car) {
|
||||||
|
if (widget.isViewer) return;
|
||||||
final id = car['id'] as int;
|
final id = car['id'] as int;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectionMode = true;
|
_selectionMode = true;
|
||||||
|
|
@ -395,6 +404,10 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
|
|
||||||
Future<void> _relocateSelectedCars() async {
|
Future<void> _relocateSelectedCars() async {
|
||||||
if (_selectedIds.isEmpty) return;
|
if (_selectedIds.isEmpty) return;
|
||||||
|
if (widget.isViewer) {
|
||||||
|
showGlobalSnackBar('Viewer role is read-only for this collection.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final isOwner = widget.isOwner;
|
final isOwner = widget.isOwner;
|
||||||
|
|
@ -593,25 +606,26 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Change / Add photo button
|
// Change / Add photo button
|
||||||
Align(
|
if (widget.canModifyCars) ...[
|
||||||
alignment: Alignment.centerRight,
|
Align(
|
||||||
child: TextButton.icon(
|
alignment: Alignment.centerRight,
|
||||||
onPressed: () => _updatePhoto(car, context),
|
child: TextButton.icon(
|
||||||
icon: Icon(
|
onPressed: () => _updatePhoto(car, context),
|
||||||
(car['user_image_url'] as String?) != null
|
icon: Icon(
|
||||||
? Icons.camera_alt
|
(car['user_image_url'] as String?) != null
|
||||||
: Icons.add_a_photo,
|
? Icons.camera_alt
|
||||||
size: 18,
|
: Icons.add_a_photo,
|
||||||
),
|
size: 18,
|
||||||
label: Text(
|
),
|
||||||
(car['user_image_url'] as String?) != null
|
label: Text(
|
||||||
? 'Change Photo'
|
(car['user_image_url'] as String?) != null
|
||||||
: 'Add Photo',
|
? 'Change Photo'
|
||||||
|
: 'Add Photo',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
const SizedBox(height: 4),
|
|
||||||
|
|
||||||
// ID badge
|
// ID badge
|
||||||
Align(
|
Align(
|
||||||
|
|
@ -693,41 +707,55 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Edit & Delete buttons
|
if (widget.canModifyCars) ...[
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _moveSingleCar(car, context),
|
onPressed: () => _relocateSingleCar(car, context),
|
||||||
icon: const Icon(Icons.drive_file_move_outline, size: 18),
|
icon: Icon(
|
||||||
label: const Text('Move'),
|
widget.isOwner
|
||||||
|
? Icons.drive_file_move_outline
|
||||||
|
: Icons.copy_outlined,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(widget.isOwner ? 'Move' : 'Copy'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
Expanded(
|
||||||
Expanded(
|
child: ElevatedButton.icon(
|
||||||
child: ElevatedButton.icon(
|
onPressed: () => _editCar(car, context),
|
||||||
onPressed: () => _editCar(car, context),
|
icon: const Icon(Icons.edit, size: 18),
|
||||||
icon: const Icon(Icons.edit, size: 18),
|
label: const Text('Edit'),
|
||||||
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.
|
/// Take a new photo and update the image_url for this car.
|
||||||
Future<void> _updatePhoto(
|
Future<void> _updatePhoto(
|
||||||
Map<String, dynamic> car, BuildContext sheetContext) async {
|
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||||
|
if (!widget.canModifyCars) {
|
||||||
|
showGlobalSnackBar('Viewer role is read-only for this collection.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
final xFile = await picker.pickImage(
|
final xFile = await picker.pickImage(
|
||||||
source: ImageSource.camera,
|
source: ImageSource.camera,
|
||||||
|
|
@ -804,6 +837,11 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
/// Open an edit dialog for this car, then update Supabase.
|
/// Open an edit dialog for this car, then update Supabase.
|
||||||
Future<void> _editCar(
|
Future<void> _editCar(
|
||||||
Map<String, dynamic> car, BuildContext sheetContext) async {
|
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>>(
|
final updated = await showDialog<Map<String, dynamic>>(
|
||||||
context: sheetContext,
|
context: sheetContext,
|
||||||
builder: (_) => _EditCarDialog(car: car),
|
builder: (_) => _EditCarDialog(car: car),
|
||||||
|
|
@ -922,7 +960,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => StatefulBuilder(
|
builder: (_) => StatefulBuilder(
|
||||||
builder: (context, setSheetState) => AlertDialog(
|
builder: (context, setSheetState) => AlertDialog(
|
||||||
title: const Text('Move Car'),
|
title: Text(widget.isOwner ? 'Move Car' : 'Copy Car'),
|
||||||
content: DropdownButtonFormField<String>(
|
content: DropdownButtonFormField<String>(
|
||||||
initialValue: targetId,
|
initialValue: targetId,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
|
|
@ -947,7 +985,7 @@ class GarageScreenState extends State<GarageScreen> {
|
||||||
onPressed: targetId == null
|
onPressed: targetId == null
|
||||||
? null
|
? null
|
||||||
: () => Navigator.pop(context, true),
|
: () => 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;
|
return targetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _moveSingleCar(
|
Future<void> _relocateSingleCar(
|
||||||
Map<String, dynamic> car, BuildContext sheetContext) async {
|
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||||
|
if (widget.isViewer) {
|
||||||
|
showGlobalSnackBar('Viewer role is read-only for this collection.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final targetId = await _pickTargetCollection();
|
final targetId = await _pickTargetCollection();
|
||||||
if (targetId == null) return;
|
if (targetId == null) return;
|
||||||
|
|
||||||
await supabase
|
if (widget.isOwner) {
|
||||||
.from('hotwheels')
|
await supabase
|
||||||
.update({'collection_id': targetId})
|
.from('hotwheels')
|
||||||
.eq('id', car['id']);
|
.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 (!mounted) return;
|
||||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
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);
|
await _loadCars(reset: true);
|
||||||
} catch (e) {
|
} 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(
|
Future<void> _deleteCar(
|
||||||
Map<String, dynamic> car, BuildContext sheetContext) async {
|
Map<String, dynamic> car, BuildContext sheetContext) async {
|
||||||
|
if (!widget.canModifyCars) {
|
||||||
|
showGlobalSnackBar('Viewer role is read-only for this collection.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sheetContext.mounted) return;
|
if (!sheetContext.mounted) return;
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: sheetContext,
|
context: sheetContext,
|
||||||
|
|
|
||||||
|
|
@ -131,10 +131,12 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
Future<void> _inviteMember() async {
|
Future<void> _inviteMember() async {
|
||||||
if (_isInviting) return;
|
if (_isInviting) return;
|
||||||
final emailCtrl = TextEditingController();
|
final emailCtrl = TextEditingController();
|
||||||
|
String inviteRole = 'member';
|
||||||
|
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => AlertDialog(
|
builder: (_) => StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) => AlertDialog(
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
|
|
@ -166,6 +168,22 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
prefixIcon: Icon(Icons.email_outlined),
|
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: [
|
actions: [
|
||||||
|
|
@ -181,6 +199,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
child: const Text('Invite'),
|
child: const Text('Invite'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -198,8 +217,11 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
await CollectionService.inviteByEmail(
|
await CollectionService.inviteByEmail(
|
||||||
collectionId: _collection.id,
|
collectionId: _collection.id,
|
||||||
email: email,
|
email: email,
|
||||||
|
role: inviteRole,
|
||||||
|
);
|
||||||
|
showGlobalSnackBar(
|
||||||
|
inviteRole == 'viewer' ? 'Viewer invited!' : 'Member invited!',
|
||||||
);
|
);
|
||||||
showGlobalSnackBar('Member invited!');
|
|
||||||
await _loadMembers();
|
await _loadMembers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showGlobalSnackBar('$e', isError: true);
|
showGlobalSnackBar('$e', isError: true);
|
||||||
|
|
@ -387,7 +409,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
member.isOwner ? 'Owner' : 'Member',
|
_roleLabel(member.role),
|
||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
trailing: (!member.isOwner &&
|
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;
|
String? _lastProcessedHwId;
|
||||||
DateTime? _lastProcessedAt;
|
DateTime? _lastProcessedAt;
|
||||||
|
|
||||||
|
bool get _canAddToSelectedCollection =>
|
||||||
|
(_selectedCollection?.canModifyCars ?? false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -175,6 +178,15 @@ class ScanTabState extends State<ScanTab> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 56,
|
height: 56,
|
||||||
|
|
@ -191,7 +203,7 @@ class ScanTabState extends State<ScanTab> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isBusy || _selectedCollection == null
|
onPressed: _isBusy || !_canAddToSelectedCollection
|
||||||
? null
|
? null
|
||||||
: _openScanner,
|
: _openScanner,
|
||||||
icon: _isBusy
|
icon: _isBusy
|
||||||
|
|
@ -226,7 +238,7 @@ class ScanTabState extends State<ScanTab> {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _isBusy || _selectedCollection == null
|
onPressed: _isBusy || !_canAddToSelectedCollection
|
||||||
? null
|
? null
|
||||||
: _manualEntry,
|
: _manualEntry,
|
||||||
icon: const Icon(Icons.keyboard),
|
icon: const Icon(Icons.keyboard),
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ class Collection {
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isOwner => role == 'owner';
|
bool get isOwner => role == 'owner';
|
||||||
|
bool get isMember => role == 'member';
|
||||||
|
bool get isViewer => role == 'viewer';
|
||||||
|
bool get canModifyCars => isOwner || isMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Member of a collection.
|
/// Member of a collection.
|
||||||
|
|
@ -43,6 +46,7 @@ class CollectionMember {
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isOwner => role == 'owner';
|
bool get isOwner => role == 'owner';
|
||||||
|
bool get isViewer => role == 'viewer';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service for managing collections and membership.
|
/// Service for managing collections and membership.
|
||||||
|
|
@ -262,7 +266,13 @@ class CollectionService {
|
||||||
static Future<void> inviteByEmail({
|
static Future<void> inviteByEmail({
|
||||||
required String collectionId,
|
required String collectionId,
|
||||||
required String email,
|
required String email,
|
||||||
|
String role = 'member',
|
||||||
}) async {
|
}) async {
|
||||||
|
final normalizedRole = role.trim().toLowerCase();
|
||||||
|
if (normalizedRole != 'member' && normalizedRole != 'viewer') {
|
||||||
|
throw Exception('Unsupported role "$role".');
|
||||||
|
}
|
||||||
|
|
||||||
final currentUserId = supabase.auth.currentUser!.id;
|
final currentUserId = supabase.auth.currentUser!.id;
|
||||||
|
|
||||||
final collection = await supabase
|
final collection = await supabase
|
||||||
|
|
@ -309,7 +319,7 @@ class CollectionService {
|
||||||
await supabase.from('collection_members').insert({
|
await supabase.from('collection_members').insert({
|
||||||
'collection_id': collectionId,
|
'collection_id': collectionId,
|
||||||
'user_id': userId,
|
'user_id': userId,
|
||||||
'role': 'member',
|
'role': normalizedRole,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue