From ced94d435624cbb7013e66008a6870a9b65aa379 Mon Sep 17 00:00:00 2001 From: etlami Date: Sat, 1 Aug 2026 21:32:18 +0200 Subject: [PATCH 1/2] feat(import): import dives directly from a connected Garmin device (USB) A Garmin Descent isn't a libdivecomputer serial/BLE device -- plugged in by cable it mounts as a USB drive whose activities are FIT files under GARMIN/Activity. Until now users had to find that folder and pick the files by hand. Add a desktop-only "Import from Garmin Device" button to the import wizard's file step. It detects the mounted volume (by looking for a non-empty GARMIN/Activity folder, regardless of volume name), keeps only dive FITs -- non-dive activities (runs/rides) and corrupt files parse to null and are skipped -- and feeds them into the existing single/batch triage -> duplicate-check -> import pipeline. No new credentials, network, or backend: this is pure filesystem access, reusing FitParserService for the dive filter. Falls back to the existing "Choose Folder" button when no device is detected. Tests: - garmin_device_detector_test: volume detection (name-agnostic, empty folders ignored, multi-volume) and .fit listing. - universal_import_garmin_test: single/batch import from a real dive FIT fixture, corrupt-file skipping, and the no-device / no-dives errors. --- .../data/services/garmin_device_detector.dart | 146 ++++++++++++++++++ .../providers/universal_import_providers.dart | 81 ++++++++++ .../widgets/file_selection_step.dart | 15 ++ lib/l10n/arb/app_en.arb | 4 + lib/l10n/arb/app_localizations.dart | 6 + lib/l10n/arb/app_localizations_ar.dart | 4 + lib/l10n/arb/app_localizations_de.dart | 4 + lib/l10n/arb/app_localizations_en.dart | 4 + lib/l10n/arb/app_localizations_es.dart | 4 + lib/l10n/arb/app_localizations_fr.dart | 4 + lib/l10n/arb/app_localizations_he.dart | 4 + lib/l10n/arb/app_localizations_hu.dart | 4 + lib/l10n/arb/app_localizations_it.dart | 4 + lib/l10n/arb/app_localizations_nl.dart | 4 + lib/l10n/arb/app_localizations_pt.dart | 4 + lib/l10n/arb/app_localizations_zh.dart | 4 + .../services/garmin_device_detector_test.dart | 119 ++++++++++++++ .../universal_import_garmin_test.dart | 120 ++++++++++++++ 18 files changed, 535 insertions(+) create mode 100644 lib/features/universal_import/data/services/garmin_device_detector.dart create mode 100644 test/features/universal_import/data/services/garmin_device_detector_test.dart create mode 100644 test/features/universal_import/presentation/providers/universal_import_garmin_test.dart diff --git a/lib/features/universal_import/data/services/garmin_device_detector.dart b/lib/features/universal_import/data/services/garmin_device_detector.dart new file mode 100644 index 0000000000..97250eb9df --- /dev/null +++ b/lib/features/universal_import/data/services/garmin_device_detector.dart @@ -0,0 +1,146 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:path/path.dart' as p; + +/// A Garmin dive computer mounted as a USB mass-storage volume. +/// +/// Garmin watches (Descent, etc.) do not use a libdivecomputer download +/// protocol -- when connected by cable they appear as a plain drive whose +/// activities live as FIT files under `GARMIN/Activity`. +class GarminDevice { + const GarminDevice({ + required this.volumeName, + required this.volumeRootPath, + required this.activityDirPath, + required this.fitFileCount, + }); + + /// Display name of the mounted volume (e.g. "GARMIN"). + final String volumeName; + + /// Absolute path of the mounted volume root (e.g. `/Volumes/GARMIN`). + final String volumeRootPath; + + /// Absolute path of the `GARMIN/Activity` directory holding the FIT files. + final String activityDirPath; + + /// Number of `.fit` files found directly under [activityDirPath]. + final int fitFileCount; +} + +/// Detects Garmin dive computers attached as USB mass-storage volumes. +/// +/// This is desktop-only: mobile platforms cannot mount a watch as a drive. +/// Detection is purely filesystem-based (no vendor API, credentials, or +/// network) -- it looks for the `GARMIN/Activity` folder that every Garmin +/// device exposes, regardless of the volume's name. +class GarminDeviceDetector { + /// [volumeRoots] overrides the platform mount-point scan; used by tests to + /// point detection at a temporary directory tree. + const GarminDeviceDetector({List Function()? volumeRoots}) + : _volumeRootsOverride = volumeRoots; + + final List Function()? _volumeRootsOverride; + + /// Whether the current platform can mount a Garmin device as a drive. + static bool get isSupportedPlatform { + if (kIsWeb) return false; + final platform = defaultTargetPlatform; + return platform == TargetPlatform.macOS || + platform == TargetPlatform.windows || + platform == TargetPlatform.linux; + } + + /// Scan mounted volumes and return every one that looks like a Garmin + /// device (i.e. contains a non-empty `GARMIN/Activity` folder). + Future> detect() async { + final roots = _volumeRootsOverride != null + ? _volumeRootsOverride() + : _defaultVolumeRoots(); + + final devices = []; + for (final root in roots) { + try { + final activityDir = Directory(p.join(root.path, 'GARMIN', 'Activity')); + if (!await activityDir.exists()) continue; + final fitCount = await _countFitFiles(activityDir); + if (fitCount == 0) continue; + devices.add( + GarminDevice( + volumeName: p.basename(root.path).isEmpty + ? root.path + : p.basename(root.path), + volumeRootPath: root.path, + activityDirPath: activityDir.path, + fitFileCount: fitCount, + ), + ); + } catch (_) { + // A volume can be unmounted mid-scan or be unreadable; skip it. + } + } + return devices; + } + + /// All `.fit` file paths directly under [activityDirPath], sorted for a + /// stable order. Callers filter dives from non-dive activities. + Future> listFitFiles(String activityDirPath) async { + final paths = []; + final dir = Directory(activityDirPath); + if (!await dir.exists()) return paths; + await for (final entity in dir.list(followLinks: false)) { + if (entity is File && _isFit(entity.path)) paths.add(entity.path); + } + paths.sort(); + return paths; + } + + Future _countFitFiles(Directory dir) async { + var count = 0; + await for (final entity in dir.list(followLinks: false)) { + if (entity is File && _isFit(entity.path)) count++; + } + return count; + } + + static bool _isFit(String path) => p.extension(path).toLowerCase() == '.fit'; + + /// Candidate mounted-volume roots per platform. + List _defaultVolumeRoots() { + if (Platform.isMacOS) { + return _childDirs('/Volumes'); + } + if (Platform.isLinux) { + final user = + Platform.environment['USER'] ?? Platform.environment['LOGNAME']; + final roots = []; + if (user != null && user.isNotEmpty) { + roots + ..addAll(_childDirs('/media/$user')) + ..addAll(_childDirs('/run/media/$user')); + } + roots.addAll(_childDirs('/media')); + return roots; + } + if (Platform.isWindows) { + // Removable drives get a letter; skip C: (the system volume). + return [ + for (var c = 'D'.codeUnitAt(0); c <= 'Z'.codeUnitAt(0); c++) + Directory('${String.fromCharCode(c)}:\\'), + ]; + } + return const []; + } + + static List _childDirs(String path) { + try { + final dir = Directory(path); + if (!dir.existsSync()) return const []; + return dir.listSync(followLinks: false).whereType().toList(); + } catch (_) { + return const []; + } + } +} diff --git a/lib/features/universal_import/presentation/providers/universal_import_providers.dart b/lib/features/universal_import/presentation/providers/universal_import_providers.dart index 1704779e4a..5b937c2469 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -29,7 +29,9 @@ import 'package:submersion/features/universal_import/data/parsers/import_parser. import 'package:submersion/features/universal_import/data/services/format_detector.dart'; import 'package:submersion/features/universal_import/data/models/picked_import_file.dart'; import 'package:submersion/features/universal_import/data/parsers/parser_registry.dart'; +import 'package:submersion/features/dive_import/data/services/fit_parser_service.dart'; import 'package:submersion/features/universal_import/data/services/batch_parse_service.dart'; +import 'package:submersion/features/universal_import/data/services/garmin_device_detector.dart'; import 'package:submersion/features/universal_import/data/services/macdive_db_reader.dart'; import 'package:submersion/features/universal_import/data/services/payload_merger.dart'; import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; @@ -49,8 +51,12 @@ class UniversalImportNotifier extends StateNotifier { this._ref, { BatchParseService batchParseService = const BatchParseService(), ZipExpansionService zipExpansionService = const ZipExpansionService(), + GarminDeviceDetector garminDeviceDetector = const GarminDeviceDetector(), + FitParserService fitParserService = const FitParserService(), }) : _batchParseService = batchParseService, _zipExpansion = zipExpansionService, + _garminDetector = garminDeviceDetector, + _fitParser = fitParserService, super(const UniversalImportState()); final Ref _ref; @@ -63,6 +69,14 @@ class UniversalImportNotifier extends StateNotifier { /// intake so members flow through normal detection and batching. final ZipExpansionService _zipExpansion; + /// Detects a Garmin dive computer mounted as a USB drive. Injectable so + /// tests can point it at a temporary directory tree. + final GarminDeviceDetector _garminDetector; + + /// Filters dive FITs from non-dive activities when importing from a Garmin + /// device (it returns null for runs/rides/corrupt files). + final FitParserService _fitParser; + /// Build a [PresetRegistry] that includes both built-in and user-saved /// presets so auto-detection scores against all of them. Future _buildPresetRegistry() async { @@ -437,6 +451,73 @@ class UniversalImportNotifier extends StateNotifier { } } + /// Desktop only: detect a Garmin dive computer connected as a USB drive + /// and load its dive activities into the wizard. + /// + /// Garmin watches expose activities as FIT files under `GARMIN/Activity`, + /// a folder that mixes dives with runs/rides; non-dive FITs are filtered + /// out before triage. When several Garmin volumes are mounted the first is + /// used. + Future importFromGarminDevice() async { + state = state.copyWith( + isLoading: true, + clearError: true, + currentStep: ImportWizardStep.fileSelection, + ); + + try { + final devices = await _garminDetector.detect(); + if (devices.isEmpty) { + state = state.copyWith( + isLoading: false, + error: + 'No connected Garmin device found. Connect it by cable, ' + "or use Choose Folder to select the device's GARMIN/Activity " + 'folder.', + ); + return; + } + await _loadDiveFitsFromFolder(devices.first.activityDirPath); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: 'Failed to read Garmin device: $e', + ); + } + } + + /// Scan [activityDirPath] for FIT files, keep only dive activities, and + /// enter the wizard's single/batch flow. Corrupt or non-dive FITs (which + /// parse to null) are skipped so triage lists only dives. + Future _loadDiveFitsFromFolder(String activityDirPath) async { + final fitPaths = await _garminDetector.listFitFiles(activityDirPath); + final divePaths = []; + for (final path in fitPaths) { + try { + final bytes = await File(path).readAsBytes(); + final dive = await _fitParser.parseFitFile(bytes); + if (dive != null) divePaths.add(path); + } catch (_) { + // Unreadable/corrupt FIT: skip it and keep scanning the rest. + } + } + + if (divePaths.isEmpty) { + state = state.copyWith( + isLoading: false, + error: 'No dives found on the connected Garmin device.', + ); + return; + } + + if (divePaths.length == 1) { + await _loadSingleFromFilePath(divePaths.first); + } else { + await _loadBatchFromPaths(divePaths); + } + state = state.copyWith(wasLoadedExternally: true); + } + // -- Step 1: Source Confirmation -- /// Store a pending source-app and format override chosen by the user. diff --git a/lib/features/universal_import/presentation/widgets/file_selection_step.dart b/lib/features/universal_import/presentation/widgets/file_selection_step.dart index 5022f83eea..8f3f5cc530 100644 --- a/lib/features/universal_import/presentation/widgets/file_selection_step.dart +++ b/lib/features/universal_import/presentation/widgets/file_selection_step.dart @@ -66,6 +66,21 @@ class FileSelectionStep extends ConsumerWidget { .pickFolder(), ), ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: const Icon(Icons.watch), + label: Text( + context.l10n.universalImport_action_importFromGarmin, + ), + onPressed: state.isLoading + ? null + : () => ref + .read(universalImportNotifierProvider.notifier) + .importFromGarminDevice(), + ), + ), ], if (state.error != null) ...[ const SizedBox(height: 16), diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 97db756c13..0e229b7567 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -10498,6 +10498,7 @@ "universalImport_action_selectFile": "Select File", "universalImport_action_selectFiles": "Select Files", "universalImport_action_chooseFolder": "Choose Folder", + "universalImport_action_importFromGarmin": "Import from Garmin Device", "universalImport_triage_title": "Files to Import", "universalImport_triage_readyCount": "{count, plural, =1{1 file ready to import} other{{count} files ready to import}}", "universalImport_label_filesSelected": "{count, plural, =1{1 file selected} other{{count} files selected}}", @@ -10595,6 +10596,9 @@ "@universalImport_action_chooseFolder": { "description": "Desktop-only button that picks a folder and scans it for importable dive files" }, + "@universalImport_action_importFromGarmin": { + "description": "Desktop-only button that detects a Garmin dive computer mounted as a USB drive and imports its dive activities" + }, "@universalImport_triage_title": { "description": "Header for the batch file triage list" }, diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 1c073b7728..3c5d761dd9 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -30130,6 +30130,12 @@ abstract class AppLocalizations { /// **'Choose Folder'** String get universalImport_action_chooseFolder; + /// Desktop-only button that detects a Garmin dive computer mounted as a USB drive and imports its dive activities + /// + /// In en, this message translates to: + /// **'Import from Garmin Device'** + String get universalImport_action_importFromGarmin; + /// Header for the batch file triage list /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 2a1f3f822d..f7dd623aa1 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -17572,6 +17572,10 @@ class AppLocalizationsAr extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'اختيار مجلد'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'الملفات المراد استيرادها'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index afa9415148..a7b88324b0 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -17866,6 +17866,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'Ordner auswählen'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'Zu importierende Dateien'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 47189a5e5f..c705a40ab1 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -17593,6 +17593,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'Choose Folder'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'Files to Import'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 64e65d0d90..4e6a227238 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -17913,6 +17913,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'Elegir carpeta'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'Archivos a importar'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index c69641ff99..1b59c0acd4 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -17969,6 +17969,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'Choisir un dossier'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'Fichiers à importer'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 55e3c2f4df..028d8dd08a 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -17446,6 +17446,10 @@ class AppLocalizationsHe extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'בחירת תיקייה'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'קבצים לייבוא'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index c2b1f2c4c3..096a6735c5 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -17848,6 +17848,10 @@ class AppLocalizationsHu extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'Mappa kiválasztása'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'Importálandó fájlok'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 3e000591c3..0a9853ba79 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -17899,6 +17899,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'Scegli cartella'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'File da importare'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index a1e2fda188..bf29a3ab2b 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -17749,6 +17749,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'Map kiezen'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'Te importeren bestanden'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 8a24277317..7874cefbb2 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -17906,6 +17906,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get universalImport_action_chooseFolder => 'Escolher pasta'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => 'Arquivos a importar'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 83e7b2d17c..54a2dece6b 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -17004,6 +17004,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get universalImport_action_chooseFolder => '选择文件夹'; + @override + String get universalImport_action_importFromGarmin => + 'Import from Garmin Device'; + @override String get universalImport_triage_title => '要导入的文件'; diff --git a/test/features/universal_import/data/services/garmin_device_detector_test.dart b/test/features/universal_import/data/services/garmin_device_detector_test.dart new file mode 100644 index 0000000000..b2c9f5d37d --- /dev/null +++ b/test/features/universal_import/data/services/garmin_device_detector_test.dart @@ -0,0 +1,119 @@ +// Verifies Garmin USB detection against a temporary directory tree that +// mimics one or more mounted volumes: a device is recognised by a non-empty +// GARMIN/Activity folder, regardless of the volume's name. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:submersion/features/universal_import/data/services/garmin_device_detector.dart'; + +void main() { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('garmin_detect_test'); + }); + + tearDown(() async { + await tmp.delete(recursive: true); + }); + + /// Create `//GARMIN/Activity` and drop [fitNames] into it (plus + /// any [otherNames] non-FIT files). Returns the volume root path. + Future makeVolume( + String volume, { + List fitNames = const [], + List otherNames = const [], + }) async { + final activity = Directory(p.join(tmp.path, volume, 'GARMIN', 'Activity')); + await activity.create(recursive: true); + for (final name in fitNames) { + await File(p.join(activity.path, name)).writeAsBytes([0, 1, 2, 3]); + } + for (final name in otherNames) { + await File(p.join(activity.path, name)).writeAsString('x'); + } + return p.join(tmp.path, volume); + } + + GarminDeviceDetector detectorFor(List volumeRoots) { + return GarminDeviceDetector( + volumeRoots: () => [for (final r in volumeRoots) Directory(r)], + ); + } + + test('detects a volume with a non-empty GARMIN/Activity folder', () async { + final vol = await makeVolume( + 'GARMIN', + fitNames: ['1.fit', '2.fit'], + otherNames: ['notes.txt'], + ); + + final devices = await detectorFor([vol]).detect(); + + expect(devices, hasLength(1)); + expect(devices.single.volumeName, 'GARMIN'); + expect(devices.single.fitFileCount, 2); + expect(devices.single.activityDirPath, p.join(vol, 'GARMIN', 'Activity')); + }); + + test('recognises the device regardless of the volume name', () async { + final vol = await makeVolume('MY_WATCH', fitNames: ['a.fit']); + + final devices = await detectorFor([vol]).detect(); + + expect(devices, hasLength(1)); + expect(devices.single.volumeName, 'MY_WATCH'); + }); + + test('ignores a volume without a GARMIN/Activity folder', () async { + final plainDir = Directory(p.join(tmp.path, 'USB_STICK')); + await plainDir.create(recursive: true); + await File(p.join(plainDir.path, 'photo.jpg')).writeAsString('x'); + + final devices = await detectorFor([plainDir.path]).detect(); + + expect(devices, isEmpty); + }); + + test('ignores a GARMIN/Activity folder with no FIT files', () async { + final vol = await makeVolume('EMPTY', otherNames: ['README']); + + final devices = await detectorFor([vol]).detect(); + + expect(devices, isEmpty); + }); + + test('returns every matching volume when several are mounted', () async { + final a = await makeVolume('A', fitNames: ['1.fit']); + final b = await makeVolume('B', fitNames: ['1.fit', '2.fit']); + final plain = Directory(p.join(tmp.path, 'PLAIN')) + ..createSync(recursive: true); + + final devices = await detectorFor([a, b, plain.path]).detect(); + + expect(devices.map((d) => d.volumeName), containsAll(['A', 'B'])); + expect(devices, hasLength(2)); + }); + + test('listFitFiles returns only .fit paths, sorted', () async { + final vol = await makeVolume( + 'GARMIN', + fitNames: ['b.fit', 'a.fit'], + otherNames: ['c.gpx'], + ); + final activity = p.join(vol, 'GARMIN', 'Activity'); + + final files = await detectorFor([vol]).listFitFiles(activity); + + expect(files.map(p.basename), ['a.fit', 'b.fit']); + }); + + test('listFitFiles is empty for a missing directory', () async { + final files = await const GarminDeviceDetector().listFitFiles( + p.join(tmp.path, 'does', 'not', 'exist'), + ); + expect(files, isEmpty); + }); +} diff --git a/test/features/universal_import/presentation/providers/universal_import_garmin_test.dart b/test/features/universal_import/presentation/providers/universal_import_garmin_test.dart new file mode 100644 index 0000000000..f26a49a375 --- /dev/null +++ b/test/features/universal_import/presentation/providers/universal_import_garmin_test.dart @@ -0,0 +1,120 @@ +// Drives the notifier's Garmin-USB import path: detect the mounted device, +// keep only dive FITs (skipping non-dive/corrupt files), and hand them to the +// wizard's single/batch flow. Uses a real Garmin dive FIT fixture and a +// temporary directory tree standing in for the mounted volume. + +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/universal_import/data/services/garmin_device_detector.dart'; +import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; + +import '../../../../helpers/test_database.dart'; + +const _diveFixture = 'test/dives/005_oc-trimix-two-deco-gases.fit'; + +void main() { + late ProviderContainer container; + late Directory tmp; + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + tmp = await Directory.systemTemp.createTemp('garmin_import_test'); + }); + + tearDown(() async { + container.dispose(); + await tearDownTestDatabase(); + await tmp.delete(recursive: true); + }); + + /// Build a notifier whose detector points at [volumeRoots]. + Future notifierFor(List volumeRoots) async { + final prefs = await SharedPreferences.getInstance(); + final detector = GarminDeviceDetector( + volumeRoots: () => [for (final r in volumeRoots) Directory(r)], + ); + container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + universalImportNotifierProvider.overrideWith( + (ref) => UniversalImportNotifier(ref, garminDeviceDetector: detector), + ), + ], + ); + return container.read(universalImportNotifierProvider.notifier); + } + + /// Create `//GARMIN/Activity` and populate it. Copies the dive + /// fixture [diveCopies] times and writes [corrupt] junk .fit files. + Future makeGarminVolume( + String volume, { + int diveCopies = 0, + int corrupt = 0, + }) async { + final activity = Directory(p.join(tmp.path, volume, 'GARMIN', 'Activity')); + await activity.create(recursive: true); + final diveBytes = await File(_diveFixture).readAsBytes(); + for (var i = 0; i < diveCopies; i++) { + await File(p.join(activity.path, 'dive_$i.fit')).writeAsBytes(diveBytes); + } + for (var i = 0; i < corrupt; i++) { + await File( + p.join(activity.path, 'run_$i.fit'), + ).writeAsBytes([0, 1, 2, 3, 4]); + } + return p.join(tmp.path, volume); + } + + test('imports a single dive, skipping a corrupt FIT', () async { + final vol = await makeGarminVolume('DESCENT', diveCopies: 1, corrupt: 1); + final notifier = await notifierFor([vol]); + + await notifier.importFromGarminDevice(); + + expect(notifier.state.error, isNull); + expect(notifier.state.files, hasLength(1)); + expect(notifier.state.isBatch, isFalse); + expect(notifier.state.currentStep, ImportWizardStep.sourceConfirmation); + expect(notifier.state.fileName, 'dive_0.fit'); + expect(notifier.state.wasLoadedExternally, isTrue); + expect(notifier.state.isLoading, isFalse); + }); + + test('enters batch triage when several dives are present', () async { + final vol = await makeGarminVolume('DESCENT', diveCopies: 2, corrupt: 1); + final notifier = await notifierFor([vol]); + + await notifier.importFromGarminDevice(); + + expect(notifier.state.error, isNull); + expect(notifier.state.isBatch, isTrue); + expect(notifier.state.files, hasLength(2)); + expect(notifier.state.currentStep, ImportWizardStep.sourceConfirmation); + }); + + test('reports an error when no Garmin device is connected', () async { + final notifier = await notifierFor(const []); + + await notifier.importFromGarminDevice(); + + expect(notifier.state.files, isEmpty); + expect(notifier.state.isLoading, isFalse); + expect(notifier.state.error, contains('No connected Garmin device')); + }); + + test('reports an error when the device holds no dives', () async { + final vol = await makeGarminVolume('DESCENT', corrupt: 2); + final notifier = await notifierFor([vol]); + + await notifier.importFromGarminDevice(); + + expect(notifier.state.files, isEmpty); + expect(notifier.state.error, contains('No dives found')); + }); +} From 16b420b11bc1076384f54bd25dff32d3c9dbaf6d Mon Sep 17 00:00:00 2001 From: etlami Date: Sat, 1 Aug 2026 21:46:38 +0200 Subject: [PATCH 2/2] i18n: translate the "Import from Garmin Device" label into all locales The new import-button key was only in the English template; add real translations so the arb_parity guard passes. --- lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations_ar.dart | 2 +- lib/l10n/arb/app_localizations_de.dart | 2 +- lib/l10n/arb/app_localizations_es.dart | 2 +- lib/l10n/arb/app_localizations_fr.dart | 2 +- lib/l10n/arb/app_localizations_he.dart | 3 +-- lib/l10n/arb/app_localizations_hu.dart | 2 +- lib/l10n/arb/app_localizations_it.dart | 2 +- lib/l10n/arb/app_localizations_nl.dart | 2 +- lib/l10n/arb/app_localizations_pt.dart | 2 +- lib/l10n/arb/app_localizations_zh.dart | 3 +-- lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + 20 files changed, 20 insertions(+), 12 deletions(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index f92daa9070..109ae2215b 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "استيراد من جهاز Garmin", "diveLog_edit_geofenceSuggestion_near": "بالقرب من {location}", "diveLog_edit_geofenceSuggestion_title": "اقتراح المعدات", "diveLog_edit_geofenceSuggestion_body": "تطبيق مجموعة \"{setName}\"؟", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 87771fc1f1..c63fbfb0e4 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "Von Garmin-Gerät importieren", "diveLog_edit_geofenceSuggestion_near": "In der Nähe von {location}", "diveLog_edit_geofenceSuggestion_title": "Ausrüstungsvorschlag", "diveLog_edit_geofenceSuggestion_body": "Set \"{setName}\" übernehmen?", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index d690536036..45e2603570 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "Importar desde dispositivo Garmin", "diveLog_edit_geofenceSuggestion_near": "Cerca de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugerencia de equipo", "diveLog_edit_geofenceSuggestion_body": "¿Aplicar tu conjunto \"{setName}\"?", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index f75a470c1f..d3436a951a 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "Importer depuis l'appareil Garmin", "diveLog_edit_geofenceSuggestion_near": "Près de {location}", "diveLog_edit_geofenceSuggestion_title": "Suggestion d'équipement", "diveLog_edit_geofenceSuggestion_body": "Appliquer l'ensemble \"{setName}\" ?", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 1619b1f24e..f068cb392d 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "ייבוא מהתקן Garmin", "diveLog_edit_geofenceSuggestion_near": "ליד {location}", "diveLog_edit_geofenceSuggestion_title": "הצעת ציוד", "diveLog_edit_geofenceSuggestion_body": "להחיל את ערכת \"{setName}\"?", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index bc87b5329f..b4813e6e65 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "Importálás Garmin eszközről", "diveLog_edit_geofenceSuggestion_near": "{location} közelében", "diveLog_edit_geofenceSuggestion_title": "Felszerelési javaslat", "diveLog_edit_geofenceSuggestion_body": "Alkalmazza a(z) \"{setName}\" készletet?", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 1cf74306fe..007b3cdefc 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "Importa da dispositivo Garmin", "diveLog_edit_geofenceSuggestion_near": "Vicino a {location}", "diveLog_edit_geofenceSuggestion_title": "Suggerimento attrezzatura", "diveLog_edit_geofenceSuggestion_body": "Applicare il set \"{setName}\"?", diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index f7dd623aa1..83a92332a1 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -17574,7 +17574,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + 'استيراد من جهاز Garmin'; @override String get universalImport_triage_title => 'الملفات المراد استيرادها'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index a7b88324b0..e10f763a02 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -17868,7 +17868,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + 'Von Garmin-Gerät importieren'; @override String get universalImport_triage_title => 'Zu importierende Dateien'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 4e6a227238..af003ed08e 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -17915,7 +17915,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + 'Importar desde dispositivo Garmin'; @override String get universalImport_triage_title => 'Archivos a importar'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 1b59c0acd4..b62eebb021 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -17971,7 +17971,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + 'Importer depuis l\'appareil Garmin'; @override String get universalImport_triage_title => 'Fichiers à importer'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 028d8dd08a..ad330afd94 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -17447,8 +17447,7 @@ class AppLocalizationsHe extends AppLocalizations { String get universalImport_action_chooseFolder => 'בחירת תיקייה'; @override - String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + String get universalImport_action_importFromGarmin => 'ייבוא מהתקן Garmin'; @override String get universalImport_triage_title => 'קבצים לייבוא'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 096a6735c5..a1fc8e3cb7 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -17850,7 +17850,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + 'Importálás Garmin eszközről'; @override String get universalImport_triage_title => 'Importálandó fájlok'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 0a9853ba79..0ae882d152 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -17901,7 +17901,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + 'Importa da dispositivo Garmin'; @override String get universalImport_triage_title => 'File da importare'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index bf29a3ab2b..914d9a1bf5 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -17751,7 +17751,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + 'Importeren vanaf Garmin-apparaat'; @override String get universalImport_triage_title => 'Te importeren bestanden'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 7874cefbb2..4e88db630d 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -17908,7 +17908,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + 'Importar do dispositivo Garmin'; @override String get universalImport_triage_title => 'Arquivos a importar'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 54a2dece6b..56b0c608c8 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -17005,8 +17005,7 @@ class AppLocalizationsZh extends AppLocalizations { String get universalImport_action_chooseFolder => '选择文件夹'; @override - String get universalImport_action_importFromGarmin => - 'Import from Garmin Device'; + String get universalImport_action_importFromGarmin => '从 Garmin 设备导入'; @override String get universalImport_triage_title => '要导入的文件'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index cc4072015f..848e513172 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "Importeren vanaf Garmin-apparaat", "diveLog_edit_geofenceSuggestion_near": "Bij {location}", "diveLog_edit_geofenceSuggestion_title": "Uitrustingssuggestie", "diveLog_edit_geofenceSuggestion_body": "Set \"{setName}\" toepassen?", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index dfbe87ffe2..a759bd654d 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "Importar do dispositivo Garmin", "diveLog_edit_geofenceSuggestion_near": "Perto de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugestão de equipamento", "diveLog_edit_geofenceSuggestion_body": "Aplicar o conjunto \"{setName}\"?", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 087672b057..b3ee23a21e 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -1,4 +1,5 @@ { + "universalImport_action_importFromGarmin": "从 Garmin 设备导入", "diveLog_edit_geofenceSuggestion_near": "靠近 {location}", "diveLog_edit_geofenceSuggestion_title": "装备建议", "diveLog_edit_geofenceSuggestion_body": "应用\"{setName}\"套装?",