fix: isolate MotorCloud ride ownership across accounts
This commit is contained in:
parent
8393849f39
commit
353b6fb66c
|
|
@ -18,7 +18,7 @@ class AppDatabase extends _$AppDatabase {
|
|||
static final Random _random = Random.secure();
|
||||
|
||||
@override
|
||||
int get schemaVersion => 6;
|
||||
int get schemaVersion => 7;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
|
|
@ -91,6 +91,9 @@ class AppDatabase extends _$AppDatabase {
|
|||
await customStatement('UPDATE rides SET cloud_ride_id = ? WHERE id = ?', [legacyId, id]);
|
||||
}
|
||||
}
|
||||
if (from < 7) {
|
||||
await m.addColumn(rides, rides.cloudOwnerUid);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -161,6 +164,25 @@ class AppDatabase extends _$AppDatabase {
|
|||
));
|
||||
}
|
||||
|
||||
Future<String?> getCloudOwnerUid(int rideId) async {
|
||||
final rows = await customSelect(
|
||||
'SELECT cloud_owner_uid FROM rides WHERE id = ?',
|
||||
variables: [Variable<int>(rideId)],
|
||||
).get();
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first.data['cloud_owner_uid'] as String?;
|
||||
}
|
||||
|
||||
/// Claims only an unclaimed ride. Existing ownership is immutable.
|
||||
Future<bool> claimRideForCloudOwner(int rideId, String uid) async {
|
||||
final normalizedUid = uid.trim();
|
||||
if (normalizedUid.isEmpty) return false;
|
||||
final changed = await (update(rides)
|
||||
..where((r) => r.id.equals(rideId) & r.cloudOwnerUid.isNull()))
|
||||
.write(RidesCompanion(cloudOwnerUid: Value(normalizedUid)));
|
||||
return changed == 1 || await getCloudOwnerUid(rideId) == normalizedUid;
|
||||
}
|
||||
|
||||
Future<void> endRide(int rideId, RidesCompanion data) async {
|
||||
await (update(rides)..where((t) => t.id.equals(rideId))).write(data.copyWith(endTime: Value(DateTime.now())));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,17 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> {
|
|||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _cloudOwnerUidMeta = const VerificationMeta(
|
||||
'cloudOwnerUid',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> cloudOwnerUid = GeneratedColumn<String>(
|
||||
'cloud_owner_uid',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _startTimeMeta = const VerificationMeta(
|
||||
'startTime',
|
||||
);
|
||||
|
|
@ -292,6 +303,7 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> {
|
|||
List<GeneratedColumn> get $columns => [
|
||||
id,
|
||||
cloudRideId,
|
||||
cloudOwnerUid,
|
||||
startTime,
|
||||
endTime,
|
||||
distanceMiles,
|
||||
|
|
@ -339,6 +351,15 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> {
|
|||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('cloud_owner_uid')) {
|
||||
context.handle(
|
||||
_cloudOwnerUidMeta,
|
||||
cloudOwnerUid.isAcceptableOrUnknown(
|
||||
data['cloud_owner_uid']!,
|
||||
_cloudOwnerUidMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('start_time')) {
|
||||
context.handle(
|
||||
_startTimeMeta,
|
||||
|
|
@ -538,6 +559,10 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> {
|
|||
DriftSqlType.string,
|
||||
data['${effectivePrefix}cloud_ride_id'],
|
||||
),
|
||||
cloudOwnerUid: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}cloud_owner_uid'],
|
||||
),
|
||||
startTime: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}start_time'],
|
||||
|
|
@ -640,6 +665,9 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
|
||||
/// Stable MotorCloud identity preserved across local IDs, reinstalls, and sync retries.
|
||||
final String? cloudRideId;
|
||||
|
||||
/// Firebase UID that owns this ride in MotorCloud. Null means device-only.
|
||||
final String? cloudOwnerUid;
|
||||
final DateTime startTime;
|
||||
final DateTime? endTime;
|
||||
final double distanceMiles;
|
||||
|
|
@ -665,6 +693,7 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
const Ride({
|
||||
required this.id,
|
||||
this.cloudRideId,
|
||||
this.cloudOwnerUid,
|
||||
required this.startTime,
|
||||
this.endTime,
|
||||
required this.distanceMiles,
|
||||
|
|
@ -695,6 +724,9 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
if (!nullToAbsent || cloudRideId != null) {
|
||||
map['cloud_ride_id'] = Variable<String>(cloudRideId);
|
||||
}
|
||||
if (!nullToAbsent || cloudOwnerUid != null) {
|
||||
map['cloud_owner_uid'] = Variable<String>(cloudOwnerUid);
|
||||
}
|
||||
map['start_time'] = Variable<DateTime>(startTime);
|
||||
if (!nullToAbsent || endTime != null) {
|
||||
map['end_time'] = Variable<DateTime>(endTime);
|
||||
|
|
@ -742,6 +774,9 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
cloudRideId: cloudRideId == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(cloudRideId),
|
||||
cloudOwnerUid: cloudOwnerUid == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(cloudOwnerUid),
|
||||
startTime: Value(startTime),
|
||||
endTime: endTime == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
|
|
@ -791,6 +826,7 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
return Ride(
|
||||
id: serializer.fromJson<int>(json['id']),
|
||||
cloudRideId: serializer.fromJson<String?>(json['cloudRideId']),
|
||||
cloudOwnerUid: serializer.fromJson<String?>(json['cloudOwnerUid']),
|
||||
startTime: serializer.fromJson<DateTime>(json['startTime']),
|
||||
endTime: serializer.fromJson<DateTime?>(json['endTime']),
|
||||
distanceMiles: serializer.fromJson<double>(json['distanceMiles']),
|
||||
|
|
@ -823,6 +859,7 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
return <String, dynamic>{
|
||||
'id': serializer.toJson<int>(id),
|
||||
'cloudRideId': serializer.toJson<String?>(cloudRideId),
|
||||
'cloudOwnerUid': serializer.toJson<String?>(cloudOwnerUid),
|
||||
'startTime': serializer.toJson<DateTime>(startTime),
|
||||
'endTime': serializer.toJson<DateTime?>(endTime),
|
||||
'distanceMiles': serializer.toJson<double>(distanceMiles),
|
||||
|
|
@ -851,6 +888,7 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
Ride copyWith({
|
||||
int? id,
|
||||
Value<String?> cloudRideId = const Value.absent(),
|
||||
Value<String?> cloudOwnerUid = const Value.absent(),
|
||||
DateTime? startTime,
|
||||
Value<DateTime?> endTime = const Value.absent(),
|
||||
double? distanceMiles,
|
||||
|
|
@ -876,6 +914,9 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
}) => Ride(
|
||||
id: id ?? this.id,
|
||||
cloudRideId: cloudRideId.present ? cloudRideId.value : this.cloudRideId,
|
||||
cloudOwnerUid: cloudOwnerUid.present
|
||||
? cloudOwnerUid.value
|
||||
: this.cloudOwnerUid,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime.present ? endTime.value : this.endTime,
|
||||
distanceMiles: distanceMiles ?? this.distanceMiles,
|
||||
|
|
@ -913,6 +954,9 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
cloudRideId: data.cloudRideId.present
|
||||
? data.cloudRideId.value
|
||||
: this.cloudRideId,
|
||||
cloudOwnerUid: data.cloudOwnerUid.present
|
||||
? data.cloudOwnerUid.value
|
||||
: this.cloudOwnerUid,
|
||||
startTime: data.startTime.present ? data.startTime.value : this.startTime,
|
||||
endTime: data.endTime.present ? data.endTime.value : this.endTime,
|
||||
distanceMiles: data.distanceMiles.present
|
||||
|
|
@ -977,6 +1021,7 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
return (StringBuffer('Ride(')
|
||||
..write('id: $id, ')
|
||||
..write('cloudRideId: $cloudRideId, ')
|
||||
..write('cloudOwnerUid: $cloudOwnerUid, ')
|
||||
..write('startTime: $startTime, ')
|
||||
..write('endTime: $endTime, ')
|
||||
..write('distanceMiles: $distanceMiles, ')
|
||||
|
|
@ -1007,6 +1052,7 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
int get hashCode => Object.hashAll([
|
||||
id,
|
||||
cloudRideId,
|
||||
cloudOwnerUid,
|
||||
startTime,
|
||||
endTime,
|
||||
distanceMiles,
|
||||
|
|
@ -1036,6 +1082,7 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
(other is Ride &&
|
||||
other.id == this.id &&
|
||||
other.cloudRideId == this.cloudRideId &&
|
||||
other.cloudOwnerUid == this.cloudOwnerUid &&
|
||||
other.startTime == this.startTime &&
|
||||
other.endTime == this.endTime &&
|
||||
other.distanceMiles == this.distanceMiles &&
|
||||
|
|
@ -1063,6 +1110,7 @@ class Ride extends DataClass implements Insertable<Ride> {
|
|||
class RidesCompanion extends UpdateCompanion<Ride> {
|
||||
final Value<int> id;
|
||||
final Value<String?> cloudRideId;
|
||||
final Value<String?> cloudOwnerUid;
|
||||
final Value<DateTime> startTime;
|
||||
final Value<DateTime?> endTime;
|
||||
final Value<double> distanceMiles;
|
||||
|
|
@ -1088,6 +1136,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
|
|||
const RidesCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.cloudRideId = const Value.absent(),
|
||||
this.cloudOwnerUid = const Value.absent(),
|
||||
this.startTime = const Value.absent(),
|
||||
this.endTime = const Value.absent(),
|
||||
this.distanceMiles = const Value.absent(),
|
||||
|
|
@ -1114,6 +1163,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
|
|||
RidesCompanion.insert({
|
||||
this.id = const Value.absent(),
|
||||
this.cloudRideId = const Value.absent(),
|
||||
this.cloudOwnerUid = const Value.absent(),
|
||||
required DateTime startTime,
|
||||
this.endTime = const Value.absent(),
|
||||
this.distanceMiles = const Value.absent(),
|
||||
|
|
@ -1140,6 +1190,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
|
|||
static Insertable<Ride> custom({
|
||||
Expression<int>? id,
|
||||
Expression<String>? cloudRideId,
|
||||
Expression<String>? cloudOwnerUid,
|
||||
Expression<DateTime>? startTime,
|
||||
Expression<DateTime>? endTime,
|
||||
Expression<double>? distanceMiles,
|
||||
|
|
@ -1166,6 +1217,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
|
|||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
if (cloudRideId != null) 'cloud_ride_id': cloudRideId,
|
||||
if (cloudOwnerUid != null) 'cloud_owner_uid': cloudOwnerUid,
|
||||
if (startTime != null) 'start_time': startTime,
|
||||
if (endTime != null) 'end_time': endTime,
|
||||
if (distanceMiles != null) 'distance_miles': distanceMiles,
|
||||
|
|
@ -1194,6 +1246,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
|
|||
RidesCompanion copyWith({
|
||||
Value<int>? id,
|
||||
Value<String?>? cloudRideId,
|
||||
Value<String?>? cloudOwnerUid,
|
||||
Value<DateTime>? startTime,
|
||||
Value<DateTime?>? endTime,
|
||||
Value<double>? distanceMiles,
|
||||
|
|
@ -1220,6 +1273,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
|
|||
return RidesCompanion(
|
||||
id: id ?? this.id,
|
||||
cloudRideId: cloudRideId ?? this.cloudRideId,
|
||||
cloudOwnerUid: cloudOwnerUid ?? this.cloudOwnerUid,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
distanceMiles: distanceMiles ?? this.distanceMiles,
|
||||
|
|
@ -1254,6 +1308,9 @@ class RidesCompanion extends UpdateCompanion<Ride> {
|
|||
if (cloudRideId.present) {
|
||||
map['cloud_ride_id'] = Variable<String>(cloudRideId.value);
|
||||
}
|
||||
if (cloudOwnerUid.present) {
|
||||
map['cloud_owner_uid'] = Variable<String>(cloudOwnerUid.value);
|
||||
}
|
||||
if (startTime.present) {
|
||||
map['start_time'] = Variable<DateTime>(startTime.value);
|
||||
}
|
||||
|
|
@ -1328,6 +1385,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
|
|||
return (StringBuffer('RidesCompanion(')
|
||||
..write('id: $id, ')
|
||||
..write('cloudRideId: $cloudRideId, ')
|
||||
..write('cloudOwnerUid: $cloudOwnerUid, ')
|
||||
..write('startTime: $startTime, ')
|
||||
..write('endTime: $endTime, ')
|
||||
..write('distanceMiles: $distanceMiles, ')
|
||||
|
|
@ -10030,6 +10088,7 @@ typedef $$RidesTableCreateCompanionBuilder =
|
|||
RidesCompanion Function({
|
||||
Value<int> id,
|
||||
Value<String?> cloudRideId,
|
||||
Value<String?> cloudOwnerUid,
|
||||
required DateTime startTime,
|
||||
Value<DateTime?> endTime,
|
||||
Value<double> distanceMiles,
|
||||
|
|
@ -10057,6 +10116,7 @@ typedef $$RidesTableUpdateCompanionBuilder =
|
|||
RidesCompanion Function({
|
||||
Value<int> id,
|
||||
Value<String?> cloudRideId,
|
||||
Value<String?> cloudOwnerUid,
|
||||
Value<DateTime> startTime,
|
||||
Value<DateTime?> endTime,
|
||||
Value<double> distanceMiles,
|
||||
|
|
@ -10224,6 +10284,11 @@ class $$RidesTableFilterComposer extends Composer<_$AppDatabase, $RidesTable> {
|
|||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get cloudOwnerUid => $composableBuilder(
|
||||
column: $table.cloudOwnerUid,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<DateTime> get startTime => $composableBuilder(
|
||||
column: $table.startTime,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
|
|
@ -10504,6 +10569,11 @@ class $$RidesTableOrderingComposer
|
|||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get cloudOwnerUid => $composableBuilder(
|
||||
column: $table.cloudOwnerUid,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<DateTime> get startTime => $composableBuilder(
|
||||
column: $table.startTime,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
|
|
@ -10632,6 +10702,11 @@ class $$RidesTableAnnotationComposer
|
|||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get cloudOwnerUid => $composableBuilder(
|
||||
column: $table.cloudOwnerUid,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<DateTime> get startTime =>
|
||||
$composableBuilder(column: $table.startTime, builder: (column) => column);
|
||||
|
||||
|
|
@ -10922,6 +10997,7 @@ class $$RidesTableTableManager
|
|||
({
|
||||
Value<int> id = const Value.absent(),
|
||||
Value<String?> cloudRideId = const Value.absent(),
|
||||
Value<String?> cloudOwnerUid = const Value.absent(),
|
||||
Value<DateTime> startTime = const Value.absent(),
|
||||
Value<DateTime?> endTime = const Value.absent(),
|
||||
Value<double> distanceMiles = const Value.absent(),
|
||||
|
|
@ -10947,6 +11023,7 @@ class $$RidesTableTableManager
|
|||
}) => RidesCompanion(
|
||||
id: id,
|
||||
cloudRideId: cloudRideId,
|
||||
cloudOwnerUid: cloudOwnerUid,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
distanceMiles: distanceMiles,
|
||||
|
|
@ -10974,6 +11051,7 @@ class $$RidesTableTableManager
|
|||
({
|
||||
Value<int> id = const Value.absent(),
|
||||
Value<String?> cloudRideId = const Value.absent(),
|
||||
Value<String?> cloudOwnerUid = const Value.absent(),
|
||||
required DateTime startTime,
|
||||
Value<DateTime?> endTime = const Value.absent(),
|
||||
Value<double> distanceMiles = const Value.absent(),
|
||||
|
|
@ -10999,6 +11077,7 @@ class $$RidesTableTableManager
|
|||
}) => RidesCompanion.insert(
|
||||
id: id,
|
||||
cloudRideId: cloudRideId,
|
||||
cloudOwnerUid: cloudOwnerUid,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
distanceMiles: distanceMiles,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ class Rides extends Table {
|
|||
IntColumn get id => integer().autoIncrement()();
|
||||
/// Stable MotorCloud identity preserved across local IDs, reinstalls, and sync retries.
|
||||
TextColumn get cloudRideId => text().nullable()();
|
||||
/// Firebase UID that owns this ride in MotorCloud. Null means device-only.
|
||||
TextColumn get cloudOwnerUid => text().nullable()();
|
||||
DateTimeColumn get startTime => dateTime()();
|
||||
DateTimeColumn get endTime => dateTime().nullable()();
|
||||
RealColumn get distanceMiles => real().withDefault(const Constant(0.0))();
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
|
||||
_AuthFormMode _formMode = _AuthFormMode.signIn;
|
||||
bool _obscurePassword = true;
|
||||
bool _showAnonymousUpgrade = false;
|
||||
String? _statusMessage;
|
||||
|
||||
@override
|
||||
|
|
@ -75,7 +76,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
isEvolution,
|
||||
isWide,
|
||||
);
|
||||
} else if (authState.isAnonymous) {
|
||||
} else if (authState.isAnonymous && !_showAnonymousUpgrade) {
|
||||
content = _buildAnonymousView(
|
||||
context,
|
||||
authState.user!,
|
||||
|
|
@ -719,8 +720,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
'SIGN IN WITH FULL ACCOUNT',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: () =>
|
||||
ref.read(authNotifierProvider.notifier).signOut(),
|
||||
onPressed: () => setState(() => _showAnonymousUpgrade = true),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -803,7 +803,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
const SizedBox(height: 12),
|
||||
if (!isAuthenticated) ...[
|
||||
const Text(
|
||||
'Sign in to back up your rides and access multi-device synchronization. All rides are safely stored on this device in your local Drift SQLite database.',
|
||||
'Sign in to MotorCloud to back up eligible rides and restore them on another device. Local rides remain available on this device.',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white70,
|
||||
|
|
@ -891,7 +891,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
],
|
||||
_buildInfoRow(
|
||||
'Cloud Provider',
|
||||
'Firebase (Auth/Firestore) + Cloudflare R2',
|
||||
'MotorCloud backup',
|
||||
),
|
||||
if (syncState.lastSuccessfulSync != null)
|
||||
_buildInfoRow(
|
||||
|
|
@ -1084,10 +1084,40 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
Future<void> _handleBackupAllHistoricalRides() async {
|
||||
final db = ref.read(databaseProvider);
|
||||
final syncManager = ref.read(syncManagerProvider);
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null || user.isAnonymous) return;
|
||||
final rides = await db.select(db.rides).get();
|
||||
final owned = rides.where((ride) => ride.cloudOwnerUid == user.uid).toList();
|
||||
final unclaimed = rides.where((ride) => ride.cloudOwnerUid == null).toList();
|
||||
final otherOwned = rides.length - owned.length - unclaimed.length;
|
||||
var claimUnclaimed = false;
|
||||
if (unclaimed.isNotEmpty && mounted) {
|
||||
claimUnclaimed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Back Up Device Rides?'),
|
||||
content: Text(
|
||||
'This will associate ${unclaimed.length} device-local ride(s) with your signed-in MotorCloud account. Rides already associated with another account will not be included.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('BACK UP')),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
int enqueued = 0;
|
||||
for (final ride in rides) {
|
||||
var enqueued = 0;
|
||||
if (claimUnclaimed) {
|
||||
for (final ride in unclaimed) {
|
||||
if (await db.claimRideForCloudOwner(ride.id, user.uid)) {
|
||||
await syncManager.enqueueRide(ride.id, autoProcess: false);
|
||||
enqueued++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final ride in owned) {
|
||||
await syncManager.enqueueRide(ride.id, autoProcess: false);
|
||||
enqueued++;
|
||||
}
|
||||
|
|
@ -1096,7 +1126,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Enqueued $enqueued local ride(s) for MotorCloud synchronization.',
|
||||
'Queued $enqueued ride(s). Skipped $otherOwned ride(s) associated with another account.',
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
|
|
@ -1227,7 +1257,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Your local Schema 5 Drift SQLite database remains the authoritative source of truth for telemetry and rides.',
|
||||
'Your local ride history remains available on this device. MotorCloud backup is optional and never required for recording or offline use.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white60),
|
||||
),
|
||||
],
|
||||
|
|
@ -1304,10 +1334,14 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
displayName: displayName.isNotEmpty ? displayName : null,
|
||||
);
|
||||
} else if (_formMode == _AuthFormMode.forgotPassword) {
|
||||
await notifier.sendPasswordResetEmail(email);
|
||||
if (mounted) {
|
||||
try {
|
||||
await notifier.sendPasswordResetEmail(email);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (mounted && !ref.read(authNotifierProvider).hasError) {
|
||||
setState(() {
|
||||
_statusMessage = 'Password reset instructions sent to $email';
|
||||
_statusMessage = 'Password reset instructions sent. Check your email.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@ class FirebaseAuthService implements AuthService {
|
|||
user: _currentState.user,
|
||||
isFirebaseConfigured: true,
|
||||
));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -158,12 +158,16 @@ class CloudReconciliationService {
|
|||
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],
|
||||
'UPDATE rides SET cloud_ride_id = ?, cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?',
|
||||
[cloudRideId, ownerId, localRideId],
|
||||
);
|
||||
return const CloudReconciliationResult(unchanged: 1);
|
||||
}
|
||||
if (existingCloudId == cloudRideId) {
|
||||
await _db.customStatement(
|
||||
'UPDATE rides SET cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?',
|
||||
[ownerId, localRideId],
|
||||
);
|
||||
return const CloudReconciliationResult(unchanged: 1);
|
||||
}
|
||||
return const CloudReconciliationResult(conflicts: 1);
|
||||
|
|
@ -178,6 +182,7 @@ class CloudReconciliationService {
|
|||
final endTime = _readDateTime(data['endTime'] ?? data['endedAt']);
|
||||
await _insertRestoredMetadata(
|
||||
cloudRideId: cloudRideId,
|
||||
ownerId: ownerId,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
data: data,
|
||||
|
|
@ -188,6 +193,7 @@ class CloudReconciliationService {
|
|||
|
||||
Future<void> _insertRestoredMetadata({
|
||||
required String cloudRideId,
|
||||
required String ownerId,
|
||||
required DateTime startTime,
|
||||
required DateTime? endTime,
|
||||
required Map<String, dynamic> data,
|
||||
|
|
@ -196,6 +202,7 @@ class CloudReconciliationService {
|
|||
'''
|
||||
INSERT INTO rides (
|
||||
cloud_ride_id,
|
||||
cloud_owner_uid,
|
||||
start_time,
|
||||
end_time,
|
||||
distance_miles,
|
||||
|
|
@ -216,10 +223,11 @@ class CloudReconciliationService {
|
|||
motorcycle_make,
|
||||
motorcycle_model,
|
||||
motorcycle_variant
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
[
|
||||
cloudRideId,
|
||||
ownerId,
|
||||
startTime.toUtc().millisecondsSinceEpoch,
|
||||
endTime?.toUtc().millisecondsSinceEpoch,
|
||||
_asDouble(data['distanceMiles']),
|
||||
|
|
|
|||
|
|
@ -43,10 +43,19 @@ class FirebaseSyncProvider implements SyncProvider {
|
|||
|
||||
@override
|
||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||
if (!await isAvailable()) return SyncResult.temporaryFailure('Firebase services are unavailable or unconfigured. Ride remains safely stored locally.');
|
||||
final user = _authService.currentUser;
|
||||
if (user == null || user.isAnonymous) return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.');
|
||||
|
||||
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull();
|
||||
if (ride != null && ride.cloudOwnerUid != user.uid) {
|
||||
return SyncResult.permanentFailure(
|
||||
ride.cloudOwnerUid == null
|
||||
? 'This device-only ride needs explicit approval before MotorCloud backup.'
|
||||
: 'This ride belongs to another MotorCloud account and was not uploaded.',
|
||||
);
|
||||
}
|
||||
if (!await isAvailable()) return SyncResult.temporaryFailure('Firebase services are unavailable or unconfigured. Ride remains safely stored locally.');
|
||||
|
||||
// Refresh the Firebase ID token before a manual upload. This prevents a
|
||||
// recently completed sign-in from being rejected because the cached token
|
||||
// has not yet propagated through the app's auth abstraction.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import 'package:flutter/widgets.dart';
|
|||
import 'sync_models.dart';
|
||||
import 'sync_outbox.dart';
|
||||
import 'sync_provider.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../auth/auth_service.dart';
|
||||
|
||||
/// Central provider-agnostic synchronization coordinator.
|
||||
///
|
||||
|
|
@ -15,8 +17,10 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||
String _activeProviderId = 'fake';
|
||||
bool _isProcessing = false;
|
||||
DateTime? Function() _nowProvider;
|
||||
final AppDatabase? _db;
|
||||
final AuthService? _authService;
|
||||
|
||||
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
||||
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
||||
if (initialProviders != null) {
|
||||
for (final p in initialProviders) { registerProvider(p); }
|
||||
}
|
||||
|
|
@ -61,6 +65,12 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||
|
||||
Future<SyncItem> enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async {
|
||||
final providerId = targetProviderId ?? _activeProviderId;
|
||||
if (providerId == 'firebase') {
|
||||
final user = _authService?.currentUser;
|
||||
if (user != null && !user.isAnonymous && _db != null) {
|
||||
await _db.claimRideForCloudOwner(localRideId, user.uid);
|
||||
}
|
||||
}
|
||||
final item = await _outbox.enqueue(localRideId, providerId, now: _now());
|
||||
notifyListeners();
|
||||
if (autoProcess) unawaited(processQueue());
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ final syncManagerProvider = ChangeNotifierProvider<SyncManager>((ref) {
|
|||
ref.watch(githubSyncProvider),
|
||||
],
|
||||
defaultProviderId: 'firebase',
|
||||
db: ref.watch(databaseProvider),
|
||||
authService: ref.watch(authServiceProvider),
|
||||
);
|
||||
final accountSync = ref.watch(firebaseAccountSyncServiceProvider);
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ void main() {
|
|||
|
||||
final migrated = await db.getCloudRideId(42);
|
||||
expect(migrated, 'ride_42_20260831_210705');
|
||||
expect(await db.getCloudOwnerUid(42), isNull);
|
||||
|
||||
final rows = await db.customSelect('PRAGMA table_info(rides)').get();
|
||||
expect(rows.any((row) => row.data['name'] == 'cloud_ride_id'), isTrue);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart';
|
||||
import 'package:motorcycle_dash/services/auth/auth_service.dart';
|
||||
import 'package:motorcycle_dash/services/auth/auth_state.dart';
|
||||
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
|
||||
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
|
||||
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
|
||||
import 'package:motorcycle_dash/services/sync/sync_models.dart';
|
||||
import 'package:motorcycle_dash/services/sync/firebase_sync_provider.dart';
|
||||
|
||||
class _Auth implements AuthService {
|
||||
AuthUser? user;
|
||||
@override bool get isConfigured => true;
|
||||
@override AuthUser? get currentUser => user;
|
||||
@override AuthState get currentState => user == null ? const AuthState.unauthenticated() : AuthState.authenticated(user!);
|
||||
@override Stream<AuthState> get authStateChanges => Stream.value(currentState);
|
||||
@override Future<void> createUserWithEmailAndPassword(String e, String p, {String? displayName}) async {}
|
||||
@override Future<void> sendPasswordResetEmail(String email) async {}
|
||||
@override Future<void> signInAnonymously() async {}
|
||||
@override Future<void> signInWithEmailAndPassword(String e, String p) async {}
|
||||
@override Future<void> signInWithGoogle() async {}
|
||||
@override Future<String?> getIdToken([bool forceRefresh = false]) async => user == null ? null : 'token';
|
||||
@override Future<void> signOut() async { user = null; }
|
||||
@override void dispose() {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('new rides are unclaimed and claim is immutable', () async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final rideId = await db.startRide();
|
||||
expect(await db.getCloudOwnerUid(rideId), isNull);
|
||||
expect(await db.claimRideForCloudOwner(rideId, 'user_a'), isTrue);
|
||||
expect(await db.getCloudOwnerUid(rideId), 'user_a');
|
||||
expect(await db.claimRideForCloudOwner(rideId, 'user_b'), isFalse);
|
||||
expect(await db.getCloudOwnerUid(rideId), 'user_a');
|
||||
expect(await db.getCloudRideId(rideId), startsWith('ride_'));
|
||||
});
|
||||
|
||||
test('authenticated enqueue claims only for the current permanent account', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final auth = _Auth()..user = const AuthUser(uid: 'user_a', email: 'a@test.com');
|
||||
final rideId = await db.startRide();
|
||||
final manager = SyncManager(
|
||||
SyncOutbox(prefs),
|
||||
db: db,
|
||||
authService: auth,
|
||||
initialProviders: [FakeSyncProvider(providerId: 'firebase')],
|
||||
defaultProviderId: 'firebase',
|
||||
);
|
||||
addTearDown(manager.dispose);
|
||||
|
||||
await manager.enqueueRide(rideId, autoProcess: false);
|
||||
expect(await db.getCloudOwnerUid(rideId), 'user_a');
|
||||
|
||||
auth.user = const AuthUser(uid: 'user_b', email: 'b@test.com');
|
||||
await manager.enqueueRide(rideId, autoProcess: false);
|
||||
expect(await db.getCloudOwnerUid(rideId), 'user_a');
|
||||
});
|
||||
|
||||
test('signed-out enqueue leaves ride unclaimed', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final rideId = await db.startRide();
|
||||
final manager = SyncManager(
|
||||
SyncOutbox(prefs),
|
||||
db: db,
|
||||
authService: _Auth(),
|
||||
initialProviders: [FakeSyncProvider(providerId: 'firebase')],
|
||||
defaultProviderId: 'firebase',
|
||||
);
|
||||
addTearDown(manager.dispose);
|
||||
await manager.enqueueRide(rideId, autoProcess: false);
|
||||
expect(await db.getCloudOwnerUid(rideId), isNull);
|
||||
});
|
||||
|
||||
test('Firebase provider rejects another account before any cloud call', () async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final rideId = await db.startRide();
|
||||
await db.claimRideForCloudOwner(rideId, 'user_a');
|
||||
final auth = _Auth()..user = const AuthUser(uid: 'user_b', email: 'b@test.com');
|
||||
final result = await FirebaseSyncProvider(db: db, authService: auth).uploadRide(
|
||||
SyncItem(localRideId: rideId, providerId: 'firebase', createdAt: DateTime.now()),
|
||||
);
|
||||
expect(result.success, isFalse);
|
||||
expect(result.isRetryable, isFalse);
|
||||
expect(await db.getCloudOwnerUid(rideId), 'user_a');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue