Skip to content

Commit 518ceae

Browse files
authored
Merge pull request #248 from Apoplexi/fix/tank-data-lost-after-editing-dive
Replace delete & re-insert approach for updating tank data with a real update mechanism
2 parents b2417d5 + 6fd88c7 commit 518ceae

3 files changed

Lines changed: 270 additions & 36 deletions

File tree

lib/features/dive_log/data/repositories/dive_repository_impl.dart

Lines changed: 94 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,21 @@ class DiveRepository {
753753
Future<void> updateDive(domain.Dive dive) async {
754754
try {
755755
_log.info('Updating dive: ${dive.id}');
756+
757+
// Validate that all tanks have non-empty IDs on update to prevent data loss
758+
// (If a tank ID is empty, it would generate a new UUID and cause the old tank to be deleted)
759+
final emptyTankIndices = dive.tanks
760+
.asMap()
761+
.entries
762+
.where((e) => e.value.id.isEmpty)
763+
.map((e) => e.key);
764+
if (emptyTankIndices.isNotEmpty) {
765+
throw ArgumentError(
766+
'Cannot update dive: tank(s) at index(es) ${emptyTankIndices.join(', ')} '
767+
'have empty IDs. All tanks must have valid IDs when updating a dive.',
768+
);
769+
}
770+
756771
final now = DateTime.now().millisecondsSinceEpoch;
757772

758773
await (_db.update(_db.dives)..where((t) => t.id.equals(dive.id))).write(
@@ -848,44 +863,90 @@ class DiveRepository {
848863
localUpdatedAt: now,
849864
);
850865

851-
// Update tanks: delete and re-insert
852-
final existingTanks = await (_db.select(
866+
// Update tanks:
867+
// Try to match existing tanks by ID to do updates instead of delete+insert when possible,
868+
// to preserve sync records and avoid unnecessary deletions/insertions in the sync engine.
869+
// Delete tanks that are no longer present.
870+
// This is more complex but should result in better sync behavior and performance when tanks
871+
// are edited but not completely changed (which is more likely).
872+
873+
// Get existing tank IDs for this dive
874+
final existingTankRows = await (_db.select(
853875
_db.diveTanks,
854876
)..where((t) => t.diveId.equals(dive.id))).get();
855-
await (_db.delete(
856-
_db.diveTanks,
857-
)..where((t) => t.diveId.equals(dive.id))).go();
858-
for (final tank in existingTanks) {
859-
await _syncRepository.logDeletion(
860-
entityType: 'diveTanks',
861-
recordId: tank.id,
862-
);
863-
}
877+
final existingTankIds = existingTankRows.map((t) => t.id).toSet();
878+
final updatedTankIds = <String>{};
879+
880+
// Update or insert tanks
864881
for (final tank in dive.tanks) {
865-
final tankId = tank.id.isNotEmpty ? tank.id : _uuid.v4();
866-
await _db
867-
.into(_db.diveTanks)
868-
.insert(
869-
DiveTanksCompanion(
870-
id: Value(tankId),
871-
diveId: Value(dive.id),
872-
volume: Value(tank.volume),
873-
workingPressure: Value(tank.workingPressure),
874-
startPressure: Value(tank.startPressure),
875-
endPressure: Value(tank.endPressure),
876-
o2Percent: Value(tank.gasMix.o2),
877-
hePercent: Value(tank.gasMix.he),
878-
tankOrder: Value(tank.order),
879-
tankRole: Value(tank.role.name),
880-
tankMaterial: Value(tank.material?.name),
881-
tankName: Value(tank.name),
882-
presetName: Value(tank.presetName),
883-
),
884-
);
885-
await _syncRepository.markRecordPending(
882+
// Tank ID is guaranteed to be non-empty by validation at start of method
883+
final tankId = tank.id;
884+
updatedTankIds.add(tankId);
885+
886+
if (existingTankIds.contains(tankId)) {
887+
// Update existing tank
888+
await (_db.update(
889+
_db.diveTanks,
890+
)..where((t) => t.id.equals(tankId))).write(
891+
DiveTanksCompanion(
892+
volume: Value(tank.volume),
893+
workingPressure: Value(tank.workingPressure),
894+
startPressure: Value(tank.startPressure),
895+
endPressure: Value(tank.endPressure),
896+
o2Percent: Value(tank.gasMix.o2),
897+
hePercent: Value(tank.gasMix.he),
898+
tankOrder: Value(tank.order),
899+
tankRole: Value(tank.role.name),
900+
tankMaterial: Value(tank.material?.name),
901+
tankName: Value(tank.name),
902+
presetName: Value(tank.presetName),
903+
),
904+
);
905+
// Log as pending update (assuming sync handles updates)
906+
await _syncRepository.markRecordPending(
907+
entityType: 'diveTanks',
908+
recordId: tankId,
909+
localUpdatedAt: now,
910+
);
911+
} else {
912+
// Insert new tank
913+
await _db
914+
.into(_db.diveTanks)
915+
.insert(
916+
DiveTanksCompanion(
917+
id: Value(tankId),
918+
diveId: Value(dive.id),
919+
volume: Value(tank.volume),
920+
workingPressure: Value(tank.workingPressure),
921+
startPressure: Value(tank.startPressure),
922+
endPressure: Value(tank.endPressure),
923+
o2Percent: Value(tank.gasMix.o2),
924+
hePercent: Value(tank.gasMix.he),
925+
tankOrder: Value(tank.order),
926+
tankRole: Value(tank.role.name),
927+
tankMaterial: Value(tank.material?.name),
928+
tankName: Value(tank.name),
929+
presetName: Value(tank.presetName),
930+
),
931+
);
932+
await _syncRepository.markRecordPending(
933+
entityType: 'diveTanks',
934+
recordId: tankId,
935+
localUpdatedAt: now,
936+
);
937+
}
938+
}
939+
940+
// Delete tanks that are no longer present
941+
// This will cascade to both tank_pressure_profiles and gas_switches for removed tanks
942+
final tanksToDelete = existingTankIds.difference(updatedTankIds);
943+
for (final tankId in tanksToDelete) {
944+
await (_db.delete(
945+
_db.diveTanks,
946+
)..where((t) => t.id.equals(tankId))).go();
947+
await _syncRepository.logDeletion(
886948
entityType: 'diveTanks',
887949
recordId: tankId,
888-
localUpdatedAt: now,
889950
);
890951
}
891952

test/features/dive_log/data/repositories/dive_repository_error_test.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,36 @@ void main() {
128128
);
129129
});
130130

131+
test(
132+
'should throw ArgumentError when updating dive with a tank that has an empty id',
133+
() async {
134+
final createdDive = await repository.createDive(
135+
Dive(
136+
id: 'test-dive',
137+
dateTime: DateTime.now(),
138+
tanks: const [
139+
DiveTank(
140+
id: '',
141+
volume: 12.0,
142+
startPressure: 200,
143+
endPressure: 50,
144+
),
145+
],
146+
),
147+
);
148+
149+
final updatedDive = createdDive.copyWith(
150+
tanks: [createdDive.tanks[0].copyWith(id: '')],
151+
notes: 'Attempt update with empty tank id',
152+
);
153+
154+
await expectLater(
155+
repository.updateDive(updatedDive),
156+
throwsA(isA<ArgumentError>()),
157+
);
158+
},
159+
);
160+
131161
test(
132162
'methods that return defaults return correct values on error',
133163
() async {

test/features/dive_log/data/repositories/dive_repository_test.dart

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import 'package:drift/drift.dart' hide isNull, isNotNull;
12
import 'package:flutter_test/flutter_test.dart';
23
import 'package:submersion/core/constants/enums.dart';
4+
import 'package:submersion/core/database/database.dart' as db;
35
import 'package:submersion/core/performance/perf_timer.dart';
6+
import 'package:submersion/core/services/database_service.dart';
47
import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart';
58
import 'package:submersion/features/buddies/domain/entities/buddy.dart';
69
import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart';
@@ -238,10 +241,16 @@ void main() {
238241
),
239242
);
240243

241-
final updatedDive = dive.copyWith(
244+
// Fetch the dive to get the generated tank IDs
245+
final fetchedDive = await repository.getDiveById(dive.id);
246+
expect(fetchedDive, isNotNull);
247+
expect(fetchedDive!.tanks.isNotEmpty, isTrue);
248+
final tankId = fetchedDive.tanks[0].id;
249+
250+
final updatedDive = fetchedDive.copyWith(
242251
tanks: [
243-
const DiveTank(
244-
id: '',
252+
DiveTank(
253+
id: tankId,
245254
volume: 15.0,
246255
startPressure: 210,
247256
endPressure: 40,
@@ -256,6 +265,140 @@ void main() {
256265
expect(result.tanks[0].volume, equals(15.0));
257266
expect(result.tanks[0].startPressure, equals(210));
258267
});
268+
269+
test(
270+
'should throw error when updating dive with empty tank IDs',
271+
() async {
272+
final dive = await repository.createDive(
273+
createTestDive(
274+
tanks: [
275+
const DiveTank(
276+
id: '',
277+
volume: 12.0,
278+
startPressure: 200,
279+
endPressure: 50,
280+
),
281+
],
282+
),
283+
);
284+
285+
// Try to update with tanks that have empty IDs (which should fail)
286+
final updatedDive = dive.copyWith(
287+
tanks: [
288+
const DiveTank(
289+
id: '', // Empty ID - this should trigger validation error
290+
volume: 15.0,
291+
startPressure: 210,
292+
endPressure: 40,
293+
),
294+
],
295+
);
296+
297+
expect(
298+
() => repository.updateDive(updatedDive),
299+
throwsA(
300+
isA<ArgumentError>().having(
301+
(e) => e.message,
302+
'message',
303+
contains('tank(s) at index(es) 0 have empty IDs'),
304+
),
305+
),
306+
);
307+
},
308+
);
309+
310+
test('should preserve tank_pressure_profiles and gas_switches', () async {
311+
final database = DatabaseService.instance.database;
312+
313+
// Create a dive with one tank
314+
final dive = await repository.createDive(
315+
createTestDive(
316+
tanks: [
317+
const DiveTank(
318+
id: '',
319+
volume: 12.0,
320+
startPressure: 200,
321+
endPressure: 50,
322+
),
323+
],
324+
),
325+
);
326+
327+
// Get the actual tank ID
328+
final createdDive = await repository.getDiveById(dive.id);
329+
expect(createdDive!.tanks.length, equals(1));
330+
final tankId = createdDive.tanks[0].id;
331+
332+
// Insert tank pressure profiles
333+
await database
334+
.into(database.tankPressureProfiles)
335+
.insert(
336+
db.TankPressureProfilesCompanion(
337+
id: const Value('profile1'),
338+
diveId: Value(dive.id),
339+
tankId: Value(tankId),
340+
timestamp: const Value(0),
341+
pressure: const Value(200.0),
342+
),
343+
);
344+
345+
// Insert gas switches
346+
await database
347+
.into(database.gasSwitches)
348+
.insert(
349+
db.GasSwitchesCompanion(
350+
id: const Value('switch1'),
351+
diveId: Value(dive.id),
352+
tankId: Value(tankId),
353+
timestamp: const Value(300),
354+
createdAt: Value(DateTime.now().millisecondsSinceEpoch),
355+
),
356+
);
357+
358+
// Verify initial data
359+
var pressureProfiles = await (database.select(
360+
database.tankPressureProfiles,
361+
)..where((t) => t.diveId.equals(dive.id))).get();
362+
expect(pressureProfiles.length, equals(1));
363+
364+
var gasSwitches = await (database.select(
365+
database.gasSwitches,
366+
)..where((t) => t.diveId.equals(dive.id))).get();
367+
expect(gasSwitches.length, equals(1));
368+
369+
// Update the dive WITHOUT changing tanks (just update dive fields)
370+
final updatedDive = createdDive.copyWith(
371+
maxDepth: 25.0,
372+
notes: 'Updated dive notes',
373+
rating: 5,
374+
);
375+
376+
await repository.updateDive(updatedDive);
377+
378+
// Verify that tank_pressure_profiles and gas_switches are still present
379+
pressureProfiles = await (database.select(
380+
database.tankPressureProfiles,
381+
)..where((t) => t.diveId.equals(dive.id))).get();
382+
expect(
383+
pressureProfiles.length,
384+
equals(1),
385+
reason:
386+
'Tank pressure profiles should not be deleted when updating dive',
387+
);
388+
389+
gasSwitches = await (database.select(
390+
database.gasSwitches,
391+
)..where((t) => t.diveId.equals(dive.id))).get();
392+
expect(
393+
gasSwitches.length,
394+
equals(1),
395+
reason: 'Gas switches should not be deleted when updating dive',
396+
);
397+
398+
// Verify the data is unchanged
399+
expect(pressureProfiles[0].pressure, equals(200.0));
400+
expect(gasSwitches[0].timestamp, equals(300));
401+
});
259402
});
260403

261404
group('deleteDive', () {

0 commit comments

Comments
 (0)