feat: store release setup — EAS config, listing copy, screenshots, new icon
Some checks failed
Build & Release APK / build (push) Has been cancelled
- eas.json production/simulator profiles; iOS plist flags; EAS project linked - literal versionCode/versionName in build.gradle so EAS can parse it; Gitea CI stamps the run number with sed instead of env-driven gradle expressions - store/: listing copy + keyword rationale, screenshot composer (headless Chrome), raw simulator captures, rendered App Store / Play slides, feature graphic, privacy policy source, icon generator - replace Expo placeholder icon with the session-ring mark; regenerate Android launcher/splash res and theme colours Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
@@ -61,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
|
||||
|
||||
|
||||
39
README.md
@@ -38,8 +38,37 @@ 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>
|
||||
13
app.json
@@ -9,7 +9,11 @@
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#160B0B",
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.marcus.kegel"
|
||||
"bundleIdentifier": "com.marcus.kegel",
|
||||
"supportsTablet": false,
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
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>
|
||||