diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index e1726438b..0ff8499b1 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -152,6 +152,11 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { getData(call: call, result: result) } + /// Handle a single unfiltered HealthKit sleep-analysis query. + else if call.method.elementsEqual("getSleepData") { + getSleepData(call: call, result: result) + } + /// Handle getIntervalData else if (call.method.elementsEqual("getIntervalData")){ getIntervalData(call: call, result: result) @@ -650,30 +655,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { case var (samplesCategory as [HKCategorySample]) as Any: - if dataTypeKey == self.SLEEP_IN_BED { - samplesCategory = samplesCategory.filter { $0.value == 0 } - } - if dataTypeKey == self.SLEEP_ASLEEP_CORE { - samplesCategory = samplesCategory.filter { $0.value == 3 } - } - if dataTypeKey == self.SLEEP_ASLEEP_DEEP { - samplesCategory = samplesCategory.filter { $0.value == 4 } - } - if dataTypeKey == self.SLEEP_ASLEEP_REM { - samplesCategory = samplesCategory.filter { $0.value == 5 } - } - if dataTypeKey == self.SLEEP_AWAKE { - samplesCategory = samplesCategory.filter { $0.value == 2 } - } - if dataTypeKey == self.SLEEP_ASLEEP { - samplesCategory = samplesCategory.filter { $0.value == 3 || $0.value == 1 } - } - if dataTypeKey == self.SLEEP_DEEP { - samplesCategory = samplesCategory.filter { $0.value == 4 } - } - if dataTypeKey == self.SLEEP_REM { - samplesCategory = samplesCategory.filter { $0.value == 5 } - } + // Sleep types are routed through getSleepData/_dataSleepQuery on iOS and + // never reach here; only headache types are handled by this query. if dataTypeKey == self.HEADACHE_UNSPECIFIED { samplesCategory = samplesCategory.filter { $0.value == 0 } } @@ -824,6 +807,66 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { HKHealthStore().execute(query) } + func getSleepData(call: FlutterMethodCall, result: @escaping FlutterResult) { + let arguments = call.arguments as? NSDictionary + let startTime = (arguments?["startTime"] as? NSNumber) ?? 0 + let endTime = (arguments?["endTime"] as? NSNumber) ?? 0 + let includeManualEntry = (arguments?["includeManualEntry"] as? Bool) ?? true + + let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) + let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) + let sleepType = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! + + var predicate = HKQuery.predicateForSamples( + withStart: dateFrom, end: dateTo, options: .strictStartDate) + if !includeManualEntry { + let manualPredicate = NSPredicate( + format: "metadata.%K != YES", HKMetadataKeyWasUserEntered) + predicate = NSCompoundPredicate( + type: .and, subpredicates: [predicate, manualPredicate]) + } + + let sortDescriptor = NSSortDescriptor( + key: HKSampleSortIdentifierEndDate, ascending: false) + let query = HKSampleQuery( + sampleType: sleepType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [sortDescriptor] + ) { _, samplesOrNil, error in + if let error = error { + DispatchQueue.main.async { + result(FlutterError( + code: "HEALTH_DATA_QUERY_ERROR", + message: error.localizedDescription, + details: nil)) + } + return + } + + let samples = samplesOrNil as? [HKCategorySample] ?? [] + let dictionaries = samples.map { sample -> NSDictionary in + let dict: NSMutableDictionary = [ + "uuid": "\(sample.uuid)", + "value": sample.value, + "date_from": Int(sample.startDate.timeIntervalSince1970 * 1000), + "date_to": Int(sample.endDate.timeIntervalSince1970 * 1000), + "source_id": sample.sourceRevision.source.bundleIdentifier, + "source_name": sample.sourceRevision.source.name, + "is_manual_entry": sample.metadata?[HKMetadataKeyWasUserEntered] != nil + ] + if let deviceId = sample.device?.localIdentifier { + dict["source_device_id"] = deviceId + } + return dict + } + DispatchQueue.main.async { + result(dictionaries) + } + } + healthStore.execute(query) + } + @available(iOS 14.0, *) private func fetchEcgMeasurements(_ sample: HKElectrocardiogram) -> NSDictionary { let semaphore = DispatchSemaphore(value: 0) diff --git a/packages/health/lib/health.g.dart b/packages/health/lib/health.g.dart index 109507403..eb4b94450 100644 --- a/packages/health/lib/health.g.dart +++ b/packages/health/lib/health.g.dart @@ -8,6 +8,7 @@ part of 'health.dart'; HealthDataPoint _$HealthDataPointFromJson(Map json) => HealthDataPoint( + uuid: json['uuid'] as String?, value: HealthValue.fromJson(json['value'] as Map), type: $enumDecode(_$HealthDataTypeEnumMap, json['type']), unit: $enumDecode(_$HealthDataUnitEnumMap, json['unit']), @@ -45,6 +46,7 @@ Map _$HealthDataPointToJson(HealthDataPoint instance) { } } + writeNotNull('uuid', instance.uuid); writeNotNull('workout_summary', instance.workoutSummary); return val; } diff --git a/packages/health/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index 3a6f65f95..3b07d0c12 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -8,6 +8,13 @@ enum HealthPlatformType { appleHealth, googleFit, googleHealthConnect } /// as value. @JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class HealthDataPoint { + /// The platform-assigned identifier for this sample, when available. + /// + /// HealthKit returns the same UUID when a single sample is read through + /// multiple aliases. Keeping it lets callers de-duplicate the underlying + /// sample instead of relying on timestamps or source names. + String? uuid; + /// The quantity value of the data point HealthValue value; @@ -48,6 +55,7 @@ class HealthDataPoint { WorkoutSummary? workoutSummary; HealthDataPoint({ + this.uuid, required this.value, required this.type, required this.unit, @@ -74,6 +82,9 @@ class HealthDataPoint { type == HealthDataType.SLEEP_DEEP || type == HealthDataType.SLEEP_LIGHT || type == HealthDataType.SLEEP_REM || + type == HealthDataType.SLEEP_ASLEEP_CORE || + type == HealthDataType.SLEEP_ASLEEP_DEEP || + type == HealthDataType.SLEEP_ASLEEP_REM || type == HealthDataType.SLEEP_OUT_OF_BED) { value = _convertMinutes(); } @@ -129,13 +140,15 @@ class HealthDataPoint { } return HealthDataPoint( + uuid: dataPoint['uuid'] as String?, value: value, type: dataType, unit: unit, dateFrom: from, dateTo: to, sourcePlatform: Health().platformType, - sourceDeviceId: Health().deviceId, + sourceDeviceId: + dataPoint['source_device_id'] as String? ?? Health().deviceId, sourceId: sourceId, sourceName: sourceName, isManualEntry: isManualEntry, @@ -160,6 +173,7 @@ class HealthDataPoint { @override bool operator ==(Object other) => other is HealthDataPoint && + uuid == other.uuid && value == other.value && unit == other.unit && dateFrom == other.dateFrom && @@ -172,6 +186,6 @@ class HealthDataPoint { isManualEntry == other.isManualEntry; @override - int get hashCode => Object.hash(value, unit, dateFrom, dateTo, type, + int get hashCode => Object.hash(uuid, value, unit, dateFrom, dateTo, type, sourcePlatform, sourceDeviceId, sourceId, sourceName); } diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 8a1e0bf22..85f1658da 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -29,6 +29,17 @@ class Health { final _deviceInfo = DeviceInfoPlugin(); bool _useHealthConnectIfAvailable = false; + static const Set _iosSleepTypes = { + HealthDataType.SLEEP_IN_BED, + HealthDataType.SLEEP_ASLEEP, + HealthDataType.SLEEP_ASLEEP_CORE, + HealthDataType.SLEEP_ASLEEP_DEEP, + HealthDataType.SLEEP_ASLEEP_REM, + HealthDataType.SLEEP_AWAKE, + HealthDataType.SLEEP_DEEP, + HealthDataType.SLEEP_REM, + }; + Health._() { _registerFromJsonFunctions(); } @@ -635,8 +646,27 @@ class Health { Duration? samplingInterval, }) async { List dataPoints = []; + final remainingTypes = List.of(types); + + // HealthKit stores every sleep state under one `sleepAnalysis` sample + // type. Query it once, then map each category value to one canonical + // requested Dart type. Previously each alias triggered its own identical + // native query, so SLEEP_DEEP/SLEEP_ASLEEP_DEEP and + // SLEEP_REM/SLEEP_ASLEEP_REM returned the same HealthKit samples twice. + if (defaultTargetPlatform == TargetPlatform.iOS) { + final requestedSleepTypes = types.where(_iosSleepTypes.contains).toSet(); + if (requestedSleepTypes.isNotEmpty) { + dataPoints.addAll(await _dataSleepQuery( + startTime, + endTime, + requestedSleepTypes, + includeManualEntry, + )); + remainingTypes.removeWhere(_iosSleepTypes.contains); + } + } - for (var type in types) { + for (var type in remainingTypes) { final result = await _prepareQuery( startTime, endTime, type, includeManualEntry, samplingInterval); dataPoints.addAll(result); @@ -650,6 +680,76 @@ class Health { return removeDuplicates(dataPoints); } + Future> _dataSleepQuery( + DateTime startTime, + DateTime endTime, + Set requestedTypes, + bool includeManualEntry, + ) async { + final args = { + 'startTime': startTime.millisecondsSinceEpoch, + 'endTime': endTime.millisecondsSinceEpoch, + 'includeManualEntry': includeManualEntry, + }; + final fetchedDataPoints = await _channel.invokeMethod('getSleepData', args); + if (fetchedDataPoints is! List) return []; + + final message = { + 'dataPoints': fetchedDataPoints, + 'requestedTypes': requestedTypes.toList(), + }; + const threshold = 100; + if (fetchedDataPoints.length > threshold) { + return compute(_parseIosSleepData, message); + } + return _parseIosSleepData(message); + } + + static HealthDataType? _requestedIosSleepType( + int value, + Set requestedTypes, + ) => + switch (value) { + 0 when requestedTypes.contains(HealthDataType.SLEEP_IN_BED) => + HealthDataType.SLEEP_IN_BED, + 1 when requestedTypes.contains(HealthDataType.SLEEP_ASLEEP) => + HealthDataType.SLEEP_ASLEEP, + 2 when requestedTypes.contains(HealthDataType.SLEEP_AWAKE) => + HealthDataType.SLEEP_AWAKE, + 3 when requestedTypes.contains(HealthDataType.SLEEP_ASLEEP_CORE) => + HealthDataType.SLEEP_ASLEEP_CORE, + 3 when requestedTypes.contains(HealthDataType.SLEEP_ASLEEP) => + HealthDataType.SLEEP_ASLEEP, + 4 when requestedTypes.contains(HealthDataType.SLEEP_ASLEEP_DEEP) => + HealthDataType.SLEEP_ASLEEP_DEEP, + 4 when requestedTypes.contains(HealthDataType.SLEEP_DEEP) => + HealthDataType.SLEEP_DEEP, + 5 when requestedTypes.contains(HealthDataType.SLEEP_ASLEEP_REM) => + HealthDataType.SLEEP_ASLEEP_REM, + 5 when requestedTypes.contains(HealthDataType.SLEEP_REM) => + HealthDataType.SLEEP_REM, + _ => null, + }; + + static List _parseIosSleepData( + Map message, + ) { + final dataPoints = message['dataPoints'] as List; + final requestedTypes = + (message['requestedTypes'] as List).cast().toSet(); + + final result = []; + for (final rawDataPoint in dataPoints) { + final dataPoint = Map.from(rawDataPoint as Map); + final value = (dataPoint['value'] as num).toInt(); + final type = _requestedIosSleepType(value, requestedTypes); + if (type != null) { + result.add(HealthDataPoint.fromHealthDataPoint(type, dataPoint)); + } + } + return result; + } + /// Fetch a list of health data points based on [types]. Future> getHealthIntervalDataFromTypes( {required DateTime startDate, @@ -917,8 +1017,21 @@ class Health { } /// Return a list of [HealthDataPoint] based on [points] with no duplicates. - List removeDuplicates(List points) => - LinkedHashSet.of(points).toList(); + List removeDuplicates(List points) { + final seenUuids = {}; + final pointsWithoutUuids = LinkedHashSet(); + final result = []; + + for (final point in points) { + final uuid = point.uuid; + if (uuid != null) { + if (seenUuids.add(uuid)) result.add(point); + } else if (pointsWithoutUuids.add(point)) { + result.add(point); + } + } + return result; + } /// Get the total number of steps within a specific time period. /// Returns null if not successful. diff --git a/packages/health/test/health_test.dart b/packages/health/test/health_test.dart index 8b1378917..835d66c0d 100644 --- a/packages/health/test/health_test.dart +++ b/packages/health/test/health_test.dart @@ -1 +1,173 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; +import 'package:health/health.dart'; +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + HealthDataPoint sleepPoint({ + required String uuid, + required HealthDataType type, + DateTime? start, + DateTime? end, + }) { + start ??= DateTime.utc(2026, 8, 20, 3); + end ??= start.add(const Duration(minutes: 17, seconds: 30)); + return HealthDataPoint.fromHealthDataPoint(type, { + 'uuid': uuid, + 'value': switch (type) { + HealthDataType.SLEEP_ASLEEP_CORE => 3, + HealthDataType.SLEEP_ASLEEP_DEEP => 4, + HealthDataType.SLEEP_ASLEEP_REM => 5, + _ => 1, + }, + 'date_from': start.millisecondsSinceEpoch, + 'date_to': end.millisecondsSinceEpoch, + 'source_device_id': 'watch-device', + 'source_id': 'com.apple.health.test', + 'source_name': 'Test Apple Watch', + 'is_manual_entry': false, + }); + } + + test('Apple-specific sleep stages expose duration rather than category', () { + for (final type in [ + HealthDataType.SLEEP_ASLEEP_CORE, + HealthDataType.SLEEP_ASLEEP_DEEP, + HealthDataType.SLEEP_ASLEEP_REM, + ]) { + final point = sleepPoint(uuid: type.name, type: type); + expect((point.value as NumericHealthValue).numericValue, 17.5); + expect(point.sourceDeviceId, 'watch-device'); + } + }); + + test('HealthKit UUID survives JSON serialization', () { + final point = sleepPoint( + uuid: 'healthkit-sample-id', + type: HealthDataType.SLEEP_ASLEEP_DEEP, + ); + + final json = point.toJson() + ..['value'] = (point.value as NumericHealthValue).toJson(); + expect(json['uuid'], 'healthkit-sample-id'); + expect( + HealthDataPoint.fromJson(json).uuid, + 'healthkit-sample-id', + ); + }); + + test('removeDuplicates collapses aliases with the same HealthKit UUID', () { + final generic = sleepPoint( + uuid: 'same-native-sample', + type: HealthDataType.SLEEP_DEEP, + ); + final appleSpecific = sleepPoint( + uuid: 'same-native-sample', + type: HealthDataType.SLEEP_ASLEEP_DEEP, + ); + + expect(Health().removeDuplicates([generic, appleSpecific]), [generic]); + }); + + test('all iOS sleep aliases use one native query and canonical types', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('flutter_health'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + expect(call.method, 'getSleepData'); + return >[ + for (var value = 0; value <= 5; value++) + { + 'uuid': 'sleep-$value', + 'value': value, + 'date_from': + DateTime.utc(2026, 8, 20, value).millisecondsSinceEpoch, + 'date_to': + DateTime.utc(2026, 8, 20, value, 10).millisecondsSinceEpoch, + 'source_device_id': 'watch-device', + 'source_id': 'com.apple.health.test', + 'source_name': 'Test Apple Watch', + 'is_manual_entry': false, + }, + ]; + }); + addTearDown(() => TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null)); + + final points = await Health().getHealthDataFromTypes( + types: const [ + HealthDataType.SLEEP_IN_BED, + HealthDataType.SLEEP_ASLEEP, + HealthDataType.SLEEP_AWAKE, + HealthDataType.SLEEP_DEEP, + HealthDataType.SLEEP_REM, + HealthDataType.SLEEP_ASLEEP_CORE, + HealthDataType.SLEEP_ASLEEP_DEEP, + HealthDataType.SLEEP_ASLEEP_REM, + ], + startTime: DateTime.utc(2026, 8, 20), + endTime: DateTime.utc(2026, 8, 21), + ); + + expect(calls, hasLength(1)); + expect(points.map((point) => point.type), [ + HealthDataType.SLEEP_IN_BED, + HealthDataType.SLEEP_ASLEEP, + HealthDataType.SLEEP_AWAKE, + HealthDataType.SLEEP_ASLEEP_CORE, + HealthDataType.SLEEP_ASLEEP_DEEP, + HealthDataType.SLEEP_ASLEEP_REM, + ]); + expect( + points.map((point) => (point.value as NumericHealthValue).numericValue), + everyElement(10), + ); + }); + + test('large iOS sleep responses filter narrow stage requests', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('flutter_health'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getSleepData'); + return >[ + for (var index = 0; index <= 100; index++) + { + 'uuid': 'sleep-$index', + 'value': index == 100 ? 4 : 3, + 'date_from': DateTime.utc(2026, 8, 20) + .add(Duration(minutes: index)) + .millisecondsSinceEpoch, + 'date_to': DateTime.utc(2026, 8, 20) + .add(Duration(minutes: index + 1)) + .millisecondsSinceEpoch, + 'source_device_id': 'watch-device', + 'source_id': 'com.apple.health.test', + 'source_name': 'Test Apple Watch', + 'is_manual_entry': false, + }, + ]; + }); + addTearDown(() => TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null)); + + final points = await Health().getHealthDataFromTypes( + types: const [HealthDataType.SLEEP_ASLEEP_DEEP], + startTime: DateTime.utc(2026, 8, 20), + endTime: DateTime.utc(2026, 8, 21), + ); + + expect(points, hasLength(1)); + expect(points.single.uuid, 'sleep-100'); + expect(points.single.type, HealthDataType.SLEEP_ASLEEP_DEEP); + }); +}