Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0ad23f16 | ||
|
|
2d74b7d152 | ||
|
|
b65832bbd1 | ||
|
|
55999b7458 | ||
|
|
6a75db0aae |
@@ -61,6 +61,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
export ANDROID_HOME="$HOME/android-sdk"
|
export ANDROID_HOME="$HOME/android-sdk"
|
||||||
cd android
|
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
|
echo "sdk.dir=$ANDROID_HOME" > local.properties
|
||||||
./gradlew assembleRelease --no-daemon --console=plain
|
./gradlew assembleRelease --no-daemon --console=plain
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -34,3 +34,6 @@ npm-debug.*
|
|||||||
|
|
||||||
# generated native folders (android/ is committed — CI builds it)
|
# generated native folders (android/ is committed — CI builds it)
|
||||||
/ios
|
/ios
|
||||||
|
|
||||||
|
# Claude Code local dev config
|
||||||
|
.claude/
|
||||||
|
|||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Marcus Rehbock
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
67
README.md
@@ -1,7 +1,14 @@
|
|||||||
# Kegel
|
# Kegel — "Him: Kegel Exercises for Men"
|
||||||
|
|
||||||
Pelvic floor trainer for sexual performance — a free, local-only take on what
|
Free, open-source pelvic floor trainer. A local-only take on what Stamina was
|
||||||
Stamina was before it went paid. Expo / React Native, Android + iOS.
|
before it went paid: no account, no ads, no analytics, no network. Expo /
|
||||||
|
React Native, Android + iOS.
|
||||||
|
|
||||||
|
- **App Store**: https://apps.apple.com/app/id6809350427 (once approved)
|
||||||
|
- **Android APK**: releases on this repo, installable via Obtainium
|
||||||
|
- **Google Play**: coming
|
||||||
|
- **Privacy policy**: https://rehbock.xyz/kegel/privacy.html
|
||||||
|
- **License**: MIT — see `LICENSE`. Fork it, ship your own, no strings.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -15,13 +22,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.
|
3. **Quick squeezes** — 10 × 1s burst contractions with 1s release.
|
||||||
4. **Quick pushes** — 10 × 1s burst pushes with 1s release.
|
4. **Quick pushes** — 10 × 1s burst pushes with 1s release.
|
||||||
|
|
||||||
Progress (session count, difficulty adjustment) lives in AsyncStorage — no
|
Progress (session count, difficulty adjustment, completion history) lives in
|
||||||
server, no account.
|
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
|
## Layout
|
||||||
|
|
||||||
- `src/app/` — expo-router screens: `index` (home), `session` (timer), `settings`
|
- `src/app/` — expo-router screens: `index` (home), `session` (timer), `settings`
|
||||||
- `src/lib/workout.ts` — pure session-plan + progression logic
|
- `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
|
- `src/lib/store.ts` — tiny local store persisted to AsyncStorage
|
||||||
- `android/` — committed prebuild output; CI builds it directly
|
- `android/` — committed prebuild output; CI builds it directly
|
||||||
|
|
||||||
@@ -31,12 +40,50 @@ server, no account.
|
|||||||
npm install
|
npm install
|
||||||
npm start # Expo dev server
|
npm start # Expo dev server
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
|
npm test # node --test over src/lib/*.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and PRs welcome. Keep the app offline-only and dependency-light; the
|
||||||
|
whole point is that it never phones home. Run `npm run typecheck && npm test`
|
||||||
|
before opening a PR.
|
||||||
|
|
||||||
## Release
|
## Release
|
||||||
|
|
||||||
Push to `main` → Gitea Actions on the desktop runner (`runs-on: desktop`,
|
### Android APK (Gitea / Obtainium)
|
||||||
never the VPS) builds a signed APK and publishes a Gitea release — install via
|
|
||||||
Obtainium. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets
|
Push to `main` → Gitea Actions builds a signed APK and publishes a Gitea
|
||||||
`KEYSTORE_B64`, `KEYSTORE_PASSWORD`; alias `kegel`). iOS runs via
|
release — install via Obtainium. `android/app/build.gradle` keeps literal
|
||||||
`npx expo run:ios` from a Mac.
|
`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)
|
||||||
|
|
||||||
|
Only the maintainer can publish to the stores; `eas.json` references an App
|
||||||
|
Store Connect API key by key ID / issuer ID, which are identifiers, not
|
||||||
|
secrets — the `.p8` private key is never committed. 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'
|
applicationId 'com.marcus.kegel'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode ((System.getenv("VERSION_CODE") ?: "1").toInteger())
|
versionCode 1
|
||||||
versionName (System.getenv("VERSION_NAME") ?: "1.0-dev")
|
versionName "1.0.0"
|
||||||
|
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
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>
|
<resources>
|
||||||
<color name="splashscreen_background">#0F1115</color>
|
<color name="splashscreen_background">#160B0B</color>
|
||||||
<color name="iconBackground">#0F1115</color>
|
<color name="iconBackground">#160B0B</color>
|
||||||
<color name="colorPrimary">#023c69</color>
|
<color name="colorPrimary">#023c69</color>
|
||||||
<color name="activityBackground">#0F1115</color>
|
<color name="activityBackground">#160B0B</color>
|
||||||
</resources>
|
</resources>
|
||||||
13
app.json
@@ -9,7 +9,11 @@
|
|||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"backgroundColor": "#160B0B",
|
"backgroundColor": "#160B0B",
|
||||||
"ios": {
|
"ios": {
|
||||||
"bundleIdentifier": "com.marcus.kegel"
|
"bundleIdentifier": "com.marcus.kegel",
|
||||||
|
"supportsTablet": false,
|
||||||
|
"infoPlist": {
|
||||||
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -34,6 +38,13 @@
|
|||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
"reactCompiler": 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 |
31
eas.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.0.1",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"simulator": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true,
|
||||||
|
"ios": {
|
||||||
|
"resourceClass": "m-medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"ascApiKeyPath": "/Users/marcus/Downloads/AuthKey_L9Z6F5H5B7.p8",
|
||||||
|
"ascApiKeyId": "L9Z6F5H5B7",
|
||||||
|
"ascApiKeyIssuerId": "52ab69b8-cabe-48a1-8168-30bfc25cb5dd",
|
||||||
|
"appleTeamId": "HZ6XCKM95R",
|
||||||
|
"ascAppId": "6809350427"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"react-native-web": "^0.21.2"
|
"react-native-web": "^0.21.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.4.1",
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
}
|
}
|
||||||
@@ -2444,9 +2445,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.2.0",
|
"version": "26.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
|
||||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~8.3.0"
|
"undici-types": "~8.3.0"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"react-native-web": "^0.21.2"
|
"react-native-web": "^0.21.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.4.1",
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
@@ -29,7 +30,8 @@
|
|||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --test src/lib/*.test.ts"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, Text, View } from "react-native";
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import { ShapeIcon } from "../components/session-viz";
|
import { ShapeIcon } from "../components/session-viz";
|
||||||
|
import { StreakCard } from "../components/streak-card";
|
||||||
import { Button, Card, StatRow } from "../components/ui";
|
import { Button, Card, StatRow } from "../components/ui";
|
||||||
import { currentHold } from "../lib/store";
|
import { currentHold } from "../lib/store";
|
||||||
import { alpha, C } from "../lib/theme";
|
import { alpha, C } from "../lib/theme";
|
||||||
@@ -9,14 +10,14 @@ import { FLICK_REPS, HOLD_REPS, MAX_HOLD, PUSH_REPS, SESSIONS_PER_INCREMENT, ses
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { sessionsCompleted, lastSessionAt, hydrated } = useStore();
|
const { sessionsCompleted, lastSessionAt, sessions, hydrated } = useStore();
|
||||||
if (!hydrated) return <View style={s.screen} />;
|
if (!hydrated) return <View style={s.screen} />;
|
||||||
|
|
||||||
const hold = currentHold();
|
const hold = currentHold();
|
||||||
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
|
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={s.screen}>
|
<ScrollView style={s.screen} contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
|
||||||
<View style={s.hero}>
|
<View style={s.hero}>
|
||||||
<View style={s.glowOuter} />
|
<View style={s.glowOuter} />
|
||||||
<View style={s.glowInner} />
|
<View style={s.glowInner} />
|
||||||
@@ -24,6 +25,8 @@ export default function Home() {
|
|||||||
<Text style={s.holdLabel}>current hold</Text>
|
<Text style={s.holdLabel}>current hold</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<StreakCard sessions={sessions} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
||||||
<StatRow
|
<StatRow
|
||||||
@@ -55,12 +58,13 @@ export default function Home() {
|
|||||||
Squeeze = lift and contract, as if stopping urine mid-flow. Push = gently bear down, as when starting to
|
Squeeze = lift and contract, as if stopping urine mid-flow. Push = gently bear down, as when starting to
|
||||||
urinate. Keep breathing throughout — never strain.
|
urinate. Keep breathing throughout — never strain.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
screen: { flex: 1, backgroundColor: C.bg },
|
||||||
|
content: { padding: 20, paddingBottom: 32 },
|
||||||
hero: { alignItems: "center", justifyContent: "center", paddingVertical: 32 },
|
hero: { alignItems: "center", justifyContent: "center", paddingVertical: 32 },
|
||||||
glowOuter: { position: "absolute", width: 230, height: 230, borderRadius: 115, backgroundColor: alpha(C.accent, 0.05) },
|
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) },
|
glowInner: { position: "absolute", width: 150, height: 150, borderRadius: 75, backgroundColor: alpha(C.accent, 0.08) },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
|||||||
import { BLOCK_SHAPE, DoneCheck, GuideDisc, ProgressRing, RoundDots, ShapeIcon, useStepProgress } from "../components/session-viz";
|
import { BLOCK_SHAPE, DoneCheck, GuideDisc, ProgressRing, RoundDots, ShapeIcon, useStepProgress } from "../components/session-viz";
|
||||||
import { Button, Card, StatRow } from "../components/ui";
|
import { Button, Card, StatRow } from "../components/ui";
|
||||||
import { completeSession, currentHold, getState } from "../lib/store";
|
import { completeSession, currentHold, getState } from "../lib/store";
|
||||||
|
import { computeStreaks, dayKey, sessionDays } from "../lib/streak";
|
||||||
import { C } from "../lib/theme";
|
import { C } from "../lib/theme";
|
||||||
import { buildSession, SESSIONS_PER_INCREMENT, type StepKind } from "../lib/workout";
|
import { buildSession, SESSIONS_PER_INCREMENT, type StepKind } from "../lib/workout";
|
||||||
|
|
||||||
@@ -77,7 +78,8 @@ export default function Session() {
|
|||||||
}, [remaining, done, i, steps]);
|
}, [remaining, done, i, steps]);
|
||||||
|
|
||||||
if (done) {
|
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 leveledUp = sessionsCompleted % SESSIONS_PER_INCREMENT === 0;
|
||||||
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
|
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
|
||||||
return (
|
return (
|
||||||
@@ -90,6 +92,7 @@ export default function Session() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Card>
|
<Card>
|
||||||
|
<StatRow label="Streak" value={`${streak} day${streak === 1 ? "" : "s"}`} />
|
||||||
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
||||||
<StatRow label="This session" value={`${Math.round(totalSeconds / 60)} min`} />
|
<StatRow label="This session" value={`${Math.round(totalSeconds / 60)} min`} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
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 },
|
||||||
|
});
|
||||||
@@ -8,12 +8,14 @@ export interface State {
|
|||||||
lastSessionAt: string | null; // ISO date
|
lastSessionAt: string | null; // ISO date
|
||||||
/** Manual difficulty tweak from settings, in seconds (can be negative). */
|
/** Manual difficulty tweak from settings, in seconds (can be negative). */
|
||||||
holdAdjust: number;
|
holdAdjust: number;
|
||||||
|
/** ISO timestamps of every completed session — drives streaks/history. */
|
||||||
|
sessions: string[];
|
||||||
hydrated: boolean;
|
hydrated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = "kegel_v1";
|
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>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
export function getState(): State {
|
export function getState(): State {
|
||||||
@@ -34,6 +36,7 @@ function setState(patch: Partial<State>) {
|
|||||||
sessionsCompleted: state.sessionsCompleted,
|
sessionsCompleted: state.sessionsCompleted,
|
||||||
lastSessionAt: state.lastSessionAt,
|
lastSessionAt: state.lastSessionAt,
|
||||||
holdAdjust: state.holdAdjust,
|
holdAdjust: state.holdAdjust,
|
||||||
|
sessions: state.sessions,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,8 @@ export async function hydrate() {
|
|||||||
if (state.hydrated) return;
|
if (state.hydrated) return;
|
||||||
const raw = await AsyncStorage.getItem(KEY);
|
const raw = await AsyncStorage.getItem(KEY);
|
||||||
const saved = raw ? JSON.parse(raw) : {};
|
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();
|
for (const l of listeners) l();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +55,8 @@ export function currentHold(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function completeSession() {
|
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) {
|
export function adjustHold(delta: number) {
|
||||||
@@ -59,5 +64,5 @@ export function adjustHold(delta: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resetProgress() {
|
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 };
|
||||||
|
}
|
||||||
152
store/asc-fill.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Push the App Store listing from store/listing.md into App Store Connect.
|
||||||
|
// Idempotent: re-running overwrites text fields and replaces the screenshot set.
|
||||||
|
//
|
||||||
|
// node store/asc-fill.js
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { api, APP_ID } = require("./asc");
|
||||||
|
|
||||||
|
const NAME = "Him: Kegel Exercises for Men";
|
||||||
|
const SUBTITLE = "Pelvic Floor Trainer & Timer";
|
||||||
|
const PRIVACY_URL = "https://rehbock.xyz/kegel/privacy.html";
|
||||||
|
const SUPPORT_URL = "https://rehbock.xyz/kegel/privacy.html";
|
||||||
|
const KEYWORDS = "kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,guided";
|
||||||
|
const PROMO = "Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.";
|
||||||
|
const DESCRIPTION = `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.`;
|
||||||
|
|
||||||
|
const SHOTS_DIR = path.join(__dirname, "screenshots", "out", "ios");
|
||||||
|
const DISPLAY_TYPE = "APP_IPHONE_67"; // 6.9" / 6.7" iPhones (1320×2868 accepted)
|
||||||
|
|
||||||
|
async function first(p) {
|
||||||
|
const r = await api("GET", p);
|
||||||
|
return r.data[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const appInfo = await first(`/v1/apps/${APP_ID}/appInfos`);
|
||||||
|
const infoLoc = await first(`/v1/appInfos/${appInfo.id}/appInfoLocalizations`);
|
||||||
|
const version = await first(`/v1/apps/${APP_ID}/appStoreVersions?filter[platform]=IOS`);
|
||||||
|
const verLoc = await first(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations`);
|
||||||
|
console.log(`appInfo ${appInfo.id} / version ${version.attributes.versionString} (${version.attributes.appStoreState})`);
|
||||||
|
|
||||||
|
// 1. Name, subtitle, privacy policy
|
||||||
|
await api("PATCH", `/v1/appInfoLocalizations/${infoLoc.id}`, {
|
||||||
|
data: { type: "appInfoLocalizations", id: infoLoc.id, attributes: { name: NAME, subtitle: SUBTITLE, privacyPolicyUrl: PRIVACY_URL } },
|
||||||
|
});
|
||||||
|
console.log("✔ name / subtitle / privacy URL");
|
||||||
|
|
||||||
|
// 2. Description, keywords, promo, support URL
|
||||||
|
await api("PATCH", `/v1/appStoreVersionLocalizations/${verLoc.id}`, {
|
||||||
|
data: {
|
||||||
|
type: "appStoreVersionLocalizations",
|
||||||
|
id: verLoc.id,
|
||||||
|
attributes: { description: DESCRIPTION, keywords: KEYWORDS, promotionalText: PROMO, supportUrl: SUPPORT_URL },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("✔ description / keywords / promo / support URL");
|
||||||
|
|
||||||
|
// 3. Categories
|
||||||
|
await api("PATCH", `/v1/appInfos/${appInfo.id}`, {
|
||||||
|
data: {
|
||||||
|
type: "appInfos",
|
||||||
|
id: appInfo.id,
|
||||||
|
relationships: {
|
||||||
|
primaryCategory: { data: { type: "appCategories", id: "HEALTH_AND_FITNESS" } },
|
||||||
|
secondaryCategory: { data: { type: "appCategories", id: "MEDICAL" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("✔ categories");
|
||||||
|
|
||||||
|
// 4. Age rating: fitness timer, health topic yes, mild medical info, nothing else
|
||||||
|
const none = "NONE";
|
||||||
|
await api("PATCH", `/v1/ageRatingDeclarations/${appInfo.id}`, {
|
||||||
|
data: {
|
||||||
|
type: "ageRatingDeclarations",
|
||||||
|
id: appInfo.id,
|
||||||
|
attributes: {
|
||||||
|
alcoholTobaccoOrDrugUseOrReferences: none,
|
||||||
|
contests: none,
|
||||||
|
gamblingSimulated: none,
|
||||||
|
gunsOrOtherWeapons: none,
|
||||||
|
horrorOrFearThemes: none,
|
||||||
|
matureOrSuggestiveThemes: none,
|
||||||
|
medicalOrTreatmentInformation: "INFREQUENT_OR_MILD",
|
||||||
|
profanityOrCrudeHumor: none,
|
||||||
|
sexualContentGraphicAndNudity: none,
|
||||||
|
sexualContentOrNudity: none,
|
||||||
|
violenceCartoonOrFantasy: none,
|
||||||
|
violenceRealistic: none,
|
||||||
|
violenceRealisticProlongedGraphicOrSadistic: none,
|
||||||
|
advertising: false,
|
||||||
|
gambling: false,
|
||||||
|
healthOrWellnessTopics: true,
|
||||||
|
lootBox: false,
|
||||||
|
messagingAndChat: false,
|
||||||
|
parentalControls: false,
|
||||||
|
socialMedia: false,
|
||||||
|
unrestrictedWebAccess: false,
|
||||||
|
userGeneratedContent: false,
|
||||||
|
ageAssurance: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("✔ age rating declaration");
|
||||||
|
|
||||||
|
// 5. Screenshots: replace the iPhone 6.9" set
|
||||||
|
const sets = await api("GET", `/v1/appStoreVersionLocalizations/${verLoc.id}/appScreenshotSets?filter[screenshotDisplayType]=${DISPLAY_TYPE}`);
|
||||||
|
for (const s of sets.data) await api("DELETE", `/v1/appScreenshotSets/${s.id}`);
|
||||||
|
const set = await api("POST", "/v1/appScreenshotSets", {
|
||||||
|
data: {
|
||||||
|
type: "appScreenshotSets",
|
||||||
|
attributes: { screenshotDisplayType: DISPLAY_TYPE },
|
||||||
|
relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: verLoc.id } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const files = fs.readdirSync(SHOTS_DIR).filter((f) => /^\d+\.png$/.test(f)).sort((a, b) => parseInt(a) - parseInt(b));
|
||||||
|
for (const f of files) {
|
||||||
|
const buf = fs.readFileSync(path.join(SHOTS_DIR, f));
|
||||||
|
const shot = await api("POST", "/v1/appScreenshots", {
|
||||||
|
data: {
|
||||||
|
type: "appScreenshots",
|
||||||
|
attributes: { fileName: f, fileSize: buf.length },
|
||||||
|
relationships: { appScreenshotSet: { data: { type: "appScreenshotSets", id: set.data.id } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const op of shot.data.attributes.uploadOperations) {
|
||||||
|
const headers = Object.fromEntries(op.requestHeaders.map((h) => [h.name, h.value]));
|
||||||
|
const res = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) });
|
||||||
|
if (!res.ok) throw new Error(`upload chunk failed ${res.status} for ${f}`);
|
||||||
|
}
|
||||||
|
await api("PATCH", `/v1/appScreenshots/${shot.data.id}`, {
|
||||||
|
data: { type: "appScreenshots", id: shot.data.id, attributes: { uploaded: true, sourceFileChecksum: crypto.createHash("md5").update(buf).digest("hex") } },
|
||||||
|
});
|
||||||
|
console.log(`✔ screenshot ${f}`);
|
||||||
|
}
|
||||||
|
console.log("done");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
50
store/asc.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Minimal App Store Connect API client (ES256 JWT via node:crypto, no deps).
|
||||||
|
//
|
||||||
|
// node store/asc.js get /v1/apps/6809350427
|
||||||
|
// node store/asc.js patch /v1/appInfos/ID '{"data":{...}}'
|
||||||
|
//
|
||||||
|
// Reads the key from eas.json submit.production.ios (ascApiKeyPath/Id/IssuerId).
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const eas = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "eas.json"), "utf8"));
|
||||||
|
const cfg = eas.submit.production.ios;
|
||||||
|
const APP_ID = cfg.ascAppId;
|
||||||
|
const BASE = "https://api.appstoreconnect.apple.com";
|
||||||
|
|
||||||
|
function b64url(buf) {
|
||||||
|
return Buffer.from(buf).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const header = b64url(JSON.stringify({ alg: "ES256", kid: cfg.ascApiKeyId, typ: "JWT" }));
|
||||||
|
const payload = b64url(JSON.stringify({ iss: cfg.ascApiKeyIssuerId, iat: now, exp: now + 19 * 60, aud: "appstoreconnect-v1" }));
|
||||||
|
const key = fs.readFileSync(cfg.ascApiKeyPath, "utf8");
|
||||||
|
const sig = crypto.sign("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" });
|
||||||
|
return `${header}.${payload}.${b64url(sig)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, p, body, extraHeaders = {}) {
|
||||||
|
const res = await fetch(p.startsWith("http") ? p : BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${token()}`, "Content-Type": "application/json", ...extraHeaders },
|
||||||
|
body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json;
|
||||||
|
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
|
||||||
|
if (!res.ok) throw new Error(`${method} ${p} -> ${res.status}\n${JSON.stringify(json, null, 2)}`);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { api, APP_ID, token };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const [, , method, p, body] = process.argv;
|
||||||
|
if (!method || !p) { console.error("usage: asc.js <get|post|patch|delete> <path> [json]"); process.exit(1); }
|
||||||
|
api(method.toUpperCase(), p, body).then((r) => console.log(JSON.stringify(r, null, 2))).catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
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,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",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"types": ["node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||