Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81632a4275 | |||
| c48212bfff | |||
| adb8905ebf | |||
| 1a65736fb8 | |||
| 24561bbc88 | |||
| 0ed7ec0624 | |||
| 08b9579839 | |||
| 21f431441e |
94
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
name: Build & Release APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
# RN/NDK builds need ~6GB and once took down the 8GB VPS — desktop runner only.
|
||||||
|
# Jobs queue while the desktop is offline.
|
||||||
|
runs-on: desktop
|
||||||
|
container:
|
||||||
|
image: eclipse-temurin:21-jdk
|
||||||
|
steps:
|
||||||
|
# actions/checkout needs node, which this container lacks — clone directly
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq git unzip curl xz-utils >/dev/null
|
||||||
|
git init -q .
|
||||||
|
git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
# /root/.npm and /root/.gradle and /root/android-sdk are persistent named
|
||||||
|
# volumes (act_runner config.yaml) — downloads below only happen on a cold cache.
|
||||||
|
- name: Install Node 22 (cached in npm volume)
|
||||||
|
run: |
|
||||||
|
NODE_DIR="$HOME/.npm/_node-v22.14.0"
|
||||||
|
if [ ! -x "$NODE_DIR/bin/node" ]; then
|
||||||
|
curl -sLo /tmp/node.tar.xz https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.xz
|
||||||
|
mkdir -p "$NODE_DIR" && tar xJf /tmp/node.tar.xz -C "$NODE_DIR" --strip-components=1
|
||||||
|
fi
|
||||||
|
echo "$NODE_DIR/bin" >> "$GITHUB_PATH"
|
||||||
|
"$NODE_DIR/bin/node" --version
|
||||||
|
|
||||||
|
- name: npm ci
|
||||||
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Install Android SDK + NDK (cached volume)
|
||||||
|
run: |
|
||||||
|
SDK="$HOME/android-sdk"
|
||||||
|
SDKMAN="$SDK/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
if [ ! -x "$SDKMAN" ]; then
|
||||||
|
mkdir -p "$SDK/cmdline-tools"
|
||||||
|
curl -sLo /tmp/ct.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
|
unzip -q /tmp/ct.zip -d /tmp
|
||||||
|
mv /tmp/cmdline-tools "$SDK/cmdline-tools/latest"
|
||||||
|
fi
|
||||||
|
yes | "$SDKMAN" --sdk_root="$SDK" --licenses >/dev/null 2>&1 || true
|
||||||
|
"$SDKMAN" --sdk_root="$SDK" \
|
||||||
|
"platform-tools" "platforms;android-36" "build-tools;36.0.0" \
|
||||||
|
"ndk;27.1.12297006" "cmake;3.22.1" >/dev/null
|
||||||
|
|
||||||
|
- name: Decode signing keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }}
|
||||||
|
run: echo "$KEYSTORE_B64" | base64 -d > /tmp/release.jks
|
||||||
|
|
||||||
|
- name: Prebuild android project
|
||||||
|
env:
|
||||||
|
VERSION_CODE: ${{ github.run_number }}
|
||||||
|
VERSION_NAME: 1.0.${{ github.run_number }}
|
||||||
|
run: npx expo prebuild --platform android --no-install
|
||||||
|
|
||||||
|
- name: Build signed release APK (arm64)
|
||||||
|
env:
|
||||||
|
HEALTH_KEYSTORE: /tmp/release.jks
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: health
|
||||||
|
VERSION_CODE: ${{ github.run_number }}
|
||||||
|
VERSION_NAME: 1.0.${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
export ANDROID_HOME="$HOME/android-sdk"
|
||||||
|
echo "sdk.dir=$ANDROID_HOME" > android/local.properties
|
||||||
|
cd android && ./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a \
|
||||||
|
--no-daemon --console=plain
|
||||||
|
|
||||||
|
- name: Publish Gitea release with APK
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
API="https://git.rehbock.xyz/api/v1/repos/${{ github.repository }}"
|
||||||
|
TAG="v1.0.${{ github.run_number }}"
|
||||||
|
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
test -f "$APK"
|
||||||
|
curl -sf -X POST "$API/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated build of commit ${{ github.sha }}\",\"target_commitish\":\"${{ github.sha }}\"}" \
|
||||||
|
> /tmp/release.json
|
||||||
|
RID=$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)
|
||||||
|
echo "release id: $RID"
|
||||||
|
curl -sf -X POST "$API/releases/$RID/assets?name=health-app-$TAG-arm64.apk" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null
|
||||||
|
echo "published $TAG"
|
||||||
12
README.md
@@ -47,8 +47,16 @@ cd android && ./gradlew assembleRelease
|
|||||||
# → android/app/build/outputs/apk/release/app-release.apk
|
# → android/app/build/outputs/apk/release/app-release.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
Install on GrapheneOS by copying the APK over (or via Obtainium once a
|
Install on GrapheneOS by copying the APK over, or via Obtainium.
|
||||||
Gitea release exists).
|
|
||||||
|
## CI (Gitea Actions)
|
||||||
|
|
||||||
|
Every push to `main` runs `.gitea/workflows/release.yml`: prebuild →
|
||||||
|
signed arm64 APK → Gitea release tagged `v1.0.<run_number>`, APK attached.
|
||||||
|
`app.config.js` stamps `VERSION_CODE`/`VERSION_NAME` from the run number so
|
||||||
|
each release installs as an upgrade (Obtainium-friendly).
|
||||||
|
|
||||||
|
Repo secrets: `KEYSTORE_B64` (base64 of the jks), `KEYSTORE_PASSWORD`.
|
||||||
|
|
||||||
## iOS
|
## iOS
|
||||||
|
|
||||||
|
|||||||
10
app.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* Wraps app.json so CI can stamp versions: VERSION_NAME → expo.version,
|
||||||
|
VERSION_CODE → android.versionCode (Obtainium needs it to increase). */
|
||||||
|
module.exports = ({ config }) => {
|
||||||
|
const versionCode = parseInt(process.env.VERSION_CODE || '', 10);
|
||||||
|
if (process.env.VERSION_NAME) config.version = process.env.VERSION_NAME;
|
||||||
|
if (Number.isFinite(versionCode)) {
|
||||||
|
config.android = { ...config.android, versionCode };
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
24
app.json
@@ -9,12 +9,25 @@
|
|||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"backgroundColor": "#0d0d0f",
|
"backgroundColor": "#0d0d0f",
|
||||||
"ios": {
|
"ios": {
|
||||||
"icon": "./assets/expo.icon",
|
"icon": "./assets/images/icon.png",
|
||||||
"bundleIdentifier": "xyz.rehbock.health",
|
"bundleIdentifier": "xyz.rehbock.health",
|
||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "xyz.rehbock.health",
|
"package": "xyz.rehbock.health",
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.health.READ_HEART_RATE",
|
||||||
|
"android.permission.health.READ_RESTING_HEART_RATE",
|
||||||
|
"android.permission.health.READ_HEART_RATE_VARIABILITY",
|
||||||
|
"android.permission.health.READ_OXYGEN_SATURATION",
|
||||||
|
"android.permission.health.READ_SLEEP",
|
||||||
|
"android.permission.health.READ_STEPS",
|
||||||
|
"android.permission.health.READ_RESPIRATORY_RATE",
|
||||||
|
"android.permission.health.READ_DISTANCE",
|
||||||
|
"android.permission.health.READ_ACTIVE_CALORIES_BURNED",
|
||||||
|
"android.permission.health.READ_TOTAL_CALORIES_BURNED",
|
||||||
|
"android.permission.health.READ_FLOORS_CLIMBED"
|
||||||
|
],
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#0d0d0f",
|
"backgroundColor": "#0d0d0f",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
@@ -30,6 +43,15 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-secure-store",
|
"expo-secure-store",
|
||||||
|
"react-native-health-connect",
|
||||||
|
[
|
||||||
|
"expo-build-properties",
|
||||||
|
{
|
||||||
|
"android": {
|
||||||
|
"minSdkVersion": 26
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"./plugins/with-release-signing.js",
|
"./plugins/with-release-signing.js",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 608 B |
|
Before Width: | Height: | Size: 52 KiB |
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"fill" : {
|
|
||||||
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
|
|
||||||
},
|
|
||||||
"groups" : [
|
|
||||||
{
|
|
||||||
"layers" : [
|
|
||||||
{
|
|
||||||
"image-name" : "expo-symbol 2.svg",
|
|
||||||
"name" : "expo-symbol 2",
|
|
||||||
"position" : {
|
|
||||||
"scale" : 1,
|
|
||||||
"translation-in-points" : [
|
|
||||||
1.1008400065293245e-05,
|
|
||||||
-16.046875
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"image-name" : "grid.png",
|
|
||||||
"name" : "grid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"shadow" : {
|
|
||||||
"kind" : "neutral",
|
|
||||||
"opacity" : 0.5
|
|
||||||
},
|
|
||||||
"translucency" : {
|
|
||||||
"enabled" : true,
|
|
||||||
"value" : 0.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"supported-platforms" : {
|
|
||||||
"circles" : [
|
|
||||||
"watchOS"
|
|
||||||
],
|
|
||||||
"squares" : "shared"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
assets/icon-src/glyph.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="heart" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#8a86ff"/>
|
||||||
|
<stop offset="100%" stop-color="#5b57d6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- glyph only, transparent bg, scaled into the adaptive-icon safe zone -->
|
||||||
|
<g transform="translate(512 512) scale(0.62) translate(-512 -512)">
|
||||||
|
<path d="M512 812
|
||||||
|
C 402 726, 232 616, 210 462
|
||||||
|
C 196 360, 268 268, 372 268
|
||||||
|
C 436 268, 486 304, 512 356
|
||||||
|
C 538 304, 588 268, 652 268
|
||||||
|
C 756 268, 828 360, 814 462
|
||||||
|
C 792 616, 622 726, 512 812 Z"
|
||||||
|
fill="url(#heart)"/>
|
||||||
|
<polyline points="60,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 964,520"
|
||||||
|
fill="none" stroke="#0d0d0f" stroke-width="58"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="60,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 964,520"
|
||||||
|
fill="none" stroke="#ffffff" stroke-width="26"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
32
assets/icon-src/icon.svg
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="50%" cy="38%" r="75%">
|
||||||
|
<stop offset="0%" stop-color="#1e1e26"/>
|
||||||
|
<stop offset="100%" stop-color="#0d0d0f"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="heart" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#8a86ff"/>
|
||||||
|
<stop offset="100%" stop-color="#5b57d6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1024" height="1024" fill="url(#bg)"/>
|
||||||
|
|
||||||
|
<!-- heart -->
|
||||||
|
<path d="M512 812
|
||||||
|
C 402 726, 232 616, 210 462
|
||||||
|
C 196 360, 268 268, 372 268
|
||||||
|
C 436 268, 486 304, 512 356
|
||||||
|
C 538 304, 588 268, 652 268
|
||||||
|
C 756 268, 828 360, 814 462
|
||||||
|
C 792 616, 622 726, 512 812 Z"
|
||||||
|
fill="url(#heart)"/>
|
||||||
|
|
||||||
|
<!-- ECG pulse across the heart -->
|
||||||
|
<polyline points="120,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 904,520"
|
||||||
|
fill="none" stroke="#0d0d0f" stroke-width="58"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="120,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 904,520"
|
||||||
|
fill="none" stroke="#ffffff" stroke-width="26"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
21
assets/icon-src/monochrome.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<mask id="cut">
|
||||||
|
<rect width="1024" height="1024" fill="white"/>
|
||||||
|
<polyline points="60,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 964,520"
|
||||||
|
fill="none" stroke="black" stroke-width="58"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<!-- single-silhouette version: heart with the ECG line cut out (launcher tints it) -->
|
||||||
|
<g transform="translate(512 512) scale(0.62) translate(-512 -512)">
|
||||||
|
<path d="M512 812
|
||||||
|
C 402 726, 232 616, 210 462
|
||||||
|
C 196 360, 268 268, 372 268
|
||||||
|
C 436 268, 486 304, 512 356
|
||||||
|
C 538 304, 588 268, 652 268
|
||||||
|
C 756 268, 828 360, 814 462
|
||||||
|
C 792 616, 622 726, 512 812 Z"
|
||||||
|
fill="#ffffff" mask="url(#cut)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 881 B |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 451 B |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 780 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 14 KiB |
47
package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.86.2",
|
"react-native": "0.86.2",
|
||||||
"react-native-gesture-handler": "~2.32.0",
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
|
"react-native-health-connect": "^4.1.3",
|
||||||
"react-native-reanimated": "4.5.1",
|
"react-native-reanimated": "4.5.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "~4.26.0",
|
"react-native-screens": "~4.26.0",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
"expo-build-properties": "^57.0.9",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3811,6 +3813,21 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-build-properties": {
|
||||||
|
"version": "57.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.9.tgz",
|
||||||
|
"integrity": "sha512-IX8Nz85yNDZyqBK7zbLEfn4ijMGaF6TmLjCY2KE0UKltUoV78DhgN6ksPZbDbPDvqLpU2fMY/5OD4CbYB7hHwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/schema-utils": "^57.0.2",
|
||||||
|
"resolve-from": "^5.0.0",
|
||||||
|
"semver": "^7.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "57.0.9",
|
"version": "57.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.9.tgz",
|
||||||
@@ -6575,6 +6592,36 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/react-native-is-edge-to-edge": {
|
||||||
"version": "1.3.1",
|
"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",
|
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.86.2",
|
"react-native": "0.86.2",
|
||||||
"react-native-gesture-handler": "~2.32.0",
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
|
"react-native-health-connect": "^4.1.3",
|
||||||
"react-native-reanimated": "4.5.1",
|
"react-native-reanimated": "4.5.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "~4.26.0",
|
"react-native-screens": "~4.26.0",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
"expo-build-properties": "^57.0.9",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ module.exports = function withReleaseSigning(config) {
|
|||||||
if (jdk && !cfg.modResults.some((p) => p.key === 'org.gradle.java.home')) {
|
if (jdk && !cfg.modResults.some((p) => p.key === 'org.gradle.java.home')) {
|
||||||
cfg.modResults.push({ type: 'property', key: 'org.gradle.java.home', value: jdk });
|
cfg.modResults.push({ type: 'property', key: 'org.gradle.java.home', value: jdk });
|
||||||
}
|
}
|
||||||
|
/* use every core: parallel module builds + build cache */
|
||||||
|
const set = (key, value) => {
|
||||||
|
const hit = cfg.modResults.find((p) => p.key === key);
|
||||||
|
if (hit) hit.value = value;
|
||||||
|
else cfg.modResults.push({ type: 'property', key, value });
|
||||||
|
};
|
||||||
|
set('org.gradle.parallel', 'true');
|
||||||
|
set('org.gradle.caching', 'true');
|
||||||
|
set('org.gradle.configuration-cache', 'false');
|
||||||
|
set('org.gradle.workers.max', String(os.cpus().length));
|
||||||
|
set('org.gradle.jvmargs', '-Xmx6g -XX:MaxMetaspaceSize=1g');
|
||||||
return cfg;
|
return cfg;
|
||||||
});
|
});
|
||||||
return withAppBuildGradle(config, (cfg) => {
|
return withAppBuildGradle(config, (cfg) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function Gate() {
|
|||||||
<Stack.Screen name="index" options={{ title: 'Health' }} />
|
<Stack.Screen name="index" options={{ title: 'Health' }} />
|
||||||
<Stack.Screen name="blood" options={{ title: 'Bloodwork' }} />
|
<Stack.Screen name="blood" options={{ title: 'Bloodwork' }} />
|
||||||
<Stack.Screen name="sleep" options={{ title: 'Sleep' }} />
|
<Stack.Screen name="sleep" options={{ title: 'Sleep' }} />
|
||||||
|
<Stack.Screen name="fitbit" options={{ title: 'Fitbit Air' }} />
|
||||||
<Stack.Screen name="train" options={{ title: 'Training' }} />
|
<Stack.Screen name="train" options={{ title: 'Training' }} />
|
||||||
<Stack.Screen name="body" options={{ title: 'Body Composition' }} />
|
<Stack.Screen name="body" options={{ title: 'Body Composition' }} />
|
||||||
<Stack.Screen name="mind" options={{ title: 'Meditation' }} />
|
<Stack.Screen name="mind" options={{ title: 'Meditation' }} />
|
||||||
|
|||||||
237
src/app/fitbit.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Platform, Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
|
||||||
|
import { DailyBars, DailyLine } from '../components/charts';
|
||||||
|
import { ErrBox, Loading, Panel, Screen, Sec, SrcNote, Tile, TileGrid } from '../components/ui';
|
||||||
|
import { load } from '../lib/api';
|
||||||
|
import { fmtDY, hm, num } from '../lib/format';
|
||||||
|
import { healthConnectStatus, healthConnectSupported, syncFitbit } from '../lib/fitbit';
|
||||||
|
import { C } from '../lib/theme';
|
||||||
|
import { useLoad } from '../lib/use-load';
|
||||||
|
|
||||||
|
interface DayVal { day: string; value: number }
|
||||||
|
interface DayAvg { day: string; avg: number; n: number }
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
days: number;
|
||||||
|
steps: DayVal[];
|
||||||
|
hr_daily: { day: string; min: number; max: number; avg: number; n: number }[];
|
||||||
|
resting_hr: DayAvg[];
|
||||||
|
hrv: DayAvg[];
|
||||||
|
spo2: DayAvg[];
|
||||||
|
respiratory_rate: DayAvg[];
|
||||||
|
sleep: {
|
||||||
|
id: string; started_at: string; ended_at: string; total_seconds: number;
|
||||||
|
sleep_seconds: number; deep_seconds: number; rem_seconds: number;
|
||||||
|
light_seconds: number; awake_seconds: number;
|
||||||
|
}[];
|
||||||
|
sample_counts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Fitbit() {
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const chartW = Math.min(608, width - 32) - 28;
|
||||||
|
const { data, err, loading, refresh } = useLoad(() => load<Summary>('fitbitSummary'));
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncMsg, setSyncMsg] = useState('');
|
||||||
|
const [hcStatus, setHcStatus] = useState<string>('');
|
||||||
|
const autoSynced = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
healthConnectStatus().then(setHcStatus);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runSync = async () => {
|
||||||
|
if (syncing) return;
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await syncFitbit(setSyncMsg);
|
||||||
|
setSyncMsg(res.message);
|
||||||
|
if (res.ok && (res.samples || res.daily || res.sleep)) refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setSyncMsg(`Sync failed: ${e instanceof Error ? e.message : e}`);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// auto-sync once per screen visit on Android
|
||||||
|
useEffect(() => {
|
||||||
|
if (hcStatus === 'available' && !autoSynced.current) {
|
||||||
|
autoSynced.current = true;
|
||||||
|
runSync();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hcStatus]);
|
||||||
|
|
||||||
|
let body: React.ReactNode = null;
|
||||||
|
if (loading && !data) body = <Loading />;
|
||||||
|
else if (err) body = <ErrBox msg={err} />;
|
||||||
|
else if (data) {
|
||||||
|
const lastVal = (arr: DayAvg[]) => (arr.length ? arr[arr.length - 1] : null);
|
||||||
|
const stepsToday = data.steps.length ? data.steps[data.steps.length - 1] : null;
|
||||||
|
const rhr = lastVal(data.resting_hr);
|
||||||
|
const hrv = lastVal(data.hrv);
|
||||||
|
const spo2 = lastVal(data.spo2);
|
||||||
|
const lastSleep = data.sleep.length ? data.sleep[data.sleep.length - 1] : null;
|
||||||
|
const empty = !data.steps.length && !data.hr_daily.length && !data.sleep.length;
|
||||||
|
|
||||||
|
body = (
|
||||||
|
<>
|
||||||
|
{empty ? (
|
||||||
|
<View style={s.emptyBox}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600', marginBottom: 6 }}>
|
||||||
|
No Fitbit data yet
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12.5, lineHeight: 18 }}>
|
||||||
|
Make sure the Google Health app is installed, the Fitbit Air is paired, and
|
||||||
|
Google Health is sharing to Health Connect (Profile → Privacy → Health Connect).
|
||||||
|
Then tap Sync below.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TileGrid>
|
||||||
|
<Tile label="Steps" value={stepsToday ? num(Math.round(stepsToday.value)) : '—'}
|
||||||
|
sub={stepsToday ? fmtDY(stepsToday.day) : undefined} />
|
||||||
|
<Tile label="Resting HR" value={rhr ? String(Math.round(rhr.avg)) : '—'} unit="bpm"
|
||||||
|
sub={rhr ? fmtDY(rhr.day) : undefined} />
|
||||||
|
<Tile label="HRV (RMSSD)" value={hrv ? String(Math.round(hrv.avg)) : '—'} unit="ms"
|
||||||
|
sub={hrv ? fmtDY(hrv.day) : undefined} />
|
||||||
|
<Tile label="SpO2" value={spo2 ? spo2.avg.toFixed(1) : '—'} unit="%"
|
||||||
|
sub={spo2 ? fmtDY(spo2.day) : undefined} />
|
||||||
|
{lastSleep ? (
|
||||||
|
<Tile label="Last sleep" value={hm(lastSleep.sleep_seconds)}
|
||||||
|
sub={fmtDY(lastSleep.started_at)} />
|
||||||
|
) : null}
|
||||||
|
</TileGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.steps.length ? (
|
||||||
|
<>
|
||||||
|
<Sec title={`Steps · ${data.days} days`} />
|
||||||
|
<Panel title="Daily steps" sub="tap a bar">
|
||||||
|
<DailyBars data={data.steps} width={chartW} unit="Steps" />
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.resting_hr.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<Sec title="Resting heart rate" />
|
||||||
|
<Panel title="Daily resting HR (bpm)" sub="tap a point">
|
||||||
|
<DailyLine data={data.resting_hr} width={chartW} unit="bpm" color={C.s2} />
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.hrv.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<Sec title="Heart rate variability" />
|
||||||
|
<Panel title="Nightly HRV RMSSD (ms)" sub="tap a point">
|
||||||
|
<DailyLine data={data.hrv} width={chartW} unit="ms" color={C.s3} />
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.spo2.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<Sec title="Blood oxygen" />
|
||||||
|
<Panel title="Nightly SpO2 (%)" sub="tap a point">
|
||||||
|
<DailyLine data={data.spo2} width={chartW} unit="%" color={C.s1} decimals={1} />
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.sleep.length ? (
|
||||||
|
<>
|
||||||
|
<Sec title="Sleep sessions" count={data.sleep.length} />
|
||||||
|
{[...data.sleep].reverse().slice(0, 14).map((sl) => (
|
||||||
|
<View key={sl.id} style={s.row}>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 13.5, fontWeight: '500' }}>
|
||||||
|
{hm(sl.sleep_seconds)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11 }}>{fmtDY(sl.started_at)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 12, marginTop: 3 }}>
|
||||||
|
{sl.deep_seconds ? `deep ${hm(sl.deep_seconds)} · ` : ''}
|
||||||
|
{sl.rem_seconds ? `REM ${hm(sl.rem_seconds)} · ` : ''}
|
||||||
|
{sl.light_seconds ? `light ${hm(sl.light_seconds)} · ` : ''}
|
||||||
|
awake {hm(sl.awake_seconds)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Sec title="Data on record" />
|
||||||
|
<View style={s.debugBox}>
|
||||||
|
{Object.entries(data.sample_counts).map(([k, v]) => (
|
||||||
|
<Text key={k} style={{ color: v ? C.muted : C.dim, fontSize: 12, marginVertical: 1 }}>
|
||||||
|
{k.replace(/_/g, ' ')}: {num(v)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<SrcNote>
|
||||||
|
Fitbit Air → Google Health → Health Connect → health.rehbock.xyz. Data syncs from
|
||||||
|
this phone when you open this screen; the server is the system of record.
|
||||||
|
</SrcNote>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen onRefresh={refresh}>
|
||||||
|
{/* sync bar */}
|
||||||
|
<View style={s.syncBar}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>
|
||||||
|
{Platform.OS === 'android' ? 'Health Connect' : 'Sync (Android phone only)'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: C.muted, fontSize: 11.5, marginTop: 2 }}>
|
||||||
|
{healthConnectSupported()
|
||||||
|
? hcStatus === 'available'
|
||||||
|
? syncMsg || 'Ready'
|
||||||
|
: hcStatus === 'needs-update'
|
||||||
|
? 'Health Connect needs an update'
|
||||||
|
: hcStatus || 'Checking…'
|
||||||
|
: 'Viewing server data — sync runs on the Android phone'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{healthConnectSupported() && (
|
||||||
|
<Pressable
|
||||||
|
style={[s.syncBtn, (syncing || hcStatus !== 'available') && { opacity: 0.5 }]}
|
||||||
|
disabled={syncing || hcStatus !== 'available'}
|
||||||
|
onPress={runSync}>
|
||||||
|
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 13 }}>
|
||||||
|
{syncing ? 'Syncing…' : 'Sync'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{body}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
syncBar: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||||
|
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border,
|
||||||
|
borderRadius: 12, padding: 12, marginBottom: 14,
|
||||||
|
},
|
||||||
|
syncBtn: {
|
||||||
|
backgroundColor: C.accent, borderRadius: 10, paddingVertical: 9, paddingHorizontal: 18,
|
||||||
|
},
|
||||||
|
emptyBox: {
|
||||||
|
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border,
|
||||||
|
borderRadius: 12, padding: 16, marginBottom: 4,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12,
|
||||||
|
paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8,
|
||||||
|
},
|
||||||
|
debugBox: {
|
||||||
|
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border,
|
||||||
|
borderRadius: 12, padding: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ export default function Today() {
|
|||||||
const { data, loading, refresh } = useLoad(() =>
|
const { data, loading, refresh } = useLoad(() =>
|
||||||
loadAll({
|
loadAll({
|
||||||
meta: 'meta', bio: 'biomarkers', wo: 'workouts', sApi: 'sleepApi',
|
meta: 'meta', bio: 'biomarkers', wo: 'workouts', sApi: 'sleepApi',
|
||||||
sc: 'sleepCycle', dexa: 'dexa', med: 'medStats',
|
sc: 'sleepCycle', dexa: 'dexa', med: 'medStats', fitbit: 'fitbitSummary',
|
||||||
} as const),
|
} as const),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -30,6 +30,11 @@ export default function Today() {
|
|||||||
sc: SleepCycleExport | null;
|
sc: SleepCycleExport | null;
|
||||||
dexa: DexaResponse | null;
|
dexa: DexaResponse | null;
|
||||||
med: MeditationStats | null;
|
med: MeditationStats | null;
|
||||||
|
fitbit: {
|
||||||
|
steps: { day: string; value: number }[];
|
||||||
|
resting_hr: { day: string; avg: number }[];
|
||||||
|
hrv: { day: string; avg: number }[];
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -89,6 +94,21 @@ export default function Today() {
|
|||||||
);
|
);
|
||||||
} else cards.push(<NavCard key="mind" title="Mind" big="—" note="Couldn’t load" href="/mind" />);
|
} else cards.push(<NavCard key="mind" title="Mind" big="—" note="Couldn’t load" href="/mind" />);
|
||||||
|
|
||||||
|
{
|
||||||
|
const fb = r.fitbit;
|
||||||
|
const steps = fb?.steps?.length ? fb.steps[fb.steps.length - 1] : null;
|
||||||
|
const hrv = fb?.hrv?.length ? fb.hrv[fb.hrv.length - 1] : null;
|
||||||
|
cards.push(
|
||||||
|
<NavCard key="fitbit" title="Fitbit" href="/fitbit"
|
||||||
|
big={steps ? num(Math.round(steps.value)) : '—'} unit={steps ? 'steps' : undefined}
|
||||||
|
note={
|
||||||
|
steps
|
||||||
|
? `${fmtD(steps.day)}${hrv ? ` · HRV ${Math.round(hrv.avg)} ms` : ''}`
|
||||||
|
: 'Open to sync from Health Connect'
|
||||||
|
} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
cards.push(
|
cards.push(
|
||||||
<NavCard key="dna" title="DNA" big="99" unit="traits" href="/dna"
|
<NavCard key="dna" title="DNA" big="99" unit="traits" href="/dna"
|
||||||
note="AncestryDNA trait report" />,
|
note="AncestryDNA trait report" />,
|
||||||
|
|||||||
@@ -13,15 +13,19 @@ export default function Sleep() {
|
|||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const chartW = Math.min(608, width - 32) - 28;
|
const chartW = Math.min(608, width - 32) - 28;
|
||||||
const { data, err, loading, refresh } = useLoad(() =>
|
const { data, err, loading, refresh } = useLoad(() =>
|
||||||
Promise.all([load<SleepApiResponse>('sleepApi'), load<SleepCycleExport>('sleepCycle')]),
|
Promise.all([
|
||||||
|
load<SleepApiResponse>('sleepApi'),
|
||||||
|
load<SleepCycleExport>('sleepCycle'),
|
||||||
|
load<{ sleep: import('../lib/shape').HcSleepRow[] }>('fitbitSummary').catch(() => null),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
let body: React.ReactNode = null;
|
let body: React.ReactNode = null;
|
||||||
if (loading && !data) body = <Loading />;
|
if (loading && !data) body = <Loading />;
|
||||||
else if (err) body = <ErrBox msg={err} />;
|
else if (err) body = <ErrBox msg={err} />;
|
||||||
else if (data) {
|
else if (data) {
|
||||||
const [api, sc] = data;
|
const [api, sc, fb] = data;
|
||||||
const nights = shapeSleep(api, sc);
|
const nights = shapeSleep(api, sc, fb?.sleep ?? []);
|
||||||
const recent = nights.slice(-30);
|
const recent = nights.slice(-30);
|
||||||
const last7 = nights.slice(-7);
|
const last7 = nights.slice(-7);
|
||||||
const avg = (arr: typeof nights, f: (t: (typeof nights)[0]) => number) =>
|
const avg = (arr: typeof nights, f: (t: (typeof nights)[0]) => number) =>
|
||||||
@@ -56,8 +60,9 @@ export default function Sleep() {
|
|||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
<SrcNote>
|
<SrcNote>
|
||||||
Sources: Eight Sleep pod (through Feb 2026, incl. HRV/HR) and Sleep Cycle app export
|
Sources: Fitbit Air (via Health Connect), Eight Sleep pod (through Feb 2026, incl.
|
||||||
(through {sc.exported_at}). {num(nights.length)} nights total on record.
|
HRV/HR), Sleep Cycle export (through {sc.exported_at}). {num(nights.length)} nights
|
||||||
|
total on record.
|
||||||
</SrcNote>
|
</SrcNote>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -157,6 +157,108 @@ export function VolumeChart({ weeks, width }: { weeks: Week[]; width: number })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* generic daily bars (steps, kcal…) — tap for value */
|
||||||
|
export function DailyBars({
|
||||||
|
data, width, unit, color = C.s1, fmt = (v: number) => kfmt(v),
|
||||||
|
}: {
|
||||||
|
data: { day: string; value: number }[];
|
||||||
|
width: number;
|
||||||
|
unit: string;
|
||||||
|
color?: string;
|
||||||
|
fmt?: (v: number) => string;
|
||||||
|
}) {
|
||||||
|
const [sel, setSel] = useState<number | null>(null);
|
||||||
|
if (!data.length) return null;
|
||||||
|
const H = 130, padL = 34, padB = 16, W = width;
|
||||||
|
const iw = W - padL, n = data.length, bw = Math.max(3, iw / n - 2);
|
||||||
|
const maxV = Math.max(...data.map((d) => d.value), 1);
|
||||||
|
const y = (v: number) => H - padB - (v / maxV) * (H - padB - 8);
|
||||||
|
const selD = sel != null ? data[sel] : null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
{[0, 0.5, 1].map((f) => (
|
||||||
|
<G key={f}>
|
||||||
|
<Line x1={padL} y1={y(maxV * f)} x2={W} y2={y(maxV * f)} stroke={C.gridline} strokeWidth={1} />
|
||||||
|
<SvgText x={padL - 5} y={y(maxV * f) + 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
|
||||||
|
{fmt(maxV * f)}
|
||||||
|
</SvgText>
|
||||||
|
</G>
|
||||||
|
))}
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const x = padL + (i + 0.5) * (iw / n) - bw / 2;
|
||||||
|
return (
|
||||||
|
<G key={d.day} onPress={() => setSel(i === sel ? null : i)}>
|
||||||
|
<Rect x={x - 1} y={8} width={bw + 2} height={H - padB - 8} fill="transparent" />
|
||||||
|
<Rect x={x} y={y(d.value)} width={bw} height={Math.max(1, H - padB - y(d.value))}
|
||||||
|
rx={1.5} fill={color} opacity={sel == null || sel === i ? 1 : 0.35} />
|
||||||
|
</G>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<SvgText x={padL} y={H - 3} fill={C.muted} fontSize={9.5}>{fmtD(data[0].day)}</SvgText>
|
||||||
|
<SvgText x={W} y={H - 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
|
||||||
|
{fmtD(data[n - 1].day)}
|
||||||
|
</SvgText>
|
||||||
|
</Svg>
|
||||||
|
{selD && (
|
||||||
|
<DetailCard title={fmtDY(selD.day)} rows={[[unit, `${num(Math.round(selD.value))}`]]} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* daily line with dots (resting HR, HRV, SpO2…) — tap a point for value */
|
||||||
|
export function DailyLine({
|
||||||
|
data, width, unit, color = C.s3, decimals = 0,
|
||||||
|
}: {
|
||||||
|
data: { day: string; avg: number }[];
|
||||||
|
width: number;
|
||||||
|
unit: string;
|
||||||
|
color?: string;
|
||||||
|
decimals?: number;
|
||||||
|
}) {
|
||||||
|
const [sel, setSel] = useState<number | null>(null);
|
||||||
|
if (data.length < 2) return null;
|
||||||
|
const H = 110, padL = 34, padB = 16, padT = 8, W = width;
|
||||||
|
const iw = W - padL, n = data.length;
|
||||||
|
const vs = data.map((d) => d.avg);
|
||||||
|
const min = Math.min(...vs), max = Math.max(...vs);
|
||||||
|
const span = max - min || 1;
|
||||||
|
const x = (i: number) => padL + (i + 0.5) * (iw / n);
|
||||||
|
const y = (v: number) => H - padB - ((v - min) / span) * (H - padB - padT);
|
||||||
|
const path = data.map((d, i) => (i ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(d.avg).toFixed(1)).join(' ');
|
||||||
|
const selD = sel != null ? data[sel] : null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
{[min, max].map((v) => (
|
||||||
|
<G key={v}>
|
||||||
|
<Line x1={padL} y1={y(v)} x2={W} y2={y(v)} stroke={C.gridline} strokeWidth={1} />
|
||||||
|
<SvgText x={padL - 5} y={y(v) + 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
|
||||||
|
{v.toFixed(decimals)}
|
||||||
|
</SvgText>
|
||||||
|
</G>
|
||||||
|
))}
|
||||||
|
<Path d={path} fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" opacity={0.85} />
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<G key={d.day} onPress={() => setSel(i === sel ? null : i)}>
|
||||||
|
<Rect x={x(i) - iw / n / 2} y={0} width={iw / n} height={H - padB} fill="transparent" />
|
||||||
|
<Circle cx={x(i)} cy={y(d.avg)} r={sel === i ? 4.5 : 2.5} fill={color}
|
||||||
|
stroke={C.surface} strokeWidth={1} />
|
||||||
|
</G>
|
||||||
|
))}
|
||||||
|
<SvgText x={padL} y={H - 3} fill={C.muted} fontSize={9.5}>{fmtD(data[0].day)}</SvgText>
|
||||||
|
<SvgText x={W} y={H - 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
|
||||||
|
{fmtD(data[n - 1].day)}
|
||||||
|
</SvgText>
|
||||||
|
</Svg>
|
||||||
|
{selD && (
|
||||||
|
<DetailCard title={fmtDY(selD.day)} rows={[[unit, selD.avg.toFixed(decimals)]]} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* biomarker sparkline */
|
/* biomarker sparkline */
|
||||||
export function Sparkline({
|
export function Sparkline({
|
||||||
pts, flagged, width,
|
pts, flagged, width,
|
||||||
|
|||||||
@@ -14,8 +14,27 @@ export const EP = {
|
|||||||
medStats: `${BASE}/api/meditation/v1/stats`,
|
medStats: `${BASE}/api/meditation/v1/stats`,
|
||||||
medSessions: `${BASE}/api/meditation/v1/sessions?limit=1000`,
|
medSessions: `${BASE}/api/meditation/v1/sessions?limit=1000`,
|
||||||
traits: `${BASE}/ancestry-traits.json`,
|
traits: `${BASE}/ancestry-traits.json`,
|
||||||
|
fitbitSummary: `${BASE}/api/health/v1/fitbit/summary?days=30`,
|
||||||
|
fitbitLatest: `${BASE}/api/health/v1/fitbit/latest`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const FITBIT_SYNC_URL = `${BASE}/api/health/v1/fitbit/sync`;
|
||||||
|
|
||||||
|
/* authenticated POST (fitbit sync) */
|
||||||
|
export async function postJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Basic ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (r.status === 401 || r.status === 403) {
|
||||||
|
onUnauthorized?.();
|
||||||
|
throw new Error('unauthorized');
|
||||||
|
}
|
||||||
|
if (!r.ok) throw new Error(`${url} → ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
export type EpKey = keyof typeof EP;
|
export type EpKey = keyof typeof EP;
|
||||||
|
|
||||||
const AUTH_KEY = 'hd.auth';
|
const AUTH_KEY = 'hd.auth';
|
||||||
@@ -79,6 +98,11 @@ export function clearCache() {
|
|||||||
cache = {};
|
cache = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* drop specific endpoints from the cache so the next load() refetches */
|
||||||
|
export function invalidate(...keys: EpKey[]) {
|
||||||
|
for (const k of keys) delete cache[k];
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- auth context ---- */
|
/* ---- auth context ---- */
|
||||||
|
|
||||||
interface AuthCtx {
|
interface AuthCtx {
|
||||||
|
|||||||
193
src/lib/fitbit.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/* Fitbit Air → Health Connect → health.rehbock.xyz sync.
|
||||||
|
Android-only: react-native-health-connect is loaded lazily so the module's
|
||||||
|
absence (iOS, Expo Go) never crashes the app. */
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { FITBIT_SYNC_URL, invalidate, load, postJson } from './api';
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
ok: boolean;
|
||||||
|
samples: number;
|
||||||
|
daily: number;
|
||||||
|
sleep: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sample { type: string; t: string; value: number }
|
||||||
|
interface DailyRow { type: string; day: string; value: number }
|
||||||
|
interface SleepRow {
|
||||||
|
id: string; started_at: string; ended_at: string;
|
||||||
|
total_seconds: number; sleep_seconds: number;
|
||||||
|
deep_seconds: number; rem_seconds: number; light_seconds: number; awake_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const READ_PERMISSIONS = [
|
||||||
|
'HeartRate', 'RestingHeartRate', 'HeartRateVariabilityRmssd', 'OxygenSaturation',
|
||||||
|
'SleepSession', 'Steps', 'RespiratoryRate', 'Distance',
|
||||||
|
'ActiveCaloriesBurned', 'TotalCaloriesBurned', 'FloorsClimbed',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const FIRST_SYNC_LOOKBACK_DAYS = 60;
|
||||||
|
const OVERLAP_MS = 6 * 3600 * 1000; // re-read a little so late-arriving records land
|
||||||
|
|
||||||
|
function hc() {
|
||||||
|
// require at call time — the native module only exists in the Android build
|
||||||
|
return require('react-native-health-connect');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healthConnectSupported(): boolean {
|
||||||
|
return Platform.OS === 'android';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function healthConnectStatus(): Promise<'available' | 'needs-update' | 'unavailable' | 'unsupported'> {
|
||||||
|
if (!healthConnectSupported()) return 'unsupported';
|
||||||
|
try {
|
||||||
|
const { getSdkStatus, SdkAvailabilityStatus } = hc();
|
||||||
|
const s = await getSdkStatus();
|
||||||
|
if (s === SdkAvailabilityStatus.SDK_AVAILABLE) return 'available';
|
||||||
|
if (s === SdkAvailabilityStatus.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED) return 'needs-update';
|
||||||
|
return 'unavailable';
|
||||||
|
} catch {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAll(recordType: string, startTime: string, endTime: string): Promise<any[]> {
|
||||||
|
const { readRecords } = hc();
|
||||||
|
const out: any[] = [];
|
||||||
|
let pageToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const res = await readRecords(recordType, {
|
||||||
|
timeRangeFilter: { operator: 'between', startTime, endTime },
|
||||||
|
pageSize: 1000,
|
||||||
|
...(pageToken ? { pageToken } : {}),
|
||||||
|
});
|
||||||
|
out.push(...(res.records || []));
|
||||||
|
pageToken = res.pageToken || undefined;
|
||||||
|
} while (pageToken && out.length < 200000);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localDay = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* HC sleep stage constants (androidx.health.connect SleepStageType) */
|
||||||
|
const STAGE = { AWAKE: 1, SLEEPING: 2, OUT_OF_BED: 3, LIGHT: 4, DEEP: 5, REM: 6, AWAKE_IN_BED: 7 };
|
||||||
|
|
||||||
|
export async function syncFitbit(onProgress?: (msg: string) => void): Promise<SyncResult> {
|
||||||
|
if (!healthConnectSupported())
|
||||||
|
return { ok: false, samples: 0, daily: 0, sleep: 0, message: 'Health Connect is Android-only' };
|
||||||
|
|
||||||
|
const { initialize, requestPermission } = hc();
|
||||||
|
const say = (m: string) => onProgress?.(m);
|
||||||
|
|
||||||
|
say('Initializing Health Connect…');
|
||||||
|
const ok = await initialize();
|
||||||
|
if (!ok) return { ok: false, samples: 0, daily: 0, sleep: 0, message: 'Health Connect init failed' };
|
||||||
|
|
||||||
|
say('Requesting permissions…');
|
||||||
|
await requestPermission(READ_PERMISSIONS.map((r) => ({ accessType: 'read', recordType: r })));
|
||||||
|
|
||||||
|
say('Checking server watermark…');
|
||||||
|
invalidate('fitbitLatest'); // watermark must be fresh, never the session-cached copy
|
||||||
|
const latest = await load<{
|
||||||
|
samples: Record<string, string | null>;
|
||||||
|
daily: Record<string, string | null>;
|
||||||
|
sleep: string | null;
|
||||||
|
}>('fitbitLatest');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = now.toISOString();
|
||||||
|
const fallback = new Date(now.getTime() - FIRST_SYNC_LOOKBACK_DAYS * 86400000).toISOString();
|
||||||
|
const sinceFor = (watermark: string | null) =>
|
||||||
|
watermark ? new Date(new Date(watermark).getTime() - OVERLAP_MS).toISOString() : fallback;
|
||||||
|
|
||||||
|
const samples: Sample[] = [];
|
||||||
|
const daily = new Map<string, DailyRow>();
|
||||||
|
const sleep: SleepRow[] = [];
|
||||||
|
|
||||||
|
// continuous heart rate (records carry arrays of samples)
|
||||||
|
say('Reading heart rate…');
|
||||||
|
for (const rec of await readAll('HeartRate', sinceFor(latest.samples.heart_rate), endTime))
|
||||||
|
for (const s of rec.samples || [])
|
||||||
|
samples.push({ type: 'heart_rate', t: s.time, value: s.beatsPerMinute });
|
||||||
|
|
||||||
|
say('Reading vitals…');
|
||||||
|
for (const rec of await readAll('RestingHeartRate', sinceFor(latest.samples.resting_hr), endTime))
|
||||||
|
samples.push({ type: 'resting_hr', t: rec.time, value: rec.beatsPerMinute });
|
||||||
|
for (const rec of await readAll('HeartRateVariabilityRmssd', sinceFor(latest.samples.hrv_rmssd), endTime))
|
||||||
|
samples.push({ type: 'hrv_rmssd', t: rec.time, value: rec.heartRateVariabilityMillis });
|
||||||
|
for (const rec of await readAll('OxygenSaturation', sinceFor(latest.samples.spo2), endTime))
|
||||||
|
samples.push({ type: 'spo2', t: rec.time, value: rec.percentage });
|
||||||
|
for (const rec of await readAll('RespiratoryRate', sinceFor(latest.samples.respiratory_rate), endTime))
|
||||||
|
samples.push({ type: 'respiratory_rate', t: rec.time, value: rec.rate });
|
||||||
|
|
||||||
|
// interval records → per-local-day sums
|
||||||
|
say('Reading activity…');
|
||||||
|
const addDaily = (type: string, day: string, value: number) => {
|
||||||
|
const k = `${type}|${day}`;
|
||||||
|
const cur = daily.get(k);
|
||||||
|
if (cur) cur.value += value;
|
||||||
|
else daily.set(k, { type, day, value });
|
||||||
|
};
|
||||||
|
const dailyStart = (t: string | null) => sinceFor(t);
|
||||||
|
for (const rec of await readAll('Steps', dailyStart(latest.daily.steps ? latest.daily.steps + 'T00:00:00Z' : null), endTime))
|
||||||
|
addDaily('steps', localDay(rec.startTime), rec.count || 0);
|
||||||
|
for (const rec of await readAll('Distance', dailyStart(latest.daily.distance_m ? latest.daily.distance_m + 'T00:00:00Z' : null), endTime))
|
||||||
|
addDaily('distance_m', localDay(rec.startTime), rec.distance?.inMeters ?? 0);
|
||||||
|
for (const rec of await readAll('ActiveCaloriesBurned', dailyStart(latest.daily.active_kcal ? latest.daily.active_kcal + 'T00:00:00Z' : null), endTime))
|
||||||
|
addDaily('active_kcal', localDay(rec.startTime), rec.energy?.inKilocalories ?? 0);
|
||||||
|
for (const rec of await readAll('TotalCaloriesBurned', dailyStart(latest.daily.total_kcal ? latest.daily.total_kcal + 'T00:00:00Z' : null), endTime))
|
||||||
|
addDaily('total_kcal', localDay(rec.startTime), rec.energy?.inKilocalories ?? 0);
|
||||||
|
for (const rec of await readAll('FloorsClimbed', dailyStart(latest.daily.floors ? latest.daily.floors + 'T00:00:00Z' : null), endTime))
|
||||||
|
addDaily('floors', localDay(rec.startTime), rec.floors || 0);
|
||||||
|
|
||||||
|
say('Reading sleep…');
|
||||||
|
for (const rec of await readAll('SleepSession', sinceFor(latest.sleep), endTime)) {
|
||||||
|
const startMs = new Date(rec.startTime).getTime();
|
||||||
|
const endMs = new Date(rec.endTime).getTime();
|
||||||
|
let deep = 0, rem = 0, light = 0, awake = 0, asleep = 0;
|
||||||
|
for (const st of rec.stages || []) {
|
||||||
|
const sec = (new Date(st.endTime).getTime() - new Date(st.startTime).getTime()) / 1000;
|
||||||
|
if (st.stage === STAGE.DEEP) deep += sec;
|
||||||
|
else if (st.stage === STAGE.REM) rem += sec;
|
||||||
|
else if (st.stage === STAGE.LIGHT || st.stage === STAGE.SLEEPING) light += sec;
|
||||||
|
else if (st.stage === STAGE.AWAKE || st.stage === STAGE.AWAKE_IN_BED || st.stage === STAGE.OUT_OF_BED) awake += sec;
|
||||||
|
}
|
||||||
|
asleep = deep + rem + light;
|
||||||
|
const total = (endMs - startMs) / 1000;
|
||||||
|
if (asleep === 0) asleep = Math.max(0, total - awake); // unstaged session
|
||||||
|
sleep.push({
|
||||||
|
id: rec.metadata?.id || `${rec.startTime}-${rec.endTime}`,
|
||||||
|
started_at: rec.startTime, ended_at: rec.endTime,
|
||||||
|
total_seconds: Math.round(total), sleep_seconds: Math.round(asleep),
|
||||||
|
deep_seconds: Math.round(deep), rem_seconds: Math.round(rem),
|
||||||
|
light_seconds: Math.round(light), awake_seconds: Math.round(awake),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
say('Uploading…');
|
||||||
|
let ns = 0, nd = 0, nl = 0;
|
||||||
|
const dailyRows = [...daily.values()];
|
||||||
|
const CHUNK = 4000;
|
||||||
|
for (let i = 0; i < Math.max(1, Math.ceil(samples.length / CHUNK)); i++) {
|
||||||
|
const chunk = samples.slice(i * CHUNK, (i + 1) * CHUNK);
|
||||||
|
const body = {
|
||||||
|
samples: chunk,
|
||||||
|
daily: i === 0 ? dailyRows : [],
|
||||||
|
sleep: i === 0 ? sleep : [],
|
||||||
|
};
|
||||||
|
if (!chunk.length && i > 0) break;
|
||||||
|
const res = await postJson<{ samples: number; daily: number; sleep: number }>(FITBIT_SYNC_URL, body);
|
||||||
|
ns += res.samples; nd += res.daily; nl += res.sleep;
|
||||||
|
}
|
||||||
|
|
||||||
|
// new rows exist server-side — cached copies of these are now stale
|
||||||
|
invalidate('fitbitSummary', 'fitbitLatest');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true, samples: ns, daily: nd, sleep: nl,
|
||||||
|
message: `Synced ${ns} samples, ${nd} daily rows, ${nl} sleep sessions`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,17 @@ import type {
|
|||||||
Week, Workout,
|
Week, Workout,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export function shapeSleep(api: SleepApiResponse, sc: SleepCycleExport): Night[] {
|
export interface HcSleepRow {
|
||||||
|
id: string; started_at: string; ended_at: string;
|
||||||
|
total_seconds: number; sleep_seconds: number;
|
||||||
|
deep_seconds: number; rem_seconds: number; light_seconds: number; awake_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shapeSleep(
|
||||||
|
api: SleepApiResponse,
|
||||||
|
sc: SleepCycleExport,
|
||||||
|
fitbit: HcSleepRow[] = [],
|
||||||
|
): Night[] {
|
||||||
const byDate = new Map<string, Night>();
|
const byDate = new Map<string, Night>();
|
||||||
(sc.sessions || []).forEach((r) => {
|
(sc.sessions || []).forEach((r) => {
|
||||||
const o: Record<string, any> = Object.fromEntries(sc.fields.map((f, i) => [f, r[i]]));
|
const o: Record<string, any> = Object.fromEntries(sc.fields.map((f, i) => [f, r[i]]));
|
||||||
@@ -39,6 +49,23 @@ export function shapeSleep(api: SleepApiResponse, sc: SleepCycleExport): Night[]
|
|||||||
src: 'Eight Sleep',
|
src: 'Eight Sleep',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Fitbit Air wins where sources overlap — it's the current device.
|
||||||
|
fitbit.forEach((f) => {
|
||||||
|
const date = f.ended_at.slice(0, 10);
|
||||||
|
byDate.set(date, {
|
||||||
|
date,
|
||||||
|
total: f.total_seconds,
|
||||||
|
asleep: f.sleep_seconds,
|
||||||
|
deep: f.deep_seconds,
|
||||||
|
rem: f.rem_seconds,
|
||||||
|
light: f.light_seconds,
|
||||||
|
awake: f.awake_seconds,
|
||||||
|
quality: null,
|
||||||
|
hrv: null,
|
||||||
|
hr: null,
|
||||||
|
src: 'Fitbit Air',
|
||||||
|
});
|
||||||
|
});
|
||||||
return [...byDate.values()].sort((a, b) => (a.date < b.date ? -1 : 1));
|
return [...byDate.values()].sort((a, b) => (a.date < b.date ? -1 : 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||