346 lines
13 KiB
Dart
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 (_) {}
|
|
}
|
|
}
|