191 lines
7.2 KiB
Dart
191 lines
7.2 KiB
Dart
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:drift/drift.dart';
|
|
import 'package:firebase_core/firebase_core.dart';
|
|
import '../../database/database.dart';
|
|
import '../auth/auth_service.dart';
|
|
import 'cloud_models.dart';
|
|
|
|
/// Synchronizes account/profile and motorcycle metadata independently of the
|
|
/// ride upload pipeline. This keeps identity data small and queryable while
|
|
/// leaving high-frequency telemetry in the existing ride/R2 path.
|
|
class FirebaseAccountSyncService {
|
|
final AppDatabase db;
|
|
final AuthService auth;
|
|
final FirebaseFirestore? _firestore;
|
|
|
|
FirebaseAccountSyncService({
|
|
required this.db,
|
|
required this.auth,
|
|
FirebaseFirestore? firestore,
|
|
}) : _firestore = firestore ?? _resolveFirestore();
|
|
|
|
static FirebaseFirestore? _resolveFirestore() {
|
|
try {
|
|
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
bool get isAvailable => _firestore != null && Firebase.apps.isNotEmpty;
|
|
|
|
DateTime? _parseDateTime(dynamic value) {
|
|
if (value == null) return null;
|
|
if (value is Timestamp) return value.toDate();
|
|
if (value is String) return DateTime.tryParse(value);
|
|
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
|
return null;
|
|
}
|
|
|
|
Future<void> syncAccount({DateTime? now}) async {
|
|
final user = auth.currentUser;
|
|
if (user == null || user.isAnonymous || !isAvailable) return;
|
|
|
|
final timestamp = (now ?? DateTime.now()).toUtc();
|
|
final profile = CloudUserProfile(
|
|
id: user.uid,
|
|
displayName: user.displayName,
|
|
profilePhotoUrl: user.photoUrl,
|
|
createdAt: user.createdAt?.toUtc() ?? timestamp,
|
|
updatedAt: timestamp,
|
|
);
|
|
|
|
final userRef = _firestore!.collection('users').doc(user.uid);
|
|
await userRef.set(profile.toJson(), SetOptions(merge: true));
|
|
|
|
// 1. Fetch remote motorcycles from Firestore and reconcile into local Drift DB
|
|
try {
|
|
final remoteSnapshot = await userRef.collection('motorcycles').get();
|
|
final localBikes = await db.select(db.garageMotorcycles).get();
|
|
final localById = {for (final b in localBikes) b.id.toString(): b};
|
|
final localByKey = {for (final b in localBikes) '${b.year}_${b.makeName}_${b.modelName}'.toLowerCase(): b};
|
|
|
|
for (final doc in remoteSnapshot.docs) {
|
|
final data = doc.data();
|
|
final docId = doc.id;
|
|
final year = (data['year'] as num?)?.toInt() ?? 2000;
|
|
final makeName = (data['makeName'] ?? '').toString();
|
|
final modelName = (data['modelName'] ?? '').toString();
|
|
final key = '${year}_${makeName}_$modelName'.toLowerCase();
|
|
|
|
final existing = localById[docId] ?? localByKey[key];
|
|
if (existing == null) {
|
|
final parsedId = int.tryParse(docId);
|
|
await db.into(db.garageMotorcycles).insert(
|
|
GarageMotorcyclesCompanion(
|
|
id: parsedId != null ? Value(parsedId) : const Value.absent(),
|
|
year: Value(year),
|
|
makeId: Value((data['makeId'] ?? parsedId?.toString() ?? '1').toString()),
|
|
makeName: Value(makeName),
|
|
modelId: Value((data['modelId'] ?? parsedId?.toString() ?? '1').toString()),
|
|
modelName: Value(modelName),
|
|
variantId: Value(data['variantId']?.toString()),
|
|
variantName: Value(data['variantName']?.toString()),
|
|
nickname: Value(data['nickname']?.toString()),
|
|
isDefault: Value(data['isDefault'] == true),
|
|
dateAdded: Value(_parseDateTime(data['createdAt']) ?? timestamp),
|
|
lastUpdated: Value(_parseDateTime(data['updatedAt']) ?? timestamp),
|
|
specificationJson: Value(data['specificationJson']?.toString()),
|
|
),
|
|
mode: InsertMode.insertOrReplace,
|
|
);
|
|
} else if (existing.specificationJson == null && data['specificationJson'] != null) {
|
|
await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing.id)))
|
|
.write(GarageMotorcyclesCompanion(
|
|
specificationJson: Value(data['specificationJson']?.toString()),
|
|
lastUpdated: Value(timestamp),
|
|
));
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Remote fetch error should not block local operations
|
|
}
|
|
|
|
// 2. Upload any local motorcycles to Firestore
|
|
final currentLocal = await db.select(db.garageMotorcycles).get();
|
|
for (final motorcycle in currentLocal) {
|
|
final cloud = CloudMotorcycle(
|
|
id: motorcycle.id.toString(),
|
|
ownerId: user.uid,
|
|
year: motorcycle.year,
|
|
makeId: motorcycle.makeId,
|
|
makeName: motorcycle.makeName,
|
|
modelId: motorcycle.modelId,
|
|
modelName: motorcycle.modelName,
|
|
variantId: motorcycle.variantId,
|
|
variantName: motorcycle.variantName,
|
|
nickname: motorcycle.nickname,
|
|
isDefault: motorcycle.isDefault,
|
|
createdAt: motorcycle.dateAdded.toUtc(),
|
|
updatedAt: motorcycle.lastUpdated.toUtc(),
|
|
specificationJson: motorcycle.specificationJson,
|
|
);
|
|
await userRef.collection('motorcycles').doc(cloud.id).set(
|
|
cloud.toJson(),
|
|
SetOptions(merge: true),
|
|
);
|
|
}
|
|
|
|
// 3. Fallback synthesis: If garage is empty but restored rides reference a motorcycle
|
|
if (currentLocal.isEmpty) {
|
|
await _synthesizeMotorcycleFromRidesIfEmpty(user.uid, userRef, timestamp);
|
|
}
|
|
}
|
|
|
|
Future<void> _synthesizeMotorcycleFromRidesIfEmpty(
|
|
String uid,
|
|
DocumentReference userRef,
|
|
DateTime timestamp,
|
|
) async {
|
|
try {
|
|
final ridesWithBikes = await (db.select(db.rides)
|
|
..where((r) => r.motorcycleMake.isNotNull() & r.motorcycleModel.isNotNull())
|
|
..orderBy([(r) => OrderingTerm(expression: r.startTime, mode: OrderingMode.desc)])
|
|
..limit(1))
|
|
.get();
|
|
|
|
if (ridesWithBikes.isNotEmpty) {
|
|
final r = ridesWithBikes.first;
|
|
final bikeId = r.motorcycleId ?? 1;
|
|
final year = r.motorcycleYear ?? 2001;
|
|
final make = r.motorcycleMake ?? 'Unknown';
|
|
final model = r.motorcycleModel ?? 'Unknown';
|
|
final variant = r.motorcycleVariant;
|
|
|
|
await db.into(db.garageMotorcycles).insert(
|
|
GarageMotorcyclesCompanion(
|
|
id: Value(bikeId),
|
|
year: Value(year),
|
|
makeId: Value(bikeId.toString()),
|
|
makeName: Value(make),
|
|
modelId: Value(bikeId.toString()),
|
|
modelName: Value(model),
|
|
variantName: Value(variant),
|
|
isDefault: const Value(true),
|
|
dateAdded: Value(timestamp),
|
|
lastUpdated: Value(timestamp),
|
|
),
|
|
mode: InsertMode.insertOrReplace,
|
|
);
|
|
|
|
final cloud = CloudMotorcycle(
|
|
id: bikeId.toString(),
|
|
ownerId: uid,
|
|
year: year,
|
|
makeId: bikeId.toString(),
|
|
makeName: make,
|
|
modelId: bikeId.toString(),
|
|
modelName: model,
|
|
variantName: variant,
|
|
isDefault: true,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
);
|
|
await userRef.collection('motorcycles').doc(cloud.id).set(
|
|
cloud.toJson(),
|
|
SetOptions(merge: true),
|
|
);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|