216 lines
7.3 KiB
Dart
216 lines
7.3 KiB
Dart
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import '../../database/database.dart';
|
|
import '../auth/auth_service.dart';
|
|
|
|
class CloudReconciliationResult {
|
|
final int added;
|
|
final int unchanged;
|
|
final int conflicts;
|
|
final int skipped;
|
|
|
|
const CloudReconciliationResult({
|
|
this.added = 0,
|
|
this.unchanged = 0,
|
|
this.conflicts = 0,
|
|
this.skipped = 0,
|
|
});
|
|
|
|
CloudReconciliationResult operator +(CloudReconciliationResult other) {
|
|
return CloudReconciliationResult(
|
|
added: added + other.added,
|
|
unchanged: unchanged + other.unchanged,
|
|
conflicts: conflicts + other.conflicts,
|
|
skipped: skipped + other.skipped,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Reconciles cloud ride metadata into the local Drift database without ever
|
|
/// replacing an existing local ride. Telemetry restoration is intentionally a
|
|
/// separate phase because R2 download/authentication is not available yet.
|
|
class CloudReconciliationService {
|
|
final AppDatabase _db;
|
|
final AuthService _authService;
|
|
final FirebaseFirestore _firestore;
|
|
|
|
CloudReconciliationService({
|
|
required AppDatabase db,
|
|
required AuthService authService,
|
|
FirebaseFirestore? firestore,
|
|
}) : _db = db,
|
|
_authService = authService,
|
|
_firestore = firestore ?? FirebaseFirestore.instance;
|
|
|
|
Future<CloudReconciliationResult> reconcileMetadata() async {
|
|
final user = _authService.currentUser;
|
|
if (user == null || user.isAnonymous) {
|
|
throw StateError('Authentication is required for cloud reconciliation.');
|
|
}
|
|
|
|
final snapshot = await _firestore
|
|
.collection('users')
|
|
.doc(user.uid)
|
|
.collection('rides')
|
|
.get();
|
|
|
|
var result = const CloudReconciliationResult();
|
|
for (final document in snapshot.docs) {
|
|
result += await _reconcileDocument(document, user.uid);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Future<CloudReconciliationResult> _reconcileDocument(
|
|
DocumentSnapshot<Map<String, dynamic>> document,
|
|
String ownerId,
|
|
) async {
|
|
final data = document.data();
|
|
if (data == null) {
|
|
return const CloudReconciliationResult(skipped: 1);
|
|
}
|
|
|
|
// Never trust a client-selected owner. Firestore rules are authoritative,
|
|
// and this client-side guard prevents accidental cross-account materialization.
|
|
final storedOwner = data['ownerId'] ?? data['uid'];
|
|
if (storedOwner != null && storedOwner != ownerId) {
|
|
return const CloudReconciliationResult(skipped: 1);
|
|
}
|
|
|
|
final cloudRideId = document.id;
|
|
if (!_isValidCloudRideId(cloudRideId)) {
|
|
return const CloudReconciliationResult(skipped: 1);
|
|
}
|
|
|
|
final localRows = await _db.customSelect(
|
|
'SELECT id, start_time, end_time, distance_miles, top_speed_mph, cloud_ride_id '
|
|
'FROM rides WHERE cloud_ride_id = ?',
|
|
variables: [Variable<String>(cloudRideId)],
|
|
).get();
|
|
|
|
if (localRows.isNotEmpty) {
|
|
final local = localRows.first.data;
|
|
if (_metadataDiffers(local, data)) {
|
|
// Conservative conflict policy: do not overwrite local data. A future
|
|
// revision-aware sync layer can resolve this explicitly using versions.
|
|
return const CloudReconciliationResult(conflicts: 1);
|
|
}
|
|
return const CloudReconciliationResult(unchanged: 1);
|
|
}
|
|
|
|
final localRideId = _asInt(data['localRideId']);
|
|
if (localRideId != null) {
|
|
final legacyRows = await _db.customSelect(
|
|
'SELECT id, cloud_ride_id FROM rides WHERE id = ?',
|
|
variables: [Variable<int>(localRideId)],
|
|
).get();
|
|
if (legacyRows.isNotEmpty) {
|
|
final existingCloudId = legacyRows.first.data['cloud_ride_id'];
|
|
if (existingCloudId == null || existingCloudId.toString().isEmpty) {
|
|
await _db.customStatement(
|
|
'UPDATE rides SET cloud_ride_id = ? WHERE id = ?',
|
|
[cloudRideId, localRideId],
|
|
);
|
|
return const CloudReconciliationResult(unchanged: 1);
|
|
}
|
|
if (existingCloudId == cloudRideId) {
|
|
return const CloudReconciliationResult(unchanged: 1);
|
|
}
|
|
return const CloudReconciliationResult(conflicts: 1);
|
|
}
|
|
}
|
|
|
|
final startTime = _readDateTime(data['startTime'] ?? data['startedAt']);
|
|
if (startTime == null) {
|
|
return const CloudReconciliationResult(skipped: 1);
|
|
}
|
|
|
|
final endTime = _readDateTime(data['endTime'] ?? data['endedAt']);
|
|
await _insertRestoredMetadata(
|
|
cloudRideId: cloudRideId,
|
|
startTime: startTime,
|
|
endTime: endTime,
|
|
data: data,
|
|
);
|
|
|
|
return const CloudReconciliationResult(added: 1);
|
|
}
|
|
|
|
Future<void> _insertRestoredMetadata({
|
|
required String cloudRideId,
|
|
required DateTime startTime,
|
|
required DateTime? endTime,
|
|
required Map<String, dynamic> data,
|
|
}) async {
|
|
await _db.customStatement('''
|
|
INSERT INTO rides (
|
|
cloud_ride_id,
|
|
start_time,
|
|
end_time,
|
|
distance_miles,
|
|
top_speed_mph,
|
|
max_lean_left,
|
|
max_lean_right,
|
|
max_accel_g,
|
|
max_braking_g,
|
|
distance,
|
|
average_speed,
|
|
top_speed,
|
|
elevation_gain,
|
|
elevation_loss,
|
|
motorcycle_id,
|
|
motorcycle_year,
|
|
motorcycle_make,
|
|
motorcycle_model,
|
|
motorcycle_variant
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''', [
|
|
cloudRideId,
|
|
startTime.toUtc().millisecondsSinceEpoch,
|
|
endTime?.toUtc().millisecondsSinceEpoch,
|
|
_asDouble(data['distanceMiles']),
|
|
_asDouble(data['topSpeedMph']),
|
|
_asDouble(data['maxLeanLeft']),
|
|
_asDouble(data['maxLeanRight']),
|
|
_asDouble(data['maxAccelG']),
|
|
_asDouble(data['maxBrakingG']),
|
|
_asDouble(data['distanceMiles']),
|
|
_asDouble(data['averageSpeed'] ?? data['averageSpeedMph']),
|
|
_asDouble(data['topSpeedMph']),
|
|
_asDouble(data['elevationGain']),
|
|
_asDouble(data['elevationLoss']),
|
|
_asInt(data['motorcycleId']),
|
|
_asInt(data['motorcycleYear']),
|
|
data['motorcycleMake']?.toString(),
|
|
data['motorcycleModel']?.toString(),
|
|
data['motorcycleVariant']?.toString(),
|
|
]);
|
|
}
|
|
|
|
bool _metadataDiffers(Map<String, dynamic> local, Map<String, dynamic> cloud) {
|
|
final localDistance = _asDouble(local['distance_miles']);
|
|
final cloudDistance = _asDouble(cloud['distanceMiles']);
|
|
final localTopSpeed = _asDouble(local['top_speed_mph']);
|
|
final cloudTopSpeed = _asDouble(cloud['topSpeedMph']);
|
|
|
|
// Only flag meaningful numeric divergence. Missing cloud fields are not
|
|
// treated as authoritative and therefore cannot overwrite local values.
|
|
if (cloudDistance != null && (localDistance - cloudDistance).abs() > 0.0001) return true;
|
|
if (cloudTopSpeed != null && (localTopSpeed - cloudTopSpeed).abs() > 0.0001) return true;
|
|
return false;
|
|
}
|
|
|
|
bool _isValidCloudRideId(String id) => RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(id);
|
|
|
|
int? _asInt(Object? value) => value is num ? value.toInt() : int.tryParse(value?.toString() ?? '');
|
|
|
|
double? _asDouble(Object? value) => value is num ? value.toDouble() : double.tryParse(value?.toString() ?? '');
|
|
|
|
DateTime? _readDateTime(Object? value) {
|
|
if (value is Timestamp) return value.toDate().toUtc();
|
|
if (value is DateTime) return value.toUtc();
|
|
if (value is num) return DateTime.fromMillisecondsSinceEpoch(value.toInt(), isUtc: true);
|
|
final parsed = DateTime.tryParse(value?.toString() ?? '');
|
|
return parsed?.toUtc();
|
|
}
|
|
}
|