craft-native 0.0.290 → 0.0.293
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.d.ts +1 -1
- package/dist/android/src/index.js +3 -1
- package/dist/android/templates/CraftNativeHealth.kt.template +77 -1
- package/dist/api/mobile.d.ts +48 -4
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +20 -0
- package/dist/ios/templates/CraftHealth.swift +76 -0
- package/dist/mobile.js +6 -0
- package/package.json +1 -1
|
@@ -177,7 +177,7 @@ export interface CraftAndroidConfig {
|
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
179
|
/** The shared health data types (the iOS builder's HealthKit names). */
|
|
180
|
-
export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat';
|
|
180
|
+
export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat' | 'bloodGlucose' | 'insulinDelivery';
|
|
181
181
|
/**
|
|
182
182
|
* The Health Connect permissions behind each type, for reading and for
|
|
183
183
|
* writing. CraftNativeHealth.kt asks for the same ones; a permission the
|
|
@@ -480,7 +480,9 @@ var HEALTH_CONNECT_PERMISSIONS = {
|
|
|
480
480
|
dietaryEnergy: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
481
481
|
dietaryProtein: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
482
482
|
dietaryCarbohydrates: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
483
|
-
dietaryFat: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] }
|
|
483
|
+
dietaryFat: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
484
|
+
bloodGlucose: { read: ["READ_BLOOD_GLUCOSE"], write: [] },
|
|
485
|
+
insulinDelivery: { read: [], write: [] }
|
|
484
486
|
};
|
|
485
487
|
var DEFAULT_HEALTH_CONNECT = { read: ["steps", "heartRate", "activeEnergy", "distance", "workouts"], write: ["workouts"] };
|
|
486
488
|
function healthConnectPermissions(config) {
|
|
@@ -17,6 +17,7 @@ import androidx.health.connect.client.aggregate.AggregationResult
|
|
|
17
17
|
import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi
|
|
18
18
|
import androidx.health.connect.client.permission.HealthPermission
|
|
19
19
|
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
|
20
|
+
import androidx.health.connect.client.records.BloodGlucoseRecord
|
|
20
21
|
import androidx.health.connect.client.records.DistanceRecord
|
|
21
22
|
import androidx.health.connect.client.records.ElevationGainedRecord
|
|
22
23
|
import androidx.health.connect.client.records.ExerciseSessionRecord
|
|
@@ -162,6 +163,10 @@ internal class CraftNativeHealth(
|
|
|
162
163
|
getDailyStatistics(type, options(1))
|
|
163
164
|
}
|
|
164
165
|
"getHeartRateSamples" -> answer(completion) { getHeartRateSamples(options(0)) }
|
|
166
|
+
"getSamples" -> answer(completion) {
|
|
167
|
+
val type = args.opt(0) as? String ?: throw invalid("getSamples needs a type")
|
|
168
|
+
getSamples(type, options(1))
|
|
169
|
+
}
|
|
165
170
|
"getSleepIntervals" -> answer(completion) { getSleepIntervals(options(0)) }
|
|
166
171
|
"saveMindfulSession" -> answer(completion) { saveMindfulSession(options(0)) }
|
|
167
172
|
"getMindfulSessions" -> answer(completion) { getMindfulSessions(options(0)) }
|
|
@@ -438,7 +443,8 @@ internal class CraftNativeHealth(
|
|
|
438
443
|
/** The types whose every read permission is granted, in the order of TYPES. */
|
|
439
444
|
private suspend fun grantedTypes(): Any {
|
|
440
445
|
val granted = client().permissionController.getGrantedPermissions()
|
|
441
|
-
|
|
446
|
+
// A type Health Connect cannot hold (insulin) is never granted.
|
|
447
|
+
return JSONArray(TYPES.filter { type -> readPermissions(type).let { it.isNotEmpty() && granted.containsAll(it) } })
|
|
442
448
|
}
|
|
443
449
|
|
|
444
450
|
/**
|
|
@@ -783,6 +789,53 @@ internal class CraftNativeHealth(
|
|
|
783
789
|
return rows
|
|
784
790
|
}
|
|
785
791
|
|
|
792
|
+
/**
|
|
793
|
+
* Every glucose reading or carb entry between two instants, oldest first,
|
|
794
|
+
* as the writing app saved it: its record id (so a sync stores it once),
|
|
795
|
+
* value, source and device. Glucose comes in mg/dL with its meal relation
|
|
796
|
+
* and specimen when the source said. Health Connect has no insulin type,
|
|
797
|
+
* so `insulinDelivery` answers an empty list rather than failing a sync
|
|
798
|
+
* that asks for all three.
|
|
799
|
+
*/
|
|
800
|
+
private suspend fun getSamples(type: String, options: JSONObject): Any {
|
|
801
|
+
if (type !in SAMPLE_TYPES) throw invalid("getSamples reads bloodGlucose, insulinDelivery or dietaryCarbohydrates")
|
|
802
|
+
val end = options.instant("endDate") ?: Instant.now()
|
|
803
|
+
val start = options.instant("startDate") ?: end.minus(Duration.ofDays(7))
|
|
804
|
+
val cap = (options.int("limit") ?: 10_000).coerceIn(1, 50_000)
|
|
805
|
+
val rows = JSONArray()
|
|
806
|
+
if (type == "insulinDelivery") return rows
|
|
807
|
+
val client = client()
|
|
808
|
+
fun row(id: String, value: Double, unit: String, from: Instant, to: Instant, metadata: Metadata): JSONObject {
|
|
809
|
+
val origin = metadata.dataOrigin.packageName
|
|
810
|
+
val item = JSONObject()
|
|
811
|
+
.put("id", id)
|
|
812
|
+
.put("value", value)
|
|
813
|
+
.put("unit", unit)
|
|
814
|
+
.put("startDate", from.ms())
|
|
815
|
+
.put("endDate", to.ms())
|
|
816
|
+
.put("sourceName", sourceName(origin))
|
|
817
|
+
.put("sourceBundleId", origin)
|
|
818
|
+
.put("ownApp", origin == context.packageName)
|
|
819
|
+
putDevice(item, metadata.device, withModel = false)
|
|
820
|
+
return item
|
|
821
|
+
}
|
|
822
|
+
if (type == "bloodGlucose") {
|
|
823
|
+
for (record in readAll(client, BloodGlucoseRecord::class, range(start, end), cap = cap)) {
|
|
824
|
+
val item = row(record.metadata.id, record.level.inMilligramsPerDeciliter, "mg/dL", record.time, record.time, record.metadata)
|
|
825
|
+
mealTimeName(record.relationToMeal)?.let { item.put("mealTime", it) }
|
|
826
|
+
specimenName(record.specimenSource)?.let { item.put("specimen", it) }
|
|
827
|
+
rows.put(item)
|
|
828
|
+
}
|
|
829
|
+
return rows
|
|
830
|
+
}
|
|
831
|
+
for (record in readAll(client, NutritionRecord::class, range(start, end), cap = cap)) {
|
|
832
|
+
val grams = record.totalCarbohydrate?.inGrams ?: continue
|
|
833
|
+
if (grams <= 0) continue
|
|
834
|
+
rows.put(row(record.metadata.id, grams, "g", record.startTime, record.endTime, record.metadata))
|
|
835
|
+
}
|
|
836
|
+
return rows
|
|
837
|
+
}
|
|
838
|
+
|
|
786
839
|
/**
|
|
787
840
|
* Sleep between two instants, oldest first, as the sources wrote it: each
|
|
788
841
|
* stage with its name and whether it counts as asleep. A session with no
|
|
@@ -996,7 +1049,28 @@ internal class CraftNativeHealth(
|
|
|
996
1049
|
"steps", "heartRate", "activeEnergy", "distance", "workouts", "restingHeartRate",
|
|
997
1050
|
"heartRateVariability", "bodyMass", "sleep", "mindful",
|
|
998
1051
|
"dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat",
|
|
1052
|
+
"bloodGlucose", "insulinDelivery",
|
|
999
1053
|
)
|
|
1054
|
+
/** What `getSamples` reads one record at a time. */
|
|
1055
|
+
private val SAMPLE_TYPES = setOf("bloodGlucose", "insulinDelivery", "dietaryCarbohydrates")
|
|
1056
|
+
|
|
1057
|
+
private fun mealTimeName(relation: Int): String? = when (relation) {
|
|
1058
|
+
BloodGlucoseRecord.RELATION_TO_MEAL_BEFORE_MEAL -> "beforeMeal"
|
|
1059
|
+
BloodGlucoseRecord.RELATION_TO_MEAL_AFTER_MEAL -> "afterMeal"
|
|
1060
|
+
BloodGlucoseRecord.RELATION_TO_MEAL_FASTING -> "fasting"
|
|
1061
|
+
BloodGlucoseRecord.RELATION_TO_MEAL_GENERAL -> "general"
|
|
1062
|
+
else -> null
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private fun specimenName(source: Int): String? = when (source) {
|
|
1066
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_CAPILLARY_BLOOD -> "capillaryBlood"
|
|
1067
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_INTERSTITIAL_FLUID -> "interstitialFluid"
|
|
1068
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_PLASMA -> "plasma"
|
|
1069
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_SERUM -> "serum"
|
|
1070
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_TEARS -> "tears"
|
|
1071
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_WHOLE_BLOOD -> "wholeBlood"
|
|
1072
|
+
else -> null
|
|
1073
|
+
}
|
|
1000
1074
|
private val DIETARY_TYPES = setOf("dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat")
|
|
1001
1075
|
|
|
1002
1076
|
private val AWAKE_STAGES = setOf(
|
|
@@ -1142,6 +1216,8 @@ internal class CraftNativeHealth(
|
|
|
1142
1216
|
"sleep" -> setOf(HealthPermission.getReadPermission(SleepSessionRecord::class))
|
|
1143
1217
|
"mindful" -> setOf(HealthPermission.getReadPermission(MindfulnessSessionRecord::class))
|
|
1144
1218
|
in DIETARY_TYPES -> setOf(HealthPermission.getReadPermission(NutritionRecord::class))
|
|
1219
|
+
"bloodGlucose" -> setOf(HealthPermission.getReadPermission(BloodGlucoseRecord::class))
|
|
1220
|
+
// Health Connect has no insulin record: nothing to ask for.
|
|
1145
1221
|
else -> emptySet()
|
|
1146
1222
|
}
|
|
1147
1223
|
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -1546,11 +1546,48 @@ export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance'
|
|
|
1546
1546
|
/** Food logged by any app, per day: kcal eaten, then grams of protein, carbohydrate and fat. */
|
|
1547
1547
|
| 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat'
|
|
1548
1548
|
/** Mindful minutes (meditation, breathing): read with getMindfulSessions, written with saveMindfulSession. */
|
|
1549
|
-
| 'mindful'
|
|
1549
|
+
| 'mindful'
|
|
1550
|
+
/** Blood glucose from a CGM or meter, in mg/dL: read with getSamples. Read only. */
|
|
1551
|
+
| 'bloodGlucose'
|
|
1552
|
+
/**
|
|
1553
|
+
* Insulin from a pump, pen or closed loop, in international units, each
|
|
1554
|
+
* marked basal or bolus: read with getSamples. Read only. HealthKit only:
|
|
1555
|
+
* Health Connect has no insulin type, so Android answers none.
|
|
1556
|
+
*/
|
|
1557
|
+
| 'insulinDelivery';
|
|
1550
1558
|
/** The types with one value per day. `sleep` is hours asleep, counted on the day you woke. */
|
|
1551
|
-
export type HealthDailyType = Exclude<HealthDataType, 'workouts' | 'mindful'>;
|
|
1559
|
+
export type HealthDailyType = Exclude<HealthDataType, 'workouts' | 'mindful' | 'bloodGlucose' | 'insulinDelivery'>;
|
|
1552
1560
|
/** Quantity types that can be written with `health.saveData()`. */
|
|
1553
|
-
export type HealthQuantityType = Exclude<HealthDataType, 'workouts' | 'sleep' | 'mindful'>;
|
|
1561
|
+
export type HealthQuantityType = Exclude<HealthDataType, 'workouts' | 'sleep' | 'mindful' | 'bloodGlucose' | 'insulinDelivery'>;
|
|
1562
|
+
/** The types `health.getSamples()` reads one sample at a time: glucose, insulin and carbs, as a diabetes app needs them. */
|
|
1563
|
+
export type HealthSampleType = 'bloodGlucose' | 'insulinDelivery' | 'dietaryCarbohydrates';
|
|
1564
|
+
export interface HealthSamplesQuery extends HealthSampleQuery {
|
|
1565
|
+
/** At most this many, oldest first. Defaults to 10000, capped at 50000. Start defaults to 7 days ago. */
|
|
1566
|
+
limit?: number;
|
|
1567
|
+
}
|
|
1568
|
+
/** One glucose reading, insulin dose or carb entry as the writing app saved it. */
|
|
1569
|
+
export interface HealthQuantitySample {
|
|
1570
|
+
/** HealthKit's UUID or Health Connect's record id: stable across reads. */
|
|
1571
|
+
id: string;
|
|
1572
|
+
/** In `unit`: mg/dL for glucose, IU for insulin, g for carbs. */
|
|
1573
|
+
value: number;
|
|
1574
|
+
unit: 'mg/dL' | 'IU' | 'g';
|
|
1575
|
+
/** Epoch milliseconds. */
|
|
1576
|
+
startDate: number;
|
|
1577
|
+
/** Epoch milliseconds; the same as `startDate` for an instant reading. A basal delivery spans its segment. */
|
|
1578
|
+
endDate: number;
|
|
1579
|
+
sourceName: string;
|
|
1580
|
+
sourceBundleId: string;
|
|
1581
|
+
/** True when this app wrote it. */
|
|
1582
|
+
ownApp: boolean;
|
|
1583
|
+
deviceName?: string;
|
|
1584
|
+
/** Insulin only: what the dose was for. */
|
|
1585
|
+
insulinReason?: 'basal' | 'bolus';
|
|
1586
|
+
/** Glucose only: when it was taken relative to a meal, when the source said. */
|
|
1587
|
+
mealTime?: 'beforeMeal' | 'afterMeal' | 'fasting' | 'general';
|
|
1588
|
+
/** Glucose only (Health Connect): what was sampled, when the source said. */
|
|
1589
|
+
specimen?: 'capillaryBlood' | 'interstitialFluid' | 'plasma' | 'serum' | 'tears' | 'wholeBlood';
|
|
1590
|
+
}
|
|
1554
1591
|
export interface HealthAuthorizationOptions {
|
|
1555
1592
|
/** `false` asks to read only. Default: also allowed to save workouts. */
|
|
1556
1593
|
write?: boolean;
|
|
@@ -1737,7 +1774,7 @@ export interface HealthLiveWorkoutEvent {
|
|
|
1737
1774
|
message?: string;
|
|
1738
1775
|
}
|
|
1739
1776
|
/** The health reads and writes a host may or may not have, depending on the app build it runs in. */
|
|
1740
|
-
export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals' | 'saveData' | 'saveMindfulSession' | 'getMindfulSessions' | 'startHeartRateUpdates' | 'stopHeartRateUpdates' | 'startWorkout' | 'pauseWorkout' | 'resumeWorkout' | 'endWorkout';
|
|
1777
|
+
export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSamples' | 'getSleepIntervals' | 'saveData' | 'saveMindfulSession' | 'getMindfulSessions' | 'startHeartRateUpdates' | 'stopHeartRateUpdates' | 'startWorkout' | 'pauseWorkout' | 'resumeWorkout' | 'endWorkout';
|
|
1741
1778
|
export declare const health: {
|
|
1742
1779
|
/**
|
|
1743
1780
|
* Whether this app build answers `method`. An app installed before a read
|
|
@@ -1760,6 +1797,13 @@ export declare const health: {
|
|
|
1760
1797
|
getDailyStatistics(type: HealthDailyType, options?: HealthDataOptions): Promise<HealthDailyValue[]>;
|
|
1761
1798
|
/** Every heart-rate reading between two instants, oldest first. iOS only for now. */
|
|
1762
1799
|
getHeartRateSamples(options?: HealthHeartRateQuery): Promise<HealthHeartRateSample[]>;
|
|
1800
|
+
/**
|
|
1801
|
+
* Every glucose reading, insulin dose or carb entry between two instants,
|
|
1802
|
+
* oldest first, each with its own id so a sync can store it once. Needs
|
|
1803
|
+
* the type's read grant. Health Connect has no insulin, so Android answers
|
|
1804
|
+
* an empty list for `insulinDelivery`.
|
|
1805
|
+
*/
|
|
1806
|
+
getSamples(type: HealthSampleType, options?: HealthSamplesQuery): Promise<HealthQuantitySample[]>;
|
|
1763
1807
|
/** Sleep between two instants, oldest first: time in bed and each asleep or awake stage. iOS only for now. */
|
|
1764
1808
|
getSleepIntervals(options?: HealthSampleQuery): Promise<HealthSleepInterval[]>;
|
|
1765
1809
|
/**
|
package/dist/cli.js
CHANGED
|
@@ -2945,7 +2945,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
2945
2945
|
`);
|
|
2946
2946
|
}
|
|
2947
2947
|
// package.json
|
|
2948
|
-
var version = "0.0.
|
|
2948
|
+
var version = "0.0.293";
|
|
2949
2949
|
|
|
2950
2950
|
// bin/cli.ts
|
|
2951
2951
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -3116,6 +3116,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3116
3116
|
} else {
|
|
3117
3117
|
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
3118
3118
|
}
|
|
3119
|
+
// Glucose, insulin and carbs, one sample at a time (CraftHealth.swift).
|
|
3120
|
+
case "getHealthSamples":
|
|
3121
|
+
if config.enableHealthKit {
|
|
3122
|
+
if let dataType = body["type"] as? String {
|
|
3123
|
+
CraftHealth.shared.getSamples(
|
|
3124
|
+
type: dataType,
|
|
3125
|
+
startDate: body["startDate"] as? Double,
|
|
3126
|
+
endDate: body["endDate"] as? Double,
|
|
3127
|
+
limit: body["limit"] as? Int
|
|
3128
|
+
) { [weak self] in self?.answer(callbackId, $0) }
|
|
3129
|
+
} else {
|
|
3130
|
+
rejectCallback(callbackId, error: "getHealthSamples was called without a type", code: "INVALID_ARGUMENT")
|
|
3131
|
+
}
|
|
3132
|
+
} else {
|
|
3133
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
3134
|
+
}
|
|
3119
3135
|
case "getHealthSleepIntervals":
|
|
3120
3136
|
if config.enableHealthKit {
|
|
3121
3137
|
getHealthSleepIntervals(
|
|
@@ -5749,6 +5765,10 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5749
5765
|
options = options || {};
|
|
5750
5766
|
return craft._invoke('getHealthHeartRateSamples', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
|
|
5751
5767
|
},
|
|
5768
|
+
getSamples: function(type, options) {
|
|
5769
|
+
options = options || {};
|
|
5770
|
+
return craft._invoke('getHealthSamples', {type: type, startDate: options.startDate, endDate: options.endDate, limit: options.limit});
|
|
5771
|
+
},
|
|
5752
5772
|
getSleepIntervals: function(options) {
|
|
5753
5773
|
options = options || {};
|
|
5754
5774
|
return craft._invoke('getHealthSleepIntervals', {startDate: options.startDate, endDate: options.endDate});
|
|
@@ -106,6 +106,9 @@ final class CraftHealth {
|
|
|
106
106
|
getDailyStatistics(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
|
|
107
107
|
case "getHeartRateSamples":
|
|
108
108
|
getHeartRateSamples(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
|
|
109
|
+
case "getSamples":
|
|
110
|
+
guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getSamples needs a type", code: "INVALID_ARGUMENT"))) }
|
|
111
|
+
getSamples(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, limit: options(1)["limit"] as? Int, completion: completion)
|
|
109
112
|
case "getSleepIntervals":
|
|
110
113
|
getSleepIntervals(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, completion: completion)
|
|
111
114
|
case "saveMindfulSession":
|
|
@@ -196,6 +199,12 @@ final class CraftHealth {
|
|
|
196
199
|
readTypes.insert(mindfulType)
|
|
197
200
|
shareTypes.insert(mindfulType)
|
|
198
201
|
}
|
|
202
|
+
case "bloodGlucose", "insulinDelivery":
|
|
203
|
+
// A CGM, meter, pump or closed loop's own writes: read
|
|
204
|
+
// only, never written back, so the sheet only asks to read.
|
|
205
|
+
if let diabetesType = Self.healthQuantity(type)?.type {
|
|
206
|
+
readTypes.insert(diabetesType)
|
|
207
|
+
}
|
|
199
208
|
case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
|
|
200
209
|
// What a food log (MyFitnessPal, Lose It!, Cronometer...)
|
|
201
210
|
// writes to Apple Health. Request the write grant too unless
|
|
@@ -404,6 +413,8 @@ final class CraftHealth {
|
|
|
404
413
|
case "dietaryProtein": return (HKQuantityType.quantityType(forIdentifier: .dietaryProtein), .gram(), .cumulativeSum)
|
|
405
414
|
case "dietaryCarbohydrates": return (HKQuantityType.quantityType(forIdentifier: .dietaryCarbohydrates), .gram(), .cumulativeSum)
|
|
406
415
|
case "dietaryFat": return (HKQuantityType.quantityType(forIdentifier: .dietaryFatTotal), .gram(), .cumulativeSum)
|
|
416
|
+
case "bloodGlucose": return (HKQuantityType.quantityType(forIdentifier: .bloodGlucose), HKUnit(from: "mg/dL"), .discreteAverage)
|
|
417
|
+
case "insulinDelivery": return (HKQuantityType.quantityType(forIdentifier: .insulinDelivery), .internationalUnit(), .cumulativeSum)
|
|
407
418
|
default: return nil
|
|
408
419
|
}
|
|
409
420
|
}
|
|
@@ -613,6 +624,71 @@ final class CraftHealth {
|
|
|
613
624
|
healthStore.execute(query)
|
|
614
625
|
}
|
|
615
626
|
|
|
627
|
+
/// The types `getSamples` reads, and the unit each answers in.
|
|
628
|
+
private static let sampleUnits: [String: String] = ["bloodGlucose": "mg/dL", "insulinDelivery": "IU", "dietaryCarbohydrates": "g"]
|
|
629
|
+
|
|
630
|
+
/// Every glucose reading, insulin dose or carb entry between two
|
|
631
|
+
/// instants, oldest first, as the writing app saved it: its UUID (so a
|
|
632
|
+
/// sync stores it once), value, source and device, plus what a diabetes
|
|
633
|
+
/// app needs to tell readings and doses apart: an insulin dose's reason
|
|
634
|
+
/// (basal or bolus) and a glucose reading's meal time.
|
|
635
|
+
func getSamples(type: String, startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
|
|
636
|
+
let completion = completeOnMain(rawCompletion)
|
|
637
|
+
guard let healthStore = healthStore else {
|
|
638
|
+
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
guard let unitName = Self.sampleUnits[type], let spec = Self.healthQuantity(type), let sampleType = spec.type else {
|
|
642
|
+
completion(.failure(CraftHealthError(message: "getSamples reads bloodGlucose, insulinDelivery or dietaryCarbohydrates", code: "INVALID_ARGUMENT")))
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
|
|
647
|
+
let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -7, to: end)!
|
|
648
|
+
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
|
|
649
|
+
let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
|
650
|
+
let cap = max(1, min(limit ?? 10_000, 50_000))
|
|
651
|
+
let ownBundle = Bundle.main.bundleIdentifier
|
|
652
|
+
|
|
653
|
+
let query = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { _, samples, error in
|
|
654
|
+
if let error = error, (error as? HKError)?.code != .errorNoData {
|
|
655
|
+
completion(.failure(CraftHealthError(message: error.localizedDescription)))
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
let rows: [[String: Any]] = (samples as? [HKQuantitySample] ?? []).map { sample in
|
|
659
|
+
let source = sample.sourceRevision.source
|
|
660
|
+
var item: [String: Any] = [
|
|
661
|
+
"id": sample.uuid.uuidString,
|
|
662
|
+
"value": sample.quantity.doubleValue(for: spec.unit),
|
|
663
|
+
"unit": unitName,
|
|
664
|
+
"startDate": sample.startDate.timeIntervalSince1970 * 1000,
|
|
665
|
+
"endDate": sample.endDate.timeIntervalSince1970 * 1000,
|
|
666
|
+
"sourceName": source.name,
|
|
667
|
+
"sourceBundleId": source.bundleIdentifier,
|
|
668
|
+
"ownApp": ownBundle != nil && source.bundleIdentifier == ownBundle,
|
|
669
|
+
]
|
|
670
|
+
if let device = sample.device?.name { item["deviceName"] = device }
|
|
671
|
+
if let reason = sample.metadata?[HKMetadataKeyInsulinDeliveryReason] as? NSNumber {
|
|
672
|
+
switch HKInsulinDeliveryReason(rawValue: reason.intValue) {
|
|
673
|
+
case .basal: item["insulinReason"] = "basal"
|
|
674
|
+
case .bolus: item["insulinReason"] = "bolus"
|
|
675
|
+
default: break
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if let meal = sample.metadata?[HKMetadataKeyBloodGlucoseMealTime] as? NSNumber {
|
|
679
|
+
switch HKBloodGlucoseMealTime(rawValue: meal.intValue) {
|
|
680
|
+
case .preprandial: item["mealTime"] = "beforeMeal"
|
|
681
|
+
case .postprandial: item["mealTime"] = "afterMeal"
|
|
682
|
+
default: break
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return item
|
|
686
|
+
}
|
|
687
|
+
completion(.success(rows))
|
|
688
|
+
}
|
|
689
|
+
healthStore.execute(query)
|
|
690
|
+
}
|
|
691
|
+
|
|
616
692
|
/// One heart-rate sample as a page sees it, from a read or the stream
|
|
617
693
|
/// alike: bpm, when, the app and device that wrote it, a series' count,
|
|
618
694
|
/// lowest and highest, and what the wearer was doing when the watch says.
|
package/dist/mobile.js
CHANGED
|
@@ -1715,6 +1715,12 @@ var health = {
|
|
|
1715
1715
|
throw new Error("Reading heart-rate samples is unavailable");
|
|
1716
1716
|
return craft.health.getHeartRateSamples(options);
|
|
1717
1717
|
},
|
|
1718
|
+
async getSamples(type, options = {}) {
|
|
1719
|
+
const craft = getCraftRoot();
|
|
1720
|
+
if (!craft?.health?.getSamples)
|
|
1721
|
+
throw new Error("Reading health samples is unavailable");
|
|
1722
|
+
return craft.health.getSamples(type, options);
|
|
1723
|
+
},
|
|
1718
1724
|
async getSleepIntervals(options = {}) {
|
|
1719
1725
|
const craft = getCraftRoot();
|
|
1720
1726
|
if (!craft?.health?.getSleepIntervals)
|