feat: 接入真实健康数据与幂等刷新

Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-17 11:16:31 +08:00
co-authored by Codex
parent 85ec2aa831
commit 9beadf5c9c
22 changed files with 3709 additions and 16 deletions
+143
View File
@@ -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',
};
}
+22
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
buildscript {
ext {
buildToolsVersion = "36.0.0"
minSdkVersion = 24
minSdkVersion = 26
compileSdkVersion = 36
targetSdkVersion = 36
ndkVersion = "27.1.12297006"
+30 -2
View File
@@ -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;
+10
View File
@@ -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>
+6 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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(),
}));
+58
View File
@@ -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",
+3
View File
@@ -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",
+7
View File
@@ -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;
};
+1
View File
@@ -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: '确认删除' },
+26 -4
View File
@@ -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 },
});
+64 -2
View File
@@ -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);
}
+202
View File
@@ -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';
}
+13
View File
@@ -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,
};
+143
View File
@@ -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';
}
+37
View File
@@ -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
View File
@@ -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]);
}