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

346 lines
13 KiB
Dart

import 'dart:convert';
import 'dart:math' as math;
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:drift/drift.dart';
import 'package:firebase_core/firebase_core.dart';
import '../../database/database.dart';
import '../../models/maintenance_models.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;
final bool _customFirestore;
FirebaseAccountSyncService({
required this.db,
required this.auth,
FirebaseFirestore? firestore,
}) : _customFirestore = firestore != null,
_firestore = firestore ?? _resolveFirestore();
static FirebaseFirestore? _resolveFirestore() {
try {
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
} catch (_) {}
return null;
}
bool get isAvailable =>
_firestore != null && (_customFirestore || Firebase.apps.isNotEmpty);
/// Extracts the stored cloudId from a motorcycle's specificationJson metadata.
static String? extractCloudId(String? specJson) {
if (specJson == null || specJson.isEmpty) return null;
try {
final decoded = json.decode(specJson);
if (decoded is Map<String, dynamic>) {
final id = decoded['cloudId']?.toString();
if (id != null && id.isNotEmpty) return id;
}
} catch (_) {}
return null;
}
/// Injects or updates cloudId inside specificationJson without altering other specs.
static String attachCloudId(String? specJson, String cloudId) {
Map<String, dynamic> map = {};
if (specJson != null && specJson.isNotEmpty) {
try {
final decoded = json.decode(specJson);
if (decoded is Map<String, dynamic>) {
map = Map<String, dynamic>.from(decoded);
}
} catch (_) {}
}
map['cloudId'] = cloudId;
return json.encode(map);
}
/// Merges local and remote specificationJson, preserving maintenance records
/// from both devices (deduplicated by record id), retaining the highest odometer,
/// and attaching the authoritative cloudId.
static String mergeSpecificationJson({
required String? localSpecJson,
required String? remoteSpecJson,
required String cloudId,
}) {
final localData = MaintenanceData.fromSpecificationJson(localSpecJson);
final remoteData = MaintenanceData.fromSpecificationJson(remoteSpecJson);
// Merge service records deduplicated by id
final recordMap = <String, ServiceRecord>{};
for (final r in localData.serviceRecords) {
recordMap[r.id] = r;
}
for (final r in remoteData.serviceRecords) {
recordMap[r.id] = r;
}
final mergedRecords = recordMap.values.toList()
..sort((a, b) => b.odometer.compareTo(a.odometer));
// Highest base odometer
final mergedBaseOdo = math.max(localData.baseOdometer, remoteData.baseOdometer);
// Merged custom intervals
final mergedIntervals = <String, int>{
...localData.customIntervals,
...remoteData.customIntervals,
};
// Combine base spec map
Map<String, dynamic> baseMap = {};
if (localSpecJson != null && localSpecJson.isNotEmpty) {
try {
final dec = json.decode(localSpecJson);
if (dec is Map<String, dynamic>) baseMap = Map<String, dynamic>.from(dec);
} catch (_) {}
} else if (remoteSpecJson != null && remoteSpecJson.isNotEmpty) {
try {
final dec = json.decode(remoteSpecJson);
if (dec is Map<String, dynamic>) baseMap = Map<String, dynamic>.from(dec);
} catch (_) {}
}
baseMap['cloudId'] = cloudId;
baseMap['baseOdometer'] = mergedBaseOdo;
baseMap['serviceRecords'] = mergedRecords.map((s) => s.toJson()).toList();
baseMap['customIntervals'] = mergedIntervals;
return json.encode(baseMap);
}
/// Generates a globally unique stable cloud document ID for a motorcycle.
static String generateCloudMotorcycleId(
String uid,
int localId,
int year,
String make,
String model,
) {
final prefix = uid.length > 8 ? uid.substring(0, 8) : uid;
final slugMake = make.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '_');
final slugModel = model.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '_');
return 'moto_${prefix}_${localId}_${year}_${slugMake}_$slugModel';
}
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 safely reconcile into local Drift DB
try {
final remoteSnapshot = await userRef.collection('motorcycles').get();
final localBikes = await db.select(db.garageMotorcycles).get();
// Index local motorcycles by stored cloudId and normalized vehicle attributes
final localByCloudId = <String, GarageMotorcycle>{};
final localByAttributes = <String, GarageMotorcycle>{};
for (final b in localBikes) {
final cid = extractCloudId(b.specificationJson);
if (cid != null && cid.isNotEmpty) {
localByCloudId[cid] = b;
}
final attrKey = '${b.year}_${b.makeName.trim()}_${b.modelName.trim()}'.toLowerCase();
localByAttributes[attrKey] = 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 attrKey = '${year}_${makeName.trim()}_${modelName.trim()}'.toLowerCase();
// Cross-device reconciliation: match by cloudId first, then by vehicle attributes.
// Never match different motorcycles that share an identical local integer ID!
GarageMotorcycle? existing = localByCloudId[docId] ?? localByAttributes[attrKey];
if (existing == null) {
final parsedId = int.tryParse(docId);
if (parsedId != null) {
final cand = localBikes.where((b) => b.id == parsedId).firstOrNull;
if (cand != null) {
final candKey = '${cand.year}_${cand.makeName.trim()}_${cand.modelName.trim()}'.toLowerCase();
if (candKey == attrKey) {
existing = cand;
}
}
}
}
if (existing == null) {
// Brand new motorcycle from remote device or website:
// Insert with auto-increment local ID (never overwrite existing local bike!)
final specWithCloudId = attachCloudId(data['specificationJson']?.toString(), docId);
await db.into(db.garageMotorcycles).insert(
GarageMotorcyclesCompanion(
id: const Value.absent(), // Let SQLite assign fresh local ID to prevent collisions!
year: Value(year),
makeId: Value((data['makeId'] ?? '1').toString()),
makeName: Value(makeName),
modelId: Value((data['modelId'] ?? '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 && localBikes.isEmpty),
dateAdded: Value(_parseDateTime(data['createdAt']) ?? timestamp),
lastUpdated: Value(_parseDateTime(data['updatedAt']) ?? timestamp),
specificationJson: Value(specWithCloudId),
),
);
} else {
// Existing motorcycle: link cloudId and merge specifications/maintenance
final updatedSpec = mergeSpecificationJson(
localSpecJson: existing.specificationJson,
remoteSpecJson: data['specificationJson']?.toString(),
cloudId: docId,
);
await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing!.id)))
.write(GarageMotorcyclesCompanion(
specificationJson: Value(updatedSpec),
lastUpdated: Value(timestamp),
));
}
}
} catch (_) {
// Remote fetch error should not block local operations
}
// 2. Upload any local motorcycles to Firestore with stable unique cloud IDs
final currentLocal = await db.select(db.garageMotorcycles).get();
for (final motorcycle in currentLocal) {
var cloudId = extractCloudId(motorcycle.specificationJson);
if (cloudId == null || cloudId.isEmpty) {
cloudId = generateCloudMotorcycleId(
user.uid,
motorcycle.id,
motorcycle.year,
motorcycle.makeName,
motorcycle.modelName,
);
// Persist generated cloudId locally in specificationJson
final updatedSpec = attachCloudId(motorcycle.specificationJson, cloudId);
await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(motorcycle.id)))
.write(GarageMotorcyclesCompanion(
specificationJson: Value(updatedSpec),
));
}
final cloud = CloudMotorcycle(
id: cloudId,
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;
final cloudId = generateCloudMotorcycleId(uid, bikeId, year, make, model);
final specWithCloudId = attachCloudId(null, cloudId);
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),
specificationJson: Value(specWithCloudId),
),
mode: InsertMode.insertOrReplace,
);
final cloud = CloudMotorcycle(
id: cloudId,
ownerId: uid,
year: year,
makeId: bikeId.toString(),
makeName: make,
modelId: bikeId.toString(),
modelName: model,
variantName: variant,
isDefault: true,
createdAt: timestamp,
updatedAt: timestamp,
specificationJson: specWithCloudId,
);
await userRef.collection('motorcycles').doc(cloud.id).set(
cloud.toJson(),
SetOptions(merge: true),
);
}
} catch (_) {}
}
}