Compare commits
5 Commits
v1.3
...
55999b7458
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55999b7458 | ||
|
|
6a75db0aae | ||
|
|
4908684b07 | ||
|
|
22c761cedc | ||
| 7fc5be82be |
@@ -6,10 +6,11 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# RN/NDK builds starve the 4-core VPS — run on the desktop runner
|
||||
# (marcusDesktop). Jobs queue while the desktop is off and start when it
|
||||
# comes online; switch back to ubuntu-latest to build on the VPS.
|
||||
runs-on: desktop
|
||||
# RN/NDK builds starve the 4-core VPS, so `desktop` (marcusDesktop) is the
|
||||
# preferred runner — but jobs queue while the desktop is off. On
|
||||
# ubuntu-latest the VPS builds immediately; flip back when the desktop is
|
||||
# reliably online.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: eclipse-temurin:21-jdk
|
||||
steps:
|
||||
@@ -60,6 +61,10 @@ jobs:
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
cd android
|
||||
# build.gradle keeps literal versions so EAS (Play builds) can parse them;
|
||||
# stamp the CI run number in for the Gitea/Obtainium APK.
|
||||
sed -i "s/^\( *\)versionCode [0-9]*$/\1versionCode $VERSION_CODE/; s/^\( *\)versionName \".*\"$/\1versionName \"$VERSION_NAME\"/" app/build.gradle
|
||||
grep -n "versionCode\|versionName" app/build.gradle
|
||||
echo "sdk.dir=$ANDROID_HOME" > local.properties
|
||||
./gradlew assembleRelease --no-daemon --console=plain
|
||||
|
||||
|
||||
3
.gitignore
vendored
@@ -34,3 +34,6 @@ npm-debug.*
|
||||
|
||||
# generated native folders (android/ is committed — CI builds it)
|
||||
/ios
|
||||
|
||||
# Claude Code local dev config
|
||||
.claude/
|
||||
|
||||
46
README.md
@@ -15,13 +15,15 @@ A session is four guided blocks driven by a step timer with haptic cues:
|
||||
3. **Quick squeezes** — 10 × 1s burst contractions with 1s release.
|
||||
4. **Quick pushes** — 10 × 1s burst pushes with 1s release.
|
||||
|
||||
Progress (session count, difficulty adjustment) lives in AsyncStorage — no
|
||||
server, no account.
|
||||
Progress (session count, difficulty adjustment, completion history) lives in
|
||||
AsyncStorage — no server, no account. The home screen shows your **day streak**
|
||||
(alive until a full day is missed), best streak, and a two-week activity strip.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/app/` — expo-router screens: `index` (home), `session` (timer), `settings`
|
||||
- `src/lib/workout.ts` — pure session-plan + progression logic
|
||||
- `src/lib/streak.ts` — pure streak / calendar-day logic (tested)
|
||||
- `src/lib/store.ts` — tiny local store persisted to AsyncStorage
|
||||
- `android/` — committed prebuild output; CI builds it directly
|
||||
|
||||
@@ -31,12 +33,42 @@ server, no account.
|
||||
npm install
|
||||
npm start # Expo dev server
|
||||
npm run typecheck
|
||||
npm test # node --test over src/lib/*.test.ts
|
||||
```
|
||||
|
||||
## Release
|
||||
|
||||
Push to `main` → Gitea Actions on the desktop runner (`runs-on: desktop`,
|
||||
never the VPS) builds a signed APK and publishes a Gitea release — install via
|
||||
Obtainium. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets
|
||||
`KEYSTORE_B64`, `KEYSTORE_PASSWORD`; alias `kegel`). iOS runs via
|
||||
`npx expo run:ios` from a Mac.
|
||||
### Android APK (Gitea / Obtainium)
|
||||
|
||||
Push to `main` → Gitea Actions builds a signed APK and publishes a Gitea
|
||||
release — install via Obtainium. `android/app/build.gradle` keeps literal
|
||||
`versionCode`/`versionName`; CI stamps the run number in with `sed` before
|
||||
building. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets
|
||||
`KEYSTORE_B64`, `KEYSTORE_PASSWORD`; alias `kegel`). Beware: `expo prebuild -p android`
|
||||
(used to regenerate launcher icons from `store/icons/make.js`) rewrites
|
||||
`build.gradle` and drops the release `signingConfigs` block — restore it from git.
|
||||
|
||||
### App Store / Google Play (EAS)
|
||||
|
||||
Store builds run on EAS (project `@mrmen23/kegel`, profile `production`,
|
||||
version source remote with auto-increment). The EAS-managed Android keystore
|
||||
differs from the Gitea one, so Play and Obtainium installs can't update over
|
||||
each other.
|
||||
|
||||
```sh
|
||||
# Apple: needs an App Store Connect API key for non-interactive auth
|
||||
export EXPO_ASC_API_KEY_PATH=~/Downloads/AuthKey_L9Z6F5H5B7.p8 EXPO_ASC_KEY_ID=L9Z6F5H5B7 \
|
||||
EXPO_ASC_ISSUER_ID=<issuer uuid> EXPO_APPLE_TEAM_ID=HZ6XCKM95R EXPO_APPLE_TEAM_TYPE=INDIVIDUAL
|
||||
eas build -p ios --profile production --non-interactive
|
||||
eas submit -p ios --profile production --latest --non-interactive # creates the ASC record on first run
|
||||
|
||||
# Android: needs a Play service-account JSON with release access
|
||||
eas build -p android --profile production --non-interactive
|
||||
eas submit -p android --profile production --latest --key <service-account.json> --track internal
|
||||
```
|
||||
|
||||
Listing copy, keyword rationale and screenshot generation live in `store/`
|
||||
(`node store/screenshots/make.js` renders `store/screenshots/raw/*.png` into
|
||||
store-sized slides). Privacy policy is served from
|
||||
https://rehbock.xyz/kegel/privacy.html (source in `store/web/`). iOS dev runs
|
||||
via `npx expo run:ios` from a Mac.
|
||||
|
||||
@@ -92,8 +92,8 @@ android {
|
||||
applicationId 'com.marcus.kegel'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode ((System.getenv("VERSION_CODE") ?: "1").toInteger())
|
||||
versionName (System.getenv("VERSION_NAME") ?: "1.0-dev")
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 18 KiB |
@@ -1,6 +1,6 @@
|
||||
<resources>
|
||||
<color name="splashscreen_background">#0F1115</color>
|
||||
<color name="iconBackground">#0F1115</color>
|
||||
<color name="splashscreen_background">#160B0B</color>
|
||||
<color name="iconBackground">#160B0B</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="activityBackground">#0F1115</color>
|
||||
<color name="activityBackground">#160B0B</color>
|
||||
</resources>
|
||||
21
app.json
@@ -7,13 +7,17 @@
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "kegel",
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#0F1115",
|
||||
"backgroundColor": "#160B0B",
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.marcus.kegel"
|
||||
"bundleIdentifier": "com.marcus.kegel",
|
||||
"supportsTablet": false,
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#0F1115",
|
||||
"backgroundColor": "#160B0B",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
@@ -25,7 +29,7 @@
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#0F1115",
|
||||
"backgroundColor": "#160B0B",
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 120
|
||||
}
|
||||
@@ -34,6 +38,13 @@
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "0edd3f95-b270-4aef-aa1c-f25a9ee57d7f"
|
||||
}
|
||||
},
|
||||
"owner": "mrmen23"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
21
eas.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"simulator": {
|
||||
"distribution": "internal",
|
||||
"ios": { "simulator": true }
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"ios": { "resourceClass": "m-medium" }
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
205
package-lock.json
generated
@@ -23,9 +23,11 @@
|
||||
"react-native": "0.86.2",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-web": "^0.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~6.0.3"
|
||||
}
|
||||
@@ -64,7 +66,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -561,6 +562,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
|
||||
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
@@ -949,6 +951,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
|
||||
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
@@ -964,6 +967,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
|
||||
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
@@ -995,6 +999,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz",
|
||||
"integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
@@ -1039,6 +1044,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
|
||||
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
@@ -1054,6 +1060,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
|
||||
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
@@ -2085,6 +2092,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.2.tgz",
|
||||
"integrity": "sha512-4XKEJ6jKW9lXMB1O5o47gBoGgolde1fbX13gLW/erlcn+1ky+MHHo5UjuM3RWGdPJHIvIzekDDumSUHhB9x5iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-proposal-export-default-from": "^7.24.7",
|
||||
@@ -2247,6 +2255,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.2.tgz",
|
||||
"integrity": "sha512-mX1wgLErdb2hDgXJr9zM9SWLe+ZteZTFTwRWGOQ53yEJwc9DVSnxdTlAtVdMeOj2ntycKh0R8jYdat2Am43cwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@react-native/babel-preset": "0.86.2",
|
||||
@@ -2265,6 +2274,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.2.tgz",
|
||||
"integrity": "sha512-hJno256j+MS0b3JD1aD3ouTGZVacKNVBuXL2atMQQ8BZ060vl1ptnZ83y569aDW+/rgFSOcqn6ydKeSz4uUKQQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-native/js-polyfills": "0.86.2",
|
||||
"@react-native/metro-babel-transformer": "0.86.2",
|
||||
@@ -2315,6 +2325,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -2334,6 +2345,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -2346,6 +2358,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -2359,7 +2372,8 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
@@ -2403,7 +2417,8 @@
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
@@ -2430,9 +2445,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
|
||||
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
@@ -2443,7 +2458,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2453,6 +2467,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
"integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
@@ -2772,6 +2787,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/bplist-creator": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
||||
@@ -2836,7 +2857,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
@@ -3201,6 +3221,56 @@
|
||||
"hyphenate-style-name": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.14",
|
||||
"source-map": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
@@ -3313,7 +3383,63 @@
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
@@ -3342,6 +3468,18 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error-stack-parser": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
|
||||
@@ -3410,7 +3548,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-57.0.13.tgz",
|
||||
"integrity": "sha512-oJnPSAcQYDIXTJowL9fm6SvAU5CU0P+u1WtWM9a/7uUmeCrkcVKoaGstwF+UY3GoM11S7FO4KKmRl1aU12CTKA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "^57.0.15",
|
||||
@@ -3473,7 +3610,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.11.tgz",
|
||||
"integrity": "sha512-gpSZ13Nfx0BOc9ALjG1SMhks5csb8Z6ncEVdhDgv0NRrmJcHPRvyUlWaKjRjzwW730XrQ6PIXFRcnNmAxOLj5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@expo/env": "~2.4.2"
|
||||
},
|
||||
@@ -3487,7 +3623,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz",
|
||||
"integrity": "sha512-QyS9L1Kh9sKJg4gfU6rdbpxpmH+DyzBX8z6jVvXMUDoqLr1GqmkO/Wu379KCXjL///kWbhpNlbi7AgBuj4VdIQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fontfaceobserver": "^2.1.0"
|
||||
},
|
||||
@@ -3532,7 +3667,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.6.tgz",
|
||||
"integrity": "sha512-NdKjEGk+ywwGRNvgQCq9KettM1eVHfR1+uLov+0ohb6rmmMEgnstfLcc3f0DKMQ0PnROsY5G5+ZkNeiairYRXA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"expo-constants": "~57.0.11",
|
||||
"invariant": "^2.2.4"
|
||||
@@ -3750,7 +3884,6 @@
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz",
|
||||
"integrity": "sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.16.0"
|
||||
}
|
||||
@@ -4148,7 +4281,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
@@ -5197,6 +5329,7 @@
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -5216,6 +5349,12 @@
|
||||
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
@@ -5740,6 +5879,18 @@
|
||||
"node": "^16.14.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/nullthrows": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
||||
@@ -6183,7 +6334,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -6203,7 +6353,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -6240,7 +6389,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.2.tgz",
|
||||
"integrity": "sha512-zbJXGZpwfZGA79Z9ob6Atvfx4nAQL8yJBa35s58E4Oo+khPykfQP2sTeumkKbjwajFYfVayg8pj7Il9nIfTk7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-native/assets-registry": "0.86.2",
|
||||
"@react-native/codegen": "0.86.2",
|
||||
@@ -6331,6 +6479,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
||||
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
@@ -6357,7 +6506,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
|
||||
"integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
@@ -6377,12 +6525,26 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-svg": {
|
||||
"version": "15.15.4",
|
||||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz",
|
||||
"integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"css-tree": "^1.1.3",
|
||||
"warn-once": "0.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-web": {
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.6",
|
||||
"@react-native/normalize-colors": "^0.74.1",
|
||||
@@ -6451,7 +6613,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7191,7 +7352,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7259,7 +7419,6 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"react-native": "0.86.2",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-web": "^0.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
@@ -28,7 +30,8 @@
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test src/lib/*.test.ts"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useEffect } from "react";
|
||||
import { Pressable } from "react-native";
|
||||
import Svg, { Circle, Path } from "react-native-svg";
|
||||
import { hydrate } from "../lib/store";
|
||||
import { C } from "../lib/theme";
|
||||
|
||||
function GearIcon() {
|
||||
return (
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none" stroke={C.muted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Circle cx={12} cy={12} r={3} />
|
||||
<Path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
useEffect(() => {
|
||||
void hydrate();
|
||||
@@ -25,8 +36,10 @@ export default function RootLayout() {
|
||||
options={{
|
||||
title: "Kegel",
|
||||
headerRight: () => (
|
||||
<Link href="/settings" style={{ paddingHorizontal: 8, fontSize: 18 }}>
|
||||
⚙️
|
||||
<Link href="/settings" asChild>
|
||||
<Pressable style={({ pressed }) => ({ paddingHorizontal: 8, paddingVertical: 4, opacity: pressed ? 0.6 : 1 })} hitSlop={8}>
|
||||
<GearIcon />
|
||||
</Pressable>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { Button, StatRow } from "../components/ui";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { ShapeIcon } from "../components/session-viz";
|
||||
import { StreakCard } from "../components/streak-card";
|
||||
import { Button, Card, StatRow } from "../components/ui";
|
||||
import { currentHold } from "../lib/store";
|
||||
import { C } from "../lib/theme";
|
||||
import { alpha, C } from "../lib/theme";
|
||||
import { useStore } from "../lib/useStore";
|
||||
import { MAX_HOLD, SESSIONS_PER_INCREMENT, sessionMinutes } from "../lib/workout";
|
||||
import { FLICK_REPS, HOLD_REPS, MAX_HOLD, PUSH_REPS, SESSIONS_PER_INCREMENT, sessionMinutes } from "../lib/workout";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { sessionsCompleted, lastSessionAt, hydrated } = useStore();
|
||||
const { sessionsCompleted, lastSessionAt, sessions, hydrated } = useStore();
|
||||
if (!hydrated) return <View style={s.screen} />;
|
||||
|
||||
const hold = currentHold();
|
||||
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
|
||||
|
||||
return (
|
||||
<View style={s.screen}>
|
||||
<ScrollView style={s.screen} contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={s.hero}>
|
||||
<View style={s.glowOuter} />
|
||||
<View style={s.glowInner} />
|
||||
<Text style={s.holdValue}>{hold}s</Text>
|
||||
<Text style={s.holdLabel}>current hold</Text>
|
||||
</View>
|
||||
|
||||
<View style={s.card}>
|
||||
<StreakCard sessions={sessions} />
|
||||
|
||||
<Card>
|
||||
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
||||
<StatRow
|
||||
label="Next hold increase"
|
||||
@@ -29,7 +35,22 @@ export default function Home() {
|
||||
/>
|
||||
<StatRow label="Session length" value={`~${sessionMinutes(hold)} min`} />
|
||||
<StatRow label="Last session" value={lastSessionAt ? new Date(lastSessionAt).toLocaleDateString() : "—"} />
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<View style={s.legendRow}>
|
||||
<ShapeIcon shape="circle" size={12} color={C.accent} />
|
||||
<Text style={s.legendText}>Squeeze & hold — {HOLD_REPS} reps</Text>
|
||||
</View>
|
||||
<View style={s.legendRow}>
|
||||
<ShapeIcon shape="square" size={12} color={C.push} />
|
||||
<Text style={s.legendText}>Push out — {PUSH_REPS} reps</Text>
|
||||
</View>
|
||||
<View style={[s.legendRow, s.legendLast]}>
|
||||
<ShapeIcon shape="x" size={12} color={C.accent} />
|
||||
<Text style={s.legendText}>Quick squeezes & pushes — {FLICK_REPS} each</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button title="Start session" onPress={() => router.push("/session")} />
|
||||
|
||||
@@ -37,23 +58,27 @@ export default function Home() {
|
||||
Squeeze = lift and contract, as if stopping urine mid-flow. Push = gently bear down, as when starting to
|
||||
urinate. Keep breathing throughout — never strain.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
||||
hero: { alignItems: "center", paddingVertical: 28 },
|
||||
holdValue: { color: C.accent, fontSize: 72, fontWeight: "700", fontVariant: ["tabular-nums"] },
|
||||
holdLabel: { color: C.muted, fontSize: 14, textTransform: "uppercase", letterSpacing: 1 },
|
||||
card: {
|
||||
backgroundColor: C.surface,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 20,
|
||||
screen: { flex: 1, backgroundColor: C.bg },
|
||||
content: { padding: 20, paddingBottom: 32 },
|
||||
hero: { alignItems: "center", justifyContent: "center", paddingVertical: 32 },
|
||||
glowOuter: { position: "absolute", width: 230, height: 230, borderRadius: 115, backgroundColor: alpha(C.accent, 0.05) },
|
||||
glowInner: { position: "absolute", width: 150, height: 150, borderRadius: 75, backgroundColor: alpha(C.accent, 0.08) },
|
||||
holdValue: { color: C.accent, fontSize: 72, fontWeight: "800", fontVariant: ["tabular-nums"], letterSpacing: -1 },
|
||||
holdLabel: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 1.6, marginTop: 2 },
|
||||
legendRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 11,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: C.border,
|
||||
},
|
||||
legendLast: { borderBottomWidth: 0 },
|
||||
legendText: { color: C.text, fontSize: 14 },
|
||||
hint: { color: C.muted, fontSize: 13, lineHeight: 19, marginTop: 8 },
|
||||
});
|
||||
|
||||
@@ -2,10 +2,12 @@ import * as Haptics from "expo-haptics";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { Animated, StyleSheet, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Button } from "../components/ui";
|
||||
import { BLOCK_SHAPE, DoneCheck, GuideDisc, ProgressRing, RoundDots, ShapeIcon, useStepProgress } from "../components/session-viz";
|
||||
import { Button, Card, StatRow } from "../components/ui";
|
||||
import { completeSession, currentHold, getState } from "../lib/store";
|
||||
import { computeStreaks, dayKey, sessionDays } from "../lib/streak";
|
||||
import { C } from "../lib/theme";
|
||||
import { buildSession, SESSIONS_PER_INCREMENT, type StepKind } from "../lib/workout";
|
||||
|
||||
@@ -15,7 +17,7 @@ const KIND_COLOR: Record<StepKind, string> = {
|
||||
push: C.push,
|
||||
"flick-squeeze": C.accent,
|
||||
"flick-push": C.push,
|
||||
relax: C.muted,
|
||||
relax: C.rest,
|
||||
rest: C.rest,
|
||||
};
|
||||
|
||||
@@ -24,6 +26,8 @@ function stepHaptic(kind: StepKind) {
|
||||
void Haptics.impactAsync(work ? Haptics.ImpactFeedbackStyle.Heavy : Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
|
||||
const RING_SIZE = 300;
|
||||
|
||||
export default function Session() {
|
||||
useKeepAwake();
|
||||
const router = useRouter();
|
||||
@@ -39,6 +43,16 @@ export default function Session() {
|
||||
const [done, setDone] = useState(false);
|
||||
const recorded = useRef(false);
|
||||
|
||||
const progress = useStepProgress(i, remaining, paused, done);
|
||||
|
||||
// Small pop on the countdown when a new step starts, so the cue change
|
||||
// registers even in peripheral vision.
|
||||
const pop = useRef(new Animated.Value(1)).current;
|
||||
useEffect(() => {
|
||||
pop.setValue(1.08);
|
||||
Animated.spring(pop, { toValue: 1, friction: 5, tension: 120, useNativeDriver: true }).start();
|
||||
}, [i, pop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || done) return;
|
||||
const id = setInterval(() => setRemaining((r) => r - 1), 1000);
|
||||
@@ -64,17 +78,24 @@ export default function Session() {
|
||||
}, [remaining, done, i, steps]);
|
||||
|
||||
if (done) {
|
||||
const { sessionsCompleted } = getState();
|
||||
const { sessionsCompleted, sessions } = getState();
|
||||
const { current: streak } = computeStreaks(sessionDays(sessions), dayKey(new Date()));
|
||||
const leveledUp = sessionsCompleted % SESSIONS_PER_INCREMENT === 0;
|
||||
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
|
||||
return (
|
||||
<SafeAreaView style={s.screen}>
|
||||
<View style={s.center}>
|
||||
<Text style={s.doneTitle}>Session complete 💪</Text>
|
||||
<DoneCheck size={120} />
|
||||
<Text style={s.doneTitle}>Session complete</Text>
|
||||
<Text style={s.doneSub}>
|
||||
{sessionsCompleted} session{sessionsCompleted === 1 ? "" : "s"} completed.
|
||||
{leveledUp ? ` Holds increase to ${currentHold()}s next time.` : ""}
|
||||
{leveledUp ? `Holds increase to ${currentHold()}s next time.` : `${untilNext} more session${untilNext === 1 ? "" : "s"} until holds increase.`}
|
||||
</Text>
|
||||
</View>
|
||||
<Card>
|
||||
<StatRow label="Streak" value={`${streak} day${streak === 1 ? "" : "s"}`} />
|
||||
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
||||
<StatRow label="This session" value={`${Math.round(totalSeconds / 60)} min`} />
|
||||
</Card>
|
||||
<Button title="Done" onPress={() => router.back()} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -83,17 +104,23 @@ export default function Session() {
|
||||
const step = steps[i];
|
||||
const elapsed = steps.slice(0, i).reduce((s, st) => s + st.seconds, 0) + (step.seconds - remaining);
|
||||
const color = KIND_COLOR[step.kind];
|
||||
const shape = BLOCK_SHAPE[step.blockId];
|
||||
const next = steps[i + 1];
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.screen}>
|
||||
<Text style={s.block}>{step.block}</Text>
|
||||
<RoundDots step={step} />
|
||||
|
||||
<View style={s.center}>
|
||||
<View style={[s.circle, { borderColor: color }]}>
|
||||
<ProgressRing size={RING_SIZE} strokeWidth={10} progress={progress} color={color}>
|
||||
<GuideDisc size={RING_SIZE - 44} kind={step.kind} stepIndex={i} color={color} />
|
||||
<View style={s.inner}>
|
||||
{shape ? <ShapeIcon shape={shape} size={22} color={color} /> : null}
|
||||
<Text style={[s.label, { color }]}>{step.label}</Text>
|
||||
<Text style={s.count}>{remaining}</Text>
|
||||
<Animated.Text style={[s.count, { transform: [{ scale: pop }] }]}>{remaining}</Animated.Text>
|
||||
</View>
|
||||
</ProgressRing>
|
||||
<Text style={s.next}>{next ? `Next: ${next.label} ${next.seconds}s` : "Last one!"}</Text>
|
||||
</View>
|
||||
|
||||
@@ -101,30 +128,22 @@ export default function Session() {
|
||||
<View style={[s.progressFill, { width: `${(elapsed / totalSeconds) * 100}%` }]} />
|
||||
</View>
|
||||
|
||||
<Button title={paused ? "Resume" : "Pause"} onPress={() => setPaused((p) => !p)} />
|
||||
<Button title="End session" danger onPress={() => router.back()} />
|
||||
<Button title={paused ? "Resume" : "Pause"} variant="secondary" onPress={() => setPaused((p) => !p)} />
|
||||
<Button title="End session" variant="danger" onPress={() => router.back()} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
||||
block: { color: C.muted, fontSize: 15, textAlign: "center", textTransform: "uppercase", letterSpacing: 1, marginTop: 8 },
|
||||
block: { color: C.muted, fontSize: 14, textAlign: "center", textTransform: "uppercase", letterSpacing: 1.2, marginTop: 8, marginBottom: 12 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
circle: {
|
||||
width: 260,
|
||||
height: 260,
|
||||
borderRadius: 130,
|
||||
borderWidth: 6,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: C.surface,
|
||||
},
|
||||
label: { fontSize: 22, fontWeight: "600", marginBottom: 4 },
|
||||
inner: { alignItems: "center", justifyContent: "center", gap: 6 },
|
||||
label: { fontSize: 22, fontWeight: "600" },
|
||||
count: { color: C.text, fontSize: 80, fontWeight: "700", fontVariant: ["tabular-nums"] },
|
||||
next: { color: C.muted, fontSize: 15, marginTop: 24 },
|
||||
doneTitle: { color: C.text, fontSize: 28, fontWeight: "700", marginBottom: 12 },
|
||||
doneTitle: { color: C.text, fontSize: 28, fontWeight: "700", marginTop: 28, marginBottom: 10 },
|
||||
doneSub: { color: C.muted, fontSize: 16, textAlign: "center", lineHeight: 23 },
|
||||
progressTrack: { height: 6, borderRadius: 3, backgroundColor: C.surface2, marginBottom: 16 },
|
||||
progressTrack: { height: 6, borderRadius: 3, backgroundColor: C.surface2, marginBottom: 16, overflow: "hidden" },
|
||||
progressFill: { height: 6, borderRadius: 3, backgroundColor: C.accent },
|
||||
});
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||
import { Button, StatRow } from "../components/ui";
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Button, Card, StatRow } from "../components/ui";
|
||||
import { adjustHold, currentHold, resetProgress } from "../lib/store";
|
||||
import { C } from "../lib/theme";
|
||||
import { useStore } from "../lib/useStore";
|
||||
import { MAX_HOLD, MIN_HOLD } from "../lib/workout";
|
||||
|
||||
function StepperButton({ label, disabled, onPress }: { label: string; disabled: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [s.step, disabled && s.stepDisabled, pressed && !disabled && s.stepPressed]}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text style={[s.stepText, disabled && s.stepTextDisabled]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { sessionsCompleted, holdAdjust } = useStore();
|
||||
const hold = currentHold();
|
||||
@@ -12,27 +24,26 @@ export default function Settings() {
|
||||
return (
|
||||
<View style={s.screen}>
|
||||
<Text style={s.section}>Difficulty</Text>
|
||||
<View style={s.card}>
|
||||
<StatRow label="Current hold" value={`${hold}s`} />
|
||||
<Card style={{ marginBottom: 12 }}>
|
||||
<View style={s.stepperRow}>
|
||||
<StepperButton label="−" disabled={hold <= MIN_HOLD} onPress={() => adjustHold(-1)} />
|
||||
<View style={s.stepperValue}>
|
||||
<Text style={s.holdValue}>{hold}s</Text>
|
||||
<Text style={s.holdLabel}>hold length</Text>
|
||||
</View>
|
||||
<StepperButton label="+" disabled={hold >= MAX_HOLD} onPress={() => adjustHold(1)} />
|
||||
</View>
|
||||
<StatRow label="Manual adjustment" value={`${holdAdjust >= 0 ? "+" : ""}${holdAdjust}s`} />
|
||||
</View>
|
||||
<View style={s.row}>
|
||||
<View style={s.half}>
|
||||
<Button title="Easier −1s" onPress={() => hold > MIN_HOLD && adjustHold(-1)} />
|
||||
</View>
|
||||
<View style={s.half}>
|
||||
<Button title="Harder +1s" onPress={() => hold < MAX_HOLD && adjustHold(1)} />
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
<Text style={s.hint}>
|
||||
Holds grow automatically by 1s every 3 completed sessions ({MIN_HOLD}–{MAX_HOLD}s). Use these if a session
|
||||
Holds grow automatically by 1s every 3 completed sessions ({MIN_HOLD}–{MAX_HOLD}s). Adjust here if a session
|
||||
feels too easy or too hard — the adjustment stacks with the automatic progression.
|
||||
</Text>
|
||||
|
||||
<Text style={s.section}>Progress</Text>
|
||||
<Button
|
||||
title="Reset progress"
|
||||
danger
|
||||
variant="danger"
|
||||
onPress={() =>
|
||||
Alert.alert("Reset progress?", `This clears ${sessionsCompleted} completed sessions and starts over at ${MIN_HOLD}s holds.`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
@@ -46,17 +57,24 @@ export default function Settings() {
|
||||
|
||||
const s = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
||||
section: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 8, marginTop: 12 },
|
||||
card: {
|
||||
backgroundColor: C.surface,
|
||||
borderRadius: 12,
|
||||
section: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 1.2, marginBottom: 8, marginTop: 12 },
|
||||
stepperRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 14 },
|
||||
stepperValue: { alignItems: "center" },
|
||||
holdValue: { color: C.accent, fontSize: 34, fontWeight: "700", fontVariant: ["tabular-nums"] },
|
||||
holdLabel: { color: C.muted, fontSize: 12, textTransform: "uppercase", letterSpacing: 1.2, marginTop: 2 },
|
||||
step: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
backgroundColor: C.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
row: { flexDirection: "row", gap: 12 },
|
||||
half: { flex: 1 },
|
||||
stepPressed: { opacity: 0.7, transform: [{ scale: 0.95 }] },
|
||||
stepDisabled: { opacity: 0.35 },
|
||||
stepText: { color: C.text, fontSize: 26, fontWeight: "600", lineHeight: 30 },
|
||||
stepTextDisabled: { color: C.muted },
|
||||
hint: { color: C.muted, fontSize: 13, lineHeight: 19, marginBottom: 12 },
|
||||
});
|
||||
|
||||
220
src/components/session-viz.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { Animated, Easing, StyleSheet, View } from "react-native";
|
||||
import Svg, { Circle, Path } from "react-native-svg";
|
||||
import { alpha, C } from "../lib/theme";
|
||||
import type { BlockId, Step, StepKind } from "../lib/workout";
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
/** Shape language: ○ = squeeze (in), □ = push (out), ✕ = quick flicks. */
|
||||
export type Shape = "circle" | "square" | "x";
|
||||
|
||||
export const BLOCK_SHAPE: Record<BlockId, Shape | null> = {
|
||||
warmup: null,
|
||||
holds: "circle",
|
||||
pushes: "square",
|
||||
"flick-squeezes": "x",
|
||||
"flick-pushes": "x",
|
||||
};
|
||||
|
||||
const BLOCK_COLOR: Record<BlockId, string> = {
|
||||
warmup: C.muted,
|
||||
holds: C.accent,
|
||||
pushes: C.push,
|
||||
"flick-squeezes": C.accent,
|
||||
"flick-pushes": C.push,
|
||||
};
|
||||
|
||||
export function ShapeIcon({ shape, size, color, outline }: { shape: Shape; size: number; color: string; outline?: boolean }) {
|
||||
if (shape === "x") {
|
||||
const bar = { position: "absolute" as const, width: size * 1.25, height: Math.max(2, size * 0.26), borderRadius: size, backgroundColor: color };
|
||||
return (
|
||||
<View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
|
||||
<View style={[bar, { transform: [{ rotate: "45deg" }] }]} />
|
||||
<View style={[bar, { transform: [{ rotate: "-45deg" }] }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: shape === "circle" ? size / 2 : Math.max(2, size * 0.18),
|
||||
backgroundColor: outline ? "transparent" : color,
|
||||
borderWidth: outline ? 1.5 : 0,
|
||||
borderColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring that sweeps around the timer edge in proportion to the current step —
|
||||
* same idiom as the meditation app. `progress` runs 0→1 over the step.
|
||||
*/
|
||||
export function ProgressRing({
|
||||
size,
|
||||
strokeWidth,
|
||||
progress,
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
size: number;
|
||||
strokeWidth: number;
|
||||
progress: Animated.Value;
|
||||
color: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const r = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
return (
|
||||
<View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
|
||||
<Svg width={size} height={size} style={StyleSheet.absoluteFill}>
|
||||
<Circle cx={size / 2} cy={size / 2} r={r} stroke={C.surface2} strokeWidth={strokeWidth} fill={C.surface} />
|
||||
<AnimatedCircle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
strokeDasharray={`${circumference}`}
|
||||
strokeDashoffset={progress.interpolate({ inputRange: [0, 1], outputRange: [circumference, 0] })}
|
||||
rotation={-90}
|
||||
origin={`${size / 2}, ${size / 2}`}
|
||||
/>
|
||||
</Svg>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Breathing-guide disc inside the ring: inflates on work (squeeze/push/flick),
|
||||
* deflates on rest. Each 1s flick step re-triggers the inflate, so flick blocks
|
||||
* pulse at the flick rhythm for free.
|
||||
*/
|
||||
export function GuideDisc({ size, kind, stepIndex, color }: { size: number; kind: StepKind; stepIndex: number; color: string }) {
|
||||
const work = kind === "squeeze" || kind === "push" || kind === "flick-squeeze" || kind === "flick-push";
|
||||
const scale = useRef(new Animated.Value(0.6)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const anim = Animated.timing(scale, {
|
||||
toValue: work ? 1 : 0.6,
|
||||
duration: work ? 650 : 450,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
anim.start();
|
||||
return () => anim.stop();
|
||||
}, [stepIndex, work, scale]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: alpha(color, work ? 0.14 : 0.07),
|
||||
borderWidth: 1,
|
||||
borderColor: alpha(color, work ? 0.4 : 0.18),
|
||||
transform: [{ scale }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Session-complete mark: accent ring with a check, springing in once. */
|
||||
export function DoneCheck({ size }: { size: number }) {
|
||||
const pop = useRef(new Animated.Value(0)).current;
|
||||
useEffect(() => {
|
||||
Animated.spring(pop, { toValue: 1, friction: 5, tension: 60, useNativeDriver: true }).start();
|
||||
}, [pop]);
|
||||
|
||||
return (
|
||||
<Animated.View style={{ transform: [{ scale: pop }], opacity: pop }}>
|
||||
<Svg width={size} height={size} viewBox="0 0 100 100">
|
||||
<Circle cx={50} cy={50} r={46} stroke={C.accent} strokeWidth={5} fill={alpha(C.accent, 0.1)} />
|
||||
<Path d="M31 52 L45 66 L70 37" stroke={C.text} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Drives a ProgressRing: restarts on each step, freezes on pause. */
|
||||
export function useStepProgress(stepIndex: number, remaining: number, paused: boolean, done: boolean) {
|
||||
const progress = useRef(new Animated.Value(0)).current;
|
||||
const lastStep = useRef(-1);
|
||||
const remainingRef = useRef(remaining);
|
||||
remainingRef.current = remaining;
|
||||
|
||||
useEffect(() => {
|
||||
if (done) return;
|
||||
if (lastStep.current !== stepIndex) {
|
||||
lastStep.current = stepIndex;
|
||||
progress.setValue(0);
|
||||
}
|
||||
if (paused) return;
|
||||
const anim = Animated.timing(progress, {
|
||||
toValue: 1,
|
||||
duration: remainingRef.current * 1000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: false,
|
||||
});
|
||||
anim.start();
|
||||
return () => anim.stop();
|
||||
}, [stepIndex, paused, done, progress]);
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the round is out of the block: one marker per rep in the block's own
|
||||
* shape, with small interleaved dots for the rests between hold/push reps so
|
||||
* rest rounds read differently from work rounds. Flick blocks skip the 1s
|
||||
* relax gaps — 10 markers is already the ceiling of glanceable.
|
||||
*/
|
||||
export function RoundDots({ step }: { step: Step }) {
|
||||
const shape = BLOCK_SHAPE[step.blockId];
|
||||
if (!shape) return <View style={d.row} />;
|
||||
|
||||
const color = BLOCK_COLOR[step.blockId];
|
||||
const showRests = step.blockId === "holds" || step.blockId === "pushes";
|
||||
const working = step.kind !== "rest" && step.kind !== "relax";
|
||||
const markers: ReactNode[] = [];
|
||||
|
||||
for (let r = 1; r <= step.repsTotal; r++) {
|
||||
const isCurrent = r === step.rep && working;
|
||||
const isDone = r < step.rep || (r === step.rep && !working);
|
||||
markers.push(
|
||||
<View key={`w${r}`} style={isCurrent ? d.current : undefined}>
|
||||
<ShapeIcon shape={shape} size={isCurrent ? 15 : 11} color={isDone || isCurrent ? color : C.border} outline={!isDone && !isCurrent} />
|
||||
</View>,
|
||||
);
|
||||
if (showRests && r < step.repsTotal) {
|
||||
const restCurrent = r === step.rep && !working;
|
||||
markers.push(
|
||||
<View
|
||||
key={`r${r}`}
|
||||
style={{
|
||||
width: restCurrent ? 7 : 4,
|
||||
height: restCurrent ? 7 : 4,
|
||||
borderRadius: 4,
|
||||
backgroundColor: r < step.rep ? C.muted : restCurrent ? C.rest : C.border,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <View style={d.row}>{markers}</View>;
|
||||
}
|
||||
|
||||
const d = StyleSheet.create({
|
||||
row: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 7, minHeight: 20 },
|
||||
current: { transform: [{ scale: 1.15 }] },
|
||||
});
|
||||
74
src/components/streak-card.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { alpha, C } from "../lib/theme";
|
||||
import { computeStreaks, dayKey, fromKey, lastNDays, sessionDays } from "../lib/streak";
|
||||
import { Card } from "./ui";
|
||||
|
||||
const DAYS_SHOWN = 14;
|
||||
const WEEKDAY = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
/**
|
||||
* Consistency at a glance: current streak as the headline, best streak
|
||||
* beside it, and a two-week strip of day dots — filled on trained days,
|
||||
* ringed on today so an untrained today reads as "still open", not missed.
|
||||
*/
|
||||
export function StreakCard({ sessions }: { sessions: string[] }) {
|
||||
const today = dayKey(new Date());
|
||||
const days = sessionDays(sessions);
|
||||
const trained = new Set(days);
|
||||
const { current, best } = computeStreaks(days, today);
|
||||
const strip = lastNDays(DAYS_SHOWN, today);
|
||||
const trainedToday = trained.has(today);
|
||||
|
||||
const headline =
|
||||
current === 0 ? (days.length === 0 ? "Start your first streak" : "Streak reset — start again today") : trainedToday ? "Keep it going tomorrow" : "Train today to keep it";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<View style={s.top}>
|
||||
<View style={s.stat}>
|
||||
<Text style={[s.value, current > 0 && s.valueLive]}>{current}</Text>
|
||||
<Text style={s.label}>day streak</Text>
|
||||
</View>
|
||||
<View style={s.divider} />
|
||||
<View style={s.stat}>
|
||||
<Text style={s.valueSmall}>{best}</Text>
|
||||
<Text style={s.label}>best</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={s.strip}>
|
||||
{strip.map((key) => {
|
||||
const isToday = key === today;
|
||||
const done = trained.has(key);
|
||||
const weekday = WEEKDAY[fromKey(key).getDay()];
|
||||
return (
|
||||
<View key={key} style={s.day}>
|
||||
<View style={[s.dot, done && s.dotDone, isToday && !done && s.dotToday]} />
|
||||
<Text style={[s.weekday, isToday && s.weekdayToday]}>{weekday}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Text style={s.headline}>{headline}</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
top: { flexDirection: "row", alignItems: "center", paddingTop: 14, paddingBottom: 12 },
|
||||
stat: { flex: 1, alignItems: "center" },
|
||||
divider: { width: 1, height: 36, backgroundColor: C.border },
|
||||
value: { color: C.muted, fontSize: 40, fontWeight: "800", fontVariant: ["tabular-nums"], letterSpacing: -1, lineHeight: 44 },
|
||||
valueLive: { color: C.accent },
|
||||
valueSmall: { color: C.text, fontSize: 28, fontWeight: "700", fontVariant: ["tabular-nums"], lineHeight: 44 },
|
||||
label: { color: C.muted, fontSize: 11, textTransform: "uppercase", letterSpacing: 1.4, marginTop: 2 },
|
||||
strip: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 10, borderTopWidth: 1, borderTopColor: C.border },
|
||||
day: { alignItems: "center", gap: 5 },
|
||||
dot: { width: 12, height: 12, borderRadius: 6, backgroundColor: C.surface2, borderWidth: 1, borderColor: C.border },
|
||||
dotDone: { backgroundColor: C.accent, borderColor: C.accent },
|
||||
dotToday: { borderColor: alpha(C.accent, 0.8), borderWidth: 1.5 },
|
||||
weekday: { color: alpha(C.muted, 0.55), fontSize: 10, fontWeight: "600" },
|
||||
weekdayToday: { color: C.text },
|
||||
headline: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 10, borderTopWidth: 1, borderTopColor: C.border },
|
||||
});
|
||||
@@ -1,21 +1,36 @@
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { C } from "../lib/theme";
|
||||
import { Pressable, StyleSheet, Text, View, ViewStyle } from "react-native";
|
||||
import { alpha, C } from "../lib/theme";
|
||||
|
||||
type Variant = "primary" | "secondary" | "danger";
|
||||
|
||||
const VARIANT_STYLE: Record<Variant, { box: ViewStyle; text: { color: string } }> = {
|
||||
primary: { box: { backgroundColor: C.accent }, text: { color: C.text } },
|
||||
secondary: {
|
||||
box: { backgroundColor: C.surface, borderWidth: 1, borderColor: C.border },
|
||||
text: { color: C.text },
|
||||
},
|
||||
danger: {
|
||||
box: { backgroundColor: "transparent", borderWidth: 1, borderColor: alpha(C.danger, 0.55) },
|
||||
text: { color: C.danger },
|
||||
},
|
||||
};
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
danger,
|
||||
variant = "primary",
|
||||
}: {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
danger?: boolean;
|
||||
variant?: Variant;
|
||||
}) {
|
||||
const v = VARIANT_STYLE[variant];
|
||||
return (
|
||||
<Pressable
|
||||
style={[u.button, danger && { backgroundColor: "transparent", borderColor: C.danger, borderWidth: 1 }]}
|
||||
style={({ pressed }) => [u.button, v.box, pressed && u.pressed]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={[u.buttonText, danger && { color: C.danger }]}>{title}</Text>
|
||||
<Text style={[u.buttonText, v.text]}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -29,23 +44,36 @@ export function StatRow({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
|
||||
return <View style={[u.card, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
const u = StyleSheet.create({
|
||||
button: {
|
||||
backgroundColor: C.accent,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
padding: 15,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
buttonText: { color: C.text, fontSize: 16, fontWeight: "600" },
|
||||
pressed: { opacity: 0.8, transform: [{ scale: 0.98 }] },
|
||||
buttonText: { fontSize: 16, fontWeight: "600", letterSpacing: 0.2 },
|
||||
statRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 10,
|
||||
paddingVertical: 11,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: C.border,
|
||||
},
|
||||
statLabel: { color: C.muted, fontSize: 15 },
|
||||
statValue: { color: C.text, fontSize: 15, fontWeight: "600" },
|
||||
statValue: { color: C.text, fontSize: 15, fontWeight: "600", fontVariant: ["tabular-nums"] },
|
||||
card: {
|
||||
backgroundColor: C.surface,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 20,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,12 +8,14 @@ export interface State {
|
||||
lastSessionAt: string | null; // ISO date
|
||||
/** Manual difficulty tweak from settings, in seconds (can be negative). */
|
||||
holdAdjust: number;
|
||||
/** ISO timestamps of every completed session — drives streaks/history. */
|
||||
sessions: string[];
|
||||
hydrated: boolean;
|
||||
}
|
||||
|
||||
const KEY = "kegel_v1";
|
||||
|
||||
let state: State = { sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0, hydrated: false };
|
||||
let state: State = { sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0, sessions: [], hydrated: false };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getState(): State {
|
||||
@@ -34,6 +36,7 @@ function setState(patch: Partial<State>) {
|
||||
sessionsCompleted: state.sessionsCompleted,
|
||||
lastSessionAt: state.lastSessionAt,
|
||||
holdAdjust: state.holdAdjust,
|
||||
sessions: state.sessions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -42,7 +45,8 @@ export async function hydrate() {
|
||||
if (state.hydrated) return;
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
const saved = raw ? JSON.parse(raw) : {};
|
||||
state = { ...state, ...saved, hydrated: true };
|
||||
// `sessions` arrived after v1 shipped — older installs have none saved.
|
||||
state = { ...state, ...saved, sessions: Array.isArray(saved.sessions) ? saved.sessions : [], hydrated: true };
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
@@ -51,7 +55,8 @@ export function currentHold(): number {
|
||||
}
|
||||
|
||||
export function completeSession() {
|
||||
setState({ sessionsCompleted: state.sessionsCompleted + 1, lastSessionAt: new Date().toISOString() });
|
||||
const now = new Date().toISOString();
|
||||
setState({ sessionsCompleted: state.sessionsCompleted + 1, lastSessionAt: now, sessions: [...state.sessions, now] });
|
||||
}
|
||||
|
||||
export function adjustHold(delta: number) {
|
||||
@@ -59,5 +64,5 @@ export function adjustHold(delta: number) {
|
||||
}
|
||||
|
||||
export function resetProgress() {
|
||||
setState({ sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0 });
|
||||
setState({ sessionsCompleted: 0, lastSessionAt: null, holdAdjust: 0, sessions: [] });
|
||||
}
|
||||
|
||||
58
src/lib/streak.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { computeStreaks, dayKey, lastNDays, sessionDays } from "./streak.ts";
|
||||
|
||||
describe("dayKey", () => {
|
||||
it("formats a local calendar date as YYYY-MM-DD", () => {
|
||||
assert.equal(dayKey(new Date(2026, 8, 2, 23, 59)), "2026-09-02");
|
||||
assert.equal(dayKey(new Date(2026, 0, 5, 0, 0)), "2026-01-05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionDays", () => {
|
||||
it("dedupes multiple sessions on the same day and sorts ascending", () => {
|
||||
const a = new Date(2026, 8, 2, 8).toISOString();
|
||||
const b = new Date(2026, 8, 2, 20).toISOString();
|
||||
const c = new Date(2026, 8, 1, 9).toISOString();
|
||||
assert.deepEqual(sessionDays([a, b, c]), ["2026-09-01", "2026-09-02"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastNDays", () => {
|
||||
it("returns n keys ending on today, oldest first", () => {
|
||||
assert.deepEqual(lastNDays(3, "2026-03-01"), ["2026-02-27", "2026-02-28", "2026-03-01"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeStreaks", () => {
|
||||
const today = "2026-09-02";
|
||||
|
||||
it("is zero with no history", () => {
|
||||
assert.deepEqual(computeStreaks([], today), { current: 0, best: 0 });
|
||||
});
|
||||
|
||||
it("counts consecutive days ending today", () => {
|
||||
assert.deepEqual(computeStreaks(["2026-08-31", "2026-09-01", "2026-09-02"], today), { current: 3, best: 3 });
|
||||
});
|
||||
|
||||
it("keeps the streak alive if the last session was yesterday", () => {
|
||||
assert.deepEqual(computeStreaks(["2026-08-31", "2026-09-01"], today), { current: 2, best: 2 });
|
||||
});
|
||||
|
||||
it("drops the current streak after a missed day but remembers the best", () => {
|
||||
assert.deepEqual(computeStreaks(["2026-08-28", "2026-08-29", "2026-08-30"], today), { current: 0, best: 3 });
|
||||
});
|
||||
|
||||
it("tracks best across gaps", () => {
|
||||
const days = ["2026-08-01", "2026-08-02", "2026-08-03", "2026-08-04", "2026-08-20", "2026-09-02"];
|
||||
assert.deepEqual(computeStreaks(days, today), { current: 1, best: 4 });
|
||||
});
|
||||
|
||||
it("handles month boundaries", () => {
|
||||
assert.deepEqual(computeStreaks(["2026-08-30", "2026-08-31", "2026-09-01"], "2026-09-01"), { current: 3, best: 3 });
|
||||
});
|
||||
|
||||
it("ignores duplicate and unsorted input", () => {
|
||||
assert.deepEqual(computeStreaks(["2026-09-02", "2026-09-01", "2026-09-02"], today), { current: 2, best: 2 });
|
||||
});
|
||||
});
|
||||
56
src/lib/streak.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Pure streak logic over local calendar days — no React, no storage. Days are
|
||||
// "YYYY-MM-DD" keys in the device's local time zone, so a session at 23:50
|
||||
// counts for the day it was actually done, not the UTC one.
|
||||
|
||||
export type DayKey = string;
|
||||
|
||||
export function dayKey(date: Date): DayKey {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/** Local-midnight Date for a day key. */
|
||||
export function fromKey(key: DayKey): Date {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
function shiftKey(key: DayKey, days: number): DayKey {
|
||||
const date = fromKey(key);
|
||||
date.setDate(date.getDate() + days);
|
||||
return dayKey(date);
|
||||
}
|
||||
|
||||
/** Unique, ascending day keys from a list of ISO session timestamps. */
|
||||
export function sessionDays(sessionsIso: string[]): DayKey[] {
|
||||
return Array.from(new Set(sessionsIso.map((iso) => dayKey(new Date(iso))))).sort();
|
||||
}
|
||||
|
||||
/** The last `n` day keys ending on `today`, oldest first. */
|
||||
export function lastNDays(n: number, today: DayKey): DayKey[] {
|
||||
const out: DayKey[] = [];
|
||||
for (let i = n - 1; i >= 0; i--) out.push(shiftKey(today, -i));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current streak = consecutive trained days ending today or yesterday (a
|
||||
* streak isn't broken until a full day is missed). Best = longest run ever.
|
||||
*/
|
||||
export function computeStreaks(days: DayKey[], today: DayKey): { current: number; best: number } {
|
||||
const sorted = Array.from(new Set(days)).sort();
|
||||
if (sorted.length === 0) return { current: 0, best: 0 };
|
||||
|
||||
let best = 1;
|
||||
let run = 1;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
run = shiftKey(sorted[i - 1], 1) === sorted[i] ? run + 1 : 1;
|
||||
if (run > best) best = run;
|
||||
}
|
||||
|
||||
const last = sorted[sorted.length - 1];
|
||||
const alive = last === today || last === shiftKey(today, -1);
|
||||
return { current: alive ? run : 0, best };
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
// Stamena-style red theme: near-black red-tinted ground, saturated red as the
|
||||
// primary work color, warm amber for reverse-kegel pushes so the two moves
|
||||
// never read as the same cue.
|
||||
export const C = {
|
||||
bg: "#0F1115",
|
||||
surface: "#1A1D24",
|
||||
surface2: "#232733",
|
||||
border: "#2C313D",
|
||||
text: "#E6E8EC",
|
||||
muted: "#8A909C",
|
||||
accent: "#8B5CF6", // squeeze / primary
|
||||
push: "#F59E0B", // reverse kegel (push out)
|
||||
rest: "#4CAF7D",
|
||||
danger: "#E05B5B",
|
||||
bg: "#160B0B",
|
||||
surface: "#241213",
|
||||
surface2: "#2F1718",
|
||||
border: "#42201F",
|
||||
text: "#F7ECEA",
|
||||
muted: "#A98A86",
|
||||
accent: "#E7402E", // squeeze / primary
|
||||
push: "#FF9D3B", // reverse kegel (push out)
|
||||
rest: "#8B7573", // recovery — deliberately dull so work steps pop
|
||||
danger: "#FF5D5D",
|
||||
};
|
||||
|
||||
/** Append an alpha byte to a #RRGGBB token — RN accepts #RRGGBBAA. */
|
||||
export function alpha(color: string, a: number): string {
|
||||
return (
|
||||
color +
|
||||
Math.round(a * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,18 @@ export type StepKind =
|
||||
| "relax" // 1s gap between flicks
|
||||
| "rest"; // recovery between reps/blocks
|
||||
|
||||
export type BlockId = "warmup" | "holds" | "pushes" | "flick-squeezes" | "flick-pushes";
|
||||
|
||||
export interface Step {
|
||||
kind: StepKind;
|
||||
label: string;
|
||||
seconds: number;
|
||||
/** Block title shown as secondary context, e.g. "Holds 3/8". */
|
||||
block: string;
|
||||
blockId: BlockId;
|
||||
/** Which rep of the block this step belongs to (rests carry the rep they follow). */
|
||||
rep: number;
|
||||
repsTotal: number;
|
||||
}
|
||||
|
||||
export const MIN_HOLD = 5;
|
||||
@@ -34,9 +40,9 @@ function pushSeconds(hold: number): number {
|
||||
return Math.min(12, MIN_HOLD + Math.floor((hold - MIN_HOLD) / 2));
|
||||
}
|
||||
|
||||
const HOLD_REPS = 8;
|
||||
const PUSH_REPS = 5;
|
||||
const FLICK_REPS = 10;
|
||||
export const HOLD_REPS = 8;
|
||||
export const PUSH_REPS = 5;
|
||||
export const FLICK_REPS = 10;
|
||||
const BLOCK_REST = 20;
|
||||
|
||||
export function buildSession(hold: number): Step[] {
|
||||
@@ -44,33 +50,33 @@ export function buildSession(hold: number): Step[] {
|
||||
const repRest = Math.max(4, Math.round(hold * 0.75));
|
||||
const push = pushSeconds(hold);
|
||||
|
||||
steps.push({ kind: "ready", label: "Get ready", seconds: 5, block: "Warm up" });
|
||||
steps.push({ kind: "ready", label: "Get ready", seconds: 5, block: "Warm up", blockId: "warmup", rep: 0, repsTotal: 0 });
|
||||
|
||||
// Block 1: squeeze-and-hold (the progressive-overload driver).
|
||||
for (let r = 1; r <= HOLD_REPS; r++) {
|
||||
steps.push({ kind: "squeeze", label: "Squeeze & hold", seconds: hold, block: `Holds ${r}/${HOLD_REPS}` });
|
||||
if (r < HOLD_REPS) steps.push({ kind: "rest", label: "Rest", seconds: repRest, block: `Holds ${r}/${HOLD_REPS}` });
|
||||
steps.push({ kind: "squeeze", label: "Squeeze & hold", seconds: hold, block: `Holds ${r}/${HOLD_REPS}`, blockId: "holds", rep: r, repsTotal: HOLD_REPS });
|
||||
if (r < HOLD_REPS) steps.push({ kind: "rest", label: "Rest", seconds: repRest, block: `Holds ${r}/${HOLD_REPS}`, blockId: "holds", rep: r, repsTotal: HOLD_REPS });
|
||||
}
|
||||
steps.push({ kind: "rest", label: "Rest", seconds: BLOCK_REST, block: "Holds done" });
|
||||
steps.push({ kind: "rest", label: "Rest", seconds: BLOCK_REST, block: "Holds done", blockId: "holds", rep: HOLD_REPS, repsTotal: HOLD_REPS });
|
||||
|
||||
// Block 2: reverse kegels — gently push out, as when starting to urinate.
|
||||
for (let r = 1; r <= PUSH_REPS; r++) {
|
||||
steps.push({ kind: "push", label: "Push out", seconds: push, block: `Pushes ${r}/${PUSH_REPS}` });
|
||||
if (r < PUSH_REPS) steps.push({ kind: "rest", label: "Rest", seconds: 5, block: `Pushes ${r}/${PUSH_REPS}` });
|
||||
steps.push({ kind: "push", label: "Push out", seconds: push, block: `Pushes ${r}/${PUSH_REPS}`, blockId: "pushes", rep: r, repsTotal: PUSH_REPS });
|
||||
if (r < PUSH_REPS) steps.push({ kind: "rest", label: "Rest", seconds: 5, block: `Pushes ${r}/${PUSH_REPS}`, blockId: "pushes", rep: r, repsTotal: PUSH_REPS });
|
||||
}
|
||||
steps.push({ kind: "rest", label: "Rest", seconds: BLOCK_REST, block: "Pushes done" });
|
||||
steps.push({ kind: "rest", label: "Rest", seconds: BLOCK_REST, block: "Pushes done", blockId: "pushes", rep: PUSH_REPS, repsTotal: PUSH_REPS });
|
||||
|
||||
// Block 3: quick flicks in — fast full contractions, full release between.
|
||||
for (let r = 1; r <= FLICK_REPS; r++) {
|
||||
steps.push({ kind: "flick-squeeze", label: "Squeeze", seconds: 1, block: `Quick squeezes ${r}/${FLICK_REPS}` });
|
||||
steps.push({ kind: "relax", label: "Relax", seconds: 1, block: `Quick squeezes ${r}/${FLICK_REPS}` });
|
||||
steps.push({ kind: "flick-squeeze", label: "Squeeze", seconds: 1, block: `Quick squeezes ${r}/${FLICK_REPS}`, blockId: "flick-squeezes", rep: r, repsTotal: FLICK_REPS });
|
||||
steps.push({ kind: "relax", label: "Relax", seconds: 1, block: `Quick squeezes ${r}/${FLICK_REPS}`, blockId: "flick-squeezes", rep: r, repsTotal: FLICK_REPS });
|
||||
}
|
||||
steps.push({ kind: "rest", label: "Rest", seconds: 15, block: "Quick squeezes done" });
|
||||
steps.push({ kind: "rest", label: "Rest", seconds: 15, block: "Quick squeezes done", blockId: "flick-squeezes", rep: FLICK_REPS, repsTotal: FLICK_REPS });
|
||||
|
||||
// Block 4: quick flicks out.
|
||||
for (let r = 1; r <= FLICK_REPS; r++) {
|
||||
steps.push({ kind: "flick-push", label: "Push", seconds: 1, block: `Quick pushes ${r}/${FLICK_REPS}` });
|
||||
steps.push({ kind: "relax", label: "Relax", seconds: 1, block: `Quick pushes ${r}/${FLICK_REPS}` });
|
||||
steps.push({ kind: "flick-push", label: "Push", seconds: 1, block: `Quick pushes ${r}/${FLICK_REPS}`, blockId: "flick-pushes", rep: r, repsTotal: FLICK_REPS });
|
||||
steps.push({ kind: "relax", label: "Relax", seconds: 1, block: `Quick pushes ${r}/${FLICK_REPS}`, blockId: "flick-pushes", rep: r, repsTotal: FLICK_REPS });
|
||||
}
|
||||
|
||||
return steps;
|
||||
|
||||
85
store/icons/make.js
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
// Generate the app icon set from one SVG mark, rendered through headless Chrome.
|
||||
//
|
||||
// node store/icons/make.js
|
||||
//
|
||||
// Writes into assets/images/:
|
||||
// icon.png 1024×1024 opaque (iOS + fallback)
|
||||
// android-icon-foreground.png 1024×1024 alpha (adaptive foreground, mark in the 66% safe zone)
|
||||
// android-icon-monochrome.png 1024×1024 alpha (themed icon, white mark)
|
||||
// splash-icon.png 512×512 alpha (splash, mark only)
|
||||
//
|
||||
// The mark is the session ring from the app: a thick accent ring with a filled
|
||||
// core — "squeeze". Colours from src/lib/theme.ts. After running, regenerate the
|
||||
// committed Android res with `npx expo prebuild -p android --no-install`.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||
const OUT = path.join(__dirname, "..", "..", "assets", "images");
|
||||
const TMP = path.join(__dirname, ".tmp");
|
||||
|
||||
const C = { bg: "#160B0B", bg2: "#2A1112", accent: "#E7402E", accentDeep: "#B8271A", text: "#F7ECEA" };
|
||||
|
||||
// size: canvas px; scale: fraction of canvas the mark spans; color overrides for monochrome.
|
||||
function mark(size, scale, { ring = C.accent, core = C.accent, glow = true } = {}) {
|
||||
const c = size / 2;
|
||||
const R = (size * scale) / 2; // outer radius of the ring
|
||||
const stroke = R * 0.3;
|
||||
const ringR = R - stroke / 2;
|
||||
const coreR = R * 0.32;
|
||||
return `
|
||||
<defs>
|
||||
<radialGradient id="g" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="${ring}" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="${ring}" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
${glow ? `<circle cx="${c}" cy="${c}" r="${R * 1.35}" fill="url(#g)"/>` : ""}
|
||||
<!-- open arc, ~82% of the ring, like the session progress ring mid-hold; gap at the top-right, dash starts at 12 o'clock -->
|
||||
<circle cx="${c}" cy="${c}" r="${ringR}" fill="none" stroke="${ring}" stroke-width="${stroke}" stroke-linecap="round"
|
||||
stroke-dasharray="${2 * Math.PI * ringR * 0.82} ${2 * Math.PI * ringR}" transform="rotate(-90 ${c} ${c})"/>
|
||||
<circle cx="${c}" cy="${c}" r="${coreR}" fill="${core}"/>`;
|
||||
}
|
||||
|
||||
function svg(size, body, bg) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
${bg ? `<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${C.bg2}"/><stop offset="100%" stop-color="${C.bg}"/></linearGradient></defs>
|
||||
<rect width="${size}" height="${size}" fill="url(#bg)"/>` : ""}
|
||||
${body}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function render(svgText, size, out, transparent) {
|
||||
fs.mkdirSync(TMP, { recursive: true });
|
||||
const file = path.join(TMP, path.basename(out, ".png") + ".html");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;width:${size}px;height:${size}px;overflow:hidden;background:${transparent ? "transparent" : C.bg}}svg{display:block}</style></head><body>${svgText}</body></html>`,
|
||||
);
|
||||
const args = [
|
||||
"--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1",
|
||||
`--window-size=${size},${size}`, `--screenshot=${out}`,
|
||||
];
|
||||
if (transparent) args.push("--default-background-color=00000000");
|
||||
args.push("file://" + file);
|
||||
execFileSync(CHROME, args, { stdio: "ignore" });
|
||||
}
|
||||
|
||||
function main() {
|
||||
// iOS: opaque, mark fills ~62% of the canvas (Apple masks the corners itself).
|
||||
render(svg(1024, mark(1024, 0.62), true), 1024, path.join(OUT, "icon.png"), false);
|
||||
// Android adaptive foreground: content must sit inside the central 66% safe zone.
|
||||
render(svg(1024, mark(1024, 0.46, { glow: false })), 1024, path.join(OUT, "android-icon-foreground.png"), true);
|
||||
// Android monochrome: single-colour silhouette, same safe zone.
|
||||
render(svg(1024, mark(1024, 0.46, { ring: "#FFFFFF", core: "#FFFFFF", glow: false })), 1024, path.join(OUT, "android-icon-monochrome.png"), true);
|
||||
// Splash: mark only, on transparent; app.json sets the background colour.
|
||||
render(svg(512, mark(512, 0.8, { glow: false })), 512, path.join(OUT, "splash-icon.png"), true);
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
console.log("wrote icon.png, android-icon-foreground.png, android-icon-monochrome.png, splash-icon.png");
|
||||
}
|
||||
|
||||
main();
|
||||
68
store/listing.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Store listing copy
|
||||
|
||||
Keyword data (Google monthly volume, en-US, Sep 2026): "kegel exercises" 135k,
|
||||
"kegel" 90k, "kegel exercises for men" 74k, "kegels for men" 15k,
|
||||
"reverse kegel" 10k, "pelvic floor trainer" 3.6k, "kegel trainer" 1.9k.
|
||||
Title stacks the three highest-volume phrases; subtitle/short description carry
|
||||
the next tier. Never repeat a title word in the Apple keyword field.
|
||||
|
||||
## Apple App Store
|
||||
|
||||
- **Name** (30 max): `Him: Kegel Exercises for Men`
|
||||
- Fallback if taken: `Him: Kegel Exercises for Men` is 28 chars; if Apple rejects, drop to `Him: Kegel Exercises`
|
||||
- **Subtitle** (30 max): `Pelvic Floor Trainer & Timer`
|
||||
- **Keywords** (100 max, comma-separated, no spaces):
|
||||
`kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,routine,guided`
|
||||
- **Promotional text** (170 max):
|
||||
`Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.`
|
||||
- **Category**: Health & Fitness (secondary: Medical)
|
||||
- **Age rating**: answer the questionnaire honestly — Medical/Treatment Info: Infrequent; everything else None → expect 12+ or 17+. Do not mention sex in the listing; keep it fitness-framed.
|
||||
- **Privacy policy URL**: https://rehbock.xyz/kegel/privacy.html
|
||||
- **App Privacy**: "Data Not Collected"
|
||||
- **Support URL**: https://rehbock.xyz/kegel/privacy.html (or a GitHub/Gitea repo page)
|
||||
- **Copyright**: 2026 Marcus Rehbock
|
||||
- **Review notes**: `No account or sign-in. Tap Start on the home screen to run a full guided session (about 4 minutes). Haptics cue each rep. All data stays on device.`
|
||||
|
||||
### Description (4000 max)
|
||||
|
||||
```
|
||||
A free pelvic floor trainer for men. No account, no ads, no subscription, no data collection. Everything runs on your phone.
|
||||
|
||||
Each session is a guided, four-block routine driven by a step timer with haptic cues so you can train with the screen off or your eyes closed:
|
||||
|
||||
• Holds — 8 slow squeeze-and-hold reps. The core of the workout. Holds start at 5 seconds and automatically grow by 1 second every 3 completed sessions, up to 30 seconds.
|
||||
• Pushes — 5 reverse kegels (gently bear down) to balance tension and relaxation.
|
||||
• Quick squeezes — 10 one-second burst contractions.
|
||||
• Quick pushes — 10 one-second burst releases.
|
||||
|
||||
Progressive overload is built in. Finish sessions and the holds get longer on their own. Want to go faster or slower? Adjust hold time manually in Settings.
|
||||
|
||||
Stay consistent with a day streak, your best streak, and a two-week activity strip on the home screen.
|
||||
|
||||
Why kegels?
|
||||
Pelvic floor exercises strengthen the muscles that support the bladder and bowel and are widely recommended for bladder control, post-prostate-surgery recovery, and general pelvic health. Like any muscle, they respond to regular, progressively harder training.
|
||||
|
||||
What this app is not
|
||||
It is a timer and progression tracker, not a medical device, and gives no medical advice. If you have a medical condition or recent surgery, talk to a clinician first.
|
||||
|
||||
Privacy
|
||||
Zero network access. Your session count, difficulty and history are stored only on your device. Delete the app and the data is gone.
|
||||
```
|
||||
|
||||
## Google Play
|
||||
|
||||
- **App name** (30 max): `Him: Kegel Exercises for Men`
|
||||
- **Short description** (80 max): `Free pelvic floor trainer & timer. Guided kegels that get harder as you improve.`
|
||||
- **Full description**: same as the Apple description above.
|
||||
- **Category**: Health & Fitness
|
||||
- **Data safety**: no data collected, no data shared, no encryption in transit needed (no network).
|
||||
- **Content rating**: IARC questionnaire — no violence/sexual content/etc. Fitness app.
|
||||
- **Contact email**: marcus@thepai.co
|
||||
- **Privacy policy**: https://rehbock.xyz/kegel/privacy.html
|
||||
|
||||
## Screenshots
|
||||
|
||||
Generated by `store/screenshots/make.js` from simulator captures. Required sizes:
|
||||
|
||||
- Apple iPhone 6.9" (1320×2868) — mandatory since 2024; Apple scales down for other iPhones.
|
||||
- Google Play phone: 1080×1920 or larger, 16:9 to 9:16, plus a 1024×500 feature graphic.
|
||||
151
store/screenshots/make.js
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
// Compose store screenshots from raw simulator captures.
|
||||
//
|
||||
// node store/screenshots/make.js
|
||||
//
|
||||
// Inputs: store/screenshots/raw/<name>.png (iPhone 6.9" captures, 1320×2868)
|
||||
// Outputs: store/screenshots/out/ios/<n>.png 1320×2868 (App Store, 6.9")
|
||||
// store/screenshots/out/play/<n>.png 1080×1920 (Google Play phone)
|
||||
// store/screenshots/out/play/feature.png 1024×500 (Play feature graphic)
|
||||
//
|
||||
// Renders HTML through headless Chrome so we can use real typography. Colours
|
||||
// come from src/lib/theme.ts.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||
const ROOT = __dirname;
|
||||
const RAW = path.join(ROOT, "raw");
|
||||
const OUT = path.join(ROOT, "out");
|
||||
const TMP = path.join(ROOT, ".tmp");
|
||||
|
||||
const C = {
|
||||
bg: "#160B0B",
|
||||
surface: "#241213",
|
||||
border: "#42201F",
|
||||
text: "#F7ECEA",
|
||||
muted: "#A98A86",
|
||||
accent: "#E7402E",
|
||||
push: "#FF9D3B",
|
||||
};
|
||||
|
||||
const slides = [
|
||||
{
|
||||
raw: "home.png",
|
||||
h: "Kegels that get<br>harder as you do",
|
||||
sub: "Holds grow automatically every 3 sessions",
|
||||
},
|
||||
{
|
||||
raw: "session-hold.png",
|
||||
h: "Guided by timer<br>and haptics",
|
||||
sub: "Train with the screen off or eyes closed",
|
||||
},
|
||||
{
|
||||
raw: "session-push.png",
|
||||
h: "Squeezes and<br>reverse kegels",
|
||||
sub: "Both sides of the pelvic floor, every session",
|
||||
},
|
||||
{
|
||||
raw: "streak.png",
|
||||
h: "Build the habit",
|
||||
sub: "Day streak, best streak, two-week history",
|
||||
},
|
||||
{
|
||||
raw: "settings.png",
|
||||
h: "Free. Offline.<br>No account.",
|
||||
sub: "Nothing leaves your phone. No ads, ever.",
|
||||
},
|
||||
];
|
||||
|
||||
const dataUri = (file) => "data:image/png;base64," + fs.readFileSync(file).toString("base64");
|
||||
|
||||
function slideHtml({ w, h, cap, sub, img, phoneW, capSize, subSize, top, gap }) {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; -webkit-font-smoothing:antialiased; }
|
||||
html, body { width:${w}px; height:${h}px; overflow:hidden; background:${C.bg}; }
|
||||
body { font-family:-apple-system,"SF Pro Display","Helvetica Neue",Helvetica,Arial,sans-serif; }
|
||||
.slide { width:${w}px; height:${h}px; position:relative; display:flex; flex-direction:column; align-items:center; overflow:hidden; }
|
||||
.glow { position:absolute; left:50%; top:${top + capSize * 2.6}px; width:${w * 1.2}px; height:${w * 1.2}px;
|
||||
transform:translateX(-50%); border-radius:50%;
|
||||
background:radial-gradient(circle, ${C.accent}33 0%, ${C.accent}14 30%, transparent 62%); }
|
||||
.cap { position:relative; margin-top:${top}px; width:${w * 0.86}px; text-align:center; }
|
||||
.h { font-size:${capSize}px; font-weight:800; color:${C.text}; line-height:1.04; letter-spacing:-0.02em; }
|
||||
.sub { font-size:${subSize}px; color:${C.muted}; margin-top:${subSize * 0.7}px; font-weight:500; line-height:1.3; }
|
||||
.ph { position:relative; margin-top:${gap}px; width:${phoneW}px; border-radius:${phoneW * 0.11}px;
|
||||
border:${Math.round(phoneW * 0.012)}px solid ${C.border}; overflow:hidden;
|
||||
box-shadow:0 ${phoneW * 0.06}px ${phoneW * 0.16}px rgba(0,0,0,.6); background:${C.bg}; }
|
||||
.ph img { display:block; width:100%; }
|
||||
</style></head><body><div class="slide">
|
||||
<div class="glow"></div>
|
||||
<div class="cap"><div class="h">${cap}</div><div class="sub">${sub}</div></div>
|
||||
<div class="ph"><img src="${img}"></div>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
function featureHtml(icon) {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; -webkit-font-smoothing:antialiased; }
|
||||
html, body { width:1024px; height:500px; overflow:hidden; background:${C.bg}; }
|
||||
body { font-family:-apple-system,"SF Pro Display","Helvetica Neue",Helvetica,Arial,sans-serif; }
|
||||
.wrap { width:1024px; height:500px; display:flex; align-items:center; justify-content:center; gap:56px; position:relative; overflow:hidden; }
|
||||
.glow { position:absolute; left:50%; top:50%; width:900px; height:900px; transform:translate(-50%,-50%); border-radius:50%;
|
||||
background:radial-gradient(circle, ${C.accent}33 0%, ${C.accent}10 35%, transparent 65%); }
|
||||
img { position:relative; width:220px; height:220px; border-radius:50px; box-shadow:0 20px 60px rgba(0,0,0,.6); }
|
||||
.t { position:relative; }
|
||||
.brand { font-size:88px; font-weight:800; color:${C.text}; letter-spacing:-0.03em; line-height:1; }
|
||||
.brand span { color:${C.accent}; }
|
||||
.tag { font-size:34px; color:${C.muted}; margin-top:18px; font-weight:500; }
|
||||
</style></head><body><div class="wrap">
|
||||
<div class="glow"></div>
|
||||
<img src="${icon}">
|
||||
<div class="t"><div class="brand">Him<span>.</span></div>
|
||||
<div class="tag">Kegel exercises for men.<br>Free, offline, no account.</div></div>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
function render(html, w, h, out) {
|
||||
fs.mkdirSync(TMP, { recursive: true });
|
||||
const file = path.join(TMP, path.basename(out, ".png") + ".html");
|
||||
fs.writeFileSync(file, html);
|
||||
execFileSync(CHROME, [
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--hide-scrollbars",
|
||||
"--force-device-scale-factor=1",
|
||||
`--window-size=${w},${h}`,
|
||||
`--screenshot=${out}`,
|
||||
"file://" + file,
|
||||
], { stdio: "ignore" });
|
||||
}
|
||||
|
||||
function main() {
|
||||
for (const s of slides) {
|
||||
const f = path.join(RAW, s.raw);
|
||||
if (!fs.existsSync(f)) throw new Error(`missing raw capture: ${f}`);
|
||||
}
|
||||
fs.mkdirSync(path.join(OUT, "ios"), { recursive: true });
|
||||
fs.mkdirSync(path.join(OUT, "play"), { recursive: true });
|
||||
|
||||
slides.forEach((s, i) => {
|
||||
const img = dataUri(path.join(RAW, s.raw));
|
||||
// App Store 6.9": caption then phone; phone overflows the bottom edge on purpose.
|
||||
render(
|
||||
slideHtml({ w: 1320, h: 2868, cap: s.h, sub: s.sub, img, phoneW: 1100, capSize: 104, subSize: 46, top: 150, gap: 96 }),
|
||||
1320, 2868, path.join(OUT, "ios", `${i + 1}.png`),
|
||||
);
|
||||
// Play phone (16:9): same layout, smaller phone.
|
||||
render(
|
||||
slideHtml({ w: 1080, h: 1920, cap: s.h, sub: s.sub, img, phoneW: 720, capSize: 78, subSize: 36, top: 110, gap: 70 }),
|
||||
1080, 1920, path.join(OUT, "play", `${i + 1}.png`),
|
||||
);
|
||||
});
|
||||
|
||||
const icon = dataUri(path.join(ROOT, "..", "..", "assets", "images", "icon.png"));
|
||||
render(featureHtml(icon), 1024, 500, path.join(OUT, "play", "feature.png"));
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
console.log(`wrote ${slides.length} iOS + ${slides.length} Play slides and feature.png to ${OUT}`);
|
||||
}
|
||||
|
||||
main();
|
||||
BIN
store/screenshots/out/ios/1.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
store/screenshots/out/ios/2.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
store/screenshots/out/ios/3.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
store/screenshots/out/ios/4.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
store/screenshots/out/ios/5.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
store/screenshots/out/play/1.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
store/screenshots/out/play/2.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
store/screenshots/out/play/3.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
store/screenshots/out/play/4.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
store/screenshots/out/play/5.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
store/screenshots/out/play/feature.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
store/screenshots/raw/home.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
store/screenshots/raw/session-hold.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
store/screenshots/raw/session-push.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
store/screenshots/raw/settings.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
store/screenshots/raw/streak.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
40
store/web/privacy.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Him: Kegel Exercises for Men – Privacy Policy</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;max-width:680px;margin:40px auto;padding:0 20px;line-height:1.6;color:#222}
|
||||
h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:2em}
|
||||
small{color:#666}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Him: Kegel Exercises for Men – Privacy Policy</h1>
|
||||
<p><small>Effective 7 September 2026</small></p>
|
||||
|
||||
<p>Him: Kegel Exercises for Men ("the app") is a free pelvic floor exercise timer. It is built to work entirely on your device.</p>
|
||||
|
||||
<h2>Data we collect</h2>
|
||||
<p><strong>None.</strong> The app does not collect, transmit, sell, or share any personal data. There is no account, no sign-up, no analytics, no advertising, and no crash reporting.</p>
|
||||
|
||||
<h2>Data stored on your device</h2>
|
||||
<p>Your session count, difficulty level, and completion history are saved only in the app's local storage on your phone. This data never leaves your device. Deleting the app deletes it.</p>
|
||||
|
||||
<h2>Network access</h2>
|
||||
<p>The app makes no network requests. It works fully offline.</p>
|
||||
|
||||
<h2>Health disclaimer</h2>
|
||||
<p>The app is a general fitness timer, not a medical device, and provides no medical advice. Consult a healthcare professional before starting pelvic floor training if you have a medical condition, are pregnant, or have recently had surgery.</p>
|
||||
|
||||
<h2>Children</h2>
|
||||
<p>The app is not directed at children under 17 and collects no data from anyone.</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>If this policy changes, the updated version will be posted at this address.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Marcus Rehbock – <a href="mailto:marcus@thepai.co">marcus@thepai.co</a></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,6 +2,8 @@
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||