303 lines
9.8 KiB
Dart
303 lines
9.8 KiB
Dart
// ignore_for_file: type_init_formals
|
|
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:drift/drift.dart' show Variable;
|
|
import 'package:firebase_core/firebase_core.dart';
|
|
import '../../database/database.dart';
|
|
import '../../database/ride_timestamp_migration.dart';
|
|
import '../auth/auth_service.dart';
|
|
|
|
class CloudRideMetadataDocument {
|
|
final String id;
|
|
final Map<String, dynamic> data;
|
|
|
|
const CloudRideMetadataDocument({required this.id, required this.data});
|
|
}
|
|
|
|
abstract class CloudRideMetadataStore {
|
|
Future<List<CloudRideMetadataDocument>> listRideDocuments(String uid);
|
|
}
|
|
|
|
class FirestoreCloudRideMetadataStore implements CloudRideMetadataStore {
|
|
final FirebaseFirestore? _firestore;
|
|
|
|
FirestoreCloudRideMetadataStore({FirebaseFirestore? firestore})
|
|
: _firestore = firestore ?? _resolveFirestore();
|
|
|
|
static FirebaseFirestore? _resolveFirestore() {
|
|
try {
|
|
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<List<CloudRideMetadataDocument>> listRideDocuments(String uid) async {
|
|
final firestore = _firestore;
|
|
if (firestore == null) {
|
|
throw StateError('Firebase services are unavailable or unconfigured.');
|
|
}
|
|
final snapshot = await firestore
|
|
.collection('users')
|
|
.doc(uid)
|
|
.collection('rides')
|
|
.get();
|
|
return snapshot.docs
|
|
.map(
|
|
(document) =>
|
|
CloudRideMetadataDocument(id: document.id, data: document.data()),
|
|
)
|
|
.toList(growable: false);
|
|
}
|
|
}
|
|
|
|
class CloudReconciliationResult {
|
|
final int discovered;
|
|
final int added;
|
|
final int unchanged;
|
|
final int conflicts;
|
|
final int skipped;
|
|
|
|
const CloudReconciliationResult({
|
|
this.discovered = 0,
|
|
this.added = 0,
|
|
this.unchanged = 0,
|
|
this.conflicts = 0,
|
|
this.skipped = 0,
|
|
});
|
|
|
|
CloudReconciliationResult operator +(CloudReconciliationResult other) {
|
|
return CloudReconciliationResult(
|
|
discovered: discovered + other.discovered,
|
|
added: added + other.added,
|
|
unchanged: unchanged + other.unchanged,
|
|
conflicts: conflicts + other.conflicts,
|
|
skipped: skipped + other.skipped,
|
|
);
|
|
}
|
|
|
|
int get existing => unchanged + conflicts;
|
|
}
|
|
|
|
/// 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 CloudRideMetadataStore _metadataStore;
|
|
|
|
CloudReconciliationService({
|
|
required AppDatabase this._db,
|
|
required AuthService this._authService,
|
|
FirebaseFirestore? firestore,
|
|
CloudRideMetadataStore? metadataStore,
|
|
}) : _metadataStore =
|
|
metadataStore ??
|
|
FirestoreCloudRideMetadataStore(firestore: firestore);
|
|
|
|
Future<CloudReconciliationResult> reconcileMetadata() async {
|
|
final user = _authService.currentUser;
|
|
if (user == null || user.isAnonymous) {
|
|
throw StateError('Authentication is required for cloud reconciliation.');
|
|
}
|
|
|
|
final documents = await _metadataStore.listRideDocuments(user.uid);
|
|
|
|
var result = CloudReconciliationResult(discovered: documents.length);
|
|
for (final document in documents) {
|
|
result += await _reconcileDocument(document, user.uid);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Future<CloudReconciliationResult> _reconcileDocument(
|
|
CloudRideMetadataDocument document,
|
|
String ownerId,
|
|
) async {
|
|
final data = document.data;
|
|
|
|
// 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, start_time FROM rides WHERE id = ?',
|
|
variables: [Variable<int>(localRideId)],
|
|
)
|
|
.get();
|
|
if (legacyRows.isNotEmpty) {
|
|
final existingCloudId = legacyRows.first.data['cloud_ride_id'];
|
|
final startTime = _readDateTime(data['startTime'] ?? data['startedAt']);
|
|
final localStartTime = _readDateTime(legacyRows.first.data['start_time']);
|
|
|
|
final isSameRide = (existingCloudId == null ||
|
|
existingCloudId.toString().isEmpty ||
|
|
existingCloudId == cloudRideId) &&
|
|
startTime != null &&
|
|
localStartTime != null &&
|
|
(localStartTime.difference(startTime).inSeconds).abs() <= 5;
|
|
|
|
if (isSameRide) {
|
|
await _db.customStatement(
|
|
'UPDATE rides SET cloud_ride_id = ?, cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?',
|
|
[cloudRideId, ownerId, localRideId],
|
|
);
|
|
return const CloudReconciliationResult(unchanged: 1);
|
|
}
|
|
// If not the same ride, this is simply a device local-id collision.
|
|
// Fall through directly to insert the cloud ride.
|
|
}
|
|
}
|
|
|
|
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,
|
|
ownerId: ownerId,
|
|
startTime: startTime,
|
|
endTime: endTime,
|
|
data: data,
|
|
);
|
|
|
|
return const CloudReconciliationResult(added: 1);
|
|
}
|
|
|
|
Future<void> _insertRestoredMetadata({
|
|
required String cloudRideId,
|
|
required String ownerId,
|
|
required DateTime startTime,
|
|
required DateTime? endTime,
|
|
required Map<String, dynamic> data,
|
|
}) async {
|
|
await _db.customStatement(
|
|
'''
|
|
INSERT INTO rides (
|
|
cloud_ride_id,
|
|
cloud_owner_uid,
|
|
start_time,
|
|
end_time,
|
|
distance_miles,
|
|
top_speed_mph,
|
|
max_lean_left,
|
|
max_lean_right,
|
|
max_accel_g,
|
|
max_braking_g,
|
|
best0to60_ms,
|
|
best0to100_ms,
|
|
distance,
|
|
average_speed,
|
|
top_speed,
|
|
moving_time,
|
|
stopped_time,
|
|
elevation_gain,
|
|
elevation_loss,
|
|
motorcycle_id,
|
|
motorcycle_year,
|
|
motorcycle_make,
|
|
motorcycle_model,
|
|
motorcycle_variant
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''',
|
|
[
|
|
cloudRideId,
|
|
ownerId,
|
|
_db.typeMapping.mapToSqlVariable(startTime),
|
|
_db.typeMapping.mapToSqlVariable(endTime),
|
|
_asDouble(data['distanceMiles']) ?? 0.0,
|
|
_asDouble(data['topSpeedMph']) ?? 0.0,
|
|
_asDouble(data['maxLeanLeft']) ?? 0.0,
|
|
_asDouble(data['maxLeanRight']) ?? 0.0,
|
|
_asDouble(data['maxAccelG']) ?? 0.0,
|
|
_asDouble(data['maxBrakingG']) ?? 0.0,
|
|
_asDouble(data['best0to60Ms'] ?? data['best0to60_ms']),
|
|
_asDouble(data['best0to100Ms'] ?? data['best0to100_ms']),
|
|
_asDouble(data['distanceMiles']) ?? 0.0,
|
|
_asDouble(data['averageSpeed'] ?? data['averageSpeedMph']) ?? 0.0,
|
|
_asDouble(data['topSpeedMph']) ?? 0.0,
|
|
_asInt(data['movingTime']) ?? 0,
|
|
_asInt(data['stoppedTime']) ?? 0,
|
|
_asDouble(data['elevationGain']) ?? 0.0,
|
|
_asDouble(data['elevationLoss']) ?? 0.0,
|
|
_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 != null &&
|
|
(localDistance - cloudDistance).abs() > 0.0001) {
|
|
return true;
|
|
}
|
|
if (cloudTopSpeed != null &&
|
|
localTopSpeed != 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();
|
|
return readRideDateTime(value);
|
|
}
|
|
}
|