diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1dd351e..f89695f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ All notable changes to Submersion are documented in this file. ### Added +- **MacDive photo import.** Photos referenced by MacDive native XML + (``) and SQLite (`ZDIVEIMAGE`) imports are now linked to dives. + After importing dives, the summary screen offers to locate the photos: + pick a folder and Submersion scans it, matches each recorded photo by + path (direct/rebase) or filename, and links matches to their dive as + standard local-file media that reference the files in place (no copying). + Works on desktop and mobile; re-picking another folder is safe (linking + is idempotent per dive + filename). Photos link to both newly-imported + and matched-existing duplicate dives. Non-image references are skipped + and counted; missing photos are reported in a summary. - MacDive UDDF imports now capture substantially richer dive data: boat name and captain, dive operator, surface conditions, weather (stored in the existing weather description field), plus site water type, body diff --git a/android/app/src/main/kotlin/app/submersion/LocalMediaHandler.kt b/android/app/src/main/kotlin/app/submersion/LocalMediaHandler.kt index b73670ded..cf4d5d46d 100644 --- a/android/app/src/main/kotlin/app/submersion/LocalMediaHandler.kt +++ b/android/app/src/main/kotlin/app/submersion/LocalMediaHandler.kt @@ -1,8 +1,10 @@ package app.submersion +import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri +import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -17,6 +19,12 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler * Calls ContentResolver.takePersistableUriPermission with read flag * and returns the URI string itself (which Dart-side stores as * MediaItem.bookmarkRef). + * - pickPersistableTreeUri(): String? + * Launches ACTION_OPEN_DOCUMENT_TREE, takes a persistable READ + * permission on the chosen tree, and returns its content://tree/... + * URI string. Returns null if the user cancelled. Requires an Activity + * (set via [attachActivity]) to launch the picker and receive its + * result through [onPickTreeResult]. * - resolveBookmark(bookmarkRef: String): String? * Returns the URI as a string if the resource still exists, null if * the underlying file is gone. @@ -31,21 +39,92 @@ class LocalMediaHandler( private val channel: MethodChannel, ) : MethodCallHandler { + /** + * The hosting Activity, required to launch the document-tree picker. + * FlutterActivity extends plain android.app.Activity (not a + * ComponentActivity), so we cannot use the AndroidX ActivityResult APIs; + * instead MainActivity forwards startActivityForResult outcomes to + * [onPickTreeResult]. + */ + private var activity: Activity? = null + + /** Result callback awaiting the folder-tree picker outcome, if any. */ + private var pendingPickResult: MethodChannel.Result? = null + init { channel.setMethodCallHandler(this) } + /** Wire (or clear) the hosting Activity. Called from MainActivity. */ + fun attachActivity(activity: Activity?) { + this.activity = activity + } + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "takePersistableUri" -> takePersistableUri(call, result) + "pickPersistableTreeUri" -> pickPersistableTreeUri(result) "resolveBookmark" -> resolveBookmark(call, result) "releaseBookmark" -> releaseBookmark(call, result) "listPersistedUris" -> listPersistedUris(result) "readUriBytes" -> readUriBytes(call, result) + "enumerateTree" -> enumerateTree(call, result) else -> result.notImplemented() } } + private fun pickPersistableTreeUri(result: MethodChannel.Result) { + val act = activity + if (act == null) { + result.error("NO_ACTIVITY", "No Activity available to launch picker", null) + return + } + if (pendingPickResult != null) { + result.error("ALREADY_PICKING", "A folder pick is already in progress", null) + return + } + pendingPickResult = result + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION, + ) + } + try { + act.startActivityForResult(intent, REQUEST_PICK_TREE) + } catch (e: Exception) { + pendingPickResult = null + result.error("PICK_FAILED", e.localizedMessage, null) + } + } + + /** + * Forwarded from MainActivity.onActivityResult. Takes a persistable READ + * permission on the chosen tree and completes the pending Dart result + * with the tree URI string (or null on cancellation). Returns true if + * this handler consumed the result. + */ + fun onPickTreeResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode != REQUEST_PICK_TREE) return false + val result = pendingPickResult ?: return true + pendingPickResult = null + val treeUri = data?.data + if (resultCode != Activity.RESULT_OK || treeUri == null) { + result.success(null) // cancelled / no selection + return true + } + try { + context.contentResolver.takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + result.success(treeUri.toString()) + } catch (e: SecurityException) { + result.error("PERMISSION_DENIED", e.localizedMessage, null) + } + return true + } + private fun takePersistableUri(call: MethodCall, result: MethodChannel.Result) { val uriStr = call.argument("uri") ?: return result.error("INVALID_ARGS", "uri required", null) @@ -113,7 +192,50 @@ class LocalMediaHandler( } } + private fun enumerateTree(call: MethodCall, result: MethodChannel.Result) { + val treeUriStr = call.argument("treeUri") + ?: return result.error("INVALID_ARGS", "treeUri required", null) + try { + val resolver = context.contentResolver + val treeUri = Uri.parse(treeUriStr) + val out = ArrayList>() + val stack = ArrayDeque() + stack.addLast(DocumentsContract.getTreeDocumentId(treeUri)) + while (stack.isNotEmpty()) { + val parentDocId = stack.removeLast() + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId) + resolver.query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + ), + null, null, null, + )?.use { c -> + while (c.moveToNext()) { + val docId = c.getString(0) + val name = c.getString(1) + val mime = c.getString(2) ?: "" + if (mime == DocumentsContract.Document.MIME_TYPE_DIR) { + stack.addLast(docId) + } else { + val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId) + out.add(hashMapOf("basename" to name, "contentUri" to docUri.toString())) + } + } + } + } + result.success(out) + } catch (e: Exception) { + result.error("ENUMERATE_FAILED", e.message, null) + } + } + companion object { const val CHANNEL = "com.submersion.app/local_media" + + /** startActivityForResult request code for the document-tree picker. */ + const val REQUEST_PICK_TREE = 0x5542 // 'SU' } } diff --git a/android/app/src/main/kotlin/app/submersion/MainActivity.kt b/android/app/src/main/kotlin/app/submersion/MainActivity.kt index f57b12765..28d3891e7 100644 --- a/android/app/src/main/kotlin/app/submersion/MainActivity.kt +++ b/android/app/src/main/kotlin/app/submersion/MainActivity.kt @@ -1,5 +1,6 @@ package app.submersion +import android.content.Intent import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -21,6 +22,25 @@ class MainActivity : FlutterActivity() { flutterEngine.dartExecutor.binaryMessenger, LocalMediaHandler.CHANNEL, ) - localMediaHandler = LocalMediaHandler(applicationContext, localMediaChannel) + // ContentResolver work runs against the application context, but the + // document-tree picker needs an Activity to startActivityForResult and + // a path back through onActivityResult (FlutterActivity is a plain + // android.app.Activity, so the AndroidX ActivityResult APIs are not + // available here). + localMediaHandler = LocalMediaHandler(applicationContext, localMediaChannel).also { + it.attachActivity(this) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (localMediaHandler?.onPickTreeResult(requestCode, resultCode, data) == true) { + return + } + super.onActivityResult(requestCode, resultCode, data) + } + + override fun onDestroy() { + localMediaHandler?.attachActivity(null) + super.onDestroy() } } diff --git a/docs/superpowers/plans/2026-05-21-macdive-photo-import.md b/docs/superpowers/plans/2026-05-21-macdive-photo-import.md new file mode 100644 index 000000000..bf89aef4c --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-macdive-photo-import.md @@ -0,0 +1,3260 @@ +# MacDive Photo Import (Reference-In-Place) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Revive MacDive photo import (closed PR #257) rebuilt on the merged local media pipeline. Photos referenced by MacDive native XML (``) and SQLite (`ZDIVEIMAGE`) are linked to dives as standard `localFile` `MediaItem`s that **reference files in place** (no byte copying). After dives are imported, a post-import prompt lets the user pick a folder; the app scans it, resolves each recorded photo path to an actual file, and creates `localFile` `MediaItem`s linked to the right dive. + +**Architecture:** Three phases. (1) PARSE — XML/SQLite readers emit `ImportImageRef`s onto `ImportPayload.imageRefs`. (2) IMPORT — the existing wizard runs UNCHANGED; the result now exposes `sourceUuidToDiveId` covering newly-created dives, and a session map of matched-existing dives is computed from `DiveRepository.getSourceUuidByDiveId`. (3) POST-IMPORT (Approach B) — a done-screen prompt drives a folder pick; a `DirectoryScanner` enumerates the folder (desktop `dart:io`; iOS scoped-dir channel; Android SAF tree channel) yielding a referenceable handle per file; `PhotoResolver` resolves each ref (direct → rebase → filename-index) to a `ScannedFile`; `LocalMediaLinker` (extracted from `FilesTabNotifier._persistOne`) creates a `localFile` `MediaItem` per resolved photo via `MediaRepository.createMedia`; the `ImportPhotoLinkController` orchestrates this, dedupes by `(diveId + basename)`, and emits a summary. There is NO `ImportedPhotoStorage` and NO byte copying. Pending refs are session-only (in memory) — no DB table, no migration. + +**Tech Stack:** Flutter, Dart 3, Drift ORM, Riverpod (`StateNotifier`), go_router, `file_picker` (desktop `getDirectoryPath`), `xml`, `sqlite3`, `native_exif`, `uuid`, `flutter_test`, `mockito`/`@GenerateMocks`, platform channels (Swift/Kotlin) on the existing `com.submersion.app/local_media` channel. + +## Background Reading + +- Authoritative design spec (locked decisions + rationale): [docs/superpowers/specs/2026-05-21-macdive-photo-import-design.md](../specs/2026-05-21-macdive-photo-import-design.md) +- Prior (superseded) plan, source of portable code: [docs/superpowers/plans/2026-04-21-macdive-photo-import.md](2026-04-21-macdive-photo-import.md) +- Media pipeline seams (current `main`): + - `lib/features/media/domain/entities/media_item.dart` — `MediaItem` ctor + `copyWith` (uses `_undefined` sentinel), `MediaType`, `MediaEnrichment`. + - `lib/features/media/domain/entities/media_source_type.dart` — `MediaSourceType.localFile`. + - `lib/features/media/data/repositories/media_repository.dart` — `createMedia` (empty `id` → UUID), `getMediaForDive`. + - `lib/features/media/presentation/providers/files_tab_providers.dart` — `FilesTabNotifier._persistOne` (lines ~184-235), the extraction source. + - `lib/features/media/data/services/local_media_platform.dart` — `createBookmark`, `takePersistableUri`; where iOS `enumerateScopedDirectory` + Android `enumerateTree` get ADDED. + - `lib/features/media/data/services/local_bookmark_storage.dart` — `write`/`read`. + - `lib/features/media/domain/value_objects/media_source_metadata.dart` — EXIF output type. + - `lib/features/media/data/services/exif_extractor.dart` — `ExifExtractor.extract(File)`. + - `lib/features/media/presentation/providers/media_resolver_providers.dart` — `localBookmarkStorageProvider`, `localMediaPlatformProvider`. + - `lib/features/media/presentation/providers/media_providers.dart` — `mediaRepositoryProvider`. +- Import pipeline seams (current `main`): + - `lib/features/universal_import/data/models/import_payload.dart` — add `imageRefs`. + - `lib/features/dive_import/data/services/uddf_entity_importer.dart` — `UddfEntityImportResult`, `_DiveImportResult`, `_importDives`. + - `lib/features/dive_log/data/repositories/dive_repository_impl.dart` — `getSourceUuidByDiveId({String? diverId})` returns `{diveId -> sourceUuid}`. + - `lib/features/import_wizard/data/adapters/universal_adapter.dart` — `performImport` returns `UnifiedImportResult`. + - `lib/features/import_wizard/domain/models/unified_import_result.dart` — add photo-pipeline fields. + - `lib/features/import_wizard/presentation/widgets/import_summary_step.dart` — done screen; prompt host. + - `lib/features/import_wizard/presentation/pages/unified_import_wizard.dart` — wires `ImportSummaryStep`. +- Worktree (deferred branch) — ACTUAL post-review portable code (prefer over the old plan): `.worktrees/macdive-photos/lib/features/universal_import/data/models/import_image_ref.dart`, `.../data/services/photo_resolver.dart`, the MacDive reader/model/mapper/parser photo additions, and the `UddfEntityImportResult.sourceUuidToDiveId` diff. + +## File Structure + +| File | Responsibility | New/Modified | +|---|---|---| +| `lib/features/universal_import/data/models/import_image_ref.dart` | `ImportImageRef` value type (one photo intention). | Create | +| `lib/features/universal_import/data/models/import_payload.dart` | Add `List imageRefs` field + `isEmpty`/`props`. | Modify | +| `lib/features/universal_import/data/services/macdive_raw_types.dart` | Add `MacDiveRawDiveImage` + `diveImages` on `MacDiveRawLogbook`. | Modify | +| `lib/features/universal_import/data/services/macdive_db_reader.dart` | Read `ZDIVEIMAGE` rows. | Modify | +| `lib/features/universal_import/data/services/macdive_dive_mapper.dart` | Emit `imageRefs` from `diveImages`. | Modify | +| `lib/features/universal_import/data/services/macdive_xml_models.dart` | Add `MacDiveXmlPhoto` + `photos` on `MacDiveXmlDive`. | Modify | +| `lib/features/universal_import/data/services/macdive_xml_reader.dart` | Parse ``. | Modify | +| `lib/features/universal_import/data/parsers/macdive_xml_parser.dart` | Emit `imageRefs` on payload. | Modify | +| `lib/features/universal_import/data/parsers/macdive_sqlite_parser.dart` | (No code change; payload already flows via mapper.) | — | +| `lib/features/dive_import/data/services/uddf_entity_importer.dart` | Add `sourceUuidToDiveId` to result + `_importDives`. | Modify | +| `lib/features/universal_import/data/value_objects/scanned_file.dart` | `ScannedFile` + `MediaHandle` value types. | Create | +| `lib/features/universal_import/data/services/directory_scanner.dart` | `DirectoryScanner` abstract + `GrantedFolder`. | Create | +| `lib/features/universal_import/data/services/desktop_directory_scanner.dart` | `dart:io` desktop impl. | Create | +| `lib/features/universal_import/data/services/photo_resolver.dart` | Resolver consuming a `DirectoryScanner`; outputs `ResolvedPhoto` (handle, no bytes). | Create | +| `lib/features/media/data/services/local_media_linker.dart` | Extracted persistence: `(diveId, ScannedFile, metadata) -> localFile MediaItem`. | Create | +| `lib/features/media/presentation/providers/files_tab_providers.dart` | Refactor `_persistOne` to call `LocalMediaLinker`. | Modify | +| `lib/features/universal_import/presentation/providers/import_photo_link_controller.dart` | Riverpod notifier orchestrating scan→resolve→link; dedupe; summary. | Create | +| `lib/features/import_wizard/domain/models/unified_import_result.dart` | Add `imageRefs` + `sourceUuidToDiveId`. | Modify | +| `lib/features/import_wizard/data/adapters/universal_adapter.dart` | Surface `imageRefs` + combined `sourceUuidToDiveId` into the result. | Modify | +| `lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart` | Post-import prompt UI (prompt/progress/summary/try-again). | Create | +| `lib/features/import_wizard/presentation/widgets/import_summary_step.dart` | Host the prompt below the success view. | Modify | +| `lib/features/media/data/services/local_media_platform.dart` | Add `enumerateScopedDirectory` (iOS) + `enumerateTree` (Android). | Modify | +| `lib/features/universal_import/data/services/ios_directory_scanner.dart` | iOS scanner over the channel. | Create | +| `lib/features/universal_import/data/services/android_directory_scanner.dart` | Android scanner over the channel. | Create | +| `macos/Runner/.../LocalMediaPlugin.swift` (+ iOS) | Native `enumerateScopedDirectory`. | Modify | +| `android/app/src/main/kotlin/.../LocalMediaPlugin.kt` | Native `enumerateTree`. | Modify | +| `test/features/universal_import/data/models/import_image_ref_test.dart` | `ImportImageRef`. | Create | +| `test/features/universal_import/data/models/import_payload_test.dart` | `imageRefs` field. | Create | +| `test/features/universal_import/data/services/macdive_db_reader_photo_test.dart` | `ZDIVEIMAGE` read. | Create | +| `test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart` | mapper → imageRefs. | Create | +| `test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart` | `` read. | Create | +| `test/features/universal_import/data/parsers/macdive_xml_photo_test.dart` | XML parser imageRefs. | Create | +| `test/features/universal_import/data/parsers/macdive_sqlite_photo_test.dart` | SQLite parser imageRefs. | Create | +| `test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart` | `sourceUuidToDiveId`. | Create | +| `test/features/universal_import/data/services/desktop_directory_scanner_test.dart` | desktop scan. | Create | +| `test/features/universal_import/data/services/photo_resolver_test.dart` | resolver strategies + single-scan. | Create | +| `test/features/media/data/services/local_media_linker_test.dart` | linker (mock repo/platform/storage). | Create | +| `test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart` | controller orchestration. | Create | +| `test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart` | prompt widget. | Create | +| `test/features/universal_import/data/parsers/macdive_sqlite_photos_real_test.dart` | gated real-sample (261 imageRefs) + fixture-folder link counts. | Create | +| `test/features/media/data/services/local_media_platform_enumerate_test.dart` | iOS/Android Dart wrappers via mock channel. | Create | +| `test/fixtures/macdive_sqlite/build_synthetic_db.dart` | Add `ZDIVEIMAGE` table + rows. | Modify | +| `test/fixtures/macdive_xml/metric_small.xml` | Add ``. | Modify | +| `CHANGELOG.md` | Unreleased entry. | Modify | + +--- + +## Task 1: `ImportImageRef` value object + +**Files:** +- Create: `lib/features/universal_import/data/models/import_image_ref.dart` +- Create (Test): `test/features/universal_import/data/models/import_image_ref_test.dart` + +- [ ] **Step 1: Write the failing test** (ported verbatim from the worktree). + +```dart +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; + +void main() { + group('ImportImageRef', () { + test('preserves fields verbatim', () { + const ref = ImportImageRef( + originalPath: '/Users/me/Pictures/shark.jpg', + diveSourceUuid: 'dive-1', + caption: 'Shark!', + position: 2, + sourceUuid: 'img-1', + ); + expect(ref.originalPath, '/Users/me/Pictures/shark.jpg'); + expect(ref.diveSourceUuid, 'dive-1'); + expect(ref.caption, 'Shark!'); + expect(ref.position, 2); + expect(ref.sourceUuid, 'img-1'); + }); + + test('optional fields default sensibly', () { + const ref = ImportImageRef( + originalPath: 'a.jpg', + diveSourceUuid: 'dive-1', + ); + expect(ref.caption, isNull); + expect(ref.position, 0); + expect(ref.sourceUuid, isNull); + }); + + group('filename', () { + test('extracts basename from POSIX path', () { + const ref = ImportImageRef( + originalPath: '/Users/me/Pictures/shark.jpg', + diveSourceUuid: 'd', + ); + expect(ref.filename, 'shark.jpg'); + }); + + test('extracts basename from Windows-style path', () { + const ref = ImportImageRef( + originalPath: r'C:\Users\me\Pictures\shark.jpg', + diveSourceUuid: 'd', + ); + expect(ref.filename, 'shark.jpg'); + }); + + test('returns path unchanged when no separator', () { + const ref = ImportImageRef( + originalPath: 'shark.jpg', + diveSourceUuid: 'd', + ); + expect(ref.filename, 'shark.jpg'); + }); + }); + }); +} +``` + +- [ ] **Step 2: Run — expect FAIL.** `flutter test test/features/universal_import/data/models/import_image_ref_test.dart`. Reason: `import_image_ref.dart` does not exist; the import fails to resolve so the test cannot compile. + +- [ ] **Step 3: Implement** `lib/features/universal_import/data/models/import_image_ref.dart` (ported from the worktree; no dependency on the dropped `ImportedPhotoStorage`). + +```dart +/// A photo referenced by a dive in the source dataset. Carries the +/// original filesystem path (absolute on the machine that wrote the +/// export), the caption (if any), the dive it belongs to (by source +/// UUID), and a display position for ordering. +/// +/// Populated by format-specific readers: +/// - [MacDiveDbReader] reads `ZDIVEIMAGE.ZPATH`/`ZORIGINALPATH` +/// - [MacDiveXmlReader] reads `` +/// +/// Consumed by the post-import photo-linking pipeline (PhotoResolver → +/// LocalMediaLinker via ImportPhotoLinkController). +class ImportImageRef { + /// Absolute path as recorded in the source (may not exist on the + /// machine running the import — the resolver handles misses). + final String originalPath; + + /// The dive this photo is attached to, matched by source UUID on the + /// dive map. + final String diveSourceUuid; + + /// Optional caption from the source. + final String? caption; + + /// Display position among a dive's photos (0-based). Sources that + /// don't record ordering default to 0. + final int position; + + /// Optional stable UUID the source assigned to this photo + /// (MacDive's `ZDIVEIMAGE.ZUUID`). Null when the source has no + /// per-photo ID. + final String? sourceUuid; + + const ImportImageRef({ + required this.originalPath, + required this.diveSourceUuid, + this.caption, + this.position = 0, + this.sourceUuid, + }); + + /// Filename component of [originalPath]. Used for filename-fallback + /// resolution when the original absolute path doesn't exist on the + /// local filesystem. + String get filename { + // Handle both POSIX and Windows separators — MacDive paths on a Mac + // use /, but a Windows-generated export might use \. + final slash = originalPath.lastIndexOf('/'); + final bslash = originalPath.lastIndexOf(r'\'); + final split = slash > bslash ? slash : bslash; + return split >= 0 ? originalPath.substring(split + 1) : originalPath; + } +} +``` + +- [ ] **Step 4: Run — expect PASS.** `flutter test test/features/universal_import/data/models/import_image_ref_test.dart` → 5 tests pass. + +- [ ] **Step 5: Format + commit.** + +```bash +dart format lib/features/universal_import/data/models/import_image_ref.dart test/features/universal_import/data/models/import_image_ref_test.dart +git add lib/features/universal_import/data/models/import_image_ref.dart test/features/universal_import/data/models/import_image_ref_test.dart +git commit -m "feat(import): add ImportImageRef value object" +``` + +--- + +## Task 2: `ImportPayload.imageRefs` field + +**Files:** +- Modify: `lib/features/universal_import/data/models/import_payload.dart` +- Create (Test): `test/features/universal_import/data/models/import_payload_test.dart` + +- [ ] **Step 1: Write the failing test.** + +```dart +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/models/import_payload.dart'; + +void main() { + test('defaults imageRefs to empty', () { + const payload = ImportPayload(entities: {}); + expect(payload.imageRefs, isEmpty); + }); + + test('carries imageRefs', () { + const payload = ImportPayload( + entities: {}, + imageRefs: [ + ImportImageRef(originalPath: '/photos/a.jpg', diveSourceUuid: 'dive-1'), + ], + ); + expect(payload.imageRefs.length, 1); + expect(payload.imageRefs.first.filename, 'a.jpg'); + }); + + test('imageRefs-only payload is not empty', () { + const payload = ImportPayload( + entities: {ImportEntityType.dives: []}, + imageRefs: [ + ImportImageRef(originalPath: '/photos/a.jpg', diveSourceUuid: 'd'), + ], + ); + expect(payload.isEmpty, isFalse); + expect(payload.isNotEmpty, isTrue); + }); + + test('truly empty payload is empty', () { + const payload = ImportPayload(entities: {ImportEntityType.dives: []}); + expect(payload.isEmpty, isTrue); + }); + + test('imageRefs participates in equality', () { + const a = ImportPayload( + entities: {}, + imageRefs: [ + ImportImageRef(originalPath: '/p/a.jpg', diveSourceUuid: 'd'), + ], + ); + const b = ImportPayload(entities: {}); + expect(a == b, isFalse); + }); +} +``` + +- [ ] **Step 2: Run — expect FAIL.** `flutter test test/features/universal_import/data/models/import_payload_test.dart`. Reason: `ImportPayload` has no `imageRefs` parameter, and `isEmpty` ignores image-only payloads — compile error on the named arg + the imageRefs-only assertion fails. + +- [ ] **Step 3: Implement.** Edit `lib/features/universal_import/data/models/import_payload.dart`. Add the import, the field, the constructor param, update `isEmpty`, and add `imageRefs` to `props`. + +Add after the existing imports: +```dart +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +``` + +Add the field after `metadata`: +```dart + /// Photo references extracted by the parser (MacDive native XML / + /// SQLite). Empty for formats that carry no photo references. Carried + /// out of parse for the post-import photo-linking pipeline. + final List imageRefs; +``` + +Update the constructor: +```dart + const ImportPayload({ + required this.entities, + this.warnings = const [], + this.metadata = const {}, + this.imageRefs = const [], + }); +``` + +Replace `isEmpty`: +```dart + /// Whether the payload has any data to import. An imageRefs-only payload + /// is NOT empty — photo intentions are still actionable post-import. + bool get isEmpty => + entities.values.every((list) => list.isEmpty) && imageRefs.isEmpty; +``` + +Replace `props`: +```dart + @override + List get props => [entities, warnings, metadata, imageRefs]; +``` + +- [ ] **Step 4: Run — expect PASS.** `flutter test test/features/universal_import/data/models/import_payload_test.dart` → 5 tests pass. + +- [ ] **Step 5: Format + commit.** + +```bash +dart format lib/features/universal_import/data/models/import_payload.dart test/features/universal_import/data/models/import_payload_test.dart +git add lib/features/universal_import/data/models/import_payload.dart test/features/universal_import/data/models/import_payload_test.dart +git commit -m "feat(import): add ImportPayload.imageRefs field" +``` + +--- + +## Task 3: MacDive SQLite photo extraction (`ZDIVEIMAGE` → `imageRefs`) + +**Files:** +- Modify: `lib/features/universal_import/data/services/macdive_raw_types.dart` +- Modify: `lib/features/universal_import/data/services/macdive_db_reader.dart` +- Modify: `lib/features/universal_import/data/services/macdive_dive_mapper.dart` +- Modify: `test/fixtures/macdive_sqlite/build_synthetic_db.dart` +- Create (Test): `test/features/universal_import/data/services/macdive_db_reader_photo_test.dart` +- Create (Test): `test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart` + +- [ ] **Step 1: Extend the synthetic DB fixture.** In `test/fixtures/macdive_sqlite/build_synthetic_db.dart`, inside `_createSchema(Database db)` (after the `ZMETADATA` table creation) add: + +```dart + db.execute(''' + CREATE TABLE ZDIVEIMAGE ( + Z_PK INTEGER PRIMARY KEY, Z_ENT INTEGER, Z_OPT INTEGER, + ZPOSITION INTEGER, ZRELATIONSHIPDIVE INTEGER, + ZCAPTION VARCHAR, ZORIGINALPATH VARCHAR, ZPATH VARCHAR, ZUUID VARCHAR + ) + '''); +``` + +Inside `_insertFixtureRows(Database db)` (at the end, after the `ZMETADATA` insert) add: + +```dart + // ---- dive images ---- + // Dive 1 gets 2 photos; dive 2 gets 1; dive 3 has none. + db.execute(''' + INSERT INTO ZDIVEIMAGE ( + Z_PK, ZPOSITION, ZRELATIONSHIPDIVE, + ZCAPTION, ZPATH, ZORIGINALPATH, ZUUID + ) VALUES + (1, 0, 1, 'Shark!', '/Users/test/Pictures/Diving/shark.jpg', + '/old/Pictures/shark.jpg', 'img-uuid-1'), + (2, 1, 1, NULL, '/Users/test/Pictures/Diving/turtle.jpg', + NULL, 'img-uuid-2'), + (3, 0, 2, 'Reef', '/Users/test/Pictures/Diving/reef.jpg', + NULL, 'img-uuid-3') + '''); +``` + +> NOTE: The fixture assumes dives with `Z_PK` 1 and 2 exist and have non-empty `ZUUID`s. The synthetic builder already inserts dives with PKs starting at 1 (see the existing `ZDIVE` inserts in the file). If a dive PK in the fixture lacks a `ZUUID`, the mapper drops the photo as an orphan and the mapper-test counts below must be adjusted; verify by reading the existing `ZDIVE` inserts before running. + +- [ ] **Step 2: Write the failing reader test** `test/features/universal_import/data/services/macdive_db_reader_photo_test.dart`. + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_db_reader.dart'; + +import '../../../../fixtures/macdive_sqlite/build_synthetic_db.dart'; + +void main() { + test('reads ZDIVEIMAGE rows into MacDiveRawLogbook.diveImages', () async { + final path = + '${Directory.systemTemp.path}/mdi_${DateTime.now().microsecondsSinceEpoch}.sqlite'; + final dbFile = buildSyntheticMacDiveDb(path); + addTearDown(() { + if (dbFile.existsSync()) dbFile.deleteSync(); + }); + + final logbook = await MacDiveDbReader.readAll( + Uint8List.fromList(await dbFile.readAsBytes()), + ); + + expect(logbook.diveImages.length, 3); + final first = logbook.diveImages.firstWhere((i) => i.pk == 1); + expect(first.caption, 'Shark!'); + expect(first.path, '/Users/test/Pictures/Diving/shark.jpg'); + expect(first.originalPath, '/old/Pictures/shark.jpg'); + expect(first.uuid, 'img-uuid-1'); + expect(first.diveFk, 1); + expect(first.position, 0); + + final second = logbook.diveImages.firstWhere((i) => i.pk == 2); + expect(second.caption, isNull); + expect(second.originalPath, isNull); + }); +} +``` + +> NOTE: Confirm the exact public name of the synthetic-DB builder function in `test/fixtures/macdive_sqlite/build_synthetic_db.dart` (the existing `macdive_db_reader_test.dart` already calls it). If the function is named differently than `buildSyntheticMacDiveDb`, use the real name in both tests. + +- [ ] **Step 3: Run — expect FAIL.** `flutter test test/features/universal_import/data/services/macdive_db_reader_photo_test.dart`. Reason: `MacDiveRawLogbook` has no `diveImages`, `MacDiveRawDiveImage` does not exist, and the reader does not query `ZDIVEIMAGE` — compile error. + +- [ ] **Step 4a: Add the raw type.** In `lib/features/universal_import/data/services/macdive_raw_types.dart` add this class (place it before `MacDiveRawLogbook`): + +```dart +/// A row from MacDive's `ZDIVEIMAGE` table — a photo reference attached +/// to a dive. The image bytes live on the filesystem at `ZPATH`; this +/// row carries only the reference. +class MacDiveRawDiveImage { + final int pk; + final String uuid; + final int diveFk; + final int position; + final String? caption; + + /// MacDive's current path for the photo (`ZPATH`). Usually an absolute + /// path on the machine MacDive last saw it on; may be just a UUID-based + /// basename for photos imported into MacDive's internal library. + final String? path; + + /// Original absolute path when MacDive first imported this photo + /// (`ZORIGINALPATH`). Typically null for internal-library photos. + final String? originalPath; + + const MacDiveRawDiveImage({ + required this.pk, + required this.uuid, + required this.diveFk, + this.position = 0, + this.caption, + this.path, + this.originalPath, + }); +} +``` + +In `MacDiveRawLogbook`, add the field, constructor param (defaulted so existing call sites stay valid): +```dart + final List diveImages; +``` +and in the constructor parameter list: +```dart + this.diveImages = const [], +``` + +- [ ] **Step 4b: Extend the reader.** In `lib/features/universal_import/data/services/macdive_db_reader.dart`, inside `readAll`, after `final events = _readEvents(db);` add: +```dart + final diveImages = _readDiveImages(db); +``` +and pass it to the `MacDiveRawLogbook(...)` constructor: +```dart + diveImages: diveImages, +``` +Add the reader method alongside the other `_read*` methods: +```dart + static List _readDiveImages(Database db) { + return _selectOrEmpty( + db, + 'SELECT * FROM ZDIVEIMAGE', + (r) => MacDiveRawDiveImage( + pk: r['Z_PK'] as int, + uuid: _str(r['ZUUID']) ?? '', + diveFk: (r['ZRELATIONSHIPDIVE'] as int?) ?? 0, + position: (r['ZPOSITION'] as int?) ?? 0, + caption: _str(r['ZCAPTION']), + path: _str(r['ZPATH']), + originalPath: _str(r['ZORIGINALPATH']), + ), + ); + } +``` + +- [ ] **Step 5: Run — expect PASS.** `flutter test test/features/universal_import/data/services/macdive_db_reader_photo_test.dart` → 1 test passes. + +- [ ] **Step 6: Write the failing mapper test** `test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart`. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_dive_mapper.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_raw_types.dart'; + +MacDiveRawLogbook _logbook({ + required List dives, + required List images, +}) { + return MacDiveRawLogbook( + dives: dives, + sitesByPk: const {}, + buddiesByPk: const {}, + tagsByPk: const {}, + gearByPk: const {}, + tanksByPk: const {}, + gasesByPk: const {}, + tankAndGases: const [], + crittersByPk: const {}, + certifications: const [], + serviceRecords: const [], + events: const [], + diveImages: images, + diveToBuddyPks: const {}, + diveToTagPks: const {}, + diveToGearPks: const {}, + diveToCritterPks: const {}, + unitsPreference: 'Metric', + ); +} + +void main() { + test('maps ZDIVEIMAGE rows to imageRefs keyed by dive UUID', () { + final payload = MacDiveDiveMapper.toPayload( + _logbook( + dives: const [ + MacDiveRawDive(pk: 1, uuid: 'dive-uuid-1'), + MacDiveRawDive(pk: 2, uuid: 'dive-uuid-2'), + ], + images: const [ + MacDiveRawDiveImage( + pk: 1, + uuid: 'img-1', + diveFk: 1, + position: 0, + caption: 'Shark!', + path: '/Users/test/Pictures/Diving/shark.jpg', + originalPath: '/old/shark.jpg', + ), + MacDiveRawDiveImage( + pk: 2, + uuid: 'img-2', + diveFk: 2, + position: 1, + path: '/Users/test/Pictures/Diving/turtle.jpg', + ), + ], + ), + ); + + expect(payload.imageRefs.length, 2); + final shark = payload.imageRefs.firstWhere((r) => r.caption == 'Shark!'); + expect(shark.diveSourceUuid, 'dive-uuid-1'); + expect(shark.originalPath, '/Users/test/Pictures/Diving/shark.jpg'); + expect(shark.position, 0); + expect(shark.sourceUuid, 'img-1'); + }); + + test('drops photos whose dive FK has no UUID (orphan rows)', () { + final payload = MacDiveDiveMapper.toPayload( + _logbook( + dives: const [MacDiveRawDive(pk: 1, uuid: 'dive-uuid-1')], + images: const [ + MacDiveRawDiveImage( + pk: 9, + uuid: 'img-9', + diveFk: 999, // no matching dive + path: '/x.jpg', + ), + ], + ), + ); + expect(payload.imageRefs, isEmpty); + }); + + test('falls back to originalPath when path is null; drops if both null', () { + final payload = MacDiveDiveMapper.toPayload( + _logbook( + dives: const [MacDiveRawDive(pk: 1, uuid: 'dive-uuid-1')], + images: const [ + MacDiveRawDiveImage( + pk: 1, + uuid: 'img-1', + diveFk: 1, + originalPath: '/orig/only.jpg', + ), + MacDiveRawDiveImage(pk: 2, uuid: 'img-2', diveFk: 1), + ], + ), + ); + expect(payload.imageRefs.length, 1); + expect(payload.imageRefs.single.originalPath, '/orig/only.jpg'); + }); +} +``` + +- [ ] **Step 7: Run — expect FAIL.** `flutter test test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart`. Reason: `MacDiveDiveMapper.toPayload` does not yet build `imageRefs`, so `payload.imageRefs` is empty. + +- [ ] **Step 8: Extend the mapper** (ported from the worktree). In `lib/features/universal_import/data/services/macdive_dive_mapper.dart` add the import at the top: +```dart +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +``` +Inside `toPayload`, after the `entities` map is built and before the `return ImportPayload(...)`, add: +```dart + // Build a diveFk -> sourceUuid lookup so we link photos without + // re-scanning the dives list per image. + final diveUuidByPk = { + for (final d in logbook.dives) + if (d.uuid.isNotEmpty) d.pk: d.uuid, + }; + + final imageRefs = []; + for (final img in logbook.diveImages) { + final diveUuid = diveUuidByPk[img.diveFk]; + if (diveUuid == null) continue; // orphan row, skip + final path = img.path ?? img.originalPath; + if (path == null || path.isEmpty) continue; + imageRefs.add( + ImportImageRef( + originalPath: path, + diveSourceUuid: diveUuid, + caption: img.caption, + position: img.position, + sourceUuid: img.uuid.isEmpty ? null : img.uuid, + ), + ); + } +``` +Change the `return ImportPayload(...)` to add `imageRefs: imageRefs,` as the final argument (keep existing `entities`, `warnings`, `metadata`). + +- [ ] **Step 9: Run — expect PASS.** `flutter test test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart` → 3 tests pass. Also run the reader test again to confirm no regression: `flutter test test/features/universal_import/data/services/macdive_db_reader_photo_test.dart`. + +- [ ] **Step 10: Format + commit.** + +```bash +dart format lib/features/universal_import/data/services/macdive_raw_types.dart lib/features/universal_import/data/services/macdive_db_reader.dart lib/features/universal_import/data/services/macdive_dive_mapper.dart test/fixtures/macdive_sqlite/build_synthetic_db.dart test/features/universal_import/data/services/macdive_db_reader_photo_test.dart test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart +git add lib/features/universal_import/data/services/macdive_raw_types.dart lib/features/universal_import/data/services/macdive_db_reader.dart lib/features/universal_import/data/services/macdive_dive_mapper.dart test/fixtures/macdive_sqlite/build_synthetic_db.dart test/features/universal_import/data/services/macdive_db_reader_photo_test.dart test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart +git commit -m "feat(import): extract MacDive SQLite ZDIVEIMAGE photos into imageRefs" +``` + +--- + +## Task 4: MacDive XML photo extraction (`` → `imageRefs`) + +**Files:** +- Modify: `lib/features/universal_import/data/services/macdive_xml_models.dart` +- Modify: `lib/features/universal_import/data/services/macdive_xml_reader.dart` +- Modify: `lib/features/universal_import/data/parsers/macdive_xml_parser.dart` +- Modify: `test/fixtures/macdive_xml/metric_small.xml` +- Create (Test): `test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart` +- Create (Test): `test/features/universal_import/data/parsers/macdive_xml_photo_test.dart` + +- [ ] **Step 1: Extend the XML fixture.** In `test/fixtures/macdive_xml/metric_small.xml`, inside the existing `` element (after the `` close tag, before ``) add: + +```xml + + /Users/test/Pictures/a.jpgShark + /Users/test/Pictures/b.jpg + +``` + +- [ ] **Step 2: Write the failing reader test** `test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart`. + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_xml_reader.dart'; + +void main() { + test('reads with path + caption and assigns positions', + () async { + final content = await File( + 'test/fixtures/macdive_xml/metric_small.xml', + ).readAsString(); + final dive = MacDiveXmlReader.parse(content).dives.first; + + expect(dive.photos.length, 2); + expect(dive.photos[0].path, '/Users/test/Pictures/a.jpg'); + expect(dive.photos[0].caption, 'Shark'); + expect(dive.photos[0].position, 0); + expect(dive.photos[1].path, '/Users/test/Pictures/b.jpg'); + expect(dive.photos[1].caption, isNull); + expect(dive.photos[1].position, 1); + }); +} +``` + +- [ ] **Step 3: Run — expect FAIL.** `flutter test test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart`. Reason: `MacDiveXmlDive` has no `photos`, `MacDiveXmlPhoto` does not exist — compile error. + +- [ ] **Step 4a: Add the model.** In `lib/features/universal_import/data/services/macdive_xml_models.dart` add (before `MacDiveXmlDive`): + +```dart +/// A photo referenced under a dive's `` container in MacDive XML. +class MacDiveXmlPhoto { + /// Absolute path as recorded in the XML. May not exist on the machine + /// running the import — the resolver handles misses. + final String path; + + /// Optional caption. Empty/whitespace-only comes through as null + /// (consistent with the reader's `_text` normaliser). + final String? caption; + + /// 0-based index among this dive's photos. Assigned by the reader. + final int position; + + const MacDiveXmlPhoto({required this.path, this.caption, this.position = 0}); +} +``` + +In `MacDiveXmlDive`, add the field after `samples`: +```dart + final List photos; +``` +and in the constructor parameter list (after `this.samples = const []`): +```dart + this.photos = const [], +``` + +- [ ] **Step 4b: Extend the reader.** In `lib/features/universal_import/data/services/macdive_xml_reader.dart`, in `_parseDive`, add `photos: _parsePhotos(el),` to the `MacDiveXmlDive(...)` construction (after `samples:`). Add the helper alongside the other `_parse*` methods: + +```dart + static List _parsePhotos(XmlElement dive) { + final container = dive.findElements('photos').firstOrNull; + if (container == null) return const []; + var idx = 0; + final out = []; + for (final p in container.findElements('photo')) { + final path = p.findElements('path').firstOrNull?.innerText.trim(); + if (path == null || path.isEmpty) { + idx++; // advance so subsequent positions stay stable + continue; + } + final caption = _text(p, 'caption'); + out.add(MacDiveXmlPhoto(path: path, caption: caption, position: idx)); + idx++; + } + return out; + } +``` + +- [ ] **Step 5: Run — expect PASS.** `flutter test test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart` → 1 test passes. + +- [ ] **Step 6: Write the failing parser test** `test/features/universal_import/data/parsers/macdive_xml_photo_test.dart`. + +```dart +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart'; + +void main() { + test('MacDiveXmlParser emits imageRefs from ', () async { + final content = await File( + 'test/fixtures/macdive_xml/metric_small.xml', + ).readAsString(); + final bytes = Uint8List.fromList(utf8.encode(content)); + + final payload = await const MacDiveXmlParser().parse(bytes); + + expect(payload.imageRefs.length, 2); + final first = payload.imageRefs.first; + expect(first.caption, 'Shark'); + expect(first.originalPath, '/Users/test/Pictures/a.jpg'); + // diveSourceUuid is the dive's ; assert it is non-empty and + // shared by both refs (single-dive fixture). + expect(first.diveSourceUuid, isNotEmpty); + expect( + payload.imageRefs.every( + (r) => r.diveSourceUuid == first.diveSourceUuid, + ), + isTrue, + ); + }); +} +``` + +> NOTE: The exact `diveSourceUuid` value equals the fixture dive's `` text. Read `test/fixtures/macdive_xml/metric_small.xml` to confirm it has a non-empty ``; if absent, add one (e.g. `20240601090000-ABC123`) in Step 1 and assert that exact value here. Photos require a non-empty dive identifier or the parser drops them. + +- [ ] **Step 7: Run — expect FAIL.** `flutter test test/features/universal_import/data/parsers/macdive_xml_photo_test.dart`. Reason: `MacDiveXmlParser` does not build `imageRefs` yet, so `payload.imageRefs` is empty. + +- [ ] **Step 8: Extend the parser** (ported from the worktree). In `lib/features/universal_import/data/parsers/macdive_xml_parser.dart` add the import: +```dart +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +``` +After the `entities` map is built and before the final `return ImportPayload(...)`, add: +```dart + final imageRefs = []; + for (final dive in logbook.dives) { + final diveUuid = dive.identifier; + if (diveUuid == null || diveUuid.isEmpty) continue; + for (final p in dive.photos) { + if (p.path.isEmpty) continue; + imageRefs.add( + ImportImageRef( + originalPath: p.path, + diveSourceUuid: diveUuid, + caption: p.caption, + position: p.position, + ), + ); + } + } +``` +Add `imageRefs: imageRefs,` as the final argument of the existing `return ImportPayload(...)`. + +- [ ] **Step 9: Run — expect PASS.** `flutter test test/features/universal_import/data/parsers/macdive_xml_photo_test.dart` → 1 test passes. Re-run the reader test to confirm no regression. + +- [ ] **Step 10: Format + commit.** + +```bash +dart format lib/features/universal_import/data/services/macdive_xml_models.dart lib/features/universal_import/data/services/macdive_xml_reader.dart lib/features/universal_import/data/parsers/macdive_xml_parser.dart test/fixtures/macdive_xml/metric_small.xml test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart test/features/universal_import/data/parsers/macdive_xml_photo_test.dart +git add lib/features/universal_import/data/services/macdive_xml_models.dart lib/features/universal_import/data/services/macdive_xml_reader.dart lib/features/universal_import/data/parsers/macdive_xml_parser.dart test/fixtures/macdive_xml/metric_small.xml test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart test/features/universal_import/data/parsers/macdive_xml_photo_test.dart +git commit -m "feat(import): extract MacDive XML into imageRefs" +``` + +--- + +## Task 5: SQLite parser imageRefs end-to-end test + +The SQLite parser (`macdive_sqlite_parser.dart`) requires NO code change — it already calls `MacDiveDiveMapper.toPayload`, which now emits `imageRefs` (Task 3). This task adds the end-to-end coverage proving the parser surfaces them. + +**Files:** +- Create (Test): `test/features/universal_import/data/parsers/macdive_sqlite_photo_test.dart` + +- [ ] **Step 1: Write the failing test.** + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_sqlite_parser.dart'; + +import '../../../../fixtures/macdive_sqlite/build_synthetic_db.dart'; + +void main() { + test('MacDiveSqliteParser emits imageRefs from ZDIVEIMAGE', () async { + final path = + '${Directory.systemTemp.path}/msp_${DateTime.now().microsecondsSinceEpoch}.sqlite'; + final dbFile = buildSyntheticMacDiveDb(path); + addTearDown(() { + if (dbFile.existsSync()) dbFile.deleteSync(); + }); + + final payload = await const MacDiveSqliteParser().parse( + Uint8List.fromList(await dbFile.readAsBytes()), + ); + + // Fixture (Task 3): dives 1 & 2 carry photos (2 + 1). Adjust the count + // if the fixture's ZDIVE rows for PK 1/2 lack UUIDs (mapper drops those). + expect(payload.imageRefs.length, 3); + final shark = payload.imageRefs.firstWhere((r) => r.caption == 'Shark!'); + expect(shark.originalPath, '/Users/test/Pictures/Diving/shark.jpg'); + expect(shark.diveSourceUuid, isNotEmpty); + }); +} +``` + +> NOTE: The expected count (3) assumes the synthetic builder's `ZDIVE` rows for PK 1 and 2 have non-empty `ZUUID`s (so the mapper keeps all three photos). If they don't, the count drops accordingly — read the existing `ZDIVE` inserts and set the count to match. This is the same assumption flagged in Task 3 Step 1. + +- [ ] **Step 2: Run — expect PASS immediately** (no production change needed; this is a confirmation test that the Task 3 plumbing reaches the parser). `flutter test test/features/universal_import/data/parsers/macdive_sqlite_photo_test.dart`. If it FAILS with count 0, the mapper change from Task 3 was not applied; if it fails with a different non-zero count, adjust the expectation per the NOTE. + +- [ ] **Step 3: Format + commit.** + +```bash +dart format test/features/universal_import/data/parsers/macdive_sqlite_photo_test.dart +git add test/features/universal_import/data/parsers/macdive_sqlite_photo_test.dart +git commit -m "test(import): MacDive SQLite parser surfaces imageRefs end-to-end" +``` + +--- + +## Task 6: `UddfEntityImportResult.sourceUuidToDiveId` (newly-created dives) + +Expose the source-UUID → new-dive-ID map the linker needs. Matched-existing dives are handled in Task 11 (which builds the combined map fed to the controller) via `DiveRepository.getSourceUuidByDiveId`; this task covers newly-created dives only. + +**Files:** +- Modify: `lib/features/dive_import/data/services/uddf_entity_importer.dart` +- Create (Test): `test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart` + +- [ ] **Step 1: Write the failing test.** This is a focused unit test on the result type plus its default. Full importer integration is covered by Task 11's gated test. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; + +void main() { + test('UddfEntityImportResult defaults sourceUuidToDiveId to empty', () { + const result = UddfEntityImportResult(); + expect(result.sourceUuidToDiveId, isEmpty); + }); + + test('UddfEntityImportResult carries sourceUuidToDiveId', () { + const result = UddfEntityImportResult( + dives: 2, + diveIds: ['db-1', 'db-2'], + sourceUuidToDiveId: {'src-1': 'db-1', 'src-2': 'db-2'}, + ); + expect(result.sourceUuidToDiveId, {'src-1': 'db-1', 'src-2': 'db-2'}); + expect(result.diveIds, ['db-1', 'db-2']); + }); +} +``` + +- [ ] **Step 2: Run — expect FAIL.** `flutter test test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart`. Reason: `UddfEntityImportResult` has no `sourceUuidToDiveId` parameter — compile error. + +- [ ] **Step 3: Implement** (ported from the worktree diff). In `lib/features/dive_import/data/services/uddf_entity_importer.dart`: + +In `UddfEntityImportResult`, add the field after `diveIds`: +```dart + /// Maps source-side dive UUID -> the newly-created dive's internal ID. + /// + /// Populated for every imported dive whose source record carried a + /// non-null `sourceUuid` key. Used by the post-import photo-linking + /// pipeline to resolve `ImportImageRef.diveSourceUuid` to the new dive + /// row. Dives whose source record had no UUID are absent — callers MUST + /// treat missing entries as "no mapping" rather than as an error. + final Map sourceUuidToDiveId; +``` +Add to its constructor (after `this.diveIds = const []`): +```dart + this.sourceUuidToDiveId = const {}, +``` +In `import(...)`, in the final `return UddfEntityImportResult(...)`, add (after `diveIds: divesResult.diveIds,`): +```dart + sourceUuidToDiveId: divesResult.sourceUuidToDiveId, +``` +In `_importDives`, declare the map near `final importedDiveIds = [];`: +```dart + final sourceUuidToDiveId = {}; +``` +After `await repos.diveRepository.createDive(dive);` and `importedDiveIds.add(diveId);`, add: +```dart + // Capture source UUID -> new dive ID so the post-import photo + // pipeline can resolve ImportImageRef.diveSourceUuid to this row. + // Only dives whose source record carried a non-null sourceUuid land + // in the map. + final sourceUuidValue = diveData['sourceUuid'] as String?; + if (sourceUuidValue != null && sourceUuidValue.isNotEmpty) { + sourceUuidToDiveId[sourceUuidValue] = diveId; + } +``` +Change the `_importDives` return from `return _DiveImportResult(count, inlineBuddyIds.length, importedDiveIds);` to: +```dart + return _DiveImportResult( + count, + inlineBuddyIds.length, + importedDiveIds, + sourceUuidToDiveId, + ); +``` +In the `_DiveImportResult` class, add the field + constructor param: +```dart + final Map sourceUuidToDiveId; +``` +and update its constructor: +```dart + const _DiveImportResult( + this.count, + this.inlineBuddies, [ + this.diveIds = const [], + this.sourceUuidToDiveId = const {}, + ]); +``` + +- [ ] **Step 4: Run — expect PASS.** `flutter test test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart` → 2 tests pass. + +- [ ] **Step 5: Format + commit.** + +```bash +dart format lib/features/dive_import/data/services/uddf_entity_importer.dart test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart +git add lib/features/dive_import/data/services/uddf_entity_importer.dart test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart +git commit -m "feat(import): expose sourceUuidToDiveId from UddfEntityImporter" +``` + +--- + +## Task 7: `DirectoryScanner` interface + `ScannedFile`/`MediaHandle` + desktop impl + +Define the scanner abstraction yielding a referenceable handle per file, and the desktop (`dart:io`) implementation. The handle carries everything `LocalMediaLinker` needs to persist a file without re-walking: a desktop path, an iOS bookmark blob, or an Android content URI. + +**Files:** +- Create: `lib/features/universal_import/data/value_objects/scanned_file.dart` +- Create: `lib/features/universal_import/data/services/directory_scanner.dart` +- Create: `lib/features/universal_import/data/services/desktop_directory_scanner.dart` +- Create (Test): `test/features/universal_import/data/services/desktop_directory_scanner_test.dart` + +- [ ] **Step 1: Write the failing test.** + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/desktop_directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +void main() { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('desktop_scanner_'); + }); + tearDown(() { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + }); + + test('enumerates files recursively with basename + desktop-path handle', + () async { + File('${tmp.path}/a.jpg').writeAsBytesSync([1]); + final sub = Directory('${tmp.path}/nested')..createSync(); + File('${sub.path}/b.png').writeAsBytesSync([2]); + + final scanner = DesktopDirectoryScanner(); + final files = await scanner + .scan(GrantedFolder(path: tmp.path)) + .toList(); + + expect(files.length, 2); + final byName = {for (final f in files) f.basename: f}; + expect(byName.keys.toSet(), {'a.jpg', 'b.png'}); + expect(byName['a.jpg']!.handle.localPath, '${tmp.path}/a.jpg'); + expect(byName['a.jpg']!.handle.bookmarkRef, isNull); + expect(byName['b.png']!.handle.localPath, '${sub.path}/b.png'); + }); + + test('yields nothing for a missing folder (no throw)', () async { + final scanner = DesktopDirectoryScanner(); + final files = await scanner + .scan(const GrantedFolder(path: '/does/not/exist/xyz')) + .toList(); + expect(files, isEmpty); + }); + + test('skips directories, yields only files', () async { + Directory('${tmp.path}/onlydir')..createSync(); + File('${tmp.path}/c.jpg').writeAsBytesSync([3]); + final scanner = DesktopDirectoryScanner(); + final files = await scanner.scan(GrantedFolder(path: tmp.path)).toList(); + expect(files.map((f) => f.basename), ['c.jpg']); + }); +} +``` + +- [ ] **Step 2: Run — expect FAIL.** `flutter test test/features/universal_import/data/services/desktop_directory_scanner_test.dart`. Reason: none of `ScannedFile`, `MediaHandle`, `GrantedFolder`, `DirectoryScanner`, `DesktopDirectoryScanner` exist — compile error. + +- [ ] **Step 3a: Implement `scanned_file.dart`.** + +```dart +import 'dart:typed_data'; + +/// A platform-neutral, persistable reference to a single file produced by +/// a [DirectoryScanner] during enumeration. Exactly one of the three +/// reference forms is populated, matching the platform: +/// - desktop: [localPath] +/// - iOS / macOS: [bookmarkBlob] (a security-scoped bookmark created while +/// the directory scope was held) +/// - Android: [contentUri] (a per-file document URI under a persisted tree) +/// +/// [LocalMediaLinker] consumes this without touching the filesystem again. +class MediaHandle { + /// Desktop absolute filesystem path. Null on mobile. + final String? localPath; + + /// iOS / macOS security-scoped bookmark blob, created during the scan + /// while the directory's scope was held. Null off iOS / macOS. + final Uint8List? bookmarkBlob; + + /// Android per-file content URI under a persisted tree. Null off Android. + final String? contentUri; + + const MediaHandle({this.localPath, this.bookmarkBlob, this.contentUri}) + : assert( + localPath != null || bookmarkBlob != null || contentUri != null, + 'MediaHandle requires exactly one reference form', + ); + + /// Desktop convenience constructor. + const MediaHandle.localPath(String path) : this(localPath: path); + + /// iOS / macOS convenience constructor. + const MediaHandle.bookmark(Uint8List blob) : this(bookmarkBlob: blob); + + /// Android convenience constructor. + const MediaHandle.contentUri(String uri) : this(contentUri: uri); + + /// True when this handle is an Android-style content URI used as the + /// `bookmarkRef` on the persisted [MediaItem] row. + String? get bookmarkRef => contentUri; +} + +/// One file discovered during a [DirectoryScanner.scan]: its [basename] +/// (for filename-index resolution) plus a persistable [handle]. +class ScannedFile { + final String basename; + final MediaHandle handle; + + const ScannedFile({required this.basename, required this.handle}); +} +``` + +- [ ] **Step 3b: Implement `directory_scanner.dart`.** + +```dart +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// A user-granted folder to enumerate. On desktop [path] is an absolute +/// filesystem path; on Android it is a persisted tree URI string; on +/// iOS / macOS it is a security-scoped directory URL string. +class GrantedFolder { + final String path; + const GrantedFolder({required this.path}); +} + +/// Platform abstraction enumerating a user-granted folder recursively and +/// yielding a persistable [ScannedFile] per file. +/// +/// The iOS / macOS implementation MUST create each file's security-scoped +/// bookmark while the directory scope is held (during the walk), which is +/// why the stream yields a [ScannedFile] carrying a handle rather than +/// just a name. Callers enumerate exactly once per run. +abstract class DirectoryScanner { + Stream scan(GrantedFolder folder); +} +``` + +- [ ] **Step 3c: Implement `desktop_directory_scanner.dart`.** + +```dart +import 'dart:io'; + +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Desktop (Windows / Linux / macOS) [DirectoryScanner] backed by +/// `dart:io`. The recorded paths in the export came from a Mac, so the +/// handle is the absolute filesystem path; no bookmark is needed because +/// desktop file access does not expire. +class DesktopDirectoryScanner implements DirectoryScanner { + final _log = LoggerService.forClass(DesktopDirectoryScanner); + + @override + Stream scan(GrantedFolder folder) async* { + final dir = Directory(folder.path); + if (!await dir.exists()) return; + Stream entities; + try { + entities = dir.list(recursive: true, followLinks: false); + } catch (e, st) { + _log.error('Failed to list directory: ${folder.path}', error: e, stackTrace: st); + return; + } + await for (final entity in entities) { + if (entity is! File) continue; + final sep = Platform.pathSeparator; + final idx = entity.path.lastIndexOf(sep); + final basename = idx >= 0 ? entity.path.substring(idx + 1) : entity.path; + yield ScannedFile( + basename: basename, + handle: MediaHandle.localPath(entity.path), + ); + } + } +} +``` + +- [ ] **Step 4: Run — expect PASS.** `flutter test test/features/universal_import/data/services/desktop_directory_scanner_test.dart` → 3 tests pass. + +- [ ] **Step 5: Format + commit.** + +```bash +dart format lib/features/universal_import/data/value_objects/scanned_file.dart lib/features/universal_import/data/services/directory_scanner.dart lib/features/universal_import/data/services/desktop_directory_scanner.dart test/features/universal_import/data/services/desktop_directory_scanner_test.dart +git add lib/features/universal_import/data/value_objects/scanned_file.dart lib/features/universal_import/data/services/directory_scanner.dart lib/features/universal_import/data/services/desktop_directory_scanner.dart test/features/universal_import/data/services/desktop_directory_scanner_test.dart +git commit -m "feat(import): add DirectoryScanner abstraction with desktop impl" +``` + +--- + +## Task 8: `PhotoResolver` consuming a `DirectoryScanner` + +Refactor the worktree's `PhotoResolver` (which used `dart:io` directly) to consume a `DirectoryScanner`. It scans ONCE per `resolveAll`, builds a filename index from the single stream, and resolves each ref direct → rebase → filename-index, emitting a `ResolvedPhoto` carrying the matched `ScannedFile` (handle, no bytes). + +**Files:** +- Create: `lib/features/universal_import/data/services/photo_resolver.dart` +- Create (Test): `test/features/universal_import/data/services/photo_resolver_test.dart` + +- [ ] **Step 1: Write the failing test.** Uses a fake `DirectoryScanner` so the resolver is platform-neutral and the scan-once behavior is observable. + +```dart +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/photo_resolver.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Fake scanner that yields a fixed list of (basename -> desktop path) and +/// counts how many times scan() is invoked. +class _FakeScanner implements DirectoryScanner { + _FakeScanner(this._files); + final Map _files; // basename -> absolute path + int scanCount = 0; + + @override + Stream scan(GrantedFolder folder) async* { + scanCount++; + for (final entry in _files.entries) { + yield ScannedFile( + basename: entry.key, + handle: MediaHandle.localPath(entry.value), + ); + } + } +} + +void main() { + group('PhotoResolver via DirectoryScanner', () { + test('rebase: recorded tail grafted onto picked root', () async { + final scanner = _FakeScanner({ + 'shark.jpg': '/picked/Photos/Diving/shark.jpg', + }); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const [ + ImportImageRef( + originalPath: '/Users/other/Photos/Diving/shark.jpg', + diveSourceUuid: 'd', + ), + ]); + expect(results.single.kind, PhotoResolutionKind.rebased); + expect( + results.single.scannedFile!.handle.localPath, + '/picked/Photos/Diving/shark.jpg', + ); + }); + + test('filename-index match when path tail does not graft', () async { + final scanner = _FakeScanner({ + 'b.jpg': '/picked/deep/nested/b.jpg', + }); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const [ + ImportImageRef( + originalPath: '/other/machine/x/b.jpg', + diveSourceUuid: 'd', + ), + ]); + expect(results.single.kind, PhotoResolutionKind.filenameMatch); + expect( + results.single.scannedFile!.handle.localPath, + '/picked/deep/nested/b.jpg', + ); + }); + + test('miss when basename absent from scan', () async { + final scanner = _FakeScanner({'other.jpg': '/picked/other.jpg'}); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const [ + ImportImageRef(originalPath: '/x/missing.jpg', diveSourceUuid: 'd'), + ]); + expect(results.single.kind, PhotoResolutionKind.miss); + expect(results.single.scannedFile, isNull); + expect(results.single.ref.originalPath, '/x/missing.jpg'); + }); + + test('scans exactly once for many refs', () async { + final scanner = _FakeScanner({ + for (var i = 0; i < 10; i++) 'p$i.jpg': '/picked/p$i.jpg', + }); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final refs = List.generate( + 10, + (i) => ImportImageRef(originalPath: '/x/p$i.jpg', diveSourceUuid: 'd'), + ); + final results = await resolver.resolveAll(refs); + expect(results.every((r) => r.kind == PhotoResolutionKind.filenameMatch), isTrue); + expect(scanner.scanCount, 1); + }); + + test('non-image extensions are skipped and counted', () async { + final scanner = _FakeScanner({'movie.mov': '/picked/movie.mov'}); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const [ + ImportImageRef(originalPath: '/x/movie.mov', diveSourceUuid: 'd'), + ]); + expect(results.single.kind, PhotoResolutionKind.skippedNonImage); + expect(results.single.scannedFile, isNull); + }); + + test('empty input returns empty output without scanning', () async { + final scanner = _FakeScanner({'a.jpg': '/picked/a.jpg'}); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const []); + expect(results, isEmpty); + expect(scanner.scanCount, 0); + }); + }); +} +``` + +- [ ] **Step 2: Run — expect FAIL.** `flutter test test/features/universal_import/data/services/photo_resolver_test.dart`. Reason: `PhotoResolver`, `ResolvedPhoto`, `PhotoResolutionKind` do not exist — compile error. + +- [ ] **Step 3: Implement** `lib/features/universal_import/data/services/photo_resolver.dart`. (Adapted from the worktree: the direct-path `File.exists` strategy is dropped from the index path because the scanner already yields only files that exist under the picked root; the recorded basename is matched against the scanned index. The `skippedNonImage` outcome is new per the spec.) + +```dart +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// How a photo was located (or not) during resolution. +enum PhotoResolutionKind { + /// Found by grafting the recorded path tail onto the picked root. + rebased, + + /// Found by matching the recorded basename against the scanned index. + filenameMatch, + + /// The recorded reference is a non-image extension; skipped + counted. + skippedNonImage, + + /// Not found by any strategy. + miss, +} + +/// Result of resolving one [ImportImageRef] against a scanned folder. +/// Carries the matched [ScannedFile] (handle, NOT bytes) when found. +class ResolvedPhoto { + final ImportImageRef ref; + final PhotoResolutionKind kind; + final ScannedFile? scannedFile; + + const ResolvedPhoto({ + required this.ref, + required this.kind, + this.scannedFile, + }); +} + +/// Resolves recorded photo paths to actual files under a user-picked +/// folder. Enumerates the folder exactly once via [scanner], builds a +/// basename index + a path-tail index from that single stream, then +/// resolves each ref. Outputs handles only — no bytes are read. +class PhotoResolver { + final DirectoryScanner scanner; + final GrantedFolder folder; + + const PhotoResolver({required this.scanner, required this.folder}); + + static const _imageExtensions = { + 'jpg', 'jpeg', 'png', 'heic', 'heif', 'gif', 'tif', 'tiff', 'webp', 'bmp', + }; + + Future> resolveAll(List refs) async { + if (refs.isEmpty) return const []; + + // Single scan: build a basename -> list of ScannedFile index. + final byBasename = >{}; + await for (final file in scanner.scan(folder)) { + byBasename.putIfAbsent(file.basename, () => []).add(file); + } + + final results = []; + for (final ref in refs) { + results.add(_resolveOne(ref, byBasename)); + } + return results; + } + + ResolvedPhoto _resolveOne( + ImportImageRef ref, + Map> byBasename, + ) { + if (!_isImage(ref.filename)) { + return ResolvedPhoto( + ref: ref, + kind: PhotoResolutionKind.skippedNonImage, + ); + } + + final candidates = byBasename[ref.filename] ?? const []; + if (candidates.isEmpty) { + return ResolvedPhoto(ref: ref, kind: PhotoResolutionKind.miss); + } + + // Rebase preference: among same-basename candidates, prefer the one + // whose desktop path shares the longest trailing path segment run with + // the recorded path. A shared tail beyond the basename is a "rebase"; + // a bare-basename-only match is "filenameMatch". + final recordedSegments = _segments(ref.originalPath); + ScannedFile? best; + var bestSharedTail = 0; + for (final candidate in candidates) { + final candPath = candidate.handle.localPath ?? candidate.basename; + final shared = _sharedTailLength(recordedSegments, _segments(candPath)); + if (best == null || shared > bestSharedTail) { + best = candidate; + bestSharedTail = shared; + } + } + + return ResolvedPhoto( + ref: ref, + kind: bestSharedTail >= 2 + ? PhotoResolutionKind.rebased + : PhotoResolutionKind.filenameMatch, + scannedFile: best, + ); + } + + bool _isImage(String filename) { + final dot = filename.lastIndexOf('.'); + if (dot < 0) return false; + return _imageExtensions.contains(filename.substring(dot + 1).toLowerCase()); + } + + List _segments(String path) => path + .split(RegExp(r'[\\/]+')) + .where((s) => s.isNotEmpty) + .toList(growable: false); + + /// Number of trailing segments [a] and [b] share (counting the basename). + int _sharedTailLength(List a, List b) { + var i = a.length - 1; + var j = b.length - 1; + var shared = 0; + while (i >= 0 && j >= 0 && a[i] == b[j]) { + shared++; + i--; + j--; + } + return shared; + } +} +``` + +- [ ] **Step 4: Run — expect PASS.** `flutter test test/features/universal_import/data/services/photo_resolver_test.dart` → 6 tests pass. + +- [ ] **Step 5: Format + commit.** + +```bash +dart format lib/features/universal_import/data/services/photo_resolver.dart test/features/universal_import/data/services/photo_resolver_test.dart +git add lib/features/universal_import/data/services/photo_resolver.dart test/features/universal_import/data/services/photo_resolver_test.dart +git commit -m "feat(import): add PhotoResolver consuming DirectoryScanner" +``` + +--- + +## Task 9: `LocalMediaLinker` extraction + refactor `FilesTabNotifier` + +Extract the `localFile`-persistence logic currently inside `FilesTabNotifier._persistOne` into a reusable `LocalMediaLinker` service so imported photos and Files-tab photos share one persistence path. Then refactor `FilesTabNotifier` to call it, keeping its existing tests green. + +**Files:** +- Create: `lib/features/media/data/services/local_media_linker.dart` +- Modify: `lib/features/media/presentation/providers/files_tab_providers.dart` +- Create (Test): `test/features/media/data/services/local_media_linker_test.dart` +- Modify (regenerate mocks): `test/features/media/presentation/providers/files_tab_providers_test.dart` stays as-is; its `.mocks.dart` is reused. + +- [ ] **Step 1: Write the failing linker test.** Mocks `MediaRepository`, `LocalMediaPlatform`, `LocalBookmarkStorage`. The linker takes a `diveId`, a `MediaHandle` (desktop path on the test host), and a `MediaSourceMetadata`, and returns the created `MediaItem`. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/local_bookmark_storage.dart'; +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/data/services/local_media_platform.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +import 'local_media_linker_test.mocks.dart'; + +MediaItem _saved(String id) => MediaItem( + id: id, + mediaType: MediaType.photo, + takenAt: DateTime(2024), + createdAt: DateTime(2024), + updatedAt: DateTime(2024), +); + +@GenerateMocks([MediaRepository, LocalMediaPlatform, LocalBookmarkStorage]) +void main() { + late MockMediaRepository repo; + late MockLocalMediaPlatform platform; + late MockLocalBookmarkStorage bookmarkStorage; + late LocalMediaLinker linker; + + setUp(() { + repo = MockMediaRepository(); + platform = MockLocalMediaPlatform(); + bookmarkStorage = MockLocalBookmarkStorage(); + linker = LocalMediaLinker( + mediaRepository: repo, + platform: platform, + bookmarkStorage: bookmarkStorage, + ); + when(repo.createMedia(any)).thenAnswer((inv) async { + final item = inv.positionalArguments.single as MediaItem; + return item.copyWith(id: item.id.isEmpty ? 'generated-id' : item.id); + }); + }); + + test('desktop handle: persists localPath, no bookmark side effects', () async { + final created = await linker.link( + diveId: 'dive-1', + handle: const MediaHandle.localPath('/picked/shark.jpg'), + basename: 'shark.jpg', + metadata: const MediaSourceMetadata( + mimeType: 'image/jpeg', + latitude: 1.5, + longitude: -2.5, + width: 800, + height: 600, + ), + caption: 'Reef shark', + fallbackTakenAt: DateTime(2020, 1, 1), + ); + + expect(created.id, 'generated-id'); + final captured = + verify(repo.createMedia(captureAny)).captured.single as MediaItem; + expect(captured.diveId, 'dive-1'); + expect(captured.sourceType, MediaSourceType.localFile); + expect(captured.mediaType, MediaType.photo); + expect(captured.localPath, '/picked/shark.jpg'); + expect(captured.filePath, '/picked/shark.jpg'); + expect(captured.bookmarkRef, isNull); + expect(captured.originalFilename, 'shark.jpg'); + expect(captured.caption, 'Reef shark'); + expect(captured.latitude, 1.5); + expect(captured.longitude, -2.5); + expect(captured.width, 800); + expect(captured.height, 600); + verifyNever(platform.createBookmark(any)); + verifyNever(bookmarkStorage.write(any, any)); + }); + + test('android handle: persists bookmarkRef from content URI, null localPath', + () async { + await linker.link( + diveId: 'dive-2', + handle: const MediaHandle.contentUri('content://tree/doc/42'), + basename: 'turtle.jpg', + metadata: const MediaSourceMetadata(mimeType: 'image/jpeg'), + fallbackTakenAt: DateTime(2020), + ); + final captured = + verify(repo.createMedia(captureAny)).captured.single as MediaItem; + expect(captured.bookmarkRef, 'content://tree/doc/42'); + expect(captured.localPath, isNull); + expect(captured.sourceType, MediaSourceType.localFile); + }); + + test('iOS handle: stores bookmark blob under a generated key', () async { + await linker.link( + diveId: 'dive-3', + handle: MediaHandle.bookmark(Uint8List.fromList([9, 9, 9])), + basename: 'eel.jpg', + metadata: const MediaSourceMetadata(mimeType: 'image/jpeg'), + fallbackTakenAt: DateTime(2020), + ); + final keyCap = verify(bookmarkStorage.write(captureAny, any)).captured; + expect(keyCap.single, isA()); + final captured = + verify(repo.createMedia(captureAny)).captured.single as MediaItem; + expect(captured.bookmarkRef, isNotEmpty); + expect(captured.localPath, isNull); + }); + + test('falls back to fallbackTakenAt when metadata.takenAt is null', () async { + await linker.link( + diveId: 'dive-4', + handle: const MediaHandle.localPath('/picked/a.jpg'), + basename: 'a.jpg', + metadata: const MediaSourceMetadata(mimeType: 'image/jpeg'), + fallbackTakenAt: DateTime(2019, 6, 1), + ); + final captured = + verify(repo.createMedia(captureAny)).captured.single as MediaItem; + expect(captured.takenAt, DateTime(2019, 6, 1)); + }); +} +``` + +> NOTE: `Uint8List` needs `import 'dart:typed_data';` at the top of the test — add it. + +- [ ] **Step 2: Generate mocks + run — expect FAIL.** + +```bash +dart run build_runner build --delete-conflicting-outputs +flutter test test/features/media/data/services/local_media_linker_test.dart +``` +Reason: `LocalMediaLinker` does not exist — build fails to find the class for mock generation / the test cannot compile. + +- [ ] **Step 3: Implement** `lib/features/media/data/services/local_media_linker.dart`. This mirrors the platform branching in `FilesTabNotifier._persistOne` exactly, generalised to accept a `MediaHandle` (so the iOS/Android handles produced by the scanner are honored directly without re-deriving a path), and uses `MediaRepository.createMedia` (empty `id` → UUID). + +```dart +import 'dart:io'; + +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/local_bookmark_storage.dart'; +import 'package:submersion/features/media/data/services/local_media_platform.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Turns a (diveId, [MediaHandle], [MediaSourceMetadata]) into a persisted +/// `localFile` [MediaItem]. Extracted from `FilesTabNotifier._persistOne` +/// so imported photos and Files-tab photos share one persistence path and +/// cannot drift. +/// +/// Handle handling: +/// - [MediaHandle.localPath] -> stored as `localPath` (desktop). +/// - [MediaHandle.contentUri] -> stored as `bookmarkRef` (Android; the +/// scanner already took the persistable URI permission during the walk). +/// - [MediaHandle.bookmarkBlob] -> written to the keychain under a fresh +/// UUID via [LocalBookmarkStorage]; that UUID becomes `bookmarkRef` +/// (iOS / macOS; the blob was created while the directory scope was held). +/// On macOS the path is also stored in `localPath` for "Show in Finder", +/// matching `_persistOne`. +class LocalMediaLinker { + LocalMediaLinker({ + required this.mediaRepository, + required this.platform, + required this.bookmarkStorage, + }); + + final MediaRepository mediaRepository; + final LocalMediaPlatform platform; + final LocalBookmarkStorage bookmarkStorage; + + static const _uuid = Uuid(); + final _log = LoggerService.forClass(LocalMediaLinker); + + /// Persists one file as a `localFile` [MediaItem] linked to [diveId] and + /// returns the created item. [basename] is recorded as `originalFilename` + /// and, for desktop handles, as `filePath`. [metadata] supplies EXIF + /// lat/long/dimensions/takenAt; [fallbackTakenAt] is used when + /// `metadata.takenAt` is null. + Future link({ + required String diveId, + required MediaHandle handle, + required String basename, + required MediaSourceMetadata metadata, + required DateTime fallbackTakenAt, + String? caption, + }) async { + try { + String? localPath; + String? bookmarkRef; + + if (handle.localPath != null) { + localPath = handle.localPath; + } else if (handle.contentUri != null) { + bookmarkRef = handle.contentUri; + } else if (handle.bookmarkBlob != null) { + bookmarkRef = _uuid.v4(); + await bookmarkStorage.write(bookmarkRef, handle.bookmarkBlob!); + // macOS desktop UX needs the path for "Show in Finder"; the + // bookmark stays the source of truth for resolution. iOS keeps + // localPath null (sandbox-scoped picker path is not reusable). + if (Platform.isMacOS && metadata.mimeType.isNotEmpty) { + // No path available from a bookmark-only handle; left null. + } + } + + final isVideo = metadata.mimeType.startsWith('video/'); + final now = DateTime.now(); + final item = MediaItem( + // Empty id triggers UUID generation in MediaRepository.createMedia. + id: '', + diveId: diveId, + mediaType: isVideo ? MediaType.video : MediaType.photo, + sourceType: MediaSourceType.localFile, + localPath: localPath, + bookmarkRef: bookmarkRef, + filePath: localPath, + originalFilename: basename, + caption: caption, + takenAt: metadata.takenAt ?? fallbackTakenAt, + latitude: metadata.latitude, + longitude: metadata.longitude, + width: metadata.width, + height: metadata.height, + durationSeconds: metadata.durationSeconds, + createdAt: now, + updatedAt: now, + ); + + return await mediaRepository.createMedia(item); + } catch (e, st) { + _log.error( + 'Failed to link local media for dive: $diveId', + error: e, + stackTrace: st, + ); + rethrow; + } + } +} +``` + +> NOTE: `MediaItem` has no caption-only path in the Files tab today; the linker passes `caption` straight through to `createMedia`, which already persists `MediaItem.caption`. EXIF capture mirrors the Files tab: lat/long/width/height/takenAt come from `MediaSourceMetadata`. Captions therefore persist on the media row itself via the existing `MediaItem.caption` column; no `MediaEnrichment` fallback is needed. + +- [ ] **Step 4: Run — expect PASS.** `flutter test test/features/media/data/services/local_media_linker_test.dart` → 4 tests pass. + +- [ ] **Step 5: Refactor `FilesTabNotifier._persistOne` to delegate.** In `lib/features/media/presentation/providers/files_tab_providers.dart`, add imports: +```dart +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; +``` +Add a `LocalMediaLinker` field built from the existing deps, and rewrite `_persistOne` to construct the right `MediaHandle` per platform and call the linker. Replace the body of `_persistOne` with: +```dart + Future _persistOne(ExtractedFile file, String diveId) async { + final MediaHandle handle; + if (Platform.isIOS || Platform.isMacOS) { + final blob = await platform.createBookmark(file.file.path); + handle = MediaHandle.bookmark(blob); + } + // coverage:ignore-start + else if (Platform.isAndroid) { + final uri = await platform.takePersistableUri(file.file.path); + handle = MediaHandle.contentUri(uri); + } else { + handle = MediaHandle.localPath(file.file.path); + } + // coverage:ignore-end + + final basename = file.file.path.split(Platform.pathSeparator).last; + final saved = await _linker.link( + diveId: diveId, + handle: handle, + basename: basename, + metadata: file.metadata, + fallbackTakenAt: DateTime.now(), + ); + return saved.id; + } +``` +Add the `_linker` field initialised in the constructor body or as a late final: +```dart + late final LocalMediaLinker _linker = LocalMediaLinker( + mediaRepository: mediaRepository, + platform: platform, + bookmarkStorage: bookmarkStorage, + ); +``` + +> RECONCILIATION RISK: The existing `files_tab_providers_test.dart` asserts (line ~306) that on macOS `_persistOne` sets `localPath` to the picker path. The bookmark-only `MediaHandle` produced above does NOT carry that path, so the linker leaves `localPath` null on macOS — which would break that assertion. To preserve current behavior, in the iOS/macOS branch build the handle so macOS keeps its path. Use a handle that carries BOTH the bookmark blob and (on macOS) the localPath. Adjust `MediaHandle.bookmark` call to: +> ```dart +> handle = MediaHandle( +> bookmarkBlob: blob, +> localPath: Platform.isMacOS ? file.file.path : null, +> ); +> ``` +> and update `LocalMediaLinker.link` so that when BOTH `bookmarkBlob` and `localPath` are present it writes the blob to the keychain (bookmarkRef) AND stores localPath. The simplest implementation: check `handle.bookmarkBlob != null` first (write keychain + set bookmarkRef), then independently `if (handle.localPath != null) localPath = handle.localPath;`. Reorder the branches in `link` accordingly and relax the `MediaHandle` assertion to allow blob+path together. Add a linker test asserting blob+path produces both `bookmarkRef` and `localPath`. This keeps `files_tab_providers_test.dart` green. + +- [ ] **Step 6: Run the Files-tab tests — expect PASS (no regression).** +```bash +flutter test test/features/media/presentation/providers/files_tab_providers_test.dart +flutter test test/features/media/data/services/local_media_linker_test.dart +``` + +- [ ] **Step 7: Format + commit.** + +```bash +dart format lib/features/media/data/services/local_media_linker.dart lib/features/media/presentation/providers/files_tab_providers.dart test/features/media/data/services/local_media_linker_test.dart +git add lib/features/media/data/services/local_media_linker.dart lib/features/media/presentation/providers/files_tab_providers.dart test/features/media/data/services/local_media_linker_test.dart test/features/media/data/services/local_media_linker_test.mocks.dart +git commit -m "refactor(media): extract LocalMediaLinker from FilesTabNotifier._persistOne" +``` + +--- + +## Task 10: `ImportPhotoLinkController` orchestration + +A Riverpod `StateNotifier` holding the session's `imageRefs` + `sourceUuidToDiveId`. On folder pick it drives scanner → `PhotoResolver` → `LocalMediaLinker`, dedupes by `(diveId + basename)`, isolates per-photo failures, and emits progress + a summary `{total, linked, notFound, skippedNonImage}`. Re-picking another folder is safe (idempotent by basename). + +**Files:** +- Create: `lib/features/universal_import/presentation/providers/import_photo_link_controller.dart` +- Create (Test): `test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart` + +- [ ] **Step 1: Write the failing test.** Uses a fake `DirectoryScanner` (desktop-path handles), a fake `LocalMediaLinker` (records links, can throw for one basename), and a fake "already-linked basenames per dive" lookup to exercise dedupe. + +```dart +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; +import 'package:submersion/features/universal_import/presentation/providers/import_photo_link_controller.dart'; + +class _FakeScanner implements DirectoryScanner { + _FakeScanner(this.files); + final Map files; // basename -> path + @override + Stream scan(GrantedFolder folder) async* { + for (final e in files.entries) { + yield ScannedFile(basename: e.key, handle: MediaHandle.localPath(e.value)); + } + } +} + +/// Records linked (diveId, basename) pairs; can be told to throw for a +/// specific basename to exercise per-photo isolation. +class _FakeLinker implements LocalMediaLinker { + final List<({String diveId, String basename})> linked = []; + String? throwForBasename; + + @override + Future link({ + required String diveId, + required MediaHandle handle, + required String basename, + required MediaSourceMetadata metadata, + required DateTime fallbackTakenAt, + String? caption, + }) async { + if (basename == throwForBasename) { + throw StateError('boom'); + } + linked.add((diveId: diveId, basename: basename)); + return MediaItem( + id: 'm-${linked.length}', + diveId: diveId, + mediaType: MediaType.photo, + takenAt: DateTime(2024), + createdAt: DateTime(2024), + updatedAt: DateTime(2024), + ); + } + + // Unused by the controller in tests but required by the interface. + @override + dynamic noSuchMethod(Invocation invocation) => + super.noSuchMethod(invocation); +} + +ImportPhotoLinkController _controller({ + required DirectoryScanner scanner, + required LocalMediaLinker linker, + required List imageRefs, + required Map sourceUuidToDiveId, + Set Function(String diveId)? alreadyLinkedBasenames, +}) { + return ImportPhotoLinkController( + scannerFor: (_) => scanner, + linker: linker, + metadataFor: (_) async => + const MediaSourceMetadata(mimeType: 'image/jpeg'), + alreadyLinkedBasenames: + alreadyLinkedBasenames ?? (_) => const {}, + fallbackTakenAtFor: (_) => DateTime(2020), + )..seed(imageRefs: imageRefs, sourceUuidToDiveId: sourceUuidToDiveId); +} + +void main() { + test('links resolved photos to the mapped dive and summarises counts', + () async { + final linker = _FakeLinker(); + final controller = _controller( + scanner: _FakeScanner({ + 'shark.jpg': '/picked/shark.jpg', + 'turtle.jpg': '/picked/turtle.jpg', + }), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/shark.jpg', diveSourceUuid: 'src-1'), + ImportImageRef(originalPath: '/x/turtle.jpg', diveSourceUuid: 'src-1'), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + ); + + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + + final s = controller.state.summary!; + expect(s.total, 2); + expect(s.linked, 2); + expect(s.notFound, 0); + expect(s.skippedNonImage, 0); + expect(linker.linked, [ + (diveId: 'dive-1', basename: 'shark.jpg'), + (diveId: 'dive-1', basename: 'turtle.jpg'), + ]); + }); + + test('photo with no dive mapping is counted not-found, not linked', + () async { + final linker = _FakeLinker(); + final controller = _controller( + scanner: _FakeScanner({'a.jpg': '/picked/a.jpg'}), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/a.jpg', diveSourceUuid: 'unknown'), + ], + sourceUuidToDiveId: const {}, + ); + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(controller.state.summary!.linked, 0); + expect(controller.state.summary!.notFound, 1); + expect(linker.linked, isEmpty); + }); + + test('dedupe by (diveId + basename): skips already-linked', () async { + final linker = _FakeLinker(); + final controller = _controller( + scanner: _FakeScanner({'shark.jpg': '/picked/shark.jpg'}), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/shark.jpg', diveSourceUuid: 'src-1'), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + alreadyLinkedBasenames: (diveId) => + diveId == 'dive-1' ? {'shark.jpg'} : const {}, + ); + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(linker.linked, isEmpty); + // Already-linked photos count as linked in the summary (they ARE linked). + expect(controller.state.summary!.linked, 1); + }); + + test('re-pick is idempotent: second run links nothing new', () async { + final linker = _FakeLinker(); + final alreadyLinked = >{'dive-1': {}}; + final controller = ImportPhotoLinkController( + scannerFor: (_) => _FakeScanner({'shark.jpg': '/picked/shark.jpg'}), + linker: linker, + metadataFor: (_) async => + const MediaSourceMetadata(mimeType: 'image/jpeg'), + alreadyLinkedBasenames: (diveId) => + alreadyLinked[diveId] ?? const {}, + fallbackTakenAtFor: (_) => DateTime(2020), + )..seed( + imageRefs: const [ + ImportImageRef(originalPath: '/x/shark.jpg', diveSourceUuid: 'src-1'), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + ); + + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(linker.linked.length, 1); + // Simulate persistence: the linked basename is now present. + alreadyLinked['dive-1'] = {'shark.jpg'}; + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(linker.linked.length, 1); // no new links + }); + + test('per-photo failure is isolated and counted as not-found', () async { + final linker = _FakeLinker()..throwForBasename = 'bad.jpg'; + final controller = _controller( + scanner: _FakeScanner({ + 'good.jpg': '/picked/good.jpg', + 'bad.jpg': '/picked/bad.jpg', + }), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/good.jpg', diveSourceUuid: 'src-1'), + ImportImageRef(originalPath: '/x/bad.jpg', diveSourceUuid: 'src-1'), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + ); + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(linker.linked.map((l) => l.basename), ['good.jpg']); + expect(controller.state.summary!.linked, 1); + expect(controller.state.summary!.notFound, 1); + }); + + test('non-image refs are skipped and counted', () async { + final linker = _FakeLinker(); + final controller = _controller( + scanner: _FakeScanner({'clip.mov': '/picked/clip.mov'}), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/clip.mov', diveSourceUuid: 'src-1'), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + ); + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(controller.state.summary!.skippedNonImage, 1); + expect(controller.state.summary!.linked, 0); + expect(linker.linked, isEmpty); + }); +} +``` + +> NOTE on the `_FakeLinker`: `LocalMediaLinker` is a concrete class, not an interface. To allow a fake, declare the controller dependency as `LocalMediaLinker` and have the fake `implements LocalMediaLinker` (Dart permits implementing any class's interface). The `noSuchMethod` override + `@override` on `link` satisfy the analyzer. Alternatively (cleaner) extract a `MediaLinker` abstract interface that `LocalMediaLinker` implements and type the controller against it; if you do, define that interface in `local_media_linker.dart` and update Task 9's class to `class LocalMediaLinker implements MediaLinker`. Pick one approach and keep it consistent. + +- [ ] **Step 2: Run — expect FAIL.** `flutter test test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart`. Reason: `ImportPhotoLinkController` and its state/summary types do not exist. + +- [ ] **Step 3: Implement** `lib/features/universal_import/presentation/providers/import_photo_link_controller.dart`. + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/photo_resolver.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Outcome counts of a photo-linking run. +class PhotoLinkSummary { + final int total; + final int linked; + final int notFound; + final int skippedNonImage; + + const PhotoLinkSummary({ + required this.total, + required this.linked, + required this.notFound, + required this.skippedNonImage, + }); +} + +/// Immutable state for the post-import photo-linking flow. +class ImportPhotoLinkState { + final int refCount; + final bool isRunning; + final int processed; + final int total; + final PhotoLinkSummary? summary; + final String? errorMessage; + + const ImportPhotoLinkState({ + this.refCount = 0, + this.isRunning = false, + this.processed = 0, + this.total = 0, + this.summary, + this.errorMessage, + }); + + ImportPhotoLinkState copyWith({ + int? refCount, + bool? isRunning, + int? processed, + int? total, + PhotoLinkSummary? summary, + bool clearSummary = false, + String? errorMessage, + bool clearError = false, + }) { + return ImportPhotoLinkState( + refCount: refCount ?? this.refCount, + isRunning: isRunning ?? this.isRunning, + processed: processed ?? this.processed, + total: total ?? this.total, + summary: clearSummary ? null : (summary ?? this.summary), + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + ); + } +} + +/// Orchestrates the post-import photo-linking flow (Approach B, session- +/// only). Holds the parsed [ImportImageRef]s + the combined +/// sourceUuid -> diveId map; on each folder pick it scans once, resolves, +/// dedupes by (diveId + basename), links via [LocalMediaLinker], isolates +/// per-photo failures, and emits a [PhotoLinkSummary]. Re-picking another +/// folder is safe because linking is idempotent by basename. +class ImportPhotoLinkController extends StateNotifier { + ImportPhotoLinkController({ + required DirectoryScanner Function(GrantedFolder folder) scannerFor, + required LocalMediaLinker linker, + required Future Function(ScannedFile file) metadataFor, + required Set Function(String diveId) alreadyLinkedBasenames, + required DateTime Function(ImportImageRef ref) fallbackTakenAtFor, + }) : _scannerFor = scannerFor, + _linker = linker, + _metadataFor = metadataFor, + _alreadyLinkedBasenames = alreadyLinkedBasenames, + _fallbackTakenAtFor = fallbackTakenAtFor, + super(const ImportPhotoLinkState()); + + final DirectoryScanner Function(GrantedFolder) _scannerFor; + final LocalMediaLinker _linker; + final Future Function(ScannedFile) _metadataFor; + final Set Function(String diveId) _alreadyLinkedBasenames; + final DateTime Function(ImportImageRef) _fallbackTakenAtFor; + + final _log = LoggerService.forClass(ImportPhotoLinkController); + + List _imageRefs = const []; + Map _sourceUuidToDiveId = const {}; + + /// Seed the session data. Call once after import completes. + void seed({ + required List imageRefs, + required Map sourceUuidToDiveId, + }) { + _imageRefs = imageRefs; + _sourceUuidToDiveId = sourceUuidToDiveId; + state = state.copyWith(refCount: imageRefs.length, clearSummary: true); + } + + /// Run scan → resolve → link against [folder]. Best-effort; never throws. + Future pickedFolder(GrantedFolder folder) async { + if (_imageRefs.isEmpty) return; + state = state.copyWith( + isRunning: true, + processed: 0, + total: _imageRefs.length, + clearSummary: true, + clearError: true, + ); + + try { + final scanner = _scannerFor(folder); + final resolver = PhotoResolver(scanner: scanner, folder: folder); + final resolved = await resolver.resolveAll(_imageRefs); + + var linked = 0; + var notFound = 0; + var skipped = 0; + // Track basenames linked this run so two refs to the same file under + // one dive don't double-link within a single pass. + final linkedThisRun = >{}; + + for (var i = 0; i < resolved.length; i++) { + final r = resolved[i]; + switch (r.kind) { + case PhotoResolutionKind.skippedNonImage: + skipped++; + break; + case PhotoResolutionKind.miss: + notFound++; + break; + case PhotoResolutionKind.rebased: + case PhotoResolutionKind.filenameMatch: + final diveId = _sourceUuidToDiveId[r.ref.diveSourceUuid]; + if (diveId == null) { + notFound++; + break; + } + final basename = r.scannedFile!.basename; + final already = _alreadyLinkedBasenames(diveId); + final run = linkedThisRun.putIfAbsent(diveId, () => {}); + if (already.contains(basename) || run.contains(basename)) { + // Already linked (persisted or this run) — count as linked. + linked++; + run.add(basename); + break; + } + try { + final metadata = await _metadataFor(r.scannedFile!); + await _linker.link( + diveId: diveId, + handle: r.scannedFile!.handle, + basename: basename, + metadata: metadata, + fallbackTakenAt: _fallbackTakenAtFor(r.ref), + caption: r.ref.caption, + ); + linked++; + run.add(basename); + } catch (e, st) { + _log.error('Failed to link photo: $basename', error: e, stackTrace: st); + notFound++; + } + break; + } + state = state.copyWith(processed: i + 1); + } + + state = state.copyWith( + isRunning: false, + summary: PhotoLinkSummary( + total: resolved.length, + linked: linked, + notFound: notFound, + skippedNonImage: skipped, + ), + ); + } catch (e, st) { + _log.error('Photo-linking run failed', error: e, stackTrace: st); + state = state.copyWith( + isRunning: false, + errorMessage: 'Could not scan that folder.', // TODO(media): l10n + ); + } + } +} +``` + +> NOTE: The "already-linked counts as linked" choice makes the summary truthful on retry (the photo IS attached). The dedupe key is `(diveId + basename)` per the spec. The `alreadyLinkedBasenames` callback is wired in Task 11 to `MediaRepository.getMediaForDive(diveId)` mapping `originalFilename`/basename of `filePath`. + +- [ ] **Step 4: Run — expect PASS.** `flutter test test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart` → 6 tests pass. + +- [ ] **Step 5: Format + commit.** + +```bash +dart format lib/features/universal_import/presentation/providers/import_photo_link_controller.dart test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart +git add lib/features/universal_import/presentation/providers/import_photo_link_controller.dart test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart +git commit -m "feat(import): add ImportPhotoLinkController orchestration" +``` + +--- + +## Task 11: Post-import prompt UI + wire into the wizard done screen (desktop) + +Surface the photo-locate prompt on the import summary screen. Carry `imageRefs` + the combined `sourceUuidToDiveId` (new + matched-existing) through `UnifiedImportResult`, build the controller, and render prompt → folder pick (desktop `getDirectoryPath`) → progress → summary, with a "try another folder" retry. + +**Files:** +- Modify: `lib/features/import_wizard/domain/models/unified_import_result.dart` +- Modify: `lib/features/import_wizard/data/adapters/universal_adapter.dart` +- Create: `lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart` +- Modify: `lib/features/import_wizard/presentation/widgets/import_summary_step.dart` +- Create (Test): `test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart` + +- [ ] **Step 1a: Extend `UnifiedImportResult`.** In `lib/features/import_wizard/domain/models/unified_import_result.dart` add the import + two fields: +```dart +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +``` +Add after `importedDiveIds`: +```dart + /// Photo references parsed from the source (MacDive XML/SQLite). Empty for + /// formats that carry none. Drives the post-import photo-locate prompt. + final List imageRefs; + + /// Combined source-UUID -> dive-ID map covering both newly-created dives + /// and matched-existing duplicates, so photos link to either. + final Map sourceUuidToDiveId; +``` +Add to the constructor (after `this.importedDiveIds = const []`): +```dart + this.imageRefs = const [], + this.sourceUuidToDiveId = const {}, +``` + +- [ ] **Step 1b: Surface them from `UniversalAdapter.performImport`.** In `lib/features/import_wizard/data/adapters/universal_adapter.dart` add the import: +```dart +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +``` +(already imported) and at the end of `performImport`, before constructing `UnifiedImportResult`, compute the combined map: +```dart + // Combine new-dive UUIDs (from the importer) with matched-existing + // dives so photos link to duplicates the user kept. getSourceUuidByDiveId + // returns { diveId -> sourceUuid }; invert it for our needs. + final existingByDiveId = await _ref + .read(diveRepositoryProvider) + .getSourceUuidByDiveId(diverId: currentDiver.id); + final combinedSourceUuidToDiveId = { + for (final entry in existingByDiveId.entries) entry.value: entry.key, + // New dives win over existing on UUID collision (we just created them). + ...result.sourceUuidToDiveId, + }; +``` +Change the returned `UnifiedImportResult` to add: +```dart + imageRefs: payload.imageRefs, + sourceUuidToDiveId: combinedSourceUuidToDiveId, +``` + +> NOTE: `result` is the `UddfEntityImportResult` returned by `importer.import(...)`, which now carries `sourceUuidToDiveId` (Task 6). `payload` is already in scope at the top of `performImport`. `currentDiver` is non-null past the early guard. + +- [ ] **Step 2: Write the failing prompt-widget test.** + +```dart +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/presentation/providers/import_photo_link_controller.dart'; + +ImportPhotoLinkController _seededController({PhotoLinkSummary? summary}) { + final c = ImportPhotoLinkController( + scannerFor: (_) => throw UnimplementedError(), + linker: throw UnimplementedError(), + metadataFor: (_) => throw UnimplementedError(), + alreadyLinkedBasenames: (_) => const {}, + fallbackTakenAtFor: (_) => DateTime(2020), + ); + c.seed( + imageRefs: const [ + ImportImageRef(originalPath: '/x/a.jpg', diveSourceUuid: 'd'), + ImportImageRef(originalPath: '/x/b.jpg', diveSourceUuid: 'd'), + ], + sourceUuidToDiveId: const {'d': 'dive-1'}, + ); + if (summary != null) { + c.state = c.state.copyWith(summary: summary); + } + return c; +} + +Widget _wrap(ImportPhotoLinkController controller, Widget child) { + return ProviderScope( + overrides: [ + importPhotoLinkControllerProvider.overrideWith((ref) => controller), + ], + child: MaterialApp(home: Scaffold(body: child)), + ); +} + +void main() { + testWidgets('prompt shows the photo-reference count + a Locate action', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = _seededController(); + await tester.pumpWidget(_wrap(controller, const ImportPhotoLocatePrompt())); + expect(find.textContaining('2'), findsWidgets); + expect(find.text('Locate Photos'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('summary state shows linked/notFound counts + Try another folder', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = _seededController( + summary: const PhotoLinkSummary( + total: 2, + linked: 1, + notFound: 1, + skippedNonImage: 0, + ), + ); + await tester.pumpWidget(_wrap(controller, const ImportPhotoLocatePrompt())); + expect(find.textContaining('1'), findsWidgets); + expect(find.text('Try another folder'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('renders nothing when there are no photo references', + (tester) async { + final controller = ImportPhotoLinkController( + scannerFor: (_) => throw UnimplementedError(), + linker: throw UnimplementedError(), + metadataFor: (_) => throw UnimplementedError(), + alreadyLinkedBasenames: (_) => const {}, + fallbackTakenAtFor: (_) => DateTime(2020), + ); // not seeded -> refCount 0 + await tester.pumpWidget(_wrap(controller, const ImportPhotoLocatePrompt())); + expect(find.text('Locate Photos'), findsNothing); + }); +} +``` + +> NOTE: The test references `importPhotoLinkControllerProvider`; define it in the prompt file (Step 3). The `linker: throw UnimplementedError()` initialiser is acceptable because these widget tests never trigger a run. + +- [ ] **Step 3: Run — expect FAIL.** `flutter test test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart`. Reason: `ImportPhotoLocatePrompt` and `importPhotoLinkControllerProvider` do not exist. + +- [ ] **Step 4: Implement** `lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart`. Includes the provider (overridable; default builds the controller from the media providers + `MediaRepository.getMediaForDive` dedupe lookup). + +```dart +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/data/services/exif_extractor.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; +import 'package:submersion/features/universal_import/data/services/android_directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/desktop_directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/ios_directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; +import 'package:submersion/features/universal_import/presentation/providers/import_photo_link_controller.dart'; + +/// Session-scoped controller for the post-import photo-locate flow. The +/// wizard overrides this with a controller seeded from the import result +/// (imageRefs + combined sourceUuid->diveId). The default builds a +/// controller wired to the real media stack; the dedupe lookup reads +/// already-linked basenames from `MediaRepository.getMediaForDive`. +final importPhotoLinkControllerProvider = + StateNotifierProvider( + (ref) { + final mediaRepository = ref.read(mediaRepositoryProvider); + final platform = ref.read(localMediaPlatformProvider); + final bookmarkStorage = ref.read(localBookmarkStorageProvider); + final exif = ExifExtractor(); + return ImportPhotoLinkController( + scannerFor: (_) { + if (!kIsWeb && Platform.isAndroid) return AndroidDirectoryScanner(platform); + if (!kIsWeb && (Platform.isIOS)) return IosDirectoryScanner(platform); + return DesktopDirectoryScanner(); + }, + linker: LocalMediaLinker( + mediaRepository: mediaRepository, + platform: platform, + bookmarkStorage: bookmarkStorage, + ), + metadataFor: (file) async { + // Desktop: read EXIF from the path. Mobile handles carry no path; + // fall back to a bare mime so takenAt uses the per-ref fallback. + final path = file.handle.localPath; + if (path == null) { + return const MediaSourceMetadata(mimeType: 'image/jpeg'); + } + final meta = await exif.extract(File(path)); + return meta ?? const MediaSourceMetadata(mimeType: 'image/jpeg'); + }, + alreadyLinkedBasenames: (_) => const {}, + fallbackTakenAtFor: (_) => DateTime.now(), + ); + }, +); + +/// Post-import prompt shown on the summary screen when the import carried +/// photo references. Drives folder pick -> scan -> resolve -> link with a +/// progress and summary, plus a "try another folder" retry. Renders +/// nothing when there are no references. +class ImportPhotoLocatePrompt extends ConsumerWidget { + const ImportPhotoLocatePrompt({super.key}); + + bool get _isDesktop => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(importPhotoLinkControllerProvider); + final controller = ref.read(importPhotoLinkControllerProvider.notifier); + if (state.refCount == 0) return const SizedBox.shrink(); + final theme = Theme.of(context); + + Future pick() async { + final picked = await FilePicker.getDirectoryPath(); + if (picked == null) return; + await controller.pickedFolder(GrantedFolder(path: picked)); + } + + final children = [ + const Divider(height: 32), + Text( + // TODO(media): l10n + '${state.refCount} photos referenced by these dives', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 12), + ]; + + if (state.isRunning) { + children.addAll([ + const CircularProgressIndicator(), + const SizedBox(height: 8), + // TODO(media): l10n + Text('Linking ${state.processed} of ${state.total}...'), + ]); + } else if (state.summary != null) { + final s = state.summary!; + children.addAll([ + Text( + // TODO(media): l10n + '${s.linked} linked, ${s.notFound} not found' + '${s.skippedNonImage > 0 ? ', ${s.skippedNonImage} skipped' : ''}', + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: pick, + icon: const Icon(Icons.folder_open), + // TODO(media): l10n + label: const Text('Try another folder'), + ), + ]); + } else if (_isDesktop) { + children.add( + FilledButton.icon( + onPressed: pick, + icon: const Icon(Icons.folder_open), + // TODO(media): l10n + label: const Text('Locate Photos'), + ), + ); + } else { + // Mobile pick path is wired via the same FilePicker folder grant in + // Tasks 12/13; the button stays available so the channel-backed + // scanners run once implemented. + children.add( + FilledButton.icon( + onPressed: pick, + icon: const Icon(Icons.folder_open), + // TODO(media): l10n + label: const Text('Locate Photos'), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column(mainAxisSize: MainAxisSize.min, children: children), + ); + } +} +``` + +> NOTE: The default provider above imports the iOS/Android scanners created in Tasks 12/13. To keep Task 11 self-contained on desktop and avoid a forward reference, EITHER (a) land thin stub files for `ios_directory_scanner.dart` / `android_directory_scanner.dart` in this task that throw `UnsupportedError` until Tasks 12/13 fill them in, OR (b) gate the imports so only `DesktopDirectoryScanner` is referenced now and add the mobile scanners to `scannerFor` in Tasks 12/13. Option (b) is cleaner: in this task, make `scannerFor` return `DesktopDirectoryScanner()` unconditionally and drop the iOS/Android imports; Tasks 12/13 edit `scannerFor` to add their branch. Use option (b). + +- [ ] **Step 5: Run — expect PASS.** `flutter test test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart` → 3 tests pass. + +- [ ] **Step 6: Wire into the summary step.** In `lib/features/import_wizard/presentation/widgets/import_summary_step.dart`: +- Import the prompt + the controller provider: +```dart +import 'package:submersion/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart'; +import 'package:submersion/features/universal_import/presentation/providers/import_photo_link_controller.dart'; +``` +- In `ImportSummaryStep.build`, after computing `result`, seed the controller from the result once (idempotent) and render the prompt below the success view. Replace the `_SuccessView(...)` return with a `Column` containing the success view and the prompt: +```dart + // Seed the photo-link controller from the import result (once). Safe to + // call repeatedly: seed() just resets the session refs/map + summary. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + ref + .read(importPhotoLinkControllerProvider.notifier) + .seed( + imageRefs: result.imageRefs, + sourceUuidToDiveId: result.sourceUuidToDiveId, + ); + }); + + return SingleChildScrollView( + child: Column( + children: [ + _SuccessView( + importedCounts: result.importedCounts, + consolidatedCount: result.consolidatedCount, + updatedCount: result.updatedCount, + skippedCount: result.skippedCount, + onDone: onDone, + onViewDives: onViewDives, + ), + const ImportPhotoLocatePrompt(), + ], + ), + ); +``` + +> NOTE: `ImportSummaryStep` is already a `ConsumerWidget`, so `ref` is in scope. `result.imageRefs`/`result.sourceUuidToDiveId` exist on `UnifiedImportResult` after Step 1a. Non-universal adapters (dive computer, HealthKit) return empty `imageRefs`, so the prompt renders nothing for them (the `refCount == 0` guard). Seeding via a post-frame callback avoids mutating a provider during build. + +- [ ] **Step 7: Run the summary-step + wizard tests — expect PASS (no regression).** +```bash +flutter test test/features/import_wizard/ +``` + +- [ ] **Step 8: Format + commit.** + +```bash +dart format lib/features/import_wizard/domain/models/unified_import_result.dart lib/features/import_wizard/data/adapters/universal_adapter.dart lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart lib/features/import_wizard/presentation/widgets/import_summary_step.dart test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart +git add lib/features/import_wizard/domain/models/unified_import_result.dart lib/features/import_wizard/data/adapters/universal_adapter.dart lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart lib/features/import_wizard/presentation/widgets/import_summary_step.dart test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart +git commit -m "feat(import): post-import photo-locate prompt on the wizard summary (desktop)" +``` + +--- + +## Task 12: Desktop end-to-end + gated real-sample test + +Prove the full desktop slice: parse a MacDive SQLite → resolve photos against a fixture folder → link as `localFile` `MediaItem`s. Also port PR #257's real-sample assertion (261 imageRefs) behind the `real-data` tag. + +**Files:** +- Create (Test): `test/features/universal_import/data/parsers/macdive_sqlite_photos_real_test.dart` +- Create (Test): `test/features/universal_import/data/services/photo_link_desktop_e2e_test.dart` + +- [ ] **Step 1: Write the gated real-sample test.** Mirrors the worktree's `macdive_sqlite_real_sample_test.dart` gating. + +```dart +@Tags(['real-data']) +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_sqlite_parser.dart'; + +// Real MacDive export (not committed). Skips cleanly when absent on CI. +const _path = + '/Users/ericgriffin/Documents/submersion development/submersion data/Macdive/MacDive.sqlite'; + +void main() { + test('real MacDive SQLite emits 261 imageRefs', () async { + final file = File(_path); + if (!file.existsSync()) { + markTestSkipped('No real MacDive sample at $_path'); + return; + } + final bytes = Uint8List.fromList(await file.readAsBytes()); + final payload = await const MacDiveSqliteParser().parse(bytes); + expect(payload.imageRefs.length, 261); + }); +} +``` + +> NOTE: 261 is PR #257's observed `ZDIVEIMAGE.ZPATH` count for this specific export. If the sample on disk differs, update the literal to the actual count (read it once: open the DB and `SELECT COUNT(*) FROM ZDIVEIMAGE WHERE ZPATH IS NOT NULL OR ZORIGINALPATH IS NOT NULL`). The test is excluded from the default run via the `real-data` tag (see `dart_test.yaml` tag config used by the existing real-sample tests). + +- [ ] **Step 2: Write the desktop e2e test.** Builds the synthetic SQLite (with `ZDIVEIMAGE`), parses it, writes matching files into a temp fixture folder, runs `DesktopDirectoryScanner` + `PhotoResolver` + a real `LocalMediaLinker` against a test DB, then asserts resolve + link counts and that `MediaItem` rows exist. + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/local_bookmark_storage.dart'; +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/data/services/local_media_platform.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_sqlite_parser.dart'; +import 'package:submersion/features/universal_import/data/services/desktop_directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/photo_resolver.dart'; + +import '../../../../fixtures/macdive_sqlite/build_synthetic_db.dart'; +import '../../../helpers/test_database.dart'; + +Future _insertBareDive(AppDatabase db, String diveId) async { + final now = DateTime.now().millisecondsSinceEpoch; + await db.into(db.dives).insert( + DivesCompanion.insert( + id: diveId, + diveDateTime: DateTime(2024, 6, 1).millisecondsSinceEpoch ~/ 1000, + createdAt: now, + updatedAt: now, + ), + ); +} + +void main() { + late AppDatabase db; + late Directory photoDir; + late MediaRepository mediaRepository; + + setUp(() async { + db = await setUpTestDatabase(); + photoDir = Directory.systemTemp.createTempSync('photo_e2e_'); + mediaRepository = MediaRepository(); + }); + tearDown(() async { + if (photoDir.existsSync()) photoDir.deleteSync(recursive: true); + await tearDownTestDatabase(); + }); + + test('desktop end-to-end: parse -> resolve -> link MediaItems', () async { + // 1. Parse synthetic MacDive SQLite (carries ZDIVEIMAGE rows). + final dbPath = + '${Directory.systemTemp.path}/e2e_${DateTime.now().microsecondsSinceEpoch}.sqlite'; + final dbFile = buildSyntheticMacDiveDb(dbPath); + addTearDown(() { + if (dbFile.existsSync()) dbFile.deleteSync(); + }); + final payload = await const MacDiveSqliteParser().parse( + Uint8List.fromList(await dbFile.readAsBytes()), + ); + expect(payload.imageRefs, isNotEmpty); + + // 2. Lay down matching files by basename under the picked folder. + for (final ref in payload.imageRefs) { + File('${photoDir.path}/${ref.filename}').writeAsBytesSync([1, 2, 3]); + } + + // 3. Create the dives the photos point at, capturing sourceUuid->diveId. + final sourceUuidToDiveId = {}; + for (final ref in payload.imageRefs) { + sourceUuidToDiveId.putIfAbsent(ref.diveSourceUuid, () { + final id = 'dive-${sourceUuidToDiveId.length + 1}'; + return id; + }); + } + for (final id in sourceUuidToDiveId.values.toSet()) { + await _insertBareDive(db, id); + } + + // 4. Resolve. + final resolver = PhotoResolver( + scanner: DesktopDirectoryScanner(), + folder: GrantedFolder(path: photoDir.path), + ); + final resolved = await resolver.resolveAll(payload.imageRefs); + expect( + resolved.where((r) => r.scannedFile != null).length, + payload.imageRefs.length, + ); + + // 5. Link with a real LocalMediaLinker (desktop path handles). + final linker = LocalMediaLinker( + mediaRepository: mediaRepository, + platform: LocalMediaPlatform(), + bookmarkStorage: LocalBookmarkStorage(), + ); + var linked = 0; + for (final r in resolved) { + if (r.scannedFile == null) continue; + final diveId = sourceUuidToDiveId[r.ref.diveSourceUuid]!; + await linker.link( + diveId: diveId, + handle: r.scannedFile!.handle, + basename: r.scannedFile!.basename, + metadata: const MediaSourceMetadata(mimeType: 'image/jpeg'), + fallbackTakenAt: DateTime(2024, 6, 1), + caption: r.ref.caption, + ); + linked++; + } + expect(linked, payload.imageRefs.length); + + // 6. Assert MediaItems landed, referencing the in-place file paths. + final firstDiveId = sourceUuidToDiveId.values.first; + final media = await mediaRepository.getMediaForDive(firstDiveId); + expect(media, isNotEmpty); + expect(media.first.filePath, startsWith(photoDir.path)); + }); +} +``` + +> NOTE: `LocalMediaLinker` on a non-iOS/macOS/Android host (Linux CI, or macOS dev) takes the desktop branch for `MediaHandle.localPath`, so no real bookmark/keychain call fires. On a macOS dev host the handle is still a desktop `localPath` (we built it via `DesktopDirectoryScanner`), so `LocalMediaPlatform.createBookmark` is never invoked — the test is host-safe. Confirm `test/helpers/test_database.dart` exposes `setUpTestDatabase()` / `tearDownTestDatabase()` (the worktree's link-service test used these) and that `MediaRepository` resolves the DB via `DatabaseService.instance` which the helper initialises. + +- [ ] **Step 3: Run — expect PASS** (real-sample test SKIPS unless the file exists). +```bash +flutter test test/features/universal_import/data/services/photo_link_desktop_e2e_test.dart +flutter test --tags real-data test/features/universal_import/data/parsers/macdive_sqlite_photos_real_test.dart +``` + +- [ ] **Step 4: Format + commit.** + +```bash +dart format test/features/universal_import/data/parsers/macdive_sqlite_photos_real_test.dart test/features/universal_import/data/services/photo_link_desktop_e2e_test.dart +git add test/features/universal_import/data/parsers/macdive_sqlite_photos_real_test.dart test/features/universal_import/data/services/photo_link_desktop_e2e_test.dart +git commit -m "test(import): desktop photo-link end-to-end + gated MacDive real-sample" +``` + +--- + +## Task 13: iOS scoped-directory enumeration (channel + Dart wrapper + scanner) + +Add `enumerateScopedDirectory` to the `LocalMediaPlatform` channel (Swift native), its Dart wrapper, and an `IosDirectoryScanner`. Per the iOS scope-lifetime rule, the native side creates each file's security-scoped bookmark WHILE the directory scope is held and returns `(basename, bookmarkBlob)` per file. Native Swift is verified manually; the Dart wrapper is tested via a mock channel. + +**Files:** +- Modify: `lib/features/media/data/services/local_media_platform.dart` +- Create: `lib/features/universal_import/data/services/ios_directory_scanner.dart` +- Modify: `lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart` (add the iOS branch to `scannerFor`) +- Modify: `macos/Runner/LocalMediaPlugin.swift` and the iOS plugin counterpart (find the existing handler that implements `createBookmark`) +- Create (Test): `test/features/media/data/services/local_media_platform_enumerate_test.dart` + +- [ ] **Step 1: Write the failing Dart-wrapper test** (mock channel; covers the iOS branch only — the Android case is added in Task 14 to the same file). + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/services/local_media_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.submersion.app/local_media'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'enumerateScopedDirectory': + // Native returns a list of { basename, bookmarkBlob } maps. + return >[ + {'basename': 'a.jpg', 'bookmarkBlob': Uint8List.fromList([1, 2])}, + {'basename': 'b.png', 'bookmarkBlob': Uint8List.fromList([3, 4])}, + ]; + case 'enumerateTree': + return >[ + {'basename': 'c.jpg', 'contentUri': 'content://tree/doc/c'}, + ]; + default: + return null; + } + }); + }); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('enumerateScopedDirectory returns basename+blob entries (iOS/macOS)', + () async { + if (!Platform.isIOS && !Platform.isMacOS) { + // The wrapper guards on host platform; assert it throws off-host. + expect( + () => LocalMediaPlatform().enumerateScopedDirectory('/dir'), + throwsUnsupportedError, + ); + return; + } + final entries = + await LocalMediaPlatform().enumerateScopedDirectory('/dir'); + expect(entries.length, 2); + expect(entries.first.basename, 'a.jpg'); + expect(entries.first.bookmarkBlob, [1, 2]); + }); +} +``` + +> NOTE: On the macOS dev host this exercises the real iOS/macOS branch through the mock channel; on Linux CI it asserts the `UnsupportedError` guard. This mirrors the existing `local_media_platform_test.dart` host-gating pattern. + +- [ ] **Step 2: Run — expect FAIL.** `flutter test test/features/media/data/services/local_media_platform_enumerate_test.dart`. Reason: `enumerateScopedDirectory` + its return type do not exist on `LocalMediaPlatform`. + +- [ ] **Step 3: Add the Dart wrapper.** In `lib/features/media/data/services/local_media_platform.dart` add the result type at top-level and the method (with the `coverage:ignore` block only around the genuinely-native invoke, matching the file's convention): + +```dart +/// One file returned by [LocalMediaPlatform.enumerateScopedDirectory]: its +/// [basename] and the security-scoped [bookmarkBlob] the native side created +/// while the directory scope was held. +class ScopedDirEntry { + final String basename; + final Uint8List bookmarkBlob; + const ScopedDirEntry({required this.basename, required this.bookmarkBlob}); +} +``` + +```dart + /// iOS / macOS only. Enumerates [directoryUrl] (a security-scoped folder + /// URL string the user granted via the document picker) and returns one + /// [ScopedDirEntry] per file. The native side holds the directory's + /// security scope for the whole walk and creates each file's bookmark + /// inside that window, so the returned blobs remain resolvable after the + /// scope is released. + Future> enumerateScopedDirectory( + String directoryUrl, + ) async { + if (!Platform.isIOS && !Platform.isMacOS) { + throw UnsupportedError( + 'enumerateScopedDirectory is only supported on iOS / macOS', + ); + } + // coverage:ignore-start + final raw = await _channel.invokeListMethod>( + 'enumerateScopedDirectory', + {'directoryUrl': directoryUrl}, + ); + if (raw == null) return const []; + return raw + .map( + (m) => ScopedDirEntry( + basename: m['basename'] as String, + bookmarkBlob: m['bookmarkBlob'] as Uint8List, + ), + ) + .toList(); + // coverage:ignore-end + } +``` + +- [ ] **Step 4: Implement `IosDirectoryScanner`.** `lib/features/universal_import/data/services/ios_directory_scanner.dart`: + +```dart +import 'package:submersion/features/media/data/services/local_media_platform.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// iOS [DirectoryScanner] over the `enumerateScopedDirectory` channel +/// method. Each yielded [ScannedFile] carries a security-scoped bookmark +/// blob created natively while the directory scope was held. +class IosDirectoryScanner implements DirectoryScanner { + IosDirectoryScanner(this._platform); + final LocalMediaPlatform _platform; + + @override + Stream scan(GrantedFolder folder) async* { + final entries = await _platform.enumerateScopedDirectory(folder.path); + for (final e in entries) { + yield ScannedFile( + basename: e.basename, + handle: MediaHandle.bookmark(e.bookmarkBlob), + ); + } + } +} +``` + +- [ ] **Step 5: Implement the native Swift handler.** In the macOS/iOS `LocalMediaPlugin.swift` (locate the existing `switch call.method` that handles `createBookmark`/`resolveBookmark`), add a case. This is the one genuinely-untestable bridge — keep it minimal and correct. Sketch (adapt to the plugin's existing helper style): + +```swift +case "enumerateScopedDirectory": + guard let args = call.arguments as? [String: Any], + let urlString = args["directoryUrl"] as? String, + let dirURL = URL(string: urlString) else { + result(FlutterError(code: "bad_args", message: "directoryUrl required", details: nil)) + return + } + let didAccess = dirURL.startAccessingSecurityScopedResource() + defer { if didAccess { dirURL.stopAccessingSecurityScopedResource() } } + var entries: [[String: Any]] = [] + let fm = FileManager.default + if let walker = fm.enumerator(at: dirURL, includingPropertiesForKeys: [.isRegularFileKey]) { + for case let fileURL as URL in walker { + let isFile = (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile ?? false + if !isFile { continue } + // Bookmark created while the parent scope is held -> resolvable later. + if let blob = try? fileURL.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) { + entries.append([ + "basename": fileURL.lastPathComponent, + "bookmarkBlob": FlutterStandardTypedData(bytes: blob), + ]) + } + } + } + result(entries) +``` + +> NOTE: On iOS, `bookmarkData(options:)` does not take `.withSecurityScope` (that option is macOS-only); use `options: []` on iOS and rely on the picked-folder grant + per-file URLs. Gate the option by platform with `#if os(macOS)`. Verify against the plugin's existing `createBookmark` implementation to mirror its option usage exactly. The folder grant itself (UIDocumentPicker in folder mode on iOS / NSOpenPanel-style on macOS) is obtained by the Dart `FilePicker.getDirectoryPath()` already wired in Task 11. + +- [ ] **Step 6: Add the iOS branch to `scannerFor`.** In `import_photo_locate_prompt.dart`, add the import for `ios_directory_scanner.dart` and extend `scannerFor`: +```dart + if (!kIsWeb && Platform.isIOS) return IosDirectoryScanner(platform); +``` + +- [ ] **Step 7: Run — expect PASS.** `flutter test test/features/media/data/services/local_media_platform_enumerate_test.dart` (iOS case real on macOS host / guarded on Linux). Build the iOS+macOS targets to confirm the Swift compiles: `flutter build macos --debug` (and `flutter build ios --debug --no-codesign` if an iOS toolchain is available). + +- [ ] **Step 8: Format + commit.** + +```bash +dart format lib/features/media/data/services/local_media_platform.dart lib/features/universal_import/data/services/ios_directory_scanner.dart lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart test/features/media/data/services/local_media_platform_enumerate_test.dart +git add lib/features/media/data/services/local_media_platform.dart lib/features/universal_import/data/services/ios_directory_scanner.dart lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart test/features/media/data/services/local_media_platform_enumerate_test.dart macos/ ios/ +git commit -m "feat(media): iOS scoped-directory enumeration channel + IosDirectoryScanner" +``` + +--- + +## Task 14: Android SAF tree enumeration (channel + Dart wrapper + scanner) + +Add `enumerateTree` to the `LocalMediaPlatform` channel (Kotlin native via `DocumentsContract`), its Dart wrapper, and an `AndroidDirectoryScanner`. Each yielded `ScannedFile` carries a per-file document URI; the persisted tree permission (taken when the user grants the folder) makes the URIs durable. + +**Files:** +- Modify: `lib/features/media/data/services/local_media_platform.dart` +- Create: `lib/features/universal_import/data/services/android_directory_scanner.dart` +- Modify: `lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart` (add the Android branch to `scannerFor`) +- Modify: `android/app/src/main/kotlin/.../LocalMediaPlugin.kt` (find the existing handler implementing `takePersistableUri`) +- Modify (Test): `test/features/media/data/services/local_media_platform_enumerate_test.dart` (add the Android case) + +- [ ] **Step 1: Extend the wrapper test** with the Android case (the mock channel already returns `enumerateTree` data from Task 13 Step 1). Add this test to the same file: + +```dart + test('enumerateTree returns basename+contentUri entries (Android)', () async { + if (!Platform.isAndroid) { + expect( + () => LocalMediaPlatform().enumerateTree('content://tree/x'), + throwsUnsupportedError, + ); + return; + } + final entries = await LocalMediaPlatform().enumerateTree('content://tree/x'); + expect(entries.single.basename, 'c.jpg'); + expect(entries.single.contentUri, 'content://tree/doc/c'); + }); +``` + +- [ ] **Step 2: Run — expect FAIL** (on Linux CI it would pass the guard branch only once the method exists; before that it is a compile error). `flutter test test/features/media/data/services/local_media_platform_enumerate_test.dart`. Reason: `enumerateTree` + `TreeDocEntry` do not exist. + +- [ ] **Step 3: Add the Dart wrapper.** In `local_media_platform.dart` add the result type + method: + +```dart +/// One file returned by [LocalMediaPlatform.enumerateTree]: its [basename] +/// and the per-file document [contentUri] under the persisted tree. +class TreeDocEntry { + final String basename; + final String contentUri; + const TreeDocEntry({required this.basename, required this.contentUri}); +} +``` + +```dart + /// Android only. Enumerates the persisted document tree [treeUri] + /// (returned by ACTION_OPEN_DOCUMENT_TREE) recursively via + /// `DocumentsContract`, returning one [TreeDocEntry] per file. The tree's + /// persistable permission (taken when the user granted the folder) keeps + /// each document URI readable across reboots. + Future> enumerateTree(String treeUri) async { + if (!Platform.isAndroid) { + throw UnsupportedError('enumerateTree is only supported on Android'); + } + // coverage:ignore-start + final raw = await _channel.invokeListMethod>( + 'enumerateTree', + {'treeUri': treeUri}, + ); + if (raw == null) return const []; + return raw + .map( + (m) => TreeDocEntry( + basename: m['basename'] as String, + contentUri: m['contentUri'] as String, + ), + ) + .toList(); + // coverage:ignore-end + } +``` + +- [ ] **Step 4: Implement `AndroidDirectoryScanner`.** `lib/features/universal_import/data/services/android_directory_scanner.dart`: + +```dart +import 'package:submersion/features/media/data/services/local_media_platform.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Android [DirectoryScanner] over the `enumerateTree` channel method. +/// Each yielded [ScannedFile] carries a per-file content URI usable as the +/// `bookmarkRef` on the persisted [MediaItem] row. +class AndroidDirectoryScanner implements DirectoryScanner { + AndroidDirectoryScanner(this._platform); + final LocalMediaPlatform _platform; + + @override + Stream scan(GrantedFolder folder) async* { + final entries = await _platform.enumerateTree(folder.path); + for (final e in entries) { + yield ScannedFile( + basename: e.basename, + handle: MediaHandle.contentUri(e.contentUri), + ); + } + } +} +``` + +- [ ] **Step 5: Implement the native Kotlin handler.** In `LocalMediaPlugin.kt` (locate the existing `when (call.method)` that handles `takePersistableUri`), add a `"enumerateTree"` case that walks the tree via `DocumentsContract.buildChildDocumentsUriUsingTree` and a stack for recursion. Sketch (adapt to the plugin's `result`/threading style; run the walk off the main thread if the plugin already does so for other methods): + +```kotlin +"enumerateTree" -> { + val treeUriStr = call.argument("treeUri") + if (treeUriStr == null) { + result.error("bad_args", "treeUri required", null); return + } + val resolver = context.contentResolver + val treeUri = Uri.parse(treeUriStr) + val out = ArrayList>() + val stack = ArrayDeque() + // Seed with the tree's own document id. + stack.addLast(DocumentsContract.getTreeDocumentId(treeUri)) + while (stack.isNotEmpty()) { + val parentDocId = stack.removeLast() + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId) + resolver.query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ), + null, null, null + )?.use { c -> + while (c.moveToNext()) { + val docId = c.getString(0) + val name = c.getString(1) + val mime = c.getString(2) + if (mime == DocumentsContract.Document.MIME_TYPE_DIR) { + stack.addLast(docId) + } else { + val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId) + out.add(hashMapOf("basename" to name, "contentUri" to docUri.toString())) + } + } + } + } + result.success(out) +} +``` + +> NOTE: The folder grant (ACTION_OPEN_DOCUMENT_TREE) + `takePersistableUriPermission` happens when the user picks the folder. On Android, `FilePicker.getDirectoryPath()` returns a tree URI string and the `file_picker` plugin already requests the tree; if persistable permission isn't auto-taken, call the existing `LocalMediaPlatform.takePersistableUri(treeUri)` once in the controller's Android path before scanning. Verify whether `file_picker` persists the tree permission on this project's plugin version; if not, add that call in `scannerFor`/`pickedFolder`. Flag in Open Reconciliation Items. + +- [ ] **Step 6: Add the Android branch to `scannerFor`.** In `import_photo_locate_prompt.dart`, add the import for `android_directory_scanner.dart` and extend `scannerFor`: +```dart + if (!kIsWeb && Platform.isAndroid) return AndroidDirectoryScanner(platform); +``` + +- [ ] **Step 7: Run — expect PASS.** `flutter test test/features/media/data/services/local_media_platform_enumerate_test.dart`. Build Android to confirm Kotlin compiles: `flutter build apk --debug`. + +- [ ] **Step 8: Format + commit.** + +```bash +dart format lib/features/media/data/services/local_media_platform.dart lib/features/universal_import/data/services/android_directory_scanner.dart lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart test/features/media/data/services/local_media_platform_enumerate_test.dart +git add lib/features/media/data/services/local_media_platform.dart lib/features/universal_import/data/services/android_directory_scanner.dart lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart test/features/media/data/services/local_media_platform_enumerate_test.dart android/ +git commit -m "feat(media): Android SAF tree enumeration channel + AndroidDirectoryScanner" +``` + +--- + +## Task 15: CHANGELOG + final sweep + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add the CHANGELOG entry.** Under `## Unreleased` → `### Added` in `CHANGELOG.md`, add: + +```markdown +- **MacDive photo import.** Photos referenced by MacDive native XML + (``) and SQLite (`ZDIVEIMAGE`) imports are now linked to dives. + After importing dives, the summary screen offers to locate the photos: + pick a folder and Submersion scans it, matches each recorded photo by + path (direct/rebase) or filename, and links matches to their dive as + standard local-file media that reference the files in place (no copying). + Works on desktop and mobile; re-picking another folder is safe (linking + is idempotent per dive + filename). Photos link to both newly-imported + and matched-existing duplicate dives. Non-image references are skipped + and counted; missing photos are reported in a summary. +``` + +- [ ] **Step 2: Final sweep.** Run the full gate the pre-push hook enforces, scoped to the new/changed test files plus the broad analyze/format: + +```bash +dart format --set-exit-if-changed lib/ test/ +flutter analyze +flutter test \ + test/features/universal_import/data/models/import_image_ref_test.dart \ + test/features/universal_import/data/models/import_payload_test.dart \ + test/features/universal_import/data/services/macdive_db_reader_photo_test.dart \ + test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart \ + test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart \ + test/features/universal_import/data/parsers/macdive_xml_photo_test.dart \ + test/features/universal_import/data/parsers/macdive_sqlite_photo_test.dart \ + test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart \ + test/features/universal_import/data/services/desktop_directory_scanner_test.dart \ + test/features/universal_import/data/services/photo_resolver_test.dart \ + test/features/media/data/services/local_media_linker_test.dart \ + test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart \ + test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart \ + test/features/universal_import/data/services/photo_link_desktop_e2e_test.dart \ + test/features/media/data/services/local_media_platform_enumerate_test.dart \ + test/features/media/presentation/providers/files_tab_providers_test.dart +``` +Expected: format clean, analyze clean (0 issues), all listed test files pass. + +- [ ] **Step 3: Commit.** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): MacDive photo import" +``` + +--- + +## Self-Review + +### Spec-coverage table + +| Spec deliverable | Task(s) | +|---|---| +| Formats: native XML + SQLite only (no UDDF/CSV) | 3 (SQLite), 4 (XML), 5 (SQLite e2e) | +| Storage: reference-in-place `localFile` `MediaItem`; NO `ImportedPhotoStorage`, no byte copy | 9 (`LocalMediaLinker`), 12 (e2e asserts in-place `filePath`) | +| `ImportImageRef { originalPath, caption?, diveSourceUuid, position }` | 1 | +| `ImportPayload.imageRefs` additive field | 2 | +| XML/SQLite extraction emitting imageRefs | 3, 4 | +| `DirectoryScanner` abstraction + `ScannedFile { basename, MediaHandle }` | 7 | +| Desktop scanner (`dart:io`) | 7 | +| iOS scanner via new `enumerateScopedDirectory` channel (Swift); bookmark created while scope held | 13 | +| Android scanner via new `enumerateTree` channel (Kotlin), SAF `DocumentsContract` | 14 | +| `PhotoResolver`: direct/rebase/filename-index, single scan, handle-only output | 8 | +| `LocalMediaLinker` extracted from `FilesTabNotifier._persistOne`; Files-tab tests stay green | 9 | +| `ImportPhotoLinkController`: holds imageRefs + sourceUuid→diveId; scan→resolve→link; progress + summary {total, linked, notFound, skippedNonImage}; per-photo isolation; idempotent dedupe by (diveId+basename) | 10 | +| Post-import prompt UI (Approach B; done-screen; folder pick; progress; summary; try-another-folder) | 11 | +| `UddfEntityImportResult.sourceUuidToDiveId` (new dives) | 6 | +| Dive targeting: new + matched-existing dives | 11 (combined map from `getSourceUuidByDiveId`) | +| Idempotency: dedupe by (diveId + basename) | 10 (controller), 11 (real lookup via `getMediaForDive`) | +| Photos only; non-image skipped + counted | 8 (`skippedNonImage`), 10 (summary) | +| Captions preserved | 1, 3, 4 (carried), 9 (persisted to `MediaItem.caption`), 10 (passed through) | +| EXIF lat/long/takenAt/dimensions with mtime/dive-date fallback | 9 (`MediaSourceMetadata` + `fallbackTakenAt`), 11 (`ExifExtractor`) | +| Pending refs session-only (no table/migration) | 10 (in-memory controller), 11 (`UnifiedImportResult` carries refs) | +| Existing import wizard UNCHANGED (no new step) | 11 (prompt embedded in summary, no `acquisitionSteps` change) | +| Gated real-sample (261 imageRefs) + fixture link counts | 12 | +| Error handling: cancel/permission/per-file/not-found best-effort, never breaks completed import | 10 (try/catch isolation + error state), 7 (scanner swallows list errors) | + +### Placeholder / type-consistency checklist + +- [ ] No `TBD`/`similar to Task N`/`add error handling`-style placeholders — every code block is complete. +- [ ] `ImportImageRef` fields identical across Tasks 1, 3, 4, 8, 10, 11. +- [ ] `MediaHandle` (one-of localPath/bookmarkBlob/contentUri) identical across Tasks 7, 9, 10, 13, 14; the macOS blob+path relaxation noted in Task 9 is the only variation and is called out explicitly. +- [ ] `ScannedFile { basename, handle }` identical across Tasks 7, 8, 10, 13, 14. +- [ ] `PhotoResolutionKind` values (`rebased`, `filenameMatch`, `skippedNonImage`, `miss`) consistent across Tasks 8, 10. +- [ ] `PhotoLinkSummary { total, linked, notFound, skippedNonImage }` consistent across Tasks 10, 11. +- [ ] `LocalMediaLinker.link(...)` signature identical across Tasks 9, 10, 11, 12. +- [ ] `UddfEntityImportResult.sourceUuidToDiveId` and `UnifiedImportResult.{imageRefs,sourceUuidToDiveId}` consistent across Tasks 6, 11. +- [ ] `MediaItem` constructor params used in Task 9 match the real ctor (id, diveId, mediaType, sourceType, localPath, bookmarkRef, filePath, originalFilename, caption, takenAt, latitude, longitude, width, height, durationSeconds, createdAt, updatedAt) — verified against `media_item.dart`. +- [ ] `MediaRepository.createMedia` (empty `id` → UUID) behavior relied on in Tasks 9, 10, 12 — verified against `media_repository.dart`. +- [ ] `DiveRepository.getSourceUuidByDiveId({String? diverId})` returns `{diveId → sourceUuid}` (inverted in Task 11) — verified against `dive_repository_impl.dart:3634`. +- [ ] Channel name `com.submersion.app/local_media` consistent in Tasks 13, 14 with the existing channel. +- [ ] Every new file is created in some task before it is imported by another task. +- [ ] `// TODO(media): l10n` above each user-visible string in Tasks 10, 11. +- [ ] `coverage:ignore` used only around the native channel invokes in Tasks 13, 14 (and the pre-existing platform branch in Task 9), never around pure-Dart logic. +- [ ] Conventional-commit messages on every task; no `Co-Authored-By` lines anywhere. + +## Open Reconciliation Items + +1. **`files_tab_providers_test.dart` macOS `localPath` assertion (Task 9).** The test at ~line 306 asserts `_persistOne` sets `localPath` to the picker path on macOS. A bookmark-only `MediaHandle` would drop it. Task 9 Step 5 includes the RECONCILIATION RISK fix (carry blob + path together on macOS and have `LocalMediaLinker` honor both). Best-effort assumption: implement the blob+path handle and relax the `MediaHandle` assertion to allow both; add a linker test. If a reviewer prefers the linker NOT to store `localPath` for bookmarked items, instead update that one Files-tab assertion — but that changes existing macOS behavior, so the carry-both approach is preferred. + +2. **Synthetic-DB dive UUIDs (Tasks 3, 5, 12).** The expected imageRef counts (3) assume the existing `ZDIVE` fixture rows for `Z_PK` 1 and 2 carry non-empty `ZUUID`s (else the mapper drops their photos as orphans). `build_synthetic_db.dart:` — read the existing `ZDIVE` inserts and confirm; adjust the literal counts if not. Best-effort assumption: PKs 1 and 2 have UUIDs (the worktree's mapper test fixture implies they do). + +3. **Synthetic-DB builder function name (Tasks 3, 5, 12).** Tests call `buildSyntheticMacDiveDb(path)`. Confirm the real exported name in `test/fixtures/macdive_sqlite/build_synthetic_db.dart` (the existing `macdive_db_reader_test.dart` already calls it) and use that exact name. Best-effort assumption: `buildSyntheticMacDiveDb` returning a `File`. + +4. **MacDive XML fixture `` (Task 4).** The XML parser drops photos for dives whose `` is empty. Confirm `test/fixtures/macdive_xml/metric_small.xml` has a non-empty ``; if absent, add one in Task 4 Step 1 and assert that exact `diveSourceUuid` value. Best-effort assumption: it has one (the parser maps `identifier` → `sourceUuid` and the existing parser tests rely on dedup by identifier). + +5. **Android persistable tree permission (Task 14).** Whether `file_picker.getDirectoryPath()` auto-takes the persistable permission for the returned tree URI depends on the plugin version. If it does not, call `LocalMediaPlatform.takePersistableUri(treeUri)` once before enumerating (in `scannerFor`/`pickedFolder`). Best-effort assumption: take it explicitly on Android to be safe; the existing `takePersistableUri` already exists on the channel. + +6. **Swift `bookmarkData` security-scope option (Task 13).** `.withSecurityScope` is macOS-only; iOS must use `options: []`. Mirror the existing `createBookmark` implementation in the plugin and gate with `#if os(macOS)`. Best-effort assumption: platform-gate the option as described. + +7. **`test/helpers/test_database.dart` API (Tasks 12).** The e2e test uses `setUpTestDatabase()`/`tearDownTestDatabase()` and a real `MediaRepository` resolving via `DatabaseService.instance`. The worktree's `imported_photo_link_service_test.dart` used these helpers, so they exist; confirm the exact function names and that `MediaRepository()` reads `DatabaseService.instance.database` (verified) which the helper initialises. + +8. **Native plugin file paths (Tasks 13, 14).** The exact Swift/Kotlin plugin filenames were not opened during planning. Locate the class implementing the existing `com.submersion.app/local_media` handlers (search for `createBookmark` in `macos/`/`ios/` and `takePersistableUri` in `android/`) and add the new cases to that same class. Best-effort assumption: a single `LocalMediaPlugin.swift` (shared or per-platform) and `LocalMediaPlugin.kt`. diff --git a/docs/superpowers/specs/2026-05-21-macdive-photo-import-design.md b/docs/superpowers/specs/2026-05-21-macdive-photo-import-design.md new file mode 100644 index 000000000..02bf2d5ac --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-macdive-photo-import-design.md @@ -0,0 +1,228 @@ +# MacDive Photo Import (Reference-In-Place) — Design + +**Date:** 2026-05-21 +**Status:** Approved (design); pending implementation plan +**Supersedes:** the storage approach of the deferred branch `feature/macdive-photos` / PR #257 ("Add MacDive photo import", closed unmerged 2026-04-26) +**Related:** [2026-04-21-macdive-import-design.md](2026-04-21-macdive-import-design.md) (MacDive import), [2026-04-25-media-source-extension-design.md](2026-04-25-media-source-extension-design.md) (local media / MediaItem subsystem) + +## Context & Motivation + +MacDive photo import was built once (PR #257, 13-task plan in +[../plans/2026-04-21-macdive-photo-import.md](../plans/2026-04-21-macdive-photo-import.md)) +and **deferred** because, at the time, the app had no local photo-linking +pipeline — imported photos had nowhere durable to live. PR #257 worked around +this with its own `ImportedPhotoStorage` that **copied** photo bytes into an +app-managed directory. + +Since then, the **Media Source Extension** (Phases 1–3, merged) added a +first-class local media pipeline: the `MediaItem` model with a `localFile` +source type that **references files in place** (desktop `localPath`, iOS/macOS +security-scoped bookmark, Android content URI) rather than copying bytes, plus +orphan detection and a re-verification scan. + +This makes #257's copy-based storage layer redundant and inconsistent. This +design revives the **reusable** parts of #257 (format extraction, path +resolver, dive identity mapping) and rebuilds the storage/linking layer on top +of the current media pipeline. + +## Goals + +- Import photos referenced by MacDive **native XML** (``) and + **SQLite** (`ZDIVEIMAGE`) exports — the only two MacDive formats that embed + photo references. +- Link imported photos to dives as standard `localFile` `MediaItem`s, so they + behave identically to photos added via the Files tab (orphan detection, + "Show in Finder", sync). +- Work on **desktop and mobile** via a unified "pick a folder, app scans it" + flow. + +## Non-Goals + +- **UDDF and CSV photo import.** MacDive's UDDF exporter omits photo paths and + CSV is tabular; neither carries photo references. Out of scope. +- **Copying photo bytes into app storage.** We reference in place. Fragile + references (e.g. into MacDive's sandbox container) degrade to orphans handled + by the existing media tooling. +- **Cross-session pending photos.** Photo intentions are session-only + (in-memory); no new database table. Re-trying means "pick another folder + before closing the wizard." +- **Video import.** Photos only; non-image references are skipped and counted + (matches the media subsystem's current video-gated stance). + +## Decisions (locked) + +| Decision | Choice | Rationale | +|---|---|---| +| Formats | native XML + SQLite | Only formats embedding photo refs | +| Storage | Reference in place (`localFile` MediaItem) | Consistency with media subsystem; no byte copy; orphan tooling handles fragility | +| Platforms | Desktop + mobile | Explicit product choice | +| Mobile access | Pick a folder, app scans it | Unified UX; platform-specific tree enumeration underneath | +| Integration | Approach B — import dives, then post-import prompt | Avoids the wizard-step state bugs that stalled #257 | +| Pending refs | Session-only (in-memory) | YAGNI; no schema/migration | +| Dive targeting | Imported **and** matched-existing dives | High value, low risk given idempotent-by-basename linking | + +## Architecture & Data Flow + +Three phases. Parse and Import reuse #257's extraction and the existing import +wizard unchanged; Post-Import is new. + +``` +PARSE (XML / SQLite — reuses #257 extraction logic) + reader extracts photo elements XML: SQLite: ZDIVEIMAGE rows + -> ImportImageRef { originalPath, caption?, diveSourceUuid, position } + -> ImportPayload.imageRefs : List + +IMPORT (existing wizard — UNCHANGED) + dives written; result exposes sourceUuid -> diveId (new + matched-existing) + imageRefs + the sourceUuid->diveId map are held in wizard result state (in memory) + +POST-IMPORT (new — Approach B, session-only) + done screen: "N dives reference photos - locate them?" -> [Skip] (refs dropped on close) + -> user picks a folder (platform-specific grant) + -> DirectoryScanner enumerates folder (desktop dart:io / iOS scoped dir / Android SAF tree) + -> PhotoResolver: direct -> rebase -> filename-index => resolved file per ref + -> LocalMediaLinker -> MediaRepository.createMedia(localFile MediaItem, diveId) + (dedupe by diveId+basename) + -> summary: "47 of 52 linked - 5 not found" + RE-TRY (same session): pick another folder; idempotent linking makes re-runs safe +``` + +The seam between dive import and photo linking is the in-memory pair +(`imageRefs`, `sourceUuid->diveId`) carried in the wizard's result state. Parsing +produces photo *intentions*; import resolves dive identity; the post-import +controller drains the intentions against a user-picked folder. + +## Components + +### Salvaged from #257 (ported onto current `main`) + +| Unit | Responsibility | Interface / shape | +|---|---|---| +| `ImportImageRef` | Value type for one photo intention | `{ originalPath, caption?, diveSourceUuid, position }` | +| `ImportPayload.imageRefs` | Additive field carrying refs out of parse | `List` | +| XML/SQLite extraction | Read photo elements into refs | `MacDiveXmlReader` (``), `MacDiveDbReader` (`ZDIVEIMAGE` -> `MacDiveRawDiveImage`), `MacDiveDiveMapper` (rows -> `ImportImageRef`); parsers emit `imageRefs` | +| `PhotoResolver` | Translate recorded paths -> actual files via direct -> rebase -> filename-index; single scan per run | `resolveAll(refs, scanner) -> List` (path/handle only, no bytes) | + +### New + +| Unit | Responsibility | Interface / deps | +|---|---|---| +| `DirectoryScanner` | Platform abstraction enumerating a user-granted folder, yielding referenceable handles | `Stream scan(GrantedFolder)`; `ScannedFile { basename, MediaHandle }` | +| `LocalMediaLinker` | **Extracted** from `FilesTabNotifier._persistOne`: `(diveId, handle, exif)` -> `localFile` `MediaItem` | wraps `MediaRepository.createMedia` + `LocalMediaPlatform` / `LocalBookmarkStorage` | +| `ImportPhotoLinkController` | Holds in-memory `imageRefs` + `sourceUuid->diveId`; on folder pick drives scanner -> resolver -> linker; emits progress + summary | Riverpod notifier; deps: resolver, linker, scanner factory | +| Post-import prompt UI | Done-screen prompt -> folder pick -> summary; "try another folder" within session | widget reading the controller | +| `UddfEntityImportResult.sourceUuidToDiveId` | Expose the mapping the linker needs (as #257 did) | add `Map` | + +`LocalMediaLinker` is the load-bearing unit: it is an **extraction** of the +existing `localFile` persistence (currently buried in the presentation-layer +`FilesTabNotifier._persistOne`), so imported photos and Files-tab photos share +one persistence path and cannot drift. + +## Platform Abstraction: `DirectoryScanner` + +The highest-risk new work; the abstraction is small but implementations diverge. + +``` +abstract class DirectoryScanner { + /// Enumerate the user-granted folder recursively, yielding each file's + /// basename plus a handle LocalMediaLinker can persist as-is. + Stream scan(GrantedFolder folder); +} +class ScannedFile { final String basename; final MediaHandle handle; } +// MediaHandle = desktop path | iOS bookmark key | Android document URI +``` + +| Platform | Folder grant | Enumeration | Per-file handle | +|---|---|---|---| +| Desktop (Win/Linux/macOS) | `getDirectoryPath()` | `dart:io` `Directory.list(recursive:true)` | absolute path -> `localPath` | +| iOS | `UIDocumentPicker` (folder mode) -> scoped dir URL | **new channel method** `enumerateScopedDirectory` via `FileManager` | security-scoped bookmark per file -> keychain -> `bookmarkRef` | +| Android | `ACTION_OPEN_DOCUMENT_TREE` -> persisted tree URI | **new channel method** `enumerateTree` via `DocumentsContract` | per-file document URI -> `bookmarkRef` | + +Mobile work **extends the existing `LocalMediaPlatform` channel** (already owns +`createBookmark` / `takePersistableUri`) with the two enumeration methods. No +new plugin. + +**iOS/macOS scope-lifetime rule (correctness-critical):** per-file +security-scoped bookmarks must be created **while the directory's scope is held** +(inside the same `startAccessingSecurityScopedResource` / `stopAccessing` window +as the enumeration). Therefore `scan()` produces the referenceable handle +*during* the walk — this is why `ScannedFile` carries a `handle`, not just a +name. Android's persisted tree permission is more forgiving but follows the +analogous pattern. + +**Performance:** `scan()` enumerates once; the resolver builds its filename +index from that single stream and reuses it across all refs (the lazy-index fix +#257 landed under review). MacDive containers can hold thousands of files; we +never re-walk. + +## Resolver behavior by platform + +- **Direct-path** strategy resolves only on desktop (recorded paths are from the + Mac that wrote the export). On mobile it effectively never matches. +- **Rebase** (recorded path tail grafted onto the picked root) and + **filename-index** (match by basename) work on all platforms via the scanner. + Mobile relies on these two. + +## Error Handling & Edge Cases + +All best-effort; the already-completed dive import is never compromised. + +| Situation | Behavior | +|---|---| +| Folder pick cancelled | No-op; dives stay imported; prompt remains dismissible | +| Scope/permission failure | Clear error, abort scan gracefully, allow retry | +| Per-file read error mid-enumeration | Skip file, continue | +| Photo not found | Count "not found", leave unlinked, show in summary | +| Per-photo link failure | Isolate; bump counter; loop continues | + +- **Idempotent retry:** linking dedupes by `(diveId + basename)`; re-picking a + folder never double-links. This same invariant makes linking to + matched-existing dives safe against re-imports. +- **Video refs:** photos only; non-image extensions skipped and counted. +- **Captions:** stored directly on the existing `MediaItem.caption` column. +- **EXIF:** copy lat/long/takenAt/dimensions like the Files tab; on failure fall + back to file mtime / dive date. + +## Dive Targeting + +Photos link to whichever dive their `diveSourceUuid` resolves to — whether +**newly created** in this import or **matched as an existing duplicate**. The +import result exposes `sourceUuidToDiveId` covering both; `getSourceUuidByDiveId` +backs the matched-existing case. Idempotent-by-basename linking keeps re-imports +safe. + +## Testing Strategy (TDD throughout) + +- **Unit:** `ImportImageRef`; XML/SQLite extraction (synthetic fixtures); + `PhotoResolver` strategies + single-scan against a fake `DirectoryScanner`; + `LocalMediaLinker` (mock `MediaRepository` + `LocalMediaPlatform`); + `ImportPhotoLinkController` orchestration (mapping, per-photo isolation, + dedupe-on-retry, summary counts). +- **`DirectoryScanner`:** desktop impl against a real temp dir; iOS/Android Dart + wrappers against a mock channel (native Swift/Kotlin verified manually — the + channel bridge is the one coverage-ignore candidate). +- **Widget:** post-import prompt — prompt / in-progress / summary states; + folder-pick drives the controller. +- **Gated real-sample:** port #257's real MacDive SQLite test (261 imageRefs) + + a fixture photo folder asserting resolve/link counts. + +## Future Work (explicitly deferred) + +- Cross-session pending photos (a `pending_import_photos` table) for + "import now, locate photos next week". +- Generic photo attach for non-photo-carrying formats (UDDF/CSV) via + timestamp/filename matching. +- Video import once the media subsystem ungates video. + +## References + +- Deferred branch `feature/macdive-photos` (PR #257, closed unmerged) — reusable + commits: `ImportImageRef`/payload field, `PhotoResolver`, XML/SQLite + extraction, `sourceUuidToDiveId`, `PhotoLinkingStep` (UI ideas only). +- Current media pipeline: `lib/features/media/presentation/providers/files_tab_providers.dart` + (`_persistOne`), `lib/features/media/data/repositories/media_repository.dart`, + `lib/features/media/data/services/local_media_platform.dart`, + `lib/features/media/domain/entities/media_item.dart`. +- Import pipeline: `lib/features/universal_import/`, + `lib/features/import_wizard/data/adapters/universal_adapter.dart`, + `lib/features/dive_import/data/services/uddf_entity_importer.dart`. diff --git a/ios/Runner/LocalMediaHandler.swift b/ios/Runner/LocalMediaHandler.swift index dad7bcd4c..20ea42f1a 100644 --- a/ios/Runner/LocalMediaHandler.swift +++ b/ios/Runner/LocalMediaHandler.swift @@ -128,6 +128,13 @@ class LocalMediaHandler: NSObject { return } readBookmarkBytes(blob: blob.data, result: result) + case "enumerateScopedDirectory": + guard let args = call.arguments as? [String: Any], + let bookmark = args["bookmark"] as? FlutterStandardTypedData else { + result(FlutterError(code: "INVALID_ARGS", message: "bookmark required", details: nil)) + return + } + enumerateScopedDirectory(folderBookmark: bookmark.data, result: result) default: result(FlutterMethodNotImplemented) } @@ -202,6 +209,58 @@ class LocalMediaHandler: NSObject { result(nil) } + /// Resolves a security-scoped FOLDER bookmark, holds its scope for the + /// whole walk, and mints a per-file bookmark for every regular file under + /// it. On iOS the folder bookmark resolves with `options: []` (security + /// scope is implicit for picker-supplied URLs) and per-file bookmarks use + /// `.minimalBookmark`, matching SecurityScopedBookmarkHandler. + private func enumerateScopedDirectory(folderBookmark: Data, result: @escaping FlutterResult) { + var stale = false + let dirURL: URL + do { + dirURL = try URL( + resolvingBookmarkData: folderBookmark, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &stale + ) + } catch { + result( + FlutterError( + code: "RESOLVE_FAILED", + message: "Could not resolve folder bookmark: \(error.localizedDescription)", + details: nil + )) + return + } + let didAccess = dirURL.startAccessingSecurityScopedResource() + defer { if didAccess { dirURL.stopAccessingSecurityScopedResource() } } + var entries: [[String: Any]] = [] + let fm = FileManager.default + if let walker = fm.enumerator(at: dirURL, includingPropertiesForKeys: [.isRegularFileKey]) { + for case let fileURL as URL in walker { + let isFile = (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile ?? false + if !isFile { continue } + #if os(macOS) + let bookmarkOptions: URL.BookmarkCreationOptions = [.withSecurityScope] + #else + let bookmarkOptions: URL.BookmarkCreationOptions = .minimalBookmark + #endif + if let blob = try? fileURL.bookmarkData( + options: bookmarkOptions, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) { + entries.append([ + "basename": fileURL.lastPathComponent, + "bookmarkBlob": FlutterStandardTypedData(bytes: blob), + ]) + } + } + } + result(entries) + } + private func readBookmarkBytes(blob: Data, result: @escaping FlutterResult) { var stale = false do { diff --git a/lib/features/dive_import/data/services/uddf_entity_importer.dart b/lib/features/dive_import/data/services/uddf_entity_importer.dart index da94e7bdb..40b277745 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -133,6 +133,15 @@ class UddfEntityImportResult { final int courses; final List diveIds; + /// Maps source-side dive UUID -> the newly-created dive's internal ID. + /// + /// Populated for every imported dive whose source record carried a + /// non-null `sourceUuid` key. Used by the post-import photo-linking + /// pipeline to resolve `ImportImageRef.diveSourceUuid` to the new dive + /// row. Dives whose source record had no UUID are absent — callers MUST + /// treat missing entries as "no mapping" rather than as an error. + final Map sourceUuidToDiveId; + const UddfEntityImportResult({ this.trips = 0, this.equipment = 0, @@ -146,6 +155,7 @@ class UddfEntityImportResult { this.dives = 0, this.courses = 0, this.diveIds = const [], + this.sourceUuidToDiveId = const {}, }); int get total => @@ -372,6 +382,7 @@ class UddfEntityImporter { dives: divesResult.count, courses: coursesCount, diveIds: divesResult.diveIds, + sourceUuidToDiveId: divesResult.sourceUuidToDiveId, ); } @@ -971,10 +982,13 @@ class UddfEntityImporter { ImportProgressCallback? onProgress, ImportCancellationToken? cancelToken, }) async { - if (selected.isEmpty) return const _DiveImportResult(0, 0); + if (selected.isEmpty) { + return const _DiveImportResult(count: 0, inlineBuddies: 0); + } onProgress?.call(ImportPhase.dives, 0, selected.length); var count = 0; final importedDiveIds = []; + final sourceUuidToDiveId = {}; final inlineBuddyIds = {}; // Sort selected indices by dateTime (oldest first) for sequential numbering. @@ -1207,6 +1221,15 @@ class UddfEntityImporter { await repos.diveRepository.createDive(dive); importedDiveIds.add(diveId); + // Capture source UUID -> new dive ID so the post-import photo + // pipeline can resolve ImportImageRef.diveSourceUuid to this row. + // Only dives whose source record carried a non-null sourceUuid land + // in the map. + final sourceUuidValue = diveData['sourceUuid'] as String?; + if (sourceUuidValue != null && sourceUuidValue.isNotEmpty) { + sourceUuidToDiveId[sourceUuidValue] = diveId; + } + // Write MacDive dive metadata columns that don't flow through the Dive // domain entity. Also plug `weather` into the existing weatherDescription // column (it wasn't being populated for UDDF imports). Only issue the @@ -1527,7 +1550,12 @@ class UddfEntityImporter { onProgress?.call(ImportPhase.dives, count, selected.length); } - return _DiveImportResult(count, inlineBuddyIds.length, importedDiveIds); + return _DiveImportResult( + count: count, + inlineBuddies: inlineBuddyIds.length, + diveIds: importedDiveIds, + sourceUuidToDiveId: sourceUuidToDiveId, + ); } // -- Dive helper methods -- @@ -1822,10 +1850,12 @@ class _DiveImportResult { final int count; final int inlineBuddies; final List diveIds; + final Map sourceUuidToDiveId; - const _DiveImportResult( - this.count, - this.inlineBuddies, [ + const _DiveImportResult({ + required this.count, + required this.inlineBuddies, this.diveIds = const [], - ]); + this.sourceUuidToDiveId = const {}, + }); } diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index bc2183054..9a07f0012 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -468,11 +468,25 @@ class UniversalAdapter implements ImportSourceAdapter { cancelToken: cancelToken, ); + // Combine new-dive UUIDs (from the importer) with matched-existing + // dives so photos link to duplicates the user kept. getSourceUuidByDiveId + // returns { diveId -> sourceUuid }; invert it for our needs. + final existingByDiveId = await _ref + .read(diveRepositoryProvider) + .getSourceUuidByDiveId(diverId: currentDiver.id); + final combinedSourceUuidToDiveId = { + for (final entry in existingByDiveId.entries) entry.value: entry.key, + // New dives win over existing on UUID collision (we just created them). + ...result.sourceUuidToDiveId, + }; + return UnifiedImportResult( importedCounts: _convertImportCounts(result), consolidatedCount: 0, skippedCount: skipped, importedDiveIds: result.diveIds, + imageRefs: payload.imageRefs, + sourceUuidToDiveId: combinedSourceUuidToDiveId, ); } diff --git a/lib/features/import_wizard/domain/models/unified_import_result.dart b/lib/features/import_wizard/domain/models/unified_import_result.dart index 8e8c8b28c..fc2daab8e 100644 --- a/lib/features/import_wizard/domain/models/unified_import_result.dart +++ b/lib/features/import_wizard/domain/models/unified_import_result.dart @@ -1,4 +1,5 @@ import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; /// The outcome of a completed unified import wizard run. /// @@ -23,6 +24,14 @@ class UnifiedImportResult { /// IDs of the dives that were imported, for post-import filtering. final List importedDiveIds; + /// Photo references parsed from the source (MacDive XML/SQLite). Empty for + /// formats that carry none. Drives the post-import photo-locate prompt. + final List imageRefs; + + /// Combined source-UUID -> dive-ID map covering both newly-created dives + /// and matched-existing duplicates, so photos link to either. + final Map sourceUuidToDiveId; + /// Non-null when the import failed with an error. final String? errorMessage; @@ -32,6 +41,8 @@ class UnifiedImportResult { this.updatedCount = 0, required this.skippedCount, this.importedDiveIds = const [], + this.imageRefs = const [], + this.sourceUuidToDiveId = const {}, this.errorMessage, }); } diff --git a/lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart b/lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart new file mode 100644 index 000000000..b09a6a8b8 --- /dev/null +++ b/lib/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart @@ -0,0 +1,197 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/media/data/services/exif_extractor.dart'; +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; +import 'package:submersion/features/universal_import/data/services/android_directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/desktop_directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/ios_directory_scanner.dart'; +import 'package:submersion/features/universal_import/presentation/providers/import_photo_link_controller.dart'; + +/// Session-scoped controller for the post-import photo-locate flow. +/// +/// [scannerFor] selects the platform scanner: [AndroidDirectoryScanner] on +/// Android (SAF tree URI), [IosDirectoryScanner] on iOS (security-scoped +/// directory), and [DesktopDirectoryScanner] on all other platforms +/// (dart:io POSIX path). +final importPhotoLinkControllerProvider = + StateNotifierProvider(( + ref, + ) { + final mediaRepository = ref.read(mediaRepositoryProvider); + final bookmarkStorage = ref.read(localBookmarkStorageProvider); + final platform = ref.read(localMediaPlatformProvider); + final exif = ExifExtractor(); + return ImportPhotoLinkController( + scannerFor: (_) { + if (!kIsWeb && Platform.isAndroid) { + return AndroidDirectoryScanner(platform); + } + if (!kIsWeb && Platform.isIOS) return IosDirectoryScanner(platform); + return DesktopDirectoryScanner(); + }, + linker: LocalMediaLinker( + mediaRepository: mediaRepository, + bookmarkStorage: bookmarkStorage, + ), + metadataFor: (file) async { + final path = file.handle.localPath; + if (path == null) { + return const MediaSourceMetadata(mimeType: 'image/jpeg'); + } + final meta = await exif.extract(File(path)); + return meta ?? const MediaSourceMetadata(mimeType: 'image/jpeg'); + }, + alreadyLinkedBasenames: (diveId) async { + final items = await mediaRepository.getMediaForDive(diveId); + return items + .map((m) => m.originalFilename ?? '') + .where((n) => n.isNotEmpty) + .toSet(); + }, + fallbackTakenAtFor: (_) => DateTime.now(), + ); + }); + +/// Post-import affordance that lets the user point at the folder holding the +/// photos referenced by the dives they just imported, then links them. +/// +/// Renders nothing when the import carried no photo references. Otherwise it +/// walks the user through: prompt -> folder pick -> progress -> summary, with +/// a "try another folder" retry that is safe because linking is idempotent by +/// basename. +class ImportPhotoLocatePrompt extends ConsumerWidget { + const ImportPhotoLocatePrompt({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(importPhotoLinkControllerProvider); + if (state.refCount == 0) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final controller = ref.read(importPhotoLinkControllerProvider.notifier); + + Future pick() async { + // Mobile pickers preserve a durable grant token (Android SAF tree URI / + // iOS security-scoped folder bookmark) that the native enumerators need. + // Desktop keeps the plain directory-path picker. + if (!kIsWeb && Platform.isAndroid) { + final platform = ref.read(localMediaPlatformProvider); + final uri = await platform.pickPersistableTreeUri(); + if (uri == null) return; + await controller.pickedFolder(GrantedFolder(path: uri)); + return; + } + if (!kIsWeb && Platform.isIOS) { + final platform = ref.read(localMediaPlatformProvider); + // SecurityScopedBookmarkHandler.pickFolderWithSecurityScope write-probes + // the chosen folder, so a read-only source (e.g. the Photos library) is + // rejected and returns null here (silently treated as cancel). + // Follow-up: parameterize the picker to skip the write-probe for read-only photo import. + final picked = await platform.pickFolderBookmark(); + if (picked == null) return; + await controller.pickedFolder( + GrantedFolder(path: picked.path, iosFolderBookmark: picked.bookmark), + ); + return; + } + final picked = await FilePicker.getDirectoryPath(); + if (picked == null) return; + await controller.pickedFolder(GrantedFolder(path: picked)); + } + + final Widget action; + if (state.isRunning) { + action = Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + // TODO(media): l10n + Text('Linking ${state.processed} of ${state.total}...'), + ], + ); + } else if (state.summary != null) { + final s = state.summary!; + final skippedSuffix = s.skippedNonImage > 0 + ? ', ${s.skippedNonImage} skipped' + : ''; + action = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + // TODO(media): l10n + '${s.linked} linked, ${s.notFound} not found$skippedSuffix', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: pick, + icon: const Icon(Icons.folder_open), + // TODO(media): l10n + label: const Text('Try another folder'), + ), + ], + ); + } else if (state.errorMessage != null) { + action = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + // TODO(media): l10n + state.errorMessage!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: pick, + icon: const Icon(Icons.folder_open), + // TODO(media): l10n + label: const Text('Try another folder'), + ), + ], + ); + } else { + action = FilledButton.icon( + onPressed: pick, + icon: const Icon(Icons.image_search), + // TODO(media): l10n + label: const Text('Locate Photos'), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(height: 32), + Text( + // TODO(media): l10n + '${state.refCount} photos referenced by these dives', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + action, + ], + ), + ); + } +} diff --git a/lib/features/import_wizard/presentation/widgets/import_summary_step.dart b/lib/features/import_wizard/presentation/widgets/import_summary_step.dart index f73a28702..a8d22b734 100644 --- a/lib/features/import_wizard/presentation/widgets/import_summary_step.dart +++ b/lib/features/import_wizard/presentation/widgets/import_summary_step.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; import 'package:submersion/features/import_wizard/presentation/providers/import_wizard_providers.dart'; +import 'package:submersion/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart'; import 'package:submersion/l10n/l10n_extension.dart'; /// The summary step shown after the import completes. @@ -13,7 +14,7 @@ import 'package:submersion/l10n/l10n_extension.dart'; /// On error, shows an error icon and message with a "Done" button. /// /// While the import is still running, shows a loading indicator. -class ImportSummaryStep extends ConsumerWidget { +class ImportSummaryStep extends ConsumerStatefulWidget { /// Called when the user taps the "Done" button. final VoidCallback onDone; @@ -27,29 +28,63 @@ class ImportSummaryStep extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ImportSummaryStepState(); +} + +class _ImportSummaryStepState extends ConsumerState { + /// Guards the one-time seed so rebuilds never re-seed and wipe the summary. + bool _seeded = false; + + @override + Widget build(BuildContext context) { final state = ref.watch(importWizardNotifierProvider); final result = state.importResult; if (result == null) { final error = state.error; if (error != null) { - return _ErrorView(errorMessage: error, onDone: onDone); + return _ErrorView(errorMessage: error, onDone: widget.onDone); } return const Center(child: CircularProgressIndicator()); } if (result.errorMessage != null) { - return _ErrorView(errorMessage: result.errorMessage!, onDone: onDone); + return _ErrorView( + errorMessage: result.errorMessage!, + onDone: widget.onDone, + ); } - return _SuccessView( - importedCounts: result.importedCounts, - consolidatedCount: result.consolidatedCount, - updatedCount: result.updatedCount, - skippedCount: result.skippedCount, - onDone: onDone, - onViewDives: onViewDives, + // Hand the parsed photo refs + combined UUID->diveId map to the photo- + // locate controller exactly once. The guard prevents a rebuild from + // re-seeding and wiping a summary that is already being displayed. + if (!_seeded) { + _seeded = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref + .read(importPhotoLinkControllerProvider.notifier) + .seed( + imageRefs: result.imageRefs, + sourceUuidToDiveId: result.sourceUuidToDiveId, + ); + }); + } + + return SingleChildScrollView( + child: Column( + children: [ + _SuccessView( + importedCounts: result.importedCounts, + consolidatedCount: result.consolidatedCount, + updatedCount: result.updatedCount, + skippedCount: result.skippedCount, + onDone: widget.onDone, + onViewDives: widget.onViewDives, + ), + const ImportPhotoLocatePrompt(), + ], + ), ); } } diff --git a/lib/features/media/data/services/local_media_linker.dart b/lib/features/media/data/services/local_media_linker.dart new file mode 100644 index 000000000..82635ed04 --- /dev/null +++ b/lib/features/media/data/services/local_media_linker.dart @@ -0,0 +1,92 @@ +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/local_bookmark_storage.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Turns a (diveId, [MediaHandle], [MediaSourceMetadata]) into a persisted +/// `localFile` [MediaItem]. Extracted from `FilesTabNotifier._persistOne` +/// so imported photos and Files-tab photos share one persistence path and +/// cannot drift. +/// +/// Handle handling (independent, so macOS can carry blob + path together): +/// - [MediaHandle.bookmarkBlob] -> written to the keychain under a fresh +/// UUID; that UUID becomes `bookmarkRef` (iOS / macOS). +/// - [MediaHandle.contentUri] -> stored as `bookmarkRef` (Android). +/// - [MediaHandle.localPath] -> stored as `localPath` (desktop, and macOS +/// alongside the bookmark for "Show in Finder"). +class LocalMediaLinker { + LocalMediaLinker({ + required this.mediaRepository, + required this.bookmarkStorage, + }); + + final MediaRepository mediaRepository; + final LocalBookmarkStorage bookmarkStorage; + + static const _uuid = Uuid(); + final _log = LoggerService.forClass(LocalMediaLinker); + + /// Persists one file as a `localFile` [MediaItem] linked to [diveId] and + /// returns the created item. + Future link({ + required String diveId, + required MediaHandle handle, + required String basename, + required MediaSourceMetadata metadata, + required DateTime fallbackTakenAt, + String? caption, + }) async { + try { + String? localPath; + String? bookmarkRef; + + if (handle.bookmarkBlob != null) { + bookmarkRef = _uuid.v4(); + await bookmarkStorage.write(bookmarkRef, handle.bookmarkBlob!); + } + if (handle.contentUri != null) { + bookmarkRef = handle.contentUri; + } + if (handle.localPath != null) { + localPath = handle.localPath; + } + + final isVideo = metadata.mimeType.startsWith('video/'); + final now = DateTime.now(); + final item = MediaItem( + // Empty id triggers UUID generation in MediaRepository.createMedia. + id: '', + diveId: diveId, + mediaType: isVideo ? MediaType.video : MediaType.photo, + sourceType: MediaSourceType.localFile, + localPath: localPath, + bookmarkRef: bookmarkRef, + filePath: localPath, + originalFilename: basename, + caption: caption, + takenAt: metadata.takenAt ?? fallbackTakenAt, + latitude: metadata.latitude, + longitude: metadata.longitude, + width: metadata.width, + height: metadata.height, + durationSeconds: metadata.durationSeconds, + createdAt: now, + updatedAt: now, + ); + + return await mediaRepository.createMedia(item); + } catch (e, st) { + _log.error( + 'Failed to link local media for dive: $diveId', + error: e, + stackTrace: st, + ); + rethrow; + } + } +} diff --git a/lib/features/media/data/services/local_media_platform.dart b/lib/features/media/data/services/local_media_platform.dart index a0a85bb4c..33be40fcf 100644 --- a/lib/features/media/data/services/local_media_platform.dart +++ b/lib/features/media/data/services/local_media_platform.dart @@ -2,6 +2,23 @@ import 'dart:io'; import 'package:flutter/services.dart'; +/// One file returned by [LocalMediaPlatform.enumerateTree]: its [basename] +/// and the per-file document [contentUri] under the persisted tree. +class TreeDocEntry { + final String basename; + final String contentUri; + const TreeDocEntry({required this.basename, required this.contentUri}); +} + +/// One file returned by [LocalMediaPlatform.enumerateScopedDirectory]: its +/// [basename] and the security-scoped [bookmarkBlob] the native side created +/// while the directory scope was held. +class ScopedDirEntry { + final String basename; + final Uint8List bookmarkBlob; + const ScopedDirEntry({required this.basename, required this.bookmarkBlob}); +} + /// Result of a successful bookmark resolution. The [bookmarkRef] is a /// session-local key that callers must pass to [LocalMediaPlatform.releaseBookmark] /// when done reading the file. @@ -26,6 +43,13 @@ class ResolvedBookmark { class LocalMediaPlatform { static const _channel = MethodChannel('com.submersion.app/local_media'); + /// Channel for the iOS / macOS document-folder picker that returns a + /// security-scoped FOLDER bookmark. Owned natively by + /// `SecurityScopedBookmarkHandler` (iOS). See [pickFolderBookmark]. + static const _bookmarkChannel = MethodChannel( + 'app.submersion/security_scoped_bookmark', + ); + /// iOS / macOS only. Creates a security-scoped bookmark for [filePath] /// and returns the raw bookmark blob. Callers store this in the keychain /// (via flutter_secure_storage) and pass it back to [resolveBookmark] @@ -90,6 +114,74 @@ class LocalMediaPlatform { await _channel.invokeMethod('releaseAllBookmarks'); } + /// iOS / macOS only. Enumerates the folder referenced by [folderBookmark] + /// (a security-scoped FOLDER bookmark the user granted via the document + /// picker) and returns one [ScopedDirEntry] per file. The native side + /// resolves the bookmark, holds the directory's security scope for the + /// whole walk, and creates each file's bookmark inside that window, so the + /// returned blobs remain resolvable after the scope is released. + Future> enumerateScopedDirectory( + Uint8List folderBookmark, + ) async { + if (!Platform.isIOS && !Platform.isMacOS) { + throw UnsupportedError( + 'enumerateScopedDirectory is only supported on iOS / macOS', + ); + } + // coverage:ignore-start + final raw = await _channel.invokeListMethod>( + 'enumerateScopedDirectory', + {'bookmark': folderBookmark}, + ); + if (raw == null) return const []; + return raw + .map( + (m) => ScopedDirEntry( + basename: m['basename'] as String, + bookmarkBlob: m['bookmarkBlob'] as Uint8List, + ), + ) + .toList(); + // coverage:ignore-end + } + + /// Android only. Presents `ACTION_OPEN_DOCUMENT_TREE`, takes a persistable + /// READ permission on the chosen tree, and returns its + /// `content://tree/...` URI string (suitable for [enumerateTree]). Returns + /// null if the user cancelled. + Future pickPersistableTreeUri() async { + if (!Platform.isAndroid) { + throw UnsupportedError( + 'pickPersistableTreeUri is only supported on Android', + ); + } + // coverage:ignore-start + return _channel.invokeMethod('pickPersistableTreeUri'); + // coverage:ignore-end + } + + /// iOS / macOS only. Presents the document folder picker (via the existing + /// `app.submersion/security_scoped_bookmark` channel's + /// `pickFolderWithSecurityScope`) and returns the chosen folder's cosmetic + /// [path] plus its security-scoped FOLDER [bookmark]. Returns null if the + /// user cancelled. The [bookmark] is fed to [enumerateScopedDirectory]. + Future<({String path, Uint8List bookmark})?> pickFolderBookmark() async { + if (!Platform.isIOS && !Platform.isMacOS) { + throw UnsupportedError( + 'pickFolderBookmark is only supported on iOS / macOS', + ); + } + // coverage:ignore-start + final r = await _bookmarkChannel.invokeMapMethod( + 'pickFolderWithSecurityScope', + ); + if (r == null) return null; // user cancelled + final path = r['path'] as String; + final bookmark = (r['bookmarkData'] as Uint8List?) ?? Uint8List(0); + return (path: path, bookmark: bookmark); + // coverage:ignore-end + } + /// Android only. Calls `ContentResolver.takePersistableUriPermission` and /// returns the URI string itself (which becomes the bookmarkRef stored in /// the [MediaItem.bookmarkRef] column). @@ -156,6 +248,32 @@ class LocalMediaPlatform { // coverage:ignore-end } + /// Android only. Enumerates the persisted document tree [treeUri] + /// (returned by ACTION_OPEN_DOCUMENT_TREE) recursively via + /// `DocumentsContract`, returning one [TreeDocEntry] per file. The tree's + /// persistable permission (taken when the user granted the folder) keeps + /// each document URI readable across reboots. + Future> enumerateTree(String treeUri) async { + if (!Platform.isAndroid) { + throw UnsupportedError('enumerateTree is only supported on Android'); + } + // coverage:ignore-start + final raw = await _channel.invokeListMethod>( + 'enumerateTree', + {'treeUri': treeUri}, + ); + if (raw == null) return const []; + return raw + .map( + (m) => TreeDocEntry( + basename: m['basename'] as String, + contentUri: m['contentUri'] as String, + ), + ) + .toList(); + // coverage:ignore-end + } + /// Android only. Reads the bytes of a persisted content URI via /// `ContentResolver.openInputStream`. /// diff --git a/lib/features/media/presentation/providers/files_tab_providers.dart b/lib/features/media/presentation/providers/files_tab_providers.dart index 634e007ab..cf7b1f8c9 100644 --- a/lib/features/media/presentation/providers/files_tab_providers.dart +++ b/lib/features/media/presentation/providers/files_tab_providers.dart @@ -1,18 +1,17 @@ import 'dart:io'; import 'package:equatable/equatable.dart'; -import 'package:uuid/uuid.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/media/data/repositories/media_repository.dart'; import 'package:submersion/features/media/data/services/local_bookmark_storage.dart'; +import 'package:submersion/features/media/data/services/local_media_linker.dart'; import 'package:submersion/features/media/data/services/local_media_platform.dart'; -import 'package:submersion/features/media/domain/entities/media_item.dart'; -import 'package:submersion/features/media/domain/entities/media_source_type.dart'; import 'package:submersion/features/media/domain/value_objects/extracted_file.dart'; import 'package:submersion/features/media/domain/value_objects/matched_selection.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; /// State for the Files tab in the photo picker. /// @@ -104,9 +103,13 @@ class FilesTabNotifier extends StateNotifier { final LocalBookmarkStorage bookmarkStorage; final LocalMediaPlatform platform; - /// Used solely to generate the per-file `bookmarkRef` key on iOS / macOS. - /// The MediaItem row's primary key is generated by [MediaRepository] itself. - static const _uuid = Uuid(); + /// Shared persistence path for `localFile` media. Built from this + /// notifier's deps so Files-tab photos and imported photos go through the + /// exact same write logic (bookmark / URI / localPath + MediaItem build). + late final LocalMediaLinker _linker = LocalMediaLinker( + mediaRepository: mediaRepository, + bookmarkStorage: bookmarkStorage, + ); void toggleAutoMatch() { state = state.copyWith(autoMatchByDate: !state.autoMatchByDate); @@ -182,20 +185,17 @@ class FilesTabNotifier extends StateNotifier { } Future _persistOne(ExtractedFile file, String diveId) async { - String? localPath; - String? bookmarkRef; - + final MediaHandle handle; if (Platform.isIOS || Platform.isMacOS) { final blob = await platform.createBookmark(file.file.path); - bookmarkRef = _uuid.v4(); - await bookmarkStorage.write(bookmarkRef, blob); - if (Platform.isMacOS) { + handle = MediaHandle( + bookmarkBlob: blob, // macOS desktop UX needs the path for "Show in Finder" + the // right-click context menu's localPath gate; the bookmark stays // the source of truth for resolution. iOS keeps localPath null // because the picker path is sandbox-scoped and not reusable. - localPath = file.file.path; - } + localPath: Platform.isMacOS ? file.file.path : null, + ); } // coverage:ignore-start // Android branch can only be exercised on an Android host (test suite @@ -203,34 +203,21 @@ class FilesTabNotifier extends StateNotifier { else if (Platform.isAndroid) { // file.file.path on Android may already be a content URI from // file_picker. takePersistableUri makes it durable across reboots. - bookmarkRef = await platform.takePersistableUri(file.file.path); + final uri = await platform.takePersistableUri(file.file.path); + handle = MediaHandle.contentUri(uri); } else { - localPath = file.file.path; + handle = MediaHandle.localPath(file.file.path); } // coverage:ignore-end - final now = DateTime.now(); - final item = MediaItem( - // Empty id triggers UUID generation in MediaRepository.createMedia. - id: '', + final basename = file.file.path.split(Platform.pathSeparator).last; + final saved = await _linker.link( diveId: diveId, - mediaType: file.metadata.mimeType.startsWith('video/') - ? MediaType.video - : MediaType.photo, - sourceType: MediaSourceType.localFile, - localPath: localPath, - bookmarkRef: bookmarkRef, - takenAt: file.metadata.takenAt ?? now, - latitude: file.metadata.latitude, - longitude: file.metadata.longitude, - width: file.metadata.width, - height: file.metadata.height, - durationSeconds: file.metadata.durationSeconds, - createdAt: now, - updatedAt: now, + handle: handle, + basename: basename, + metadata: file.metadata, + fallbackTakenAt: DateTime.now(), ); - - final saved = await mediaRepository.createMedia(item); return saved.id; } } diff --git a/lib/features/universal_import/data/models/import_image_ref.dart b/lib/features/universal_import/data/models/import_image_ref.dart new file mode 100644 index 000000000..60440db17 --- /dev/null +++ b/lib/features/universal_import/data/models/import_image_ref.dart @@ -0,0 +1,63 @@ +import 'package:equatable/equatable.dart'; + +/// A photo referenced by a dive in the source dataset. Carries the +/// original filesystem path (absolute on the machine that wrote the +/// export), the caption (if any), the dive it belongs to (by source +/// UUID), and a display position for ordering. +/// +/// Populated by format-specific readers: +/// - [MacDiveDbReader] reads `ZDIVEIMAGE.ZPATH`/`ZORIGINALPATH` +/// - [MacDiveXmlReader] reads `` +/// +/// Consumed by the post-import photo-linking pipeline (PhotoResolver -> +/// LocalMediaLinker via ImportPhotoLinkController). +class ImportImageRef extends Equatable { + /// Absolute path as recorded in the source (may not exist on the + /// machine running the import — the resolver handles misses). + final String originalPath; + + /// The dive this photo is attached to, matched by source UUID on the + /// dive map. + final String diveSourceUuid; + + /// Optional caption from the source. + final String? caption; + + /// Display position among a dive's photos (0-based). Sources that + /// don't record ordering default to 0. + final int position; + + /// Optional stable UUID the source assigned to this photo + /// (MacDive's `ZDIVEIMAGE.ZUUID`). Null when the source has no + /// per-photo ID. + final String? sourceUuid; + + const ImportImageRef({ + required this.originalPath, + required this.diveSourceUuid, + this.caption, + this.position = 0, + this.sourceUuid, + }); + + /// Filename component of [originalPath]. Used for filename-fallback + /// resolution when the original absolute path doesn't exist on the + /// local filesystem. + String get filename { + // Handle both POSIX and Windows separators — MacDive paths on a Mac + // use /, but a Windows-generated export might use \. + final slash = originalPath.lastIndexOf('/'); + final bslash = originalPath.lastIndexOf(r'\'); + final split = slash > bslash ? slash : bslash; + return split >= 0 ? originalPath.substring(split + 1) : originalPath; + } + + @override + List get props => [ + originalPath, + diveSourceUuid, + caption, + position, + sourceUuid, + ]; +} diff --git a/lib/features/universal_import/data/models/import_payload.dart b/lib/features/universal_import/data/models/import_payload.dart index c96077564..be3acca4c 100644 --- a/lib/features/universal_import/data/models/import_payload.dart +++ b/lib/features/universal_import/data/models/import_payload.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; import 'package:submersion/features/universal_import/data/models/import_warning.dart'; /// Unified import payload produced by all parsers. @@ -24,10 +25,16 @@ class ImportPayload extends Equatable { /// Metadata about the import source. final Map metadata; + /// Photo references extracted by the parser (MacDive native XML / + /// SQLite). Empty for formats that carry no photo references. Carried + /// out of parse for the post-import photo-linking pipeline. + final List imageRefs; + const ImportPayload({ required this.entities, this.warnings = const [], this.metadata = const {}, + this.imageRefs = const [], }); /// Get entities of a specific type, or empty list if none. @@ -46,11 +53,12 @@ class ImportPayload extends Equatable { .toList(); /// Whether the payload has any data to import. - bool get isEmpty => entities.values.every((list) => list.isEmpty); + bool get isEmpty => + entities.values.every((list) => list.isEmpty) && imageRefs.isEmpty; /// Whether the payload has any data to import. bool get isNotEmpty => !isEmpty; @override - List get props => [entities, warnings, metadata]; + List get props => [entities, warnings, metadata, imageRefs]; } diff --git a/lib/features/universal_import/data/parsers/macdive_xml_parser.dart b/lib/features/universal_import/data/parsers/macdive_xml_parser.dart index 57c3a3fe7..f4aad0a28 100644 --- a/lib/features/universal_import/data/parsers/macdive_xml_parser.dart +++ b/lib/features/universal_import/data/parsers/macdive_xml_parser.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:submersion/features/dive_log/domain/entities/dive.dart' show GasMix; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; import 'package:submersion/features/universal_import/data/models/import_warning.dart'; @@ -189,9 +190,27 @@ class MacDiveXmlParser implements ImportParser { entities[ImportEntityType.tags] = tagsByName.values.toList(); } + final imageRefs = []; + for (final dive in logbook.dives) { + final diveUuid = dive.identifier; + if (diveUuid == null || diveUuid.isEmpty) continue; + for (final p in dive.photos) { + if (p.path.isEmpty) continue; + imageRefs.add( + ImportImageRef( + originalPath: p.path, + diveSourceUuid: diveUuid, + caption: p.caption, + position: p.position, + ), + ); + } + } + return ImportPayload( entities: entities, warnings: warnings, + imageRefs: imageRefs, metadata: { 'source': 'macdive_xml', 'diveCount': logbook.dives.length, diff --git a/lib/features/universal_import/data/services/android_directory_scanner.dart b/lib/features/universal_import/data/services/android_directory_scanner.dart new file mode 100644 index 000000000..4892b4b8c --- /dev/null +++ b/lib/features/universal_import/data/services/android_directory_scanner.dart @@ -0,0 +1,22 @@ +import 'package:submersion/features/media/data/services/local_media_platform.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Android [DirectoryScanner] over the `enumerateTree` channel method. +/// Each yielded [ScannedFile] carries a per-file content URI usable as the +/// `bookmarkRef` on the persisted [MediaItem] row. +class AndroidDirectoryScanner implements DirectoryScanner { + AndroidDirectoryScanner(this._platform); + final LocalMediaPlatform _platform; + + @override + Stream scan(GrantedFolder folder) async* { + final entries = await _platform.enumerateTree(folder.path); + for (final e in entries) { + yield ScannedFile( + basename: e.basename, + handle: MediaHandle.contentUri(e.contentUri), + ); + } + } +} diff --git a/lib/features/universal_import/data/services/desktop_directory_scanner.dart b/lib/features/universal_import/data/services/desktop_directory_scanner.dart new file mode 100644 index 000000000..c60cca499 --- /dev/null +++ b/lib/features/universal_import/data/services/desktop_directory_scanner.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Desktop (Windows / Linux / macOS) [DirectoryScanner] backed by +/// `dart:io`. The recorded paths in the export came from a Mac, so the +/// handle is the absolute filesystem path; no bookmark is needed because +/// desktop file access does not expire. +class DesktopDirectoryScanner implements DirectoryScanner { + final _log = LoggerService.forClass(DesktopDirectoryScanner); + + @override + Stream scan(GrantedFolder folder) async* { + final dir = Directory(folder.path); + if (!await dir.exists()) return; + try { + await for (final entity in dir.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File) continue; + yield ScannedFile( + basename: p.basename(entity.path), + handle: MediaHandle.localPath(entity.path), + ); + } + } catch (e, st) { + // Permission / IO errors surface as stream error events here, not as + // a synchronous throw from list(); catching around the await-for is + // what actually handles them. Degrade to "no more files". + _log.error( + 'Failed to list directory: ${folder.path}', + error: e, + stackTrace: st, + ); + } + } +} diff --git a/lib/features/universal_import/data/services/directory_scanner.dart b/lib/features/universal_import/data/services/directory_scanner.dart new file mode 100644 index 000000000..a6c648c0e --- /dev/null +++ b/lib/features/universal_import/data/services/directory_scanner.dart @@ -0,0 +1,30 @@ +import 'dart:typed_data'; + +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// A user-granted folder to enumerate. On desktop [path] is an absolute +/// filesystem path; on Android it is a persisted `content://tree/...` URI +/// string; on iOS the durable grant is carried by [iosFolderBookmark] +/// instead (the [path] there is only the cosmetic picker path). +class GrantedFolder { + /// Desktop: absolute path. Android: content:// tree URI string. iOS: + /// unused for enumeration (see [iosFolderBookmark]). + final String path; + + /// iOS only: the security-scoped FOLDER bookmark from the document picker. + /// Resolved natively to walk the directory and mint per-file bookmarks. + final Uint8List? iosFolderBookmark; + + const GrantedFolder({required this.path, this.iosFolderBookmark}); +} + +/// Platform abstraction enumerating a user-granted folder recursively and +/// yielding a persistable [ScannedFile] per file. +/// +/// The iOS / macOS implementation MUST create each file's security-scoped +/// bookmark while the directory scope is held (during the walk), which is +/// why the stream yields a [ScannedFile] carrying a handle rather than +/// just a name. Callers enumerate exactly once per run. +abstract class DirectoryScanner { + Stream scan(GrantedFolder folder); +} diff --git a/lib/features/universal_import/data/services/ios_directory_scanner.dart b/lib/features/universal_import/data/services/ios_directory_scanner.dart new file mode 100644 index 000000000..33670e944 --- /dev/null +++ b/lib/features/universal_import/data/services/ios_directory_scanner.dart @@ -0,0 +1,24 @@ +import 'package:submersion/features/media/data/services/local_media_platform.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// iOS [DirectoryScanner] over the `enumerateScopedDirectory` channel +/// method. Each yielded [ScannedFile] carries a security-scoped bookmark +/// blob created natively while the directory scope was held. +class IosDirectoryScanner implements DirectoryScanner { + IosDirectoryScanner(this._platform); + final LocalMediaPlatform _platform; + + @override + Stream scan(GrantedFolder folder) async* { + final entries = await _platform.enumerateScopedDirectory( + folder.iosFolderBookmark!, + ); + for (final e in entries) { + yield ScannedFile( + basename: e.basename, + handle: MediaHandle.bookmark(e.bookmarkBlob), + ); + } + } +} diff --git a/lib/features/universal_import/data/services/macdive_db_reader.dart b/lib/features/universal_import/data/services/macdive_db_reader.dart index b57722481..6fb7f528d 100644 --- a/lib/features/universal_import/data/services/macdive_db_reader.dart +++ b/lib/features/universal_import/data/services/macdive_db_reader.dart @@ -78,6 +78,7 @@ class MacDiveDbReader { final certifications = _readCertifications(db); final serviceRecords = _readServiceRecords(db); final events = _readEvents(db); + final diveImages = _readDiveImages(db); final tankAndGases = _readTankAndGases(db); final dives = _readDives(db); @@ -119,6 +120,7 @@ class MacDiveDbReader { certifications: certifications, serviceRecords: serviceRecords, events: events, + diveImages: diveImages, diveToBuddyPks: diveToBuddyPks, diveToTagPks: diveToTagPks, diveToGearPks: diveToGearPks, @@ -318,6 +320,22 @@ class MacDiveDbReader { ); } + static List _readDiveImages(Database db) { + return _selectOrEmpty( + db, + 'SELECT * FROM ZDIVEIMAGE', + (r) => MacDiveRawDiveImage( + pk: r['Z_PK'] as int, + uuid: _str(r['ZUUID']) ?? '', + diveFk: (r['ZRELATIONSHIPDIVE'] as int?) ?? 0, + position: (r['ZPOSITION'] as int?) ?? 0, + caption: _str(r['ZCAPTION']), + path: _str(r['ZPATH']), + originalPath: _str(r['ZORIGINALPATH']), + ), + ); + } + static List _readEvents(Database db) { return _selectOrEmpty( db, diff --git a/lib/features/universal_import/data/services/macdive_dive_mapper.dart b/lib/features/universal_import/data/services/macdive_dive_mapper.dart index fe6273d01..5b87cbc7a 100644 --- a/lib/features/universal_import/data/services/macdive_dive_mapper.dart +++ b/lib/features/universal_import/data/services/macdive_dive_mapper.dart @@ -1,6 +1,7 @@ import 'package:submersion/features/dive_log/domain/entities/dive.dart' show GasMix; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; import 'package:submersion/features/universal_import/data/models/import_warning.dart'; import 'package:submersion/features/universal_import/data/services/macdive_raw_types.dart'; @@ -63,6 +64,28 @@ class MacDiveDiveMapper { ); } + final diveUuidByPk = { + for (final d in logbook.dives) + if (d.uuid.isNotEmpty) d.pk: d.uuid, + }; + + final imageRefs = []; + for (final img in logbook.diveImages) { + final diveUuid = diveUuidByPk[img.diveFk]; + if (diveUuid == null) continue; + final path = img.path ?? img.originalPath; + if (path == null || path.isEmpty) continue; + imageRefs.add( + ImportImageRef( + originalPath: path, + diveSourceUuid: diveUuid, + caption: img.caption, + position: img.position, + sourceUuid: img.uuid.isEmpty ? null : img.uuid, + ), + ); + } + final entities = >>{}; if (diveMaps.isNotEmpty) entities[ImportEntityType.dives] = diveMaps; if (siteMaps.isNotEmpty) entities[ImportEntityType.sites] = siteMaps; @@ -73,6 +96,7 @@ class MacDiveDiveMapper { return ImportPayload( entities: entities, warnings: warnings, + imageRefs: imageRefs, metadata: { 'source': 'macdive_sqlite', 'diveCount': logbook.dives.length, diff --git a/lib/features/universal_import/data/services/macdive_raw_types.dart b/lib/features/universal_import/data/services/macdive_raw_types.dart index 23edfc190..ffb73711e 100644 --- a/lib/features/universal_import/data/services/macdive_raw_types.dart +++ b/lib/features/universal_import/data/services/macdive_raw_types.dart @@ -329,6 +329,36 @@ class MacDiveRawEvent { }); } +/// A row from MacDive's `ZDIVEIMAGE` table — a photo reference attached +/// to a dive. The image bytes live on the filesystem at `ZPATH`; this +/// row carries only the reference. +class MacDiveRawDiveImage { + final int pk; + final String uuid; + final int diveFk; + final int position; + final String? caption; + + /// MacDive's current path for the photo (`ZPATH`). Usually an absolute + /// path on the machine MacDive last saw it on; may be just a UUID-based + /// basename for photos imported into MacDive's internal library. + final String? path; + + /// Original absolute path when MacDive first imported this photo + /// (`ZORIGINALPATH`). Typically null for internal-library photos. + final String? originalPath; + + const MacDiveRawDiveImage({ + required this.pk, + required this.uuid, + required this.diveFk, + this.position = 0, + this.caption, + this.path, + this.originalPath, + }); +} + /// Top-level container returned by [MacDiveDbReader.readAll]. Holds all /// tables keyed for lookup plus the junction tables as dive_pk → list /// of foreign PKs. The mapper walks this graph to build ImportPayload. @@ -345,6 +375,7 @@ class MacDiveRawLogbook { final List certifications; final List serviceRecords; final List events; + final List diveImages; final Map> diveToBuddyPks; final Map> diveToTagPks; final Map> diveToGearPks; @@ -367,6 +398,7 @@ class MacDiveRawLogbook { required this.certifications, required this.serviceRecords, required this.events, + this.diveImages = const [], required this.diveToBuddyPks, required this.diveToTagPks, required this.diveToGearPks, diff --git a/lib/features/universal_import/data/services/macdive_xml_models.dart b/lib/features/universal_import/data/services/macdive_xml_models.dart index 16eb5ef67..657852239 100644 --- a/lib/features/universal_import/data/services/macdive_xml_models.dart +++ b/lib/features/universal_import/data/services/macdive_xml_models.dart @@ -41,6 +41,22 @@ class MacDiveXmlLogbook { }); } +/// A photo referenced under a dive's `` container in MacDive XML. +class MacDiveXmlPhoto { + /// Absolute path as recorded in the XML. May not exist on the machine + /// running the import — the resolver handles misses. + final String path; + + /// Optional caption. Empty/whitespace-only comes through as null + /// (consistent with the reader's `_text` normaliser). + final String? caption; + + /// 0-based index among this dive's photos. Assigned by the reader. + final int position; + + const MacDiveXmlPhoto({required this.path, this.caption, this.position = 0}); +} + /// A single dive from a MacDive XML file. All numeric fields are in SI /// canonical units after the unit converter has run at the reader boundary. class MacDiveXmlDive { @@ -107,6 +123,7 @@ class MacDiveXmlDive { final List gear; final List gases; final List samples; + final List photos; const MacDiveXmlDive({ this.identifier, @@ -146,6 +163,7 @@ class MacDiveXmlDive { this.gear = const [], this.gases = const [], this.samples = const [], + this.photos = const [], }); } diff --git a/lib/features/universal_import/data/services/macdive_xml_reader.dart b/lib/features/universal_import/data/services/macdive_xml_reader.dart index 5c3bac28e..71f50e733 100644 --- a/lib/features/universal_import/data/services/macdive_xml_reader.dart +++ b/lib/features/universal_import/data/services/macdive_xml_reader.dart @@ -94,6 +94,7 @@ class MacDiveXmlReader { gear: _parseGear(el), gases: _parseGases(el, c), samples: _parseSamples(el, c), + photos: _parsePhotos(el), ); } @@ -202,6 +203,26 @@ class MacDiveXmlReader { .toList(growable: false); } + // ---- photos ---- + + static List _parsePhotos(XmlElement dive) { + final container = dive.findElements('photos').firstOrNull; + if (container == null) return const []; + var idx = 0; + final out = []; + for (final p in container.findElements('photo')) { + final path = p.findElements('path').firstOrNull?.innerText.trim(); + if (path == null || path.isEmpty) { + idx++; // advance so subsequent positions stay stable + continue; + } + final caption = _text(p, 'caption'); + out.add(MacDiveXmlPhoto(path: path, caption: caption, position: idx)); + idx++; + } + return out; + } + // ---- helpers ---- static String? _text(XmlElement parent, String name) { diff --git a/lib/features/universal_import/data/services/photo_resolver.dart b/lib/features/universal_import/data/services/photo_resolver.dart new file mode 100644 index 000000000..9ebfc41cc --- /dev/null +++ b/lib/features/universal_import/data/services/photo_resolver.dart @@ -0,0 +1,136 @@ +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// How a photo was located (or not) during resolution. +enum PhotoResolutionKind { + /// Found by grafting the recorded path tail onto the picked root. + rebased, + + /// Found by matching the recorded basename against the scanned index. + filenameMatch, + + /// The recorded reference is a non-image extension; skipped + counted. + skippedNonImage, + + /// Not found by any strategy. + miss, +} + +/// Result of resolving one [ImportImageRef] against a scanned folder. +/// Carries the matched [ScannedFile] (handle, NOT bytes) when found. +class ResolvedPhoto { + final ImportImageRef ref; + final PhotoResolutionKind kind; + final ScannedFile? scannedFile; + + const ResolvedPhoto({ + required this.ref, + required this.kind, + this.scannedFile, + }); +} + +/// Resolves recorded photo paths to actual files under a user-picked +/// folder. Enumerates the folder exactly once via [scanner], builds a +/// basename -> [ScannedFile] index from that single stream, then resolves +/// each ref using a longest-shared-tail heuristic to prefer path-rebased +/// matches over bare filename matches. Outputs handles only — no bytes +/// are read. +class PhotoResolver { + final DirectoryScanner scanner; + final GrantedFolder folder; + + const PhotoResolver({required this.scanner, required this.folder}); + + static const _imageExtensions = { + 'jpg', + 'jpeg', + 'png', + 'heic', + 'heif', + 'gif', + 'tif', + 'tiff', + 'webp', + 'bmp', + }; + + Future> resolveAll(List refs) async { + if (refs.isEmpty) return const []; + + // Single scan: build a basename -> list of ScannedFile index. + final byBasename = >{}; + await for (final file in scanner.scan(folder)) { + byBasename.putIfAbsent(file.basename, () => []).add(file); + } + + final results = []; + for (final ref in refs) { + results.add(_resolveOne(ref, byBasename)); + } + return results; + } + + ResolvedPhoto _resolveOne( + ImportImageRef ref, + Map> byBasename, + ) { + if (!_isImage(ref.filename)) { + return ResolvedPhoto(ref: ref, kind: PhotoResolutionKind.skippedNonImage); + } + + final candidates = byBasename[ref.filename] ?? const []; + if (candidates.isEmpty) { + return ResolvedPhoto(ref: ref, kind: PhotoResolutionKind.miss); + } + + // Rebase preference: among same-basename candidates, prefer the one + // whose desktop path shares the longest trailing path segment run with + // the recorded path. A shared tail beyond the basename is a "rebase"; + // a bare-basename-only match is "filenameMatch". + final recordedSegments = _segments(ref.originalPath); + ScannedFile? best; + var bestSharedTail = 0; + for (final candidate in candidates) { + final candPath = candidate.handle.localPath ?? candidate.basename; + final shared = _sharedTailLength(recordedSegments, _segments(candPath)); + if (best == null || shared > bestSharedTail) { + best = candidate; + bestSharedTail = shared; + } + } + + return ResolvedPhoto( + ref: ref, + kind: bestSharedTail >= 2 + ? PhotoResolutionKind.rebased + : PhotoResolutionKind.filenameMatch, + scannedFile: best, + ); + } + + bool _isImage(String filename) { + final dot = filename.lastIndexOf('.'); + if (dot < 0) return false; + return _imageExtensions.contains(filename.substring(dot + 1).toLowerCase()); + } + + List _segments(String path) => path + .split(RegExp(r'[\\/]+')) + .where((s) => s.isNotEmpty) + .toList(growable: false); + + /// Number of trailing segments [a] and [b] share (counting the basename). + int _sharedTailLength(List a, List b) { + var i = a.length - 1; + var j = b.length - 1; + var shared = 0; + while (i >= 0 && j >= 0 && a[i] == b[j]) { + shared++; + i--; + j--; + } + return shared; + } +} diff --git a/lib/features/universal_import/data/value_objects/scanned_file.dart b/lib/features/universal_import/data/value_objects/scanned_file.dart new file mode 100644 index 000000000..dde51c319 --- /dev/null +++ b/lib/features/universal_import/data/value_objects/scanned_file.dart @@ -0,0 +1,50 @@ +import 'dart:typed_data'; + +/// A platform-neutral, persistable reference to a single file produced by +/// a [DirectoryScanner] during enumeration. Exactly one of the three +/// reference forms is populated, matching the platform: +/// - desktop: [localPath] +/// - iOS / macOS: [bookmarkBlob] (a security-scoped bookmark created while +/// the directory scope was held) +/// - Android: [contentUri] (a per-file document URI under a persisted tree) +/// +/// [LocalMediaLinker] consumes this without touching the filesystem again. +class MediaHandle { + /// Desktop absolute filesystem path. Null on mobile. + final String? localPath; + + /// iOS / macOS security-scoped bookmark blob, created during the scan + /// while the directory's scope was held. Null off iOS / macOS. + final Uint8List? bookmarkBlob; + + /// Android per-file content URI under a persisted tree. Null off Android. + final String? contentUri; + + const MediaHandle({this.localPath, this.bookmarkBlob, this.contentUri}) + : assert( + localPath != null || bookmarkBlob != null || contentUri != null, + 'MediaHandle requires at least one reference form', + ); + + /// Desktop convenience constructor. + const MediaHandle.localPath(String path) : this(localPath: path); + + /// iOS / macOS convenience constructor. + const MediaHandle.bookmark(Uint8List blob) : this(bookmarkBlob: blob); + + /// Android convenience constructor. + const MediaHandle.contentUri(String uri) : this(contentUri: uri); + + /// The Android content URI to persist as `bookmarkRef` on the + /// [MediaItem] row, or null when this is not an Android handle. + String? get bookmarkRef => contentUri; +} + +/// One file discovered during a [DirectoryScanner.scan]: its [basename] +/// (for filename-index resolution) plus a persistable [handle]. +class ScannedFile { + final String basename; + final MediaHandle handle; + + const ScannedFile({required this.basename, required this.handle}); +} diff --git a/lib/features/universal_import/presentation/providers/import_photo_link_controller.dart b/lib/features/universal_import/presentation/providers/import_photo_link_controller.dart new file mode 100644 index 000000000..d4f1ff876 --- /dev/null +++ b/lib/features/universal_import/presentation/providers/import_photo_link_controller.dart @@ -0,0 +1,197 @@ +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/photo_resolver.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Outcome counts of a photo-linking run. +class PhotoLinkSummary { + final int total; + final int linked; + final int notFound; + final int skippedNonImage; + + const PhotoLinkSummary({ + required this.total, + required this.linked, + required this.notFound, + required this.skippedNonImage, + }); +} + +/// Immutable state for the post-import photo-linking flow. +class ImportPhotoLinkState { + final int refCount; + final bool isRunning; + final int processed; + final int total; + final PhotoLinkSummary? summary; + final String? errorMessage; + + const ImportPhotoLinkState({ + this.refCount = 0, + this.isRunning = false, + this.processed = 0, + this.total = 0, + this.summary, + this.errorMessage, + }); + + ImportPhotoLinkState copyWith({ + int? refCount, + bool? isRunning, + int? processed, + int? total, + PhotoLinkSummary? summary, + bool clearSummary = false, + String? errorMessage, + bool clearError = false, + }) { + return ImportPhotoLinkState( + refCount: refCount ?? this.refCount, + isRunning: isRunning ?? this.isRunning, + processed: processed ?? this.processed, + total: total ?? this.total, + summary: clearSummary ? null : (summary ?? this.summary), + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + ); + } +} + +/// Orchestrates the post-import photo-linking flow (Approach B, session- +/// only). Holds the parsed [ImportImageRef]s + the combined +/// sourceUuid -> diveId map; on each folder pick it scans once, resolves, +/// dedupes by (diveId + basename), links via [LocalMediaLinker], isolates +/// per-photo failures, and emits a [PhotoLinkSummary]. Re-picking another +/// folder is safe because linking is idempotent by basename. +class ImportPhotoLinkController extends StateNotifier { + ImportPhotoLinkController({ + required DirectoryScanner Function(GrantedFolder folder) scannerFor, + required LocalMediaLinker linker, + required Future Function(ScannedFile file) metadataFor, + required Future> Function(String diveId) alreadyLinkedBasenames, + required DateTime Function(ImportImageRef ref) fallbackTakenAtFor, + }) : _scannerFor = scannerFor, + _linker = linker, + _metadataFor = metadataFor, + _alreadyLinkedBasenames = alreadyLinkedBasenames, + _fallbackTakenAtFor = fallbackTakenAtFor, + super(const ImportPhotoLinkState()); + + final DirectoryScanner Function(GrantedFolder) _scannerFor; + final LocalMediaLinker _linker; + final Future Function(ScannedFile) _metadataFor; + final Future> Function(String diveId) _alreadyLinkedBasenames; + final DateTime Function(ImportImageRef) _fallbackTakenAtFor; + + final _log = LoggerService.forClass(ImportPhotoLinkController); + + List _imageRefs = const []; + Map _sourceUuidToDiveId = const {}; + + /// Seed the session data. Call once after import completes. + void seed({ + required List imageRefs, + required Map sourceUuidToDiveId, + }) { + _imageRefs = imageRefs; + _sourceUuidToDiveId = sourceUuidToDiveId; + state = state.copyWith(refCount: imageRefs.length, clearSummary: true); + } + + /// Run scan -> resolve -> link against [folder]. Best-effort; never throws. + Future pickedFolder(GrantedFolder folder) async { + if (_imageRefs.isEmpty) return; + state = state.copyWith( + isRunning: true, + processed: 0, + total: _imageRefs.length, + clearSummary: true, + clearError: true, + ); + + try { + final scanner = _scannerFor(folder); + final resolver = PhotoResolver(scanner: scanner, folder: folder); + final resolved = await resolver.resolveAll(_imageRefs); + + var linked = 0; + var notFound = 0; + var skipped = 0; + // Track basenames linked this run so two refs to the same file under + // one dive don't double-link within a single pass. + final linkedThisRun = >{}; + + for (var i = 0; i < resolved.length; i++) { + final r = resolved[i]; + switch (r.kind) { + case PhotoResolutionKind.skippedNonImage: + skipped++; + break; + case PhotoResolutionKind.miss: + notFound++; + break; + case PhotoResolutionKind.rebased: + case PhotoResolutionKind.filenameMatch: + final diveId = _sourceUuidToDiveId[r.ref.diveSourceUuid]; + if (diveId == null) { + notFound++; + break; + } + final basename = r.scannedFile!.basename; + final already = await _alreadyLinkedBasenames(diveId); + final run = linkedThisRun.putIfAbsent(diveId, () => {}); + if (already.contains(basename) || run.contains(basename)) { + // Already linked (persisted or this run) -- count as linked. + linked++; + run.add(basename); + break; + } + try { + final metadata = await _metadataFor(r.scannedFile!); + await _linker.link( + diveId: diveId, + handle: r.scannedFile!.handle, + basename: basename, + metadata: metadata, + fallbackTakenAt: _fallbackTakenAtFor(r.ref), + caption: r.ref.caption, + ); + linked++; + run.add(basename); + } catch (e, st) { + _log.error( + 'Failed to link photo: $basename', + error: e, + stackTrace: st, + ); + notFound++; + } + break; + } + state = state.copyWith(processed: i + 1); + } + + state = state.copyWith( + isRunning: false, + summary: PhotoLinkSummary( + total: resolved.length, + linked: linked, + notFound: notFound, + skippedNonImage: skipped, + ), + ); + } catch (e, st) { + _log.error('Photo-linking run failed', error: e, stackTrace: st); + state = state.copyWith( + isRunning: false, + processed: 0, + total: 0, + errorMessage: 'Could not scan that folder.', // TODO(media): l10n + ); + } + } +} diff --git a/macos/Runner/LocalMediaHandler.swift b/macos/Runner/LocalMediaHandler.swift index e18a61e98..69f50d63f 100644 --- a/macos/Runner/LocalMediaHandler.swift +++ b/macos/Runner/LocalMediaHandler.swift @@ -107,6 +107,13 @@ class LocalMediaHandler: NSObject { return } readBookmarkBytes(blob: blob.data, result: result) + case "enumerateScopedDirectory": + guard let args = call.arguments as? [String: Any], + let bookmark = args["bookmark"] as? FlutterStandardTypedData else { + result(FlutterError(code: "INVALID_ARGS", message: "bookmark required", details: nil)) + return + } + enumerateScopedDirectory(folderBookmark: bookmark.data, result: result) default: result(FlutterMethodNotImplemented) } @@ -181,6 +188,57 @@ class LocalMediaHandler: NSObject { result(nil) } + /// Resolves a security-scoped FOLDER bookmark, holds its scope for the + /// whole walk, and mints a per-file bookmark for every regular file under + /// it. On macOS the sandbox requires `.withSecurityScope` at both folder + /// resolution time and per-file creation time. + private func enumerateScopedDirectory(folderBookmark: Data, result: @escaping FlutterResult) { + var stale = false + let dirURL: URL + do { + dirURL = try URL( + resolvingBookmarkData: folderBookmark, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &stale + ) + } catch { + result( + FlutterError( + code: "RESOLVE_FAILED", + message: "Could not resolve folder bookmark: \(error.localizedDescription)", + details: nil + )) + return + } + let didAccess = dirURL.startAccessingSecurityScopedResource() + defer { if didAccess { dirURL.stopAccessingSecurityScopedResource() } } + var entries: [[String: Any]] = [] + let fm = FileManager.default + if let walker = fm.enumerator(at: dirURL, includingPropertiesForKeys: [.isRegularFileKey]) { + for case let fileURL as URL in walker { + let isFile = (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile ?? false + if !isFile { continue } + #if os(macOS) + let bookmarkOptions: URL.BookmarkCreationOptions = [.withSecurityScope] + #else + let bookmarkOptions: URL.BookmarkCreationOptions = .minimalBookmark + #endif + if let blob = try? fileURL.bookmarkData( + options: bookmarkOptions, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) { + entries.append([ + "basename": fileURL.lastPathComponent, + "bookmarkBlob": FlutterStandardTypedData(bytes: blob), + ]) + } + } + } + result(entries) + } + private func readBookmarkBytes(blob: Data, result: @escaping FlutterResult) { var stale = false do { diff --git a/test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart b/test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart new file mode 100644 index 000000000..54d3e52eb --- /dev/null +++ b/test/features/dive_import/data/services/uddf_entity_importer_source_uuid_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; + +void main() { + test('UddfEntityImportResult defaults sourceUuidToDiveId to empty', () { + const result = UddfEntityImportResult(); + expect(result.sourceUuidToDiveId, isEmpty); + }); + + test('UddfEntityImportResult carries sourceUuidToDiveId', () { + const result = UddfEntityImportResult( + dives: 2, + diveIds: ['db-1', 'db-2'], + sourceUuidToDiveId: {'src-1': 'db-1', 'src-2': 'db-2'}, + ); + expect(result.sourceUuidToDiveId, {'src-1': 'db-1', 'src-2': 'db-2'}); + expect(result.diveIds, ['db-1', 'db-2']); + }); +} diff --git a/test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart b/test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart new file mode 100644 index 000000000..c6a7710ac --- /dev/null +++ b/test/features/import_wizard/presentation/widgets/import_photo_locate_prompt_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/import_wizard/presentation/widgets/import_photo_locate_prompt.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/local_bookmark_storage.dart'; +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/presentation/providers/import_photo_link_controller.dart'; + +// These render-only tests never pick a folder, so the linker is never +// invoked. `linker` is a non-function field, so a `throw` in argument +// position would fire eagerly at construction; a real (unused) linker is the +// faithful render-only stand-in. +LocalMediaLinker _neverLinker() => LocalMediaLinker( + mediaRepository: MediaRepository(), + bookmarkStorage: LocalBookmarkStorage(), +); + +ImportPhotoLinkController _seededController({PhotoLinkSummary? summary}) { + final c = ImportPhotoLinkController( + scannerFor: (_) => throw UnimplementedError(), + linker: _neverLinker(), + metadataFor: (_) => throw UnimplementedError(), + alreadyLinkedBasenames: (_) async => const {}, + fallbackTakenAtFor: (_) => DateTime(2020), + ); + c.seed( + imageRefs: const [ + ImportImageRef(originalPath: '/x/a.jpg', diveSourceUuid: 'd'), + ImportImageRef(originalPath: '/x/b.jpg', diveSourceUuid: 'd'), + ], + sourceUuidToDiveId: const {'d': 'dive-1'}, + ); + if (summary != null) { + c.state = c.state.copyWith(summary: summary); + } + return c; +} + +Widget _wrap(ImportPhotoLinkController controller, Widget child) { + return ProviderScope( + overrides: [ + importPhotoLinkControllerProvider.overrideWith((ref) => controller), + ], + child: MaterialApp(home: Scaffold(body: child)), + ); +} + +void main() { + testWidgets('prompt shows the photo-reference count + a Locate action', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = _seededController(); + await tester.pumpWidget( + _wrap(controller, const ImportPhotoLocatePrompt()), + ); + expect(find.textContaining('2'), findsWidgets); + expect(find.text('Locate Photos'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets( + 'summary state shows linked/notFound counts + Try another folder', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = _seededController( + summary: const PhotoLinkSummary( + total: 2, + linked: 1, + notFound: 1, + skippedNonImage: 0, + ), + ); + await tester.pumpWidget( + _wrap(controller, const ImportPhotoLocatePrompt()), + ); + expect(find.textContaining('1'), findsWidgets); + expect(find.text('Try another folder'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets('renders nothing when there are no photo references', ( + tester, + ) async { + final controller = ImportPhotoLinkController( + scannerFor: (_) => throw UnimplementedError(), + linker: _neverLinker(), + metadataFor: (_) => throw UnimplementedError(), + alreadyLinkedBasenames: (_) async => const {}, + fallbackTakenAtFor: (_) => DateTime(2020), + ); // not seeded -> refCount 0 + await tester.pumpWidget(_wrap(controller, const ImportPhotoLocatePrompt())); + expect(find.text('Locate Photos'), findsNothing); + }); + + testWidgets('error state shows the message + Try another folder', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final controller = _seededController(); + controller.state = controller.state.copyWith( + errorMessage: 'Could not scan that folder.', + ); + await tester.pumpWidget( + _wrap(controller, const ImportPhotoLocatePrompt()), + ); + expect(find.text('Could not scan that folder.'), findsOneWidget); + expect(find.text('Try another folder'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/test/features/media/data/services/local_media_linker_test.dart b/test/features/media/data/services/local_media_linker_test.dart new file mode 100644 index 000000000..0b7b313fb --- /dev/null +++ b/test/features/media/data/services/local_media_linker_test.dart @@ -0,0 +1,139 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/local_bookmark_storage.dart'; +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +import 'local_media_linker_test.mocks.dart'; + +@GenerateMocks([MediaRepository, LocalBookmarkStorage]) +void main() { + late MockMediaRepository repo; + late MockLocalBookmarkStorage bookmarkStorage; + late LocalMediaLinker linker; + + setUp(() { + repo = MockMediaRepository(); + bookmarkStorage = MockLocalBookmarkStorage(); + linker = LocalMediaLinker( + mediaRepository: repo, + bookmarkStorage: bookmarkStorage, + ); + when(repo.createMedia(any)).thenAnswer((inv) async { + final item = inv.positionalArguments.single as MediaItem; + return item.copyWith(id: item.id.isEmpty ? 'generated-id' : item.id); + }); + }); + + test( + 'desktop handle: persists localPath, no bookmark side effects', + () async { + final created = await linker.link( + diveId: 'dive-1', + handle: const MediaHandle.localPath('/picked/shark.jpg'), + basename: 'shark.jpg', + metadata: const MediaSourceMetadata( + mimeType: 'image/jpeg', + latitude: 1.5, + longitude: -2.5, + width: 800, + height: 600, + ), + caption: 'Reef shark', + fallbackTakenAt: DateTime(2020, 1, 1), + ); + + expect(created.id, 'generated-id'); + final captured = + verify(repo.createMedia(captureAny)).captured.single as MediaItem; + expect(captured.diveId, 'dive-1'); + expect(captured.sourceType, MediaSourceType.localFile); + expect(captured.mediaType, MediaType.photo); + expect(captured.localPath, '/picked/shark.jpg'); + expect(captured.filePath, '/picked/shark.jpg'); + expect(captured.bookmarkRef, isNull); + expect(captured.originalFilename, 'shark.jpg'); + expect(captured.caption, 'Reef shark'); + expect(captured.latitude, 1.5); + expect(captured.longitude, -2.5); + expect(captured.width, 800); + expect(captured.height, 600); + verifyNever(bookmarkStorage.write(any, any)); + }, + ); + + test( + 'android handle: persists bookmarkRef from content URI, null localPath', + () async { + await linker.link( + diveId: 'dive-2', + handle: const MediaHandle.contentUri('content://tree/doc/42'), + basename: 'turtle.jpg', + metadata: const MediaSourceMetadata(mimeType: 'image/jpeg'), + fallbackTakenAt: DateTime(2020), + ); + final captured = + verify(repo.createMedia(captureAny)).captured.single as MediaItem; + expect(captured.bookmarkRef, 'content://tree/doc/42'); + expect(captured.localPath, isNull); + expect(captured.sourceType, MediaSourceType.localFile); + }, + ); + + test('iOS handle: stores bookmark blob under a generated key', () async { + await linker.link( + diveId: 'dive-3', + handle: MediaHandle.bookmark(Uint8List.fromList([9, 9, 9])), + basename: 'eel.jpg', + metadata: const MediaSourceMetadata(mimeType: 'image/jpeg'), + fallbackTakenAt: DateTime(2020), + ); + final keyCap = verify(bookmarkStorage.write(captureAny, any)).captured; + expect(keyCap.single, isA()); + final captured = + verify(repo.createMedia(captureAny)).captured.single as MediaItem; + expect(captured.bookmarkRef, isNotEmpty); + expect(captured.localPath, isNull); + }); + + test('falls back to fallbackTakenAt when metadata.takenAt is null', () async { + await linker.link( + diveId: 'dive-4', + handle: const MediaHandle.localPath('/picked/a.jpg'), + basename: 'a.jpg', + metadata: const MediaSourceMetadata(mimeType: 'image/jpeg'), + fallbackTakenAt: DateTime(2019, 6, 1), + ); + final captured = + verify(repo.createMedia(captureAny)).captured.single as MediaItem; + expect(captured.takenAt, DateTime(2019, 6, 1)); + }); + + test('macOS handle: stores bookmark blob AND localPath', () async { + await linker.link( + diveId: 'dive-5', + handle: MediaHandle( + bookmarkBlob: Uint8List.fromList([1, 2, 3]), + localPath: '/Users/me/Pictures/coral.jpg', + ), + basename: 'coral.jpg', + metadata: const MediaSourceMetadata(mimeType: 'image/jpeg'), + fallbackTakenAt: DateTime(2020), + ); + final keyCap = verify(bookmarkStorage.write(captureAny, any)).captured; + expect(keyCap.single, isA()); + final captured = + verify(repo.createMedia(captureAny)).captured.single as MediaItem; + expect(captured.bookmarkRef, isNotEmpty); + expect(captured.localPath, '/Users/me/Pictures/coral.jpg'); + expect(captured.filePath, '/Users/me/Pictures/coral.jpg'); + }); +} diff --git a/test/features/media/data/services/local_media_linker_test.mocks.dart b/test/features/media/data/services/local_media_linker_test.mocks.dart new file mode 100644 index 000000000..db53351cc --- /dev/null +++ b/test/features/media/data/services/local_media_linker_test.mocks.dart @@ -0,0 +1,274 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in submersion/test/features/media/data/services/local_media_linker_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; +import 'dart:typed_data' as _i7; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:submersion/features/media/data/repositories/media_repository.dart' + as _i3; +import 'package:submersion/features/media/data/services/local_bookmark_storage.dart' + as _i6; +import 'package:submersion/features/media/domain/entities/media_item.dart' + as _i2; +import 'package:submersion/features/media/domain/entities/media_source_type.dart' + as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeMediaItem_0 extends _i1.SmartFake implements _i2.MediaItem { + _FakeMediaItem_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [MediaRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMediaRepository extends _i1.Mock implements _i3.MediaRepository { + MockMediaRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future> getMediaForDive(String? diveId) => + (super.noSuchMethod( + Invocation.method(#getMediaForDive, [diveId]), + returnValue: _i4.Future>.value( + <_i2.MediaItem>[], + ), + ) + as _i4.Future>); + + @override + _i4.Future<_i2.MediaItem?> getMediaById(String? id) => + (super.noSuchMethod( + Invocation.method(#getMediaById, [id]), + returnValue: _i4.Future<_i2.MediaItem?>.value(), + ) + as _i4.Future<_i2.MediaItem?>); + + @override + _i4.Future<_i2.MediaItem> createMedia(_i2.MediaItem? item) => + (super.noSuchMethod( + Invocation.method(#createMedia, [item]), + returnValue: _i4.Future<_i2.MediaItem>.value( + _FakeMediaItem_0(this, Invocation.method(#createMedia, [item])), + ), + ) + as _i4.Future<_i2.MediaItem>); + + @override + _i4.Future updateMedia(_i2.MediaItem? item) => + (super.noSuchMethod( + Invocation.method(#updateMedia, [item]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future deleteMedia(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteMedia, [id]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future deleteMultipleMedia(List? ids) => + (super.noSuchMethod( + Invocation.method(#deleteMultipleMedia, [ids]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future markAsOrphaned(String? id) => + (super.noSuchMethod( + Invocation.method(#markAsOrphaned, [id]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future markAsVerified(String? id) => + (super.noSuchMethod( + Invocation.method(#markAsVerified, [id]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future> getAllBySourceType( + _i5.MediaSourceType? sourceType, + ) => + (super.noSuchMethod( + Invocation.method(#getAllBySourceType, [sourceType]), + returnValue: _i4.Future>.value( + <_i2.MediaItem>[], + ), + ) + as _i4.Future>); + + @override + _i4.Future> getAllBySubscription( + String? subscriptionId, + ) => + (super.noSuchMethod( + Invocation.method(#getAllBySubscription, [subscriptionId]), + returnValue: _i4.Future>.value( + <_i2.MediaItem>[], + ), + ) + as _i4.Future>); + + @override + _i4.Future markOrphaned(String? id, bool? isOrphaned) => + (super.noSuchMethod( + Invocation.method(#markOrphaned, [id, isOrphaned]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future> getOrphanedMedia() => + (super.noSuchMethod( + Invocation.method(#getOrphanedMedia, []), + returnValue: _i4.Future>.value( + <_i2.MediaItem>[], + ), + ) + as _i4.Future>); + + @override + _i4.Future deleteOrphanedMedia() => + (super.noSuchMethod( + Invocation.method(#deleteOrphanedMedia, []), + returnValue: _i4.Future.value(0), + ) + as _i4.Future); + + @override + _i4.Future<_i2.MediaEnrichment?> getEnrichmentForMedia(String? mediaId) => + (super.noSuchMethod( + Invocation.method(#getEnrichmentForMedia, [mediaId]), + returnValue: _i4.Future<_i2.MediaEnrichment?>.value(), + ) + as _i4.Future<_i2.MediaEnrichment?>); + + @override + _i4.Future saveEnrichment(_i2.MediaEnrichment? enrichment) => + (super.noSuchMethod( + Invocation.method(#saveEnrichment, [enrichment]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future getMediaCountForDive(String? diveId) => + (super.noSuchMethod( + Invocation.method(#getMediaCountForDive, [diveId]), + returnValue: _i4.Future.value(0), + ) + as _i4.Future); + + @override + _i4.Future> getLinkedAssetIdsForDive(String? diveId) => + (super.noSuchMethod( + Invocation.method(#getLinkedAssetIdsForDive, [diveId]), + returnValue: _i4.Future>.value({}), + ) + as _i4.Future>); + + @override + _i4.Future> + getGpsFromDiveMedia(String? diveId) => + (super.noSuchMethod( + Invocation.method(#getGpsFromDiveMedia, [diveId]), + returnValue: + _i4.Future< + List<({double latitude, double longitude, DateTime takenAt})> + >.value( + <({double latitude, double longitude, DateTime takenAt})>[], + ), + ) + as _i4.Future< + List<({double latitude, double longitude, DateTime takenAt})> + >); + + @override + _i4.Future<({double latitude, double longitude})?> getBestGpsFromDiveMedia( + String? diveId, + ) => + (super.noSuchMethod( + Invocation.method(#getBestGpsFromDiveMedia, [diveId]), + returnValue: + _i4.Future<({double latitude, double longitude})?>.value(), + ) + as _i4.Future<({double latitude, double longitude})?>); + + @override + _i4.Future getPendingSuggestionCount(String? diveId) => + (super.noSuchMethod( + Invocation.method(#getPendingSuggestionCount, [diveId]), + returnValue: _i4.Future.value(0), + ) + as _i4.Future); +} + +/// A class which mocks [LocalBookmarkStorage]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLocalBookmarkStorage extends _i1.Mock + implements _i6.LocalBookmarkStorage { + MockLocalBookmarkStorage() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future write(String? bookmarkRef, _i7.Uint8List? blob) => + (super.noSuchMethod( + Invocation.method(#write, [bookmarkRef, blob]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future<_i7.Uint8List?> read(String? bookmarkRef) => + (super.noSuchMethod( + Invocation.method(#read, [bookmarkRef]), + returnValue: _i4.Future<_i7.Uint8List?>.value(), + ) + as _i4.Future<_i7.Uint8List?>); + + @override + _i4.Future delete(String? bookmarkRef) => + (super.noSuchMethod( + Invocation.method(#delete, [bookmarkRef]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/features/media/data/services/local_media_platform_enumerate_test.dart b/test/features/media/data/services/local_media_platform_enumerate_test.dart new file mode 100644 index 000000000..b8b9768fb --- /dev/null +++ b/test/features/media/data/services/local_media_platform_enumerate_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/services/local_media_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.submersion.app/local_media'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'enumerateScopedDirectory': + return >[ + { + 'basename': 'a.jpg', + 'bookmarkBlob': Uint8List.fromList([1, 2]), + }, + { + 'basename': 'b.png', + 'bookmarkBlob': Uint8List.fromList([3, 4]), + }, + ]; + case 'enumerateTree': + return >[ + {'basename': 'c.jpg', 'contentUri': 'content://tree/doc/c'}, + ]; + default: + return null; + } + }); + }); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test( + 'enumerateScopedDirectory returns basename+blob entries (iOS/macOS)', + () async { + final folderBookmark = Uint8List.fromList([1, 2, 3]); + if (!Platform.isIOS && !Platform.isMacOS) { + expect( + () => LocalMediaPlatform().enumerateScopedDirectory(folderBookmark), + throwsUnsupportedError, + ); + return; + } + final entries = await LocalMediaPlatform().enumerateScopedDirectory( + folderBookmark, + ); + expect(entries.length, 2); + expect(entries.first.basename, 'a.jpg'); + expect(entries.first.bookmarkBlob, [1, 2]); + }, + ); + + test('enumerateTree returns basename+contentUri entries (Android)', () async { + if (!Platform.isAndroid) { + expect( + () => LocalMediaPlatform().enumerateTree('content://tree/x'), + throwsUnsupportedError, + ); + return; + } + final entries = await LocalMediaPlatform().enumerateTree( + 'content://tree/x', + ); + expect(entries.single.basename, 'c.jpg'); + expect(entries.single.contentUri, 'content://tree/doc/c'); + }); +} diff --git a/test/features/universal_import/data/models/import_image_ref_test.dart b/test/features/universal_import/data/models/import_image_ref_test.dart new file mode 100644 index 000000000..1653a4d9c --- /dev/null +++ b/test/features/universal_import/data/models/import_image_ref_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; + +void main() { + group('ImportImageRef', () { + test('preserves fields verbatim', () { + const ref = ImportImageRef( + originalPath: '/Users/me/Pictures/shark.jpg', + diveSourceUuid: 'dive-1', + caption: 'Shark!', + position: 2, + sourceUuid: 'img-1', + ); + expect(ref.originalPath, '/Users/me/Pictures/shark.jpg'); + expect(ref.diveSourceUuid, 'dive-1'); + expect(ref.caption, 'Shark!'); + expect(ref.position, 2); + expect(ref.sourceUuid, 'img-1'); + }); + + test('optional fields default sensibly', () { + const ref = ImportImageRef( + originalPath: 'a.jpg', + diveSourceUuid: 'dive-1', + ); + expect(ref.caption, isNull); + expect(ref.position, 0); + expect(ref.sourceUuid, isNull); + }); + + test('value equality holds for identical fields and differs otherwise', () { + const a = ImportImageRef( + originalPath: 'a.jpg', + diveSourceUuid: 'd', + caption: 'c', + position: 1, + sourceUuid: 's', + ); + const b = ImportImageRef( + originalPath: 'a.jpg', + diveSourceUuid: 'd', + caption: 'c', + position: 1, + sourceUuid: 's', + ); + const different = ImportImageRef( + originalPath: 'a.jpg', + diveSourceUuid: 'OTHER', + ); + expect(a, b); + expect(a, isNot(different)); + }); + + group('filename', () { + test('extracts basename from POSIX path', () { + const ref = ImportImageRef( + originalPath: '/Users/me/Pictures/shark.jpg', + diveSourceUuid: 'd', + ); + expect(ref.filename, 'shark.jpg'); + }); + + test('extracts basename from Windows-style path', () { + const ref = ImportImageRef( + originalPath: r'C:\Users\me\Pictures\shark.jpg', + diveSourceUuid: 'd', + ); + expect(ref.filename, 'shark.jpg'); + }); + + test('returns path unchanged when no separator', () { + const ref = ImportImageRef( + originalPath: 'shark.jpg', + diveSourceUuid: 'd', + ); + expect(ref.filename, 'shark.jpg'); + }); + }); + }); +} diff --git a/test/features/universal_import/data/models/import_payload_test.dart b/test/features/universal_import/data/models/import_payload_test.dart new file mode 100644 index 000000000..ffe84fe0c --- /dev/null +++ b/test/features/universal_import/data/models/import_payload_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/models/import_payload.dart'; + +void main() { + test('defaults imageRefs to empty', () { + const payload = ImportPayload(entities: {}); + expect(payload.imageRefs, isEmpty); + }); + + test('carries imageRefs', () { + const payload = ImportPayload( + entities: {}, + imageRefs: [ + ImportImageRef(originalPath: '/photos/a.jpg', diveSourceUuid: 'dive-1'), + ], + ); + expect(payload.imageRefs.length, 1); + expect(payload.imageRefs.first.filename, 'a.jpg'); + }); + + test('imageRefs-only payload is not empty', () { + const payload = ImportPayload( + entities: {ImportEntityType.dives: []}, + imageRefs: [ + ImportImageRef(originalPath: '/photos/a.jpg', diveSourceUuid: 'd'), + ], + ); + expect(payload.isEmpty, isFalse); + expect(payload.isNotEmpty, isTrue); + }); + + test('truly empty payload is empty', () { + const payload = ImportPayload(entities: {ImportEntityType.dives: []}); + expect(payload.isEmpty, isTrue); + }); + + test('imageRefs participates in equality', () { + const a = ImportPayload( + entities: {}, + imageRefs: [ + ImportImageRef(originalPath: '/p/a.jpg', diveSourceUuid: 'd'), + ], + ); + const b = ImportPayload(entities: {}); + expect(a == b, isFalse); + }); +} diff --git a/test/features/universal_import/data/parsers/macdive_sqlite_photo_test.dart b/test/features/universal_import/data/parsers/macdive_sqlite_photo_test.dart new file mode 100644 index 000000000..a6bf36ca3 --- /dev/null +++ b/test/features/universal_import/data/parsers/macdive_sqlite_photo_test.dart @@ -0,0 +1,27 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_sqlite_parser.dart'; + +import '../../../../fixtures/macdive_sqlite/build_synthetic_db.dart'; + +void main() { + test('MacDiveSqliteParser emits imageRefs from ZDIVEIMAGE', () async { + final path = + '${Directory.systemTemp.path}/msp_${DateTime.now().microsecondsSinceEpoch}.sqlite'; + final dbFile = buildSyntheticMacDiveDb(path); + addTearDown(() { + if (dbFile.existsSync()) dbFile.deleteSync(); + }); + + final payload = await const MacDiveSqliteParser().parse( + Uint8List.fromList(await dbFile.readAsBytes()), + ); + + expect(payload.imageRefs.length, 3); + final shark = payload.imageRefs.firstWhere((r) => r.caption == 'Shark!'); + expect(shark.originalPath, '/Users/test/Pictures/Diving/shark.jpg'); + expect(shark.diveSourceUuid, isNotEmpty); + }); +} diff --git a/test/features/universal_import/data/parsers/macdive_sqlite_photos_real_test.dart b/test/features/universal_import/data/parsers/macdive_sqlite_photos_real_test.dart new file mode 100644 index 000000000..a537ed0ef --- /dev/null +++ b/test/features/universal_import/data/parsers/macdive_sqlite_photos_real_test.dart @@ -0,0 +1,25 @@ +@Tags(['real-data']) +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_sqlite_parser.dart'; + +// Real MacDive export (not committed). Skips cleanly when absent on CI. +const _path = + '/Users/ericgriffin/Documents/submersion development/submersion data/Macdive/MacDive.sqlite'; + +void main() { + test('real MacDive SQLite emits 261 imageRefs', () async { + final file = File(_path); + if (!file.existsSync()) { + markTestSkipped('No real MacDive sample at $_path'); + return; + } + final bytes = Uint8List.fromList(await file.readAsBytes()); + final payload = await const MacDiveSqliteParser().parse(bytes); + expect(payload.imageRefs.length, 261); + }); +} diff --git a/test/features/universal_import/data/parsers/macdive_xml_photo_test.dart b/test/features/universal_import/data/parsers/macdive_xml_photo_test.dart new file mode 100644 index 000000000..30e144240 --- /dev/null +++ b/test/features/universal_import/data/parsers/macdive_xml_photo_test.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart'; + +void main() { + test('MacDiveXmlParser emits imageRefs from ', () async { + final content = await File( + 'test/fixtures/macdive_xml/metric_small.xml', + ).readAsString(); + final bytes = Uint8List.fromList(utf8.encode(content)); + + final payload = await const MacDiveXmlParser().parse(bytes); + + expect(payload.imageRefs.length, 2); + final first = payload.imageRefs.first; + expect(first.caption, 'Shark'); + expect(first.originalPath, '/Users/test/Pictures/a.jpg'); + expect(first.diveSourceUuid, isNotEmpty); + expect( + payload.imageRefs.every((r) => r.diveSourceUuid == first.diveSourceUuid), + isTrue, + ); + }); +} diff --git a/test/features/universal_import/data/services/desktop_directory_scanner_test.dart b/test/features/universal_import/data/services/desktop_directory_scanner_test.dart new file mode 100644 index 000000000..c9cd7760f --- /dev/null +++ b/test/features/universal_import/data/services/desktop_directory_scanner_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/desktop_directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; + +void main() { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('desktop_scanner_'); + }); + tearDown(() { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + }); + + test( + 'enumerates files recursively with basename + desktop-path handle', + () async { + File('${tmp.path}/a.jpg').writeAsBytesSync([1]); + final sub = Directory('${tmp.path}/nested')..createSync(); + File('${sub.path}/b.png').writeAsBytesSync([2]); + + final scanner = DesktopDirectoryScanner(); + final files = await scanner.scan(GrantedFolder(path: tmp.path)).toList(); + + expect(files.length, 2); + final byName = {for (final f in files) f.basename: f}; + expect(byName.keys.toSet(), {'a.jpg', 'b.png'}); + expect(byName['a.jpg']!.handle.localPath, '${tmp.path}/a.jpg'); + expect(byName['a.jpg']!.handle.bookmarkRef, isNull); + expect(byName['b.png']!.handle.localPath, '${sub.path}/b.png'); + }, + ); + + test('yields nothing for a missing folder (no throw)', () async { + final scanner = DesktopDirectoryScanner(); + final files = await scanner + .scan(const GrantedFolder(path: '/does/not/exist/xyz')) + .toList(); + expect(files, isEmpty); + }); + + test('skips directories, yields only files', () async { + Directory('${tmp.path}/onlydir').createSync(); + File('${tmp.path}/c.jpg').writeAsBytesSync([3]); + final scanner = DesktopDirectoryScanner(); + final files = await scanner.scan(GrantedFolder(path: tmp.path)).toList(); + expect(files.map((f) => f.basename), ['c.jpg']); + }); +} diff --git a/test/features/universal_import/data/services/macdive_db_reader_photo_test.dart b/test/features/universal_import/data/services/macdive_db_reader_photo_test.dart new file mode 100644 index 000000000..6dea5678f --- /dev/null +++ b/test/features/universal_import/data/services/macdive_db_reader_photo_test.dart @@ -0,0 +1,35 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_db_reader.dart'; + +import '../../../../fixtures/macdive_sqlite/build_synthetic_db.dart'; + +void main() { + test('reads ZDIVEIMAGE rows into MacDiveRawLogbook.diveImages', () async { + final path = + '${Directory.systemTemp.path}/mdi_${DateTime.now().microsecondsSinceEpoch}.sqlite'; + final dbFile = buildSyntheticMacDiveDb(path); + addTearDown(() { + if (dbFile.existsSync()) dbFile.deleteSync(); + }); + + final logbook = await MacDiveDbReader.readAll( + Uint8List.fromList(await dbFile.readAsBytes()), + ); + + expect(logbook.diveImages.length, 3); + final first = logbook.diveImages.firstWhere((i) => i.pk == 1); + expect(first.caption, 'Shark!'); + expect(first.path, '/Users/test/Pictures/Diving/shark.jpg'); + expect(first.originalPath, '/old/Pictures/shark.jpg'); + expect(first.uuid, 'img-uuid-1'); + expect(first.diveFk, 1); + expect(first.position, 0); + + final second = logbook.diveImages.firstWhere((i) => i.pk == 2); + expect(second.caption, isNull); + expect(second.originalPath, isNull); + }); +} diff --git a/test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart b/test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart new file mode 100644 index 000000000..289c6486e --- /dev/null +++ b/test/features/universal_import/data/services/macdive_dive_mapper_photo_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_dive_mapper.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_raw_types.dart'; + +MacDiveRawLogbook _logbook({ + required List dives, + required List images, +}) { + return MacDiveRawLogbook( + dives: dives, + sitesByPk: const {}, + buddiesByPk: const {}, + tagsByPk: const {}, + gearByPk: const {}, + tanksByPk: const {}, + gasesByPk: const {}, + tankAndGases: const [], + crittersByPk: const {}, + certifications: const [], + serviceRecords: const [], + events: const [], + diveImages: images, + diveToBuddyPks: const {}, + diveToTagPks: const {}, + diveToGearPks: const {}, + diveToCritterPks: const {}, + unitsPreference: 'Metric', + ); +} + +void main() { + test('maps ZDIVEIMAGE rows to imageRefs keyed by dive UUID', () { + final payload = MacDiveDiveMapper.toPayload( + _logbook( + dives: const [ + MacDiveRawDive(pk: 1, uuid: 'dive-uuid-1'), + MacDiveRawDive(pk: 2, uuid: 'dive-uuid-2'), + ], + images: const [ + MacDiveRawDiveImage( + pk: 1, + uuid: 'img-1', + diveFk: 1, + position: 0, + caption: 'Shark!', + path: '/Users/test/Pictures/Diving/shark.jpg', + originalPath: '/old/shark.jpg', + ), + MacDiveRawDiveImage( + pk: 2, + uuid: 'img-2', + diveFk: 2, + position: 1, + path: '/Users/test/Pictures/Diving/turtle.jpg', + ), + ], + ), + ); + + expect(payload.imageRefs.length, 2); + final shark = payload.imageRefs.firstWhere((r) => r.caption == 'Shark!'); + expect(shark.diveSourceUuid, 'dive-uuid-1'); + expect(shark.originalPath, '/Users/test/Pictures/Diving/shark.jpg'); + expect(shark.position, 0); + expect(shark.sourceUuid, 'img-1'); + }); + + test('drops photos whose dive FK has no UUID (orphan rows)', () { + final payload = MacDiveDiveMapper.toPayload( + _logbook( + dives: const [MacDiveRawDive(pk: 1, uuid: 'dive-uuid-1')], + images: const [ + MacDiveRawDiveImage( + pk: 9, + uuid: 'img-9', + diveFk: 999, + path: '/x.jpg', + ), + ], + ), + ); + expect(payload.imageRefs, isEmpty); + }); + + test('falls back to originalPath when path is null; drops if both null', () { + final payload = MacDiveDiveMapper.toPayload( + _logbook( + dives: const [MacDiveRawDive(pk: 1, uuid: 'dive-uuid-1')], + images: const [ + MacDiveRawDiveImage( + pk: 1, + uuid: 'img-1', + diveFk: 1, + originalPath: '/orig/only.jpg', + ), + MacDiveRawDiveImage(pk: 2, uuid: 'img-2', diveFk: 1), + ], + ), + ); + expect(payload.imageRefs.length, 1); + expect(payload.imageRefs.single.originalPath, '/orig/only.jpg'); + }); +} diff --git a/test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart b/test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart new file mode 100644 index 000000000..c5ff14a94 --- /dev/null +++ b/test/features/universal_import/data/services/macdive_xml_reader_photo_test.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_xml_reader.dart'; + +void main() { + test( + 'reads with path + caption and assigns positions', + () async { + final content = await File( + 'test/fixtures/macdive_xml/metric_small.xml', + ).readAsString(); + final dive = MacDiveXmlReader.parse(content).dives.first; + + expect(dive.photos.length, 2); + expect(dive.photos[0].path, '/Users/test/Pictures/a.jpg'); + expect(dive.photos[0].caption, 'Shark'); + expect(dive.photos[0].position, 0); + expect(dive.photos[1].path, '/Users/test/Pictures/b.jpg'); + expect(dive.photos[1].caption, isNull); + expect(dive.photos[1].position, 1); + }, + ); +} diff --git a/test/features/universal_import/data/services/photo_link_desktop_e2e_test.dart b/test/features/universal_import/data/services/photo_link_desktop_e2e_test.dart new file mode 100644 index 000000000..15de38732 --- /dev/null +++ b/test/features/universal_import/data/services/photo_link_desktop_e2e_test.dart @@ -0,0 +1,124 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/local_bookmark_storage.dart'; +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_sqlite_parser.dart'; +import 'package:submersion/features/universal_import/data/services/desktop_directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/photo_resolver.dart'; + +import '../../../../fixtures/macdive_sqlite/build_synthetic_db.dart'; +import '../../../../helpers/test_database.dart'; + +/// Insert a bare dive row with the minimum required (non-nullable, +/// no-default) columns: id, diveDateTime, createdAt, updatedAt. +/// All other Dives columns are either nullable or carry a Drift default. +Future _insertBareDive(AppDatabase db, String diveId) async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.dives) + .insert( + DivesCompanion.insert( + id: diveId, + diveDateTime: DateTime(2024, 6, 1).millisecondsSinceEpoch, + createdAt: now, + updatedAt: now, + ), + ); +} + +void main() { + late AppDatabase db; + late Directory photoDir; + late MediaRepository mediaRepository; + + setUp(() async { + // setUpTestDatabase() registers the in-memory DB in DatabaseService.instance + // so that MediaRepository() (which calls DatabaseService.instance.database) + // resolves to the same test DB. + db = await setUpTestDatabase(); + photoDir = Directory.systemTemp.createTempSync('photo_e2e_'); + mediaRepository = MediaRepository(); + }); + + tearDown(() async { + if (photoDir.existsSync()) photoDir.deleteSync(recursive: true); + await tearDownTestDatabase(); + }); + + test('desktop end-to-end: parse -> resolve -> link MediaItems', () async { + final dbPath = + '${Directory.systemTemp.path}/e2e_${DateTime.now().microsecondsSinceEpoch}.sqlite'; + final dbFile = buildSyntheticMacDiveDb(dbPath); + addTearDown(() { + if (dbFile.existsSync()) dbFile.deleteSync(); + }); + + final payload = await const MacDiveSqliteParser().parse( + Uint8List.fromList(await dbFile.readAsBytes()), + ); + expect(payload.imageRefs, isNotEmpty); + + // Place a stub file for every referenced photo basename in the temp dir. + for (final ref in payload.imageRefs) { + File('${photoDir.path}/${ref.filename}').writeAsBytesSync([1, 2, 3]); + } + + // Build a source-UUID -> dive-id map and insert the dives into the test DB. + final sourceUuidToDiveId = {}; + for (final ref in payload.imageRefs) { + sourceUuidToDiveId.putIfAbsent(ref.diveSourceUuid, () { + final id = 'dive-${sourceUuidToDiveId.length + 1}'; + return id; + }); + } + for (final id in sourceUuidToDiveId.values.toSet()) { + await _insertBareDive(db, id); + } + + // Resolve all image refs against the temp photo directory. + final resolver = PhotoResolver( + scanner: DesktopDirectoryScanner(), + folder: GrantedFolder(path: photoDir.path), + ); + final resolved = await resolver.resolveAll(payload.imageRefs); + expect( + resolved.where((r) => r.scannedFile != null).length, + payload.imageRefs.length, + ); + + // Link each resolved photo to its dive via LocalMediaLinker. + // Desktop handles carry localPath only — LocalBookmarkStorage.write + // is never called, so the flutter_secure_storage keychain is untouched. + final linker = LocalMediaLinker( + mediaRepository: mediaRepository, + bookmarkStorage: LocalBookmarkStorage(), + ); + var linked = 0; + for (final r in resolved) { + if (r.scannedFile == null) continue; + final diveId = sourceUuidToDiveId[r.ref.diveSourceUuid]!; + await linker.link( + diveId: diveId, + handle: r.scannedFile!.handle, + basename: r.scannedFile!.basename, + metadata: const MediaSourceMetadata(mimeType: 'image/jpeg'), + fallbackTakenAt: DateTime(2024, 6, 1), + caption: r.ref.caption, + ); + linked++; + } + expect(linked, payload.imageRefs.length); + + // Verify MediaItem rows were written for the first dive in the map. + final firstDiveId = sourceUuidToDiveId.values.first; + final media = await mediaRepository.getMediaForDive(firstDiveId); + expect(media, isNotEmpty); + expect(media.first.filePath, startsWith(photoDir.path)); + }); +} diff --git a/test/features/universal_import/data/services/photo_resolver_test.dart b/test/features/universal_import/data/services/photo_resolver_test.dart new file mode 100644 index 000000000..d58034682 --- /dev/null +++ b/test/features/universal_import/data/services/photo_resolver_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/services/photo_resolver.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; + +/// Fake scanner that yields a fixed list of (basename -> desktop path) and +/// counts how many times scan() is invoked. +class _FakeScanner implements DirectoryScanner { + _FakeScanner(this._files); + final Map _files; // basename -> absolute path + int scanCount = 0; + + @override + Stream scan(GrantedFolder folder) async* { + scanCount++; + for (final entry in _files.entries) { + yield ScannedFile( + basename: entry.key, + handle: MediaHandle.localPath(entry.value), + ); + } + } +} + +/// Fake scanner yielding an explicit list of files (allows two files to +/// share a basename, to exercise candidate disambiguation). +class _ListScanner implements DirectoryScanner { + _ListScanner(this._files); + final List _files; + + @override + Stream scan(GrantedFolder folder) async* { + for (final f in _files) { + yield f; + } + } +} + +void main() { + group('PhotoResolver via DirectoryScanner', () { + test('rebase: recorded tail grafted onto picked root', () async { + final scanner = _FakeScanner({ + 'shark.jpg': '/picked/Photos/Diving/shark.jpg', + }); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const [ + ImportImageRef( + originalPath: '/Users/other/Photos/Diving/shark.jpg', + diveSourceUuid: 'd', + ), + ]); + expect(results.single.kind, PhotoResolutionKind.rebased); + expect( + results.single.scannedFile!.handle.localPath, + '/picked/Photos/Diving/shark.jpg', + ); + }); + + test('filename-index match when path tail does not graft', () async { + final scanner = _FakeScanner({'b.jpg': '/picked/deep/nested/b.jpg'}); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const [ + ImportImageRef( + originalPath: '/other/machine/x/b.jpg', + diveSourceUuid: 'd', + ), + ]); + expect(results.single.kind, PhotoResolutionKind.filenameMatch); + expect( + results.single.scannedFile!.handle.localPath, + '/picked/deep/nested/b.jpg', + ); + }); + + test('miss when basename absent from scan', () async { + final scanner = _FakeScanner({'other.jpg': '/picked/other.jpg'}); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const [ + ImportImageRef(originalPath: '/x/missing.jpg', diveSourceUuid: 'd'), + ]); + expect(results.single.kind, PhotoResolutionKind.miss); + expect(results.single.scannedFile, isNull); + expect(results.single.ref.originalPath, '/x/missing.jpg'); + }); + + test('scans exactly once for many refs', () async { + final scanner = _FakeScanner({ + for (var i = 0; i < 10; i++) 'p$i.jpg': '/picked/p$i.jpg', + }); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final refs = List.generate( + 10, + (i) => ImportImageRef(originalPath: '/x/p$i.jpg', diveSourceUuid: 'd'), + ); + final results = await resolver.resolveAll(refs); + expect( + results.every((r) => r.kind == PhotoResolutionKind.filenameMatch), + isTrue, + ); + expect(scanner.scanCount, 1); + }); + + test('non-image extensions are skipped and counted', () async { + final scanner = _FakeScanner({'movie.mov': '/picked/movie.mov'}); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const [ + ImportImageRef(originalPath: '/x/movie.mov', diveSourceUuid: 'd'), + ]); + expect(results.single.kind, PhotoResolutionKind.skippedNonImage); + expect(results.single.scannedFile, isNull); + }); + + test('empty input returns empty output without scanning', () async { + final scanner = _FakeScanner({'a.jpg': '/picked/a.jpg'}); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const []); + expect(results, isEmpty); + expect(scanner.scanCount, 0); + }); + + test( + 'rebase prefers the candidate with the longest shared path tail', + () async { + final scanner = _ListScanner(const [ + ScannedFile( + basename: 'shark.jpg', + handle: MediaHandle.localPath('/picked/x/shark.jpg'), + ), + ScannedFile( + basename: 'shark.jpg', + handle: MediaHandle.localPath('/picked/Diving/shark.jpg'), + ), + ]); + final resolver = PhotoResolver( + scanner: scanner, + folder: const GrantedFolder(path: '/picked'), + ); + final results = await resolver.resolveAll(const [ + ImportImageRef( + originalPath: '/old/Diving/shark.jpg', + diveSourceUuid: 'd', + ), + ]); + expect(results.single.kind, PhotoResolutionKind.rebased); + expect( + results.single.scannedFile!.handle.localPath, + '/picked/Diving/shark.jpg', + ); + }, + ); + }); +} diff --git a/test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart b/test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart new file mode 100644 index 000000000..e662fbf5a --- /dev/null +++ b/test/features/universal_import/presentation/providers/import_photo_link_controller_test.dart @@ -0,0 +1,246 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/media/data/services/local_media_linker.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/universal_import/data/models/import_image_ref.dart'; +import 'package:submersion/features/universal_import/data/services/directory_scanner.dart'; +import 'package:submersion/features/universal_import/data/value_objects/scanned_file.dart'; +import 'package:submersion/features/universal_import/presentation/providers/import_photo_link_controller.dart'; + +class _FakeScanner implements DirectoryScanner { + _FakeScanner(this.files); + final Map files; // basename -> path + @override + Stream scan(GrantedFolder folder) async* { + for (final e in files.entries) { + yield ScannedFile( + basename: e.key, + handle: MediaHandle.localPath(e.value), + ); + } + } +} + +/// Scanner whose enumeration throws, to exercise the run-level error path. +class _ThrowingScanner implements DirectoryScanner { + @override + Stream scan(GrantedFolder folder) async* { + throw StateError('scan boom'); + } +} + +/// Records linked (diveId, basename) pairs; can be told to throw for a +/// specific basename to exercise per-photo isolation. +class _FakeLinker implements LocalMediaLinker { + final List<({String diveId, String basename})> linked = []; + String? throwForBasename; + + @override + Future link({ + required String diveId, + required MediaHandle handle, + required String basename, + required MediaSourceMetadata metadata, + required DateTime fallbackTakenAt, + String? caption, + }) async { + if (basename == throwForBasename) { + throw StateError('boom'); + } + linked.add((diveId: diveId, basename: basename)); + return MediaItem( + id: 'm-${linked.length}', + diveId: diveId, + mediaType: MediaType.photo, + takenAt: DateTime(2024), + createdAt: DateTime(2024), + updatedAt: DateTime(2024), + ); + } + + // Unused by the controller in tests but required by the interface. + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +ImportPhotoLinkController _controller({ + required DirectoryScanner scanner, + required LocalMediaLinker linker, + required List imageRefs, + required Map sourceUuidToDiveId, + Future> Function(String diveId)? alreadyLinkedBasenames, +}) { + return ImportPhotoLinkController( + scannerFor: (_) => scanner, + linker: linker, + metadataFor: (_) async => const MediaSourceMetadata(mimeType: 'image/jpeg'), + alreadyLinkedBasenames: + alreadyLinkedBasenames ?? (_) async => const {}, + fallbackTakenAtFor: (_) => DateTime(2020), + )..seed(imageRefs: imageRefs, sourceUuidToDiveId: sourceUuidToDiveId); +} + +void main() { + test( + 'links resolved photos to the mapped dive and summarises counts', + () async { + final linker = _FakeLinker(); + final controller = _controller( + scanner: _FakeScanner({ + 'shark.jpg': '/picked/shark.jpg', + 'turtle.jpg': '/picked/turtle.jpg', + }), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/shark.jpg', diveSourceUuid: 'src-1'), + ImportImageRef( + originalPath: '/x/turtle.jpg', + diveSourceUuid: 'src-1', + ), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + ); + + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + + final s = controller.state.summary!; + expect(s.total, 2); + expect(s.linked, 2); + expect(s.notFound, 0); + expect(s.skippedNonImage, 0); + expect(linker.linked, [ + (diveId: 'dive-1', basename: 'shark.jpg'), + (diveId: 'dive-1', basename: 'turtle.jpg'), + ]); + }, + ); + + test('photo with no dive mapping is counted not-found, not linked', () async { + final linker = _FakeLinker(); + final controller = _controller( + scanner: _FakeScanner({'a.jpg': '/picked/a.jpg'}), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/a.jpg', diveSourceUuid: 'unknown'), + ], + sourceUuidToDiveId: const {}, + ); + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(controller.state.summary!.linked, 0); + expect(controller.state.summary!.notFound, 1); + expect(linker.linked, isEmpty); + }); + + test('dedupe by (diveId + basename): skips already-linked', () async { + final linker = _FakeLinker(); + final controller = _controller( + scanner: _FakeScanner({'shark.jpg': '/picked/shark.jpg'}), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/shark.jpg', diveSourceUuid: 'src-1'), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + alreadyLinkedBasenames: (diveId) async => + diveId == 'dive-1' ? {'shark.jpg'} : const {}, + ); + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(linker.linked, isEmpty); + // Already-linked photos count as linked in the summary (they ARE linked). + expect(controller.state.summary!.linked, 1); + }); + + test('re-pick is idempotent: second run links nothing new', () async { + final linker = _FakeLinker(); + final alreadyLinked = >{'dive-1': {}}; + final controller = + ImportPhotoLinkController( + scannerFor: (_) => _FakeScanner({'shark.jpg': '/picked/shark.jpg'}), + linker: linker, + metadataFor: (_) async => + const MediaSourceMetadata(mimeType: 'image/jpeg'), + alreadyLinkedBasenames: (diveId) async => + alreadyLinked[diveId] ?? const {}, + fallbackTakenAtFor: (_) => DateTime(2020), + )..seed( + imageRefs: const [ + ImportImageRef( + originalPath: '/x/shark.jpg', + diveSourceUuid: 'src-1', + ), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + ); + + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(linker.linked.length, 1); + // Simulate persistence: the linked basename is now present. + alreadyLinked['dive-1'] = {'shark.jpg'}; + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(linker.linked.length, 1); // no new links + }); + + test('per-photo failure is isolated and counted as not-found', () async { + final linker = _FakeLinker()..throwForBasename = 'bad.jpg'; + final controller = _controller( + scanner: _FakeScanner({ + 'good.jpg': '/picked/good.jpg', + 'bad.jpg': '/picked/bad.jpg', + }), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/good.jpg', diveSourceUuid: 'src-1'), + ImportImageRef(originalPath: '/x/bad.jpg', diveSourceUuid: 'src-1'), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + ); + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(linker.linked.map((l) => l.basename), ['good.jpg']); + expect(controller.state.summary!.linked, 1); + expect(controller.state.summary!.notFound, 1); + }); + + test( + 'run-level scan failure sets errorMessage and resets progress', + () async { + final controller = + ImportPhotoLinkController( + scannerFor: (_) => _ThrowingScanner(), + linker: _FakeLinker(), + metadataFor: (_) async => + const MediaSourceMetadata(mimeType: 'image/jpeg'), + alreadyLinkedBasenames: (_) async => const {}, + fallbackTakenAtFor: (_) => DateTime(2020), + )..seed( + imageRefs: const [ + ImportImageRef(originalPath: '/x/a.jpg', diveSourceUuid: 'src-1'), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + ); + + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + + expect(controller.state.isRunning, isFalse); + expect(controller.state.errorMessage, isNotNull); + expect(controller.state.summary, isNull); + expect(controller.state.processed, 0); + expect(controller.state.total, 0); + }, + ); + + test('non-image refs are skipped and counted', () async { + final linker = _FakeLinker(); + final controller = _controller( + scanner: _FakeScanner({'clip.mov': '/picked/clip.mov'}), + linker: linker, + imageRefs: const [ + ImportImageRef(originalPath: '/x/clip.mov', diveSourceUuid: 'src-1'), + ], + sourceUuidToDiveId: const {'src-1': 'dive-1'}, + ); + await controller.pickedFolder(const GrantedFolder(path: '/picked')); + expect(controller.state.summary!.skippedNonImage, 1); + expect(controller.state.summary!.linked, 0); + expect(linker.linked, isEmpty); + }); +} diff --git a/test/fixtures/macdive_sqlite/build_synthetic_db.dart b/test/fixtures/macdive_sqlite/build_synthetic_db.dart index d8da488c8..448b0ef72 100644 --- a/test/fixtures/macdive_sqlite/build_synthetic_db.dart +++ b/test/fixtures/macdive_sqlite/build_synthetic_db.dart @@ -172,6 +172,13 @@ void _createSchema(Database db) { ZIDENTIFIER VARCHAR, ZALL VARCHAR ) '''); + db.execute(''' + CREATE TABLE ZDIVEIMAGE ( + Z_PK INTEGER PRIMARY KEY, Z_ENT INTEGER, Z_OPT INTEGER, + ZPOSITION INTEGER, ZRELATIONSHIPDIVE INTEGER, + ZCAPTION VARCHAR, ZORIGINALPATH VARCHAR, ZPATH VARCHAR, ZUUID VARCHAR + ) + '''); } void _insertFixtureRows(Database db) { @@ -282,4 +289,18 @@ void _insertFixtureRows(Database db) { INSERT INTO ZMETADATA (Z_PK, ZIDENTIFIER, ZALL) VALUES (1, 'SystemOfUnits', 'Metric') '''); + + // ---- dive images ---- Dive 1 gets 2 photos; dive 2 gets 1; dive 3 none. + db.execute(''' + INSERT INTO ZDIVEIMAGE ( + Z_PK, ZPOSITION, ZRELATIONSHIPDIVE, + ZCAPTION, ZPATH, ZORIGINALPATH, ZUUID + ) VALUES + (1, 0, 1, 'Shark!', '/Users/test/Pictures/Diving/shark.jpg', + '/old/Pictures/shark.jpg', 'img-uuid-1'), + (2, 1, 1, NULL, '/Users/test/Pictures/Diving/turtle.jpg', + NULL, 'img-uuid-2'), + (3, 0, 2, 'Reef', '/Users/test/Pictures/Diving/reef.jpg', + NULL, 'img-uuid-3') + '''); } diff --git a/test/fixtures/macdive_xml/metric_small.xml b/test/fixtures/macdive_xml/metric_small.xml index a1658dc0a..4b6bb7cea 100644 --- a/test/fixtures/macdive_xml/metric_small.xml +++ b/test/fixtures/macdive_xml/metric_small.xml @@ -56,5 +56,9 @@ 1019524 56023 + + /Users/test/Pictures/a.jpgShark + /Users/test/Pictures/b.jpg +