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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions lib/domain/usecases/health/import_apple_health_export_usecase.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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_isolate.dart';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Domain usecase importing infrastructure/ directly.

lib/domain/usecases/health/import_apple_health_export_usecase.dart imports apple_health_export_isolate.dart from infrastructure/. Per Clean Architecture rules for this repo, domain should reach infrastructure only through the shared/providers/ DI boundary, not via direct imports.

As per coding guidelines, "Follow Clean Architecture: domain depends on infrastructure only through shared/providers/ as the dependency-injection boundary."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/domain/usecases/health/import_apple_health_export_usecase.dart` at line
3, `ImportAppleHealthExportUsecase` is depending on
`apple_health_export_isolate.dart` directly from `infrastructure`, which
violates the repo’s Clean Architecture boundary. Remove the direct
infrastructure import from `import_apple_health_export_usecase.dart` and inject
the needed dependency through the `shared/providers/` DI layer instead. Update
the usecase to depend on an abstraction or provider-managed instance, and wire
the concrete `apple_health_export_isolate` implementation only in the provider
setup.

Source: Coding guidelines

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,
}) : _store = store,
_importWorkout = importWorkout;

final ImportedHealthStore _store;
final ImportWorkoutUseCase _importWorkout;

Future<ImportAppleHealthExportResult> call({
required List<int> zipBytes,
DateTime? now,
}) async {
try {
final parsed = await parseAppleHealthZipAsync(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()),
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
198 changes: 104 additions & 94 deletions lib/features/settings/presentation/pages/health_import_page.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
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';
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});
Expand All @@ -19,47 +26,75 @@ class HealthImportPage extends ConsumerStatefulWidget {
}

class _HealthImportPageState extends ConsumerState<HealthImportPage> {
GpxParseResult? _preview;
GpxParseResult? _gpxPreview;
AppleHealthExportParseResult? _zipPreview;
PlatformFile? _pickedFile;
String? _error;
bool _isImporting = false;

Future<void> _pickGpxFile() async {
Future<void> _pickFile() async {
setState(() {
_preview = null;
_gpxPreview = null;
_zipPreview = null;
_pickedFile = null;
_error = null;
});

final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: const ['gpx'],
withData: true,
allowedExtensions: const ['gpx', 'zip'],
withData: kIsWeb,
);

if (!mounted || result == null || result.files.isEmpty) return;

final bytes = result.files.single.bytes;
if (bytes == null) {
setState(() => _error = 'Could not read the selected file.');
return;
}
final file = result.files.single;
final extension = file.extension?.toLowerCase();

try {
final content = String.fromCharCodes(bytes);
final parsed = const GpxWorkoutParser().parse(content);
setState(() => _preview = parsed);
final bytes = await readPickedFileBytes(file);
if (extension == 'zip') {
final parsed = await parseAppleHealthZipAsync(bytes);
if (!mounted) return;
setState(() {
_zipPreview = parsed;
_pickedFile = file;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
final parsed = const GpxWorkoutParser().parse(
utf8.decode(bytes, allowMalformed: true),
);
if (!mounted) return;
setState(() {
_gpxPreview = parsed;
_pickedFile = file;
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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<void> _confirmImport() async {
final preview = _preview;
if (preview == null) return;

setState(() => _isImporting = true);

if (_zipPreview != null && _pickedFile != null) {
await _importZip(_pickedFile!);
} else if (_gpxPreview != null) {
await _importGpx();
}

if (mounted) {
setState(() => _isImporting = false);
}
}

Future<void> _importGpx() async {
final preview = _gpxPreview;
if (preview == null) return;

final useCase = ref.read(importWorkoutUseCaseProvider);
final result = await useCase(
workout: preview.workout,
Expand All @@ -68,19 +103,14 @@ class _HealthImportPageState extends ConsumerState<HealthImportPage> {

if (!mounted) return;

setState(() => _isImporting = false);

if (result.failure != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.failure!.message)),
);
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.')),
Expand All @@ -91,19 +121,52 @@ class _HealthImportPageState extends ConsumerState<HealthImportPage> {
}
}

Future<void> _importZip(PlatformFile file) async {
final bytes = await readPickedFileBytes(file);
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;

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),
Expand All @@ -115,9 +178,9 @@ class _HealthImportPageState extends ConsumerState<HealthImportPage> {
),
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),
Expand All @@ -128,77 +191,24 @@ class _HealthImportPageState extends ConsumerState<HealthImportPage> {
),
),
],
if (_preview != null) ...[
if (_zipPreview != null) ...[
const Gap(tokens.Spacing.lg),
KynosCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Preview',
style: Theme.of(context).textTheme.titleMedium,
),
const Gap(tokens.Spacing.sm),
_PreviewRow(
label: 'Date',
value: _formatDate(_preview!.workout.start),
),
_PreviewRow(
label: 'Duration',
value: _formatDuration(_preview!.workout.duration),
),
_PreviewRow(
label: 'Distance',
value:
'${((_preview!.workout.distanceMeters ?? 0) / 1000).toStringAsFixed(2)} km',
),
_PreviewRow(
label: 'Route points',
value: '${_preview!.routePoints.length}',
),
],
),
AppleHealthExportPreviewCard(
preview: _zipPreview!,
isImporting: _isImporting,
onImport: _confirmImport,
),
const Gap(tokens.Spacing.md),
FilledButton(
onPressed: _isImporting ? null : _confirmImport,
child: Text(_isImporting ? 'Importing…' : 'Confirm import'),
],
if (_gpxPreview != null) ...[
const Gap(tokens.Spacing.lg),
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),
],
),
);
}
}
Loading
Loading