From 6b72b4f59fb466cd676dfe19def6a6e0fd21fd52 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 15:26:51 +0000 Subject: [PATCH 1/3] feat: import full Apple Health export.zip with metrics and routes Sideloaded installs cannot use HealthKit directly. This adds a local import path for the complete Apple Health export archive, not just individual GPX files. - Parse export.xml Records (HRV, sleep, steps, calories, vitals, etc.) - Parse ActivitySummary daily ring data - Import running workouts with linked workout-routes GPX files - Persist daily summaries in Drift (native) and SharedPreferences (web) - Merge imported metrics with workout rollups in ImportedHealthRepository - Update Settings and dashboard CTAs for export.zip import Co-authored-by: Youri Bontekoe --- .../import_apple_health_export_usecase.dart | 85 ++++ .../presentation/pages/run_history_page.dart | 2 +- .../widgets/connect_healthkit_card.dart | 2 +- .../pages/health_import_page.dart | 156 +++++-- .../presentation/pages/settings_page.dart | 6 +- .../health/drift_imported_health_store.dart | 44 ++ .../health_infrastructure_providers.dart | 9 + .../import/apple_health_date_parser.dart | 15 + .../import/apple_health_export_parser.dart | 292 +++++++++++++ .../apple_health_record_aggregator.dart | 245 +++++++++++ .../import/apple_health_unit_converter.dart | 62 +++ .../health/imported_health_database.dart | 21 +- .../health/imported_health_database.g.dart | 388 ++++++++++++++++++ .../health/imported_health_repository.dart | 5 +- .../health/imported_health_store.dart | 38 ++ .../health/imported_summary_merger.dart | 54 +++ .../health/prefs_imported_health_store.dart | 52 +++ pubspec.lock | 2 +- pubspec.yaml | 1 + ...port_apple_health_export_usecase_test.dart | 66 +++ .../settings/health_import_page_test.dart | 4 +- test/fixtures/apple_health_export.xml | 18 + .../apple_health_export_parser_test.dart | 66 +++ .../apple_health_record_aggregator_test.dart | 48 +++ 24 files changed, 1641 insertions(+), 40 deletions(-) create mode 100644 lib/domain/usecases/health/import_apple_health_export_usecase.dart create mode 100644 lib/infrastructure/health/import/apple_health_date_parser.dart create mode 100644 lib/infrastructure/health/import/apple_health_export_parser.dart create mode 100644 lib/infrastructure/health/import/apple_health_record_aggregator.dart create mode 100644 lib/infrastructure/health/import/apple_health_unit_converter.dart create mode 100644 lib/infrastructure/health/imported_summary_merger.dart create mode 100644 test/domain/usecases/health/import_apple_health_export_usecase_test.dart create mode 100644 test/fixtures/apple_health_export.xml create mode 100644 test/infrastructure/health/import/apple_health_export_parser_test.dart create mode 100644 test/infrastructure/health/import/apple_health_record_aggregator_test.dart diff --git a/lib/domain/usecases/health/import_apple_health_export_usecase.dart b/lib/domain/usecases/health/import_apple_health_export_usecase.dart new file mode 100644 index 0000000..6d333fc --- /dev/null +++ b/lib/domain/usecases/health/import_apple_health_export_usecase.dart @@ -0,0 +1,85 @@ +import 'package:kynos/core/errors/failures.dart'; +import 'package:kynos/domain/usecases/health/import_workout_usecase.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_export_parser.dart'; +import 'package:kynos/infrastructure/health/imported_health_store.dart'; + +/// Result of importing an Apple Health `export.zip` archive. +class ImportAppleHealthExportResult { + const ImportAppleHealthExportResult({ + required this.importedWorkouts, + required this.skippedWorkouts, + required this.importedDays, + required this.recordCount, + this.failure, + }); + + final int importedWorkouts; + final int skippedWorkouts; + final int importedDays; + final int recordCount; + final Failure? failure; +} + +/// Parses and persists an Apple Health export archive. +class ImportAppleHealthExportUseCase { + const ImportAppleHealthExportUseCase({ + required ImportedHealthStore store, + required ImportWorkoutUseCase importWorkout, + AppleHealthExportParser? parser, + }) : _store = store, + _importWorkout = importWorkout, + _parser = parser ?? const AppleHealthExportParser(); + + final ImportedHealthStore _store; + final ImportWorkoutUseCase _importWorkout; + final AppleHealthExportParser _parser; + + Future call({ + required List zipBytes, + DateTime? now, + }) async { + try { + final parsed = _parser.parseZip(zipBytes); + await _store.saveSummaries(parsed.summaries); + + var importedWorkouts = 0; + var skippedWorkouts = parsed.skippedWorkouts; + + for (final item in parsed.workouts) { + final result = await _importWorkout( + workout: item.workout, + routePoints: item.routePoints, + now: now, + ); + if (result.failure != null) { + skippedWorkouts += 1; + } else { + importedWorkouts += 1; + } + } + + return ImportAppleHealthExportResult( + importedWorkouts: importedWorkouts, + skippedWorkouts: skippedWorkouts, + importedDays: parsed.summaries.length, + recordCount: parsed.recordCount, + ); + } on FormatException catch (e) { + return ImportAppleHealthExportResult( + importedWorkouts: 0, + skippedWorkouts: 0, + importedDays: 0, + recordCount: 0, + failure: HealthDataFailure(e.message), + ); + } on Object catch (e) { + return ImportAppleHealthExportResult( + importedWorkouts: 0, + skippedWorkouts: 0, + importedDays: 0, + recordCount: 0, + failure: StorageFailure(e.toString()), + ); + } + } +} diff --git a/lib/features/dashboard/presentation/pages/run_history_page.dart b/lib/features/dashboard/presentation/pages/run_history_page.dart index 663ac8a..fce27a4 100644 --- a/lib/features/dashboard/presentation/pages/run_history_page.dart +++ b/lib/features/dashboard/presentation/pages/run_history_page.dart @@ -64,7 +64,7 @@ class RunHistoryPage extends ConsumerWidget { ), const Gap(tokens.Spacing.xs), Text( - 'Import a GPX file or log a run manually in Settings.', + 'Import Apple Health export.zip or log a run manually in Settings.', style: GoogleFonts.inter( fontSize: 13, color: AppTheme.tertiaryLabel, diff --git a/lib/features/dashboard/presentation/widgets/connect_healthkit_card.dart b/lib/features/dashboard/presentation/widgets/connect_healthkit_card.dart index 99b3164..a4019d7 100644 --- a/lib/features/dashboard/presentation/widgets/connect_healthkit_card.dart +++ b/lib/features/dashboard/presentation/widgets/connect_healthkit_card.dart @@ -48,7 +48,7 @@ class ConnectHealthkitCard extends ConsumerWidget { const Gap(Spacing.xs), Text( 'Grant access via $platform to unlock your readiness score and AI coaching insights. ' - 'Sideloaded installs can import GPX files or log runs manually instead.', + 'Sideloaded installs can import Apple Health export.zip or log runs manually.', style: Theme.of(context).textTheme.bodyMedium, ), const Gap(Spacing.md), diff --git a/lib/features/settings/presentation/pages/health_import_page.dart b/lib/features/settings/presentation/pages/health_import_page.dart index 46636d9..d18d3c7 100644 --- a/lib/features/settings/presentation/pages/health_import_page.dart +++ b/lib/features/settings/presentation/pages/health_import_page.dart @@ -7,6 +7,7 @@ import 'package:kynos/app/router.dart'; import 'package:kynos/core/theme/spacing.dart' as tokens; import 'package:kynos/core/theme/theme.dart'; import 'package:kynos/infrastructure/health/health_infrastructure_providers.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_export_parser.dart'; import 'package:kynos/infrastructure/health/import/gpx_workout_parser.dart'; import 'package:kynos/shared/providers/health_providers.dart'; import 'package:kynos/shared/widgets/kynos_card.dart'; @@ -19,47 +20,73 @@ class HealthImportPage extends ConsumerStatefulWidget { } class _HealthImportPageState extends ConsumerState { - GpxParseResult? _preview; + GpxParseResult? _gpxPreview; + AppleHealthExportParseResult? _zipPreview; + List? _zipBytes; String? _error; bool _isImporting = false; - Future _pickGpxFile() async { + Future _pickFile() async { setState(() { - _preview = null; + _gpxPreview = null; + _zipPreview = null; + _zipBytes = null; _error = null; }); final result = await FilePicker.platform.pickFiles( type: FileType.custom, - allowedExtensions: const ['gpx'], + allowedExtensions: const ['gpx', 'zip'], withData: true, ); if (!mounted || result == null || result.files.isEmpty) return; - final bytes = result.files.single.bytes; + final file = result.files.single; + final bytes = file.bytes; if (bytes == null) { setState(() => _error = 'Could not read the selected file.'); return; } + final extension = file.extension?.toLowerCase(); try { - final content = String.fromCharCodes(bytes); - final parsed = const GpxWorkoutParser().parse(content); - setState(() => _preview = parsed); + if (extension == 'zip') { + final parsed = const AppleHealthExportParser().parseZip(bytes); + setState(() { + _zipPreview = parsed; + _zipBytes = bytes; + }); + } else { + final content = String.fromCharCodes(bytes); + final parsed = const GpxWorkoutParser().parse(content); + setState(() => _gpxPreview = parsed); + } } on FormatException catch (e) { setState(() => _error = e.message); } on Object catch (e) { - setState(() => _error = 'Failed to parse GPX: $e'); + setState(() => _error = 'Failed to parse file: $e'); } } Future _confirmImport() async { - final preview = _preview; - if (preview == null) return; - setState(() => _isImporting = true); + if (_zipPreview != null && _zipBytes != null) { + await _importZip(_zipBytes!); + } else if (_gpxPreview != null) { + await _importGpx(); + } + + if (mounted) { + setState(() => _isImporting = false); + } + } + + Future _importGpx() async { + final preview = _gpxPreview; + if (preview == null) return; + final useCase = ref.read(importWorkoutUseCaseProvider); final result = await useCase( workout: preview.workout, @@ -68,8 +95,6 @@ class _HealthImportPageState extends ConsumerState { if (!mounted) return; - setState(() => _isImporting = false); - if (result.failure != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(result.failure!.message)), @@ -77,10 +102,7 @@ class _HealthImportPageState extends ConsumerState { return; } - ref.invalidate(healthSummaryProvider); - ref.invalidate(healthHistoryProvider); - ref.invalidate(recentRunsProvider); - ref.invalidate(importedWorkoutCountProvider); + _invalidateHealthProviders(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Run imported successfully.')), @@ -91,19 +113,53 @@ class _HealthImportPageState extends ConsumerState { } } + Future _importZip(List bytes) async { + final useCase = ref.read(importAppleHealthExportUseCaseProvider); + final result = await useCase(zipBytes: bytes); + + if (!mounted) return; + + if (result.failure != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.failure!.message)), + ); + return; + } + + _invalidateHealthProviders(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Imported ${result.importedDays} days of metrics and ' + '${result.importedWorkouts} runs.', + ), + ), + ); + } + + void _invalidateHealthProviders() { + ref.invalidate(healthSummaryProvider); + ref.invalidate(healthHistoryProvider); + ref.invalidate(recentRunsProvider); + ref.invalidate(importedWorkoutCountProvider); + } + @override Widget build(BuildContext context) { final kynos = context.kynosTheme; + final zipPreview = _zipPreview; + final gpxPreview = _gpxPreview; return Scaffold( backgroundColor: kynos.background, - appBar: AppBar(title: const Text('Import Run')), + appBar: AppBar(title: const Text('Import Health Data')), body: ListView( padding: const EdgeInsets.all(tokens.Spacing.md), children: [ Text( - 'Sideloaded apps may not access HealthKit. Import a GPX file ' - 'exported from Garmin, Strava, or Apple Fitness instead.', + 'Sideloaded apps may not access HealthKit. Import your full Apple ' + 'Health export.zip, or a single GPX route from Garmin or Strava.', style: Theme.of(context).textTheme.bodyMedium, ), const Gap(tokens.Spacing.sm), @@ -115,9 +171,9 @@ class _HealthImportPageState extends ConsumerState { ), const Gap(tokens.Spacing.lg), FilledButton.icon( - onPressed: _isImporting ? null : _pickGpxFile, + onPressed: _isImporting ? null : _pickFile, icon: const Icon(Icons.upload_file_outlined), - label: const Text('Choose GPX file'), + label: const Text('Choose export.zip or GPX'), ), if (_error != null) ...[ const Gap(tokens.Spacing.md), @@ -128,33 +184,75 @@ class _HealthImportPageState extends ConsumerState { ), ), ], - if (_preview != null) ...[ + if (zipPreview != null) ...[ + const Gap(tokens.Spacing.lg), + KynosCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Apple Health export preview', + style: Theme.of(context).textTheme.titleMedium, + ), + const Gap(tokens.Spacing.sm), + _PreviewRow( + label: 'Health records', + value: '${zipPreview.recordCount}', + ), + _PreviewRow( + label: 'Daily summaries', + value: '${zipPreview.summaries.length}', + ), + _PreviewRow( + label: 'Running workouts', + value: '${zipPreview.workouts.length}', + ), + _PreviewRow( + label: 'Runs with GPS routes', + value: + '${zipPreview.workouts.where((w) => w.routePoints.isNotEmpty).length}', + ), + if (zipPreview.skippedWorkouts > 0) + _PreviewRow( + label: 'Skipped workouts', + value: '${zipPreview.skippedWorkouts}', + ), + ], + ), + ), + const Gap(tokens.Spacing.md), + FilledButton( + onPressed: _isImporting ? null : _confirmImport, + child: Text(_isImporting ? 'Importing…' : 'Import all data'), + ), + ], + if (gpxPreview != null) ...[ const Gap(tokens.Spacing.lg), KynosCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Preview', + 'GPX preview', style: Theme.of(context).textTheme.titleMedium, ), const Gap(tokens.Spacing.sm), _PreviewRow( label: 'Date', - value: _formatDate(_preview!.workout.start), + value: _formatDate(gpxPreview.workout.start), ), _PreviewRow( label: 'Duration', - value: _formatDuration(_preview!.workout.duration), + value: _formatDuration(gpxPreview.workout.duration), ), _PreviewRow( label: 'Distance', value: - '${((_preview!.workout.distanceMeters ?? 0) / 1000).toStringAsFixed(2)} km', + '${((gpxPreview.workout.distanceMeters ?? 0) / 1000).toStringAsFixed(2)} km', ), _PreviewRow( label: 'Route points', - value: '${_preview!.routePoints.length}', + value: '${gpxPreview.routePoints.length}', ), ], ), diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index d70e02f..84ddfa3 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -116,7 +116,7 @@ class _SettingsPageState extends ConsumerState { Divider(color: kynos.separator, height: 1), ], _ActionTile( - title: 'Import run from GPX', + title: 'Import Apple Health export', icon: Icons.upload_file_outlined, onTap: () => context.push(Routes.healthImport), ), @@ -149,8 +149,8 @@ class _SettingsPageState extends ConsumerState { ), const Gap(tokens.Spacing.xs), Text( - 'Sideloaded installs may not access HealthKit. Import GPX ' - 'files or log runs manually as a local fallback.', + 'Sideloaded installs may not access HealthKit. Import your ' + 'Apple Health export.zip, a GPX file, or log runs manually.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: kynos.secondaryLabel, ), diff --git a/lib/infrastructure/health/drift_imported_health_store.dart b/lib/infrastructure/health/drift_imported_health_store.dart index 2432385..5b39db2 100644 --- a/lib/infrastructure/health/drift_imported_health_store.dart +++ b/lib/infrastructure/health/drift_imported_health_store.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; +import 'package:kynos/domain/entities/health_summary.dart'; import 'package:kynos/domain/entities/workout_route_point.dart'; import 'package:kynos/domain/entities/workout_session.dart'; import 'package:kynos/infrastructure/health/imported_health_database.dart'; @@ -103,6 +106,47 @@ class DriftImportedHealthStore implements ImportedHealthStore { await _db.transaction(() async { await _db.delete(_db.importedRoutePoints).go(); await _db.delete(_db.importedWorkouts).go(); + await _db.delete(_db.importedDailySummaries).go(); + }); + } + + @override + Future> getSummaries({required DateTime since}) async { + final rows = await (_db.select(_db.importedDailySummaries) + ..where((row) => row.date.isBiggerOrEqualValue(since)) + ..orderBy([(row) => OrderingTerm.desc(row.date)])) + .get(); + + return rows + .map( + (row) => healthSummaryFromJson( + jsonDecode(row.payload) as Map, + ), + ) + .toList(); + } + + @override + Future saveSummaries(List summaries) async { + if (summaries.isEmpty) { + return; + } + + await _db.batch((batch) { + for (final summary in summaries) { + batch.insert( + _db.importedDailySummaries, + ImportedDailySummariesCompanion.insert( + date: DateTime( + summary.date.year, + summary.date.month, + summary.date.day, + ), + payload: jsonEncode(healthSummaryToJson(summary)), + ), + mode: InsertMode.insertOrReplace, + ); + } }); } diff --git a/lib/infrastructure/health/health_infrastructure_providers.dart b/lib/infrastructure/health/health_infrastructure_providers.dart index 7db58b5..4e96bef 100644 --- a/lib/infrastructure/health/health_infrastructure_providers.dart +++ b/lib/infrastructure/health/health_infrastructure_providers.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:kynos/domain/repositories/health_repository.dart'; +import 'package:kynos/domain/usecases/health/import_apple_health_export_usecase.dart'; import 'package:kynos/domain/usecases/health/import_workout_usecase.dart'; import 'package:kynos/infrastructure/health/composite_health_repository.dart'; import 'package:kynos/infrastructure/health/health_kit_repository.dart'; @@ -30,3 +31,11 @@ final compositeHealthRepositoryProvider = Provider((ref) { final importWorkoutUseCaseProvider = Provider((ref) { return ImportWorkoutUseCase(ref.watch(importedHealthStoreProvider)); }); + +final importAppleHealthExportUseCaseProvider = + Provider((ref) { + return ImportAppleHealthExportUseCase( + store: ref.watch(importedHealthStoreProvider), + importWorkout: ref.watch(importWorkoutUseCaseProvider), + ); +}); diff --git a/lib/infrastructure/health/import/apple_health_date_parser.dart b/lib/infrastructure/health/import/apple_health_date_parser.dart new file mode 100644 index 0000000..8a51610 --- /dev/null +++ b/lib/infrastructure/health/import/apple_health_date_parser.dart @@ -0,0 +1,15 @@ +/// Parses timestamps from Apple Health `export.xml` attributes. +DateTime? parseAppleHealthDate(String? raw) { + if (raw == null || raw.isEmpty) { + return null; + } + + // Apple uses "2016-04-02 10:40:38 +0100" — ISO 8601 needs a T separator. + final normalized = raw.replaceFirst(' ', 'T'); + return DateTime.tryParse(normalized); +} + +/// Strips control characters that break XML parsers in some exports. +String sanitizeAppleHealthXml(String input) { + return input.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), ''); +} diff --git a/lib/infrastructure/health/import/apple_health_export_parser.dart b/lib/infrastructure/health/import/apple_health_export_parser.dart new file mode 100644 index 0000000..4864dc5 --- /dev/null +++ b/lib/infrastructure/health/import/apple_health_export_parser.dart @@ -0,0 +1,292 @@ +import 'package:archive/archive.dart'; +import 'package:kynos/core/constants/imported_workout_ids.dart'; +import 'package:kynos/domain/entities/health_summary.dart'; +import 'package:kynos/domain/entities/workout_route_point.dart'; +import 'package:kynos/domain/entities/workout_session.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_date_parser.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_record_aggregator.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_unit_converter.dart'; +import 'package:kynos/infrastructure/health/import/gpx_workout_parser.dart'; +import 'package:xml/xml_events.dart'; + +/// A running workout parsed from an Apple Health export. +class AppleHealthWorkoutImport { + const AppleHealthWorkoutImport({ + required this.workout, + this.routePoints = const [], + }); + + final WorkoutSession workout; + final List routePoints; +} + +/// Result of parsing an Apple Health `export.zip` archive. +class AppleHealthExportParseResult { + const AppleHealthExportParseResult({ + required this.summaries, + required this.workouts, + required this.recordCount, + required this.skippedWorkouts, + }); + + final List summaries; + final List workouts; + final int recordCount; + final int skippedWorkouts; +} + +/// Parses Apple Health `export.zip` into daily summaries and running workouts. +class AppleHealthExportParser { + const AppleHealthExportParser({ + GpxWorkoutParser? gpxParser, + }) : _gpxParser = gpxParser ?? const GpxWorkoutParser(); + + final GpxWorkoutParser _gpxParser; + + AppleHealthExportParseResult parseZip(List zipBytes) { + final archive = ZipDecoder().decodeBytes(zipBytes); + final files = _indexArchive(archive); + + final exportXml = _findExportXml(files); + if (exportXml == null) { + throw const FormatException( + 'Could not find export.xml inside the archive', + ); + } + + return _parseExportXml(exportXml, files); + } + + Map> _indexArchive(Archive archive) { + final files = >{}; + for (final file in archive) { + if (file.isFile) { + files[_normalizePath(file.name)] = List.from(file.content as List); + } + } + return files; + } + + String _normalizePath(String path) { + return path.replaceAll('\\', '/').replaceFirst(RegExp(r'^\.?/'), ''); + } + + String? _findExportXml(Map> files) { + for (final entry in files.entries) { + final name = entry.key.toLowerCase(); + if (name.endsWith('export.xml') && !name.endsWith('export_cda.xml')) { + return String.fromCharCodes(entry.value); + } + } + return null; + } + + AppleHealthExportParseResult _parseExportXml( + String rawXml, + Map> files, + ) { + final xml = sanitizeAppleHealthXml(rawXml); + final aggregator = AppleHealthRecordAggregator(); + final workouts = []; + var recordCount = 0; + var skippedWorkouts = 0; + + _WorkoutBuilder? currentWorkout; + var workoutDepth = 0; + + for (final event in parseEvents(xml, validateNesting: true)) { + if (event is XmlStartElementEvent) { + switch (event.name) { + case 'Record': + recordCount += 1; + aggregator.addRecord( + type: _attr(event, 'type') ?? '', + value: _attr(event, 'value'), + unit: _attr(event, 'unit'), + startDate: _attr(event, 'startDate') ?? '', + endDate: _attr(event, 'endDate') ?? '', + ); + case 'ActivitySummary': + aggregator.addActivitySummary( + dateComponents: _attr(event, 'dateComponents') ?? '', + activeEnergyBurned: _attr(event, 'activeEnergyBurned'), + activeEnergyBurnedUnit: _attr(event, 'activeEnergyBurnedUnit'), + appleExerciseTime: _attr(event, 'appleExerciseTime'), + ); + case 'Workout': + workoutDepth += 1; + if (workoutDepth == 1) { + currentWorkout = _WorkoutBuilder.fromAttributes(event); + } + case 'FileReference': + if (currentWorkout != null && workoutDepth > 0) { + final path = _attr(event, 'path'); + if (path != null) { + currentWorkout.routePaths.add(path); + } + } + } + } else if (event is XmlEndElementEvent && event.name == 'Workout') { + if (workoutDepth == 1 && currentWorkout != null) { + final import = _finalizeWorkout(currentWorkout, files); + if (import != null) { + workouts.add(import); + } else { + skippedWorkouts += 1; + } + currentWorkout = null; + } + workoutDepth = (workoutDepth - 1).clamp(0, workoutDepth); + } + } + + return AppleHealthExportParseResult( + summaries: aggregator.finalize(), + workouts: workouts, + recordCount: recordCount, + skippedWorkouts: skippedWorkouts, + ); + } + + AppleHealthWorkoutImport? _finalizeWorkout( + _WorkoutBuilder builder, + Map> files, + ) { + if (!builder.isRunning) { + return null; + } + + final start = builder.start; + final end = builder.end; + if (start == null || end == null || !end.isAfter(start)) { + return null; + } + + var distanceMeters = builder.distanceMeters; + var routePoints = const []; + + for (final path in builder.routePaths) { + final gpxBytes = _lookupFile(files, path); + if (gpxBytes == null) { + continue; + } + + try { + final parsed = _gpxParser.parse( + String.fromCharCodes(gpxBytes), + sourceName: builder.sourceName ?? 'Apple Health', + ); + routePoints = parsed.routePoints; + if ((distanceMeters ?? 0) <= 0) { + distanceMeters = parsed.workout.distanceMeters; + } + break; + } on FormatException { + continue; + } + } + + if ((distanceMeters ?? 0) <= 0) { + return null; + } + + final workout = WorkoutSession( + id: _stableWorkoutId(start, end), + start: start, + end: end, + workoutType: 'running', + distanceMeters: distanceMeters, + energyKcal: builder.energyKcal, + sourceName: builder.sourceName ?? 'Apple Health', + startLatitude: routePoints.isEmpty ? null : routePoints.first.latitude, + startLongitude: routePoints.isEmpty ? null : routePoints.first.longitude, + endLatitude: routePoints.isEmpty ? null : routePoints.last.latitude, + endLongitude: routePoints.isEmpty ? null : routePoints.last.longitude, + ); + + return AppleHealthWorkoutImport(workout: workout, routePoints: routePoints); + } + + String _stableWorkoutId(DateTime start, DateTime end) { + return '${ImportedWorkoutIds.prefix}apple:${start.toUtc().millisecondsSinceEpoch}:${end.toUtc().millisecondsSinceEpoch}'; + } + + List? _lookupFile(Map> files, String path) { + final normalized = _normalizePath(path); + if (files.containsKey(normalized)) { + return files[normalized]; + } + + final lower = normalized.toLowerCase(); + for (final entry in files.entries) { + if (entry.key.toLowerCase() == lower) { + return entry.value; + } + } + + final fileName = normalized.split('/').last; + for (final entry in files.entries) { + if (entry.key.toLowerCase().endsWith('/$fileName') || + entry.key.toLowerCase() == fileName.toLowerCase()) { + return entry.value; + } + } + + return null; + } + + String? _attr(XmlStartElementEvent event, String name) { + for (final attribute in event.attributes) { + if (attribute.name == name) { + return attribute.value; + } + } + return null; + } +} + +class _WorkoutBuilder { + _WorkoutBuilder({ + required this.activityType, + this.start, + this.end, + this.distanceMeters, + this.energyKcal, + this.sourceName, + this.routePaths = const [], + }); + + factory _WorkoutBuilder.fromAttributes(XmlStartElementEvent event) { + String? read(String name) { + for (final attribute in event.attributes) { + if (attribute.name == name) { + return attribute.value; + } + } + return null; + } + + final distance = double.tryParse(read('totalDistance') ?? ''); + final energy = double.tryParse(read('totalEnergyBurned') ?? ''); + + return _WorkoutBuilder( + activityType: read('workoutActivityType') ?? '', + start: parseAppleHealthDate(read('startDate')), + end: parseAppleHealthDate(read('endDate')), + distanceMeters: toMeters(distance, read('totalDistanceUnit')), + energyKcal: toKilocalories(energy, read('totalEnergyBurnedUnit')), + sourceName: read('sourceName'), + routePaths: [], + ); + } + + final String activityType; + final DateTime? start; + final DateTime? end; + final double? distanceMeters; + final double? energyKcal; + final String? sourceName; + final List routePaths; + + bool get isRunning => activityType.toUpperCase().contains('RUNNING'); +} diff --git a/lib/infrastructure/health/import/apple_health_record_aggregator.dart b/lib/infrastructure/health/import/apple_health_record_aggregator.dart new file mode 100644 index 0000000..9a5a346 --- /dev/null +++ b/lib/infrastructure/health/import/apple_health_record_aggregator.dart @@ -0,0 +1,245 @@ +import 'package:kynos/domain/entities/health_summary.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_date_parser.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_unit_converter.dart'; + +/// Aggregates Apple Health `Record` and `ActivitySummary` elements into daily +/// [HealthSummary] values — mirrors [aggregateHealthSummaries] for HealthKit. +class AppleHealthRecordAggregator { + final _byDay = {}; + + void addRecord({ + required String type, + String? value, + String? unit, + required String startDate, + required String endDate, + }) { + final start = parseAppleHealthDate(startDate); + if (start == null) { + return; + } + + final day = DateTime(start.year, start.month, start.day); + final acc = _byDay.putIfAbsent(day, () => _DailyAccumulator(date: day)); + final numeric = double.tryParse(value ?? ''); + + switch (type) { + case 'HKQuantityTypeIdentifierHeartRateVariabilitySDNN': + if (numeric != null) acc.addHrv(numeric); + case 'HKQuantityTypeIdentifierRestingHeartRate': + if (numeric != null) acc.addRhr(numeric); + case 'HKQuantityTypeIdentifierHeartRate': + if (numeric != null) acc.addHeartRate(numeric); + case 'HKQuantityTypeIdentifierRespiratoryRate': + if (numeric != null) acc.addRespiratoryRate(numeric); + case 'HKQuantityTypeIdentifierOxygenSaturation': + if (numeric != null) acc.addBloodOxygen(toBloodOxygenPercent(numeric)!); + case 'HKCategoryTypeIdentifierSleepAnalysis': + _addSleep(acc, value, startDate, endDate); + case 'HKQuantityTypeIdentifierActiveEnergyBurned': + if (numeric != null) acc.activeCalories += toKilocalories(numeric, unit)!; + case 'HKQuantityTypeIdentifierBasalEnergyBurned': + if (numeric != null) acc.basalCalories += toKilocalories(numeric, unit)!; + case 'HKQuantityTypeIdentifierStepCount': + if (numeric != null) acc.steps += numeric.round(); + case 'HKQuantityTypeIdentifierDistanceWalkingRunning': + if (numeric != null) { + acc.distanceMeters += toMeters(numeric, unit) ?? numeric; + } + case 'HKQuantityTypeIdentifierFlightsClimbed': + if (numeric != null) acc.flightsClimbed += numeric; + case 'HKQuantityTypeIdentifierAppleExerciseTime': + if (numeric != null) { + acc.exerciseMinutes += toMinutes(numeric, unit) ?? numeric; + } + case 'HKQuantityTypeIdentifierRunningPower': + if (numeric != null) acc.addRunningPower(numeric); + case 'HKQuantityTypeIdentifierRunningCadence': + if (numeric != null) acc.addCadence(numeric); + case 'HKQuantityTypeIdentifierRunningStrideLength': + if (numeric != null) { + acc.addStrideLength(toMeters(numeric, unit) ?? numeric); + } + default: + break; + } + } + + void addActivitySummary({ + required String dateComponents, + String? activeEnergyBurned, + String? activeEnergyBurnedUnit, + String? appleExerciseTime, + }) { + final day = _parseDateComponents(dateComponents); + if (day == null) { + return; + } + + final acc = _byDay.putIfAbsent(day, () => _DailyAccumulator(date: day)); + + final energy = double.tryParse(activeEnergyBurned ?? ''); + if (energy != null) { + acc.activeCalories += toKilocalories(energy, activeEnergyBurnedUnit) ?? energy; + } + + final exercise = double.tryParse(appleExerciseTime ?? ''); + if (exercise != null) { + acc.exerciseMinutes += exercise; + } + } + + List finalize() { + return _byDay.values + .map((acc) => acc.toSummary()) + .toList() + ..sort((a, b) => b.date.compareTo(a.date)); + } + + void _addSleep( + _DailyAccumulator acc, + String? categoryValue, + String startDate, + String endDate, + ) { + if (!_isAsleepCategory(categoryValue)) { + return; + } + + final start = parseAppleHealthDate(startDate); + final end = parseAppleHealthDate(endDate); + if (start == null || end == null) { + return; + } + + final minutes = end.difference(start).inMinutes.toDouble(); + if (minutes > 0) { + acc.sleepMinutes += minutes; + } + } + + bool _isAsleepCategory(String? value) { + if (value == null) { + return false; + } + + return value.contains('Asleep') && !value.contains('Awake'); + } + + DateTime? _parseDateComponents(String raw) { + final parts = raw.split('-'); + if (parts.length != 3) { + return null; + } + + final year = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final day = int.tryParse(parts[2]); + if (year == null || month == null || day == null) { + return null; + } + + return DateTime(year, month, day); + } +} + +class _DailyAccumulator { + _DailyAccumulator({required this.date}); + + final DateTime date; + + double _hrvSum = 0; + int _hrvCount = 0; + double _rhrSum = 0; + int _rhrCount = 0; + double _heartRateSum = 0; + int _heartRateCount = 0; + double _respiratoryRateSum = 0; + int _respiratoryRateCount = 0; + double _bloodOxygenSum = 0; + int _bloodOxygenCount = 0; + double _runningPowerSum = 0; + int _runningPowerCount = 0; + double _cadenceSum = 0; + int _cadenceCount = 0; + double _strideLengthSum = 0; + int _strideLengthCount = 0; + + double sleepMinutes = 0; + double activeCalories = 0; + double basalCalories = 0; + int steps = 0; + double distanceMeters = 0; + double flightsClimbed = 0; + double exerciseMinutes = 0; + + void addHrv(double value) { + _hrvSum += value; + _hrvCount += 1; + } + + void addRhr(double value) { + _rhrSum += value; + _rhrCount += 1; + } + + void addHeartRate(double value) { + _heartRateSum += value; + _heartRateCount += 1; + } + + void addRespiratoryRate(double value) { + _respiratoryRateSum += value; + _respiratoryRateCount += 1; + } + + void addBloodOxygen(double value) { + _bloodOxygenSum += value; + _bloodOxygenCount += 1; + } + + void addRunningPower(double value) { + _runningPowerSum += value; + _runningPowerCount += 1; + } + + void addCadence(double value) { + _cadenceSum += value; + _cadenceCount += 1; + } + + void addStrideLength(double value) { + _strideLengthSum += value; + _strideLengthCount += 1; + } + + HealthSummary toSummary() { + final totalCalories = activeCalories + basalCalories; + return HealthSummary( + date: date, + hrvMs: _hrvCount == 0 ? null : _hrvSum / _hrvCount, + rhrBpm: _rhrCount == 0 ? null : _rhrSum / _rhrCount, + avgHeartRateBpm: + _heartRateCount == 0 ? null : _heartRateSum / _heartRateCount, + respiratoryRateBrpm: _respiratoryRateCount == 0 + ? null + : _respiratoryRateSum / _respiratoryRateCount, + bloodOxygenPercent: + _bloodOxygenCount == 0 ? null : _bloodOxygenSum / _bloodOxygenCount, + sleepHours: sleepMinutes == 0 ? null : sleepMinutes / 60, + activeCalories: activeCalories == 0 ? null : activeCalories, + basalCalories: basalCalories == 0 ? null : basalCalories, + totalCalories: totalCalories == 0 ? null : totalCalories, + steps: steps == 0 ? null : steps, + distanceMeters: distanceMeters == 0 ? null : distanceMeters, + flightsClimbed: flightsClimbed == 0 ? null : flightsClimbed, + runningPowerWatts: + _runningPowerCount == 0 ? null : _runningPowerSum / _runningPowerCount, + cadenceSpm: _cadenceCount == 0 ? null : _cadenceSum / _cadenceCount, + strideLengthMeters: _strideLengthCount == 0 + ? null + : _strideLengthSum / _strideLengthCount, + exerciseMinutes: exerciseMinutes == 0 ? null : exerciseMinutes, + ); + } +} diff --git a/lib/infrastructure/health/import/apple_health_unit_converter.dart b/lib/infrastructure/health/import/apple_health_unit_converter.dart new file mode 100644 index 0000000..f1c7394 --- /dev/null +++ b/lib/infrastructure/health/import/apple_health_unit_converter.dart @@ -0,0 +1,62 @@ +/// Converts Apple Health export units into KYNOS canonical units. +double? toMeters(double? value, String? unit) { + if (value == null) { + return null; + } + + switch (unit?.toLowerCase()) { + case 'km': + return value * 1000; + case 'mi': + return value * 1609.344; + case 'm': + case 'meter': + case 'meters': + return value; + case 'cm': + return value / 100; + default: + return value; + } +} + +double? toKilocalories(double? value, String? unit) { + if (value == null) { + return null; + } + + switch (unit?.toLowerCase()) { + case 'kj': + return value / 4.184; + case 'kcal': + case 'cal': + return value; + default: + return value; + } +} + +double? toMinutes(double? value, String? unit) { + if (value == null) { + return null; + } + + switch (unit?.toLowerCase()) { + case 's': + case 'sec': + return value / 60; + case 'hr': + case 'h': + return value * 60; + case 'min': + default: + return value; + } +} + +double? toBloodOxygenPercent(double? value) { + if (value == null) { + return null; + } + return value <= 1 ? value * 100 : value; +} diff --git a/lib/infrastructure/health/imported_health_database.dart b/lib/infrastructure/health/imported_health_database.dart index 05d9a0a..6f6f5dd 100644 --- a/lib/infrastructure/health/imported_health_database.dart +++ b/lib/infrastructure/health/imported_health_database.dart @@ -31,12 +31,29 @@ class ImportedRoutePoints extends Table { IntColumn get sequence => integer()(); } -@DriftDatabase(tables: [ImportedWorkouts, ImportedRoutePoints]) +class ImportedDailySummaries extends Table { + DateTimeColumn get date => dateTime()(); + TextColumn get payload => text()(); + + @override + Set> get primaryKey => {date}; +} + +@DriftDatabase(tables: [ImportedWorkouts, ImportedRoutePoints, ImportedDailySummaries]) class ImportedHealthDatabase extends _$ImportedHealthDatabase { ImportedHealthDatabase(super.e); @override - int get schemaVersion => 1; + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (migrator, from, to) async { + if (from < 2) { + await migrator.createTable(importedDailySummaries); + } + }, + ); } QueryExecutor openImportedHealthConnection() => createImportedHealthConnection(); diff --git a/lib/infrastructure/health/imported_health_database.g.dart b/lib/infrastructure/health/imported_health_database.g.dart index f7b4937..b78330e 100644 --- a/lib/infrastructure/health/imported_health_database.g.dart +++ b/lib/infrastructure/health/imported_health_database.g.dart @@ -1153,6 +1153,224 @@ class ImportedRoutePointsCompanion extends UpdateCompanion { } } +class $ImportedDailySummariesTable extends ImportedDailySummaries + with TableInfo<$ImportedDailySummariesTable, ImportedDailySummary> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ImportedDailySummariesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _dateMeta = const VerificationMeta('date'); + @override + late final GeneratedColumn date = GeneratedColumn( + 'date', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _payloadMeta = const VerificationMeta( + 'payload', + ); + @override + late final GeneratedColumn payload = GeneratedColumn( + 'payload', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [date, payload]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'imported_daily_summaries'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('date')) { + context.handle( + _dateMeta, + date.isAcceptableOrUnknown(data['date']!, _dateMeta), + ); + } else if (isInserting) { + context.missing(_dateMeta); + } + if (data.containsKey('payload')) { + context.handle( + _payloadMeta, + payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta), + ); + } else if (isInserting) { + context.missing(_payloadMeta); + } + return context; + } + + @override + Set get $primaryKey => {date}; + @override + ImportedDailySummary map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ImportedDailySummary( + date: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}date'], + )!, + payload: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}payload'], + )!, + ); + } + + @override + $ImportedDailySummariesTable createAlias(String alias) { + return $ImportedDailySummariesTable(attachedDatabase, alias); + } +} + +class ImportedDailySummary extends DataClass + implements Insertable { + final DateTime date; + final String payload; + const ImportedDailySummary({required this.date, required this.payload}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['date'] = Variable(date); + map['payload'] = Variable(payload); + return map; + } + + ImportedDailySummariesCompanion toCompanion(bool nullToAbsent) { + return ImportedDailySummariesCompanion( + date: Value(date), + payload: Value(payload), + ); + } + + factory ImportedDailySummary.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ImportedDailySummary( + date: serializer.fromJson(json['date']), + payload: serializer.fromJson(json['payload']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'date': serializer.toJson(date), + 'payload': serializer.toJson(payload), + }; + } + + ImportedDailySummary copyWith({DateTime? date, String? payload}) => + ImportedDailySummary( + date: date ?? this.date, + payload: payload ?? this.payload, + ); + ImportedDailySummary copyWithCompanion(ImportedDailySummariesCompanion data) { + return ImportedDailySummary( + date: data.date.present ? data.date.value : this.date, + payload: data.payload.present ? data.payload.value : this.payload, + ); + } + + @override + String toString() { + return (StringBuffer('ImportedDailySummary(') + ..write('date: $date, ') + ..write('payload: $payload') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(date, payload); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ImportedDailySummary && + other.date == this.date && + other.payload == this.payload); +} + +class ImportedDailySummariesCompanion + extends UpdateCompanion { + final Value date; + final Value payload; + final Value rowid; + const ImportedDailySummariesCompanion({ + this.date = const Value.absent(), + this.payload = const Value.absent(), + this.rowid = const Value.absent(), + }); + ImportedDailySummariesCompanion.insert({ + required DateTime date, + required String payload, + this.rowid = const Value.absent(), + }) : date = Value(date), + payload = Value(payload); + static Insertable custom({ + Expression? date, + Expression? payload, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (date != null) 'date': date, + if (payload != null) 'payload': payload, + if (rowid != null) 'rowid': rowid, + }); + } + + ImportedDailySummariesCompanion copyWith({ + Value? date, + Value? payload, + Value? rowid, + }) { + return ImportedDailySummariesCompanion( + date: date ?? this.date, + payload: payload ?? this.payload, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (date.present) { + map['date'] = Variable(date.value); + } + if (payload.present) { + map['payload'] = Variable(payload.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ImportedDailySummariesCompanion(') + ..write('date: $date, ') + ..write('payload: $payload, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$ImportedHealthDatabase extends GeneratedDatabase { _$ImportedHealthDatabase(QueryExecutor e) : super(e); $ImportedHealthDatabaseManager get managers => @@ -1162,6 +1380,8 @@ abstract class _$ImportedHealthDatabase extends GeneratedDatabase { ); late final $ImportedRoutePointsTable importedRoutePoints = $ImportedRoutePointsTable(this); + late final $ImportedDailySummariesTable importedDailySummaries = + $ImportedDailySummariesTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -1169,6 +1389,7 @@ abstract class _$ImportedHealthDatabase extends GeneratedDatabase { List get allSchemaEntities => [ importedWorkouts, importedRoutePoints, + importedDailySummaries, ]; } @@ -1999,6 +2220,168 @@ typedef $$ImportedRoutePointsTableProcessedTableManager = ImportedRoutePoint, PrefetchHooks Function({bool workoutId}) >; +typedef $$ImportedDailySummariesTableCreateCompanionBuilder = + ImportedDailySummariesCompanion Function({ + required DateTime date, + required String payload, + Value rowid, + }); +typedef $$ImportedDailySummariesTableUpdateCompanionBuilder = + ImportedDailySummariesCompanion Function({ + Value date, + Value payload, + Value rowid, + }); + +class $$ImportedDailySummariesTableFilterComposer + extends Composer<_$ImportedHealthDatabase, $ImportedDailySummariesTable> { + $$ImportedDailySummariesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get date => $composableBuilder( + column: $table.date, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get payload => $composableBuilder( + column: $table.payload, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ImportedDailySummariesTableOrderingComposer + extends Composer<_$ImportedHealthDatabase, $ImportedDailySummariesTable> { + $$ImportedDailySummariesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get date => $composableBuilder( + column: $table.date, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get payload => $composableBuilder( + column: $table.payload, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ImportedDailySummariesTableAnnotationComposer + extends Composer<_$ImportedHealthDatabase, $ImportedDailySummariesTable> { + $$ImportedDailySummariesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get date => + $composableBuilder(column: $table.date, builder: (column) => column); + + GeneratedColumn get payload => + $composableBuilder(column: $table.payload, builder: (column) => column); +} + +class $$ImportedDailySummariesTableTableManager + extends + RootTableManager< + _$ImportedHealthDatabase, + $ImportedDailySummariesTable, + ImportedDailySummary, + $$ImportedDailySummariesTableFilterComposer, + $$ImportedDailySummariesTableOrderingComposer, + $$ImportedDailySummariesTableAnnotationComposer, + $$ImportedDailySummariesTableCreateCompanionBuilder, + $$ImportedDailySummariesTableUpdateCompanionBuilder, + ( + ImportedDailySummary, + BaseReferences< + _$ImportedHealthDatabase, + $ImportedDailySummariesTable, + ImportedDailySummary + >, + ), + ImportedDailySummary, + PrefetchHooks Function() + > { + $$ImportedDailySummariesTableTableManager( + _$ImportedHealthDatabase db, + $ImportedDailySummariesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ImportedDailySummariesTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$ImportedDailySummariesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$ImportedDailySummariesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value date = const Value.absent(), + Value payload = const Value.absent(), + Value rowid = const Value.absent(), + }) => ImportedDailySummariesCompanion( + date: date, + payload: payload, + rowid: rowid, + ), + createCompanionCallback: + ({ + required DateTime date, + required String payload, + Value rowid = const Value.absent(), + }) => ImportedDailySummariesCompanion.insert( + date: date, + payload: payload, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ImportedDailySummariesTableProcessedTableManager = + ProcessedTableManager< + _$ImportedHealthDatabase, + $ImportedDailySummariesTable, + ImportedDailySummary, + $$ImportedDailySummariesTableFilterComposer, + $$ImportedDailySummariesTableOrderingComposer, + $$ImportedDailySummariesTableAnnotationComposer, + $$ImportedDailySummariesTableCreateCompanionBuilder, + $$ImportedDailySummariesTableUpdateCompanionBuilder, + ( + ImportedDailySummary, + BaseReferences< + _$ImportedHealthDatabase, + $ImportedDailySummariesTable, + ImportedDailySummary + >, + ), + ImportedDailySummary, + PrefetchHooks Function() + >; class $ImportedHealthDatabaseManager { final _$ImportedHealthDatabase _db; @@ -2007,4 +2390,9 @@ class $ImportedHealthDatabaseManager { $$ImportedWorkoutsTableTableManager(_db, _db.importedWorkouts); $$ImportedRoutePointsTableTableManager get importedRoutePoints => $$ImportedRoutePointsTableTableManager(_db, _db.importedRoutePoints); + $$ImportedDailySummariesTableTableManager get importedDailySummaries => + $$ImportedDailySummariesTableTableManager( + _db, + _db.importedDailySummaries, + ); } diff --git a/lib/infrastructure/health/imported_health_repository.dart b/lib/infrastructure/health/imported_health_repository.dart index 56ff16c..e53a8fb 100644 --- a/lib/infrastructure/health/imported_health_repository.dart +++ b/lib/infrastructure/health/imported_health_repository.dart @@ -5,6 +5,7 @@ import 'package:kynos/domain/entities/workout_route_point.dart'; import 'package:kynos/domain/entities/workout_session.dart'; import 'package:kynos/domain/repositories/health_repository.dart'; import 'package:kynos/infrastructure/health/imported_health_store.dart'; +import 'package:kynos/infrastructure/health/imported_summary_merger.dart'; import 'package:kynos/infrastructure/health/imported_workout_summary_aggregator.dart'; /// [HealthRepository] backed by locally imported workout data. @@ -23,8 +24,10 @@ class ImportedHealthRepository implements HealthRepository { try { final since = DateTime.now().subtract(Duration(days: days)); final workouts = await _store.getWorkouts(since: since); + final storedSummaries = await _store.getSummaries(since: since); + final workoutSummaries = deriveSummariesFromWorkouts(workouts); return ( - summaries: deriveSummariesFromWorkouts(workouts), + summaries: mergeImportedSummaries(storedSummaries, workoutSummaries), failure: null, ); } catch (e) { diff --git a/lib/infrastructure/health/imported_health_store.dart b/lib/infrastructure/health/imported_health_store.dart index 5936973..197d6cb 100644 --- a/lib/infrastructure/health/imported_health_store.dart +++ b/lib/infrastructure/health/imported_health_store.dart @@ -1,3 +1,4 @@ +import 'package:kynos/domain/entities/health_summary.dart'; import 'package:kynos/domain/entities/workout_route_point.dart'; import 'package:kynos/domain/entities/workout_session.dart'; @@ -17,6 +18,10 @@ abstract interface class ImportedHealthStore { List routePoints = const [], }); + Future> getSummaries({required DateTime since}); + + Future saveSummaries(List summaries); + Future clearAll(); } @@ -82,3 +87,36 @@ List routePointsFromJson(List json) { ) .toList(); } + +Map healthSummaryToJson(HealthSummary summary) { + return summary.toJson(); +} + +HealthSummary healthSummaryFromJson(Map json) { + return HealthSummary( + date: DateTime.parse(json['date'] as String), + hrvMs: (json['hrv_ms'] as num?)?.toDouble(), + rhrBpm: (json['rhr_bpm'] as num?)?.toDouble(), + avgHeartRateBpm: (json['avg_heart_rate_bpm'] as num?)?.toDouble(), + respiratoryRateBrpm: (json['respiratory_rate_brpm'] as num?)?.toDouble(), + bloodOxygenPercent: (json['blood_oxygen_percent'] as num?)?.toDouble(), + sleepHours: (json['sleep_hours'] as num?)?.toDouble(), + activeCalories: (json['active_calories'] as num?)?.toDouble(), + basalCalories: (json['basal_calories'] as num?)?.toDouble(), + totalCalories: (json['total_calories'] as num?)?.toDouble(), + steps: json['steps'] as int?, + distanceMeters: (json['distance_meters'] as num?)?.toDouble(), + flightsClimbed: (json['flights_climbed'] as num?)?.toDouble(), + runningPowerWatts: (json['running_power_watts'] as num?)?.toDouble(), + cadenceSpm: (json['cadence_spm'] as num?)?.toDouble(), + strideLengthMeters: (json['stride_length_m'] as num?)?.toDouble(), + exerciseMinutes: (json['exercise_minutes'] as num?)?.toDouble(), + runningWorkoutCount: json['running_workout_count'] as int?, + runningWorkoutMinutes: + (json['running_workout_minutes'] as num?)?.toDouble(), + runningWorkoutDistanceMeters: + (json['running_workout_distance_m'] as num?)?.toDouble(), + runningWorkoutCalories: + (json['running_workout_calories'] as num?)?.toDouble(), + ); +} diff --git a/lib/infrastructure/health/imported_summary_merger.dart b/lib/infrastructure/health/imported_summary_merger.dart new file mode 100644 index 0000000..48c5c28 --- /dev/null +++ b/lib/infrastructure/health/imported_summary_merger.dart @@ -0,0 +1,54 @@ +import 'package:kynos/domain/entities/health_summary.dart'; + +/// Merges imported daily summaries, preferring stored metrics and adding +/// workout rollups from both sources. +List mergeImportedSummaries( + List stored, + List fromWorkouts, +) { + final byDay = { + for (final summary in stored) summary.date: summary, + }; + + for (final workoutSummary in fromWorkouts) { + final existing = byDay[workoutSummary.date]; + if (existing == null) { + byDay[workoutSummary.date] = workoutSummary; + continue; + } + byDay[workoutSummary.date] = _combine(existing, workoutSummary); + } + + return byDay.values.toList()..sort((a, b) => b.date.compareTo(a.date)); +} + +HealthSummary _combine(HealthSummary base, HealthSummary extra) { + return HealthSummary( + date: base.date, + hrvMs: base.hrvMs ?? extra.hrvMs, + rhrBpm: base.rhrBpm ?? extra.rhrBpm, + avgHeartRateBpm: base.avgHeartRateBpm ?? extra.avgHeartRateBpm, + respiratoryRateBrpm: base.respiratoryRateBrpm ?? extra.respiratoryRateBrpm, + bloodOxygenPercent: base.bloodOxygenPercent ?? extra.bloodOxygenPercent, + sleepHours: base.sleepHours ?? extra.sleepHours, + activeCalories: base.activeCalories ?? extra.activeCalories, + basalCalories: base.basalCalories ?? extra.basalCalories, + totalCalories: base.totalCalories ?? extra.totalCalories, + steps: base.steps ?? extra.steps, + distanceMeters: (base.distanceMeters ?? 0) + (extra.distanceMeters ?? 0), + flightsClimbed: base.flightsClimbed ?? extra.flightsClimbed, + runningPowerWatts: base.runningPowerWatts ?? extra.runningPowerWatts, + cadenceSpm: base.cadenceSpm ?? extra.cadenceSpm, + strideLengthMeters: base.strideLengthMeters ?? extra.strideLengthMeters, + exerciseMinutes: base.exerciseMinutes ?? extra.exerciseMinutes, + runningWorkoutCount: + (base.runningWorkoutCount ?? 0) + (extra.runningWorkoutCount ?? 0), + runningWorkoutMinutes: (base.runningWorkoutMinutes ?? 0) + + (extra.runningWorkoutMinutes ?? 0), + runningWorkoutDistanceMeters: + (base.runningWorkoutDistanceMeters ?? 0) + + (extra.runningWorkoutDistanceMeters ?? 0), + runningWorkoutCalories: (base.runningWorkoutCalories ?? 0) + + (extra.runningWorkoutCalories ?? 0), + ); +} diff --git a/lib/infrastructure/health/prefs_imported_health_store.dart b/lib/infrastructure/health/prefs_imported_health_store.dart index f0f21c9..458897e 100644 --- a/lib/infrastructure/health/prefs_imported_health_store.dart +++ b/lib/infrastructure/health/prefs_imported_health_store.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:kynos/domain/entities/health_summary.dart'; import 'package:kynos/domain/entities/workout_route_point.dart'; import 'package:kynos/domain/entities/workout_session.dart'; import 'package:kynos/infrastructure/health/imported_health_store.dart'; @@ -11,6 +12,7 @@ class PrefsImportedHealthStore implements ImportedHealthStore { static const _workoutsKey = 'imported_health_workouts'; static const _routesKey = 'imported_health_routes'; + static const _summariesKey = 'imported_health_summaries'; final SharedPreferences _prefs; @@ -75,6 +77,56 @@ class PrefsImportedHealthStore implements ImportedHealthStore { Future clearAll() async { await _prefs.remove(_workoutsKey); await _prefs.remove(_routesKey); + await _prefs.remove(_summariesKey); + } + + @override + Future> getSummaries({required DateTime since}) async { + return _readSummaries() + .where((summary) => !summary.date.isBefore(since)) + .toList() + ..sort((a, b) => b.date.compareTo(a.date)); + } + + @override + Future saveSummaries(List summaries) async { + if (summaries.isEmpty) { + return; + } + + final stored = { + for (final summary in _readSummaries()) + DateTime(summary.date.year, summary.date.month, summary.date.day): + summary, + }; + + for (final summary in summaries) { + final day = DateTime( + summary.date.year, + summary.date.month, + summary.date.day, + ); + stored[day] = summary; + } + + await _prefs.setString( + _summariesKey, + jsonEncode(stored.values.map(healthSummaryToJson).toList()), + ); + } + + List _readSummaries() { + final raw = _prefs.getString(_summariesKey); + if (raw == null) { + return []; + } + + final decoded = jsonDecode(raw) as List; + return decoded + .map( + (entry) => healthSummaryFromJson(entry as Map), + ) + .toList(); } List _readWorkouts() { diff --git a/pubspec.lock b/pubspec.lock index 58c8f51..955d620 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "0.13.10" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff diff --git a/pubspec.yaml b/pubspec.yaml index af29ebf..d3c20f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: uuid: ^4.5.1 xml: ^6.5.0 device_info_plus: ^12.4.0 + archive: ^4.0.9 dev_dependencies: flutter_test: sdk: flutter diff --git a/test/domain/usecases/health/import_apple_health_export_usecase_test.dart b/test/domain/usecases/health/import_apple_health_export_usecase_test.dart new file mode 100644 index 0000000..e125215 --- /dev/null +++ b/test/domain/usecases/health/import_apple_health_export_usecase_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kynos/domain/usecases/health/import_apple_health_export_usecase.dart'; +import 'package:kynos/domain/usecases/health/import_workout_usecase.dart'; +import 'package:kynos/infrastructure/health/drift_imported_health_store.dart'; +import 'package:kynos/infrastructure/health/imported_health_database.dart'; + +void main() { + group('ImportAppleHealthExportUseCase', () { + late ImportedHealthDatabase db; + late DriftImportedHealthStore store; + late ImportAppleHealthExportUseCase useCase; + late List zipBytes; + + setUp(() async { + db = ImportedHealthDatabase(NativeDatabase.memory()); + store = DriftImportedHealthStore(db); + useCase = ImportAppleHealthExportUseCase( + store: store, + importWorkout: ImportWorkoutUseCase(store), + ); + + final xml = await File('test/fixtures/apple_health_export.xml') + .readAsString(); + final gpx = + await File('test/fixtures/sample_run.gpx').readAsString(); + final archive = Archive() + ..addFile( + ArchiveFile('export.xml', xml.length, xml.codeUnits), + ) + ..addFile( + ArchiveFile( + 'workout-routes/route_2026-04-20.gpx', + gpx.length, + gpx.codeUnits, + ), + ); + zipBytes = ZipEncoder().encode(archive)!; + }); + + tearDown(() async { + await db.close(); + }); + + test('persists daily summaries and running workouts', () async { + final result = await useCase( + zipBytes: zipBytes, + now: DateTime(2026, 4, 22), + ); + + expect(result.failure, isNull); + expect(result.importedDays, greaterThan(0)); + expect(result.importedWorkouts, 1); + expect(await store.workoutCount(), 1); + + final summaries = await store.getSummaries( + since: DateTime(2026, 4, 1), + ); + expect(summaries, isNotEmpty); + expect(summaries.first.steps, 8421); + }); + }); +} diff --git a/test/features/settings/health_import_page_test.dart b/test/features/settings/health_import_page_test.dart index b7443e5..845e8ba 100644 --- a/test/features/settings/health_import_page_test.dart +++ b/test/features/settings/health_import_page_test.dart @@ -13,8 +13,8 @@ void main() { ), ); - expect(find.text('Import Run'), findsOneWidget); - expect(find.text('Choose GPX file'), findsOneWidget); + expect(find.text('Import Health Data'), findsOneWidget); + expect(find.text('Choose export.zip or GPX'), findsOneWidget); expect(find.textContaining('Sideloaded apps'), findsOneWidget); }); } diff --git a/test/fixtures/apple_health_export.xml b/test/fixtures/apple_health_export.xml new file mode 100644 index 0000000..382e724 --- /dev/null +++ b/test/fixtures/apple_health_export.xml @@ -0,0 +1,18 @@ + + +]> + + + + + + + + + + + + + + diff --git a/test/infrastructure/health/import/apple_health_export_parser_test.dart b/test/infrastructure/health/import/apple_health_export_parser_test.dart new file mode 100644 index 0000000..cfdc5de --- /dev/null +++ b/test/infrastructure/health/import/apple_health_export_parser_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_export_parser.dart'; + +void main() { + group('AppleHealthExportParser', () { + late List zipBytes; + + setUp(() async { + final xml = await File('test/fixtures/apple_health_export.xml') + .readAsString(); + final gpx = + await File('test/fixtures/sample_run.gpx').readAsString(); + + final archive = Archive() + ..addFile( + ArchiveFile( + 'apple_health_export/export.xml', + xml.length, + xml.codeUnits, + ), + ) + ..addFile( + ArchiveFile( + 'apple_health_export/workout-routes/route_2026-04-20.gpx', + gpx.length, + gpx.codeUnits, + ), + ); + + zipBytes = ZipEncoder().encode(archive)!; + }); + + test('parses metrics, summaries, and running workouts with routes', () { + const parser = AppleHealthExportParser(); + final result = parser.parseZip(zipBytes); + + expect(result.recordCount, 4); + expect(result.summaries, isNotEmpty); + expect(result.workouts, hasLength(1)); + + final summary = result.summaries.first; + expect(summary.steps, 8421); + expect(summary.hrvMs, 62); + expect(summary.activeCalories, greaterThan(0)); + expect(summary.sleepHours, closeTo(7.5, 0.1)); + + final workout = result.workouts.first.workout; + expect(workout.distanceMeters, closeTo(5200, 1)); + expect(workout.energyKcal, 310); + expect(result.workouts.first.routePoints, isNotEmpty); + }); + + test('throws when export.xml is missing', () { + const parser = AppleHealthExportParser(); + final emptyZip = ZipEncoder().encode(Archive())!; + + expect( + () => parser.parseZip(emptyZip), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/infrastructure/health/import/apple_health_record_aggregator_test.dart b/test/infrastructure/health/import/apple_health_record_aggregator_test.dart new file mode 100644 index 0000000..67ad88e --- /dev/null +++ b/test/infrastructure/health/import/apple_health_record_aggregator_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_record_aggregator.dart'; + +void main() { + group('AppleHealthRecordAggregator', () { + test('aggregates steps, HRV, sleep, and activity ring data', () { + final aggregator = AppleHealthRecordAggregator(); + + aggregator + ..addRecord( + type: 'HKQuantityTypeIdentifierStepCount', + value: '5000', + unit: 'count', + startDate: '2026-04-20 08:00:00 +0000', + endDate: '2026-04-20 08:05:00 +0000', + ) + ..addRecord( + type: 'HKQuantityTypeIdentifierHeartRateVariabilitySDNN', + value: '55', + unit: 'ms', + startDate: '2026-04-20 07:30:00 +0000', + endDate: '2026-04-20 07:30:00 +0000', + ) + ..addRecord( + type: 'HKCategoryTypeIdentifierSleepAnalysis', + value: 'HKCategoryValueSleepAnalysisAsleep', + startDate: '2026-04-20 23:00:00 +0000', + endDate: '2026-04-21 07:00:00 +0000', + ) + ..addActivitySummary( + dateComponents: '2026-04-20', + activeEnergyBurned: '400', + activeEnergyBurnedUnit: 'kcal', + appleExerciseTime: '30', + ); + + final summaries = aggregator.finalize(); + expect(summaries, hasLength(1)); + + final summary = summaries.first; + expect(summary.steps, 5000); + expect(summary.hrvMs, 55); + expect(summary.sleepHours, 8); + expect(summary.activeCalories, 400); + expect(summary.exerciseMinutes, 30); + }); + }); +} From 454b55e3acb273651a0705770fc22da6edc4166a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 15:31:26 +0000 Subject: [PATCH 2/3] fix: remove unnecessary null assertions failing CI analyze Co-authored-by: Youri Bontekoe --- .../health/import_apple_health_export_usecase_test.dart | 2 +- .../health/import/apple_health_export_parser_test.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/domain/usecases/health/import_apple_health_export_usecase_test.dart b/test/domain/usecases/health/import_apple_health_export_usecase_test.dart index e125215..7f305b6 100644 --- a/test/domain/usecases/health/import_apple_health_export_usecase_test.dart +++ b/test/domain/usecases/health/import_apple_health_export_usecase_test.dart @@ -38,7 +38,7 @@ void main() { gpx.codeUnits, ), ); - zipBytes = ZipEncoder().encode(archive)!; + zipBytes = ZipEncoder().encode(archive); }); tearDown(() async { diff --git a/test/infrastructure/health/import/apple_health_export_parser_test.dart b/test/infrastructure/health/import/apple_health_export_parser_test.dart index cfdc5de..8a7e674 100644 --- a/test/infrastructure/health/import/apple_health_export_parser_test.dart +++ b/test/infrastructure/health/import/apple_health_export_parser_test.dart @@ -30,7 +30,7 @@ void main() { ), ); - zipBytes = ZipEncoder().encode(archive)!; + zipBytes = ZipEncoder().encode(archive); }); test('parses metrics, summaries, and running workouts with routes', () { @@ -55,7 +55,7 @@ void main() { test('throws when export.xml is missing', () { const parser = AppleHealthExportParser(); - final emptyZip = ZipEncoder().encode(Archive())!; + final emptyZip = ZipEncoder().encode(Archive()); expect( () => parser.parseZip(emptyZip), From d78c6a2829b8524efd15a72970c82b34753fb9f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 16:06:52 +0000 Subject: [PATCH 3/3] fix: address Apple Health import review feedback - Parse export.zip in a background isolate via Isolate.run - Read picked files from path on native (withData only on web) - Decode XML/GPX as UTF-8 instead of String.fromCharCodes - Prefer ActivitySummary over duplicate Record rollups per day - Extract preview cards into settings presentation widgets - Add negative-path and UTF-8 regression tests Co-authored-by: Youri Bontekoe --- .../import_apple_health_export_usecase.dart | 9 +- .../pages/health_import_page.dart | 162 ++++-------------- .../apple_health_export_preview_card.dart | 67 ++++++++ .../widgets/gpx_import_preview_card.dart | 73 ++++++++ .../widgets/health_import_preview_row.dart | 27 +++ .../import/apple_health_export_isolate.dart | 15 ++ .../import/apple_health_export_parser.dart | 90 ++-------- .../apple_health_record_aggregator.dart | 13 +- .../import/apple_health_workout_builder.dart | 48 ++++++ lib/shared/utils/picked_file_bytes.dart | 2 + lib/shared/utils/picked_file_bytes_io.dart | 15 ++ lib/shared/utils/picked_file_bytes_web.dart | 9 + ...port_apple_health_export_usecase_test.dart | 67 +++++++- test/fixtures/apple_health_export.xml | 4 +- .../apple_health_export_parser_test.dart | 27 ++- .../apple_health_record_aggregator_test.dart | 30 ++++ 16 files changed, 443 insertions(+), 215 deletions(-) create mode 100644 lib/features/settings/presentation/widgets/apple_health_export_preview_card.dart create mode 100644 lib/features/settings/presentation/widgets/gpx_import_preview_card.dart create mode 100644 lib/features/settings/presentation/widgets/health_import_preview_row.dart create mode 100644 lib/infrastructure/health/import/apple_health_export_isolate.dart create mode 100644 lib/infrastructure/health/import/apple_health_workout_builder.dart create mode 100644 lib/shared/utils/picked_file_bytes.dart create mode 100644 lib/shared/utils/picked_file_bytes_io.dart create mode 100644 lib/shared/utils/picked_file_bytes_web.dart diff --git a/lib/domain/usecases/health/import_apple_health_export_usecase.dart b/lib/domain/usecases/health/import_apple_health_export_usecase.dart index 6d333fc..21d5b2c 100644 --- a/lib/domain/usecases/health/import_apple_health_export_usecase.dart +++ b/lib/domain/usecases/health/import_apple_health_export_usecase.dart @@ -1,6 +1,6 @@ import 'package:kynos/core/errors/failures.dart'; import 'package:kynos/domain/usecases/health/import_workout_usecase.dart'; -import 'package:kynos/infrastructure/health/import/apple_health_export_parser.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_export_isolate.dart'; import 'package:kynos/infrastructure/health/imported_health_store.dart'; /// Result of importing an Apple Health `export.zip` archive. @@ -25,21 +25,18 @@ class ImportAppleHealthExportUseCase { const ImportAppleHealthExportUseCase({ required ImportedHealthStore store, required ImportWorkoutUseCase importWorkout, - AppleHealthExportParser? parser, }) : _store = store, - _importWorkout = importWorkout, - _parser = parser ?? const AppleHealthExportParser(); + _importWorkout = importWorkout; final ImportedHealthStore _store; final ImportWorkoutUseCase _importWorkout; - final AppleHealthExportParser _parser; Future call({ required List zipBytes, DateTime? now, }) async { try { - final parsed = _parser.parseZip(zipBytes); + final parsed = await parseAppleHealthZipAsync(zipBytes); await _store.saveSummaries(parsed.summaries); var importedWorkouts = 0; diff --git a/lib/features/settings/presentation/pages/health_import_page.dart b/lib/features/settings/presentation/pages/health_import_page.dart index d18d3c7..0041781 100644 --- a/lib/features/settings/presentation/pages/health_import_page.dart +++ b/lib/features/settings/presentation/pages/health_import_page.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gap/gap.dart'; @@ -6,11 +9,14 @@ import 'package:go_router/go_router.dart'; import 'package:kynos/app/router.dart'; import 'package:kynos/core/theme/spacing.dart' as tokens; import 'package:kynos/core/theme/theme.dart'; +import 'package:kynos/features/settings/presentation/widgets/apple_health_export_preview_card.dart'; +import 'package:kynos/features/settings/presentation/widgets/gpx_import_preview_card.dart'; import 'package:kynos/infrastructure/health/health_infrastructure_providers.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_export_isolate.dart'; import 'package:kynos/infrastructure/health/import/apple_health_export_parser.dart'; import 'package:kynos/infrastructure/health/import/gpx_workout_parser.dart'; import 'package:kynos/shared/providers/health_providers.dart'; -import 'package:kynos/shared/widgets/kynos_card.dart'; +import 'package:kynos/shared/utils/picked_file_bytes.dart'; class HealthImportPage extends ConsumerStatefulWidget { const HealthImportPage({super.key}); @@ -22,7 +28,7 @@ class HealthImportPage extends ConsumerStatefulWidget { class _HealthImportPageState extends ConsumerState { GpxParseResult? _gpxPreview; AppleHealthExportParseResult? _zipPreview; - List? _zipBytes; + PlatformFile? _pickedFile; String? _error; bool _isImporting = false; @@ -30,37 +36,39 @@ class _HealthImportPageState extends ConsumerState { setState(() { _gpxPreview = null; _zipPreview = null; - _zipBytes = null; + _pickedFile = null; _error = null; }); final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: const ['gpx', 'zip'], - withData: true, + withData: kIsWeb, ); if (!mounted || result == null || result.files.isEmpty) return; final file = result.files.single; - final bytes = file.bytes; - if (bytes == null) { - setState(() => _error = 'Could not read the selected file.'); - return; - } - final extension = file.extension?.toLowerCase(); + try { + final bytes = await readPickedFileBytes(file); if (extension == 'zip') { - final parsed = const AppleHealthExportParser().parseZip(bytes); + final parsed = await parseAppleHealthZipAsync(bytes); + if (!mounted) return; setState(() { _zipPreview = parsed; - _zipBytes = bytes; + _pickedFile = file; }); } else { - final content = String.fromCharCodes(bytes); - final parsed = const GpxWorkoutParser().parse(content); - setState(() => _gpxPreview = parsed); + final parsed = const GpxWorkoutParser().parse( + utf8.decode(bytes, allowMalformed: true), + ); + if (!mounted) return; + setState(() { + _gpxPreview = parsed; + _pickedFile = file; + }); } } on FormatException catch (e) { setState(() => _error = e.message); @@ -72,8 +80,8 @@ class _HealthImportPageState extends ConsumerState { Future _confirmImport() async { setState(() => _isImporting = true); - if (_zipPreview != null && _zipBytes != null) { - await _importZip(_zipBytes!); + if (_zipPreview != null && _pickedFile != null) { + await _importZip(_pickedFile!); } else if (_gpxPreview != null) { await _importGpx(); } @@ -113,7 +121,8 @@ class _HealthImportPageState extends ConsumerState { } } - Future _importZip(List bytes) async { + Future _importZip(PlatformFile file) async { + final bytes = await readPickedFileBytes(file); final useCase = ref.read(importAppleHealthExportUseCaseProvider); final result = await useCase(zipBytes: bytes); @@ -148,8 +157,6 @@ class _HealthImportPageState extends ConsumerState { @override Widget build(BuildContext context) { final kynos = context.kynosTheme; - final zipPreview = _zipPreview; - final gpxPreview = _gpxPreview; return Scaffold( backgroundColor: kynos.background, @@ -184,119 +191,24 @@ class _HealthImportPageState extends ConsumerState { ), ), ], - if (zipPreview != null) ...[ + if (_zipPreview != null) ...[ const Gap(tokens.Spacing.lg), - KynosCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Apple Health export preview', - style: Theme.of(context).textTheme.titleMedium, - ), - const Gap(tokens.Spacing.sm), - _PreviewRow( - label: 'Health records', - value: '${zipPreview.recordCount}', - ), - _PreviewRow( - label: 'Daily summaries', - value: '${zipPreview.summaries.length}', - ), - _PreviewRow( - label: 'Running workouts', - value: '${zipPreview.workouts.length}', - ), - _PreviewRow( - label: 'Runs with GPS routes', - value: - '${zipPreview.workouts.where((w) => w.routePoints.isNotEmpty).length}', - ), - if (zipPreview.skippedWorkouts > 0) - _PreviewRow( - label: 'Skipped workouts', - value: '${zipPreview.skippedWorkouts}', - ), - ], - ), - ), - const Gap(tokens.Spacing.md), - FilledButton( - onPressed: _isImporting ? null : _confirmImport, - child: Text(_isImporting ? 'Importing…' : 'Import all data'), + AppleHealthExportPreviewCard( + preview: _zipPreview!, + isImporting: _isImporting, + onImport: _confirmImport, ), ], - if (gpxPreview != null) ...[ + if (_gpxPreview != null) ...[ const Gap(tokens.Spacing.lg), - KynosCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'GPX preview', - style: Theme.of(context).textTheme.titleMedium, - ), - const Gap(tokens.Spacing.sm), - _PreviewRow( - label: 'Date', - value: _formatDate(gpxPreview.workout.start), - ), - _PreviewRow( - label: 'Duration', - value: _formatDuration(gpxPreview.workout.duration), - ), - _PreviewRow( - label: 'Distance', - value: - '${((gpxPreview.workout.distanceMeters ?? 0) / 1000).toStringAsFixed(2)} km', - ), - _PreviewRow( - label: 'Route points', - value: '${gpxPreview.routePoints.length}', - ), - ], - ), - ), - const Gap(tokens.Spacing.md), - FilledButton( - onPressed: _isImporting ? null : _confirmImport, - child: Text(_isImporting ? 'Importing…' : 'Confirm import'), + GpxImportPreviewCard( + preview: _gpxPreview!, + isImporting: _isImporting, + onImport: _confirmImport, ), ], ], ), ); } - - String _formatDate(DateTime date) { - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ' - '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - } - - String _formatDuration(Duration duration) { - final minutes = duration.inMinutes; - final seconds = duration.inSeconds % 60; - return '${minutes}m ${seconds}s'; - } -} - -class _PreviewRow extends StatelessWidget { - const _PreviewRow({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: tokens.Spacing.xs), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: Theme.of(context).textTheme.bodyMedium), - Text(value, style: Theme.of(context).textTheme.titleSmall), - ], - ), - ); - } } diff --git a/lib/features/settings/presentation/widgets/apple_health_export_preview_card.dart b/lib/features/settings/presentation/widgets/apple_health_export_preview_card.dart new file mode 100644 index 0000000..7a84e10 --- /dev/null +++ b/lib/features/settings/presentation/widgets/apple_health_export_preview_card.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:kynos/core/theme/spacing.dart' as tokens; +import 'package:kynos/features/settings/presentation/widgets/health_import_preview_row.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_export_parser.dart'; +import 'package:kynos/shared/widgets/kynos_card.dart'; + +class AppleHealthExportPreviewCard extends StatelessWidget { + const AppleHealthExportPreviewCard({ + super.key, + required this.preview, + required this.isImporting, + required this.onImport, + }); + + final AppleHealthExportParseResult preview; + final bool isImporting; + final VoidCallback onImport; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + KynosCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Apple Health export preview', + style: Theme.of(context).textTheme.titleMedium, + ), + const Gap(tokens.Spacing.sm), + HealthImportPreviewRow( + label: 'Health records', + value: '${preview.recordCount}', + ), + HealthImportPreviewRow( + label: 'Daily summaries', + value: '${preview.summaries.length}', + ), + HealthImportPreviewRow( + label: 'Running workouts', + value: '${preview.workouts.length}', + ), + HealthImportPreviewRow( + label: 'Runs with GPS routes', + value: + '${preview.workouts.where((w) => w.routePoints.isNotEmpty).length}', + ), + if (preview.skippedWorkouts > 0) + HealthImportPreviewRow( + label: 'Skipped workouts', + value: '${preview.skippedWorkouts}', + ), + ], + ), + ), + const Gap(tokens.Spacing.md), + FilledButton( + onPressed: isImporting ? null : onImport, + child: Text(isImporting ? 'Importing…' : 'Import all data'), + ), + ], + ); + } +} diff --git a/lib/features/settings/presentation/widgets/gpx_import_preview_card.dart b/lib/features/settings/presentation/widgets/gpx_import_preview_card.dart new file mode 100644 index 0000000..48028ca --- /dev/null +++ b/lib/features/settings/presentation/widgets/gpx_import_preview_card.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:kynos/core/theme/spacing.dart' as tokens; +import 'package:kynos/features/settings/presentation/widgets/health_import_preview_row.dart'; +import 'package:kynos/infrastructure/health/import/gpx_workout_parser.dart'; +import 'package:kynos/shared/widgets/kynos_card.dart'; + +class GpxImportPreviewCard extends StatelessWidget { + const GpxImportPreviewCard({ + super.key, + required this.preview, + required this.isImporting, + required this.onImport, + }); + + final GpxParseResult preview; + final bool isImporting; + final VoidCallback onImport; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + KynosCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'GPX preview', + style: Theme.of(context).textTheme.titleMedium, + ), + const Gap(tokens.Spacing.sm), + HealthImportPreviewRow( + label: 'Date', + value: _formatDate(preview.workout.start), + ), + HealthImportPreviewRow( + label: 'Duration', + value: _formatDuration(preview.workout.duration), + ), + HealthImportPreviewRow( + label: 'Distance', + value: + '${((preview.workout.distanceMeters ?? 0) / 1000).toStringAsFixed(2)} km', + ), + HealthImportPreviewRow( + label: 'Route points', + value: '${preview.routePoints.length}', + ), + ], + ), + ), + const Gap(tokens.Spacing.md), + FilledButton( + onPressed: isImporting ? null : onImport, + child: Text(isImporting ? 'Importing…' : 'Confirm import'), + ), + ], + ); + } + + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ' + '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + + String _formatDuration(Duration duration) { + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return '${minutes}m ${seconds}s'; + } +} diff --git a/lib/features/settings/presentation/widgets/health_import_preview_row.dart b/lib/features/settings/presentation/widgets/health_import_preview_row.dart new file mode 100644 index 0000000..c2e7bc7 --- /dev/null +++ b/lib/features/settings/presentation/widgets/health_import_preview_row.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:kynos/core/theme/spacing.dart' as tokens; + +class HealthImportPreviewRow extends StatelessWidget { + const HealthImportPreviewRow({ + super.key, + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: tokens.Spacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: Theme.of(context).textTheme.bodyMedium), + Text(value, style: Theme.of(context).textTheme.titleSmall), + ], + ), + ); + } +} diff --git a/lib/infrastructure/health/import/apple_health_export_isolate.dart b/lib/infrastructure/health/import/apple_health_export_isolate.dart new file mode 100644 index 0000000..7f297c6 --- /dev/null +++ b/lib/infrastructure/health/import/apple_health_export_isolate.dart @@ -0,0 +1,15 @@ +import 'dart:isolate'; + +import 'package:kynos/infrastructure/health/import/apple_health_export_parser.dart'; + +/// Parses Apple Health zip bytes off the UI isolate. +Future parseAppleHealthZipAsync( + List zipBytes, +) { + return Isolate.run(() => parseAppleHealthZipBytes(zipBytes)); +} + +/// Top-level entry point for [Isolate.run]. +AppleHealthExportParseResult parseAppleHealthZipBytes(List zipBytes) { + return const AppleHealthExportParser().parseZip(zipBytes); +} diff --git a/lib/infrastructure/health/import/apple_health_export_parser.dart b/lib/infrastructure/health/import/apple_health_export_parser.dart index 4864dc5..a53d999 100644 --- a/lib/infrastructure/health/import/apple_health_export_parser.dart +++ b/lib/infrastructure/health/import/apple_health_export_parser.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:archive/archive.dart'; import 'package:kynos/core/constants/imported_workout_ids.dart'; import 'package:kynos/domain/entities/health_summary.dart'; @@ -5,7 +7,7 @@ import 'package:kynos/domain/entities/workout_route_point.dart'; import 'package:kynos/domain/entities/workout_session.dart'; import 'package:kynos/infrastructure/health/import/apple_health_date_parser.dart'; import 'package:kynos/infrastructure/health/import/apple_health_record_aggregator.dart'; -import 'package:kynos/infrastructure/health/import/apple_health_unit_converter.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_workout_builder.dart'; import 'package:kynos/infrastructure/health/import/gpx_workout_parser.dart'; import 'package:xml/xml_events.dart'; @@ -75,7 +77,7 @@ class AppleHealthExportParser { for (final entry in files.entries) { final name = entry.key.toLowerCase(); if (name.endsWith('export.xml') && !name.endsWith('export_cda.xml')) { - return String.fromCharCodes(entry.value); + return utf8.decode(entry.value, allowMalformed: true); } } return null; @@ -91,7 +93,7 @@ class AppleHealthExportParser { var recordCount = 0; var skippedWorkouts = 0; - _WorkoutBuilder? currentWorkout; + AppleHealthWorkoutBuilder? currentWorkout; var workoutDepth = 0; for (final event in parseEvents(xml, validateNesting: true)) { @@ -100,27 +102,28 @@ class AppleHealthExportParser { case 'Record': recordCount += 1; aggregator.addRecord( - type: _attr(event, 'type') ?? '', - value: _attr(event, 'value'), - unit: _attr(event, 'unit'), - startDate: _attr(event, 'startDate') ?? '', - endDate: _attr(event, 'endDate') ?? '', + type: xmlEventAttr(event, 'type') ?? '', + value: xmlEventAttr(event, 'value'), + unit: xmlEventAttr(event, 'unit'), + startDate: xmlEventAttr(event, 'startDate') ?? '', + endDate: xmlEventAttr(event, 'endDate') ?? '', ); case 'ActivitySummary': aggregator.addActivitySummary( - dateComponents: _attr(event, 'dateComponents') ?? '', - activeEnergyBurned: _attr(event, 'activeEnergyBurned'), - activeEnergyBurnedUnit: _attr(event, 'activeEnergyBurnedUnit'), - appleExerciseTime: _attr(event, 'appleExerciseTime'), + dateComponents: xmlEventAttr(event, 'dateComponents') ?? '', + activeEnergyBurned: xmlEventAttr(event, 'activeEnergyBurned'), + activeEnergyBurnedUnit: + xmlEventAttr(event, 'activeEnergyBurnedUnit'), + appleExerciseTime: xmlEventAttr(event, 'appleExerciseTime'), ); case 'Workout': workoutDepth += 1; if (workoutDepth == 1) { - currentWorkout = _WorkoutBuilder.fromAttributes(event); + currentWorkout = AppleHealthWorkoutBuilder.fromAttributes(event); } case 'FileReference': if (currentWorkout != null && workoutDepth > 0) { - final path = _attr(event, 'path'); + final path = xmlEventAttr(event, 'path'); if (path != null) { currentWorkout.routePaths.add(path); } @@ -149,7 +152,7 @@ class AppleHealthExportParser { } AppleHealthWorkoutImport? _finalizeWorkout( - _WorkoutBuilder builder, + AppleHealthWorkoutBuilder builder, Map> files, ) { if (!builder.isRunning) { @@ -173,7 +176,7 @@ class AppleHealthExportParser { try { final parsed = _gpxParser.parse( - String.fromCharCodes(gpxBytes), + utf8.decode(gpxBytes, allowMalformed: true), sourceName: builder.sourceName ?? 'Apple Health', ); routePoints = parsed.routePoints; @@ -234,59 +237,4 @@ class AppleHealthExportParser { return null; } - - String? _attr(XmlStartElementEvent event, String name) { - for (final attribute in event.attributes) { - if (attribute.name == name) { - return attribute.value; - } - } - return null; - } -} - -class _WorkoutBuilder { - _WorkoutBuilder({ - required this.activityType, - this.start, - this.end, - this.distanceMeters, - this.energyKcal, - this.sourceName, - this.routePaths = const [], - }); - - factory _WorkoutBuilder.fromAttributes(XmlStartElementEvent event) { - String? read(String name) { - for (final attribute in event.attributes) { - if (attribute.name == name) { - return attribute.value; - } - } - return null; - } - - final distance = double.tryParse(read('totalDistance') ?? ''); - final energy = double.tryParse(read('totalEnergyBurned') ?? ''); - - return _WorkoutBuilder( - activityType: read('workoutActivityType') ?? '', - start: parseAppleHealthDate(read('startDate')), - end: parseAppleHealthDate(read('endDate')), - distanceMeters: toMeters(distance, read('totalDistanceUnit')), - energyKcal: toKilocalories(energy, read('totalEnergyBurnedUnit')), - sourceName: read('sourceName'), - routePaths: [], - ); - } - - final String activityType; - final DateTime? start; - final DateTime? end; - final double? distanceMeters; - final double? energyKcal; - final String? sourceName; - final List routePaths; - - bool get isRunning => activityType.toUpperCase().contains('RUNNING'); } diff --git a/lib/infrastructure/health/import/apple_health_record_aggregator.dart b/lib/infrastructure/health/import/apple_health_record_aggregator.dart index 9a5a346..94f8f72 100644 --- a/lib/infrastructure/health/import/apple_health_record_aggregator.dart +++ b/lib/infrastructure/health/import/apple_health_record_aggregator.dart @@ -37,7 +37,9 @@ class AppleHealthRecordAggregator { case 'HKCategoryTypeIdentifierSleepAnalysis': _addSleep(acc, value, startDate, endDate); case 'HKQuantityTypeIdentifierActiveEnergyBurned': - if (numeric != null) acc.activeCalories += toKilocalories(numeric, unit)!; + if (!acc.hasActivitySummary && numeric != null) { + acc.activeCalories += toKilocalories(numeric, unit)!; + } case 'HKQuantityTypeIdentifierBasalEnergyBurned': if (numeric != null) acc.basalCalories += toKilocalories(numeric, unit)!; case 'HKQuantityTypeIdentifierStepCount': @@ -49,7 +51,7 @@ class AppleHealthRecordAggregator { case 'HKQuantityTypeIdentifierFlightsClimbed': if (numeric != null) acc.flightsClimbed += numeric; case 'HKQuantityTypeIdentifierAppleExerciseTime': - if (numeric != null) { + if (!acc.hasActivitySummary && numeric != null) { acc.exerciseMinutes += toMinutes(numeric, unit) ?? numeric; } case 'HKQuantityTypeIdentifierRunningPower': @@ -77,15 +79,16 @@ class AppleHealthRecordAggregator { } final acc = _byDay.putIfAbsent(day, () => _DailyAccumulator(date: day)); + acc.hasActivitySummary = true; final energy = double.tryParse(activeEnergyBurned ?? ''); if (energy != null) { - acc.activeCalories += toKilocalories(energy, activeEnergyBurnedUnit) ?? energy; + acc.activeCalories = toKilocalories(energy, activeEnergyBurnedUnit) ?? energy; } final exercise = double.tryParse(appleExerciseTime ?? ''); if (exercise != null) { - acc.exerciseMinutes += exercise; + acc.exerciseMinutes = exercise; } } @@ -148,6 +151,8 @@ class _DailyAccumulator { final DateTime date; + bool hasActivitySummary = false; + double _hrvSum = 0; int _hrvCount = 0; double _rhrSum = 0; diff --git a/lib/infrastructure/health/import/apple_health_workout_builder.dart b/lib/infrastructure/health/import/apple_health_workout_builder.dart new file mode 100644 index 0000000..25aaaf4 --- /dev/null +++ b/lib/infrastructure/health/import/apple_health_workout_builder.dart @@ -0,0 +1,48 @@ +import 'package:kynos/infrastructure/health/import/apple_health_date_parser.dart'; +import 'package:kynos/infrastructure/health/import/apple_health_unit_converter.dart'; +import 'package:xml/xml_events.dart'; + +String? xmlEventAttr(XmlStartElementEvent event, String name) { + for (final attribute in event.attributes) { + if (attribute.name == name) { + return attribute.value; + } + } + return null; +} + +class AppleHealthWorkoutBuilder { + AppleHealthWorkoutBuilder({ + required this.activityType, + this.start, + this.end, + this.distanceMeters, + this.energyKcal, + this.sourceName, + List? routePaths, + }) : routePaths = routePaths ?? []; + + factory AppleHealthWorkoutBuilder.fromAttributes(XmlStartElementEvent event) { + final distance = double.tryParse(xmlEventAttr(event, 'totalDistance') ?? ''); + final energy = double.tryParse(xmlEventAttr(event, 'totalEnergyBurned') ?? ''); + + return AppleHealthWorkoutBuilder( + activityType: xmlEventAttr(event, 'workoutActivityType') ?? '', + start: parseAppleHealthDate(xmlEventAttr(event, 'startDate')), + end: parseAppleHealthDate(xmlEventAttr(event, 'endDate')), + distanceMeters: toMeters(distance, xmlEventAttr(event, 'totalDistanceUnit')), + energyKcal: toKilocalories(energy, xmlEventAttr(event, 'totalEnergyBurnedUnit')), + sourceName: xmlEventAttr(event, 'sourceName'), + ); + } + + final String activityType; + final DateTime? start; + final DateTime? end; + final double? distanceMeters; + final double? energyKcal; + final String? sourceName; + final List routePaths; + + bool get isRunning => activityType.toUpperCase().contains('RUNNING'); +} diff --git a/lib/shared/utils/picked_file_bytes.dart b/lib/shared/utils/picked_file_bytes.dart new file mode 100644 index 0000000..f4d1cb9 --- /dev/null +++ b/lib/shared/utils/picked_file_bytes.dart @@ -0,0 +1,2 @@ +export 'picked_file_bytes_web.dart' + if (dart.library.io) 'picked_file_bytes_io.dart'; diff --git a/lib/shared/utils/picked_file_bytes_io.dart b/lib/shared/utils/picked_file_bytes_io.dart new file mode 100644 index 0000000..e0cfdc3 --- /dev/null +++ b/lib/shared/utils/picked_file_bytes_io.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; + +Future> readPickedFileBytes(PlatformFile file) async { + final path = file.path; + if (path != null) { + return File(path).readAsBytes(); + } + final bytes = file.bytes; + if (bytes != null) { + return bytes; + } + throw const FormatException('Could not read the selected file.'); +} diff --git a/lib/shared/utils/picked_file_bytes_web.dart b/lib/shared/utils/picked_file_bytes_web.dart new file mode 100644 index 0000000..5f27f0a --- /dev/null +++ b/lib/shared/utils/picked_file_bytes_web.dart @@ -0,0 +1,9 @@ +import 'package:file_picker/file_picker.dart'; + +Future> readPickedFileBytes(PlatformFile file) async { + final bytes = file.bytes; + if (bytes != null) { + return bytes; + } + throw const FormatException('Could not read the selected file.'); +} diff --git a/test/domain/usecases/health/import_apple_health_export_usecase_test.dart b/test/domain/usecases/health/import_apple_health_export_usecase_test.dart index 7f305b6..723c337 100644 --- a/test/domain/usecases/health/import_apple_health_export_usecase_test.dart +++ b/test/domain/usecases/health/import_apple_health_export_usecase_test.dart @@ -1,12 +1,18 @@ +import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:kynos/core/errors/failures.dart'; +import 'package:kynos/domain/entities/health_summary.dart'; +import 'package:kynos/domain/entities/workout_route_point.dart'; +import 'package:kynos/domain/entities/workout_session.dart'; import 'package:kynos/domain/usecases/health/import_apple_health_export_usecase.dart'; import 'package:kynos/domain/usecases/health/import_workout_usecase.dart'; import 'package:kynos/infrastructure/health/drift_imported_health_store.dart'; import 'package:kynos/infrastructure/health/imported_health_database.dart'; +import 'package:kynos/infrastructure/health/imported_health_store.dart'; void main() { group('ImportAppleHealthExportUseCase', () { @@ -29,13 +35,13 @@ void main() { await File('test/fixtures/sample_run.gpx').readAsString(); final archive = Archive() ..addFile( - ArchiveFile('export.xml', xml.length, xml.codeUnits), + ArchiveFile('export.xml', utf8.encode(xml).length, utf8.encode(xml)), ) ..addFile( ArchiveFile( 'workout-routes/route_2026-04-20.gpx', - gpx.length, - gpx.codeUnits, + utf8.encode(gpx).length, + utf8.encode(gpx), ), ); zipBytes = ZipEncoder().encode(archive); @@ -62,5 +68,60 @@ void main() { expect(summaries, isNotEmpty); expect(summaries.first.steps, 8421); }); + + test('maps missing export.xml to HealthDataFailure', () async { + final emptyZip = ZipEncoder().encode(Archive()); + final result = await useCase(zipBytes: emptyZip); + + expect(result.importedWorkouts, 0); + expect(result.failure, isA()); + }); + + test('maps store failures to StorageFailure', () async { + final failingUseCase = ImportAppleHealthExportUseCase( + store: _FailingImportedHealthStore(), + importWorkout: ImportWorkoutUseCase(_FailingImportedHealthStore()), + ); + + final result = await failingUseCase(zipBytes: zipBytes); + + expect(result.importedWorkouts, 0); + expect(result.failure, isA()); + }); }); } + +class _FailingImportedHealthStore implements ImportedHealthStore { + @override + Future clearAll() => throw UnimplementedError(); + + @override + Future> getSummaries({required DateTime since}) => + throw UnimplementedError(); + + @override + Future> getRoutePoints(String workoutId) => + throw UnimplementedError(); + + @override + Future> getWorkouts({ + required DateTime since, + int? limit, + }) => + throw UnimplementedError(); + + @override + Future saveSummaries(List summaries) async { + throw Exception('disk full'); + } + + @override + Future saveWorkout({ + required WorkoutSession workout, + List routePoints = const [], + }) => + throw UnimplementedError(); + + @override + Future workoutCount() => throw UnimplementedError(); +} diff --git a/test/fixtures/apple_health_export.xml b/test/fixtures/apple_health_export.xml index 382e724..db92c20 100644 --- a/test/fixtures/apple_health_export.xml +++ b/test/fixtures/apple_health_export.xml @@ -5,8 +5,8 @@ - - + + diff --git a/test/infrastructure/health/import/apple_health_export_parser_test.dart b/test/infrastructure/health/import/apple_health_export_parser_test.dart index 8a7e674..5c9e6b5 100644 --- a/test/infrastructure/health/import/apple_health_export_parser_test.dart +++ b/test/infrastructure/health/import/apple_health_export_parser_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; @@ -18,15 +19,15 @@ void main() { ..addFile( ArchiveFile( 'apple_health_export/export.xml', - xml.length, - xml.codeUnits, + utf8.encode(xml).length, + utf8.encode(xml), ), ) ..addFile( ArchiveFile( 'apple_health_export/workout-routes/route_2026-04-20.gpx', - gpx.length, - gpx.codeUnits, + utf8.encode(gpx).length, + utf8.encode(gpx), ), ); @@ -50,9 +51,27 @@ void main() { final workout = result.workouts.first.workout; expect(workout.distanceMeters, closeTo(5200, 1)); expect(workout.energyKcal, 310); + expect(workout.sourceName, 'Apple Watch'); expect(result.workouts.first.routePoints, isNotEmpty); }); + test('preserves non-ASCII metadata in export.xml', () { + const parser = AppleHealthExportParser(); + final xml = utf8.encode( + ''' + + +''', + ); + final archive = Archive() + ..addFile(ArchiveFile('export.xml', xml.length, xml)); + final result = parser.parseZip(ZipEncoder().encode(archive)); + + expect(result.summaries.first.steps, 10); + }); + test('throws when export.xml is missing', () { const parser = AppleHealthExportParser(); final emptyZip = ZipEncoder().encode(Archive()); diff --git a/test/infrastructure/health/import/apple_health_record_aggregator_test.dart b/test/infrastructure/health/import/apple_health_record_aggregator_test.dart index 67ad88e..e661d0d 100644 --- a/test/infrastructure/health/import/apple_health_record_aggregator_test.dart +++ b/test/infrastructure/health/import/apple_health_record_aggregator_test.dart @@ -44,5 +44,35 @@ void main() { expect(summary.activeCalories, 400); expect(summary.exerciseMinutes, 30); }); + + test('does not double-count active calories or exercise minutes', () { + final aggregator = AppleHealthRecordAggregator(); + + aggregator + ..addRecord( + type: 'HKQuantityTypeIdentifierActiveEnergyBurned', + value: '200', + unit: 'kcal', + startDate: '2026-04-20 08:00:00 +0000', + endDate: '2026-04-20 08:05:00 +0000', + ) + ..addRecord( + type: 'HKQuantityTypeIdentifierAppleExerciseTime', + value: '15', + unit: 'min', + startDate: '2026-04-20 08:00:00 +0000', + endDate: '2026-04-20 08:05:00 +0000', + ) + ..addActivitySummary( + dateComponents: '2026-04-20', + activeEnergyBurned: '400', + activeEnergyBurnedUnit: 'kcal', + appleExerciseTime: '30', + ); + + final summary = aggregator.finalize().first; + expect(summary.activeCalories, 400); + expect(summary.exerciseMinutes, 30); + }); }); }