@@ -0,0 +1,143 @@
|
||||
import type { HealthFact, HealthProvider } from '@/services/health/types';
|
||||
|
||||
jest.mock('@/services/health/healthProvider', () => ({
|
||||
healthProvider: {
|
||||
id: 'apple-health',
|
||||
supportedActions: ['steps', 'sleep'],
|
||||
isAvailable: jest.fn(),
|
||||
requestAuthorization: jest.fn(),
|
||||
readFacts: jest.fn(),
|
||||
openSettings: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { healthProvider } from '@/services/health/healthProvider';
|
||||
import {
|
||||
normalizeHealthConnection,
|
||||
refreshHealth,
|
||||
} from '@/services/health/healthSync';
|
||||
import { createInitialState, useAppStore } from '@/store/appStore';
|
||||
|
||||
const mockProvider = healthProvider as jest.Mocked<HealthProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAppStore.setState({ ...createInitialState(), hydrated: true });
|
||||
});
|
||||
|
||||
describe('health synchronization', () => {
|
||||
it('drops stale authorization when the persisted provider is from another platform', () => {
|
||||
expect(normalizeHealthConnection({
|
||||
provider: 'health-connect',
|
||||
status: 'authorized',
|
||||
grantedActions: ['steps'],
|
||||
lastRefreshAt: '2026-08-01T00:00:00.000Z',
|
||||
lastError: 'stale',
|
||||
})).toEqual({
|
||||
provider: 'apple-health',
|
||||
status: 'not-requested',
|
||||
grantedActions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads only granted actions and preserves partial authorization', async () => {
|
||||
addAutomaticSignal('steps', 'activity');
|
||||
addAutomaticSignal('sleep', 'calm');
|
||||
useAppStore.getState().setHealthConnection({
|
||||
provider: 'apple-health',
|
||||
status: 'partial',
|
||||
grantedActions: ['steps'],
|
||||
});
|
||||
mockProvider.readFacts.mockResolvedValue([stepFact(120)]);
|
||||
|
||||
const imported = await refreshHealth(
|
||||
new Date('2026-08-16T00:00:00.000Z'),
|
||||
new Date('2026-08-17T23:59:59.000Z'),
|
||||
);
|
||||
|
||||
expect(imported).toBe(1);
|
||||
expect(mockProvider.readFacts).toHaveBeenCalledWith(
|
||||
['steps'],
|
||||
new Date('2026-08-16T00:00:00.000Z'),
|
||||
new Date('2026-08-17T23:59:59.000Z'),
|
||||
);
|
||||
expect(useAppStore.getState().healthConnection).toMatchObject({
|
||||
provider: 'apple-health',
|
||||
status: 'partial',
|
||||
grantedActions: ['steps'],
|
||||
});
|
||||
});
|
||||
|
||||
it('updates a repeated fact without reviving an ignored event or response', async () => {
|
||||
addAutomaticSignal('steps', 'activity');
|
||||
useAppStore.getState().setHealthConnection({
|
||||
provider: 'apple-health',
|
||||
status: 'authorized',
|
||||
grantedActions: ['steps'],
|
||||
});
|
||||
mockProvider.readFacts.mockResolvedValue([stepFact(120)]);
|
||||
|
||||
await refreshHealth();
|
||||
const event = useAppStore.getState().events[0];
|
||||
useAppStore.getState().ignoreEvent(event.id, 'ignore-once');
|
||||
mockProvider.readFacts.mockResolvedValue([stepFact(180)]);
|
||||
|
||||
const imported = await refreshHealth();
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(useAppStore.getState().events).toHaveLength(1);
|
||||
expect(useAppStore.getState().events[0]).toMatchObject({
|
||||
id: event.id,
|
||||
quantity: 180,
|
||||
status: 'ignored',
|
||||
ignoredReason: 'ignore-once',
|
||||
});
|
||||
expect(useAppStore.getState().pendingResponseEventId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not write store state again when a refresh returns the same fact', async () => {
|
||||
addAutomaticSignal('steps', 'activity');
|
||||
useAppStore.getState().setHealthConnection({
|
||||
provider: 'apple-health',
|
||||
status: 'authorized',
|
||||
grantedActions: ['steps'],
|
||||
});
|
||||
mockProvider.readFacts.mockResolvedValue([stepFact(120)]);
|
||||
await refreshHealth();
|
||||
let eventStateUpdates = 0;
|
||||
const unsubscribe = useAppStore.subscribe((state, previous) => {
|
||||
if (state.events !== previous.events) eventStateUpdates += 1;
|
||||
});
|
||||
|
||||
const imported = await refreshHealth();
|
||||
unsubscribe();
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(eventStateUpdates).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function addAutomaticSignal(
|
||||
action: 'steps' | 'sleep',
|
||||
direction: 'activity' | 'calm',
|
||||
) {
|
||||
useAppStore.getState().addSignal({
|
||||
action,
|
||||
direction,
|
||||
source: 'automatic',
|
||||
name: action,
|
||||
});
|
||||
}
|
||||
|
||||
function stepFact(quantity: number): HealthFact {
|
||||
return {
|
||||
action: 'steps',
|
||||
startedAt: '2026-08-17T01:00:00.000Z',
|
||||
endedAt: '2026-08-17T02:00:00.000Z',
|
||||
externalId: 'apple-health:steps:2026-08-17',
|
||||
deviceName: 'Apple Watch',
|
||||
sourceLabel: 'Apple Health',
|
||||
quantity,
|
||||
unit: 'count',
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
|
||||
<uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED" />
|
||||
<uses-permission android:name="android.permission.health.READ_SLEEP" />
|
||||
<uses-permission android:name="android.permission.health.READ_MINDFULNESS" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -22,6 +31,19 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package com.lifecompanion
|
||||
|
||||
import android.os.Bundle
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
import dev.matinzd.healthconnect.permissions.HealthConnectPermissionDelegate
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
HealthConnectPermissionDelegate.setPermissionDelegate(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "36.0.0"
|
||||
minSdkVersion = 24
|
||||
minSdkVersion = 26
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
ndkVersion = "27.1.12297006"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
0C80B921A6F3F58F76C31292 /* libPods-LifeCompanion.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-LifeCompanion.a */; };
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
233DF4482717299D267110DE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
|
||||
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
|
||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -131,6 +132,11 @@
|
||||
TargetAttributes = {
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
LastSwiftMigration = 1120;
|
||||
SystemCapabilities = {
|
||||
com.apple.HealthKit = {
|
||||
enabled = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -159,6 +165,7 @@
|
||||
files = (
|
||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
233DF4482717299D267110DE /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -257,7 +264,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = LifeCompanion/LifeCompanion.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = X75CUV458P;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = LifeCompanion/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
@@ -271,7 +280,7 @@
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.wusu.LifeCompanion;
|
||||
PRODUCT_NAME = LifeCompanion;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -287,7 +296,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = LifeCompanion/LifeCompanion.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = X75CUV458P;
|
||||
INFOPLIST_FILE = LifeCompanion/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -300,7 +311,7 @@
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.wusu.LifeCompanion;
|
||||
PRODUCT_NAME = LifeCompanion;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -370,6 +381,10 @@
|
||||
);
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_CFLAGS = (
|
||||
"$(inherited)",
|
||||
"-DRCT_REMOVE_LEGACY_ARCH=1",
|
||||
);
|
||||
OTHER_CPLUSPLUSFLAGS = (
|
||||
"$(OTHER_CFLAGS)",
|
||||
"-DFOLLY_NO_CONFIG",
|
||||
@@ -377,8 +392,13 @@
|
||||
"-DFOLLY_USE_LIBCPP=1",
|
||||
"-DFOLLY_CFG_NO_COROUTINES=1",
|
||||
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
||||
"-DRCT_REMOVE_LEGACY_ARCH=1",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -435,6 +455,10 @@
|
||||
"\"$(inherited)\"",
|
||||
);
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
OTHER_CFLAGS = (
|
||||
"$(inherited)",
|
||||
"-DRCT_REMOVE_LEGACY_ARCH=1",
|
||||
);
|
||||
OTHER_CPLUSPLUSFLAGS = (
|
||||
"$(OTHER_CFLAGS)",
|
||||
"-DFOLLY_NO_CONFIG",
|
||||
@@ -442,8 +466,12 @@
|
||||
"-DFOLLY_USE_LIBCPP=1",
|
||||
"-DFOLLY_CFG_NO_COROUTINES=1",
|
||||
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
||||
"-DRCT_REMOVE_LEGACY_ARCH=1",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:LifeCompanion.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -28,14 +28,17 @@
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<false/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string></string>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>Life Companion reads the activity, sleep, and mindfulness signals you choose so your companion can respond to facts that already happened.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>Life Companion does not write health data. This description is present for HealthKit capability compatibility.</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+2652
File diff suppressed because it is too large
Load Diff
@@ -29,3 +29,30 @@ jest.mock('lucide-react-native', () => {
|
||||
const { View } = require('react-native');
|
||||
return new Proxy({ __esModule: true }, { get: () => View });
|
||||
});
|
||||
|
||||
jest.mock('@kingstinct/react-native-healthkit', () => ({
|
||||
CategoryValueAppleStandHour: { stood: 0, idle: 1 },
|
||||
CategoryValueSleepAnalysis: {
|
||||
inBed: 0,
|
||||
asleep: 1,
|
||||
asleepUnspecified: 1,
|
||||
awake: 2,
|
||||
asleepCore: 3,
|
||||
asleepDeep: 4,
|
||||
asleepREM: 5,
|
||||
},
|
||||
isHealthDataAvailableAsync: jest.fn(() => Promise.resolve(false)),
|
||||
requestAuthorization: jest.fn(() => Promise.resolve(false)),
|
||||
queryQuantitySamples: jest.fn(() => Promise.resolve([])),
|
||||
queryCategorySamples: jest.fn(() => Promise.resolve([])),
|
||||
queryWorkoutSamples: jest.fn(() => Promise.resolve([])),
|
||||
}));
|
||||
|
||||
jest.mock('react-native-health-connect', () => ({
|
||||
SdkAvailabilityStatus: { SDK_UNAVAILABLE: 1, SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED: 2, SDK_AVAILABLE: 3 },
|
||||
getSdkStatus: jest.fn(() => Promise.resolve(1)),
|
||||
initialize: jest.fn(() => Promise.resolve(false)),
|
||||
requestPermission: jest.fn(() => Promise.resolve([])),
|
||||
readRecords: jest.fn(() => Promise.resolve({ records: [] })),
|
||||
openHealthConnectSettings: jest.fn(),
|
||||
}));
|
||||
|
||||
Generated
+58
@@ -8,6 +8,7 @@
|
||||
"name": "LifeCompanion",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@kingstinct/react-native-healthkit": "^14.0.2",
|
||||
"@notifee/react-native": "^9.1.8",
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
@@ -22,6 +23,8 @@
|
||||
"react-native": "0.85.3",
|
||||
"react-native-gesture-handler": "^3.2.1",
|
||||
"react-native-haptic-feedback": "^3.0.0",
|
||||
"react-native-health-connect": "^4.1.3",
|
||||
"react-native-nitro-modules": "^0.35.0",
|
||||
"react-native-reanimated": "^4.5.3",
|
||||
"react-native-safe-area-context": "^5.5.2",
|
||||
"react-native-screens": "^4.27.0",
|
||||
@@ -2738,6 +2741,21 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kingstinct/react-native-healthkit": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@kingstinct/react-native-healthkit/-/react-native-healthkit-14.0.2.tgz",
|
||||
"integrity": "sha512-ed6x7xtSaML8qy8qswJWlVPKImeNEvHfWtuzylQZ9MsxgLriv33gDjf/8iWIcB4SKCW/BZNOFBcCqTQq5i5zmw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kingstinct"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
"react-native": ">=0.79",
|
||||
"react-native-nitro-modules": ">=0.35"
|
||||
}
|
||||
},
|
||||
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
|
||||
"version": "5.1.1-v1",
|
||||
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
|
||||
@@ -11166,6 +11184,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-health-connect": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-health-connect/-/react-native-health-connect-4.1.3.tgz",
|
||||
"integrity": "sha512-osiWh4RD4tNHdHeggzOeGZFeEVaLQQr0+Yezq61WsPRvN1Yb6eA10rMfSU0+dqcfm6ZvIXSr2tYns+Yg1s37nw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"example",
|
||||
"docs"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/matinzd/react-native-health-connect?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@expo/config-plugins": ">= 6.0.2",
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@expo/config-plugins": {
|
||||
"optional": true
|
||||
},
|
||||
"expo": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-is-edge-to-edge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
||||
@@ -11176,6 +11224,16 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-nitro-modules": {
|
||||
"version": "0.35.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.0.tgz",
|
||||
"integrity": "sha512-Eho1yEcLbsteGpBFn2XZOp5FIptnEciWzuYBW49S0jo41Un2LeyesIO/MqYLY/c5o7D9Fw9th4pxGtV7OAb0+g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-reanimated": {
|
||||
"version": "4.5.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.3.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kingstinct/react-native-healthkit": "^14.0.2",
|
||||
"@notifee/react-native": "^9.1.8",
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
@@ -25,6 +26,8 @@
|
||||
"react-native": "0.85.3",
|
||||
"react-native-gesture-handler": "^3.2.1",
|
||||
"react-native-haptic-feedback": "^3.0.0",
|
||||
"react-native-health-connect": "^4.1.3",
|
||||
"react-native-nitro-modules": "^0.35.0",
|
||||
"react-native-reanimated": "^4.5.3",
|
||||
"react-native-safe-area-context": "^5.5.2",
|
||||
"react-native-screens": "^4.27.0",
|
||||
|
||||
@@ -139,3 +139,10 @@ export type NotificationPreferences = {
|
||||
memories: boolean;
|
||||
};
|
||||
|
||||
export type HealthConnection = {
|
||||
provider: 'apple-health' | 'health-connect' | 'unavailable';
|
||||
status: 'not-requested' | 'authorized' | 'partial' | 'denied' | 'unavailable' | 'error';
|
||||
grantedActions: SignalAction[];
|
||||
lastRefreshAt?: string;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
@@ -155,6 +155,7 @@ const zhHans = {
|
||||
wardrobe: { title: '表达物', body: '这里保存由共同经历获得的纹样和物品。当前版本没有付费抽取或成长加速。' },
|
||||
settings: { title: '设置', data: '数据来源', notifications: '通知', motion: '动效与触感', wardrobe: '表达物', privacy: '隐私与数据', language: '语言' },
|
||||
data: { title: '数据来源', empty: '还没有添加生活信号。', enabled: '正在使用', disabled: '已停用' },
|
||||
health: { connect: '连接系统健康数据', refresh: '刷新健康数据', settings: '打开系统权限设置', authorized: '已经连接', partial: '部分权限可用', denied: '未获得权限', unavailable: '此设备不支持当前健康数据来源', error: '健康数据暂时无法读取', notRequested: '尚未请求权限', lastRefresh: '上次刷新:{{time}}', permissionFallback: '没有获得这项权限。你可以选择专注计时、呼吸计时或一键记录,它们拥有相同的关系价值。', providers: { 'apple-health': 'Apple Health', 'health-connect': 'Health Connect', unavailable: '系统健康数据' } },
|
||||
notifications: { title: '通知', body: '只在变化已经形成后通知,每周不超过三次。', habitat: '栖息地变化', discoveries: '新发现', memories: '共同记忆' },
|
||||
motion: { title: '动效与触感', reduce: '减少动态效果', haptics: '触感反馈', environment: '环境活动', noSound: '当前版本不包含声音。' },
|
||||
privacy: { title: '隐私与数据', localTitle: '本地优先', localBody: '伙伴、生活事件、纠错和发现物当前保存在这台设备。原始健康样本不会复制进 App 数据库。', export: '导出我的数据', reset: '重置本地数据', deleteTitle: '删除全部本地数据?', deleteBody: '伙伴、记录、发现物和设置都会从这台设备移除。此操作无法撤销。', delete: '确认删除' },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { BookOpen, Droplets, Footprints, MoonStar } from 'lucide-react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
@@ -8,6 +9,8 @@ import { Button } from '@/components/Controls';
|
||||
import type { Direction, SignalAction, SignalSource } from '@/domain/models';
|
||||
import { ROUTES } from '@/navigation/routes';
|
||||
import type { RootStackParamList } from '@/navigation/types';
|
||||
import { connectHealth, refreshHealth } from '@/services/health/healthSync';
|
||||
import type { AutomaticSignalAction } from '@/services/health/types';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import { colors, radius, space, type } from '@/theme/tokens';
|
||||
|
||||
@@ -29,21 +32,38 @@ const options: {
|
||||
export function SignalSetupScreen({ navigation }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const addSignal = useAppStore(state => state.addSignal);
|
||||
const [busyAction, setBusyAction] = useState<SignalAction>();
|
||||
const [permissionMessage, setPermissionMessage] = useState('');
|
||||
|
||||
const choose = (option: (typeof options)[number]) => {
|
||||
addSignal({
|
||||
const choose = async (option: (typeof options)[number]) => {
|
||||
setBusyAction(option.action);
|
||||
setPermissionMessage('');
|
||||
if (option.source === 'automatic') {
|
||||
const result = await connectHealth([option.action as AutomaticSignalAction]);
|
||||
if (result.status === 'denied' || result.status === 'unavailable') {
|
||||
setPermissionMessage(t('health.permissionFallback'));
|
||||
setBusyAction(undefined);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const signalId = addSignal({
|
||||
action: option.action,
|
||||
direction: option.direction,
|
||||
source: option.source,
|
||||
name: t(`signals.${option.action}`),
|
||||
shapesJourney: true,
|
||||
});
|
||||
if (option.source === 'automatic' && signalId) {
|
||||
await refreshHealth();
|
||||
}
|
||||
setBusyAction(undefined);
|
||||
navigation.reset({ index: 0, routes: [{ name: ROUTES.HABITAT }] });
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen title={t('signalSetup.title')} eyebrow={t('signalSetup.eyebrow')}>
|
||||
<Text style={styles.body}>{t('signalSetup.body')}</Text>
|
||||
{permissionMessage ? <Text accessibilityLiveRegion="polite" style={styles.permissionMessage}>{permissionMessage}</Text> : null}
|
||||
<View style={styles.list}>
|
||||
{options.map(option => {
|
||||
const Icon = option.icon;
|
||||
@@ -62,7 +82,9 @@ export function SignalSetupScreen({ navigation }: Props) {
|
||||
<Button
|
||||
label={t('signalSetup.choose')}
|
||||
variant="secondary"
|
||||
onPress={() => choose(option)}
|
||||
loading={busyAction === option.action}
|
||||
disabled={Boolean(busyAction && busyAction !== option.action)}
|
||||
onPress={() => { choose(option).catch(() => setBusyAction(undefined)); }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -89,5 +111,5 @@ const styles = StyleSheet.create({
|
||||
optionTitle: { ...type.titleSmall, color: colors.ink },
|
||||
source: { ...type.caption, color: colors.inkMuted, marginTop: 2 },
|
||||
optionBody: { ...type.body, color: colors.inkMuted },
|
||||
permissionMessage: { ...type.body, color: colors.coral, padding: space.md, marginBottom: space.md, borderRadius: radius.md, backgroundColor: colors.coralSoft },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, Switch, Text, View } from 'react-native';
|
||||
import { ArrowLeft, Plus } from 'lucide-react-native';
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw } from 'lucide-react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -7,6 +8,13 @@ import { AppScreen } from '@/components/AppScreen';
|
||||
import { Button, IconButton } from '@/components/Controls';
|
||||
import { ROUTES } from '@/navigation/routes';
|
||||
import type { RootStackParamList } from '@/navigation/types';
|
||||
import {
|
||||
connectHealth,
|
||||
normalizeHealthConnection,
|
||||
openHealthSettings,
|
||||
refreshHealth,
|
||||
} from '@/services/health/healthSync';
|
||||
import type { AutomaticSignalAction } from '@/services/health/types';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import { colors, radius, space, type } from '@/theme/tokens';
|
||||
|
||||
@@ -16,12 +24,53 @@ export function DataSourcesScreen({ navigation }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const signals = useAppStore(state => state.signals);
|
||||
const setSignalEnabled = useAppStore(state => state.setSignalEnabled);
|
||||
const health = useAppStore(state => state.healthConnection);
|
||||
const normalizedHealth = normalizeHealthConnection(health);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const automaticActions = signals
|
||||
.filter(signal => signal.source === 'automatic')
|
||||
.map(signal => signal.action as AutomaticSignalAction);
|
||||
|
||||
const connectOrRefresh = async () => {
|
||||
setBusy(true);
|
||||
if (
|
||||
normalizedHealth.status === 'authorized' ||
|
||||
normalizedHealth.status === 'partial'
|
||||
) {
|
||||
await refreshHealth();
|
||||
} else {
|
||||
const result = await connectHealth(automaticActions);
|
||||
if (result.status === 'authorized' || result.status === 'partial') {
|
||||
await refreshHealth();
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
return (
|
||||
<AppScreen
|
||||
title={t('data.title')}
|
||||
right={<IconButton label={t('common.back')} icon={ArrowLeft} onPress={navigation.goBack} />}
|
||||
>
|
||||
{signals.length === 0 ? <Text style={styles.empty}>{t('data.empty')}</Text> : null}
|
||||
{automaticActions.length > 0 ? (
|
||||
<View style={styles.healthPanel}>
|
||||
<Text style={styles.healthTitle}>{t(`health.providers.${normalizedHealth.provider}`)}</Text>
|
||||
<Text style={styles.meta}>{t(`health.${healthStatusKey[normalizedHealth.status]}`)}</Text>
|
||||
{health.lastRefreshAt ? <Text style={styles.meta}>{t('health.lastRefresh', { time: new Date(health.lastRefreshAt).toLocaleString() })}</Text> : null}
|
||||
{health.lastError ? <Text style={styles.error}>{health.lastError}</Text> : null}
|
||||
<View style={styles.healthActions}>
|
||||
<Button
|
||||
label={t(normalizedHealth.status === 'authorized' || normalizedHealth.status === 'partial' ? 'health.refresh' : 'health.connect')}
|
||||
icon={RefreshCw}
|
||||
loading={busy}
|
||||
variant="secondary"
|
||||
onPress={() => { connectOrRefresh().catch(() => setBusy(false)); }}
|
||||
style={styles.healthButton}
|
||||
/>
|
||||
<Button label={t('health.settings')} icon={ExternalLink} variant="quiet" onPress={openHealthSettings} style={styles.healthButton} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.list}>
|
||||
{signals.map(signal => (
|
||||
<View key={signal.id} style={styles.row}>
|
||||
@@ -38,12 +87,25 @@ export function DataSourcesScreen({ navigation }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const healthStatusKey = {
|
||||
'not-requested': 'notRequested',
|
||||
authorized: 'authorized',
|
||||
partial: 'partial',
|
||||
denied: 'denied',
|
||||
unavailable: 'unavailable',
|
||||
error: 'error',
|
||||
} as const;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
empty: { ...type.body, color: colors.inkMuted, marginBottom: space.lg },
|
||||
list: { borderWidth: 1, borderColor: colors.line, borderRadius: radius.md, backgroundColor: colors.surface, overflow: 'hidden', marginBottom: space.lg },
|
||||
healthPanel: { padding: space.lg, borderRadius: radius.md, backgroundColor: colors.skySoft, marginBottom: space.lg },
|
||||
healthTitle: { ...type.titleSmall, color: colors.ink },
|
||||
healthActions: { gap: space.xs, marginTop: space.md },
|
||||
healthButton: { alignSelf: 'stretch' },
|
||||
error: { ...type.caption, color: colors.danger, marginTop: space.xs },
|
||||
row: { minHeight: 72, padding: space.md, flexDirection: 'row', alignItems: 'center', gap: space.md, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: colors.line },
|
||||
copy: { flex: 1 },
|
||||
name: { ...type.bodyStrong, color: colors.ink },
|
||||
meta: { ...type.caption, color: colors.inkMuted, marginTop: 2 },
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
getSdkStatus,
|
||||
initialize,
|
||||
openHealthConnectSettings,
|
||||
readRecords,
|
||||
requestPermission,
|
||||
SdkAvailabilityStatus,
|
||||
} from 'react-native-health-connect';
|
||||
|
||||
import { localDayFromDate } from '@/domain/growth';
|
||||
import type {
|
||||
AutomaticSignalAction,
|
||||
HealthFact,
|
||||
HealthProvider,
|
||||
} from './types';
|
||||
|
||||
const recordTypeByAction = {
|
||||
steps: 'Steps',
|
||||
workout: 'ExerciseSession',
|
||||
stairs: 'FloorsClimbed',
|
||||
sleep: 'SleepSession',
|
||||
mindfulness: 'MindfulnessSession',
|
||||
} as const;
|
||||
|
||||
type AndroidHealthAction = keyof typeof recordTypeByAction;
|
||||
const supportedActions: readonly AndroidHealthAction[] = [
|
||||
'steps',
|
||||
'workout',
|
||||
'stairs',
|
||||
'sleep',
|
||||
'mindfulness',
|
||||
];
|
||||
|
||||
export const healthProvider: HealthProvider = {
|
||||
id: 'health-connect',
|
||||
supportedActions,
|
||||
|
||||
async isAvailable() {
|
||||
const status = await getSdkStatus();
|
||||
if (status !== SdkAvailabilityStatus.SDK_AVAILABLE) return false;
|
||||
return initialize();
|
||||
},
|
||||
|
||||
async requestAuthorization(actions) {
|
||||
const requested = actions.filter(isAndroidHealthAction);
|
||||
if (requested.length === 0) {
|
||||
return { status: 'denied', grantedActions: [] };
|
||||
}
|
||||
const granted = await requestPermission(
|
||||
requested.map(action => ({
|
||||
accessType: 'read' as const,
|
||||
recordType: recordTypeByAction[action],
|
||||
})),
|
||||
);
|
||||
const grantedActions = requested.filter(action =>
|
||||
granted.some(
|
||||
permission =>
|
||||
permission.accessType === 'read' &&
|
||||
permission.recordType === recordTypeByAction[action],
|
||||
),
|
||||
);
|
||||
return {
|
||||
status:
|
||||
grantedActions.length === requested.length
|
||||
? 'authorized'
|
||||
: grantedActions.length > 0
|
||||
? 'partial'
|
||||
: 'denied',
|
||||
grantedActions,
|
||||
};
|
||||
},
|
||||
|
||||
async readFacts(actions, since, until) {
|
||||
const requested = new Set(actions);
|
||||
const jobs: Promise<HealthFact[]>[] = [];
|
||||
if (requested.has('steps')) jobs.push(readSteps(since, until));
|
||||
if (requested.has('stairs')) jobs.push(readFloors(since, until));
|
||||
if (requested.has('workout')) jobs.push(readWorkouts(since, until));
|
||||
if (requested.has('sleep')) jobs.push(readSleep(since, until));
|
||||
if (requested.has('mindfulness')) jobs.push(readMindfulness(since, until));
|
||||
return (await Promise.all(jobs)).flat();
|
||||
},
|
||||
|
||||
openSettings: openHealthConnectSettings,
|
||||
};
|
||||
|
||||
function options(since: Date, until: Date) {
|
||||
return {
|
||||
timeRangeFilter: {
|
||||
operator: 'between' as const,
|
||||
startTime: since.toISOString(),
|
||||
endTime: until.toISOString(),
|
||||
},
|
||||
ascendingOrder: true,
|
||||
pageSize: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
async function readSteps(since: Date, until: Date): Promise<HealthFact[]> {
|
||||
const { records } = await readRecords('Steps', options(since, until));
|
||||
const groups = groupByLocalDay(records, record => new Date(record.endTime));
|
||||
return [...groups.entries()].map(([day, dayRecords]) => ({
|
||||
action: 'steps',
|
||||
startedAt: minIso(dayRecords.map(record => record.startTime)),
|
||||
endedAt: maxIso(dayRecords.map(record => record.endTime)),
|
||||
externalId: `health-connect:steps:${day}`,
|
||||
deviceName: deviceName(dayRecords.at(-1)?.metadata),
|
||||
sourceLabel: 'Health Connect',
|
||||
quantity: dayRecords.reduce((total, record) => total + record.count, 0),
|
||||
unit: 'count',
|
||||
}));
|
||||
}
|
||||
|
||||
async function readFloors(since: Date, until: Date): Promise<HealthFact[]> {
|
||||
const { records } = await readRecords('FloorsClimbed', options(since, until));
|
||||
const groups = groupByLocalDay(records, record => new Date(record.endTime));
|
||||
return [...groups.entries()].map(([day, dayRecords]) => ({
|
||||
action: 'stairs',
|
||||
startedAt: minIso(dayRecords.map(record => record.startTime)),
|
||||
endedAt: maxIso(dayRecords.map(record => record.endTime)),
|
||||
externalId: `health-connect:stairs:${day}`,
|
||||
deviceName: deviceName(dayRecords.at(-1)?.metadata),
|
||||
sourceLabel: 'Health Connect',
|
||||
quantity: dayRecords.reduce((total, record) => total + record.floors, 0),
|
||||
unit: 'floor',
|
||||
}));
|
||||
}
|
||||
|
||||
async function readWorkouts(since: Date, until: Date): Promise<HealthFact[]> {
|
||||
const { records } = await readRecords('ExerciseSession', options(since, until));
|
||||
return records.map((record, index) => ({
|
||||
action: 'workout',
|
||||
startedAt: record.startTime,
|
||||
endedAt: record.endTime,
|
||||
externalId: `health-connect:workout:${record.metadata?.id ?? `${record.startTime}:${index}`}`,
|
||||
deviceName: deviceName(record.metadata),
|
||||
sourceLabel: 'Health Connect',
|
||||
quantity: durationMinutes(record.startTime, record.endTime),
|
||||
unit: 'minute',
|
||||
}));
|
||||
}
|
||||
|
||||
async function readSleep(since: Date, until: Date): Promise<HealthFact[]> {
|
||||
const { records } = await readRecords('SleepSession', options(since, until));
|
||||
return records.map((record, index) => ({
|
||||
action: 'sleep',
|
||||
startedAt: record.startTime,
|
||||
endedAt: record.endTime,
|
||||
externalId: `health-connect:sleep:${record.metadata?.id ?? `${record.startTime}:${index}`}`,
|
||||
deviceName: deviceName(record.metadata),
|
||||
sourceLabel: 'Health Connect',
|
||||
quantity: durationMinutes(record.startTime, record.endTime),
|
||||
unit: 'minute',
|
||||
}));
|
||||
}
|
||||
|
||||
async function readMindfulness(since: Date, until: Date): Promise<HealthFact[]> {
|
||||
const { records } = await readRecords('MindfulnessSession', options(since, until));
|
||||
return records.map((record, index) => ({
|
||||
action: 'mindfulness',
|
||||
startedAt: record.startTime,
|
||||
endedAt: record.endTime,
|
||||
externalId: `health-connect:mindfulness:${record.metadata?.id ?? `${record.startTime}:${index}`}`,
|
||||
deviceName: deviceName(record.metadata),
|
||||
sourceLabel: 'Health Connect',
|
||||
quantity: durationMinutes(record.startTime, record.endTime),
|
||||
unit: 'minute',
|
||||
}));
|
||||
}
|
||||
|
||||
function groupByLocalDay<T>(items: T[], dateFor: (item: T) => Date): Map<string, T[]> {
|
||||
const groups = new Map<string, T[]>();
|
||||
items.forEach(item => {
|
||||
const day = localDayFromDate(dateFor(item));
|
||||
groups.set(day, [...(groups.get(day) ?? []), item]);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function minIso(values: string[]): string {
|
||||
return [...values].sort()[0];
|
||||
}
|
||||
|
||||
function maxIso(values: string[]): string {
|
||||
return [...values].sort().at(-1) ?? values[0];
|
||||
}
|
||||
|
||||
function durationMinutes(start: string, end: string): number {
|
||||
return Math.max(0, Math.round((Date.parse(end) - Date.parse(start)) / 60_000));
|
||||
}
|
||||
|
||||
function deviceName(metadata: { dataOrigin?: string; device?: { manufacturer?: string; model?: string } } | undefined): string {
|
||||
return [metadata?.device?.manufacturer, metadata?.device?.model]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|| metadata?.dataOrigin
|
||||
|| 'Android device';
|
||||
}
|
||||
|
||||
function isAndroidHealthAction(
|
||||
action: AutomaticSignalAction,
|
||||
): action is AndroidHealthAction {
|
||||
return supportedActions.some(item => item === action);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Linking } from 'react-native';
|
||||
import {
|
||||
CategoryValueAppleStandHour,
|
||||
CategoryValueSleepAnalysis,
|
||||
isHealthDataAvailableAsync,
|
||||
queryCategorySamples,
|
||||
queryQuantitySamples,
|
||||
queryWorkoutSamples,
|
||||
requestAuthorization,
|
||||
} from '@kingstinct/react-native-healthkit';
|
||||
|
||||
import { localDayFromDate } from '@/domain/growth';
|
||||
import type {
|
||||
AutomaticSignalAction,
|
||||
HealthFact,
|
||||
HealthProvider,
|
||||
} from './types';
|
||||
|
||||
const supportedActions: readonly AutomaticSignalAction[] = [
|
||||
'steps',
|
||||
'workout',
|
||||
'stand',
|
||||
'stairs',
|
||||
'sleep',
|
||||
'mindfulness',
|
||||
];
|
||||
|
||||
const identifiers = {
|
||||
steps: 'HKQuantityTypeIdentifierStepCount',
|
||||
workout: 'HKWorkoutTypeIdentifier',
|
||||
stand: 'HKCategoryTypeIdentifierAppleStandHour',
|
||||
stairs: 'HKQuantityTypeIdentifierFlightsClimbed',
|
||||
sleep: 'HKCategoryTypeIdentifierSleepAnalysis',
|
||||
mindfulness: 'HKCategoryTypeIdentifierMindfulSession',
|
||||
} as const;
|
||||
|
||||
export const healthProvider: HealthProvider = {
|
||||
id: 'apple-health',
|
||||
supportedActions,
|
||||
|
||||
isAvailable: isHealthDataAvailableAsync,
|
||||
|
||||
async requestAuthorization(actions) {
|
||||
const requested = supportedActions.filter(action => actions.includes(action));
|
||||
if (requested.length === 0) {
|
||||
return { status: 'denied', grantedActions: [] };
|
||||
}
|
||||
const accepted = await requestAuthorization({
|
||||
toRead: requested.map(action => identifiers[action]),
|
||||
});
|
||||
return accepted
|
||||
? { status: 'authorized', grantedActions: requested }
|
||||
: { status: 'denied', grantedActions: [] };
|
||||
},
|
||||
|
||||
async readFacts(actions, since, until) {
|
||||
const requested = new Set(actions);
|
||||
const jobs: Promise<HealthFact[]>[] = [];
|
||||
if (requested.has('steps')) jobs.push(readDailyQuantity('steps', since, until));
|
||||
if (requested.has('stairs')) jobs.push(readDailyQuantity('stairs', since, until));
|
||||
if (requested.has('stand')) jobs.push(readStandHours(since, until));
|
||||
if (requested.has('sleep')) jobs.push(readSleep(since, until));
|
||||
if (requested.has('mindfulness')) jobs.push(readMindfulness(since, until));
|
||||
if (requested.has('workout')) jobs.push(readWorkouts(since, until));
|
||||
return (await Promise.all(jobs)).flat();
|
||||
},
|
||||
|
||||
openSettings: () => {
|
||||
Linking.openURL('x-apple-health://').catch(() => undefined);
|
||||
},
|
||||
};
|
||||
|
||||
async function readDailyQuantity(
|
||||
action: 'steps' | 'stairs',
|
||||
since: Date,
|
||||
until: Date,
|
||||
): Promise<HealthFact[]> {
|
||||
const identifier = action === 'steps'
|
||||
? 'HKQuantityTypeIdentifierStepCount'
|
||||
: 'HKQuantityTypeIdentifierFlightsClimbed';
|
||||
const samples = await queryQuantitySamples(identifier, queryOptions(since, until));
|
||||
const groups = groupByLocalDay(samples, sample => sample.endDate);
|
||||
return [...groups.entries()].map(([day, daySamples]) => ({
|
||||
action,
|
||||
startedAt: minDate(daySamples.map(sample => sample.startDate)).toISOString(),
|
||||
endedAt: maxDate(daySamples.map(sample => sample.endDate)).toISOString(),
|
||||
externalId: `apple-health:${action}:${day}`,
|
||||
deviceName: sourceName(daySamples.at(-1)),
|
||||
sourceLabel: 'Apple Health',
|
||||
quantity: daySamples.reduce((total, sample) => total + sample.quantity, 0),
|
||||
unit: action === 'steps' ? 'count' : 'floor',
|
||||
}));
|
||||
}
|
||||
|
||||
async function readStandHours(since: Date, until: Date): Promise<HealthFact[]> {
|
||||
const samples = await queryCategorySamples(
|
||||
'HKCategoryTypeIdentifierAppleStandHour',
|
||||
queryOptions(since, until),
|
||||
);
|
||||
const stood = samples.filter(sample => sample.value === CategoryValueAppleStandHour.stood);
|
||||
const groups = groupByLocalDay(stood, sample => sample.endDate);
|
||||
return [...groups.entries()].map(([day, daySamples]) => ({
|
||||
action: 'stand',
|
||||
startedAt: minDate(daySamples.map(sample => sample.startDate)).toISOString(),
|
||||
endedAt: maxDate(daySamples.map(sample => sample.endDate)).toISOString(),
|
||||
externalId: `apple-health:stand:${day}`,
|
||||
deviceName: sourceName(daySamples.at(-1)),
|
||||
sourceLabel: 'Apple Health',
|
||||
quantity: daySamples.length,
|
||||
unit: 'hour',
|
||||
}));
|
||||
}
|
||||
|
||||
async function readSleep(since: Date, until: Date): Promise<HealthFact[]> {
|
||||
const samples = await queryCategorySamples(
|
||||
'HKCategoryTypeIdentifierSleepAnalysis',
|
||||
queryOptions(since, until),
|
||||
);
|
||||
const asleepValues = new Set<number>([
|
||||
CategoryValueSleepAnalysis.asleep,
|
||||
CategoryValueSleepAnalysis.asleepCore,
|
||||
CategoryValueSleepAnalysis.asleepDeep,
|
||||
CategoryValueSleepAnalysis.asleepREM,
|
||||
]);
|
||||
const asleep = samples.filter(sample => asleepValues.has(sample.value));
|
||||
const groups = groupByLocalDay(asleep, sample => sample.endDate);
|
||||
return [...groups.entries()].map(([day, daySamples]) => ({
|
||||
action: 'sleep',
|
||||
startedAt: minDate(daySamples.map(sample => sample.startDate)).toISOString(),
|
||||
endedAt: maxDate(daySamples.map(sample => sample.endDate)).toISOString(),
|
||||
externalId: `apple-health:sleep:${day}`,
|
||||
deviceName: sourceName(daySamples.at(-1)),
|
||||
sourceLabel: 'Apple Health',
|
||||
quantity: Math.round(
|
||||
daySamples.reduce(
|
||||
(total, sample) => total + sample.endDate.getTime() - sample.startDate.getTime(),
|
||||
0,
|
||||
) / 60_000,
|
||||
),
|
||||
unit: 'minute',
|
||||
}));
|
||||
}
|
||||
|
||||
async function readMindfulness(since: Date, until: Date): Promise<HealthFact[]> {
|
||||
const samples = await queryCategorySamples(
|
||||
'HKCategoryTypeIdentifierMindfulSession',
|
||||
queryOptions(since, until),
|
||||
);
|
||||
return samples.map(sample => ({
|
||||
action: 'mindfulness',
|
||||
startedAt: sample.startDate.toISOString(),
|
||||
endedAt: sample.endDate.toISOString(),
|
||||
externalId: `apple-health:mindfulness:${sample.uuid}`,
|
||||
deviceName: sourceName(sample),
|
||||
sourceLabel: 'Apple Health',
|
||||
quantity: Math.round((sample.endDate.getTime() - sample.startDate.getTime()) / 60_000),
|
||||
unit: 'minute',
|
||||
}));
|
||||
}
|
||||
|
||||
async function readWorkouts(since: Date, until: Date): Promise<HealthFact[]> {
|
||||
const workouts = await queryWorkoutSamples(queryOptions(since, until));
|
||||
return workouts.map(workout => ({
|
||||
action: 'workout',
|
||||
startedAt: workout.startDate.toISOString(),
|
||||
endedAt: workout.endDate.toISOString(),
|
||||
externalId: `apple-health:workout:${workout.uuid}`,
|
||||
deviceName: sourceName(workout),
|
||||
sourceLabel: 'Apple Health',
|
||||
quantity: Math.round((workout.endDate.getTime() - workout.startDate.getTime()) / 60_000),
|
||||
unit: 'minute',
|
||||
}));
|
||||
}
|
||||
|
||||
function queryOptions(since: Date, until: Date) {
|
||||
return {
|
||||
limit: 0,
|
||||
ascending: true,
|
||||
filter: { date: { startDate: since, endDate: until, strictStartDate: false, strictEndDate: false } },
|
||||
} as const;
|
||||
}
|
||||
|
||||
function groupByLocalDay<T>(items: readonly T[], dateFor: (item: T) => Date): Map<string, T[]> {
|
||||
const groups = new Map<string, T[]>();
|
||||
items.forEach(item => {
|
||||
const day = localDayFromDate(dateFor(item));
|
||||
groups.set(day, [...(groups.get(day) ?? []), item]);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function minDate(dates: Date[]): Date {
|
||||
return new Date(Math.min(...dates.map(date => date.getTime())));
|
||||
}
|
||||
|
||||
function maxDate(dates: Date[]): Date {
|
||||
return new Date(Math.max(...dates.map(date => date.getTime())));
|
||||
}
|
||||
|
||||
function sourceName(sample: { sourceRevision: { source: { name: string } }; device?: { name?: string; model?: string } } | undefined): string {
|
||||
return sample?.device?.name ?? sample?.device?.model ?? sample?.sourceRevision.source.name ?? 'Apple device';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { HealthProvider } from './types';
|
||||
|
||||
export const healthProvider: HealthProvider = {
|
||||
id: 'unavailable',
|
||||
supportedActions: [],
|
||||
isAvailable: async () => false,
|
||||
requestAuthorization: async () => ({
|
||||
status: 'unavailable',
|
||||
grantedActions: [],
|
||||
}),
|
||||
readFacts: async () => [],
|
||||
openSettings: () => undefined,
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { localDayFromDate } from '@/domain/growth';
|
||||
import type { HealthConnection, LifeEvent } from '@/domain/models';
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
import { healthProvider } from './healthProvider';
|
||||
import type { AutomaticSignalAction, HealthAuthorizationResult } from './types';
|
||||
|
||||
export async function connectHealth(
|
||||
actions: AutomaticSignalAction[],
|
||||
): Promise<HealthAuthorizationResult> {
|
||||
const state = useAppStore.getState();
|
||||
state.setHealthConnection({
|
||||
...normalizeHealthConnection(state.healthConnection),
|
||||
lastError: undefined,
|
||||
});
|
||||
try {
|
||||
if (!(await healthProvider.isAvailable())) {
|
||||
const unavailable: HealthAuthorizationResult = {
|
||||
status: 'unavailable',
|
||||
grantedActions: [],
|
||||
};
|
||||
state.setHealthConnection(unavailable);
|
||||
return unavailable;
|
||||
}
|
||||
const result = await healthProvider.requestAuthorization(actions);
|
||||
state.setHealthConnection(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.setHealthConnection({
|
||||
status: 'error',
|
||||
grantedActions: [],
|
||||
lastError: errorMessage(error),
|
||||
});
|
||||
return { status: 'denied', grantedActions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshHealth(
|
||||
since = daysAgo(35),
|
||||
until = new Date(),
|
||||
): Promise<number> {
|
||||
const state = useAppStore.getState();
|
||||
const connection = normalizeHealthConnection(state.healthConnection);
|
||||
const automaticSignals = state.signals.filter(
|
||||
signal => signal.enabled && signal.source === 'automatic',
|
||||
);
|
||||
const configuredActions = [
|
||||
...new Set(
|
||||
automaticSignals
|
||||
.map(signal => signal.action)
|
||||
.filter(isAutomaticAction),
|
||||
),
|
||||
];
|
||||
const granted = new Set(connection.grantedActions);
|
||||
const actions = configuredActions.filter(action => granted.has(action));
|
||||
if (
|
||||
actions.length === 0 ||
|
||||
(connection.status !== 'authorized' && connection.status !== 'partial')
|
||||
) {
|
||||
state.setHealthConnection(connection);
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const facts = await healthProvider.readFacts(actions, since, until);
|
||||
const events: LifeEvent[] = facts.flatMap(fact => {
|
||||
const signal = automaticSignals.find(item => item.action === fact.action);
|
||||
if (!signal) return [];
|
||||
return [{
|
||||
id: `health-${sanitizeId(fact.externalId)}`,
|
||||
signalId: signal.id,
|
||||
action: fact.action,
|
||||
direction: signal.direction,
|
||||
source: 'automatic',
|
||||
startedAt: fact.startedAt,
|
||||
endedAt: fact.endedAt,
|
||||
localDay: localDayFromDate(new Date(fact.endedAt)),
|
||||
deviceName: fact.deviceName,
|
||||
sourceLabel: fact.sourceLabel,
|
||||
idempotencyKey: `automatic:${fact.action}:${fact.externalId}`,
|
||||
externalId: fact.externalId,
|
||||
quantity: fact.quantity,
|
||||
unit: fact.unit,
|
||||
status: 'accepted',
|
||||
} satisfies LifeEvent];
|
||||
});
|
||||
const imported = state.importEvents(events);
|
||||
state.setHealthConnection({
|
||||
provider: healthProvider.id,
|
||||
status: connection.status,
|
||||
grantedActions: connection.grantedActions,
|
||||
lastRefreshAt: until.toISOString(),
|
||||
lastError: undefined,
|
||||
});
|
||||
return imported;
|
||||
} catch (error) {
|
||||
state.setHealthConnection({
|
||||
provider: healthProvider.id,
|
||||
status: 'error',
|
||||
lastError: errorMessage(error),
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function openHealthSettings(): void {
|
||||
healthProvider.openSettings();
|
||||
}
|
||||
|
||||
export function normalizeHealthConnection(
|
||||
connection: HealthConnection,
|
||||
): HealthConnection {
|
||||
if (connection.provider !== healthProvider.id) {
|
||||
return {
|
||||
provider: healthProvider.id,
|
||||
status: healthProvider.id === 'unavailable' ? 'unavailable' : 'not-requested',
|
||||
grantedActions: [],
|
||||
};
|
||||
}
|
||||
const supported = new Set(healthProvider.supportedActions);
|
||||
return {
|
||||
...connection,
|
||||
grantedActions: connection.grantedActions.filter(action => supported.has(
|
||||
action as AutomaticSignalAction,
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
function isAutomaticAction(action: string): action is AutomaticSignalAction {
|
||||
return healthProvider.supportedActions.some(item => item === action);
|
||||
}
|
||||
|
||||
function daysAgo(days: number): Date {
|
||||
const value = new Date();
|
||||
value.setDate(value.getDate() - days);
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeId(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 160);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Unknown health provider error';
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { SignalAction } from '@/domain/models';
|
||||
|
||||
export type AutomaticSignalAction = Extract<
|
||||
SignalAction,
|
||||
'steps' | 'workout' | 'stand' | 'stairs' | 'sleep' | 'mindfulness'
|
||||
>;
|
||||
|
||||
export type HealthFact = {
|
||||
action: AutomaticSignalAction;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
externalId: string;
|
||||
deviceName: string;
|
||||
sourceLabel: 'Apple Health' | 'Health Connect';
|
||||
quantity?: number;
|
||||
unit?: 'count' | 'minute' | 'hour' | 'floor';
|
||||
};
|
||||
|
||||
export type HealthAuthorizationResult = {
|
||||
status: 'authorized' | 'partial' | 'denied' | 'unavailable';
|
||||
grantedActions: AutomaticSignalAction[];
|
||||
};
|
||||
|
||||
export interface HealthProvider {
|
||||
id: 'apple-health' | 'health-connect' | 'unavailable';
|
||||
supportedActions: readonly AutomaticSignalAction[];
|
||||
isAvailable(): Promise<boolean>;
|
||||
requestAuthorization(
|
||||
actions: AutomaticSignalAction[],
|
||||
): Promise<HealthAuthorizationResult>;
|
||||
readFacts(
|
||||
actions: AutomaticSignalAction[],
|
||||
since: Date,
|
||||
until: Date,
|
||||
): Promise<HealthFact[]>;
|
||||
openSettings(): void;
|
||||
}
|
||||
+45
-4
@@ -17,6 +17,7 @@ import type {
|
||||
Direction,
|
||||
Discovery,
|
||||
HabitatSlot,
|
||||
HealthConnection,
|
||||
LifeEvent,
|
||||
LifeSignal,
|
||||
MotionPreferences,
|
||||
@@ -68,6 +69,7 @@ export type AppState = {
|
||||
pendingResponseEventId?: string;
|
||||
motion: MotionPreferences;
|
||||
notifications: NotificationPreferences;
|
||||
healthConnection: HealthConnection;
|
||||
language: 'zh-Hans' | 'en';
|
||||
hydrated: boolean;
|
||||
reshuffleCandidates: () => void;
|
||||
@@ -93,6 +95,7 @@ export type AppState = {
|
||||
setNotificationPreferences: (
|
||||
value: Partial<NotificationPreferences>,
|
||||
) => void;
|
||||
setHealthConnection: (value: Partial<HealthConnection>) => void;
|
||||
setLanguage: (language: AppState['language']) => void;
|
||||
resetAllData: () => void;
|
||||
setHydrated: (hydrated: boolean) => void;
|
||||
@@ -110,6 +113,7 @@ export const createInitialState = (): Pick<
|
||||
| 'habitatSlots'
|
||||
| 'motion'
|
||||
| 'notifications'
|
||||
| 'healthConnection'
|
||||
| 'language'
|
||||
| 'hydrated'
|
||||
> => ({
|
||||
@@ -137,6 +141,11 @@ export const createInitialState = (): Pick<
|
||||
discoveries: true,
|
||||
memories: true,
|
||||
},
|
||||
healthConnection: {
|
||||
provider: 'unavailable',
|
||||
status: 'not-requested',
|
||||
grantedActions: [],
|
||||
},
|
||||
language: 'zh-Hans',
|
||||
hydrated: false,
|
||||
});
|
||||
@@ -307,13 +316,33 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
importEvents: imported => {
|
||||
const previous = get().events;
|
||||
const combined = deduplicateEvents([...previous, ...imported]);
|
||||
const added = combined.length - previous.length;
|
||||
if (added <= 0) return 0;
|
||||
const importedByKey = new Map(imported.map(event => [event.idempotencyKey, event]));
|
||||
const updatedPrevious = previous.map(event => {
|
||||
const fresh = importedByKey.get(event.idempotencyKey);
|
||||
if (!fresh) return event;
|
||||
importedByKey.delete(event.idempotencyKey);
|
||||
const updated = {
|
||||
...fresh,
|
||||
id: event.id,
|
||||
status: event.status,
|
||||
ignoredReason: event.ignoredReason,
|
||||
};
|
||||
return lifeEventsEqual(event, updated) ? event : updated;
|
||||
});
|
||||
const addedEvents = [...importedByKey.values()];
|
||||
const hasUpdates = updatedPrevious.some(
|
||||
(event, index) => event !== previous[index],
|
||||
);
|
||||
if (addedEvents.length === 0 && !hasUpdates) return 0;
|
||||
const combined = deduplicateEvents([...updatedPrevious, ...addedEvents]);
|
||||
const added = addedEvents.length;
|
||||
set(state => unlockDiscoveries({
|
||||
...state,
|
||||
events: combined,
|
||||
pendingResponseEventId: combined.at(-1)?.id,
|
||||
pendingResponseEventId:
|
||||
added > 0
|
||||
? addedEvents.at(-1)?.id
|
||||
: state.pendingResponseEventId,
|
||||
}));
|
||||
return added;
|
||||
},
|
||||
@@ -391,6 +420,10 @@ export const useAppStore = create<AppState>()(
|
||||
set(state => ({ motion: { ...state.motion, ...value } })),
|
||||
setNotificationPreferences: value =>
|
||||
set(state => ({ notifications: { ...state.notifications, ...value } })),
|
||||
setHealthConnection: value =>
|
||||
set(state => ({
|
||||
healthConnection: { ...state.healthConnection, ...value },
|
||||
})),
|
||||
setLanguage: language => set({ language }),
|
||||
|
||||
resetAllData: () => set({ ...createInitialState(), hydrated: true }),
|
||||
@@ -415,6 +448,7 @@ export const useAppStore = create<AppState>()(
|
||||
pendingResponseEventId: state.pendingResponseEventId,
|
||||
motion: state.motion,
|
||||
notifications: state.notifications,
|
||||
healthConnection: state.healthConnection,
|
||||
language: state.language,
|
||||
}),
|
||||
migrate: persisted => migratePersistedState(persisted),
|
||||
@@ -458,3 +492,10 @@ function createId(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${idSequence.toString(36)}`;
|
||||
}
|
||||
|
||||
function lifeEventsEqual(left: LifeEvent, right: LifeEvent): boolean {
|
||||
const keys = new Set([
|
||||
...Object.keys(left),
|
||||
...Object.keys(right),
|
||||
] as (keyof LifeEvent)[]);
|
||||
return [...keys].every(key => left[key] === right[key]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user