Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Directory> Function()? volumeRoots})
: _volumeRootsOverride = volumeRoots;

final List<Directory> 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<List<GarminDevice>> detect() async {
final roots = _volumeRootsOverride != null
? _volumeRootsOverride()
: _defaultVolumeRoots();

final devices = <GarminDevice>[];
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<List<String>> listFitFiles(String activityDirPath) async {
final paths = <String>[];
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<int> _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<Directory> _defaultVolumeRoots() {
if (Platform.isMacOS) {
return _childDirs('/Volumes');
}
if (Platform.isLinux) {
final user =
Platform.environment['USER'] ?? Platform.environment['LOGNAME'];
final roots = <Directory>[];
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<Directory> _childDirs(String path) {
try {
final dir = Directory(path);
if (!dir.existsSync()) return const [];
return dir.listSync(followLinks: false).whereType<Directory>().toList();
} catch (_) {
return const [];
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -49,8 +51,12 @@ class UniversalImportNotifier extends StateNotifier<UniversalImportState> {
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;
Expand All @@ -63,6 +69,14 @@ class UniversalImportNotifier extends StateNotifier<UniversalImportState> {
/// 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<PresetRegistry> _buildPresetRegistry() async {
Expand Down Expand Up @@ -437,6 +451,73 @@ class UniversalImportNotifier extends StateNotifier<UniversalImportState> {
}
}

/// 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<void> 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<void> _loadDiveFitsFromFolder(String activityDirPath) async {
final fitPaths = await _garminDetector.listFitFiles(activityDirPath);
final divePaths = <String>[];
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/arb/app_ar.arb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"universalImport_action_importFromGarmin": "استيراد من جهاز Garmin",
"diveSites_list_menu_select": "تحديد المواقع",
"diveLog_edit_geofenceSuggestion_near": "بالقرب من {location}",
"diveLog_edit_geofenceSuggestion_title": "اقتراح المعدات",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/arb/app_de.arb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"universalImport_action_importFromGarmin": "Von Garmin-Gerät importieren",
"diveSites_list_menu_select": "Tauchplätze auswählen",
"diveLog_edit_geofenceSuggestion_near": "In der Nähe von {location}",
"diveLog_edit_geofenceSuggestion_title": "Ausrüstungsvorschlag",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -10575,6 +10575,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}}",
Expand Down Expand Up @@ -10675,6 +10676,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"
},
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/arb/app_es.arb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"universalImport_action_importFromGarmin": "Importar desde dispositivo Garmin",
"diveSites_list_menu_select": "Seleccionar puntos",
"diveLog_edit_geofenceSuggestion_near": "Cerca de {location}",
"diveLog_edit_geofenceSuggestion_title": "Sugerencia de equipo",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/arb/app_fr.arb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"universalImport_action_importFromGarmin": "Importer depuis l'appareil Garmin",
"diveSites_list_menu_select": "Sélectionner des sites",
"diveLog_edit_geofenceSuggestion_near": "Près de {location}",
"diveLog_edit_geofenceSuggestion_title": "Suggestion d'équipement",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/arb/app_he.arb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"universalImport_action_importFromGarmin": "ייבוא מהתקן Garmin",
"diveSites_list_menu_select": "בחירת אתרים",
"diveLog_edit_geofenceSuggestion_near": "ליד {location}",
"diveLog_edit_geofenceSuggestion_title": "הצעת ציוד",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/arb/app_hu.arb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"universalImport_action_importFromGarmin": "Importálás Garmin eszközről",
"diveSites_list_menu_select": "Merülőhelyek kiválasztása",
"diveLog_edit_geofenceSuggestion_near": "{location} közelében",
"diveLog_edit_geofenceSuggestion_title": "Felszerelési javaslat",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/arb/app_it.arb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"universalImport_action_importFromGarmin": "Importa da dispositivo Garmin",
"diveSites_list_menu_select": "Seleziona siti",
"diveLog_edit_geofenceSuggestion_near": "Vicino a {location}",
"diveLog_edit_geofenceSuggestion_title": "Suggerimento attrezzatura",
Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/arb/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30334,6 +30334,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:
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_localizations_ar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17734,6 +17734,10 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get universalImport_action_chooseFolder => 'اختيار مجلد';

@override
String get universalImport_action_importFromGarmin =>
'استيراد من جهاز Garmin';

@override
String get universalImport_triage_title => 'الملفات المراد استيرادها';

Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_localizations_de.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18028,6 +18028,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get universalImport_action_chooseFolder => 'Ordner auswählen';

@override
String get universalImport_action_importFromGarmin =>
'Von Garmin-Gerät importieren';

@override
String get universalImport_triage_title => 'Zu importierende Dateien';

Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17754,6 +17754,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';

Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_localizations_es.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18075,6 +18075,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get universalImport_action_chooseFolder => 'Elegir carpeta';

@override
String get universalImport_action_importFromGarmin =>
'Importar desde dispositivo Garmin';

@override
String get universalImport_triage_title => 'Archivos a importar';

Expand Down
Loading