@flomentumsolutions/capacitor-health-extended 0.1.0 → 0.3.1
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/{FlomentumsolutionsCapacitorHealthExtended.podspec → FlomentumSolutionsCapacitorHealthExtended.podspec} +7 -7
- package/LICENSE +1 -0
- package/Package.swift +27 -0
- package/README.md +38 -11
- package/android/build.gradle +15 -10
- package/android/src/main/AndroidManifest.xml +1 -1
- package/android/src/main/java/com/flomentum/health/capacitor/HealthPlugin.kt +548 -6
- package/dist/esm/definitions.d.ts +7 -6
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPluginPlugin/HealthPlugin.swift +262 -26
- package/package.json +34 -16
|
@@ -55,7 +55,7 @@ export interface HealthPlugin {
|
|
|
55
55
|
* @param request
|
|
56
56
|
*/
|
|
57
57
|
queryLatestSample(request: {
|
|
58
|
-
dataType:
|
|
58
|
+
dataType: LatestDataType;
|
|
59
59
|
}): Promise<QueryLatestSampleResponse>;
|
|
60
60
|
/**
|
|
61
61
|
* Query latest weight sample
|
|
@@ -74,14 +74,13 @@ export interface HealthPlugin {
|
|
|
74
74
|
*/
|
|
75
75
|
querySteps(): Promise<QueryLatestSampleResponse>;
|
|
76
76
|
}
|
|
77
|
-
export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE';
|
|
77
|
+
export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME';
|
|
78
|
+
export type LatestDataType = 'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'hrv' | 'blood-pressure';
|
|
78
79
|
export interface PermissionsRequest {
|
|
79
80
|
permissions: HealthPermission[];
|
|
80
81
|
}
|
|
81
82
|
export interface PermissionResponse {
|
|
82
|
-
permissions:
|
|
83
|
-
[key: string]: boolean;
|
|
84
|
-
}[];
|
|
83
|
+
permissions: Record<HealthPermission, boolean>;
|
|
85
84
|
}
|
|
86
85
|
export interface QueryWorkoutRequest {
|
|
87
86
|
startDate: string;
|
|
@@ -120,7 +119,7 @@ export interface Workout {
|
|
|
120
119
|
export interface QueryAggregatedRequest {
|
|
121
120
|
startDate: string;
|
|
122
121
|
endDate: string;
|
|
123
|
-
dataType: 'steps' | 'active-calories' | 'mindfulness' | 'hrv' | 'blood-pressure';
|
|
122
|
+
dataType: 'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'sleep' | 'mindfulness' | 'hrv' | 'blood-pressure';
|
|
124
123
|
bucket: string;
|
|
125
124
|
}
|
|
126
125
|
export interface QueryAggregatedResponse {
|
|
@@ -136,5 +135,7 @@ export interface QueryLatestSampleResponse {
|
|
|
136
135
|
systolic?: number;
|
|
137
136
|
diastolic?: number;
|
|
138
137
|
timestamp: number;
|
|
138
|
+
endTimestamp?: number;
|
|
139
139
|
unit: string;
|
|
140
|
+
metadata?: Record<string, unknown>;
|
|
140
141
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * Query aggregated data\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * @param request\n */\n queryLatestSample(request: { dataType:
|
|
1
|
+
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * Query aggregated data\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * @param request\n */\n queryLatestSample(request: { dataType: LatestDataType }): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest weight sample\n */\n queryWeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest height sample\n */\n queryHeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest heart rate sample\n */\n queryHeartRate(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest steps sample\n */\n querySteps(): Promise<QueryLatestSampleResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE'\n | 'READ_BASAL_CALORIES'\n | 'READ_RESPIRATORY_RATE'\n | 'READ_OXYGEN_SATURATION'\n | 'READ_BLOOD_GLUCOSE'\n | 'READ_BODY_TEMPERATURE'\n | 'READ_BASAL_BODY_TEMPERATURE'\n | 'READ_BODY_FAT'\n | 'READ_FLOORS_CLIMBED'\n | 'READ_SLEEP'\n | 'READ_EXERCISE_TIME';\n\nexport type LatestDataType =\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'mindfulness'\n | 'sleep'\n | 'hrv'\n | 'blood-pressure';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: Record<HealthPermission, boolean>;\n}\n\nexport interface QueryWorkoutRequest {\n startDate: string;\n endDate: string;\n includeHeartRate: boolean;\n includeRoute: boolean;\n includeSteps: boolean;\n}\n\nexport interface HeartRateSample {\n timestamp: string;\n bpm: number;\n}\n\nexport interface RouteSample {\n timestamp: string;\n lat: number;\n lng: number;\n alt?: number;\n}\n\nexport interface QueryWorkoutResponse {\n workouts: Workout[];\n}\n\nexport interface Workout {\n startDate: string;\n endDate: string;\n workoutType: string;\n sourceName: string;\n id?: string;\n duration: number;\n distance?: number;\n steps?: number;\n calories: number;\n sourceBundleId: string;\n route?: RouteSample[];\n heartRate?: HeartRateSample[];\n}\n\nexport interface QueryAggregatedRequest {\n startDate: string;\n endDate: string;\n dataType:\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'sleep'\n | 'mindfulness'\n | 'hrv'\n | 'blood-pressure';\n bucket: string;\n}\n\nexport interface QueryAggregatedResponse {\n aggregatedData: AggregatedSample[];\n}\n\nexport interface AggregatedSample {\n startDate: string;\n endDate: string;\n value: number;\n}\n\nexport interface QueryLatestSampleResponse {\n value?: number;\n systolic?: number;\n diastolic?: number;\n timestamp: number;\n endTimestamp?: number;\n unit: string;\n metadata?: Record<string, unknown>;\n}\n"]}
|
|
@@ -27,7 +27,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
27
27
|
let healthStore = HKHealthStore()
|
|
28
28
|
|
|
29
29
|
/// Serial queue to make route‑location mutations thread‑safe without locks
|
|
30
|
-
private let routeSyncQueue = DispatchQueue(label: "com.
|
|
30
|
+
private let routeSyncQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.routeSync")
|
|
31
31
|
|
|
32
32
|
@objc func isHealthAvailable(_ call: CAPPluginCall) {
|
|
33
33
|
let isAvailable = HKHealthStore.isHealthDataAvailable()
|
|
@@ -153,6 +153,68 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
153
153
|
healthStore.execute(query)
|
|
154
154
|
return
|
|
155
155
|
}
|
|
156
|
+
// ---- Special handling for sleep sessions (category samples) ----
|
|
157
|
+
if dataTypeString == "sleep" {
|
|
158
|
+
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
|
|
159
|
+
call.reject("Sleep type not available")
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
|
164
|
+
let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
|
|
165
|
+
|
|
166
|
+
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, error in
|
|
167
|
+
guard let sleepSample = samples?.first as? HKCategorySample else {
|
|
168
|
+
if let error = error {
|
|
169
|
+
call.reject("Error fetching latest sleep sample", "NO_SAMPLE", error)
|
|
170
|
+
} else {
|
|
171
|
+
call.reject("No sleep sample found", "NO_SAMPLE")
|
|
172
|
+
}
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
let durationMinutes = sleepSample.endDate.timeIntervalSince(sleepSample.startDate) / 60
|
|
176
|
+
call.resolve([
|
|
177
|
+
"value": durationMinutes,
|
|
178
|
+
"timestamp": sleepSample.startDate.timeIntervalSince1970 * 1000,
|
|
179
|
+
"endTimestamp": sleepSample.endDate.timeIntervalSince1970 * 1000,
|
|
180
|
+
"unit": "min",
|
|
181
|
+
"metadata": ["state": sleepSample.value]
|
|
182
|
+
])
|
|
183
|
+
}
|
|
184
|
+
healthStore.execute(query)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
// ---- Special handling for mindfulness sessions (category samples) ----
|
|
188
|
+
if dataTypeString == "mindfulness" {
|
|
189
|
+
guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
|
|
190
|
+
call.reject("Mindfulness type not available")
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
|
195
|
+
let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
|
|
196
|
+
|
|
197
|
+
let query = HKSampleQuery(sampleType: mindfulType, predicate: predicate, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, error in
|
|
198
|
+
guard let mindfulSample = samples?.first as? HKCategorySample else {
|
|
199
|
+
if let error = error {
|
|
200
|
+
call.reject("Error fetching latest mindfulness sample", "NO_SAMPLE", error)
|
|
201
|
+
} else {
|
|
202
|
+
call.reject("No mindfulness sample found", "NO_SAMPLE")
|
|
203
|
+
}
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
let durationMinutes = mindfulSample.endDate.timeIntervalSince(mindfulSample.startDate) / 60
|
|
207
|
+
call.resolve([
|
|
208
|
+
"value": durationMinutes,
|
|
209
|
+
"timestamp": mindfulSample.startDate.timeIntervalSince1970 * 1000,
|
|
210
|
+
"endTimestamp": mindfulSample.endDate.timeIntervalSince1970 * 1000,
|
|
211
|
+
"unit": "min",
|
|
212
|
+
"metadata": ["value": mindfulSample.value]
|
|
213
|
+
])
|
|
214
|
+
}
|
|
215
|
+
healthStore.execute(query)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
156
218
|
guard aggregateTypeToHKQuantityType(dataTypeString) != nil else {
|
|
157
219
|
call.reject("Invalid data type")
|
|
158
220
|
return
|
|
@@ -162,6 +224,10 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
162
224
|
switch dataTypeString {
|
|
163
225
|
case "heart-rate":
|
|
164
226
|
return HKObjectType.quantityType(forIdentifier: .heartRate)
|
|
227
|
+
case "resting-heart-rate":
|
|
228
|
+
return HKObjectType.quantityType(forIdentifier: .restingHeartRate)
|
|
229
|
+
case "respiratory-rate":
|
|
230
|
+
return HKObjectType.quantityType(forIdentifier: .respiratoryRate)
|
|
165
231
|
case "weight":
|
|
166
232
|
return HKObjectType.quantityType(forIdentifier: .bodyMass)
|
|
167
233
|
case "steps":
|
|
@@ -172,12 +238,30 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
172
238
|
return HKObjectType.quantityType(forIdentifier: .height)
|
|
173
239
|
case "distance":
|
|
174
240
|
return HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning)
|
|
241
|
+
case "distance-cycling":
|
|
242
|
+
return HKObjectType.quantityType(forIdentifier: .distanceCycling)
|
|
175
243
|
case "active-calories":
|
|
176
244
|
return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
|
|
177
245
|
case "total-calories":
|
|
178
246
|
return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
|
|
247
|
+
case "basal-calories":
|
|
248
|
+
return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
|
|
179
249
|
case "blood-pressure":
|
|
180
250
|
return nil // handled above
|
|
251
|
+
case "oxygen-saturation":
|
|
252
|
+
return HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
|
|
253
|
+
case "blood-glucose":
|
|
254
|
+
return HKObjectType.quantityType(forIdentifier: .bloodGlucose)
|
|
255
|
+
case "body-temperature":
|
|
256
|
+
return HKObjectType.quantityType(forIdentifier: .bodyTemperature)
|
|
257
|
+
case "basal-body-temperature":
|
|
258
|
+
return HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)
|
|
259
|
+
case "body-fat":
|
|
260
|
+
return HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)
|
|
261
|
+
case "flights-climbed":
|
|
262
|
+
return HKObjectType.quantityType(forIdentifier: .flightsClimbed)
|
|
263
|
+
case "exercise-time":
|
|
264
|
+
return HKObjectType.quantityType(forIdentifier: .appleExerciseTime)
|
|
181
265
|
default:
|
|
182
266
|
return nil
|
|
183
267
|
}
|
|
@@ -209,16 +293,36 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
209
293
|
var unit: HKUnit = .count()
|
|
210
294
|
if dataTypeString == "heart-rate" {
|
|
211
295
|
unit = HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
296
|
+
} else if dataTypeString == "resting-heart-rate" {
|
|
297
|
+
unit = HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
212
298
|
} else if dataTypeString == "weight" {
|
|
213
299
|
unit = .gramUnit(with: .kilo)
|
|
214
300
|
} else if dataTypeString == "hrv" {
|
|
215
301
|
unit = HKUnit.secondUnit(with: .milli)
|
|
216
302
|
} else if dataTypeString == "distance" {
|
|
217
303
|
unit = HKUnit.meter()
|
|
304
|
+
} else if dataTypeString == "distance-cycling" {
|
|
305
|
+
unit = HKUnit.meter()
|
|
218
306
|
} else if dataTypeString == "active-calories" || dataTypeString == "total-calories" {
|
|
219
307
|
unit = HKUnit.kilocalorie()
|
|
308
|
+
} else if dataTypeString == "basal-calories" {
|
|
309
|
+
unit = HKUnit.kilocalorie()
|
|
220
310
|
} else if dataTypeString == "height" {
|
|
221
311
|
unit = HKUnit.meter()
|
|
312
|
+
} else if dataTypeString == "oxygen-saturation" {
|
|
313
|
+
unit = HKUnit.percent()
|
|
314
|
+
} else if dataTypeString == "blood-glucose" {
|
|
315
|
+
unit = HKUnit(from: "mg/dL")
|
|
316
|
+
} else if dataTypeString == "body-temperature" || dataTypeString == "basal-body-temperature" {
|
|
317
|
+
unit = HKUnit.degreeCelsius()
|
|
318
|
+
} else if dataTypeString == "body-fat" {
|
|
319
|
+
unit = HKUnit.percent()
|
|
320
|
+
} else if dataTypeString == "flights-climbed" {
|
|
321
|
+
unit = HKUnit.count()
|
|
322
|
+
} else if dataTypeString == "exercise-time" {
|
|
323
|
+
unit = HKUnit.minute()
|
|
324
|
+
} else if dataTypeString == "respiratory-rate" {
|
|
325
|
+
unit = HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
222
326
|
}
|
|
223
327
|
let value = quantitySample.quantity.doubleValue(for: unit)
|
|
224
328
|
let timestamp = quantitySample.startDate.timeIntervalSince1970 * 1000
|
|
@@ -317,6 +421,8 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
317
421
|
return [HKObjectType.workoutType()].compactMap{$0}
|
|
318
422
|
case "READ_HEART_RATE":
|
|
319
423
|
return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
|
|
424
|
+
case "READ_RESTING_HEART_RATE":
|
|
425
|
+
return [HKObjectType.quantityType(forIdentifier: .restingHeartRate)].compactMap { $0 }
|
|
320
426
|
case "READ_ROUTE":
|
|
321
427
|
return [HKSeriesType.workoutRoute()].compactMap{$0}
|
|
322
428
|
case "READ_DISTANCE":
|
|
@@ -335,6 +441,28 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
335
441
|
HKObjectType.quantityType(forIdentifier: .bloodPressureSystolic),
|
|
336
442
|
HKObjectType.quantityType(forIdentifier: .bloodPressureDiastolic)
|
|
337
443
|
].compactMap { $0 }
|
|
444
|
+
case "READ_RESPIRATORY_RATE":
|
|
445
|
+
return [HKObjectType.quantityType(forIdentifier: .respiratoryRate)].compactMap { $0 }
|
|
446
|
+
case "READ_OXYGEN_SATURATION":
|
|
447
|
+
return [HKObjectType.quantityType(forIdentifier: .oxygenSaturation)].compactMap { $0 }
|
|
448
|
+
case "READ_BLOOD_GLUCOSE":
|
|
449
|
+
return [HKObjectType.quantityType(forIdentifier: .bloodGlucose)].compactMap { $0 }
|
|
450
|
+
case "READ_BODY_TEMPERATURE":
|
|
451
|
+
return [HKObjectType.quantityType(forIdentifier: .bodyTemperature)].compactMap { $0 }
|
|
452
|
+
case "READ_BASAL_BODY_TEMPERATURE":
|
|
453
|
+
return [HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)].compactMap { $0 }
|
|
454
|
+
case "READ_BODY_FAT":
|
|
455
|
+
return [HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)].compactMap { $0 }
|
|
456
|
+
case "READ_FLOORS_CLIMBED":
|
|
457
|
+
return [HKObjectType.quantityType(forIdentifier: .flightsClimbed)].compactMap { $0 }
|
|
458
|
+
case "READ_BASAL_CALORIES":
|
|
459
|
+
return [
|
|
460
|
+
HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
|
|
461
|
+
].compactMap { $0 }
|
|
462
|
+
case "READ_SLEEP":
|
|
463
|
+
return [HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!].compactMap { $0 }
|
|
464
|
+
case "READ_EXERCISE_TIME":
|
|
465
|
+
return [HKObjectType.quantityType(forIdentifier: .appleExerciseTime)].compactMap { $0 }
|
|
338
466
|
// Add common alternative permission names
|
|
339
467
|
case "steps":
|
|
340
468
|
return [HKObjectType.quantityType(forIdentifier: .stepCount)].compactMap{$0}
|
|
@@ -371,6 +499,26 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
371
499
|
HKObjectType.quantityType(forIdentifier: .bloodPressureSystolic),
|
|
372
500
|
HKObjectType.quantityType(forIdentifier: .bloodPressureDiastolic)
|
|
373
501
|
].compactMap { $0 }
|
|
502
|
+
case "respiratory-rate":
|
|
503
|
+
return [HKObjectType.quantityType(forIdentifier: .respiratoryRate)].compactMap { $0 }
|
|
504
|
+
case "oxygen-saturation":
|
|
505
|
+
return [HKObjectType.quantityType(forIdentifier: .oxygenSaturation)].compactMap { $0 }
|
|
506
|
+
case "blood-glucose":
|
|
507
|
+
return [HKObjectType.quantityType(forIdentifier: .bloodGlucose)].compactMap { $0 }
|
|
508
|
+
case "body-temperature":
|
|
509
|
+
return [HKObjectType.quantityType(forIdentifier: .bodyTemperature)].compactMap { $0 }
|
|
510
|
+
case "basal-body-temperature":
|
|
511
|
+
return [HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)].compactMap { $0 }
|
|
512
|
+
case "body-fat":
|
|
513
|
+
return [HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)].compactMap { $0 }
|
|
514
|
+
case "flights-climbed":
|
|
515
|
+
return [HKObjectType.quantityType(forIdentifier: .flightsClimbed)].compactMap { $0 }
|
|
516
|
+
case "basal-calories":
|
|
517
|
+
return [HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)].compactMap { $0 }
|
|
518
|
+
case "exercise-time":
|
|
519
|
+
return [HKObjectType.quantityType(forIdentifier: .appleExerciseTime)].compactMap { $0 }
|
|
520
|
+
case "sleep":
|
|
521
|
+
return [HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!].compactMap { $0 }
|
|
374
522
|
default:
|
|
375
523
|
print("⚡️ [HealthPlugin] Unknown permission: \(permission)")
|
|
376
524
|
return []
|
|
@@ -385,16 +533,38 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
385
533
|
return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
|
|
386
534
|
case "heart-rate":
|
|
387
535
|
return HKObjectType.quantityType(forIdentifier: .heartRate)
|
|
536
|
+
case "resting-heart-rate":
|
|
537
|
+
return HKObjectType.quantityType(forIdentifier: .restingHeartRate)
|
|
388
538
|
case "weight":
|
|
389
539
|
return HKObjectType.quantityType(forIdentifier: .bodyMass)
|
|
390
540
|
case "hrv":
|
|
391
541
|
return HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
|
|
392
542
|
case "distance":
|
|
393
543
|
return HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning) // pick one rep type
|
|
544
|
+
case "distance-cycling":
|
|
545
|
+
return HKObjectType.quantityType(forIdentifier: .distanceCycling)
|
|
394
546
|
case "total-calories":
|
|
395
547
|
return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
|
|
548
|
+
case "basal-calories":
|
|
549
|
+
return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
|
|
396
550
|
case "height":
|
|
397
551
|
return HKObjectType.quantityType(forIdentifier: .height)
|
|
552
|
+
case "respiratory-rate":
|
|
553
|
+
return HKObjectType.quantityType(forIdentifier: .respiratoryRate)
|
|
554
|
+
case "oxygen-saturation":
|
|
555
|
+
return HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
|
|
556
|
+
case "blood-glucose":
|
|
557
|
+
return HKObjectType.quantityType(forIdentifier: .bloodGlucose)
|
|
558
|
+
case "body-temperature":
|
|
559
|
+
return HKObjectType.quantityType(forIdentifier: .bodyTemperature)
|
|
560
|
+
case "basal-body-temperature":
|
|
561
|
+
return HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)
|
|
562
|
+
case "body-fat":
|
|
563
|
+
return HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)
|
|
564
|
+
case "flights-climbed":
|
|
565
|
+
return HKObjectType.quantityType(forIdentifier: .flightsClimbed)
|
|
566
|
+
case "exercise-time":
|
|
567
|
+
return HKObjectType.quantityType(forIdentifier: .appleExerciseTime)
|
|
398
568
|
default:
|
|
399
569
|
return nil
|
|
400
570
|
}
|
|
@@ -423,6 +593,16 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
423
593
|
}
|
|
424
594
|
}
|
|
425
595
|
}
|
|
596
|
+
} else if dataTypeString == "sleep" {
|
|
597
|
+
self.querySleepAggregated(startDate: startDate, endDate: endDate) { result, error in
|
|
598
|
+
DispatchQueue.main.async {
|
|
599
|
+
if let error = error {
|
|
600
|
+
call.reject(error.localizedDescription)
|
|
601
|
+
} else if let result = result {
|
|
602
|
+
call.resolve(["aggregatedData": result])
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
426
606
|
} else {
|
|
427
607
|
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
|
428
608
|
guard let interval = calculateInterval(bucket: bucket) else {
|
|
@@ -441,7 +621,10 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
441
621
|
switch dataType.aggregationStyle {
|
|
442
622
|
case .cumulative:
|
|
443
623
|
return .cumulativeSum
|
|
444
|
-
case .discrete
|
|
624
|
+
case .discrete,
|
|
625
|
+
.discreteArithmetic,
|
|
626
|
+
.discreteTemporallyWeighted,
|
|
627
|
+
.discreteEquivalentContinuousLevel:
|
|
445
628
|
return .discreteAverage
|
|
446
629
|
@unknown default:
|
|
447
630
|
return .discreteAverage
|
|
@@ -471,12 +654,19 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
471
654
|
let unit: HKUnit = {
|
|
472
655
|
switch dataTypeString {
|
|
473
656
|
case "steps": return .count()
|
|
474
|
-
case "active-calories", "total-calories": return .kilocalorie()
|
|
475
|
-
case "distance": return .meter()
|
|
657
|
+
case "active-calories", "total-calories", "basal-calories": return .kilocalorie()
|
|
658
|
+
case "distance", "distance-cycling": return .meter()
|
|
476
659
|
case "weight": return .gramUnit(with: .kilo)
|
|
477
660
|
case "height": return .meter()
|
|
478
|
-
case "heart-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
661
|
+
case "heart-rate", "resting-heart-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
662
|
+
case "respiratory-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
479
663
|
case "hrv": return HKUnit.secondUnit(with: .milli)
|
|
664
|
+
case "oxygen-saturation": return HKUnit.percent()
|
|
665
|
+
case "blood-glucose": return HKUnit(from: "mg/dL")
|
|
666
|
+
case "body-temperature", "basal-body-temperature": return HKUnit.degreeCelsius()
|
|
667
|
+
case "body-fat": return HKUnit.percent()
|
|
668
|
+
case "flights-climbed": return .count()
|
|
669
|
+
case "exercise-time": return HKUnit.minute()
|
|
480
670
|
case "mindfulness": return HKUnit.second()
|
|
481
671
|
default: return .count()
|
|
482
672
|
}
|
|
@@ -532,6 +722,46 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
532
722
|
}
|
|
533
723
|
healthStore.execute(query)
|
|
534
724
|
}
|
|
725
|
+
|
|
726
|
+
func querySleepAggregated(startDate: Date, endDate: Date, completion: @escaping ([[String: Any]]?, Error?) -> Void) {
|
|
727
|
+
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
|
|
728
|
+
DispatchQueue.main.async {
|
|
729
|
+
completion(nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "SleepAnalysis type unavailable"]))
|
|
730
|
+
}
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
|
734
|
+
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
735
|
+
var dailyDurations: [Date: TimeInterval] = [:]
|
|
736
|
+
let calendar = Calendar.current
|
|
737
|
+
if let categorySamples = samples as? [HKCategorySample], error == nil {
|
|
738
|
+
for sample in categorySamples {
|
|
739
|
+
// Ignore in-bed samples; we care about actual sleep
|
|
740
|
+
if sample.value == HKCategoryValueSleepAnalysis.inBed.rawValue { continue }
|
|
741
|
+
let startOfDay = calendar.startOfDay(for: sample.startDate)
|
|
742
|
+
let duration = sample.endDate.timeIntervalSince(sample.startDate)
|
|
743
|
+
dailyDurations[startOfDay, default: 0] += duration
|
|
744
|
+
}
|
|
745
|
+
var aggregatedSamples: [[String: Any]] = []
|
|
746
|
+
let dayComponent = DateComponents(day: 1)
|
|
747
|
+
for (date, duration) in dailyDurations {
|
|
748
|
+
aggregatedSamples.append([
|
|
749
|
+
"startDate": date,
|
|
750
|
+
"endDate": calendar.date(byAdding: dayComponent, to: date) as Any,
|
|
751
|
+
"value": duration
|
|
752
|
+
])
|
|
753
|
+
}
|
|
754
|
+
DispatchQueue.main.async {
|
|
755
|
+
completion(aggregatedSamples, nil)
|
|
756
|
+
}
|
|
757
|
+
} else {
|
|
758
|
+
DispatchQueue.main.async {
|
|
759
|
+
completion(nil, error)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
healthStore.execute(query)
|
|
764
|
+
}
|
|
535
765
|
|
|
536
766
|
private func queryAggregated(for startDate: Date, for endDate: Date, for dataType: HKQuantityType?, completion: @escaping (Double?) -> Void) {
|
|
537
767
|
guard let quantityType = dataType else {
|
|
@@ -609,7 +839,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
609
839
|
return
|
|
610
840
|
}
|
|
611
841
|
let outerGroup = DispatchGroup()
|
|
612
|
-
let resultsQueue = DispatchQueue(label: "com.
|
|
842
|
+
let resultsQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.workoutResults")
|
|
613
843
|
var workoutResults: [[String: Any]] = []
|
|
614
844
|
var errors: [String: String] = [:]
|
|
615
845
|
|
|
@@ -627,48 +857,54 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
627
857
|
"distance": workout.totalDistance?.doubleValue(for: .meter()) ?? 0
|
|
628
858
|
]
|
|
629
859
|
let innerGroup = DispatchGroup()
|
|
860
|
+
let heartRateQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.heartRates")
|
|
861
|
+
let routeQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.routes")
|
|
630
862
|
var localHeartRates: [[String: Any]] = []
|
|
631
863
|
var localRoutes: [[String: Any]] = []
|
|
632
864
|
|
|
633
865
|
if includeHeartRate {
|
|
634
866
|
innerGroup.enter()
|
|
635
867
|
self.queryHeartRate(for: workout) { rates, error in
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
errors["heart-rate"] = error
|
|
868
|
+
heartRateQueue.async {
|
|
869
|
+
localHeartRates = rates
|
|
870
|
+
if let error = error {
|
|
871
|
+
errors["heart-rate"] = error
|
|
640
872
|
}
|
|
873
|
+
innerGroup.leave()
|
|
641
874
|
}
|
|
642
|
-
innerGroup.leave()
|
|
643
875
|
}
|
|
644
876
|
}
|
|
645
877
|
if includeRoute {
|
|
646
878
|
innerGroup.enter()
|
|
647
879
|
self.queryRoute(for: workout) { routes, error in
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
errors["route"] = error
|
|
880
|
+
routeQueue.async {
|
|
881
|
+
localRoutes = routes
|
|
882
|
+
if let error = error {
|
|
883
|
+
errors["route"] = error
|
|
652
884
|
}
|
|
885
|
+
innerGroup.leave()
|
|
653
886
|
}
|
|
654
|
-
innerGroup.leave()
|
|
655
887
|
}
|
|
656
888
|
}
|
|
657
889
|
if includeSteps {
|
|
658
890
|
innerGroup.enter()
|
|
659
891
|
self.queryAggregated(for: workout.startDate, for: workout.endDate, for: HKObjectType.quantityType(forIdentifier: .stepCount)) { steps in
|
|
660
|
-
|
|
661
|
-
|
|
892
|
+
resultsQueue.async {
|
|
893
|
+
if let steps = steps {
|
|
894
|
+
localDict["steps"] = steps
|
|
895
|
+
}
|
|
896
|
+
innerGroup.leave()
|
|
662
897
|
}
|
|
663
|
-
innerGroup.leave()
|
|
664
898
|
}
|
|
665
899
|
}
|
|
666
|
-
innerGroup.notify(queue:
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
900
|
+
innerGroup.notify(queue: resultsQueue) {
|
|
901
|
+
heartRateQueue.sync {
|
|
902
|
+
localDict["heartRate"] = localHeartRates
|
|
903
|
+
}
|
|
904
|
+
routeQueue.sync {
|
|
905
|
+
localDict["route"] = localRoutes
|
|
671
906
|
}
|
|
907
|
+
workoutResults.append(localDict)
|
|
672
908
|
outerGroup.leave()
|
|
673
909
|
}
|
|
674
910
|
}
|
|
@@ -721,7 +957,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
721
957
|
guard let self = self else { return }
|
|
722
958
|
if let routes = samples as? [HKWorkoutRoute], error == nil {
|
|
723
959
|
let routeDispatchGroup = DispatchGroup()
|
|
724
|
-
let allLocationsQueue = DispatchQueue(label: "com.
|
|
960
|
+
let allLocationsQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.allLocations")
|
|
725
961
|
var allLocations: [[String: Any]] = []
|
|
726
962
|
|
|
727
963
|
for route in routes {
|
|
@@ -870,4 +1106,4 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
870
1106
|
3000 : "other"
|
|
871
1107
|
]
|
|
872
1108
|
|
|
873
|
-
}
|
|
1109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flomentumsolutions/capacitor-health-extended",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Capacitor plugin for Apple HealthKit and Google Health Connect Platform",
|
|
5
5
|
"main": "dist/plugin.cjs.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -13,13 +13,30 @@
|
|
|
13
13
|
"ios/Sources",
|
|
14
14
|
"ios/Tests",
|
|
15
15
|
"Package.swift",
|
|
16
|
-
"
|
|
16
|
+
"FlomentumSolutionsCapacitorHealthExtended.podspec"
|
|
17
17
|
],
|
|
18
|
-
"author":
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Flomentum Solutions, LLC",
|
|
20
|
+
"url": "https://github.com/Flomentum-Solutions"
|
|
21
|
+
},
|
|
19
22
|
"license": "MIT",
|
|
23
|
+
"contributors": [
|
|
24
|
+
{
|
|
25
|
+
"name": "Michael Ley",
|
|
26
|
+
"url": "https://github.com/mley"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "Flomentum Solutions, LLC",
|
|
30
|
+
"url": "https://github.com/Flomentum-Solutions"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "MBRO95",
|
|
34
|
+
"url": "https://github.com/MBRO95"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
20
37
|
"repository": {
|
|
21
38
|
"type": "git",
|
|
22
|
-
"url": "https://github.com/Flomentum-Solutions/capacitor-health-extended.git"
|
|
39
|
+
"url": "git+https://github.com/Flomentum-Solutions/capacitor-health-extended.git"
|
|
23
40
|
},
|
|
24
41
|
"bugs": {
|
|
25
42
|
"url": "https://github.com/Flomentum-Solutions/capacitor-health-extended/issues"
|
|
@@ -36,7 +53,7 @@
|
|
|
36
53
|
],
|
|
37
54
|
"scripts": {
|
|
38
55
|
"verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
|
|
39
|
-
"verify:ios": "xcodebuild -scheme
|
|
56
|
+
"verify:ios": "xcodebuild -scheme FlomentumSolutionsCapacitorHealthExtended -destination generic/platform=iOS",
|
|
40
57
|
"verify:android": "cd android && ./gradlew clean build test && cd ..",
|
|
41
58
|
"verify:web": "npm run build",
|
|
42
59
|
"lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
|
|
@@ -51,23 +68,24 @@
|
|
|
51
68
|
"prepublishOnly": "npm run build"
|
|
52
69
|
},
|
|
53
70
|
"devDependencies": {
|
|
54
|
-
"@capacitor/android": "^
|
|
55
|
-
"@capacitor/
|
|
56
|
-
"@capacitor/
|
|
57
|
-
"@capacitor/
|
|
71
|
+
"@capacitor/android": "^8.0.0",
|
|
72
|
+
"@capacitor/cli": "^8.0.0",
|
|
73
|
+
"@capacitor/core": "^8.0.0",
|
|
74
|
+
"@capacitor/docgen": "^0.3.1",
|
|
75
|
+
"@capacitor/ios": "^8.0.0",
|
|
58
76
|
"@ionic/eslint-config": "^0.4.0",
|
|
59
77
|
"@ionic/prettier-config": "^4.0.0",
|
|
60
78
|
"@ionic/swiftlint-config": "^2.0.0",
|
|
61
|
-
"eslint": "^8.57.
|
|
62
|
-
"prettier": "^3.
|
|
63
|
-
"prettier-plugin-java": "^2.
|
|
64
|
-
"rimraf": "^6.0
|
|
65
|
-
"rollup": "^4.
|
|
79
|
+
"eslint": "^8.57.1",
|
|
80
|
+
"prettier": "^3.6.2",
|
|
81
|
+
"prettier-plugin-java": "^2.7.7",
|
|
82
|
+
"rimraf": "^6.1.0",
|
|
83
|
+
"rollup": "^4.53.2",
|
|
66
84
|
"swiftlint": "^2.0.0",
|
|
67
|
-
"typescript": "
|
|
85
|
+
"typescript": "^5.9.3"
|
|
68
86
|
},
|
|
69
87
|
"peerDependencies": {
|
|
70
|
-
"@capacitor/core": ">=
|
|
88
|
+
"@capacitor/core": ">=8.0.0"
|
|
71
89
|
},
|
|
72
90
|
"prettier": "@ionic/prettier-config",
|
|
73
91
|
"swiftlint": "@ionic/swiftlint-config",
|