hwhub/lib/services/collection_service.dart
Lukas Müllner d8dbc2573a feat(v3.0): rebrand to car64 and align app flow with TPB schema
- Rename installed app display name to car64 (Android/iOS/Web titles)
- Remove Buy Me a Coffee link from About screen
- Update app version to 3.0.0+1
- Migrate scanner workflow to catalog-first flow:
  - look up in global_cars
  - show Found in Catalog bottom sheet for known cars
  - show New Discovery bottom sheet and insert into global_cars + car_votes
  - insert only into hotwheels for collection entries
- Align garage reads to TPB schema by joining global_cars
- Switch image field usage from image_url to user_image_url
- Implement private-storage image service:
  - upload path auth.uid()/hotwheels.id.jpg
  - signed URL generation (1h) for display
  - in-app compression pipeline targeting <=500KB and max 1080px width
- Use local brand fallback image assets/img/icon_bg_removed.png for missing car photos
- Restrict edit dialog to personal notes (hotwheels) instead of global car metadata
- Ensure first login auto-creates a default collection and owner membership
2026-03-04 10:12:12 +01:00

313 lines
8.8 KiB
Dart

//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._();
/// Ensures the current user has at least one collection membership.
/// Creates a default collection on first login.
static Future<void> ensureDefaultCollection() async {
final userId = supabase.auth.currentUser?.id;
if (userId == null) return;
final existing = await supabase
.from('collection_members')
.select('id')
.eq('user_id', userId)
.limit(1);
if (existing.isNotEmpty) return;
await create(
name: 'My Garage',
description: 'Your default collection',
);
}
/// Fetch all collections the current user is a member of,
/// including item count and member count.
static Future<List<Collection>> getMyCollections() async {
final userId = supabase.auth.currentUser!.id;
// Get memberships with collection data.
final memberships = await supabase
.from('collection_members')
.select('role, collections(id, name, description, owner_id, created_at)')
.eq('user_id', userId);
// If the user has no memberships, return early.
if (memberships.isEmpty) {
return <Collection>[];
}
// Collect all distinct collection IDs from memberships.
final collectionIds = <String>{};
for (final m in memberships) {
final c = m['collections'] as Map<String, dynamic>;
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')
.inFilter('collection_id', collectionIdList);
final itemCounts = <String, int>{};
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')
.inFilter('collection_id', collectionIdList);
final memberCounts = <String, int>{};
for (final member in members) {
final collectionId = member['collection_id'] as String;
memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1;
}
final collections = <Collection>[];
for (final m in memberships) {
final c = m['collections'] as Map<String, dynamic>;
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<Collection> create({
required String name,
String? description,
}) async {
final userId = supabase.auth.currentUser!.id;
final row = await supabase
.from('collections')
.insert({
'name': name,
'owner_id': userId,
if (description != null && description.isNotEmpty)
'description': description,
})
.select()
.single();
// Add owner as a member.
await supabase.from('collection_members').insert({
'collection_id': row['id'],
'user_id': userId,
'role': 'owner',
});
return Collection(
id: row['id'] as String,
name: row['name'] as String,
description: row['description'] as String?,
ownerId: userId,
createdAt: DateTime.parse(row['created_at'] as String),
role: 'owner',
itemCount: 0,
memberCount: 1,
);
}
/// Update a collection's name/description. Owner only.
static Future<void> update({
required String collectionId,
required String name,
String? description,
}) async {
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<void> 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<List<CollectionMember>> getMembers(
String collectionId) async {
final rows = await supabase.rpc('get_collection_members', params: {
'p_collection_id': collectionId,
});
final members = <CollectionMember>[];
for (final r in rows) {
members.add(CollectionMember(
id: r['id'] as String,
userId: r['user_id'] as String,
email: r['email'] as String? ?? 'unknown',
role: r['role'] as String,
joinedAt: DateTime.parse(r['created_at'] as String),
));
}
return members;
}
/// Invite a user by email. Looks up auth.users via an RPC function,
/// then inserts a collection_members row.
static Future<void> inviteByEmail({
required String collectionId,
required String email,
}) async {
// Call an RPC to look up the user ID by email.
final result = await supabase.rpc('get_user_id_by_email', params: {
'lookup_email': email.trim().toLowerCase(),
});
if (result == null || (result is List && result.isEmpty)) {
throw Exception(
'No user found with that email. They must create an account first.');
}
final userId = result is List ? result.first['id'] as String : result as String;
// Check if already a member.
final existing = await supabase
.from('collection_members')
.select('id')
.eq('collection_id', collectionId)
.eq('user_id', userId)
.maybeSingle();
if (existing != null) {
throw Exception('This user is already a member of this collection.');
}
await supabase.from('collection_members').insert({
'collection_id': collectionId,
'user_id': userId,
'role': 'member',
});
}
/// Remove a member from a collection.
static Future<void> removeMember({
required String collectionId,
required String membershipId,
}) async {
await supabase
.from('collection_members')
.delete()
.eq('id', membershipId);
}
/// Leave a collection (for non-owners).
static Future<void> leave(String collectionId) async {
final userId = supabase.auth.currentUser!.id;
await supabase
.from('collection_members')
.delete()
.eq('collection_id', collectionId)
.eq('user_id', userId);
}
}