Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
38
APP_STORE_CONNECT.md
Normal 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
|
||||||
@ -4,10 +4,10 @@
|
|||||||
"color" : {
|
"color" : {
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.0",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.81",
|
"blue" : "0xCE",
|
||||||
"green" : "0.85",
|
"green" : "0xD8",
|
||||||
"red" : "0.97"
|
"red" : "0xF7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
@ -22,10 +22,10 @@
|
|||||||
"color" : {
|
"color" : {
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.0",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.15",
|
"blue" : "0x26",
|
||||||
"green" : "0.17",
|
"green" : "0x2B",
|
||||||
"red" : "0.23"
|
"red" : "0x3A"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|||||||
@ -4,10 +4,10 @@
|
|||||||
"color" : {
|
"color" : {
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.0",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.92",
|
"blue" : "0xEA",
|
||||||
"green" : "0.95",
|
"green" : "0xF2",
|
||||||
"red" : "0.97"
|
"red" : "0xF7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
@ -22,10 +22,10 @@
|
|||||||
"color" : {
|
"color" : {
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.0",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.08",
|
"blue" : "0.080",
|
||||||
"green" : "0.09",
|
"green" : "0.090",
|
||||||
"red" : "0.12"
|
"red" : "0.120"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|||||||
@ -4,10 +4,10 @@
|
|||||||
"color" : {
|
"color" : {
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.0",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.80",
|
"blue" : "0xCC",
|
||||||
"green" : "0.85",
|
"green" : "0xD8",
|
||||||
"red" : "0.89"
|
"red" : "0xE2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
@ -22,10 +22,10 @@
|
|||||||
"color" : {
|
"color" : {
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.0",
|
"alpha" : "1.000",
|
||||||
"blue" : "0.12",
|
"blue" : "0.120",
|
||||||
"green" : "0.14",
|
"green" : "0.140",
|
||||||
"red" : "0.18"
|
"red" : "0.180"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|||||||
@ -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 {
|
private func makeApp(resetDefaults: Bool, hasCompletedSetupWizard: Bool) -> XCUIApplication {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launchEnvironment["UITEST_MODE"] = "1"
|
app.launchEnvironment["UITEST_MODE"] = "1"
|
||||||
@ -162,6 +195,225 @@ final class AndromidaUITests: XCTestCase {
|
|||||||
return app
|
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(
|
private func tapFirstAvailableElementIfPresent(
|
||||||
app: XCUIApplication,
|
app: XCUIApplication,
|
||||||
identifiers: [String],
|
identifiers: [String],
|
||||||
|
|||||||
BIN
Screenshots/iPad-13/01-today-focus-portrait.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
Screenshots/iPad-13/02-ritual-arcs-portrait.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
Screenshots/iPad-13/03-insights-trends-portrait.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
Screenshots/iPad-13/04-history-calendar-portrait.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
Screenshots/iPad-13/05-history-day-detail-portrait.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
Screenshots/iPhone-6.3/01-today-focus-portrait.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
Screenshots/iPhone-6.3/02-ritual-arcs-portrait.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
Screenshots/iPhone-6.3/03-insights-trends-portrait.png
Normal file
|
After Width: | Height: | Size: 935 KiB |
BIN
Screenshots/iPhone-6.3/04-history-calendar-portrait.png
Normal file
|
After Width: | Height: | Size: 969 KiB |
BIN
Screenshots/iPhone-6.3/05-history-day-detail-portrait.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
Screenshots/iPhone-6.5-fallback/01-today-focus-portrait.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
Screenshots/iPhone-6.5-fallback/02-ritual-arcs-portrait.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
Screenshots/iPhone-6.5-fallback/03-insights-trends-portrait.png
Normal file
|
After Width: | Height: | Size: 1017 KiB |
BIN
Screenshots/iPhone-6.5-fallback/04-history-calendar-portrait.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 280 KiB |
BIN
Screenshots/iPhone-6.9/01-today-focus-portrait.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
Screenshots/iPhone-6.9/02-ritual-arcs-portrait.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
Screenshots/iPhone-6.9/03-insights-trends-portrait.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
Screenshots/iPhone-6.9/04-history-calendar-portrait.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
Screenshots/iPhone-6.9/05-history-day-detail-portrait.png
Normal file
|
After Width: | Height: | Size: 301 KiB |