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
5 changes: 5 additions & 0 deletions .changeset/add-query-activity-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@kingstinct/react-native-healthkit': minor
---

Add support for executing `HKActivitySummaryQuery` via `queryActivitySummary`
40 changes: 40 additions & 0 deletions packages/react-native-healthkit/ios/CoreModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -549,4 +549,44 @@ class CoreModule: HybridCoreModuleSpec {
return Double(successCounts.filter { $0 }.count)
}

func queryActivitySummary(filter: ActivitySummaryQueryFilter?) -> Promise<[ActivitySummary]> {
return Promise.async {
let calendar = Calendar.current
let toComponents: (Date?) -> DateComponents? = { date in
guard let date else { return nil }
var comps = calendar.dateComponents([.year, .month, .day, .era], from: date)
comps.calendar = calendar
return comps
}

let start = toComponents(filter?.date?.startDate)
let end = toComponents(filter?.date?.endDate)

let predicate: NSPredicate? = switch (start, end) {
case let (.some(start), .some(end)):
HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
case let (.some(start), .none):
HKQuery.predicateForActivitySummary(with: start)
case let (.none, .some(end)):
HKQuery.predicateForActivitySummary(with: end)
default:
nil
}

return try await withCheckedThrowingContinuation { continuation in
let query = HKActivitySummaryQuery(predicate: predicate) { (_, summaries, error) in
if let error = error {
continuation.resume(throwing: error)
return
}

let serialized = (summaries ?? []).map { serializeActivitySummary($0) }
continuation.resume(returning: serialized)
}

healthStore.execute(query)
}
}
}

}
72 changes: 72 additions & 0 deletions packages/react-native-healthkit/ios/Serializers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,75 @@ func serializeSampleType(_ sampleType: HKSampleType) -> SampleType {
isMaximumDurationRestricted: sampleType.isMaximumDurationRestricted
)
}

func serializeActivitySummary(_ summary: HKActivitySummary) -> ActivitySummary {
let dateComponents = summary.dateComponents(for: Calendar.current)
let serializedDateComponents = ActivitySummaryDateComponents(
era: dateComponents.era.map(Double.init),
year: dateComponents.year.map(Double.init),
month: dateComponents.month.map(Double.init),
day: dateComponents.day.map(Double.init)
)

let moveMode: ActivityMoveMode
if summary.activityMoveMode == .appleMoveTime {
moveMode = .applemovetime
} else {
moveMode = .activeenergy
}

let activeEnergyBurned = serializeQuantityTyped(
unit: HKUnit.kilocalorie(),
quantity: summary.activeEnergyBurned
)
let activeEnergyBurnedGoal = serializeQuantityTyped(
unit: HKUnit.kilocalorie(),
quantity: summary.activeEnergyBurnedGoal
)
let appleExerciseTime = serializeQuantityTyped(
unit: HKUnit.minute(),
quantity: summary.appleExerciseTime
)
let appleExerciseTimeGoal = serializeQuantityTyped(
unit: HKUnit.minute(),
quantity: summary.appleExerciseTimeGoal
)
let appleStandHours = serializeQuantityTyped(
unit: HKUnit.count(),
quantity: summary.appleStandHours
)
let appleStandHoursGoal = serializeQuantityTyped(
unit: HKUnit.count(),
quantity: summary.appleStandHoursGoal
)

let appleMoveTime = serializeQuantityTyped(
unit: HKUnit.minute(),
quantityNullable: summary.appleMoveTime
)
let appleMoveTimeGoal = serializeQuantityTyped(
unit: HKUnit.minute(),
quantityNullable: summary.appleMoveTimeGoal
)

var isPaused: Bool?
#if compiler(>=6)
if #available(iOS 18.0, *) {
isPaused = summary.isPaused
}
#endif

return ActivitySummary(
dateComponents: serializedDateComponents,
activityMoveMode: moveMode,
activeEnergyBurned: activeEnergyBurned,
activeEnergyBurnedGoal: activeEnergyBurnedGoal,
appleExerciseTime: appleExerciseTime,
appleExerciseTimeGoal: appleExerciseTimeGoal,
appleStandHours: appleStandHours,
appleStandHoursGoal: appleStandHoursGoal,
appleMoveTime: appleMoveTime,
appleMoveTimeGoal: appleMoveTimeGoal,
isPaused: isPaused
)
}
3 changes: 3 additions & 0 deletions packages/react-native-healthkit/src/healthkit.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,10 @@ export const getFitzpatrickSkinTypeAsync =
export const getWheelchairUseAsync =
Characteristics.getWheelchairUseAsync.bind(Characteristics)

export const queryActivitySummary = Core.queryActivitySummary.bind(Core)

