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();
|
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())));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))();
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,7 @@ class FirebaseAuthService implements AuthService {
|
||||||
user: _currentState.user,
|
user: _currentState.user,
|
||||||
isFirebaseConfigured: true,
|
isFirebaseConfigured: true,
|
||||||
));
|
));
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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']),
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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