fix: isolate MotorCloud ride ownership across accounts

This commit is contained in:
Developer 2026-09-03 08:12:10 -04:00
parent 8393849f39
commit 353b6fb66c
11 changed files with 286 additions and 18 deletions

View File

@ -18,7 +18,7 @@ class AppDatabase extends _$AppDatabase {
static final Random _random = Random.secure(); static final Random _random = Random.secure();
@override @override
int get schemaVersion => 6; int get schemaVersion => 7;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -91,6 +91,9 @@ class AppDatabase extends _$AppDatabase {
await customStatement('UPDATE rides SET cloud_ride_id = ? WHERE id = ?', [legacyId, id]); 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 { Future<void> endRide(int rideId, RidesCompanion data) async {
await (update(rides)..where((t) => t.id.equals(rideId))).write(data.copyWith(endTime: Value(DateTime.now()))); await (update(rides)..where((t) => t.id.equals(rideId))).write(data.copyWith(endTime: Value(DateTime.now())));
} }

View File

@ -32,6 +32,17 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> {
type: DriftSqlType.string, type: DriftSqlType.string,
requiredDuringInsert: false, 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( static const VerificationMeta _startTimeMeta = const VerificationMeta(
'startTime', 'startTime',
); );
@ -292,6 +303,7 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> {
List<GeneratedColumn> get $columns => [ List<GeneratedColumn> get $columns => [
id, id,
cloudRideId, cloudRideId,
cloudOwnerUid,
startTime, startTime,
endTime, endTime,
distanceMiles, 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')) { if (data.containsKey('start_time')) {
context.handle( context.handle(
_startTimeMeta, _startTimeMeta,
@ -538,6 +559,10 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> {
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}cloud_ride_id'], data['${effectivePrefix}cloud_ride_id'],
), ),
cloudOwnerUid: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}cloud_owner_uid'],
),
startTime: attachedDatabase.typeMapping.read( startTime: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, DriftSqlType.dateTime,
data['${effectivePrefix}start_time'], 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. /// Stable MotorCloud identity preserved across local IDs, reinstalls, and sync retries.
final String? cloudRideId; final String? cloudRideId;
/// Firebase UID that owns this ride in MotorCloud. Null means device-only.
final String? cloudOwnerUid;
final DateTime startTime; final DateTime startTime;
final DateTime? endTime; final DateTime? endTime;
final double distanceMiles; final double distanceMiles;
@ -665,6 +693,7 @@ class Ride extends DataClass implements Insertable<Ride> {
const Ride({ const Ride({
required this.id, required this.id,
this.cloudRideId, this.cloudRideId,
this.cloudOwnerUid,
required this.startTime, required this.startTime,
this.endTime, this.endTime,
required this.distanceMiles, required this.distanceMiles,
@ -695,6 +724,9 @@ class Ride extends DataClass implements Insertable<Ride> {
if (!nullToAbsent || cloudRideId != null) { if (!nullToAbsent || cloudRideId != null) {
map['cloud_ride_id'] = Variable<String>(cloudRideId); map['cloud_ride_id'] = Variable<String>(cloudRideId);
} }
if (!nullToAbsent || cloudOwnerUid != null) {
map['cloud_owner_uid'] = Variable<String>(cloudOwnerUid);
}
map['start_time'] = Variable<DateTime>(startTime); map['start_time'] = Variable<DateTime>(startTime);
if (!nullToAbsent || endTime != null) { if (!nullToAbsent || endTime != null) {
map['end_time'] = Variable<DateTime>(endTime); map['end_time'] = Variable<DateTime>(endTime);
@ -742,6 +774,9 @@ class Ride extends DataClass implements Insertable<Ride> {
cloudRideId: cloudRideId == null && nullToAbsent cloudRideId: cloudRideId == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(cloudRideId), : Value(cloudRideId),
cloudOwnerUid: cloudOwnerUid == null && nullToAbsent
? const Value.absent()
: Value(cloudOwnerUid),
startTime: Value(startTime), startTime: Value(startTime),
endTime: endTime == null && nullToAbsent endTime: endTime == null && nullToAbsent
? const Value.absent() ? const Value.absent()
@ -791,6 +826,7 @@ class Ride extends DataClass implements Insertable<Ride> {
return Ride( return Ride(
id: serializer.fromJson<int>(json['id']), id: serializer.fromJson<int>(json['id']),
cloudRideId: serializer.fromJson<String?>(json['cloudRideId']), cloudRideId: serializer.fromJson<String?>(json['cloudRideId']),
cloudOwnerUid: serializer.fromJson<String?>(json['cloudOwnerUid']),
startTime: serializer.fromJson<DateTime>(json['startTime']), startTime: serializer.fromJson<DateTime>(json['startTime']),
endTime: serializer.fromJson<DateTime?>(json['endTime']), endTime: serializer.fromJson<DateTime?>(json['endTime']),
distanceMiles: serializer.fromJson<double>(json['distanceMiles']), distanceMiles: serializer.fromJson<double>(json['distanceMiles']),
@ -823,6 +859,7 @@ class Ride extends DataClass implements Insertable<Ride> {
return <String, dynamic>{ return <String, dynamic>{
'id': serializer.toJson<int>(id), 'id': serializer.toJson<int>(id),
'cloudRideId': serializer.toJson<String?>(cloudRideId), 'cloudRideId': serializer.toJson<String?>(cloudRideId),
'cloudOwnerUid': serializer.toJson<String?>(cloudOwnerUid),
'startTime': serializer.toJson<DateTime>(startTime), 'startTime': serializer.toJson<DateTime>(startTime),
'endTime': serializer.toJson<DateTime?>(endTime), 'endTime': serializer.toJson<DateTime?>(endTime),
'distanceMiles': serializer.toJson<double>(distanceMiles), 'distanceMiles': serializer.toJson<double>(distanceMiles),
@ -851,6 +888,7 @@ class Ride extends DataClass implements Insertable<Ride> {
Ride copyWith({ Ride copyWith({
int? id, int? id,
Value<String?> cloudRideId = const Value.absent(), Value<String?> cloudRideId = const Value.absent(),
Value<String?> cloudOwnerUid = const Value.absent(),
DateTime? startTime, DateTime? startTime,
Value<DateTime?> endTime = const Value.absent(), Value<DateTime?> endTime = const Value.absent(),
double? distanceMiles, double? distanceMiles,
@ -876,6 +914,9 @@ class Ride extends DataClass implements Insertable<Ride> {
}) => Ride( }) => Ride(
id: id ?? this.id, id: id ?? this.id,
cloudRideId: cloudRideId.present ? cloudRideId.value : this.cloudRideId, cloudRideId: cloudRideId.present ? cloudRideId.value : this.cloudRideId,
cloudOwnerUid: cloudOwnerUid.present
? cloudOwnerUid.value
: this.cloudOwnerUid,
startTime: startTime ?? this.startTime, startTime: startTime ?? this.startTime,
endTime: endTime.present ? endTime.value : this.endTime, endTime: endTime.present ? endTime.value : this.endTime,
distanceMiles: distanceMiles ?? this.distanceMiles, distanceMiles: distanceMiles ?? this.distanceMiles,
@ -913,6 +954,9 @@ class Ride extends DataClass implements Insertable<Ride> {
cloudRideId: data.cloudRideId.present cloudRideId: data.cloudRideId.present
? data.cloudRideId.value ? data.cloudRideId.value
: this.cloudRideId, : this.cloudRideId,
cloudOwnerUid: data.cloudOwnerUid.present
? data.cloudOwnerUid.value
: this.cloudOwnerUid,
startTime: data.startTime.present ? data.startTime.value : this.startTime, startTime: data.startTime.present ? data.startTime.value : this.startTime,
endTime: data.endTime.present ? data.endTime.value : this.endTime, endTime: data.endTime.present ? data.endTime.value : this.endTime,
distanceMiles: data.distanceMiles.present distanceMiles: data.distanceMiles.present
@ -977,6 +1021,7 @@ class Ride extends DataClass implements Insertable<Ride> {
return (StringBuffer('Ride(') return (StringBuffer('Ride(')
..write('id: $id, ') ..write('id: $id, ')
..write('cloudRideId: $cloudRideId, ') ..write('cloudRideId: $cloudRideId, ')
..write('cloudOwnerUid: $cloudOwnerUid, ')
..write('startTime: $startTime, ') ..write('startTime: $startTime, ')
..write('endTime: $endTime, ') ..write('endTime: $endTime, ')
..write('distanceMiles: $distanceMiles, ') ..write('distanceMiles: $distanceMiles, ')
@ -1007,6 +1052,7 @@ class Ride extends DataClass implements Insertable<Ride> {
int get hashCode => Object.hashAll([ int get hashCode => Object.hashAll([
id, id,
cloudRideId, cloudRideId,
cloudOwnerUid,
startTime, startTime,
endTime, endTime,
distanceMiles, distanceMiles,
@ -1036,6 +1082,7 @@ class Ride extends DataClass implements Insertable<Ride> {
(other is Ride && (other is Ride &&
other.id == this.id && other.id == this.id &&
other.cloudRideId == this.cloudRideId && other.cloudRideId == this.cloudRideId &&
other.cloudOwnerUid == this.cloudOwnerUid &&
other.startTime == this.startTime && other.startTime == this.startTime &&
other.endTime == this.endTime && other.endTime == this.endTime &&
other.distanceMiles == this.distanceMiles && other.distanceMiles == this.distanceMiles &&
@ -1063,6 +1110,7 @@ class Ride extends DataClass implements Insertable<Ride> {
class RidesCompanion extends UpdateCompanion<Ride> { class RidesCompanion extends UpdateCompanion<Ride> {
final Value<int> id; final Value<int> id;
final Value<String?> cloudRideId; final Value<String?> cloudRideId;
final Value<String?> cloudOwnerUid;
final Value<DateTime> startTime; final Value<DateTime> startTime;
final Value<DateTime?> endTime; final Value<DateTime?> endTime;
final Value<double> distanceMiles; final Value<double> distanceMiles;
@ -1088,6 +1136,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
const RidesCompanion({ const RidesCompanion({
this.id = const Value.absent(), this.id = const Value.absent(),
this.cloudRideId = const Value.absent(), this.cloudRideId = const Value.absent(),
this.cloudOwnerUid = const Value.absent(),
this.startTime = const Value.absent(), this.startTime = const Value.absent(),
this.endTime = const Value.absent(), this.endTime = const Value.absent(),
this.distanceMiles = const Value.absent(), this.distanceMiles = const Value.absent(),
@ -1114,6 +1163,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
RidesCompanion.insert({ RidesCompanion.insert({
this.id = const Value.absent(), this.id = const Value.absent(),
this.cloudRideId = const Value.absent(), this.cloudRideId = const Value.absent(),
this.cloudOwnerUid = const Value.absent(),
required DateTime startTime, required DateTime startTime,
this.endTime = const Value.absent(), this.endTime = const Value.absent(),
this.distanceMiles = const Value.absent(), this.distanceMiles = const Value.absent(),
@ -1140,6 +1190,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
static Insertable<Ride> custom({ static Insertable<Ride> custom({
Expression<int>? id, Expression<int>? id,
Expression<String>? cloudRideId, Expression<String>? cloudRideId,
Expression<String>? cloudOwnerUid,
Expression<DateTime>? startTime, Expression<DateTime>? startTime,
Expression<DateTime>? endTime, Expression<DateTime>? endTime,
Expression<double>? distanceMiles, Expression<double>? distanceMiles,
@ -1166,6 +1217,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
return RawValuesInsertable({ return RawValuesInsertable({
if (id != null) 'id': id, if (id != null) 'id': id,
if (cloudRideId != null) 'cloud_ride_id': cloudRideId, if (cloudRideId != null) 'cloud_ride_id': cloudRideId,
if (cloudOwnerUid != null) 'cloud_owner_uid': cloudOwnerUid,
if (startTime != null) 'start_time': startTime, if (startTime != null) 'start_time': startTime,
if (endTime != null) 'end_time': endTime, if (endTime != null) 'end_time': endTime,
if (distanceMiles != null) 'distance_miles': distanceMiles, if (distanceMiles != null) 'distance_miles': distanceMiles,
@ -1194,6 +1246,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
RidesCompanion copyWith({ RidesCompanion copyWith({
Value<int>? id, Value<int>? id,
Value<String?>? cloudRideId, Value<String?>? cloudRideId,
Value<String?>? cloudOwnerUid,
Value<DateTime>? startTime, Value<DateTime>? startTime,
Value<DateTime?>? endTime, Value<DateTime?>? endTime,
Value<double>? distanceMiles, Value<double>? distanceMiles,
@ -1220,6 +1273,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
return RidesCompanion( return RidesCompanion(
id: id ?? this.id, id: id ?? this.id,
cloudRideId: cloudRideId ?? this.cloudRideId, cloudRideId: cloudRideId ?? this.cloudRideId,
cloudOwnerUid: cloudOwnerUid ?? this.cloudOwnerUid,
startTime: startTime ?? this.startTime, startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime, endTime: endTime ?? this.endTime,
distanceMiles: distanceMiles ?? this.distanceMiles, distanceMiles: distanceMiles ?? this.distanceMiles,
@ -1254,6 +1308,9 @@ class RidesCompanion extends UpdateCompanion<Ride> {
if (cloudRideId.present) { if (cloudRideId.present) {
map['cloud_ride_id'] = Variable<String>(cloudRideId.value); map['cloud_ride_id'] = Variable<String>(cloudRideId.value);
} }
if (cloudOwnerUid.present) {
map['cloud_owner_uid'] = Variable<String>(cloudOwnerUid.value);
}
if (startTime.present) { if (startTime.present) {
map['start_time'] = Variable<DateTime>(startTime.value); map['start_time'] = Variable<DateTime>(startTime.value);
} }
@ -1328,6 +1385,7 @@ class RidesCompanion extends UpdateCompanion<Ride> {
return (StringBuffer('RidesCompanion(') return (StringBuffer('RidesCompanion(')
..write('id: $id, ') ..write('id: $id, ')
..write('cloudRideId: $cloudRideId, ') ..write('cloudRideId: $cloudRideId, ')
..write('cloudOwnerUid: $cloudOwnerUid, ')
..write('startTime: $startTime, ') ..write('startTime: $startTime, ')
..write('endTime: $endTime, ') ..write('endTime: $endTime, ')
..write('distanceMiles: $distanceMiles, ') ..write('distanceMiles: $distanceMiles, ')
@ -10030,6 +10088,7 @@ typedef $$RidesTableCreateCompanionBuilder =
RidesCompanion Function({ RidesCompanion Function({
Value<int> id, Value<int> id,
Value<String?> cloudRideId, Value<String?> cloudRideId,
Value<String?> cloudOwnerUid,
required DateTime startTime, required DateTime startTime,
Value<DateTime?> endTime, Value<DateTime?> endTime,
Value<double> distanceMiles, Value<double> distanceMiles,
@ -10057,6 +10116,7 @@ typedef $$RidesTableUpdateCompanionBuilder =
RidesCompanion Function({ RidesCompanion Function({
Value<int> id, Value<int> id,
Value<String?> cloudRideId, Value<String?> cloudRideId,
Value<String?> cloudOwnerUid,
Value<DateTime> startTime, Value<DateTime> startTime,
Value<DateTime?> endTime, Value<DateTime?> endTime,
Value<double> distanceMiles, Value<double> distanceMiles,
@ -10224,6 +10284,11 @@ class $$RidesTableFilterComposer extends Composer<_$AppDatabase, $RidesTable> {
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<String> get cloudOwnerUid => $composableBuilder(
column: $table.cloudOwnerUid,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get startTime => $composableBuilder( ColumnFilters<DateTime> get startTime => $composableBuilder(
column: $table.startTime, column: $table.startTime,
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
@ -10504,6 +10569,11 @@ class $$RidesTableOrderingComposer
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<String> get cloudOwnerUid => $composableBuilder(
column: $table.cloudOwnerUid,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get startTime => $composableBuilder( ColumnOrderings<DateTime> get startTime => $composableBuilder(
column: $table.startTime, column: $table.startTime,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
@ -10632,6 +10702,11 @@ class $$RidesTableAnnotationComposer
builder: (column) => column, builder: (column) => column,
); );
GeneratedColumn<String> get cloudOwnerUid => $composableBuilder(
column: $table.cloudOwnerUid,
builder: (column) => column,
);
GeneratedColumn<DateTime> get startTime => GeneratedColumn<DateTime> get startTime =>
$composableBuilder(column: $table.startTime, builder: (column) => column); $composableBuilder(column: $table.startTime, builder: (column) => column);
@ -10922,6 +10997,7 @@ class $$RidesTableTableManager
({ ({
Value<int> id = const Value.absent(), Value<int> id = const Value.absent(),
Value<String?> cloudRideId = const Value.absent(), Value<String?> cloudRideId = const Value.absent(),
Value<String?> cloudOwnerUid = const Value.absent(),
Value<DateTime> startTime = const Value.absent(), Value<DateTime> startTime = const Value.absent(),
Value<DateTime?> endTime = const Value.absent(), Value<DateTime?> endTime = const Value.absent(),
Value<double> distanceMiles = const Value.absent(), Value<double> distanceMiles = const Value.absent(),
@ -10947,6 +11023,7 @@ class $$RidesTableTableManager
}) => RidesCompanion( }) => RidesCompanion(
id: id, id: id,
cloudRideId: cloudRideId, cloudRideId: cloudRideId,
cloudOwnerUid: cloudOwnerUid,
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
distanceMiles: distanceMiles, distanceMiles: distanceMiles,
@ -10974,6 +11051,7 @@ class $$RidesTableTableManager
({ ({
Value<int> id = const Value.absent(), Value<int> id = const Value.absent(),
Value<String?> cloudRideId = const Value.absent(), Value<String?> cloudRideId = const Value.absent(),
Value<String?> cloudOwnerUid = const Value.absent(),
required DateTime startTime, required DateTime startTime,
Value<DateTime?> endTime = const Value.absent(), Value<DateTime?> endTime = const Value.absent(),
Value<double> distanceMiles = const Value.absent(), Value<double> distanceMiles = const Value.absent(),
@ -10999,6 +11077,7 @@ class $$RidesTableTableManager
}) => RidesCompanion.insert( }) => RidesCompanion.insert(
id: id, id: id,
cloudRideId: cloudRideId, cloudRideId: cloudRideId,
cloudOwnerUid: cloudOwnerUid,
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
distanceMiles: distanceMiles, distanceMiles: distanceMiles,

View File

@ -4,6 +4,8 @@ class Rides extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
/// Stable MotorCloud identity preserved across local IDs, reinstalls, and sync retries. /// Stable MotorCloud identity preserved across local IDs, reinstalls, and sync retries.
TextColumn get cloudRideId => text().nullable()(); 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 startTime => dateTime()();
DateTimeColumn get endTime => dateTime().nullable()(); DateTimeColumn get endTime => dateTime().nullable()();
RealColumn get distanceMiles => real().withDefault(const Constant(0.0))(); RealColumn get distanceMiles => real().withDefault(const Constant(0.0))();

View File

@ -25,6 +25,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
_AuthFormMode _formMode = _AuthFormMode.signIn; _AuthFormMode _formMode = _AuthFormMode.signIn;
bool _obscurePassword = true; bool _obscurePassword = true;
bool _showAnonymousUpgrade = false;
String? _statusMessage; String? _statusMessage;
@override @override
@ -75,7 +76,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
isEvolution, isEvolution,
isWide, isWide,
); );
} else if (authState.isAnonymous) { } else if (authState.isAnonymous && !_showAnonymousUpgrade) {
content = _buildAnonymousView( content = _buildAnonymousView(
context, context,
authState.user!, authState.user!,
@ -719,8 +720,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
'SIGN IN WITH FULL ACCOUNT', 'SIGN IN WITH FULL ACCOUNT',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
onPressed: () => onPressed: () => setState(() => _showAnonymousUpgrade = true),
ref.read(authNotifierProvider.notifier).signOut(),
), ),
), ),
], ],
@ -803,7 +803,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
if (!isAuthenticated) ...[ if (!isAuthenticated) ...[
const Text( 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( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.white70, color: Colors.white70,
@ -891,7 +891,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
], ],
_buildInfoRow( _buildInfoRow(
'Cloud Provider', 'Cloud Provider',
'Firebase (Auth/Firestore) + Cloudflare R2', 'MotorCloud backup',
), ),
if (syncState.lastSuccessfulSync != null) if (syncState.lastSuccessfulSync != null)
_buildInfoRow( _buildInfoRow(
@ -1084,10 +1084,40 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
Future<void> _handleBackupAllHistoricalRides() async { Future<void> _handleBackupAllHistoricalRides() async {
final db = ref.read(databaseProvider); final db = ref.read(databaseProvider);
final syncManager = ref.read(syncManagerProvider); 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 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; var enqueued = 0;
for (final ride in rides) { 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); await syncManager.enqueueRide(ride.id, autoProcess: false);
enqueued++; enqueued++;
} }
@ -1096,7 +1126,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( 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), duration: const Duration(seconds: 3),
), ),
@ -1227,7 +1257,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
), ),
SizedBox(height: 2), SizedBox(height: 2),
Text( 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), style: TextStyle(fontSize: 11, color: Colors.white60),
), ),
], ],
@ -1304,10 +1334,14 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
displayName: displayName.isNotEmpty ? displayName : null, displayName: displayName.isNotEmpty ? displayName : null,
); );
} else if (_formMode == _AuthFormMode.forgotPassword) { } else if (_formMode == _AuthFormMode.forgotPassword) {
await notifier.sendPasswordResetEmail(email); try {
if (mounted) { await notifier.sendPasswordResetEmail(email);
} catch (_) {
return;
}
if (mounted && !ref.read(authNotifierProvider).hasError) {
setState(() { setState(() {
_statusMessage = 'Password reset instructions sent to $email'; _statusMessage = 'Password reset instructions sent. Check your email.';
}); });
} }
} }

View File

@ -363,6 +363,7 @@ class FirebaseAuthService implements AuthService {
user: _currentState.user, user: _currentState.user,
isFirebaseConfigured: true, isFirebaseConfigured: true,
)); ));
rethrow;
} }
} }

View File

@ -158,12 +158,16 @@ class CloudReconciliationService {
final existingCloudId = legacyRows.first.data['cloud_ride_id']; final existingCloudId = legacyRows.first.data['cloud_ride_id'];
if (existingCloudId == null || existingCloudId.toString().isEmpty) { if (existingCloudId == null || existingCloudId.toString().isEmpty) {
await _db.customStatement( await _db.customStatement(
'UPDATE rides SET cloud_ride_id = ? WHERE id = ?', 'UPDATE rides SET cloud_ride_id = ?, cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?',
[cloudRideId, localRideId], [cloudRideId, ownerId, localRideId],
); );
return const CloudReconciliationResult(unchanged: 1); return const CloudReconciliationResult(unchanged: 1);
} }
if (existingCloudId == cloudRideId) { 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(unchanged: 1);
} }
return const CloudReconciliationResult(conflicts: 1); return const CloudReconciliationResult(conflicts: 1);
@ -178,6 +182,7 @@ class CloudReconciliationService {
final endTime = _readDateTime(data['endTime'] ?? data['endedAt']); final endTime = _readDateTime(data['endTime'] ?? data['endedAt']);
await _insertRestoredMetadata( await _insertRestoredMetadata(
cloudRideId: cloudRideId, cloudRideId: cloudRideId,
ownerId: ownerId,
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
data: data, data: data,
@ -188,6 +193,7 @@ class CloudReconciliationService {
Future<void> _insertRestoredMetadata({ Future<void> _insertRestoredMetadata({
required String cloudRideId, required String cloudRideId,
required String ownerId,
required DateTime startTime, required DateTime startTime,
required DateTime? endTime, required DateTime? endTime,
required Map<String, dynamic> data, required Map<String, dynamic> data,
@ -196,6 +202,7 @@ class CloudReconciliationService {
''' '''
INSERT INTO rides ( INSERT INTO rides (
cloud_ride_id, cloud_ride_id,
cloud_owner_uid,
start_time, start_time,
end_time, end_time,
distance_miles, distance_miles,
@ -216,10 +223,11 @@ class CloudReconciliationService {
motorcycle_make, motorcycle_make,
motorcycle_model, motorcycle_model,
motorcycle_variant motorcycle_variant
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', ''',
[ [
cloudRideId, cloudRideId,
ownerId,
startTime.toUtc().millisecondsSinceEpoch, startTime.toUtc().millisecondsSinceEpoch,
endTime?.toUtc().millisecondsSinceEpoch, endTime?.toUtc().millisecondsSinceEpoch,
_asDouble(data['distanceMiles']), _asDouble(data['distanceMiles']),

View File

@ -43,10 +43,19 @@ class FirebaseSyncProvider implements SyncProvider {
@override @override
Future<SyncResult> uploadRide(SyncItem item) async { 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; final user = _authService.currentUser;
if (user == null || user.isAnonymous) return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.'); 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 // Refresh the Firebase ID token before a manual upload. This prevents a
// recently completed sign-in from being rejected because the cached token // recently completed sign-in from being rejected because the cached token
// has not yet propagated through the app's auth abstraction. // has not yet propagated through the app's auth abstraction.

View File

@ -3,6 +3,8 @@ import 'package:flutter/widgets.dart';
import 'sync_models.dart'; import 'sync_models.dart';
import 'sync_outbox.dart'; import 'sync_outbox.dart';
import 'sync_provider.dart'; import 'sync_provider.dart';
import '../../database/database.dart';
import '../auth/auth_service.dart';
/// Central provider-agnostic synchronization coordinator. /// Central provider-agnostic synchronization coordinator.
/// ///
@ -15,8 +17,10 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
String _activeProviderId = 'fake'; String _activeProviderId = 'fake';
bool _isProcessing = false; bool _isProcessing = false;
DateTime? Function() _nowProvider; 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) { if (initialProviders != null) {
for (final p in initialProviders) { registerProvider(p); } 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 { Future<SyncItem> enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async {
final providerId = targetProviderId ?? _activeProviderId; 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()); final item = await _outbox.enqueue(localRideId, providerId, now: _now());
notifyListeners(); notifyListeners();
if (autoProcess) unawaited(processQueue()); if (autoProcess) unawaited(processQueue());

View File

@ -79,6 +79,8 @@ final syncManagerProvider = ChangeNotifierProvider<SyncManager>((ref) {
ref.watch(githubSyncProvider), ref.watch(githubSyncProvider),
], ],
defaultProviderId: 'firebase', defaultProviderId: 'firebase',
db: ref.watch(databaseProvider),
authService: ref.watch(authServiceProvider),
); );
final accountSync = ref.watch(firebaseAccountSyncServiceProvider); final accountSync = ref.watch(firebaseAccountSyncServiceProvider);

View File

@ -90,6 +90,7 @@ void main() {
final migrated = await db.getCloudRideId(42); final migrated = await db.getCloudRideId(42);
expect(migrated, 'ride_42_20260831_210705'); expect(migrated, 'ride_42_20260831_210705');
expect(await db.getCloudOwnerUid(42), isNull);
final rows = await db.customSelect('PRAGMA table_info(rides)').get(); final rows = await db.customSelect('PRAGMA table_info(rides)').get();
expect(rows.any((row) => row.data['name'] == 'cloud_ride_id'), isTrue); expect(rows.any((row) => row.data['name'] == 'cloud_ride_id'), isTrue);

View File

@ -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');
});
}