Skip to content

Commit 1cc0b62

Browse files
committed
feat(import): persist MacDive dive/site metadata to DB
Adds 7 new columns on dives and dive_sites for previously extract-but-discard fields: - dives: dive_number_of_day, boat_name, boat_captain, dive_operator, surface_conditions - dive_sites: water_type, body_of_water Also wires the existing weather_description column for UDDF imports (populated from MacDive <weather>). The importer calls narrow new applyImportedMetadata helpers on DiveRepository and SiteRepository so the new columns ride along with the existing repository-created rows without needing entity field churn (copyWith, equatable, sync serialiser). Schema v70 -> v71 with an idempotent migration guarded by PRAGMA introspection, matching the v69/v70 house style. Closes the parser-to-DB gap for the MacDive extended fields extracted in Milestone 1 Tasks 5-6.
1 parent 756a0f6 commit 1cc0b62

6 files changed

Lines changed: 461 additions & 2 deletions

File tree

lib/core/database/database.dart

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ class Dives extends Table {
126126
text().withDefault(const Constant('recreational'))();
127127
TextColumn get buddy => text().nullable()();
128128
TextColumn get diveMaster => text().nullable()();
129+
// MacDive import fields — common dive metadata
130+
IntColumn get diveNumberOfDay => integer().nullable()();
131+
TextColumn get boatName => text().nullable()();
132+
TextColumn get boatCaptain => text().nullable()();
133+
TextColumn get diveOperator => text().nullable()();
134+
TextColumn get surfaceConditions => text().nullable()();
129135
TextColumn get notes => text().withDefault(const Constant(''))();
130136
TextColumn get siteId => text().nullable().references(DiveSites, #id)();
131137
IntColumn get rating => integer().nullable()();
@@ -300,6 +306,9 @@ class DiveSites extends Table {
300306
RealColumn get maxDepth => real().nullable()(); // Deepest point
301307
TextColumn get difficulty =>
302308
text().nullable()(); // Beginner, Intermediate, Advanced, Technical
309+
// MacDive site metadata
310+
TextColumn get waterType => text().nullable()();
311+
TextColumn get bodyOfWater => text().nullable()();
303312
TextColumn get country => text().nullable()();
304313
TextColumn get region => text().nullable()();
305314
RealColumn get rating => real().nullable()();
@@ -1327,7 +1336,7 @@ class AppDatabase extends _$AppDatabase {
13271336

13281337
/// The current schema version as a static constant so that pre-open checks
13291338
/// (e.g. version-mismatch guard) can reference it without an instance.
1330-
static const int currentSchemaVersion = 70;
1339+
static const int currentSchemaVersion = 71;
13311340

13321341
/// Every schema version that has a migration block in onUpgrade.
13331342
/// Used to calculate progress step counts. When adding a new migration,
@@ -1401,6 +1410,7 @@ class AppDatabase extends _$AppDatabase {
14011410
68,
14021411
69,
14031412
70,
1413+
71,
14041414
];
14051415

14061416
/// Returns the number of migration steps that will execute when upgrading
@@ -3265,6 +3275,61 @@ class AppDatabase extends _$AppDatabase {
32653275
}
32663276
}
32673277
if (from < 70) await reportProgress();
3278+
if (from < 71) {
3279+
// Migration 71: add MacDive dive + site metadata fields.
3280+
final divesCols = await customSelect(
3281+
"PRAGMA table_info('dives')",
3282+
).get();
3283+
final divesExisting = divesCols
3284+
.map((r) => r.data['name'] as String)
3285+
.toSet();
3286+
if (divesCols.isNotEmpty) {
3287+
if (!divesExisting.contains('dive_number_of_day')) {
3288+
await customStatement(
3289+
'ALTER TABLE dives ADD COLUMN dive_number_of_day INTEGER',
3290+
);
3291+
}
3292+
if (!divesExisting.contains('boat_name')) {
3293+
await customStatement(
3294+
'ALTER TABLE dives ADD COLUMN boat_name TEXT',
3295+
);
3296+
}
3297+
if (!divesExisting.contains('boat_captain')) {
3298+
await customStatement(
3299+
'ALTER TABLE dives ADD COLUMN boat_captain TEXT',
3300+
);
3301+
}
3302+
if (!divesExisting.contains('dive_operator')) {
3303+
await customStatement(
3304+
'ALTER TABLE dives ADD COLUMN dive_operator TEXT',
3305+
);
3306+
}
3307+
if (!divesExisting.contains('surface_conditions')) {
3308+
await customStatement(
3309+
'ALTER TABLE dives ADD COLUMN surface_conditions TEXT',
3310+
);
3311+
}
3312+
}
3313+
final sitesCols = await customSelect(
3314+
"PRAGMA table_info('dive_sites')",
3315+
).get();
3316+
final sitesExisting = sitesCols
3317+
.map((r) => r.data['name'] as String)
3318+
.toSet();
3319+
if (sitesCols.isNotEmpty) {
3320+
if (!sitesExisting.contains('water_type')) {
3321+
await customStatement(
3322+
'ALTER TABLE dive_sites ADD COLUMN water_type TEXT',
3323+
);
3324+
}
3325+
if (!sitesExisting.contains('body_of_water')) {
3326+
await customStatement(
3327+
'ALTER TABLE dive_sites ADD COLUMN body_of_water TEXT',
3328+
);
3329+
}
3330+
}
3331+
}
3332+
if (from < 71) await reportProgress();
32683333
},
32693334
beforeOpen: (details) async {
32703335
// Enable foreign keys

lib/features/dive_import/data/services/uddf_entity_importer.dart

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'package:drift/drift.dart' show Value;
22
import 'package:submersion/core/constants/enums.dart';
33
import 'package:submersion/core/database/database.dart'
4-
show DiveDataSourcesCompanion;
4+
show DiveDataSourcesCompanion, DiveSitesCompanion, DivesCompanion;
55
import 'package:submersion/core/services/export/export_service.dart';
66
import 'package:submersion/core/services/location_service.dart';
77
import 'package:submersion/core/services/logger_service.dart';
@@ -813,6 +813,21 @@ class UddfEntityImporter {
813813
);
814814

815815
final createdSite = await repository.createSite(newSite);
816+
817+
// Write MacDive site metadata columns that don't flow through the
818+
// DiveSite domain entity. Only set columns when source provides a value.
819+
final waterType = siteData['waterType'] as String?;
820+
final bodyOfWater = siteData['bodyOfWater'] as String?;
821+
if (waterType != null || bodyOfWater != null) {
822+
await repository.applyImportedMetadata(
823+
createdSite.id,
824+
DiveSitesCompanion(
825+
waterType: Value(waterType),
826+
bodyOfWater: Value(bodyOfWater),
827+
),
828+
);
829+
}
830+
816831
if (uddfId != null) idMapping[uddfId] = createdSite;
817832
count++;
818833
onProgress?.call(ImportPhase.sites, count, selected.length);
@@ -1188,6 +1203,35 @@ class UddfEntityImporter {
11881203
await repos.diveRepository.createDive(dive);
11891204
importedDiveIds.add(diveId);
11901205

1206+
// Write MacDive dive metadata columns that don't flow through the Dive
1207+
// domain entity. Also plug `weather` into the existing weatherDescription
1208+
// column (it wasn't being populated for UDDF imports). Only issue the
1209+
// UPDATE when at least one value is present to avoid a no-op write.
1210+
final diveNumberOfDay = diveData['diveNumberOfDay'] as int?;
1211+
final boatName = diveData['boatName'] as String?;
1212+
final boatCaptain = diveData['boatCaptain'] as String?;
1213+
final diveOperator = diveData['diveOperator'] as String?;
1214+
final surfaceConditions = diveData['surfaceConditions'] as String?;
1215+
final weather = diveData['weather'] as String?;
1216+
if (diveNumberOfDay != null ||
1217+
boatName != null ||
1218+
boatCaptain != null ||
1219+
diveOperator != null ||
1220+
surfaceConditions != null ||
1221+
weather != null) {
1222+
await repos.diveRepository.applyImportedMetadata(
1223+
diveId,
1224+
DivesCompanion(
1225+
diveNumberOfDay: Value(diveNumberOfDay),
1226+
boatName: Value(boatName),
1227+
boatCaptain: Value(boatCaptain),
1228+
diveOperator: Value(diveOperator),
1229+
surfaceConditions: Value(surfaceConditions),
1230+
weatherDescription: Value(weather),
1231+
),
1232+
);
1233+
}
1234+
11911235
// Store per-tank pressure data
11921236
if (profileData != null && tanks.isNotEmpty) {
11931237
await _storeTankPressures(

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3573,6 +3573,37 @@ class DiveRepository {
35733573
}
35743574
}
35753575

3576+
/// Apply a partial [DivesCompanion] update to a dive row.
3577+
///
3578+
/// Used by the UDDF importer to persist fields that do not flow through
3579+
/// the [domain.Dive] entity (e.g. MacDive boat/operator/weather metadata).
3580+
/// Only columns set on [patch] are written; others are left untouched.
3581+
/// Marks the row pending for sync.
3582+
Future<void> applyImportedMetadata(
3583+
String diveId,
3584+
DivesCompanion patch,
3585+
) async {
3586+
try {
3587+
final now = DateTime.now().millisecondsSinceEpoch;
3588+
await (_db.update(_db.dives)..where((t) => t.id.equals(diveId))).write(
3589+
patch.copyWith(updatedAt: Value(now)),
3590+
);
3591+
await _syncRepository.markRecordPending(
3592+
entityType: 'dives',
3593+
recordId: diveId,
3594+
localUpdatedAt: now,
3595+
);
3596+
SyncEventBus.notifyLocalChange();
3597+
} catch (e, stackTrace) {
3598+
_log.error(
3599+
'Failed to apply imported metadata to dive: $diveId',
3600+
error: e,
3601+
stackTrace: stackTrace,
3602+
);
3603+
rethrow;
3604+
}
3605+
}
3606+
35763607
/// Insert a new computer reading snapshot.
35773608
Future<void> saveComputerReading(DiveDataSourcesCompanion reading) async {
35783609
try {

lib/features/dive_sites/data/repositories/site_repository_impl.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,36 @@ class SiteRepository {
153153
}
154154
}
155155

156+
/// Apply a partial [DiveSitesCompanion] update to a site row.
157+
///
158+
/// Used by the UDDF importer to persist columns that do not flow through
159+
/// the [domain.DiveSite] entity (e.g. MacDive waterType / bodyOfWater).
160+
/// Only columns set on [patch] are written; others are left untouched.
161+
/// Marks the row pending for sync.
162+
Future<void> applyImportedMetadata(
163+
String siteId,
164+
DiveSitesCompanion patch,
165+
) async {
166+
try {
167+
final now = DateTime.now().millisecondsSinceEpoch;
168+
await (_db.update(_db.diveSites)..where((t) => t.id.equals(siteId)))
169+
.write(patch.copyWith(updatedAt: Value(now)));
170+
await _syncRepository.markRecordPending(
171+
entityType: 'diveSites',
172+
recordId: siteId,
173+
localUpdatedAt: now,
174+
);
175+
SyncEventBus.notifyLocalChange();
176+
} catch (e, stackTrace) {
177+
_log.error(
178+
'Failed to apply imported metadata to site: $siteId',
179+
error: e,
180+
stackTrace: stackTrace,
181+
);
182+
rethrow;
183+
}
184+
}
185+
156186
/// Flip the shared state of a single site. Marks it pending for sync.
157187
Future<void> setShared(String id, bool isShared) async {
158188
try {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import 'package:drift/native.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:submersion/core/database/database.dart';
4+
5+
void main() {
6+
group('Migration v71 - MacDive dive + site metadata columns', () {
7+
/// Creates an in-memory database at v70 (pre-migration) with the dives
8+
/// and dive_sites tables the v71 migration touches.
9+
///
10+
/// Only the parent tables and the two columns-of-interest tables are
11+
/// created; the migration must cope with missing ancillary tables as it
12+
/// runs in older migration contexts.
13+
NativeDatabase setupDb() {
14+
return NativeDatabase.memory(
15+
setup: (rawDb) {
16+
rawDb.execute('PRAGMA foreign_keys = ON');
17+
rawDb.execute('PRAGMA user_version = 70');
18+
19+
// v70 schema: minimal columns present before v71 -- no MacDive
20+
// metadata. Only what's needed for the PRAGMA introspection to
21+
// find the tables and the ALTER TABLE statements to succeed.
22+
rawDb.execute('''
23+
CREATE TABLE dives (
24+
id TEXT NOT NULL PRIMARY KEY,
25+
dive_date_time INTEGER NOT NULL DEFAULT 0,
26+
dive_type TEXT NOT NULL DEFAULT 'recreational',
27+
notes TEXT NOT NULL DEFAULT '',
28+
is_favorite INTEGER NOT NULL DEFAULT 0,
29+
dive_mode TEXT NOT NULL DEFAULT 'oc',
30+
cns_start REAL NOT NULL DEFAULT 0,
31+
is_planned INTEGER NOT NULL DEFAULT 0,
32+
created_at INTEGER NOT NULL,
33+
updated_at INTEGER NOT NULL
34+
)
35+
''');
36+
rawDb.execute('''
37+
CREATE TABLE dive_sites (
38+
id TEXT NOT NULL PRIMARY KEY,
39+
name TEXT NOT NULL,
40+
description TEXT NOT NULL DEFAULT '',
41+
notes TEXT NOT NULL DEFAULT '',
42+
is_shared INTEGER NOT NULL DEFAULT 0,
43+
created_at INTEGER NOT NULL,
44+
updated_at INTEGER NOT NULL
45+
)
46+
''');
47+
},
48+
);
49+
}
50+
51+
test('fresh database has MacDive dive columns on dives table', () async {
52+
final db = AppDatabase(NativeDatabase.memory());
53+
addTearDown(db.close);
54+
55+
final cols = await db.customSelect("PRAGMA table_info('dives')").get();
56+
final names = cols.map((c) => c.read<String>('name')).toSet();
57+
58+
expect(names, contains('dive_number_of_day'));
59+
expect(names, contains('boat_name'));
60+
expect(names, contains('boat_captain'));
61+
expect(names, contains('dive_operator'));
62+
expect(names, contains('surface_conditions'));
63+
});
64+
65+
test(
66+
'fresh database has MacDive site columns on dive_sites table',
67+
() async {
68+
final db = AppDatabase(NativeDatabase.memory());
69+
addTearDown(db.close);
70+
71+
final cols = await db
72+
.customSelect("PRAGMA table_info('dive_sites')")
73+
.get();
74+
final names = cols.map((c) => c.read<String>('name')).toSet();
75+
76+
expect(names, contains('water_type'));
77+
expect(names, contains('body_of_water'));
78+
},
79+
);
80+
81+
test(
82+
'v70 -> v71 migration adds MacDive dive columns idempotently',
83+
() async {
84+
final nativeDb = setupDb();
85+
final db = AppDatabase(nativeDb);
86+
addTearDown(db.close);
87+
88+
// Opening the database triggers migration from v70 to v71.
89+
final cols = await db.customSelect("PRAGMA table_info('dives')").get();
90+
final names = cols.map((c) => c.read<String>('name')).toSet();
91+
92+
expect(names, contains('dive_number_of_day'));
93+
expect(names, contains('boat_name'));
94+
expect(names, contains('boat_captain'));
95+
expect(names, contains('dive_operator'));
96+
expect(names, contains('surface_conditions'));
97+
},
98+
);
99+
100+
test(
101+
'v70 -> v71 migration adds MacDive site columns idempotently',
102+
() async {
103+
final nativeDb = setupDb();
104+
final db = AppDatabase(nativeDb);
105+
addTearDown(db.close);
106+
107+
final cols = await db
108+
.customSelect("PRAGMA table_info('dive_sites')")
109+
.get();
110+
final names = cols.map((c) => c.read<String>('name')).toSet();
111+
112+
expect(names, contains('water_type'));
113+
expect(names, contains('body_of_water'));
114+
},
115+
);
116+
});
117+
}

0 commit comments

Comments
 (0)