Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-08 16:54:27 -06:00
parent d3780b39cc
commit 06026db05a
25 changed files with 314 additions and 24 deletions

38
APP_STORE_CONNECT.md Normal file
View File

@ -0,0 +1,38 @@
# Rituals App Store Connect Copy
## Promotional Text (max 170)
Build better habits with guided ritual arcs, daily check-ins, and clear progress insights, all in a private, offline-first tracker designed for focus.
Character count: 150 / 170
## Keywords (max 100)
habit tracker,rituals,daily routine,streaks,goals,productivity,mindfulness,offline,privacy
Character count: 90 / 100
## Description (max 4000)
Rituals helps you build consistent habits through focused, time-bound ritual arcs instead of endless streak pressure.
Create rituals that match your life:
- Set a custom duration from 7 to 365 days
- Organize habits by morning, midday, afternoon, evening, night, or anytime
- Personalize rituals with themes, icons, categories, and notes
Stay on track every day:
- Check off habits with fast tap-to-complete actions
- See progress rings and completion summaries at a glance
- Keep momentum with milestone achievements and streak tracking
Understand your patterns:
- Review daily history in a calendar view with completion detail
- Track trends with weekly averages, active-day stats, and total check-ins
- See your best-performing ritual and habit-level completion rates
Designed for privacy and reliability:
- Offline-first by default, with no paid backend dependencies
- Local-first data storage with optional iCloud settings sync
- Clean, calm interface built for daily focus
Whether you are starting a morning routine, improving productivity, or building mindfulness habits, Rituals gives you a clear structure you can actually sustain.
Character count: 1145 / 4000

View File

@ -4,10 +4,10 @@
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.0",
"blue" : "0.81",
"green" : "0.85",
"red" : "0.97"
"alpha" : "1.000",
"blue" : "0xCE",
"green" : "0xD8",
"red" : "0xF7"
}
},
"idiom" : "universal"
@ -22,10 +22,10 @@
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.0",
"blue" : "0.15",
"green" : "0.17",
"red" : "0.23"
"alpha" : "1.000",
"blue" : "0x26",
"green" : "0x2B",
"red" : "0x3A"
}
},
"idiom" : "universal"

View File

@ -4,10 +4,10 @@
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.0",
"blue" : "0.92",
"green" : "0.95",
"red" : "0.97"
"alpha" : "1.000",
"blue" : "0xEA",
"green" : "0xF2",
"red" : "0xF7"
}
},
"idiom" : "universal"
@ -22,10 +22,10 @@
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.0",
"blue" : "0.08",
"green" : "0.09",
"red" : "0.12"
"alpha" : "1.000",
"blue" : "0.080",
"green" : "0.090",
"red" : "0.120"
}
},
"idiom" : "universal"

View File

@ -4,10 +4,10 @@
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.0",
"blue" : "0.80",
"green" : "0.85",
"red" : "0.89"
"alpha" : "1.000",
"blue" : "0xCC",
"green" : "0xD8",
"red" : "0xE2"
}
},
"idiom" : "universal"
@ -22,10 +22,10 @@
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.0",
"blue" : "0.12",
"green" : "0.14",
"red" : "0.18"
"alpha" : "1.000",
"blue" : "0.120",
"green" : "0.140",
"red" : "0.180"
}
},
"idiom" : "universal"

View File

