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
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>
|
||||