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
91 changes: 67 additions & 24 deletions packages/health/ios/Classes/SwiftHealthPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/health/lib/health.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 16 additions & 2 deletions packages/health/lib/src/health_data_point.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -48,6 +55,7 @@ class HealthDataPoint {
WorkoutSummary? workoutSummary;

HealthDataPoint({
this.uuid,
required this.value,
required this.type,
required this.unit,
Expand All @@ -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();
}
Expand Down Expand Up @@ -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,
Expand All @@ -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 &&
Expand All @@ -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);
}
119 changes: 116 additions & 3 deletions packages/health/lib/src/health_plugin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ class Health {
final _deviceInfo = DeviceInfoPlugin();
bool _useHealthConnectIfAvailable = false;

static const Set<HealthDataType> _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();
}
Expand Down Expand Up @@ -635,8 +646,27 @@ class Health {
Duration? samplingInterval,
}) async {
List<HealthDataPoint> dataPoints = [];
final remainingTypes = List<HealthDataType>.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);
Expand All @@ -650,6 +680,76 @@ class Health {
return removeDuplicates(dataPoints);
}

Future<List<HealthDataPoint>> _dataSleepQuery(
DateTime startTime,
DateTime endTime,
Set<HealthDataType> requestedTypes,
bool includeManualEntry,
) async {
final args = <String, dynamic>{
'startTime': startTime.millisecondsSinceEpoch,
'endTime': endTime.millisecondsSinceEpoch,
'includeManualEntry': includeManualEntry,
};
final fetchedDataPoints = await _channel.invokeMethod('getSleepData', args);
if (fetchedDataPoints is! List) return <HealthDataPoint>[];

final message = <String, dynamic>{
'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<HealthDataType> 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<HealthDataPoint> _parseIosSleepData(
Map<String, dynamic> message,
) {
final dataPoints = message['dataPoints'] as List;
final requestedTypes =
(message['requestedTypes'] as List).cast<HealthDataType>().toSet();

final result = <HealthDataPoint>[];
for (final rawDataPoint in dataPoints) {
final dataPoint = Map<String, dynamic>.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<List<HealthDataPoint>> getHealthIntervalDataFromTypes(
{required DateTime startDate,
Expand Down Expand Up @@ -917,8 +1017,21 @@ class Health {
}

/// Return a list of [HealthDataPoint] based on [points] with no duplicates.
List<HealthDataPoint> removeDuplicates(List<HealthDataPoint> points) =>
LinkedHashSet.of(points).toList();
List<HealthDataPoint> removeDuplicates(List<HealthDataPoint> points) {
final seenUuids = <String>{};
final pointsWithoutUuids = LinkedHashSet<HealthDataPoint>();
final result = <HealthDataPoint>[];

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.
Expand Down
Loading
Loading