@ -152,6 +152,39 @@ final class AndromidaUITests: XCTestCase {
}
}
@MainActor
func testAppStorePortraitScreenshots() throws {
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
app.launch()
XCUIDevice.shared.orientation = .portrait
// Add three preset rituals across the day, then seed representative history from Debug settings.
addThreePresetRitualsAcrossDay(app: app)
preloadSixMonthsDemoDataFromDebug(app: app)
ensureDataRichStateForScreenshots(app: app)
openTab(app: app, label: "Today", index: 0)
XCTAssertFalse(app.staticTexts["No Active Rituals"].exists, "Today should show active rituals for App Store screenshots.")
XCTAssertFalse(app.staticTexts["All caught up"].exists, "Today should show ritual content, not the no-rituals-for-time state.")
saveScreenshot(app: app, named: "01-today-focus-portrait")
openTab(app: app, label: "Rituals", index: 1)
XCTAssertFalse(app.staticTexts["No Active Rituals"].exists, "Rituals should show active ritual cards for App Store screenshots.")
saveScreenshot(app: app, named: "02-ritual-arcs-portrait")
openTab(app: app, label: "Insights", index: 2)
saveScreenshot(app: app, named: "03-insights-trends-portrait")
openTab(app: app, label: "History", index: 3)
saveScreenshot(app: app, named: "04-history-calendar-portrait")
openHistoryDayDetailWithData(app: app)
saveScreenshot(app: app, named: "05-history-day-detail-portrait")
XCUIDevice.shared.orientation = .portrait
}
private func makeApp(resetDefaults: Bool, hasCompletedSetupWizard: Bool) -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["UITEST_MODE"] = "1"
@ -162,6 +195,225 @@ final class AndromidaUITests: XCTestCase {
return app
}
private func addThreePresetRitualsAcrossDay(app: XCUIApplication) {
openTab(app: app, label: "Rituals", index: 1)
let addMenu = app.buttons["rituals.addMenu"]
guard addMenu.waitForExistence(timeout: 8) else { return }
addMenu.tap()
let browsePresets = app.buttons["rituals.browsePresets"]
guard browsePresets.waitForExistence(timeout: 8) else { return }
browsePresets.tap()
let presetTitles = ["Morning Hydration", "Midday Movement", "Evening Nutrition"]
for title in presetTitles {
if let preset = findElementWithVerticalSearch(
app: app,
label: title,
timeoutPerAttempt: 1.0,
swipeCount: 6
) {
if preset.isHittable {
preset.tap()
} else {
preset.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
let addButton = app.buttons["Add to My Rituals"]
if addButton.waitForExistence(timeout: 8) {
addButton.tap()
}
tapDoneInNavigationBar(app: app, title: title)
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
}
tapDoneInNavigationBar(app: app, title: "Preset Library")
}
private func preloadSixMonthsDemoDataFromDebug(app: XCUIApplication) {
openTab(app: app, label: "Settings", index: 4)
// Move into the lower Debug section before tapping preload.
_ = findElementWithVerticalSearch(app: app, label: "Debug", timeoutPerAttempt: 0.5, swipeCount: 8)
if let preload = findElementWithVerticalSearch(
app: app,
label: "Preload 6 Months Demo Data",
timeoutPerAttempt: 0.5,
swipeCount: 10
) {
if preload.isHittable {
preload.tap()
} else {
preload.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
waitForHistoryDataSeed(app: app)
}
}
private func waitForHistoryDataSeed(app: XCUIApplication) {
for _ in 0..<6 {
openTab(app: app, label: "History", index: 3)
let nonZeroDay = app.buttons.matching(
NSPredicate(format: "label CONTAINS[c] %@ AND NOT (label CONTAINS[c] %@)", "percent complete", "0 percent complete")
).firstMatch
if nonZeroDay.waitForExistence(timeout: 1.5) {
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
}
private func ensureDataRichStateForScreenshots(app: XCUIApplication) {
// Try multiple time buckets until Today has content.
let candidateTimes = ["Anytime", "Morning", "Midday", "Afternoon", "Evening", "Night", "Real"]
for time in candidateTimes {
openTab(app: app, label: "Settings", index: 4)
setSimulatedTimeIfAvailable(app: app, label: time)
openTab(app: app, label: "Today", index: 0)
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
let hasActiveEmptyState = app.staticTexts["No Active Rituals"].exists
let hasTimeEmptyState = app.staticTexts["All caught up"].exists
if !hasActiveEmptyState && !hasTimeEmptyState {
return
}
}
}
private func setSimulatedTimeIfAvailable(app: XCUIApplication, label: String) {
if let button = firstMatchIfExists(app: app, label: label, timeout: 2) {
if button.isHittable {
button.tap()
} else {
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
return
}
for _ in 0..<4 {
app.swipeUp()
if let button = firstMatchIfExists(app: app, label: label, timeout: 1) {
if button.isHittable {
button.tap()
} else {
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
return
}
}
}
private func firstMatchIfExists(app: XCUIApplication, label: String, timeout: TimeInterval) -> XCUIElement? {
let candidates: [XCUIElement] = [
app.buttons[label].firstMatch,
app.staticTexts[label].firstMatch,
app.otherElements[label].firstMatch
]
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let element = candidates.first(where: { $0.exists }) {
return element
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return nil
}
private func findElementWithVerticalSearch(
app: XCUIApplication,
label: String,
timeoutPerAttempt: TimeInterval,
swipeCount: Int
) -> XCUIElement? {
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
return element
}
for _ in 0..<swipeCount {
app.swipeUp()
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
return element
}
}
for _ in 0..<swipeCount {
app.swipeDown()
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
return element
}
}
return nil
}
private func tapDoneInNavigationBar(app: XCUIApplication, title: String) {
let navDone = app.navigationBars[title].buttons["Done"].firstMatch
if navDone.waitForExistence(timeout: 6) {
navDone.tap()
return
}
let anyDone = app.buttons["Done"].firstMatch
if anyDone.waitForExistence(timeout: 4) {
anyDone.tap()
}
}
private func openTab(app: XCUIApplication, label: String, index: Int) {
let primary = app.tabBars.buttons[label].firstMatch
if primary.waitForExistence(timeout: 8) {
primary.tap()
return
}
let tabButtons = app.tabBars.buttons
if tabButtons.count > index {
tabButtons.element(boundBy: index).tap()
return
}
let fallback = app.buttons[label].firstMatch
if fallback.exists {
fallback.tap()
}
}
private func saveScreenshot(app: XCUIApplication, named name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
private func openHistoryDayDetailWithData(app: XCUIApplication) {
openTab(app: app, label: "History", index: 3)
let nonZeroDay = app.buttons.matching(
NSPredicate(format: "label CONTAINS[c] %@ AND NOT (label CONTAINS[c] %@)", "percent complete", "0 percent complete")
).firstMatch
if nonZeroDay.waitForExistence(timeout: 8) {
nonZeroDay.tap()
} else {
let anyDay = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "percent complete")).firstMatch
if anyDay.waitForExistence(timeout: 8) {
anyDay.tap()
}
}
_ = app.buttons["Done"].waitForExistence(timeout: 8)
}
private func tapFirstAvailableElementIfPresent(
app: XCUIApplication,
identifiers: [String],

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1017 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB