MotorSignal-App/lib/services/cloud/firebase_account_sync_servi...

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 (_) {}
}
}