225 lines
7.5 KiB
Dart
225 lines
7.5 KiB
Dart
import 'dart:async';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:firebase_core/firebase_core.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'handle_validator.dart';
|
|
import 'rider_profile_models.dart';
|
|
|
|
/// Service managing public rider profile persistence, unique handle reservations,
|
|
/// and privacy preferences in Cloud Firestore.
|
|
class RiderProfileService {
|
|
final FirebaseFirestore? _firestore;
|
|
|
|
RiderProfileService({FirebaseFirestore? firestore})
|
|
: _firestore = firestore ?? _resolveFirestore();
|
|
|
|
static FirebaseFirestore? _resolveFirestore() {
|
|
try {
|
|
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
bool get isAvailable => _firestore != null;
|
|
|
|
/// Fetches a rider's profile by UID (reads owner profile, falls back to public profile).
|
|
Future<RiderProfile?> getProfile(String uid) async {
|
|
final firestore = _firestore;
|
|
if (firestore == null) return null;
|
|
|
|
try {
|
|
final doc = await firestore.collection('users').doc(uid).get();
|
|
if (doc.exists && doc.data() != null) {
|
|
return RiderProfile.fromJson(doc.data()!);
|
|
}
|
|
return await getPublicProfile(uid);
|
|
} catch (e) {
|
|
// If reading private user doc fails (e.g. PERMISSION_DENIED for other user), read public projection
|
|
return await getPublicProfile(uid);
|
|
}
|
|
}
|
|
|
|
/// Fetches the public projection of a rider's profile.
|
|
Future<RiderProfile?> getPublicProfile(String uid) async {
|
|
final firestore = _firestore;
|
|
if (firestore == null) return null;
|
|
|
|
try {
|
|
final doc = await firestore.collection('public_profiles').doc(uid).get();
|
|
if (!doc.exists || doc.data() == null) return null;
|
|
return RiderProfile.fromPublicProfileJson(doc.data()!);
|
|
} catch (e) {
|
|
debugPrint('[RiderProfileService] Failed to load public profile for $uid: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Realtime stream of a rider's profile by UID.
|
|
Stream<RiderProfile?> profileStream(String uid) {
|
|
final firestore = _firestore;
|
|
if (firestore == null) return Stream.value(null);
|
|
|
|
return firestore
|
|
.collection('users')
|
|
.doc(uid)
|
|
.snapshots()
|
|
.map((snapshot) {
|
|
if (!snapshot.exists || snapshot.data() == null) return null;
|
|
try {
|
|
return RiderProfile.fromJson(snapshot.data()!);
|
|
} catch (e) {
|
|
debugPrint('[RiderProfileService] Stream parse error for $uid: $e');
|
|
return null;
|
|
}
|
|
})
|
|
.handleError((e) {
|
|
debugPrint('[RiderProfileService] Stream error for $uid: $e');
|
|
return null;
|
|
});
|
|
}
|
|
|
|
/// Checks if a normalized handle is available for reservation.
|
|
/// If [currentUid] is provided, returning true if the handle is already owned by [currentUid].
|
|
Future<bool> isHandleAvailable(String handle, {String? currentUid}) async {
|
|
final validation = HandleValidator.validate(handle);
|
|
if (!validation.isValid) return false;
|
|
|
|
final firestore = _firestore;
|
|
if (firestore == null) return true;
|
|
|
|
final normalized = validation.normalizedHandle!;
|
|
try {
|
|
final doc = await firestore.collection('handles').doc(normalized).get();
|
|
if (!doc.exists) return true;
|
|
|
|
final data = doc.data();
|
|
final ownerUid = data?['uid'] as String?;
|
|
return currentUid != null && ownerUid == currentUid;
|
|
} catch (e) {
|
|
debugPrint('[RiderProfileService] Error checking handle $normalized: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Saves or updates a rider's profile while atomically securing the handle reservation.
|
|
Future<RiderProfile> saveProfile({
|
|
required String uid,
|
|
required String handle,
|
|
required String displayName,
|
|
String bio = '',
|
|
String region = '',
|
|
String? avatarPreset,
|
|
String? avatarUrl,
|
|
ProfilePrivacySettings? privacySettings,
|
|
}) async {
|
|
final validation = HandleValidator.validate(handle);
|
|
if (!validation.isValid) {
|
|
throw ArgumentError(validation.error);
|
|
}
|
|
final normalizedHandle = validation.normalizedHandle!;
|
|
|
|
final firestore = _firestore;
|
|
final now = DateTime.now();
|
|
|
|
if (firestore == null) {
|
|
// Offline/local fallback
|
|
return RiderProfile(
|
|
uid: uid,
|
|
handle: normalizedHandle,
|
|
displayName: displayName.trim(),
|
|
bio: bio.trim(),
|
|
region: region.trim(),
|
|
avatarPreset: avatarPreset,
|
|
avatarUrl: avatarUrl,
|
|
privacySettings: privacySettings ?? const ProfilePrivacySettings(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
);
|
|
}
|
|
|
|
final userRef = firestore.collection('users').doc(uid);
|
|
final publicRef = firestore.collection('public_profiles').doc(uid);
|
|
final handleRef = firestore.collection('handles').doc(normalizedHandle);
|
|
|
|
// Read current user profile to determine previous handle if changing
|
|
final currentDoc = await userRef.get();
|
|
final existingProfile =
|
|
currentDoc.exists && currentDoc.data() != null
|
|
? RiderProfile.fromJson(currentDoc.data()!)
|
|
: null;
|
|
|
|
final previousHandle = existingProfile?.handle;
|
|
final isChangingHandle =
|
|
previousHandle != null && previousHandle != normalizedHandle;
|
|
|
|
final updatedProfile = RiderProfile(
|
|
uid: uid,
|
|
handle: normalizedHandle,
|
|
displayName: displayName.trim().isEmpty ? normalizedHandle : displayName.trim(),
|
|
bio: bio.trim(),
|
|
region: region.trim(),
|
|
avatarPreset: avatarPreset,
|
|
avatarUrl: avatarUrl,
|
|
privacySettings: privacySettings ?? existingProfile?.privacySettings ?? const ProfilePrivacySettings(),
|
|
createdAt: existingProfile?.createdAt ?? now,
|
|
updatedAt: now,
|
|
);
|
|
|
|
await firestore.runTransaction((transaction) async {
|
|
// 1. Verify handle availability
|
|
final handleSnapshot = await transaction.get(handleRef);
|
|
if (handleSnapshot.exists) {
|
|
final ownerUid = handleSnapshot.data()?['uid'] as String?;
|
|
if (ownerUid != uid) {
|
|
throw StateError('Handle @$normalizedHandle is already taken.');
|
|
}
|
|
}
|
|
|
|
// 2. Reserve new handle
|
|
transaction.set(handleRef, {
|
|
'handle': normalizedHandle,
|
|
'uid': uid,
|
|
'createdAt': FieldValue.serverTimestamp(),
|
|
});
|
|
|
|
// 3. Release previous handle if changed
|
|
if (isChangingHandle) {
|
|
final oldHandleRef = firestore.collection('handles').doc(previousHandle);
|
|
transaction.delete(oldHandleRef);
|
|
}
|
|
|
|
// 4. Save private user profile document
|
|
transaction.set(userRef, updatedProfile.toJson(), SetOptions(merge: true));
|
|
|
|
// 5. Save public profile projection
|
|
transaction.set(publicRef, updatedProfile.toPublicProfileJson(), SetOptions(merge: true));
|
|
});
|
|
|
|
final saved = await getProfile(uid);
|
|
return saved ?? updatedProfile;
|
|
}
|
|
|
|
/// Releases the rider's handle and cleans up the profile doc.
|
|
Future<void> deleteProfile(String uid) async {
|
|
final firestore = _firestore;
|
|
if (firestore == null) return;
|
|
|
|
try {
|
|
final userRef = firestore.collection('users').doc(uid);
|
|
final publicRef = firestore.collection('public_profiles').doc(uid);
|
|
final doc = await userRef.get();
|
|
if (doc.exists && doc.data() != null) {
|
|
final handle = doc.data()?['username'] as String?;
|
|
if (handle != null && handle.isNotEmpty) {
|
|
await firestore.collection('handles').doc(handle.toLowerCase()).delete();
|
|
}
|
|
}
|
|
await userRef.delete();
|
|
await publicRef.delete();
|
|
} catch (e) {
|
|
debugPrint('[RiderProfileService] Failed to delete profile for $uid: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|