export default {
queryActivitySummary,
authorizationStatusFor,
isObjectTypeAvailable,
isObjectTypeAvailableAsync,
Expand Down
5 changes: 5 additions & 0 deletions packages/react-native-healthkit/src/healthkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export const querySources = UnavailableFnFromModule(
'querySources',
Promise.resolve([]),
)
export const queryActivitySummary = UnavailableFnFromModule(
'queryActivitySummary',
Promise.resolve([]),
)
export const requestAuthorization = UnavailableFnFromModule(
'requestAuthorization',
Promise.resolve(false),
Expand Down Expand Up @@ -566,6 +570,7 @@ const HealthkitModule = {
queryStatisticsCollectionForQuantitySeparateBySource,
queryWorkoutSamples,
queryWorkoutSamplesWithAnchor,
queryActivitySummary,
querySources,
requestAuthorization,
deleteObjects,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test'
import {
ActivityMoveMode,
type ActivitySummary,
type ActivitySummaryDateComponents,
type ActivitySummaryQueryFilter,
} from '../types/ActivitySummary'

describe('ActivitySummary Types and Interface', () => {
test('should construct a valid ActivitySummary object with all required properties', () => {
const dateComponents: ActivitySummaryDateComponents = {
era: 1,
year: 2026,
month: 10,
day: 8,
}

const summary: ActivitySummary = {
dateComponents,
activityMoveMode: ActivityMoveMode.activeEnergy,
activeEnergyBurned: {
unit: 'kcal',
quantity: 450.5,
},
activeEnergyBurnedGoal: {
unit: 'kcal',
quantity: 600,
},
appleExerciseTime: {
unit: 'min',
quantity: 42,
},
appleExerciseTimeGoal: {
unit: 'min',
quantity: 30,
},
appleStandHours: {
unit: 'count',
quantity: 11,
},
appleStandHoursGoal: {
unit: 'count',
quantity: 12,
},
appleMoveTime: {
unit: 'min',
quantity: 50,
},
appleMoveTimeGoal: {
unit: 'min',
quantity: 60,
},
isPaused: false,
}

expect(summary.activityMoveMode).toBe(ActivityMoveMode.activeEnergy)
expect(summary.activeEnergyBurned.quantity).toBe(450.5)
expect(summary.appleExerciseTime.quantity).toBe(42)
expect(summary.appleStandHours.quantity).toBe(11)
expect(summary.isPaused).toBe(false)
expect(summary.dateComponents.year).toBe(2026)
})

test('should support ActivitySummaryQueryFilter with DateFilter', () => {
const filter: ActivitySummaryQueryFilter = {
date: {
startDate: new Date('2026-10-01T00:00:00Z'),
endDate: new Date('2026-10-08T23:59:59Z'),
},
}

expect(filter.date?.startDate).toBeInstanceOf(Date)
expect(filter.date?.endDate).toBeInstanceOf(Date)
})

test('should support ActivityMoveMode enum values', () => {
expect(ActivityMoveMode.activeEnergy).toBe(1)
expect(ActivityMoveMode.appleMoveTime).toBe(2)
})
})
12 changes: 12 additions & 0 deletions packages/react-native-healthkit/src/specs/CoreModule.nitro.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { HybridObject } from 'react-native-nitro-modules'
import type {
ActivitySummary,
ActivitySummaryQueryFilter,
} from '../types/ActivitySummary'
import type {
AuthorizationRequestStatus,
AuthorizationStatus,
Expand Down Expand Up @@ -155,4 +159,12 @@ export interface CoreModule extends HybridObject<{ ios: 'swift' }> {
isObjectTypeAvailableAsync(
objectTypeIdentifier: ObjectTypeIdentifier,
): Promise<boolean>

/**
* Queries activity summary objects from HealthKit.
* @see {@link https://developer.apple.com/documentation/healthkit/hkactivitysummaryquery Apple Docs HKActivitySummaryQuery}
*/
queryActivitySummary(
filter?: ActivitySummaryQueryFilter,
): Promise<readonly ActivitySummary[]>
}
1 change: 1 addition & 0 deletions packages/react-native-healthkit/src/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const mockModule = {
queryHeartbeatSeriesSamplesWithAnchor: jest.fn(),
queryQuantitySamples: jest.fn(),
querySources: jest.fn(),
queryActivitySummary: jest.fn(),
queryStatisticsForQuantity: jest.fn(),
queryStatisticsCollectionForQuantity: jest.fn(),
queryWorkoutSamples: jest.fn(),
Expand Down
40 changes: 40 additions & 0 deletions packages/react-native-healthkit/src/types/ActivitySummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Quantity } from './QuantityType'
import type { DateFilter } from './QueryOptions'

/**
* Raw values match `HKActivityMoveMode`.
* @see {@link https://developer.apple.com/documentation/healthkit/hkactivitymovemode Apple Docs HKActivityMoveMode}
*/
export enum ActivityMoveMode {
activeEnergy = 1,
appleMoveTime = 2,
}

export interface ActivitySummaryDateComponents {
readonly era?: number
readonly year?: number
readonly month?: number
readonly day?: number
}

/**
* Represents daily Move, Exercise, and Stand ring data from HealthKit.
* @see {@link https://developer.apple.com/documentation/healthkit/hkactivitysummary Apple Docs HKActivitySummary}
*/
export interface ActivitySummary {
readonly dateComponents: ActivitySummaryDateComponents
readonly activityMoveMode: ActivityMoveMode
readonly activeEnergyBurned: Quantity
readonly activeEnergyBurnedGoal: Quantity
readonly appleExerciseTime: Quantity
readonly appleExerciseTimeGoal: Quantity
readonly appleStandHours: Quantity
readonly appleStandHoursGoal: Quantity
readonly appleMoveTime?: Quantity
readonly appleMoveTimeGoal?: Quantity
readonly isPaused?: boolean
}

export interface ActivitySummaryQueryFilter {
readonly date?: DateFilter
}
1 change: 1 addition & 0 deletions packages/react-native-healthkit/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ActivitySummary'
export * from './Auth'
export * from './Background'
export * from './CategoryType'
Expand Down
Loading