diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..cea5378 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,26 @@ + + + + + + + Cosmic Rituals + + + +
+
Cosmic Rituals · Native for iPhone and iPad
+

Clarity for sacred time and practice.

+
+

Cosmic Rituals provides a location-aware Vedic Panchang, transparent muhurta timing, and an offline, tradition-aware Pooja Vidhi library.

+

Core calculations, saved location preferences, and ritual searches remain on the device. The app has no advertising SDK, tracking SDK, or user account system.

+
+ + +
+ + diff --git a/docs/privacy/index.html b/docs/privacy/index.html new file mode 100644 index 0000000..fd41e1d --- /dev/null +++ b/docs/privacy/index.html @@ -0,0 +1,38 @@ + + + + + + Privacy Policy · Cosmic Rituals + + + +
+
Effective July 28, 2026
+

Privacy Policy

+

Cosmic Rituals is designed to operate without a developer-operated account or analytics service.

+ +

Information processed on your device

+

The app processes the date, a selected city or current device location, time zone, and app preferences to calculate Panchang and muhurta information. These calculations, Pooja searches, checklists, and display preferences stay on your device. A selected calculation location and preferences may remain in local app storage until you change them or delete the app.

+ +

Location

+

If you choose “Current Location,” iOS provides coordinates while the app is in use. Cosmic Rituals uses them locally to calculate sunrise, sunset, Panchang, and muhurta times. The developer does not receive or store your location on a server. You may instead choose an offline city and can change location permission in iOS Settings.

+ +

Subscriptions and payments

+

Purchases are processed by Apple through the App Store. Cosmic Rituals receives signed entitlement status and localized product information needed to grant access. The developer does not receive your payment-card details. Apple’s handling of purchase information is governed by Apple’s privacy policy.

+ +

Data collection, advertising, and tracking

+

Cosmic Rituals does not include third-party advertising, cross-app tracking, analytics, or data-broker SDKs. The developer does not collect personal data through the app.

+ +

External links

+

The app can open source references, policy pages, App Store subscription management, and support pages at your request. Those destinations apply their own privacy practices once opened.

+ +

Children

+

The app does not knowingly collect personal information from children or anyone else.

+ +

Changes and contact

+

Material changes will be posted on this page with a revised effective date. For privacy questions, use the Cosmic Rituals support page.

+ +
+ + diff --git a/docs/styles.css b/docs/styles.css new file mode 100644 index 0000000..e168336 --- /dev/null +++ b/docs/styles.css @@ -0,0 +1,57 @@ +:root { + color-scheme: dark; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + background: #080811; + color: #f5f1ea; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: + radial-gradient(circle at 80% 5%, rgba(118, 89, 170, 0.22), transparent 36rem), + radial-gradient(circle at 5% 40%, rgba(217, 150, 91, 0.12), transparent 30rem), + #080811; +} + +main { + width: min(760px, calc(100% - 32px)); + margin: 0 auto; + padding: 64px 0 80px; +} + +.eyebrow { + color: #e5a469; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +h1 { font-size: clamp(2.3rem, 7vw, 4.4rem); line-height: 0.98; margin: 14px 0 24px; } +h2 { margin-top: 36px; font-size: 1.35rem; } +p, li { color: #c8c3cd; line-height: 1.68; } +strong { color: #f5f1ea; } +a { color: #f1b17b; } + +.card { + margin-top: 28px; + padding: 24px; + border: 1px solid rgba(229, 164, 105, 0.32); + border-radius: 24px; + background: rgba(26, 24, 35, 0.72); + backdrop-filter: blur(18px); +} + +.links { display: grid; gap: 12px; margin-top: 28px; } +.links a { + display: block; + padding: 15px 18px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.06); + text-decoration: none; + font-weight: 650; +} + +footer { margin-top: 52px; font-size: 0.82rem; color: #89838e; } diff --git a/docs/support/index.html b/docs/support/index.html new file mode 100644 index 0000000..6c7daa0 --- /dev/null +++ b/docs/support/index.html @@ -0,0 +1,29 @@ + + + + + + Support · Cosmic Rituals + + + +
+
Cosmic Rituals
+

Support

+
+

Restore a subscription

+

On the Premium screen, choose Restore Purchases. Use the same Apple Account that made the original purchase.

+ +

Manage or cancel

+

Open Apple subscription settings. Billing and refunds are handled by Apple.

+ +

Location or timing looks wrong

+

Confirm the selected city, date, and time zone. Current Location requires permission and a fresh GPS fix. Regional observance rules can differ even when astronomical boundaries agree.

+ +

Contact support

+

Open a support request with the app version, device model, iOS version, selected city, and a description of the issue. Do not include payment details, precise home coordinates, or other sensitive information.

+
+ +
+ + diff --git a/docs/terms/index.html b/docs/terms/index.html new file mode 100644 index 0000000..b5985f5 --- /dev/null +++ b/docs/terms/index.html @@ -0,0 +1,40 @@ + + + + + + Terms of Use · Cosmic Rituals + + + +
+
Effective July 28, 2026
+

Terms of Use

+ +

App license

+

Your use of Cosmic Rituals is subject to these terms and Apple’s Standard Licensed Application End User License Agreement.

+ +

Auto-renewable subscriptions

+ + +

Traditional and astronomical information

+

Panchang, muhurta, festival, and Pooja content is educational and ceremonial reference material. Traditions and regional observances vary. The app does not guarantee outcomes and is not medical, legal, financial, safety, or professional religious advice. Confirm consequential timing and formal rites with a qualified practitioner.

+ +

Availability and acceptable use

+

Do not use the app where an error or interruption could cause harm. You may not reverse engineer, redistribute, misuse, or interfere with the app except where applicable law expressly permits.

+ +

Updates

+

Features, calculations, content, and these terms may be updated to improve accuracy, security, legal compliance, or reliability. A revised effective date will appear here for material changes.

+ +

Support

+

For subscription or technical assistance, visit Cosmic Rituals Support.

+ +
+ + diff --git a/ios/README.md b/ios/README.md index 138fffb..29ea2f6 100644 --- a/ios/README.md +++ b/ios/README.md @@ -1,25 +1,29 @@ # Cosmic Rituals — Native iOS handoff -This `ios/` section is an additive, self-contained snapshot of the completed native SwiftUI application. The GitHub repository was empty when cloned; its remote and new local history remain intact. +This `ios/` section is the canonical, self-contained native SwiftUI application. +It began as a sanitized handoff from the development checkout and now carries its +own tested accuracy and product-integrity improvements. ## Layout -- `native/` — exact sanitized snapshot of the live Xcode project -- `evidence/` — selected final simulator screenshots +- `native/` — working Xcode project, tests, and product documentation +- `evidence/` — historical handoff screenshots (not current QA evidence) ## Current product state - Native SwiftUI application targeting iOS 26 - Six selectable light/dark visual themes with Liquid Glass surfaces and motion polish -- Ritual-now guidance, Panchang/five-limb context, Vedic ledger, location support, and saved preferences +- Sunrise-anchored Panchang, four limb transitions, three experience layouts, + location-aware timing, saved preferences, and calculation-integrity disclosure - Privacy manifest, city data, tests, and app icon assets included - `CosmicWidgets/` source is preserved, but it is not currently wired as an Xcode widget target and must not be presented as shipping functionality -## Verification already completed +## Historical release state -- Simulator design and experience QA completed on iPhone 17 / iOS 26.5 -- Native project and unit-test target are included under `native/` -- TestFlight: version 1.0, build 3 uploaded successfully; archive/build products are intentionally excluded from source control +- The imported handoff had simulator design QA on iPhone 17 / iOS 26.5. +- TestFlight version 1.0 build 3 was uploaded from the earlier development checkout. +- Those facts do not prove that the current source is release-ready; rerun the + build, tests, and simulator QA below for every change. ## Build and test @@ -41,11 +45,13 @@ xcodebuild \ test ``` -## Snapshot provenance +## Initial-import provenance - Copied read-only from `/Users/psy/Documents/Gohler/Cosmic-Rituals` - Snapshot date: 2026-07-26 - Included source files: 41 -- Aggregate relative-path/content SHA-256: `80bab676abbf21a77da200b60ab534e37318af6dfe6f018d099dd7a679430495` +- Original aggregate relative-path/content SHA-256: `80bab676abbf21a77da200b60ab534e37318af6dfe6f018d099dd7a679430495` - Excluded: `.DS_Store`, user Xcode state, DerivedData, build products, archives, and signing material +`SNAPSHOT.sha256` records that import only. It is intentionally not a checksum of +the evolving canonical source tree. diff --git a/ios/native/.gitignore b/ios/native/.gitignore index 5bb84b1..2e8cc84 100644 --- a/ios/native/.gitignore +++ b/ios/native/.gitignore @@ -4,6 +4,7 @@ # Xcode build/ DerivedData/ +.derived-data/ *.xcuserstate xcuserdata/ *.moved-aside diff --git a/ios/native/ACCURACY.md b/ios/native/ACCURACY.md new file mode 100644 index 0000000..8350ba7 --- /dev/null +++ b/ios/native/ACCURACY.md @@ -0,0 +1,90 @@ +# Calculation accuracy and provenance + +Cosmic Rituals is an offline Panchang application. It favors deterministic, +location-aware calculations and explicit limits over unexplained precision. + +## Daily reference convention + +- A Panchang day is anchored to sunrise at the selected calculation location. +- Tithi, Nakshatra, Yoga, Karana, Moon sign, and the displayed Pada are evaluated + at that sunrise instant. +- If sunrise does not exist for the selected latitude and civil date, the app + does not fabricate sunrise-based schedules. The five-limb snapshot falls back + to 12:00 PM local civil time and labels that fallback in the interface. +- Dates and clock times are formatted in the calculation location's IANA time + zone, including daylight-saving transitions. They never silently inherit the + device time zone. + +## Astronomical methods + +| Quantity | Current method | Presentation contract | +|---|---|---| +| Julian date | Gregorian UTC conversion based on Meeus chapter 7 | Absolute instant | +| Sun longitude | Meeus chapter 25 apparent ecliptic longitude | Offline compact ephemeris | +| Moon longitude | Complete Meeus table 47.A longitude series | Offline compact ephemeris | +| Sidereal conversion | Lahiri (Chitra Paksha) ayanamsha polynomial | Named in Settings and exports | +| Sunrise / sunset | NOAA/Meeus solar altitude crossing at -0.8333 degrees | No result for polar day/night | +| Limb transitions | Bracketed 56-step boundary solve for each limb | Tithi, Nakshatra, Yoga, and Karana independently | +| Muhurta / Choghadiya / Hora | Proportional local day and night divisions | Requires real sunrise, sunset, and next sunrise | +| Rahu Kala / Yamaganda / Gulika | Weekday-specific eighths of local daylight | Requires real sunrise and sunset | +| Abhijit | Eighth of 15 daylight muhurtas | Scales with daylight; omitted on Wednesday | +| Brahma Muhurta | 96 to 48 minutes before sunrise | Requires real sunrise | +| Dur Muhurta | Weekday-specific day/night division table | Tuesday second period uses the night division | + +The numerical boundary solver converges to sub-second resolution. That describes +the solver, not the absolute accuracy of the compact astronomical model. The UI +uses a conservative plus-or-minus 12 minute validation envelope for externally +published civil-time comparisons. + +## Regression evidence + +The native test target includes independent fixtures whose expected values are +recorded beside their sources: + +- Sunrise and sunset: New Delhi, Tokyo, Los Angeles, and New York on a DST + transition day. +- Five-limb transitions: Paris on 21 July 2026, covering Tithi, Nakshatra, Yoga, + and Karana. +- Rahu Kala, Yamaganda, Gulika, and Dur Muhurta: published Mumbai Friday + fixtures, plus Hyderabad Tuesday Dur Muhurta including its night period. +- Abhijit Muhurta: Mumbai on Friday, plus the Wednesday omission rule. +- Boundary invariants: multiple seasons and time zones verify that every solved + transition changes from its declared current value to its declared next value. +- Civil-time invariants: selected-day preservation east and west of UTC, DST day + advancement, civil-day preservation when changing locations, and explicit + polar fallback behavior. + +Run the evidence with: + +```bash +xcodebuild \ + -project CosmicRituals.xcodeproj \ + -scheme CosmicRituals \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + CODE_SIGNING_ALLOWED=NO \ + test +``` + +## Deliberately not claimed as reference-grade + +The repository preserves experimental festival, extended Vedic-calendar, +moonrise/moonset, and low-precision Graha-position work for future development. +Those calculations are not routed into the shipping navigation and must not be +described as available or reference-grade until they have independent fixtures, +regional rule handling where applicable, and visible precision disclosures. + +The WidgetKit source is also not a shipping widget until an extension target, +App Group, signing capability, and end-to-end tests are present. + +## Practical limits + +- Atmospheric refraction, observer elevation, terrain, and local horizon + obstruction can shift observed sunrise and sunset. +- A compact offline ephemeris is not a replacement for JPL or Swiss Ephemeris + data when arcsecond-level planetary positions are required. +- Festival and vrata observance can depend on regional school, sunrise/tithi + precedence, and event-specific rules; a simple date or tithi lookup is not + sufficient. +- Ritual interpretation is traditional and symbolic. For consequential + ceremonial timing, users should confirm the chosen convention with a qualified + practitioner. diff --git a/ios/native/APP_STORE_SUBMISSION.md b/ios/native/APP_STORE_SUBMISSION.md new file mode 100644 index 0000000..35faf99 --- /dev/null +++ b/ios/native/APP_STORE_SUBMISSION.md @@ -0,0 +1,163 @@ +# App Store submission copy · version 1.0 (build 5) + +> **Status: TESTING ONLY.** This is a release-preparation draft, not authorization +> to select a build for the App Store version, add the version or subscriptions for +> review, submit for App Review or Beta App Review, or release publicly. Keep the +> App Store version on manual release until explicit approval is given. + +This file is the source of truth for the first Premium release. Preserve the existing +App Store Connect contact name, phone number, and email; do not invent reviewer contact +details. + +## Current testing snapshot + +Verified in App Store Connect on July 28, 2026: + +- App version 1.0 is **Prepare for Submission**, has no App Store build selected, has + no product-page screenshots or release metadata populated, and uses **manual release**. +- TestFlight build 1.0 (5) finished processing and is **Testing**. The internal group + has two testers; the existing external testing group also has two testers and no + public link. No App Review or new Beta App Review action was taken while preparing + this checklist. +- Subscription group `22270515` (`Cosmic Rituals Premium`) and both products remain + **Prepare for Submission** and have not been added for review. +- Annual Apple ID `6795562945`: USD 49.99 base price, all 175 storefronts, free for + the first two weeks from July 28, 2026 with no configured end date. +- Monthly Apple ID `6795566735`: USD 6.99 base price, all 175 storefronts, free for + the first two weeks from July 28, 2026 with no configured end date. + +This is a dated evidence snapshot. Re-check every mutable App Store Connect status +before any future submission. + +## Product page + +**Name:** Cosmic Rituals + +**Subtitle:** Panchang, Muhurta & Pooja + +**Promotional text:** Plan sacred time with a location-aware Panchang, transparent +muhurta guidance, and 12 sourced, step-by-step Pooja Vidhis that remain available +offline. + +**Keywords:** + +`panchang,muhurta,puja,pooja,vidhi,hindu,vedic,nakshatra,tithi,calendar,mantra,festival` + +**Description:** + +Cosmic Rituals brings precise sacred-time planning and careful household practice into +one private, native app. + +LOCATION-AWARE PANCHANG + +Choose an offline city or use Current Location to calculate Vara, Tithi, Nakshatra, +Yoga, Karana, sunrise, sunset, and independently solved transition times for the civil +day and time zone you selected. + +MUHURTA INTELLIGENCE + +Explore all 15 daytime and 15 nighttime muhurtas, along with Rahu Kala, Yamaganda, +Gulika, Abhijit, Dur Muhurta, Choghadiya, and Hora. Current intervals are highlighted +against the clock, while polar-day limitations and calculation fallbacks are disclosed +instead of hidden. + +12 OFFLINE POOJA VIDHIS + +Search daily, deity, festival, vrata, life-event, and planetary guides. Each guide +provides preparation, a material checklist, ordered steps, optional short public +mantras in Devanagari and transliteration, plain-language meanings, safety notes, +respectful closure, source notes, and a focused guided mode. + +TRADITION-AWARE BY DESIGN + +Household guides are clearly separated from priest-recommended rites. Family, temple, +regional, and sampradaya practice takes precedence. Cosmic Rituals does not synthesize +initiatory bija-mantras, nyasa, homa, formal kalasha installation, or supposedly +universal priestly procedures. + +PRIVATE AND ACCESSIBLE + +Calculations, saved location preferences, Pooja searches, and checklists remain on your +device. There are no advertising, tracking, analytics, or account SDKs. The interface +supports Dynamic Type, VoiceOver labels, Reduce Motion, light and dark appearances, +iPhone, and iPad. + +Cosmic Rituals Premium is available as monthly and annual auto-renewable subscriptions. +Eligible customers receive the introductory offer shown by the App Store. Payment, +renewal, eligibility, cancellation, and restoration are handled securely through Apple. + +Traditional calendars and ceremonial practice vary. For consequential timing or formal +rites, confirm the convention with a qualified practitioner. + +**What’s New in 1.0:** + +- Location-aware Panchang with independently solved transitions +- All 30 day and night muhurtas, calendar, Hora, and Choghadiya +- 12 sourced offline Pooja Vidhis with checklists and guided mode +- Private on-device calculations and accessible cosmic-glass design +- Monthly and annual Premium plans with an eligible 14-day introductory trial + +## URLs + +- Privacy Policy: +- Terms of Use: +- Support URL: +- Marketing URL: + +## App Review notes + +Cosmic Rituals does not require an account or demo credentials. + +On first launch, the app presents the native StoreKit subscription screen. Use either +the monthly or annual sandbox product. An eligible sandbox Apple Account will see the +two-week free introductory offer; an ineligible account correctly sees standard +pricing without a free-trial claim. + +Products: + +- `com.cosmic.rituals.premium.annual` +- `com.cosmic.rituals.premium.monthly` + +After purchase, the main experience unlocks immediately. Restore Purchases is available +on the subscription screen. Subscription management, Privacy, Terms, and Support are +also available from the in-app appearance/settings sheet. + +For location testing, choose the visible offline city flow; precise location permission +is optional. The Pooja destination is the fourth item in the main navigation. Its 12 +guides, sources, materials, and guided steps work offline after access is unlocked. + +The app uses StoreKit-signed `Transaction.currentEntitlements`; it has no local trial +timer or reviewer bypass. It uses only Apple-provided encryption and declares no +non-exempt encryption. + +## Subscription review metadata + +| Product | Display name | Customer description | +| --- | --- | --- | +| Annual | Premium Annual | Full access, billed yearly after trial. | +| Monthly | Premium Monthly | Full access, billed monthly after trial. | + +Use the branded subscription screen as the App Review Screenshot for both products. +The screenshot is for review only and must clearly show both plans and the service being +offered. The first auto-renewable subscriptions must be submitted with app version 1.0. + +## Privacy and compliance answers + +- **App Privacy:** No, the developer and third-party partners do not collect data from + this app. Location, date, time zone, searches, preferences, and calculations are + processed on device; Apple separately processes App Store purchases. +- **Tracking:** No. +- **Advertising identifier:** Not used. +- **Export compliance:** No non-exempt encryption. The generated Info.plist includes + `ITSAppUsesNonExemptEncryption = NO`. +- **Login:** None. +- **User-generated content, chat, social networking, ads, gambling, contests, medical + treatment, violence, sexual content, controlled substances:** None. +- **Unrestricted web access:** No. User-initiated links open bounded policy, source, + support, and Apple subscription-management destinations. +- **Release:** Manual release. Keep version 1.0 unsubmitted and do not make it publicly + available until explicit approval is given after testing. + +Content-rights or new contract attestations are account-holder decisions. Confirm them +at the point App Store Connect presents the legal statement; do not infer acceptance +from this engineering checklist. diff --git a/ios/native/ARCHITECTURE.md b/ios/native/ARCHITECTURE.md index 7d983a7..f61d384 100644 --- a/ios/native/ARCHITECTURE.md +++ b/ios/native/ARCHITECTURE.md @@ -13,12 +13,14 @@ CosmicRituals/ ├── Engine/ ← pure Foundation, no SwiftUI │ ├── PanchangModels.swift ZodiacSign, Panchang, NakshatraResult, Muhurta(+Quality), CelestialBody │ ├── CosmicEngine.swift Panchang-focused ephemeris (sidereal, Lahiri ayanamsha) -│ └── MuhurtaLibrary.swift rich per-muhurta detail: deity, resonance, favourable/avoid +│ ├── MuhurtaLibrary.swift rich per-muhurta detail: deity, resonance, favourable/avoid +│ └── PoojaVidhiCatalog.swift offline ritual models, search, validation, content policy ├── Theme/ │ └── CosmicTheme.swift shared cosmic-glass palette + reusable components └── Views/ ├── RootView.swift hosts PanchangView in the cosmic theme ├── PanchangView.swift Five Limbs + 30 Muhurtas, with live "now" timing + ├── PoojaVidhiViews.swift library, material checklist, detail + guided ritual flow └── MuhurtaDetailView.swift tap-through sheet: deity, description, favourable/avoid ``` @@ -45,34 +47,60 @@ brought over: - **`PanchangModels.swift`** is the *Panchang slice* of the parent `AstroModel.swift` — only the value types the Panchang screen touches. `CelestialBody` is retained because a nakshatra reports its (dasha) lord. -- **`CosmicTheme.swift`** is the shared theme/component library, copied verbatim so the - app looks identical to its siblings. -- **`PanchangView.swift`** is copied verbatim from the parent. +- **`CosmicTheme.swift`** began from the shared theme/component library and now carries + app-specific semantic colors, responsive navigation, and motion safeguards. +- **`PanchangView.swift`** began from the parent surface and is now independently + maintained around the location-aware calculation contract described below. ## Data flow -`RootView` hosts `PanchangView`, which owns a `selectedDate` and a sub-tab selection -(Five Limbs / 30 Muhurtas). On appear and on any date change it asks the engine for the -day's facts — the computation is cheap and side-effect-free, so there is no view model -or persistence. +`RootView` hosts `PanchangView`, which owns a selected civil date, destination, +persisted experience/theme preferences, and a `LocationManager`. A +`CalculationContext` binds that date to explicit coordinates and an IANA time zone. +The daily Panchang snapshot is cached with its context so SwiftUI updates never show +values from a previous location while recomputation is in flight. ``` -selectedDate ──▶ CosmicEngine.getPanchang(date:) ──▶ Five Limbs section - └─▶ CosmicEngine.getMuhurtas(date:) ──▶ 30 Muhurtas section (live "now") +selectedDate + RitualLocation ──▶ CalculationContext + ├─▶ sunrise-anchored five limbs + ├─▶ four independent transition solves + ├─▶ solar / kala / choghadiya / hora schedules + └─▶ 30 day + night muhurtas ``` +Pooja guidance is deliberately independent of the ephemeris. `PoojaVidhiCatalog` is +pure Foundation data with deterministic search and executable validation. +`PoojaVidhiLibraryView` renders that catalog, while `PoojaVidhiDetailView` and +`GuidedPoojaView` own only ephemeral checklist/progress state. No ritual completion, +search text, or religious preference leaves the device. + +The catalog distinguishes `simpleHousehold`, `extendedHousehold`, and +`priestRecommended` practice. Formal homa, nyasa, kalasha installation, visarjana, +initiatory mantras, and supposedly universal priestly procedures are never synthesized. +See [POOJA_CONTENT.md](POOJA_CONTENT.md). + +Premium access is owned by `SubscriptionStore`. It accepts only verified current +StoreKit entitlements for the configured monthly or annual product, observes transaction +updates, and preserves access when product merchandising metadata is temporarily +unavailable. `RootView` owns this app-level service and selects the Premium gate or the +main Panchang experience. App Intents check the same StoreKit entitlement boundary. +Trial eligibility and localized prices come from App Store Connect, never device time. + ## The engine `CosmicEngine` is a sidereal Vedic ephemeris using the **Lahiri (Chitra Paksha)** ayanamsha and Meeus algorithms (Sun §25, Moon §47 47-term series). `getPanchang` derives the five limbs from the Sun/Moon sidereal longitudes: **tithi** (12° elongation steps), **nakshatra** (Moon's 13°20′ segment + pada), **yoga** (Sun+Moon), **karana** (half-tithi), -and **vara** (weekday). `getMuhurtas` divides the local day/night (from NOAA sunrise/sunset) -into 15 + 15 named windows, each tagged with its classical auspiciousness and marked -`isCurrent` against the clock. - -> Muhurta timings use a Delhi default location (28.61° N, 77.21° E). For other locations, -> pass `latDeg`/`lonDeg` to `getMuhurtas`. +and **vara** (weekday). Each changing limb has a separately solved next boundary. +`getMuhurtas` divides the local day/night (from NOAA sunrise/sunset) into 15 + 15 +named windows, each tagged with its classical auspiciousness and marked `isCurrent` +against the clock. See [ACCURACY.md](ACCURACY.md) for the calculation contract, +fixtures, tolerances, and quarantined prototypes. + +> New Delhi is the visible first-run default, not a hidden calculation fallback. +> Once the user chooses a city or current location, every daily calculation, export, +> and App Intent uses that explicit saved context. ## Theme diff --git a/ios/native/ART_ASSET_PROVENANCE.md b/ios/native/ART_ASSET_PROVENANCE.md new file mode 100644 index 0000000..35bae0b --- /dev/null +++ b/ios/native/ART_ASSET_PROVENANCE.md @@ -0,0 +1,26 @@ +# Cosmic Rituals Art Asset Provenance + +## Ritual sanctuary background + +- **Asset:** `CosmicRituals/Assets.xcassets/RitualSanctuaryBackground.imageset/ritual-sanctuary-background.jpg` +- **Created:** 2026-07-28 +- **Generator:** OpenAI built-in ImageGen +- **Purpose:** Full-screen ambient artwork behind Panchang, Pooja Vidhi, guided-practice, Muhurta, detail, and subscription surfaces. +- **Post-processing:** Converted from the generated PNG to JPEG at quality 88 with `sips`; dimensions remain 853 by 1844 pixels. +- **Content boundary:** Decorative atmosphere only. The image is not evidence for ritual instructions, regional practice, Panchang calculations, or any claim in the app. + +### Final generation prompt + +```text +Use case: stylized-concept +Asset type: full-screen portrait background artwork for a premium native iOS app named Cosmic Rituals +Primary request: create an elegant, immersive household Hindu ritual sanctuary background that immediately communicates pooja preparation, Panchang timing, and guided sacred practice +Scene/backdrop: a quiet Indian home altar at the threshold of dawn, with one small brass ghee lamp glowing, a restrained brass pooja thali, a few marigold and lotus petals, sandalwood, kumkum, rice grains, a small bell, and a subtle kalash silhouette; warm dawn light falls through an out-of-focus carved doorway +Style/medium: sophisticated cinematic editorial photography with a slightly painterly fine-art finish, authentic natural materials, premium but humble, contemplative and respectful +Composition/framing: vertical phone wallpaper, approximately 9:19.5; low-detail dark central field for highly legible app content; ritual objects placed mainly along the lower edge and outer corners; soft upper glow with generous negative space; no single object dominates +Lighting/mood: reverent, calm, intimate, trustworthy, warm diya amber and sandalwood brown with restrained kumkum red and ivory highlights +Color palette: deep sandalwood brown, burnt sienna, ghee-lamp gold, kumkum red, aged brass, warm ivory +Materials/textures: natural brass patina, cotton wick, stone or dark wood altar, flower petals, faint incense atmosphere kept subtle +Constraints: original artwork; culturally respectful generic household pooja setting; background must remain quiet enough for white and cream interface text after a dark overlay; avoid region-specific claims; no people; no deity faces or idols; no readable scriptures or symbols; no text; no logos; no watermark +Avoid: stars, galaxies, zodiac, planets, crescent moon, astrology charts, sci-fi, neon, purple cosmic palette, excessive smoke, flames larger than a small diya, crowded objects, kitsch festival decoration, artificial symmetry, blurry low-quality output +``` diff --git a/ios/native/CosmicRituals.xcodeproj/project.pbxproj b/ios/native/CosmicRituals.xcodeproj/project.pbxproj index 5045e93..e594f27 100644 --- a/ios/native/CosmicRituals.xcodeproj/project.pbxproj +++ b/ios/native/CosmicRituals.xcodeproj/project.pbxproj @@ -294,13 +294,63 @@ }; name = Release; }; + C05B000000000000000000C3 /* TestFlight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = TestFlight; + }; C05B000000000000000000C9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = DJJ824N6RB; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -331,7 +381,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = DJJ824N6RB; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -356,6 +406,38 @@ }; name = Release; }; + C05B000000000000000000CB /* TestFlight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 7; + DEVELOPMENT_TEAM = DJJ824N6RB; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Cosmic Rituals uses your location to calculate precise local muhurta times based on your sunrise and sunset."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cosmic.rituals; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "TESTFLIGHT_BETA_ACCESS $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = TestFlight; + }; C05B000000000000000001C9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -388,6 +470,22 @@ }; name = Release; }; + C05B000000000000000001CB /* TestFlight */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = DJJ824N6RB; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cosmic.rituals.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CosmicRituals.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CosmicRituals"; + }; + name = TestFlight; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -396,6 +494,7 @@ buildConfigurations = ( C05B000000000000000000C1 /* Debug */, C05B000000000000000000C2 /* Release */, + C05B000000000000000000C3 /* TestFlight */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -405,6 +504,7 @@ buildConfigurations = ( C05B000000000000000000C9 /* Debug */, C05B000000000000000000CA /* Release */, + C05B000000000000000000CB /* TestFlight */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -414,6 +514,7 @@ buildConfigurations = ( C05B000000000000000001C9 /* Debug */, C05B000000000000000001CA /* Release */, + C05B000000000000000001CB /* TestFlight */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/ios/native/CosmicRituals.xcodeproj/xcshareddata/xcschemes/CosmicRituals.xcscheme b/ios/native/CosmicRituals.xcodeproj/xcshareddata/xcschemes/CosmicRituals.xcscheme index 5ee98c0..d1d9987 100644 --- a/ios/native/CosmicRituals.xcodeproj/xcshareddata/xcschemes/CosmicRituals.xcscheme +++ b/ios/native/CosmicRituals.xcodeproj/xcshareddata/xcschemes/CosmicRituals.xcscheme @@ -75,6 +75,9 @@ ReferencedContainer = "container:CosmicRituals.xcodeproj"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/native/CosmicRituals/App/CosmicAppIntents.swift b/ios/native/CosmicRituals/App/CosmicAppIntents.swift index 84fd16f..f97c0ac 100644 --- a/ios/native/CosmicRituals/App/CosmicAppIntents.swift +++ b/ios/native/CosmicRituals/App/CosmicAppIntents.swift @@ -16,6 +16,16 @@ private func shortTime(_ date: Date, in timeZone: TimeZone) -> String { return formatter.string(from: date) } +private func spokenTransition( + _ transition: PanchangTransition?, + relativeTo referenceDate: Date, + in timeZone: TimeZone +) -> String { + guard let transition else { return "transition unavailable" } + let ending = transition.endTime.ritualTransitionLabel(relativeTo: referenceDate, in: timeZone) + return "\(transition.currentName) until \(ending), then \(transition.nextName)" +} + // MARK: - "What's the muhurta right now?" Intent struct CurrentMuhurtaIntent: AppIntent { @@ -24,6 +34,10 @@ struct CurrentMuhurtaIntent: AppIntent { @MainActor func perform() async throws -> some ReturnsValue & ProvidesDialog { + guard await SubscriptionEntitlementChecker.grantsCurrentAccess() else { + let reply = "Open Cosmic Rituals to start or restore Premium before using this shortcut." + return .result(value: reply, dialog: IntentDialog(stringLiteral: reply)) + } guard let (context, location) = IntentCalculationContext.resolve(for: Date()) else { let reply = "Open Cosmic Rituals and choose a calculation location before using this shortcut." return .result(value: reply, dialog: IntentDialog(stringLiteral: reply)) @@ -47,16 +61,23 @@ struct CurrentMuhurtaIntent: AppIntent { struct TodayPanchangIntent: AppIntent { static let title: LocalizedStringResource = "Daily Panchang Snapshot" - static let description = IntentDescription("Get the five limbs sampled at local noon: Vara, Tithi, Nakshatra, Yoga, and Karana.") + static let description = IntentDescription("Get the five limbs at local sunrise, with their next transition times.") @MainActor func perform() async throws -> some ReturnsValue & ProvidesDialog { + guard await SubscriptionEntitlementChecker.grantsCurrentAccess() else { + let reply = "Open Cosmic Rituals to start or restore Premium before using this shortcut." + return .result(value: reply, dialog: IntentDialog(stringLiteral: reply)) + } guard let (context, location) = IntentCalculationContext.resolve(for: Date()) else { let reply = "Open Cosmic Rituals and choose a calculation location before using this shortcut." return .result(value: reply, dialog: IntentDialog(stringLiteral: reply)) } let p = CosmicEngine.getPanchang(context: context) - let summary = "Daily reference for \(location.name), sampled at 12:00 PM local time. \(p.weekdayName). Tithi: \(p.tithiName). Nakshatra: \(p.nakshatraName). Yoga: \(p.yogaName). Karana: \(p.karanaName)." + let reference = p.sunriseTime == nil + ? "Sunrise is unavailable; this snapshot uses \(shortTime(p.date, in: context.timeZone)) local time." + : "The Panchang day begins at local sunrise." + let summary = "Daily reference for \(location.name). \(reference) \(p.weekdayName). Tithi: \(spokenTransition(p.transitions.tithi, relativeTo: p.date, in: context.timeZone)). Nakshatra: \(spokenTransition(p.transitions.nakshatra, relativeTo: p.date, in: context.timeZone)). Yoga: \(spokenTransition(p.transitions.yoga, relativeTo: p.date, in: context.timeZone)). Karana: \(spokenTransition(p.transitions.karana, relativeTo: p.date, in: context.timeZone))." return .result(value: summary, dialog: IntentDialog(stringLiteral: summary)) } } @@ -69,6 +90,10 @@ struct NextAuspiciousTimeIntent: AppIntent { @MainActor func perform() async throws -> some ReturnsValue & ProvidesDialog { + guard await SubscriptionEntitlementChecker.grantsCurrentAccess() else { + let reply = "Open Cosmic Rituals to start or restore Premium before using this shortcut." + return .result(value: reply, dialog: IntentDialog(stringLiteral: reply)) + } let now = Date() guard let (context, location) = IntentCalculationContext.resolve(for: now) else { let reply = "Open Cosmic Rituals and choose a calculation location before using this shortcut." @@ -110,7 +135,7 @@ struct CosmicShortcuts: AppShortcutsProvider { intent: TodayPanchangIntent(), phrases: [ "Daily Panchang snapshot in \(.applicationName)", - "Panchang noon reference in \(.applicationName)", + "Panchang sunrise reference in \(.applicationName)", "Vedic day snapshot in \(.applicationName)" ], shortTitle: "Daily Snapshot", diff --git a/ios/native/CosmicRituals/App/CosmicRitualsApp.swift b/ios/native/CosmicRituals/App/CosmicRitualsApp.swift index 4253d66..b0a5e2d 100644 --- a/ios/native/CosmicRituals/App/CosmicRitualsApp.swift +++ b/ios/native/CosmicRituals/App/CosmicRitualsApp.swift @@ -2,8 +2,10 @@ import SwiftUI /// Cosmic Rituals — a native SwiftUI app for the Vedic Panchang: the five limbs of /// the day (tithi, nakshatra, yoga, karana, vara), the Moon's nakshatra & pada, and -/// the thirty day & night muhurtas with live timing — presented in the shared -/// cosmic-glass theme. Day-level limbs are explicitly disclosed as a noon snapshot. +/// the thirty day & night muhurtas with live timing, and an offline, tradition-aware +/// Pooja Vidhi library — presented in the shared cosmic-glass theme. Day-level limbs +/// are anchored to the selected location's sunrise, with an explicit local-noon +/// fallback when sunrise is unavailable. @main struct CosmicRitualsApp: App { var body: some Scene { diff --git a/ios/native/CosmicRituals/Assets.xcassets/RitualSanctuaryBackground.imageset/Contents.json b/ios/native/CosmicRituals/Assets.xcassets/RitualSanctuaryBackground.imageset/Contents.json new file mode 100644 index 0000000..6bd0101 --- /dev/null +++ b/ios/native/CosmicRituals/Assets.xcassets/RitualSanctuaryBackground.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ritual-sanctuary-background.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/native/CosmicRituals/Assets.xcassets/RitualSanctuaryBackground.imageset/ritual-sanctuary-background.jpg b/ios/native/CosmicRituals/Assets.xcassets/RitualSanctuaryBackground.imageset/ritual-sanctuary-background.jpg new file mode 100644 index 0000000..1abe226 Binary files /dev/null and b/ios/native/CosmicRituals/Assets.xcassets/RitualSanctuaryBackground.imageset/ritual-sanctuary-background.jpg differ diff --git a/ios/native/CosmicRituals/Engine/CalculationContext.swift b/ios/native/CosmicRituals/Engine/CalculationContext.swift index edf4bdb..5885cf0 100644 --- a/ios/native/CosmicRituals/Engine/CalculationContext.swift +++ b/ios/native/CosmicRituals/Engine/CalculationContext.swift @@ -6,8 +6,7 @@ import Foundation /// the calendar and time zone beside the coordinates prevents that selected day from /// silently becoming the previous UTC day in eastern time zones. struct CalculationContext: Equatable, Sendable { - static let dailySnapshotReferenceHour = 12 - static let dailySnapshotDisclosure = "Daily Panchang reference · sampled at 12:00 PM local time" + static let polarFallbackReferenceHour = 12 let localDay: Date let latitude: Double @@ -45,12 +44,12 @@ struct CalculationContext: Equatable, Sendable { calendar.startOfDay(for: localDay) } - /// A deterministic instant for day-level lunar calculations. + /// Deterministic fallback for dates and latitudes where sunrise is unavailable. /// Noon exists on ordinary DST-transition days and does not inherit a hidden /// time component from whichever date picker last changed the selection. var localNoon: Date { calendar.date( - bySettingHour: Self.dailySnapshotReferenceHour, + bySettingHour: Self.polarFallbackReferenceHour, minute: 0, second: 0, of: localDayStart @@ -76,6 +75,24 @@ struct CalculationContext: Equatable, Sendable { /// formatted in the active calculation location's time zone, never implicitly in /// the device zone. extension Date { + /// Reinterprets the source location's year-month-day in another time zone. + /// The selected Panchang value is a civil day, not an instant; using local + /// noon avoids nonexistent-midnight edge cases on historical clock changes. + func ritualCivilDay( + preservingDateFrom sourceTimeZone: TimeZone, + into targetTimeZone: TimeZone + ) -> Date { + var sourceCalendar = Calendar(identifier: .gregorian) + sourceCalendar.timeZone = sourceTimeZone + let day = sourceCalendar.dateComponents([.year, .month, .day], from: self) + + var targetCalendar = Calendar(identifier: .gregorian) + targetCalendar.timeZone = targetTimeZone + var targetComponents = day + targetComponents.hour = CalculationContext.polarFallbackReferenceHour + return targetCalendar.date(from: targetComponents) ?? self + } + func ritualShortTime(in timeZone: TimeZone, locale: Locale = .autoupdatingCurrent) -> String { let formatter = DateFormatter() formatter.locale = locale @@ -105,4 +122,19 @@ extension Date { formatter.setLocalizedDateFormatFromTemplate(template) return formatter.string(from: self) } + + /// A clock time for same-day events and an unambiguous civil date plus time + /// when an astronomical transition falls after midnight in the active place. + func ritualTransitionLabel( + relativeTo referenceDate: Date, + in timeZone: TimeZone, + locale: Locale = .autoupdatingCurrent + ) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let time = ritualShortTime(in: timeZone, locale: locale) + guard !calendar.isDate(self, inSameDayAs: referenceDate) else { return time } + let civilDate = ritualDate(template: "EEE d MMM", in: timeZone, locale: locale) + return "\(civilDate) · \(time)" + } } diff --git a/ios/native/CosmicRituals/Engine/CosmicEngine.swift b/ios/native/CosmicRituals/Engine/CosmicEngine.swift index 1c2bee5..ce99c18 100644 --- a/ios/native/CosmicRituals/Engine/CosmicEngine.swift +++ b/ios/native/CosmicRituals/Engine/CosmicEngine.swift @@ -207,17 +207,44 @@ enum CosmicEngine { // MARK: - Panchang - static func getPanchang(context: CalculationContext) -> Panchang { - getPanchang(date: context.localNoon, calendar: context.calendar) + static func getPanchang( + context: CalculationContext, + includeTransitions: Bool = true + ) -> Panchang { + let solar = getSunriseSunset(context: context) + let referenceDate = solar?.sunrise ?? context.localNoon + return getPanchang( + date: referenceDate, + calendar: context.calendar, + sunriseTime: solar?.sunrise, + sunsetTime: solar?.sunset, + transitions: includeTransitions ? getPanchangTransitions(after: referenceDate) : .unavailable + ) + } + + private static func panchangReferenceDate(for context: CalculationContext) -> Date { + getSunriseSunset(context: context)?.sunrise ?? context.localNoon } static func getPanchang(date: Date, timezoneIdentifier: String = "UTC") -> Panchang { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: timezoneIdentifier) ?? .gmt - return getPanchang(date: date, calendar: calendar) + return getPanchang( + date: date, + calendar: calendar, + sunriseTime: nil, + sunsetTime: nil, + transitions: .unavailable + ) } - private static func getPanchang(date: Date, calendar: Calendar) -> Panchang { + private static func getPanchang( + date: Date, + calendar: Calendar, + sunriseTime: Date?, + sunsetTime: Date?, + transitions: PanchangTransitions + ) -> Panchang { let jd = julianDateFromDate(date) let sunLon = siderealize(sunLongitude(jd: jd), jd: jd) let moonLon = siderealize(moonLongitude(jd: jd), jd: jd) @@ -255,8 +282,9 @@ enum CosmicEngine { weekdayName: weekdayNames[(weekday - 1).clamped(to: 0...6)], moonSignIndex: moonSignIdx, moonSignName: ZodiacSign.fromIndex(moonSignIdx).name, - sunriseTime: nil, - sunsetTime: nil + sunriseTime: sunriseTime, + sunsetTime: sunsetTime, + transitions: transitions ) } @@ -267,6 +295,124 @@ enum CosmicEngine { return raw - 50 // 57 Shakuni, 58 Chatushpada, 59 Naga } + // MARK: - Panchang limb boundaries + + /// Solves the next boundary of every changing Panchang limb from an exact + /// reference instant. Each angle is monotonic over the short search window, + /// so a bracketed binary search gives sub-second numerical precision without + /// coupling the result to the UI's refresh cadence. + static func getPanchangTransitions(after date: Date) -> PanchangTransitions { + PanchangTransitions( + tithi: nextTransition(for: .tithi, after: date), + nakshatra: nextTransition(for: .nakshatra, after: date), + yoga: nextTransition(for: .yoga, after: date), + karana: nextTransition(for: .karana, after: date) + ) + } + + private struct LimbTransitionState { + let angle: Double + let segmentSize: Double + let currentName: String + let nextName: String + } + + private static func nextTransition( + for kind: PanchangLimbKind, + after date: Date + ) -> PanchangTransition? { + let initial = limbTransitionState(for: kind, at: date) + let remainder = initial.angle.truncatingRemainder(dividingBy: initial.segmentSize) + let distanceToBoundary = remainder < 1e-10 + ? initial.segmentSize + : initial.segmentSize - remainder + + func accumulatedAngle(at candidate: Date) -> Double { + let angle = limbTransitionState(for: kind, at: candidate).angle + return normalize360(angle - initial.angle) + } + + var lower = date + var upper = date.addingTimeInterval(36 * 3_600) + while accumulatedAngle(at: upper) < distanceToBoundary, + upper.timeIntervalSince(date) < 72 * 3_600 { + upper = upper.addingTimeInterval(12 * 3_600) + } + guard accumulatedAngle(at: upper) >= distanceToBoundary else { return nil } + + for _ in 0..<56 { + let midpoint = lower.addingTimeInterval(upper.timeIntervalSince(lower) / 2) + if accumulatedAngle(at: midpoint) >= distanceToBoundary { + upper = midpoint + } else { + lower = midpoint + } + } + + return PanchangTransition( + kind: kind, + currentName: initial.currentName, + nextName: initial.nextName, + endTime: lower.addingTimeInterval(upper.timeIntervalSince(lower) / 2) + ) + } + + private static func limbTransitionState( + for kind: PanchangLimbKind, + at date: Date + ) -> LimbTransitionState { + let jd = julianDateFromDate(date) + let sunSidereal = siderealize(sunLongitude(jd: jd), jd: jd) + let moonSidereal = siderealize(moonLongitude(jd: jd), jd: jd) + let elongation = normalize360(moonSidereal - sunSidereal) + + let angle: Double + let segmentSize: Double + let segmentCount: Int + switch kind { + case .tithi: + angle = elongation + segmentSize = 12 + segmentCount = 30 + case .nakshatra: + angle = moonSidereal + segmentSize = 360.0 / 27.0 + segmentCount = 27 + case .yoga: + angle = normalize360(sunSidereal + moonSidereal) + segmentSize = 360.0 / 27.0 + segmentCount = 27 + case .karana: + angle = elongation + segmentSize = 6 + segmentCount = 60 + } + + let rawIndex = Int(angle / segmentSize).clamped(to: 0...(segmentCount - 1)) + let nextRawIndex = (rawIndex + 1) % segmentCount + let names: (current: String, next: String) + switch kind { + case .tithi: + names = (Panchang.tithiNames[rawIndex], Panchang.tithiNames[nextRawIndex]) + case .nakshatra: + names = (Panchang.nakshatraNames[rawIndex], Panchang.nakshatraNames[nextRawIndex]) + case .yoga: + names = (Panchang.yogaNames[rawIndex], Panchang.yogaNames[nextRawIndex]) + case .karana: + names = ( + Panchang.karanaNames[karanaIndex(forHalfTithiIndex: rawIndex)], + Panchang.karanaNames[karanaIndex(forHalfTithiIndex: nextRawIndex)] + ) + } + + return LimbTransitionState( + angle: angle, + segmentSize: segmentSize, + currentName: names.current, + nextName: names.next + ) + } + // MARK: - Sunrise / Sunset (NOAA / Meeus ch.15) /// Returns sunrise and sunset as UTC hour-of-day (e.g. 6.5 = 06:30 UTC) @@ -394,7 +540,7 @@ enum CosmicEngine { } static func getMoonNakshatraPada(context: CalculationContext) -> NakshatraResult { - getMoonNakshatraPada(date: context.localNoon) + getMoonNakshatraPada(date: panchangReferenceDate(for: context)) } /// Returns sunrise and sunset as `Date` values for a given day and location. @@ -421,6 +567,36 @@ enum CosmicEngine { )) } + // MARK: - Sunrise-based daily observances + + /// Brahma Muhurta is the traditional 48-minute muhurta beginning two + /// muhurtas before sunrise and ending one muhurta before sunrise. + static func getBrahmaMuhurta(context: CalculationContext) -> (start: Date, end: Date)? { + guard let sunrise = getSunriseSunset(context: context)?.sunrise else { return nil } + return ( + start: sunrise.addingTimeInterval(-96 * 60), + end: sunrise.addingTimeInterval(-48 * 60) + ) + } + + /// Abhijit is the eighth of fifteen daylight muhurtas, centered on local + /// apparent noon. It scales with daylight rather than assuming a fixed + /// 48-minute clock interval. Classical daily Panchang practice does not + /// present it as an auspicious window on Wednesday. + static func getAbhijitMuhurta(context: CalculationContext) -> (start: Date, end: Date)? { + guard context.localDayComponents.weekday != 4, + let solar = getSunriseSunset(context: context) else { + return nil + } + let daylight = solar.sunset.timeIntervalSince(solar.sunrise) + let muhurtaLength = daylight / 15.0 + let solarNoon = solar.sunrise.addingTimeInterval(daylight / 2.0) + return ( + start: solarNoon.addingTimeInterval(-muhurtaLength / 2.0), + end: solarNoon.addingTimeInterval(muhurtaLength / 2.0) + ) + } + // MARK: - Choghadiya (Vedic day-segments) static func getChoghadiya(context: CalculationContext) -> [Choghadiya] { @@ -517,8 +693,11 @@ enum CosmicEngine { )) } - // MARK: - Moonrise / Moonset (approximate; Meeus §15 adapted) + // MARK: - Moonrise / Moonset prototype (not surfaced) + /// Experimental approximation retained for research only. It is not + /// topocentric and must not be wired into a shipping surface without the + /// validation work listed in ACCURACY.md. static func getMoonriseMoonset(date: Date, latDeg: Double, lonDeg: Double) -> (moonrise: Date?, moonset: Date?) { var cal = Calendar(identifier: .gregorian) cal.timeZone = TimeZone(identifier: "UTC")! @@ -552,41 +731,17 @@ enum CosmicEngine { } static func getSunNakshatra(context: CalculationContext) -> NakshatraResult { - getSunNakshatra(date: context.localNoon) + getSunNakshatra(date: panchangReferenceDate(for: context)) } - // MARK: - Tithi End Time (binary search to next 12° elongation boundary) + // MARK: - Tithi End Time static func getTithiEndTime(date: Date) -> Date? { - let jd0 = julianDateFromDate(date) - - func elongRaw(_ jd: Double) -> Double { - let sL = siderealize(sunLongitude(jd: jd), jd: jd) - let mL = siderealize(moonLongitude(jd: jd), jd: jd) - return normalize360(mL - sL) - } - - let e0 = elongRaw(jd0) - let degsToNext = 12.0 - e0.truncatingRemainder(dividingBy: 12.0) - - // Monotonically increasing accumulated elongation since jd0 - func accumulated(_ jd: Double) -> Double { - let e = elongRaw(jd) - return e < e0 - 180 ? e + 360 - e0 : e - e0 - } - - var lo = jd0, hi = jd0 + 2.0 - guard accumulated(hi) >= degsToNext else { return nil } - - for _ in 0..<52 { - let mid = (lo + hi) / 2.0 - if accumulated(mid) >= degsToNext { hi = mid } else { lo = mid } - } - return date.addingTimeInterval(((lo + hi) / 2.0 - jd0) * 86400.0) + nextTransition(for: .tithi, after: date)?.endTime } static func getTithiEndTime(context: CalculationContext) -> Date? { - getTithiEndTime(date: context.localNoon) + nextTransition(for: .tithi, after: panchangReferenceDate(for: context))?.endTime } // MARK: - Chandra Bala & Tara Bala @@ -622,47 +777,84 @@ enum CosmicEngine { ) } - // MARK: - Gulika Kala slot (1-based, within 8 equal day divisions) + // MARK: - Sunrise-based inauspicious kalas + + /// Rahu Kala, Yamaganda, and Gulika Kala are weekday-specific selections + /// from eight equal daylight divisions. Keeping the tables in the engine + /// prevents presentation code from inventing clock-time fallbacks. + static func getRahuKala(context: CalculationContext) -> (start: Date, end: Date)? { + let slots = [8, 2, 7, 5, 6, 4, 3] // Sunday through Saturday + return daylightKala(context: context, slotsByWeekday: slots) + } + + static func getYamaganda(context: CalculationContext) -> (start: Date, end: Date)? { + let slots = [5, 4, 3, 2, 1, 7, 6] // Sunday through Saturday + return daylightKala(context: context, slotsByWeekday: slots) + } + + static func getGulikaKala(context: CalculationContext) -> (start: Date, end: Date)? { + let slots = [7, 6, 5, 4, 3, 2, 1] // Sunday through Saturday + return daylightKala(context: context, slotsByWeekday: slots) + } - static func gulikaSlot(weekday: String) -> Int { - switch weekday { - case "Sunday": return 7 - case "Monday": return 6 - case "Tuesday": return 5 - case "Wednesday": return 4 - case "Thursday": return 3 - case "Friday": return 2 - case "Saturday": return 1 - default: return 1 + private static func daylightKala( + context: CalculationContext, + slotsByWeekday: [Int] + ) -> (start: Date, end: Date)? { + guard slotsByWeekday.count == 7, + let weekday = context.localDayComponents.weekday, + (1...7).contains(weekday), + let solar = getSunriseSunset(context: context) else { + return nil } + let slotLength = solar.sunset.timeIntervalSince(solar.sunrise) / 8.0 + let slot = slotsByWeekday[weekday - 1] + let start = solar.sunrise.addingTimeInterval(Double(slot - 1) * slotLength) + return (start: start, end: start.addingTimeInterval(slotLength)) } - /// Returns the 1-2 classically inauspicious Dur Muhurta windows for the given date. - /// Indices are 0-based into the 15 day muhurtas; table from Muhurta Chintamani. + /// Returns the classically inauspicious Dur Muhurta windows for the Vedic day. + /// Indices are zero-based within the 15 daylight or 15 night divisions. + /// Tuesday's second period is a night division; treating every weekday entry + /// as a daylight slot produces a plausible but materially wrong result. static func getDurMuhurta(context: CalculationContext) -> [(start: Date, end: Date, label: String)] { - guard let ss = getSunriseSunset(context: context) else { return [] } - let dayDuration = ss.sunset.timeIntervalSince(ss.sunrise) - let slotLen = dayDuration / 15.0 + guard let today = getSunriseSunset(context: context), + let tomorrow = getSunriseSunset(context: context.advancedByLocalDays(1)) else { + return [] + } + let daySlotLength = today.sunset.timeIntervalSince(today.sunrise) / 15.0 + let nightSlotLength = tomorrow.sunrise.timeIntervalSince(today.sunset) / 15.0 // Weekday from Calendar (1=Sun…7=Sat) let wd = context.localDayComponents.weekday ?? 1 - let slots: [Int] + let daySlots: [Int] + let nightSlots: [Int] switch wd { - case 1: slots = [3, 8] // Sunday: 4th (Vidha) + 9th (Kutupa) - case 2: slots = [6, 14] // Monday: 7th + 15th - case 3: slots = [0, 7] // Tuesday: 1st + 8th - case 4: slots = [3] // Wednesday: 4th (Rohita) - case 5: slots = [5, 9] // Thursday: 6th + 10th - case 6: slots = [2, 7] // Friday: 3rd + 8th - case 7: slots = [2, 9] // Saturday: 3rd + 10th - default: slots = [] + case 1: (daySlots, nightSlots) = ([13], []) + case 2: (daySlots, nightSlots) = ([8, 11], []) + case 3: (daySlots, nightSlots) = ([3], [6]) + case 4: (daySlots, nightSlots) = ([7], []) + case 5: (daySlots, nightSlots) = ([5, 11], []) + case 6: (daySlots, nightSlots) = ([3, 8], []) + case 7: (daySlots, nightSlots) = ([0, 1], []) + default: (daySlots, nightSlots) = ([], []) } - let labels = slots.enumerated().map { i, _ in i == 0 ? "1st" : "2nd" } - return zip(slots, labels).map { (idx, lbl) in - let start = ss.sunrise.addingTimeInterval(Double(idx) * slotLen) - let end = start.addingTimeInterval(slotLen) - return (start: start, end: end, label: lbl) + var periods = daySlots.map { index in + let start = today.sunrise.addingTimeInterval(Double(index) * daySlotLength) + return (start: start, end: start.addingTimeInterval(daySlotLength), label: "") + } + periods += nightSlots.map { index in + let start = today.sunset.addingTimeInterval(Double(index) * nightSlotLength) + return (start: start, end: start.addingTimeInterval(nightSlotLength), label: "") + } + periods.sort { $0.start < $1.start } + return periods.enumerated().map { index, period in + ( + start: period.start, + end: period.end, + label: periods.count == 1 ? "Dur Muhurta" : (index == 0 ? "1st" : "2nd") + ) } } @@ -675,7 +867,7 @@ enum CosmicEngine { )) } - // MARK: - Vedic Calendar Extended Info + // MARK: - Vedic Calendar Extended Info prototype (not surfaced) static func getVedicCalendarInfo(date: Date, latDeg: Double, lonDeg: Double, birthNakshatraIndex: Int = -1) -> VedicCalendarInfo { @@ -763,7 +955,7 @@ enum CosmicEngine { ) } - // MARK: - Nine Graha Positions (Schlyter low-precision algorithm) + // MARK: - Nine Graha Positions prototype (not surfaced; low precision) private enum PlanetID { case mercury, venus, mars, jupiter, saturn } diff --git a/ios/native/CosmicRituals/Engine/FestivalData.swift b/ios/native/CosmicRituals/Engine/FestivalData.swift index d1578a6..f0fe904 100644 --- a/ios/native/CosmicRituals/Engine/FestivalData.swift +++ b/ios/native/CosmicRituals/Engine/FestivalData.swift @@ -2,6 +2,10 @@ import Foundation // MARK: - Festival (lunisolar keyed) // +// PROTOTYPE — deliberately not routed into the shipping UI. The month estimator +// below does not yet implement Adhika/Kshaya months or observance precedence. +// See ACCURACY.md before extending or surfacing this file. +// // Festivals are keyed to (lunarMonth, tithiIndex) where: // lunarMonth: 1=Chaitra, 2=Vaishakha, 3=Jyeshtha, 4=Ashadha, // 5=Shravana, 6=Bhadrapada, 7=Ashvin, 8=Kartika, diff --git a/ios/native/CosmicRituals/Engine/PanchangModels.swift b/ios/native/CosmicRituals/Engine/PanchangModels.swift index d03f9ba..9b3f8c9 100644 --- a/ios/native/CosmicRituals/Engine/PanchangModels.swift +++ b/ios/native/CosmicRituals/Engine/PanchangModels.swift @@ -58,6 +58,57 @@ struct ZodiacSign: Equatable { static func fromIndex(_ i: Int) -> ZodiacSign { all[((i % 12) + 12) % 12] } } +// MARK: - Panchang Transitions + +enum PanchangLimbKind: String, CaseIterable, Codable, Sendable { + case tithi + case nakshatra + case yoga + case karana +} + +/// The next exact boundary for one changing Panchang limb. +/// +/// Daily labels alone are incomplete because a limb can change at any instant +/// between one sunrise and the next. Keeping the current and next names beside +/// the solved boundary makes the value self-describing for the app, exports, +/// widgets, and accessibility output. +struct PanchangTransition: Codable, Equatable, Sendable { + let kind: PanchangLimbKind + let currentName: String + let nextName: String + let endTime: Date +} + +struct PanchangTransitions: Codable, Equatable, Sendable { + let tithi: PanchangTransition? + let nakshatra: PanchangTransition? + let yoga: PanchangTransition? + let karana: PanchangTransition? + + static let unavailable = PanchangTransitions( + tithi: nil, + nakshatra: nil, + yoga: nil, + karana: nil + ) + + var chronological: [PanchangTransition] { + [tithi, nakshatra, yoga, karana] + .compactMap { $0 } + .sorted { $0.endTime < $1.endTime } + } + + func transition(for kind: PanchangLimbKind) -> PanchangTransition? { + switch kind { + case .tithi: return tithi + case .nakshatra: return nakshatra + case .yoga: return yoga + case .karana: return karana + } + } +} + // MARK: - Panchang (Five Limbs of the Vedic Day) struct Panchang: Codable { @@ -75,6 +126,7 @@ struct Panchang: Codable { let moonSignName: String let sunriseTime: Date? let sunsetTime: Date? + let transitions: PanchangTransitions static let tithiNames = [ "Pratipada","Dvitiya","Tritiya","Chaturthi","Panchami", @@ -104,6 +156,13 @@ struct Panchang: Codable { "Bava","Balava","Kaulava","Taitila","Garija","Vanija","Vishti", "Shakuni","Chatushpada","Naga","Kimstughna" ] + + func referenceDisclosure(in timeZone: TimeZone) -> String { + if let sunriseTime { + return "Panchang day begins at sunrise · \(sunriseTime.ritualShortTime(in: timeZone)) local time" + } + return "Sunrise unavailable · sampled at \(date.ritualShortTime(in: timeZone)) local time" + } } // MARK: - Nakshatra Result diff --git a/ios/native/CosmicRituals/Engine/PanchangPDFExporter.swift b/ios/native/CosmicRituals/Engine/PanchangPDFExporter.swift index a3c8b3e..183a8bb 100644 --- a/ios/native/CosmicRituals/Engine/PanchangPDFExporter.swift +++ b/ios/native/CosmicRituals/Engine/PanchangPDFExporter.swift @@ -47,19 +47,30 @@ enum PanchangPDFExporter { let bodyFont = UIFont.systemFont(ofSize: 13, weight: .regular) let labelFont = UIFont.systemFont(ofSize: 10, weight: .regular) - draw("✦ Cosmic Rituals", at: CGPoint(x: 36, y: 24), font: titleFont, color: gold) - - let dateStr = longDate(context.localNoon, timeZone: context.timeZone) - draw(dateStr, at: CGPoint(x: 36, y: 60), font: subtitleFont, color: .white) - draw("Vedic Panchang · noon reference · \(context.timeZoneIdentifier)", at: CGPoint(x: 36, y: 80), font: labelFont, color: subtle.withAlphaComponent(0.8)) - - // Compute panchang data + // Compute all values before drawing so the header and body share the + // same sunrise-based reference instant. let p = CosmicEngine.getPanchang(context: context) let nak = CosmicEngine.getMoonNakshatraPada(context: context) let sunNak = CosmicEngine.getSunNakshatra(context: context) let sunSignIdx = Int(sunNak.degree / 30.0) % 12 let ss = CosmicEngine.getSunriseSunset(context: context) - let tithiEnd = CosmicEngine.getTithiEndTime(context: context) + + func transitionSuffix(_ transition: PanchangTransition?) -> String { + transition.map { + let ending = $0.endTime.ritualTransitionLabel( + relativeTo: p.date, + in: context.timeZone + ) + return " · until \(ending), then \($0.nextName)" + } ?? "" + } + + draw("✦ Cosmic Rituals", at: CGPoint(x: 36, y: 24), font: titleFont, color: gold) + + let dateStr = longDate(context.localNoon, timeZone: context.timeZone) + draw(dateStr, at: CGPoint(x: 36, y: 60), font: subtitleFont, color: .white) + let referenceLabel = p.sunriseTime == nil ? "local-noon fallback" : "sunrise reference" + draw("Vedic Panchang · \(referenceLabel) · \(context.timeZoneIdentifier)", at: CGPoint(x: 36, y: 80), font: labelFont, color: subtle.withAlphaComponent(0.8)) var y: CGFloat = 140 @@ -70,15 +81,21 @@ enum PanchangPDFExporter { let limbs: [(String, String)] = [ ("Vara (Weekday)", p.weekdayName), ("Tithi (Lunar Day)", p.tithiName + (p.tithiIndex < 15 ? " · Shukla Paksha" : " · Krishna Paksha") - + (tithiEnd.map { " · ends \(shortTime($0, timeZone: context.timeZone))" } ?? "")), - ("Nakshatra", "\(p.nakshatraName) · Pada \(nak.pada) · Lord: \(nak.nakshatraLord.rawValue)"), - ("Yoga", p.yogaName), - ("Karana", p.karanaName), + + transitionSuffix(p.transitions.tithi)), + ("Nakshatra", "\(p.nakshatraName) · Pada \(nak.pada) · Lord: \(nak.nakshatraLord.rawValue)" + + transitionSuffix(p.transitions.nakshatra)), + ("Yoga", p.yogaName + transitionSuffix(p.transitions.yoga)), + ("Karana", p.karanaName + transitionSuffix(p.transitions.karana)), ] for (label, value) in limbs { draw(label, at: CGPoint(x: 48, y: y), font: labelFont, color: subtle) - draw(value, at: CGPoint(x: 200, y: y), font: bodyFont, color: .white) - y += 22 + let valueHeight = drawWrapped( + value, + in: CGRect(x: 200, y: y, width: pageRect.width - 236, height: 58), + font: bodyFont, + color: .white + ) + y += max(22, valueHeight + 6) } y += 12 @@ -110,10 +127,17 @@ enum PanchangPDFExporter { let muhurtas = CosmicEngine.getMuhurtas(context: context) let notable = muhurtas.filter { $0.quality == .excellent || $0.quality == .auspicious }.prefix(10) for m in notable { - let timeStr = "\(shortTime(m.startTime, timeZone: context.timeZone))–\(shortTime(m.endTime, timeZone: context.timeZone))" + let start = m.startTime.ritualTransitionLabel(relativeTo: p.date, in: context.timeZone) + let end = m.endTime.ritualTransitionLabel(relativeTo: p.date, in: context.timeZone) + let timeStr = "\(start)–\(end)" draw(m.quality.emoji + " " + m.name, at: CGPoint(x: 48, y: y), font: bodyFont, color: .white) - draw(timeStr, at: CGPoint(x: 300, y: y), font: labelFont, color: subtle) - y += 20 + let timeHeight = drawWrapped( + timeStr, + in: CGRect(x: 300, y: y, width: pageRect.width - 336, height: 38), + font: labelFont, + color: subtle + ) + y += max(20, timeHeight + 4) if y > pageRect.height - 80 { break } } @@ -147,6 +171,38 @@ enum PanchangPDFExporter { (text as NSString).draw(at: point, withAttributes: attrs) } + @discardableResult + private static func drawWrapped( + _ text: String, + in rect: CGRect, + font: UIFont, + color: UIColor + ) -> CGFloat { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byWordWrapping + paragraph.lineSpacing = 1 + let attrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading] + let bounds = (text as NSString).boundingRect( + with: CGSize(width: rect.width, height: rect.height), + options: options, + attributes: attrs, + context: nil + ) + let height = min(rect.height, ceil(bounds.height)) + (text as NSString).draw( + with: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: height), + options: options, + attributes: attrs, + context: nil + ) + return height + } + private static func shortTime(_ date: Date, timeZone: TimeZone) -> String { let formatter = DateFormatter() formatter.dateStyle = .none diff --git a/ios/native/CosmicRituals/Engine/PoojaVidhiCatalog.swift b/ios/native/CosmicRituals/Engine/PoojaVidhiCatalog.swift new file mode 100644 index 0000000..f734099 --- /dev/null +++ b/ios/native/CosmicRituals/Engine/PoojaVidhiCatalog.swift @@ -0,0 +1,767 @@ +import Foundation + +enum PoojaCategory: String, CaseIterable, Identifiable, Hashable, Sendable { + case daily + case deity + case festival + case vrata + case lifeEvent + case planetary + + var id: String { rawValue } + + var displayName: String { + switch self { + case .daily: return "Daily" + case .deity: return "Deity" + case .festival: return "Festival" + case .vrata: return "Vrata" + case .lifeEvent: return "Life event" + case .planetary: return "Planetary" + } + } + + var symbol: String { + switch self { + case .daily: return "sunrise.fill" + case .deity: return "sparkles" + case .festival: return "flame.fill" + case .vrata: return "moon.stars.fill" + case .lifeEvent: return "house.fill" + case .planetary: return "circle.hexagongrid.fill" + } + } +} + +enum PoojaPracticeLevel: String, Hashable, Sendable { + case simpleHousehold + case extendedHousehold + case priestRecommended + + var displayName: String { + switch self { + case .simpleHousehold: return "Simple household" + case .extendedHousehold: return "Extended household" + case .priestRecommended: return "Priest recommended" + } + } + + var guidance: String { + switch self { + case .simpleHousehold: + return "A respectful home sequence using public name-mantras and simple offerings." + case .extendedHousehold: + return "Suitable at home when the family already observes this practice; a priest may guide local variations." + case .priestRecommended: + return "This guide prepares the household and explains the sequence. Formal sankalpa, homa, nyasa, and lineage mantras should be led by a qualified priest." + } + } +} + +struct PoojaMaterial: Identifiable, Hashable, Sendable { + let id: String + let name: String + let purpose: String + let isRequired: Bool + let alternative: String? +} + +struct PoojaMantra: Hashable, Sendable { + let title: String + let devanagari: String + let transliteration: String + let meaning: String + let repetition: String +} + +struct PoojaStep: Identifiable, Hashable, Sendable { + let number: Int + let title: String + let instruction: String + let mantra: PoojaMantra? + let note: String? + let estimatedMinutes: Int + + var id: Int { number } +} + +struct PoojaSourceNote: Identifiable, Hashable, Sendable { + let title: String + let detail: String + let urlString: String + + var id: String { urlString } +} + +/// A factual, derived preparation state. It deliberately avoids a single +/// decorative "confidence" score: readiness depends on concrete materials, +/// the practice boundary, and visible source context. +struct PoojaReadiness: Equatable, Sendable { + let requiredMaterialCount: Int + let preparedRequiredMaterialCount: Int + let optionalMaterialCount: Int + let preparedOptionalMaterialCount: Int + let sourceCount: Int + let practiceLevel: PoojaPracticeLevel + + var remainingRequiredMaterialCount: Int { + max(0, requiredMaterialCount - preparedRequiredMaterialCount) + } + + var requiredPreparationProgress: Double { + guard requiredMaterialCount > 0 else { return 1 } + return Double(preparedRequiredMaterialCount) / Double(requiredMaterialCount) + } + + var hasPreparedRequiredMaterials: Bool { + remainingRequiredMaterialCount == 0 + } + + var materialStatus: String { + if hasPreparedRequiredMaterials { + switch requiredMaterialCount { + case 0: + return "No required materials" + case 1: + return "Required material marked ready" + default: + return "All \(requiredMaterialCount) required materials marked ready" + } + } + let noun = remainingRequiredMaterialCount == 1 ? "item" : "items" + return "\(remainingRequiredMaterialCount) required \(noun) left to review" + } + + var practiceStatus: String { + practiceLevel == .priestRecommended + ? "Qualified practitioner recommended" + : practiceLevel.displayName + } + + var sourceStatus: String { + let noun = sourceCount == 1 ? "reference" : "references" + return "\(sourceCount) cited \(noun) · reviewed \(PoojaContentPolicy.sourceReviewDate)" + } +} + +struct PoojaVidhi: Identifiable, Hashable, Sendable { + let id: String + let title: String + let sacredFocus: String + let summary: String + let purpose: String + let category: PoojaCategory + let practiceLevel: PoojaPracticeLevel + let durationMinutes: ClosedRange + let occasions: [String] + let preparation: [String] + let materials: [PoojaMaterial] + let steps: [PoojaStep] + let completion: [String] + let safetyNotes: [String] + let traditionNote: String + let sourceNotes: [PoojaSourceNote] + + var durationText: String { + if durationMinutes.lowerBound == durationMinutes.upperBound { + return "\(durationMinutes.lowerBound) min" + } + return "\(durationMinutes.lowerBound)–\(durationMinutes.upperBound) min" + } + + var searchableText: String { + ([title, sacredFocus, summary, purpose, category.displayName, practiceLevel.displayName] + + occasions + + preparation + + materials.flatMap { [$0.name, $0.purpose, $0.alternative ?? ""] } + + steps.flatMap { step in + [step.title, step.instruction, step.note ?? "", step.mantra?.devanagari ?? "", + step.mantra?.transliteration ?? "", step.mantra?.meaning ?? ""] + }) + .joined(separator: " ") + } + + func readiness(preparedMaterialIDs: Set) -> PoojaReadiness { + let required = materials.filter(\.isRequired) + let optional = materials.filter { !$0.isRequired } + return PoojaReadiness( + requiredMaterialCount: required.count, + preparedRequiredMaterialCount: required.filter { preparedMaterialIDs.contains($0.id) }.count, + optionalMaterialCount: optional.count, + preparedOptionalMaterialCount: optional.filter { preparedMaterialIDs.contains($0.id) }.count, + sourceCount: sourceNotes.count, + practiceLevel: practiceLevel + ) + } + + var shareText: String { + """ + \(title) · \(durationText) + \(summary) + + \(steps.map { "\($0.number). \($0.title): \($0.instruction)" }.joined(separator: "\n")) + + Household reference from Cosmic Rituals. Family, temple, and sampradaya practice may differ. + """ + } +} + +enum PoojaContentPolicy { + static let sourceReviewDate = "2026-07-28" + static let reviewStatus = "Source-adapted household reference, not a sampradaya-specific certification. Confirm formal rites and received practice with a qualified practitioner." + static let householdScope = "These are inclusive household reference sequences, not claims of one universal or canonical Pooja Vidhi. Family, temple, regional, and sampradaya practice takes precedence." + static let inclusionScope = "The app does not restrict participation by caste, gender, menstruation, marital status, or birth. A person may sit, stand, bow, or adapt movements for safety, disability, age, and comfort." + static let mantraScope = "Only short public name-mantras are used. Initiatory bija-mantras, nyasa, fire rites, and lineage-restricted recitations are intentionally omitted. Meaning is shown so a person may pray in a familiar language if Sanskrit pronunciation is uncertain." + static let safetyScope = "A flame, incense, water, food, and flowers are optional when unsafe or unavailable. Never leave a flame unattended, keep smoke away from sensitive people and animals, and check food offerings for allergies before sharing prasada." +} + +enum PoojaVidhiCatalog { + private static let householdPujaSource = PoojaSourceNote( + title: "Himalayan Academy · Home Puja", + detail: "Used for the common preparation, offering, prasada, and quiet-closing pattern. Its detailed Ganesha rite is Saiva-lineage specific and is not generalized here.", + urlString: "https://www.himalayanacademy.com/media/books/home-puja_ei/web/ch24a.html" + ) + + private static let dailyPracticeSource = PoojaSourceNote( + title: "BAPS Swaminarayan Sanstha · Daily Puja", + detail: "Used as a second reference for cleanliness, a steady daily practice, meditation, prayer, and respectful closure. Sect-specific elements remain identified as such and are not copied into the general sequence.", + urlString: "https://www.baps.org/Spiritual-Living/Hindu-Practices/Daily-Puja.aspx" + ) + + private static let lakshmiSource = PoojaSourceNote( + title: "Drik Panchang · Lakshmi Shodashopachara Puja", + detail: "Used to check the traditional progression from dhyana and avahana through offerings and closure. The app presents a shorter household Panchopachara form, not the full sixteen-offering rite.", + urlString: "https://www.drikpanchang.com/festivals/lakshmipuja/info/lakshmi-puja-vidhi.html" + ) + + private static let navratriSource = PoojaSourceNote( + title: "Drik Panchang · Durga Puja Vidhi", + detail: "Used to confirm the broad Navratri worship progression. Ghatasthapana and lineage-specific Shodashopachara mantras are outside the simple household guide.", + urlString: "https://www.drikpanchang.com/navratri/durga-puja/puja-vidhi/durga-puja-vidhi.html" + ) + + private static let satyanarayanaSource = PoojaSourceNote( + title: "Drik Panchang · Satyanarayana Puja Vidhi", + detail: "Used to check the Vishnu-centered vrata sequence. A complete vrata includes the locally received katha and may include priest-led steps.", + urlString: "https://www.drikpanchang.com/hindu-gods/trimurti/lord-vishnu/puja-vidhi/satyanarayan-puja-vidhi.html" + ) + + static let all: [PoojaVidhi] = [ + householdVidhi( + id: "daily-panchopachara", + title: "Daily Panchopachara Pooja", + sacredFocus: "Ishta Devata", + summary: "A calm, compact daily practice of centering, intention, five symbolic offerings, prayer, and respectful closure.", + purpose: "Daily devotion, gratitude, and a steady home-shrine rhythm.", + category: .daily, + practiceLevel: .simpleHousehold, + duration: 8...15, + occasions: ["Every day", "Morning prayer", "Evening prayer", "Gratitude"], + specificMaterials: [], + focusPreparation: "Place the murti or image of your chosen form of the Divine on a clean, stable surface.", + invocation: "With folded hands, welcome your Ishta Devata in the way your family uses. If you have no received invocation, a sincere welcome in your own language is sufficient.", + offering: "Offer fragrance or sandalwood, one flower, incense if suitable, a safely placed lamp, and a small food or water offering. Each is optional when unavailable.", + mantra: PoojaMantra( + title: "Universal offering prayer", + devanagari: "ॐ नमः", + transliteration: "Om namaḥ", + meaning: "Reverence to the Divine.", + repetition: "Repeat slowly 3, 9, or 11 times" + ), + closingPrayer: "Offer gratitude, ask forgiveness for errors, bow, and sit quietly before returning to the day.", + traditionNote: "Panchopachara names five modes of hospitality; their exact order and substances vary. This neutral home sequence does not replace a received family paddhati.", + sources: [householdPujaSource, dailyPracticeSource] + ), + householdVidhi( + id: "ganesha-home", + title: "Shri Ganesha Pooja", + sacredFocus: "Ganesha", + summary: "A simple home Pooja honoring Ganesha before a new undertaking or as a regular devotional practice.", + purpose: "Clarity, an auspicious beginning, humility, and removal of inner obstacles.", + category: .deity, + practiceLevel: .simpleHousehold, + duration: 15...25, + occasions: ["New beginning", "Before study", "Before travel", "Ganesha Chaturthi", "Wednesday"], + specificMaterials: [ + material("durva", "Durva grass or a fresh flower", "Traditional Ganesha offering", required: false, alternative: "Any clean, respectfully offered flower"), + material("modaka", "Modaka or a simple sweet", "Naivedya", required: false, alternative: "Fruit or water") + ], + focusPreparation: "Place a Ganesha murti or image on a clean altar. Keep liquids away from paper images and electronics.", + invocation: "Contemplate Ganesha as compassionate wisdom. Invite his presence with folded hands; elaborate avahana mudras are not required for this household form.", + offering: "Offer sandalwood or fragrance, durva or a flower, incense if appropriate, lamp light, and fruit or a sweet.", + mantra: PoojaMantra( + title: "Ganesha name-mantra", + devanagari: "ॐ गणपतये नमः", + transliteration: "Om gaṇapataye namaḥ", + meaning: "Reverence to Ganapati, lord of the hosts.", + repetition: "Repeat 11 or 21 times, without rushing" + ), + closingPrayer: "Pray for wisdom to recognize and work through obstacles, perform a gentle namaskara, and share the offering as prasada.", + traditionNote: "This is a short public household form. The longer Sanskrit Ganesha Pooja published by Himalayan Academy belongs to a specific Saiva teaching context and should be learned from that source or a teacher.", + sources: [householdPujaSource] + ), + householdVidhi( + id: "lakshmi-home", + title: "Mahalakshmi Pooja", + sacredFocus: "Mahalakshmi", + summary: "A household Lakshmi Pooja centered on gratitude, ethical abundance, hospitality, and care for the home.", + purpose: "Gratitude for sustenance and a prayer for well-being, generosity, and wise stewardship.", + category: .deity, + practiceLevel: .simpleHousehold, + duration: 20...30, + occasions: ["Friday", "Diwali", "Varalakshmi Vrata", "At home", "New home", "Prosperity", "Gratitude"], + specificMaterials: [ + material("lotus", "Lotus or another fresh flower", "Floral offering", required: false, alternative: "A clean local flower"), + material("coin", "Clean coin or account book", "Symbol of responsible resources", required: false, alternative: nil), + material("sweet", "Fruit or vegetarian sweet", "Naivedya", required: false, alternative: "Water") + ], + focusPreparation: "Clean the worship area and place Lakshmi's murti or image securely. Treat cleaning as preparation, not as a guarantee of material gain.", + invocation: "Meditate on Lakshmi seated on a lotus, embodying auspiciousness, generosity, beauty, and well-being. Welcome her with folded hands.", + offering: "Offer fragrance, a flower, gentle incense if suitable, lamp light, and fruit or a sweet. Full bathing and sixteen-offering rites are omitted from this home guide.", + mantra: PoojaMantra( + title: "Lakshmi name-mantra", + devanagari: "ॐ महालक्ष्म्यै नमः", + transliteration: "Om mahālakṣmyai namaḥ", + meaning: "Reverence to Mahalakshmi.", + repetition: "Repeat 11 or 21 times with attention to meaning" + ), + closingPrayer: "Express gratitude, commit to a concrete act of generosity or responsible care, offer namaskara, and share prasada.", + traditionNote: "The full Diwali Shodashopachara sequence contains sixteen formal offerings and longer mantras. This app intentionally provides a shorter Panchopachara household form.", + sources: [lakshmiSource, householdPujaSource] + ), + householdVidhi( + id: "shiva-home", + title: "Shiva Pooja", + sacredFocus: "Shiva", + summary: "A contemplative Shiva Pooja using water, bilva or flowers, lamp light, mantra, and silence.", + purpose: "Stillness, self-reflection, compassion, and release of unhelpful habits.", + category: .deity, + practiceLevel: .simpleHousehold, + duration: 15...30, + occasions: ["Monday", "Pradosha", "Mahashivaratri", "Meditation", "Peace"], + specificMaterials: [ + material("bilva", "Bilva leaves", "Traditional Shiva offering", required: false, alternative: "A clean flower or leaf accepted in your family tradition"), + material("abhisheka-water", "Separate clean water", "Optional abhisheka for a suitable Shiva Linga", required: false, alternative: nil) + ], + focusPreparation: "Use a stable Shiva murti, image, or a Shiva Linga already worshipped in your household. Never pour liquid over a framed image or electrical surface.", + invocation: "Sit quietly and contemplate Shiva as auspicious stillness and compassionate awareness. Welcome the Divine presence without attempting lineage-specific nyasa.", + offering: "If your household custom permits, offer a small stream of clean water to a suitable Linga over a catch tray. Otherwise offer water symbolically, then fragrance, bilva or a flower, lamp light, and fruit.", + mantra: PoojaMantra( + title: "Shiva name-mantra", + devanagari: "ॐ शिवाय नमः", + transliteration: "Om śivāya namaḥ", + meaning: "Reverence to Shiva, the auspicious one.", + repetition: "Repeat 11, 21, or 108 times according to comfort" + ), + closingPrayer: "Rest in silence, ask that insight become compassionate action, bow, and respectfully clear any offered water.", + traditionNote: "Abhisheka substances and rules differ widely. This guide uses only clean water and makes abhisheka optional; follow the practice received from your family or temple.", + sources: [householdPujaSource] + ), + householdVidhi( + id: "vishnu-home", + title: "Vishnu Pooja", + sacredFocus: "Vishnu or Narayana", + summary: "A simple Vishnu Pooja of welcome, Tulsi or flowers, lamp, naivedya, mantra, and gratitude.", + purpose: "Steadiness, preservation of dharma, gratitude, and service.", + category: .deity, + practiceLevel: .simpleHousehold, + duration: 15...25, + occasions: ["Thursday", "Ekadashi", "Vaikuntha Ekadashi", "Peace", "Family prayer"], + specificMaterials: [ + material("tulsi", "Tulsi leaf", "Traditional Vishnu offering", required: false, alternative: "A clean flower accepted in your tradition"), + material("fruit-vishnu", "Fruit or simple sattvic food", "Naivedya", required: false, alternative: "Water") + ], + focusPreparation: "Place a Vishnu or Narayana murti or image on a clean, stable altar. Prepare food without ingredients your family excludes from worship.", + invocation: "Contemplate Vishnu as the sustaining presence within life and welcome him with folded hands.", + offering: "Offer water, sandalwood or fragrance, Tulsi or a flower, incense if suitable, lamp light, and naivedya.", + mantra: PoojaMantra( + title: "Vishnu name-mantra", + devanagari: "ॐ विष्णवे नमः", + transliteration: "Om viṣṇave namaḥ", + meaning: "Reverence to Vishnu.", + repetition: "Repeat 11 or 21 times" + ), + closingPrayer: "Pray for the strength to uphold what sustains life, offer namaskara, and share prasada mindfully.", + traditionNote: "Vaishnava sampradayas differ in mantra, tilaka, food rules, and offering sequence. Those received practices take precedence over this general guide.", + sources: [householdPujaSource] + ), + householdVidhi( + id: "durga-navratri-home", + title: "Durga & Navratri Pooja", + sacredFocus: "Durga", + summary: "A simple daily Navratri or household Durga Pooja focused on courage, compassionate protection, and disciplined devotion.", + purpose: "Courage, protection, dignity, and reverence for the Divine Mother.", + category: .festival, + practiceLevel: .extendedHousehold, + duration: 20...35, + occasions: ["Navratri", "Friday", "Ashtami", "Durga Puja", "Protection", "Courage"], + specificMaterials: [ + material("red-flower", "Red or seasonal flower", "Floral offering", required: false, alternative: "Any clean flower"), + material("kalasha", "Kalasha and sprouting grains", "Optional Navratri observance", required: false, alternative: "Omit unless this is an established household practice") + ], + focusPreparation: "Place Durga's murti or image securely. If maintaining a Navratri kalasha, follow the practice learned from your family or priest rather than improvising installation or visarjana.", + invocation: "Contemplate Durga as compassionate strength that protects dignity and dharma. Welcome her in your own words or with the public name-mantra below.", + offering: "Offer water, fragrance, a red or seasonal flower, incense if suitable, lamp light, and fruit or a simple sweet.", + mantra: PoojaMantra( + title: "Durga name-mantra", + devanagari: "ॐ दुर्गायै नमः", + transliteration: "Om durgāyai namaḥ", + meaning: "Reverence to Durga.", + repetition: "Repeat 11 or 21 times" + ), + closingPrayer: "Pray that strength be joined with compassion, offer namaskara, and conclude without moving an installed kalasha casually.", + traditionNote: "Navratri forms, fasting, Ghatasthapana, Kumari Puja, and visarjana vary by region and lineage. Fasting is optional and is not prescribed by this app; health needs take priority.", + sources: [navratriSource, householdPujaSource] + ), + householdVidhi( + id: "hanuman-home", + title: "Hanuman Pooja", + sacredFocus: "Hanuman", + summary: "A home Pooja honoring Hanuman through simple offerings, name-mantra, prayer, and optional familiar devotional reading.", + purpose: "Courage, disciplined service, resilience, and devotion.", + category: .deity, + practiceLevel: .simpleHousehold, + duration: 15...30, + occasions: ["Tuesday", "Saturday", "Hanuman Jayanti", "Courage", "Service"], + specificMaterials: [ + material("hanuman-flower", "Red or orange flower", "Floral offering", required: false, alternative: "Any clean flower"), + material("hanuman-reading", "Hanuman Chalisa or a familiar prayer book", "Optional devotional reading", required: false, alternative: nil) + ], + focusPreparation: "Place Hanuman's murti or image on a clean altar. Do not apply oil or sindoor to a murti unless its material and your established custom allow it.", + invocation: "Contemplate Hanuman as strength guided by humility and selfless service, then welcome him with folded hands.", + offering: "Offer water, fragrance, a flower, lamp light, and fruit. Read a familiar prayer only if you can do so respectfully and without rushing.", + mantra: PoojaMantra( + title: "Hanuman name-mantra", + devanagari: "ॐ हनुमते नमः", + transliteration: "Om hanumate namaḥ", + meaning: "Reverence to Hanuman.", + repetition: "Repeat 11 or 21 times" + ), + closingPrayer: "Ask for strength to serve wisely, offer namaskara, and carry one act of disciplined kindness into the day.", + traditionNote: "Offerings and longer recitations differ by region. This guide deliberately does not prescribe sindoor, oil, or restricted ritual applications.", + sources: [householdPujaSource] + ), + householdVidhi( + id: "saraswati-home", + title: "Saraswati Pooja", + sacredFocus: "Saraswati", + summary: "A gentle Pooja for learning, music, language, discernment, and respectful use of knowledge.", + purpose: "Clarity in learning, creativity, truthful speech, and humility before knowledge.", + category: .deity, + practiceLevel: .simpleHousehold, + duration: 15...25, + occasions: ["Vasant Panchami", "Before study", "New course", "Music", "Examination", "Learning"], + specificMaterials: [ + material("book", "Book, notebook, or instrument", "Symbol of learning", required: false, alternative: nil), + material("white-flower", "White or pale flower", "Floral offering", required: false, alternative: "Any clean flower") + ], + focusPreparation: "Place Saraswati's image and the book or instrument nearby on a clean cloth. Keep food, water, oil, and flame safely separated from books and instruments.", + invocation: "Contemplate Saraswati as wisdom, language, music, and clear discernment. Welcome her with folded hands.", + offering: "Offer fragrance, a flower, safely placed lamp light, and fruit or water. Touch the learning object only with clean, dry hands.", + mantra: PoojaMantra( + title: "Saraswati name-mantra", + devanagari: "ॐ सरस्वत्यै नमः", + transliteration: "Om sarasvatyai namaḥ", + meaning: "Reverence to Saraswati.", + repetition: "Repeat 11 or 21 times" + ), + closingPrayer: "Pray to use knowledge truthfully and for benefit, bow, then begin a short period of attentive study or practice.", + traditionNote: "Vasant Panchami customs differ across regions and schools. This is a simple devotional guide, not a substitute for a school or family ceremony.", + sources: [householdPujaSource] + ), + suryaArghya, + satyanarayana, + grihaPravesh, + navagraha + ] + + static func vidhi(id: String) -> PoojaVidhi? { + all.first { $0.id == id } + } + + static func search(_ query: String, category: PoojaCategory? = nil) -> [PoojaVidhi] { + let candidates = category.map { selected in all.filter { $0.category == selected } } ?? all + let normalizedQuery = normalized(query) + guard !normalizedQuery.isEmpty else { return candidates } + let tokens = normalizedQuery.split(separator: " ").map(String.init) + + return candidates + .filter { vidhi in + let corpus = normalized(vidhi.searchableText) + return tokens.allSatisfy(corpus.contains) + } + .sorted { lhs, rhs in + let lhsTitle = normalized(lhs.title) + let rhsTitle = normalized(rhs.title) + let lhsPrefix = lhsTitle.hasPrefix(normalizedQuery) + let rhsPrefix = rhsTitle.hasPrefix(normalizedQuery) + if lhsPrefix != rhsPrefix { return lhsPrefix } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + static var validationIssues: [String] { + var issues: [String] = [] + let ids = all.map(\.id) + if Set(ids).count != ids.count { issues.append("Catalog IDs must be unique") } + + for vidhi in all { + if vidhi.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + issues.append("\(vidhi.id): title is empty") + } + if vidhi.steps.count < 8 { issues.append("\(vidhi.id): fewer than eight steps") } + if vidhi.materials.count < 5 { issues.append("\(vidhi.id): incomplete materials") } + if vidhi.sourceNotes.isEmpty { issues.append("\(vidhi.id): missing source transparency") } + if vidhi.safetyNotes.isEmpty { issues.append("\(vidhi.id): missing safety notes") } + if vidhi.steps.map(\.number) != Array(1...vidhi.steps.count) { + issues.append("\(vidhi.id): steps are not sequential") + } + for step in vidhi.steps { + if let mantra = step.mantra, + [mantra.devanagari, mantra.transliteration, mantra.meaning, mantra.repetition] + .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) { + issues.append("\(vidhi.id): incomplete mantra at step \(step.number)") + } + } + } + return issues + } + + private static func normalized(_ value: String) -> String { + value.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX")) + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + private static func householdVidhi( + id: String, + title: String, + sacredFocus: String, + summary: String, + purpose: String, + category: PoojaCategory, + practiceLevel: PoojaPracticeLevel, + duration: ClosedRange, + occasions: [String], + specificMaterials: [PoojaMaterial], + focusPreparation: String, + invocation: String, + offering: String, + mantra: PoojaMantra, + closingPrayer: String, + traditionNote: String, + sources: [PoojaSourceNote] + ) -> PoojaVidhi { + PoojaVidhi( + id: id, + title: title, + sacredFocus: sacredFocus, + summary: summary, + purpose: purpose, + category: category, + practiceLevel: practiceLevel, + durationMinutes: duration, + occasions: occasions, + preparation: [ + "Choose a time when you can remain unhurried and attentive.", + "Bathe or wash hands and face, wear clean clothing, and clean the worship surface.", + focusPreparation, + "Gather every item before lighting a lamp so the ritual is not left unattended." + ], + materials: commonMaterials + specificMaterials, + steps: [ + step(1, "Prepare the sacred space", focusPreparation, minutes: 2), + step(2, "Settle and centre", "Sit or stand comfortably. Take three natural breaths, reduce distractions, and bring attention to the sacred focus.", note: "No breath retention or forceful breathing is required.", minutes: 1), + step(3, "Purify symbolically", "Sprinkle a few drops of clean water around the offering area or simply hold the intention of cleanliness when water is unsuitable.", minutes: 1), + step(4, "State the sankalpa", "Name the place, day, your name if desired, and a clear devotional intention. Avoid promises or vows you may not be able to keep.", note: "A sincere statement in your own language is valid for this household guide.", minutes: 2), + step(5, "Remember Ganesha and the teachers", "Offer a brief namaskara to Ganesha, your family tradition, teachers, and all who preserved the practice. This is remembrance, not a separate elaborate rite.", minutes: 1), + step(6, "Dhyana and welcome", invocation, minutes: 2), + step(7, "Offer respectful hospitality", offering, note: "Offer only what is clean, safe, and permitted in your household practice.", minutes: 4), + step(8, "Recite with meaning", "Read the mantra once before repeating it. Pronounce gently; if uncertain, pray using its meaning in a language you know.", mantra: mantra, minutes: 4), + step(9, "Offer naivedya and quiet prayer", "Place the food or water offering before the sacred focus. Pause quietly; do not taste it before offering.", note: "Keep allergens separate and never give unsafe foods to children or animals.", minutes: 2), + step(10, "Show the light safely", "If a lamp is lit, move it in small clockwise circles at a safe distance, or simply hold folded hands before a stationary lamp. A battery lamp or no flame is acceptable.", note: "Keep hair, sleeves, decorations, children, and animals away from flame.", minutes: 2), + step(11, "Pray and offer namaskara", closingPrayer, minutes: 2), + step(12, "Close and share prasada", "Thank the sacred presence, sit quietly for a moment, extinguish unsafe flames without leaving smoke, and share the offering respectfully.", note: "Do not discard ritual water, flowers, oil, or food where they create litter, plumbing problems, fire risk, or harm to wildlife.", minutes: 2) + ], + completion: [ + "Sit quietly for at least a few breaths before clearing the altar.", + "Share safe food offerings as prasada; refrigerate perishables promptly.", + "Return reusable items clean and dry. Compost flowers only where locally appropriate.", + "Let the intention continue through one concrete, ethical action." + ], + safetyNotes: [PoojaContentPolicy.safetyScope, "Do not ingest ash, oils, powders, flowers, or ritual water unless you know they are food-safe."], + traditionNote: traditionNote, + sourceNotes: sources + ) + } + + private static var commonMaterials: [PoojaMaterial] { + [ + material("focus", "Murti, image, or other sacred focus", "A stable focus for devotion", required: true, alternative: "A clean, empty space used with reverence"), + material("water", "Clean water and a small spoon", "Symbolic purification and offering", required: false, alternative: "Omit where spills are unsafe"), + material("flower", "Fresh flower, petals, or a clean leaf", "Floral offering", required: false, alternative: "A mental offering"), + material("lamp", "Stable oil/ghee lamp or battery lamp", "Light offering", required: false, alternative: "No flame"), + material("incense", "Incense with a fireproof holder", "Fragrance offering", required: false, alternative: "Sandalwood paste, a flower, or no fragrance"), + material("naivedya", "Fruit, simple vegetarian food, or water", "Naivedya and prasada", required: false, alternative: "Clean water") + ] + } + + private static let suryaArghya = PoojaVidhi( + id: "surya-arghya", + title: "Surya Arghya", + sacredFocus: "Surya", + summary: "A short sunrise practice offering water with gratitude for light, time, health-supporting routine, and life.", + purpose: "Gratitude, disciplined morning attention, and reverence for the visible Sun.", + category: .daily, + practiceLevel: .simpleHousehold, + durationMinutes: 5...10, + occasions: ["Sunrise", "Sunday", "Daily practice", "Gratitude"], + preparation: ["Choose a safe, non-slip place with drainage or use a basin indoors.", "Know the local sunrise from the app, but do not look directly at the Sun.", "Use a small amount of clean water."], + materials: [ + material("surya-water", "Small vessel of clean water", "Arghya", required: true, alternative: "A symbolic folded-hands offering when water cannot be poured"), + material("surya-basin", "Basin or safe soil area", "Collects the water without creating a slip hazard", required: true, alternative: nil), + material("surya-flower", "Flower", "Optional offering", required: false, alternative: "Mental offering"), + material("surya-cloth", "Clean cloth", "Dry hands and any spills", required: false, alternative: nil), + material("surya-focus", "Open sky or a well-lit window", "Direction of attention", required: false, alternative: "Face east without viewing the Sun") + ], + steps: [ + step(1, "Choose a safe place", "Stand where poured water cannot make a walkway slippery or contaminate shared space.", minutes: 1), + step(2, "Face the morning light", "Face east around local sunrise. Keep your gaze below or away from the Sun; never stare at it.", minutes: 1), + step(3, "Set the intention", "Offer gratitude for light, time, warmth, and the conditions that support life.", minutes: 1), + step(4, "Hold the water", "Hold a small vessel steadily with both hands at chest level.", minutes: 1), + step(5, "Recite the name-mantra", "Recite gently or use its meaning in your own language.", mantra: PoojaMantra(title: "Surya name-mantra", devanagari: "ॐ सूर्याय नमः", transliteration: "Om sūryāya namaḥ", meaning: "Reverence to Surya.", repetition: "Repeat 3 or 11 times"), minutes: 2), + step(6, "Offer the water", "Pour a thin stream into the basin or safe soil while keeping eyes away from direct sunlight.", note: "Do not pour from balconies, into public walkways, or where water can damage property.", minutes: 1), + step(7, "Offer namaskara", "Bring palms together and bow gently.", minutes: 1), + step(8, "Close responsibly", "Wipe spills, reuse collected clean water where appropriate, and leave the place safe.", minutes: 1) + ], + completion: ["Carry the practice into a consistent, safe morning routine.", "Do not interpret the ritual as medical treatment or a substitute for sun-safety guidance."], + safetyNotes: ["Never look directly at the Sun. This app does not provide eye-safety or medical advice.", "Prevent slips and do not pour water where it may fall on people, property, or electrical equipment."], + traditionNote: "Surya worship has Vedic, Puranic, yogic, and regional forms. This is a minimal public name-mantra and water-offering practice, not Sandhyavandana or a Vedic rite.", + sourceNotes: [householdPujaSource] + ) + + private static let satyanarayana = PoojaVidhi( + id: "satyanarayana-vrata", + title: "Satyanarayana Vrata Pooja", + sacredFocus: "Satyanarayana, a form of Vishnu", + summary: "A preparation-aware guide to the Vishnu-centered vrata, including worship, katha, naivedya, community participation, and closure.", + purpose: "Gratitude, truthfulness, family prayer, and fulfillment of a consciously undertaken vrata.", + category: .vrata, + practiceLevel: .priestRecommended, + durationMinutes: 60...120, + occasions: ["Purnima", "Family milestone", "Thanksgiving", "New beginning", "Vrata"], + preparation: ["Confirm the date, form of sankalpa, katha text, and food practice with the priest or family tradition.", "Invite participants without pressuring anyone to fast.", "Plan safe seating, food hygiene, and enough time for the complete katha."], + materials: commonMaterials + [ + material("satya-katha", "Received Satyanarayana Katha text", "Essential narrative recitation", required: true, alternative: "Arrange a priest or trusted family reader"), + material("satya-kalasha", "Kalasha materials", "Used in many established forms", required: false, alternative: "Follow the officiant's list"), + material("satya-prasada", "Family-tradition prasada ingredients", "Naivedya and distribution", required: true, alternative: "Adapt allergens with the officiant before the vrata") + ], + steps: [ + step(1, "Confirm the received procedure", "Agree on the vrata text, sankalpa, language, food rules, and officiant before the day.", minutes: 5), + step(2, "Prepare the home and altar", "Clean the area, arrange safe seating, and gather the complete materials list.", minutes: 10), + step(3, "State the sankalpa", "The officiant or family elder states the intention without making coercive or impossible promises.", minutes: 5), + step(4, "Begin with Ganesha remembrance", "Offer a brief Ganesha prayer according to the received procedure.", minutes: 5), + step(5, "Kalasha and Vishnu worship", "Follow the officiant for kalasha installation and the chosen Vishnu or Satyanarayana offering sequence.", note: "Do not improvise Vedic or lineage mantras from the app.", minutes: 20), + step(6, "Recite the public name-mantra", "Use the name-mantra during a pause if it agrees with the officiant's procedure.", mantra: PoojaMantra(title: "Satyanarayana name-mantra", devanagari: "ॐ सत्यनारायणाय नमः", transliteration: "Om satyanārāyaṇāya namaḥ", meaning: "Reverence to Satyanarayana.", repetition: "As directed, or 11 times in a simple household pause"), minutes: 5), + step(7, "Listen to the complete katha", "Read or hear the complete locally received Satyanarayana Katha attentively; do not replace it with an app-generated summary.", minutes: 30), + step(8, "Offer naivedya and arati", "Present the prepared food and light safely according to the household procedure.", minutes: 10), + step(9, "Pray for truthful living", "Connect the vrata to honest speech, responsible action, gratitude, and service.", minutes: 5), + step(10, "Complete and share prasada", "Close through the officiant's visarjana or concluding prayer, then distribute prasada with allergy awareness.", minutes: 10) + ], + completion: ["Complete the whole katha form agreed at sankalpa.", "Share prasada without food pressure.", "Fulfill only the ethical, practical commitments consciously made."], + safetyNotes: [PoojaContentPolicy.safetyScope, "Fasting is not required by this app. Health needs, medication schedules, pregnancy, age, and eating-disorder risk take priority."], + traditionNote: "Vrata details and katha recensions differ. This app explains the structure but intentionally does not synthesize a supposedly universal katha or priestly mantra sequence.", + sourceNotes: [satyanarayanaSource, householdPujaSource] + ) + + private static let grihaPravesh = PoojaVidhi( + id: "griha-pravesh", + title: "Griha Pravesh Preparation", + sacredFocus: "The Divine, Vastu Purusha, and household deities", + summary: "A truthful household preparation and ceremony map for entering a new home, with formal rites clearly reserved for a qualified priest.", + purpose: "Gratitude, a mindful beginning, household safety, hospitality, and prayer for harmonious living.", + category: .lifeEvent, + practiceLevel: .priestRecommended, + durationMinutes: 60...180, + occasions: ["New home", "First entry", "Housewarming", "Vastu Shanti", "Life event"], + preparation: ["Confirm that the property is legally accessible, structurally safe, ventilated, and has working utilities.", "Choose the muhurta using the home's exact location and the family's tradition; the app's general Panchang is not a substitute for a complete electional chart.", "Ask the officiant for the exact materials, fire-safety plan, and local smoke rules."], + materials: commonMaterials + [ + material("griha-keys", "Keys and essential property documents", "Practical readiness", required: true, alternative: nil), + material("griha-safety", "Fireproof homa area and extinguisher if homa is planned", "Fire safety", required: true, alternative: "Omit homa where unsafe or prohibited"), + material("griha-kalasha", "Kalasha and doorway items", "Tradition-specific entry observance", required: false, alternative: "Use the officiant's list") + ], + steps: [ + step(1, "Complete practical safety checks", "Verify permission to enter, utilities, ventilation, alarms, exits, and a clear walking path before ritual decoration.", minutes: 10), + step(2, "Confirm muhurta and officiant", "Provide the exact address, time zone, household details, and tradition to the qualified person choosing the formal time.", minutes: 5), + step(3, "Clean without creating hazards", "Clean the entrance and worship area; secure rangoli, cords, lamps, and vessels so no one can trip.", minutes: 10), + step(4, "Prepare the doorway welcome", "Arrange the locally received doorway observance and enter in the order guided by the family or officiant.", minutes: 5), + step(5, "Begin with Ganesha remembrance", "Offer a simple prayer for clarity and a safe beginning.", minutes: 5), + step(6, "Perform kalasha or Vastu worship", "Follow the officiant for kalasha sthapana, Vastu Puja, Navagraha Puja, or other formal components.", note: "These components vary and are not generated by the app.", minutes: 30), + step(7, "Conduct homa only when safe", "A trained officiant manages any fire rite under property rules with ventilation and extinguishing equipment.", note: "Never improvise an indoor homa from written app steps.", minutes: 30), + step(8, "Offer household prayer", "Each resident may express gratitude and one intention for how the home will be cared for.", minutes: 10), + step(9, "Prepare food safely", "If boiling milk or cooking prasada is customary, supervise the stove and keep handles, children, garments, and decorations away from heat.", minutes: 15), + step(10, "Close, ventilate, and welcome", "Complete the officiant's closing, extinguish every flame, ventilate smoke, clear hazards, and welcome guests without crowding exits.", minutes: 10) + ], + completion: ["Confirm all flame, embers, gas, and electrical appliances are safe.", "Keep one accessible area quiet for elders, children, disabled guests, and anyone sensitive to smoke.", "Begin the household with an act of hospitality or service."], + safetyNotes: ["Property, fire, smoke, occupancy, and food-safety rules override ceremonial preference.", "A formal homa, Vastu Shanti, or priestly sankalpa must not be improvised from this app."], + traditionNote: "Griha Pravesh varies substantially by region, family, property type, and priestly school. The app provides a ceremony map and safety checklist, not a universal formal paddhati.", + sourceNotes: [householdPujaSource] + ) + + private static let navagraha = PoojaVidhi( + id: "navagraha-pooja", + title: "Navagraha Pooja Preparation", + sacredFocus: "The nine grahas", + summary: "A preparation and overview guide that keeps formal Navagraha worship, homa, donations, and remedial claims within qualified tradition.", + purpose: "Contemplation of time, consequence, responsibility, and harmony—not fear-based guarantees.", + category: .planetary, + practiceLevel: .priestRecommended, + durationMinutes: 45...120, + occasions: ["Navagraha", "Temple observance", "Life transition", "Planetary prayer"], + preparation: ["Ask a qualified practitioner why the rite is recommended and what tradition will be followed.", "Reject guarantees, fear-based sales, or demands for unaffordable gems and donations.", "Confirm whether the rite is temple-based, household worship, or homa."], + materials: commonMaterials + [ + material("navagraha-list", "Officiant-provided materials list", "Tradition-specific colors, grains, flowers, or cloth", required: true, alternative: nil), + material("navagraha-donation", "Optional ethical donation", "Service or charity", required: false, alternative: "A practical act of service within your means"), + material("navagraha-fire", "Approved homa setup if applicable", "Priest-led fire rite", required: false, alternative: "Temple rite or no fire") + ], + steps: [ + step(1, "Clarify the purpose", "State the concern in practical terms and separate spiritual reflection from medical, legal, or financial decisions.", minutes: 5), + step(2, "Choose a qualified setting", "Prefer an established temple, family priest, or transparent practitioner who explains lineage and costs.", minutes: 5), + step(3, "Prepare safely", "Use the provided materials list and arrange flame, smoke, food, and accessibility safeguards.", minutes: 10), + step(4, "State a non-fearful sankalpa", "Frame the intention around wisdom, responsibility, patience, and ethical action rather than guaranteed outcomes.", minutes: 5), + step(5, "Begin with customary preliminaries", "Follow the officiant for Ganesha, kalasha, and purification steps.", minutes: 10), + step(6, "Honor the grahas in sequence", "Follow the received order, names, offerings, and pronunciation. The app does not generate bija-mantras or substitute chants.", minutes: 25), + step(7, "Conduct homa only through the officiant", "If included, maintain local fire rules and never leave embers unattended.", minutes: 20), + step(8, "Connect prayer to conduct", "Identify one grounded action: accountability, service, budgeting, rest, reconciliation, or professional help where needed.", minutes: 5), + step(9, "Offer ethical dana if desired", "Give voluntarily and within your means; do not treat payment or gemstones as a guaranteed cure.", minutes: 5), + step(10, "Close and document the practice", "Complete the officiant's prayer, extinguish flames, and record the lineage or text used for future consistency.", minutes: 5) + ], + completion: ["Keep a note of the officiant, tradition, and text used.", "Do not replace evidence-based medical, legal, safety, or financial action with ritual remedies.", "Prefer voluntary service over fear-driven spending."], + safetyNotes: ["No Pooja, gemstone, donation, or mantra guarantees an outcome.", "Formal homa and lineage mantras require qualified guidance and safe facilities."], + traditionNote: "Navagraha procedures and mantra systems vary. The app intentionally provides an ethical preparation map rather than a fabricated universal ritual.", + sourceNotes: [householdPujaSource] + ) + + private static func material( + _ id: String, + _ name: String, + _ purpose: String, + required: Bool, + alternative: String? + ) -> PoojaMaterial { + PoojaMaterial(id: id, name: name, purpose: purpose, isRequired: required, alternative: alternative) + } + + private static func step( + _ number: Int, + _ title: String, + _ instruction: String, + mantra: PoojaMantra? = nil, + note: String? = nil, + minutes: Int + ) -> PoojaStep { + PoojaStep(number: number, title: title, instruction: instruction, mantra: mantra, note: note, estimatedMinutes: minutes) + } +} diff --git a/ios/native/CosmicRituals/Engine/SubscriptionStore.swift b/ios/native/CosmicRituals/Engine/SubscriptionStore.swift new file mode 100644 index 0000000..52c1173 --- /dev/null +++ b/ios/native/CosmicRituals/Engine/SubscriptionStore.swift @@ -0,0 +1,178 @@ +import Foundation +import StoreKit + +enum SubscriptionCatalog { + static let monthlyProductID = "com.cosmic.rituals.premium.monthly" + static let annualProductID = "com.cosmic.rituals.premium.annual" + static let productIDs = [annualProductID, monthlyProductID] + + static let privacyPolicyURL = URL(string: "https://gohlerdev.github.io/Cosmic-Rituals/privacy/")! + static let termsOfUseURL = URL(string: "https://gohlerdev.github.io/Cosmic-Rituals/terms/")! + static let supportURL = URL(string: "https://gohlerdev.github.io/Cosmic-Rituals/support/")! + static let manageSubscriptionsURL = URL(string: "https://apps.apple.com/account/subscriptions")! + + static let requestedTrialDays = 14 +} + +enum SubscriptionAccessState: Equatable { + case checking + case entitled + case testingAccess + case locked + case storeUnavailable(String) + + var hasPremiumAccess: Bool { + self == .entitled || self == .testingAccess + } + + var isTestingAccess: Bool { + self == .testingAccess + } +} + +enum SubscriptionLaunchPolicy { + static func initialState( + isUITestingPremium: Bool, + isTestingDistribution: Bool + ) -> SubscriptionAccessState { + if isUITestingPremium { + return .entitled + } + if isTestingDistribution { + return .testingAccess + } + return .checking + } +} + +enum SubscriptionEntitlementChecker { + static func grantsAccess(activeProductIDs: Set) -> Bool { + !activeProductIDs.isDisjoint(with: SubscriptionCatalog.productIDs) + } + + static func currentActiveProductIDs() async -> Set { + var activeProductIDs: Set = [] + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result else { continue } + guard SubscriptionCatalog.productIDs.contains(transaction.productID) else { continue } + activeProductIDs.insert(transaction.productID) + } + return activeProductIDs + } + + static func grantsCurrentAccess() async -> Bool { + grantsAccess(activeProductIDs: await currentActiveProductIDs()) + } +} + +@MainActor +final class SubscriptionStore: ObservableObject { + @Published private(set) var accessState: SubscriptionAccessState + @Published private(set) var products: [Product] = [] + @Published private(set) var isEligibleForRequestedTrial = false + @Published private(set) var isRefreshing = false + + private var transactionUpdatesTask: Task? + + init(accessState: SubscriptionAccessState = .checking, listensForTransactions: Bool = true) { + self.accessState = accessState + if listensForTransactions { + transactionUpdatesTask = Task { [weak self] in + for await result in Transaction.updates { + guard !Task.isCancelled else { return } + await self?.handle(transactionResult: result) + } + } + } + } + + deinit { + transactionUpdatesTask?.cancel() + } + + func refresh() async { + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + + if !accessState.hasPremiumAccess { + accessState = .checking + } + + let activeProductIDs = await SubscriptionEntitlementChecker.currentActiveProductIDs() + let hasAccess = SubscriptionEntitlementChecker.grantsAccess(activeProductIDs: activeProductIDs) + if hasAccess { + accessState = .entitled + } + + do { + let loadedProducts = try await Product.products(for: SubscriptionCatalog.productIDs) + products = loadedProducts.sorted(by: productSort) + isEligibleForRequestedTrial = await hasEligibleRequestedTrial(in: products) + + if !hasAccess { + accessState = products.isEmpty + ? .storeUnavailable("Subscriptions are temporarily unavailable. Check your connection and try again.") + : .locked + } + } catch is CancellationError { + return + } catch { + if !hasAccess { + accessState = .storeUnavailable("The App Store could not load subscription options. Check your connection and try again.") + } + } + } + + func restorePurchases() async { + do { + try await AppStore.sync() + await refresh() + } catch is CancellationError { + return + } catch { + accessState = .storeUnavailable("Purchases could not be restored. Confirm the App Store account and try again.") + } + } + + private func handle(transactionResult: VerificationResult) async { + guard case .verified(let transaction) = transactionResult else { return } + guard SubscriptionCatalog.productIDs.contains(transaction.productID) else { return } + await transaction.finish() + await refresh() + } + + private func hasEligibleRequestedTrial(in products: [Product]) async -> Bool { + for product in products { + guard let subscription = product.subscription, + let offer = subscription.introductoryOffer, + offer.paymentMode == .freeTrial, + offer.period.unit == .week, + offer.period.value == 2 else { continue } + if await subscription.isEligibleForIntroOffer { + return true + } + } + return false + } + + private func productSort(_ lhs: Product, _ rhs: Product) -> Bool { + guard let lhsPeriod = lhs.subscription?.subscriptionPeriod, + let rhsPeriod = rhs.subscription?.subscriptionPeriod else { + return lhs.id < rhs.id + } + return subscriptionPeriodDays(lhsPeriod) > subscriptionPeriodDays(rhsPeriod) + } + + private func subscriptionPeriodDays(_ period: Product.SubscriptionPeriod) -> Int { + let multiplier: Int + switch period.unit { + case .day: multiplier = 1 + case .week: multiplier = 7 + case .month: multiplier = 30 + case .year: multiplier = 365 + @unknown default: multiplier = 0 + } + return period.value * multiplier + } +} diff --git a/ios/native/CosmicRituals/Theme/CosmicTheme.swift b/ios/native/CosmicRituals/Theme/CosmicTheme.swift index 2996756..fda09ed 100644 --- a/ios/native/CosmicRituals/Theme/CosmicTheme.swift +++ b/ios/native/CosmicRituals/Theme/CosmicTheme.swift @@ -53,9 +53,9 @@ struct CosmicColorScheme: Sendable { /// Explicit semantic roles prevent an already-presented sheet from retaining /// dark system label colors while the user switches to a light app theme. /// Dark variants continue using the existing system semantics unchanged. - var semanticPrimaryText: Color { isLight ? onSurface : .primary } - var semanticSecondaryText: Color { isLight ? onSurface.opacity(0.72) : .secondary } - var semanticTertiaryText: Color { isLight ? onSurface.opacity(0.60) : Color.secondary.opacity(0.74) } + var semanticPrimaryText: Color { onSurface } + var semanticSecondaryText: Color { onSurface.opacity(isLight ? 0.72 : 0.76) } + var semanticTertiaryText: Color { onSurface.opacity(isLight ? 0.60 : 0.62) } var semanticDivider: Color { isLight ? onSurface.opacity(0.16) : Color.white.opacity(0.10) } var semanticBorder: Color { isLight ? onSurface.opacity(0.22) : Color.white.opacity(0.10) } var semanticHighlight: Color { isLight ? onSurface.opacity(0.08) : Color.white.opacity(0.20) } @@ -65,86 +65,86 @@ struct CosmicColorScheme: Sendable { var selectedControlForeground: Color { background } static let obsidianGold = CosmicColorScheme( - id: "obsidian_gold", displayName: "Obsidian Gold", - primary: Color(hex: "E5A97C"), - secondary: Color(hex: "7E8EAA"), - tertiary: Color(hex: "73A5AA"), - background: Color(hex: "08090F"), - surface: Color(hex: "111421"), - surfaceElevated: Color(hex: "1A2030"), - onSurface: Color(hex: "E8EAED"), - gradientTop: Color(hex: "08090F"), - gradientBottom: Color(hex: "130E20"), + id: "obsidian_gold", displayName: "Deep Sandhya", + primary: Color(hex: "F0B36E"), + secondary: Color(hex: "D66A47"), + tertiary: Color(hex: "9CB77D"), + background: Color(hex: "100703"), + surface: Color(hex: "211108"), + surfaceElevated: Color(hex: "321A0D"), + onSurface: Color(hex: "FFF3E4"), + gradientTop: Color(hex: "090301"), + gradientBottom: Color(hex: "260D04"), isLight: false ) static let midnightAurora = CosmicColorScheme( - id: "midnight_aurora", displayName: "Midnight Aurora", - primary: Color(hex: "8FD5C4"), - secondary: Color(hex: "A58FD6"), - tertiary: Color(hex: "E7B97A"), - background: Color(hex: "050E14"), - surface: Color(hex: "0D1A22"), - surfaceElevated: Color(hex: "162430"), - onSurface: Color(hex: "EAF3F1"), - gradientTop: Color(hex: "050E14"), - gradientBottom: Color(hex: "091620"), + id: "midnight_aurora", displayName: "Temple Dawn", + primary: Color(hex: "F4C574"), + secondary: Color(hex: "C85F46"), + tertiary: Color(hex: "D99458"), + background: Color(hex: "150806"), + surface: Color(hex: "28110D"), + surfaceElevated: Color(hex: "3B1B13"), + onSurface: Color(hex: "FFF2DF"), + gradientTop: Color(hex: "110503"), + gradientBottom: Color(hex: "351008"), isLight: false ) static let celestialPlum = CosmicColorScheme( - id: "celestial_plum", displayName: "Celestial Plum", - primary: Color(hex: "D7A6B8"), - secondary: Color(hex: "9DA7D9"), - tertiary: Color(hex: "E2C278"), - background: Color(hex: "0E0A14"), - surface: Color(hex: "1A1224"), - surfaceElevated: Color(hex: "261A34"), - onSurface: Color(hex: "F1EAF2"), - gradientTop: Color(hex: "0E0A14"), - gradientBottom: Color(hex: "160E22"), + id: "celestial_plum", displayName: "Kumkum Night", + primary: Color(hex: "E8A764"), + secondary: Color(hex: "C84F62"), + tertiary: Color(hex: "D7B56B"), + background: Color(hex: "140508"), + surface: Color(hex: "260D13"), + surfaceElevated: Color(hex: "39151D"), + onSurface: Color(hex: "FFF0E8"), + gradientTop: Color(hex: "100305"), + gradientBottom: Color(hex: "310811"), isLight: false ) static let cloudDancer = CosmicColorScheme( - id: "cloud_dancer", displayName: "Cloud Dancer", - primary: Color(hex: "755F4C"), - secondary: Color(hex: "4F7F83"), - tertiary: Color(hex: "956C88"), - background: Color(hex: "FFFEFB"), - surface: Color(hex: "FFFCF8"), - surfaceElevated: Color(hex: "F6F0E8"), - onSurface: Color(hex: "24211D"), - gradientTop: Color(hex: "FFF8F2"), - gradientBottom: Color(hex: "EDE6DC"), + id: "cloud_dancer", displayName: "Ghee Lamp Ivory", + primary: Color(hex: "98541F"), + secondary: Color(hex: "A43F32"), + tertiary: Color(hex: "5E7341"), + background: Color(hex: "FFF8E9"), + surface: Color(hex: "FFFCF4"), + surfaceElevated: Color(hex: "F6E7C9"), + onSurface: Color(hex: "2B1C0F"), + gradientTop: Color(hex: "FFFDF5"), + gradientBottom: Color(hex: "F3D9AD"), isLight: true ) static let mochaAura = CosmicColorScheme( - id: "mocha_aura", displayName: "Mocha Aura", - primary: Color(hex: "8F604F"), - secondary: Color(hex: "4F7D63"), - tertiary: Color(hex: "556F95"), - background: Color(hex: "FFFDF9"), - surface: Color(hex: "FFF8F1"), - surfaceElevated: Color(hex: "F6ECE2"), - onSurface: Color(hex: "241C17"), - gradientTop: Color(hex: "FFF8F1"), - gradientBottom: Color(hex: "EDE0D4"), + id: "mocha_aura", displayName: "Sandalwood", + primary: Color(hex: "8A4C24"), + secondary: Color(hex: "9D4A38"), + tertiary: Color(hex: "667647"), + background: Color(hex: "FAF0DF"), + surface: Color(hex: "FFF9ED"), + surfaceElevated: Color(hex: "EDDBBC"), + onSurface: Color(hex: "2C2116"), + gradientTop: Color(hex: "FFF9ED"), + gradientBottom: Color(hex: "E8CEAA"), isLight: true ) static let celestialMist = CosmicColorScheme( - id: "celestial_mist", displayName: "Celestial Mist", - primary: Color(hex: "336F86"), - secondary: Color(hex: "725EA1"), - tertiary: Color(hex: "A56F4E"), - background: Color(hex: "FDFFFF"), - surface: Color(hex: "F8FCFE"), - surfaceElevated: Color(hex: "EEF6FA"), - onSurface: Color(hex: "122027"), - gradientTop: Color(hex: "F0F8FF"), - gradientBottom: Color(hex: "E0EFF7"), + id: "celestial_mist", displayName: "Lotus Ash", + primary: Color(hex: "7D4A32"), + secondary: Color(hex: "8A4F58"), + tertiary: Color(hex: "5C7055"), + background: Color(hex: "F4F0EA"), + surface: Color(hex: "FCFAF6"), + surfaceElevated: Color(hex: "E8DED2"), + onSurface: Color(hex: "28201B"), + gradientTop: Color(hex: "FBF8F2"), + gradientBottom: Color(hex: "E4D6C8"), isLight: true ) } @@ -176,12 +176,12 @@ enum CosmicThemeVariant: String, CaseIterable, Identifiable { var displayName: String { switch self { - case .cosmicDark: return "Obsidian Gold" - case .nebulaBlue: return "Midnight Aurora" - case .vedicSaffron: return "Celestial Plum" - case .lunarSilver: return "Cloud Dancer" - case .solarGold: return "Mocha Aura" - case .auroraGreen: return "Celestial Mist" + case .cosmicDark: return "Deep Sandhya" + case .nebulaBlue: return "Temple Dawn" + case .vedicSaffron: return "Kumkum Night" + case .lunarSilver: return "Ghee Lamp Ivory" + case .solarGold: return "Sandalwood" + case .auroraGreen: return "Lotus Ash" } } } @@ -199,202 +199,61 @@ extension CosmicThemeVariant { } } -// MARK: - Animated Starfield Background +// MARK: - Ceremonial Background -private struct StarData { - let x, y: CGFloat - let r: CGFloat - let baseOpacity: Double - let twinklePhase: Double - let tier: Int // 0=tiny, 1=medium, 2=large with glow -} - -struct CosmicStarfieldBackground: View { +struct RitualSanctuaryBackground: View { @Environment(\.cosmicTheme) private var theme - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private let stars: [StarData] = { - var out = [StarData]() - var rng = SystemRandomNumberGenerator() - // Tier 0 — tiny, very dim - for i in 0..<90 { - out.append(StarData( - x: CGFloat.random(in: 0...1, using: &rng), - y: CGFloat.random(in: 0...1, using: &rng), - r: CGFloat.random(in: 0.28...0.58, using: &rng), - baseOpacity: Double.random(in: 0.12...0.38, using: &rng), - twinklePhase: Double(i) * 0.41, - tier: 0 - )) - } - // Tier 1 — medium - for i in 0..<65 { - out.append(StarData( - x: CGFloat.random(in: 0...1, using: &rng), - y: CGFloat.random(in: 0...1, using: &rng), - r: CGFloat.random(in: 0.58...1.05, using: &rng), - baseOpacity: Double.random(in: 0.30...0.60, using: &rng), - twinklePhase: Double(i) * 0.73, - tier: 1 - )) - } - // Tier 2 — large, bright, with glow halo - for i in 0..<22 { - out.append(StarData( - x: CGFloat.random(in: 0.04...0.96, using: &rng), - y: CGFloat.random(in: 0.04...0.96, using: &rng), - r: CGFloat.random(in: 1.05...1.75, using: &rng), - baseOpacity: Double.random(in: 0.55...0.90, using: &rng), - twinklePhase: Double(i) * 1.09, - tier: 2 - )) - } - return out - }() + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency var body: some View { - if theme.isLight { - LinearGradient(colors: [theme.gradientTop, theme.gradientBottom], startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - } else { - darkBackground - } - } + GeometryReader { proxy in + ZStack { + theme.background - private var darkBackground: some View { - TimelineView(.animation(minimumInterval: reduceMotion ? 60 : nil, paused: reduceMotion)) { ctx in - let t = ctx.date.timeIntervalSinceReferenceDate - Canvas { gfx, size in - // 1. Background gradient - gfx.fill( - Path(CGRect(origin: .zero, size: size)), - with: .linearGradient( - Gradient(colors: [theme.gradientTop, theme.gradientBottom]), - startPoint: .zero, - endPoint: CGPoint(x: 0, y: size.height) - ) - ) + Image("RitualSanctuaryBackground") + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + .saturation(theme.isLight ? 0.72 : 0.92) + .contrast(theme.isLight ? 0.88 : 1.02) + .opacity(imageOpacity) - // 2. Nebula clouds (3 layers, slow breathing) - let breathe = 0.5 + 0.5 * sin(t * 0.12) - // Blue-violet nebula — top right - gfx.fill( - Path(ellipseIn: CGRect(x: size.width*0.42, y: -size.height*0.05, - width: size.width*0.65, height: size.height*0.40)), - with: .color(theme.secondary.opacity(0.045 + breathe * 0.018)) - ) - // Warm gold nebula — left center - gfx.fill( - Path(ellipseIn: CGRect(x: -size.width*0.10, y: size.height*0.30, - width: size.width*0.50, height: size.height*0.22)), - with: .color(theme.primary.opacity(0.038 + breathe * 0.012)) - ) - // Teal nebula — bottom right, very faint - gfx.fill( - Path(ellipseIn: CGRect(x: size.width*0.60, y: size.height*0.68, - width: size.width*0.45, height: size.height*0.18)), - with: .color(theme.tertiary.opacity(0.025 + breathe * 0.010)) + LinearGradient( + colors: readabilityVeil, + startPoint: .top, + endPoint: .bottom ) - - // 3. Stars - let twinkleRate = 1.209 - for star in stars { - let twinkle = 0.55 + 0.45 * sin(t * twinkleRate + star.twinklePhase) - let opacity = star.baseOpacity * twinkle - let pt = CGPoint(x: star.x * size.width, y: star.y * size.height) - let r = star.r - - if star.tier == 2 { - // Outer soft glow - let g1r = r * 4.5 - gfx.fill( - Path(ellipseIn: CGRect(x: pt.x-g1r, y: pt.y-g1r, width: g1r*2, height: g1r*2)), - with: .color(Color.white.opacity(opacity * 0.055)) - ) - // Inner glow - let g2r = r * 2.2 - gfx.fill( - Path(ellipseIn: CGRect(x: pt.x-g2r, y: pt.y-g2r, width: g2r*2, height: g2r*2)), - with: .color(Color.white.opacity(opacity * 0.14)) - ) - } - // Star core - let finalOpacity: Double - switch star.tier { - case 0: finalOpacity = opacity * 0.22 - case 1: finalOpacity = opacity * 0.42 - default: finalOpacity = opacity * 0.88 - } - gfx.fill( - Path(ellipseIn: CGRect(x: pt.x-r, y: pt.y-r, width: r*2, height: r*2)), - with: .color(Color.white.opacity(finalOpacity)) - ) - } - - // 4. Shooting star — appears every 24 seconds, lasts 0.72s - let shootPeriod = 24.0 - let shootDuration = 0.72 - let shootPhase = t.truncatingRemainder(dividingBy: shootPeriod) - if shootPhase < shootDuration { - let progress = shootPhase / shootDuration - // Ease in, peak, ease out - let eased: Double - if progress < 0.15 { eased = progress / 0.15 } - else if progress > 0.72 { eased = (1.0 - progress) / 0.28 } - else { eased = 1.0 } - - let sx = size.width * 0.74, sy = size.height * 0.07 - let ex = size.width * 0.32, ey = size.height * 0.44 - let headX = sx + progress * (ex - sx) - let headY = sy + progress * (ey - sy) - let dx = (ex - sx), dy = (ey - sy) - - // Trail segments - for j in 0..<16 { - let frac = Double(j) / 16.0 - let tx = headX - frac * dx * 0.20 - let ty = headY - frac * dy * 0.20 - let tOpacity = (1.0 - frac) * (1.0 - frac) * eased * 0.85 - let tr = max(0.18, (1.0 - frac * 0.72) * 1.7) - gfx.fill( - Path(ellipseIn: CGRect(x: tx-tr, y: ty-tr, width: tr*2, height: tr*2)), - with: .color(Color.white.opacity(tOpacity)) - ) - } - // Head glow - let hgr = 4.5 - gfx.fill( - Path(ellipseIn: CGRect(x: headX-hgr, y: headY-hgr, width: hgr*2, height: hgr*2)), - with: .color(Color.white.opacity(eased * 0.22)) - ) - let hr = 1.6 - gfx.fill( - Path(ellipseIn: CGRect(x: headX-hr, y: headY-hr, width: hr*2, height: hr*2)), - with: .color(Color.white.opacity(eased * 0.92)) - ) - } - - // 5. Vignette — dark edges - let ve = min(size.width, size.height) * 0.38 - gfx.fill(Path(CGRect(x: 0, y: 0, width: size.width, height: ve)), - with: .linearGradient(Gradient(colors: [Color.black.opacity(0.42), .clear]), - startPoint: .init(x: 0, y: 0), endPoint: .init(x: 0, y: ve))) - gfx.fill(Path(CGRect(x: 0, y: size.height-ve, width: size.width, height: ve)), - with: .linearGradient(Gradient(colors: [.clear, Color.black.opacity(0.48)]), - startPoint: .init(x: 0, y: size.height-ve), endPoint: .init(x: 0, y: size.height))) - gfx.fill(Path(CGRect(x: 0, y: 0, width: ve*0.65, height: size.height)), - with: .linearGradient(Gradient(colors: [Color.black.opacity(0.28), .clear]), - startPoint: .init(x: 0, y: 0), endPoint: .init(x: ve*0.65, y: 0))) - gfx.fill(Path(CGRect(x: size.width-ve*0.65, y: 0, width: ve*0.65, height: size.height)), - with: .linearGradient(Gradient(colors: [.clear, Color.black.opacity(0.28)]), - startPoint: .init(x: size.width-ve*0.65, y: 0), endPoint: .init(x: size.width, y: 0))) } } .ignoresSafeArea() + .accessibilityHidden(true) + } + + private var imageOpacity: Double { + if reduceTransparency { + return theme.isLight ? 0.22 : 0.44 + } + return theme.isLight ? 0.42 : 0.78 + } + + private var readabilityVeil: [Color] { + if theme.isLight { + return [ + theme.gradientTop.opacity(0.84), + theme.background.opacity(0.72), + theme.gradientBottom.opacity(0.86) + ] + } + return [ + Color.black.opacity(0.34), + theme.background.opacity(0.30), + theme.gradientBottom.opacity(0.62) + ] } } -// MARK: - Glass Card +// MARK: - Ceremonial Surface struct CosmicGlassCard: View { let cornerRadius: CGFloat @@ -403,7 +262,7 @@ struct CosmicGlassCard: View { @Environment(\.cosmicTheme) private var theme @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - init(cornerRadius: CGFloat = 20, accentBorder: Bool = false, @ViewBuilder content: @escaping () -> Content) { + init(cornerRadius: CGFloat = 26, accentBorder: Bool = false, @ViewBuilder content: @escaping () -> Content) { self.cornerRadius = cornerRadius self.accentBorder = accentBorder self.content = content @@ -413,27 +272,28 @@ struct CosmicGlassCard: View { content() .padding(16) .background( - theme.surface.opacity(reduceTransparency ? 1.0 : (theme.isLight ? 0.88 : 0.24)), + theme.surface.opacity(reduceTransparency ? 1.0 : (theme.isLight ? 0.96 : 0.82)), in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(borderGradient, lineWidth: accentBorder ? 1.0 : 0.6) ) - // Specular top highlight .overlay(alignment: .top) { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill( - LinearGradient( - colors: [theme.semanticHighlight, theme.semanticHighlight.opacity(0)], - startPoint: .leading, endPoint: .trailing - ) - ) + LinearGradient( + colors: [.clear, theme.primary.opacity(accentBorder ? 0.62 : 0.24), .clear], + startPoint: .leading, + endPoint: .trailing + ) .frame(height: 1) - .padding(.horizontal, cornerRadius * 0.5) + .padding(.horizontal, cornerRadius) .padding(.top, 1) } + .shadow( + color: theme.isLight ? theme.onSurface.opacity(0.08) : Color.black.opacity(0.24), + radius: accentBorder ? 18 : 10, + y: accentBorder ? 8 : 4 + ) } private var borderGradient: LinearGradient { @@ -523,6 +383,7 @@ struct CosmicSectionHeader: View { CosmicIcon(name: icon, size: 15) Text(title) .font(.subheadline.weight(.semibold)) + .fontDesign(.serif) .foregroundStyle(theme.semanticPrimaryText) Spacer() } @@ -917,7 +778,7 @@ struct CosmicScreenHeader: View { VStack(alignment: .leading, spacing: 4) { Text(title) - .font(.title2.weight(.bold)) + .font(.system(.title2, design: .serif, weight: .bold)) .foregroundStyle(theme.onSurface) .lineLimit(1) .minimumScaleFactor(0.82) diff --git a/ios/native/CosmicRituals/Views/GrahaPositionsView.swift b/ios/native/CosmicRituals/Views/GrahaPositionsView.swift index 337df39..00c8d86 100644 --- a/ios/native/CosmicRituals/Views/GrahaPositionsView.swift +++ b/ios/native/CosmicRituals/Views/GrahaPositionsView.swift @@ -1,5 +1,8 @@ import SwiftUI +// Prototype view intentionally not routed into shipping navigation. The +// low-precision source model must meet ACCURACY.md's evidence gate first. + struct GrahaPositionsCard: View { let grahas: [GrahaPosition] @Environment(\.cosmicTheme) private var theme diff --git a/ios/native/CosmicRituals/Views/MonthlyCalendarView.swift b/ios/native/CosmicRituals/Views/MonthlyCalendarView.swift index 8632141..7beca0e 100644 --- a/ios/native/CosmicRituals/Views/MonthlyCalendarView.swift +++ b/ios/native/CosmicRituals/Views/MonthlyCalendarView.swift @@ -136,11 +136,10 @@ struct MonthlyCalendarView: View { private var legendCard: some View { CosmicGlassCard { VStack(alignment: .leading, spacing: 8) { - CosmicSectionHeader(title: "Quality Legend", icon: "circle.grid.2x2.fill") + CosmicSectionHeader(title: "Sunrise Snapshot Legend", icon: "circle.grid.2x2.fill") HStack(spacing: 16) { - legendItem(.green, "Auspicious yoga") - legendItem(.red, "Inauspicious yoga") - legendItem(.orange, "Mixed") + legendItem(.green, "Favourable yoga") + legendItem(.red, "Caution yoga") } HStack(spacing: 16) { legendItem(.purple, "Purnima / Ekadashi") @@ -187,7 +186,9 @@ struct MonthlyCalendarView: View { longitude: calculationContext.longitude, timeZoneIdentifier: calculationContext.timeZoneIdentifier ) - cache[key] = CosmicEngine.getPanchang(context: context) + // Month cells need the sunrise snapshot, not four sub-second boundary + // solves per day. Detailed transitions are computed only on the daily view. + cache[key] = CosmicEngine.getPanchang(context: context, includeTransitions: false) } panchangCache = cache } @@ -250,7 +251,7 @@ private struct DayCell: View { } .buttonStyle(.plain) .accessibilityLabel(date.ritualCompleteDate(in: calendar.timeZone)) - .accessibilityValue("\(panchang?.tithiName ?? "Panchang unavailable"), \(yogaDetail.isAuspicious ? "auspicious" : "caution")") + .accessibilityValue("At sunrise, \(panchang?.tithiName ?? "Panchang unavailable"), \(yogaDetail.isAuspicious ? "favourable yoga" : "caution yoga")") } private func shortTithi(_ idx: Int) -> String { diff --git a/ios/native/CosmicRituals/Views/MuhurtaDetailView.swift b/ios/native/CosmicRituals/Views/MuhurtaDetailView.swift index b985caf..ea82e3a 100644 --- a/ios/native/CosmicRituals/Views/MuhurtaDetailView.swift +++ b/ios/native/CosmicRituals/Views/MuhurtaDetailView.swift @@ -21,7 +21,7 @@ struct MuhurtaDetailView: View { var body: some View { NavigationStack { ZStack { - CosmicStarfieldBackground() + RitualSanctuaryBackground() ScrollView { VStack(spacing: 16) { header diff --git a/ios/native/CosmicRituals/Views/PanchangExperienceViews.swift b/ios/native/CosmicRituals/Views/PanchangExperienceViews.swift index e682712..47214fd 100644 --- a/ios/native/CosmicRituals/Views/PanchangExperienceViews.swift +++ b/ios/native/CosmicRituals/Views/PanchangExperienceViews.swift @@ -20,7 +20,7 @@ enum RitualExperienceMode: String, CaseIterable, Identifiable { var summary: String { switch self { case .ritualNow: - return "A glanceable local-noon reference" + return "A glanceable local-sunrise reference" case .vedicLedger: return "An editorial Vedic almanac" case .fiveLimbFocus: @@ -102,25 +102,15 @@ struct PanchangExperienceHome: View { let mode: RitualExperienceMode @Binding var selectedDate: Date let panchang: Panchang - let tithiEndTime: Date? let showTithiDetail: () -> Void let showYogaDetail: () -> Void let showKaranaDetail: () -> Void - @Environment(\.cosmicTheme) private var theme @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: 10) { - HStack(spacing: 6) { - Image(systemName: "clock.badge.info") - .foregroundStyle(theme.primary) - Text(CalculationContext.dailySnapshotDisclosure) - .foregroundStyle(theme.semanticSecondaryText) - Spacer(minLength: 0) - } - .font(.caption2.weight(.medium)) - .accessibilityElement(children: .combine) + RitualCalculationSeal(panchang: panchang) Group { switch mode { @@ -128,7 +118,6 @@ struct PanchangExperienceHome: View { RitualNowExperience( selectedDate: $selectedDate, panchang: panchang, - tithiEndTime: tithiEndTime, showTithiDetail: showTithiDetail, showYogaDetail: showYogaDetail, showKaranaDetail: showKaranaDetail @@ -137,7 +126,6 @@ struct PanchangExperienceHome: View { VedicLedgerExperience( selectedDate: $selectedDate, panchang: panchang, - tithiEndTime: tithiEndTime, showTithiDetail: showTithiDetail, showYogaDetail: showYogaDetail, showKaranaDetail: showKaranaDetail @@ -146,7 +134,6 @@ struct PanchangExperienceHome: View { FiveLimbFocusExperience( selectedDate: $selectedDate, panchang: panchang, - tithiEndTime: tithiEndTime, showTithiDetail: showTithiDetail, showYogaDetail: showYogaDetail, showKaranaDetail: showKaranaDetail @@ -162,12 +149,160 @@ struct PanchangExperienceHome: View { } } +/// Keeps the calculation contract beside the result instead of burying it in +/// Settings. This is disclosure, not an accuracy percentage or certification. +private struct RitualCalculationSeal: View { + let panchang: Panchang + + @Environment(\.cosmicTheme) private var theme + @Environment(\.timeZone) private var timeZone + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 8) { + Label("Ritual-day calculation", systemImage: "checkmark.seal.fill") + .font(.caption.weight(.bold)) + .fontDesign(.serif) + .foregroundStyle(theme.semanticPrimaryText) + Spacer(minLength: 8) + Text("ON DEVICE") + .font(.system(size: 9, weight: .bold, design: .rounded)) + .tracking(0.8) + .foregroundStyle(theme.primary) + } + + Text(panchang.referenceDisclosure(in: timeZone)) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .fixedSize(horizontal: false, vertical: true) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { calculationDetails } + VStack(alignment: .leading, spacing: 5) { calculationDetails } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + theme.surface.opacity(theme.isLight ? 0.92 : 0.76), + in: RoundedRectangle(cornerRadius: 18, style: .continuous) + ) + .overlay(alignment: .leading) { + Capsule() + .fill(theme.primary) + .frame(width: 3) + .padding(.vertical, 12) + } + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(theme.semanticBorder, lineWidth: 0.6) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "Ritual-day calculation, on device. \(panchang.referenceDisclosure(in: timeZone)). Lahiri sidereal system. Time zone \(timeZone.identifier)." + ) + } + + @ViewBuilder + private var calculationDetails: some View { + Label("Lahiri sidereal", systemImage: "circle.grid.cross.fill") + Label(timeZone.identifier, systemImage: "globe.asia.australia.fill") + } +} + +// MARK: - Daily transition timeline + +struct PanchangTransitionTimeline: View { + let referenceDate: Date + let transitions: [PanchangTransition] + + @Environment(\.cosmicTheme) private var theme + @Environment(\.timeZone) private var timeZone + + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar + } + + var body: some View { + CosmicGlassCard(cornerRadius: 18) { + VStack(alignment: .leading, spacing: 10) { + CosmicSectionHeader(title: "Next Limb Changes", icon: "clock.arrow.circlepath") + + if transitions.isEmpty { + Label("Transition times are unavailable.", systemImage: "exclamationmark.circle") + .font(.subheadline) + .foregroundStyle(theme.semanticSecondaryText) + } else { + ForEach(Array(transitions.enumerated()), id: \.element.kind) { index, transition in + HStack(spacing: 10) { + Image(systemName: symbol(for: transition.kind)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(color(for: transition.kind)) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(transition.kind.rawValue.capitalized) + .font(.caption2.weight(.semibold)) + .foregroundStyle(theme.semanticSecondaryText) + Text("\(transition.currentName) → \(transition.nextName)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(theme.semanticPrimaryText) + .lineLimit(1) + .minimumScaleFactor(0.78) + } + Spacer(minLength: 8) + Text(timeLabel(for: transition.endTime)) + .font(.caption.weight(.semibold)) + .monospacedDigit() + .multilineTextAlignment(.trailing) + .foregroundStyle(color(for: transition.kind)) + } + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(transition.kind.rawValue.capitalized), \(transition.currentName) changes to \(transition.nextName) at \(timeLabel(for: transition.endTime))" + ) + + if index < transitions.count - 1 { + Divider().overlay(theme.semanticDivider) + } + } + } + } + } + } + + private func timeLabel(for date: Date) -> String { + let time = date.ritualShortTime(in: timeZone) + guard !calendar.isDate(date, inSameDayAs: referenceDate) else { return time } + return "\(date.ritualDate(template: "EEE d MMM", in: timeZone))\n\(time)" + } + + private func symbol(for kind: PanchangLimbKind) -> String { + switch kind { + case .tithi: return "moon.fill" + case .nakshatra: return "sparkles" + case .yoga: return "circle.grid.cross.fill" + case .karana: return "divide.circle.fill" + } + } + + private func color(for kind: PanchangLimbKind) -> Color { + switch kind { + case .tithi: return .blue + case .nakshatra: return theme.tertiary + case .yoga: return .green + case .karana: return .orange + } + } +} + // MARK: - Daily Snapshot private struct RitualNowExperience: View { @Binding var selectedDate: Date let panchang: Panchang - let tithiEndTime: Date? let showTithiDetail: () -> Void let showYogaDetail: () -> Void let showKaranaDetail: () -> Void @@ -218,21 +353,22 @@ private struct RitualNowExperience: View { PanchangExperienceRow( symbol: "moon.fill", tint: .blue, label: "Tithi", supporting: "Lunar day", value: panchang.tithiName, - detail: tithiEndLabel, action: showTithiDetail + detail: transitionLabel(for: .tithi), action: showTithiDetail ) PanchangExperienceRow( symbol: "sparkles", tint: theme.tertiary, - label: "Nakshatra", supporting: "Lunar mansion", value: panchang.nakshatraName + label: "Nakshatra", supporting: "Lunar mansion", value: panchang.nakshatraName, + detail: transitionLabel(for: .nakshatra) ) PanchangExperienceRow( symbol: "circle.grid.cross.fill", tint: .green, label: "Yoga", supporting: "Union", value: panchang.yogaName, - action: showYogaDetail + detail: transitionLabel(for: .yoga), action: showYogaDetail ) PanchangExperienceRow( symbol: "divide.circle.fill", tint: .orange, label: "Karana", supporting: "Half-tithi", value: panchang.karanaName, - isLast: true, action: showKaranaDetail + detail: transitionLabel(for: .karana), isLast: true, action: showKaranaDetail ) } } @@ -264,15 +400,17 @@ private struct RitualNowExperience: View { } } - private var tithiEndLabel: String { - guard let tithiEndTime else { return "time unavailable" } - return "ends \(tithiEndTime.ritualShortTime(in: timeZone))" + private func transitionLabel(for kind: PanchangLimbKind) -> String { + guard let transition = panchang.transitions.transition(for: kind) else { + return "transition unavailable" + } + return "until \(transition.endTime.ritualTransitionLabel(relativeTo: panchang.date, in: timeZone))" } private var transitionDetail: String { - guard let tithiEndTime else { return "Transition time unavailable" } - let nextIndex = (panchang.tithiIndex + 1) % Panchang.tithiNames.count - return "\(Panchang.tithiNames[nextIndex]) begins at \(tithiEndTime.ritualShortTime(in: timeZone))" + guard let transition = panchang.transitions.tithi else { return "Transition time unavailable" } + let start = transition.endTime.ritualTransitionLabel(relativeTo: panchang.date, in: timeZone) + return "\(transition.nextName) begins at \(start)" } } @@ -281,7 +419,6 @@ private struct RitualNowExperience: View { private struct VedicLedgerExperience: View { @Binding var selectedDate: Date let panchang: Panchang - let tithiEndTime: Date? let showTithiDetail: () -> Void let showYogaDetail: () -> Void let showKaranaDetail: () -> Void @@ -349,8 +486,12 @@ private struct VedicLedgerExperience: View { Image(systemName: "moon.stars.fill") .foregroundStyle(theme.secondary) Divider().frame(height: 30) - Text(tithiEndTime.map { - "\(panchang.tithiName) ends \($0.ritualShortTime(in: timeZone))" + Text(panchang.transitions.tithi.map { + let ending = $0.endTime.ritualTransitionLabel( + relativeTo: panchang.date, + in: timeZone + ) + return "\(panchang.tithiName) ends \(ending)" } ?? "Open \(panchang.tithiName) details") .font(.system(.headline, design: .serif)) Spacer() @@ -390,7 +531,6 @@ private enum FocusLimb: String, CaseIterable, Identifiable { private struct FiveLimbFocusExperience: View { @Binding var selectedDate: Date let panchang: Panchang - let tithiEndTime: Date? let showTithiDetail: () -> Void let showYogaDetail: () -> Void let showKaranaDetail: () -> Void @@ -558,8 +698,17 @@ private struct FiveLimbFocusExperience: View { } private func supportingText(for limb: FocusLimb) -> String? { - guard limb == .tithi, let tithiEndTime else { return nil } - return "ends \(tithiEndTime.ritualShortTime(in: timeZone))" + let kind: PanchangLimbKind? + switch limb { + case .vara: kind = nil + case .tithi: kind = .tithi + case .nakshatra: kind = .nakshatra + case .yoga: kind = .yoga + case .karana: kind = .karana + } + guard let kind, let transition = panchang.transitions.transition(for: kind) else { return nil } + let ending = transition.endTime.ritualTransitionLabel(relativeTo: panchang.date, in: timeZone) + return "until \(ending) · then \(transition.nextName)" } private func color(for limb: FocusLimb) -> Color { @@ -583,6 +732,7 @@ struct RitualDestinationDescriptor: Equatable, Sendable { RitualDestinationDescriptor(title: "Panchang", symbol: "eye.fill"), RitualDestinationDescriptor(title: "Timing", symbol: "clock.fill"), RitualDestinationDescriptor(title: "Muhurtas", symbol: "clock.badge.checkmark.fill"), + RitualDestinationDescriptor(title: "Pooja", symbol: "hands.and.sparkles.fill"), RitualDestinationDescriptor(title: "Calendar", symbol: "calendar") ] } @@ -613,12 +763,14 @@ struct RitualDestinationBar: View { } } } + .frame(maxWidth: .infinity) .padding(7) .glassEffect( .regular, in: RoundedRectangle(cornerRadius: 28, style: .continuous) ) } + .frame(maxWidth: .infinity) .accessibilityElement(children: .contain) .accessibilityLabel("Panchang destinations") } @@ -653,6 +805,7 @@ struct RitualDestinationBar: View { .frame(minHeight: usesIconOnlyPresentation ? 52 : 48) .contentShape(Rectangle()) } + .frame(maxWidth: .infinity) .accessibilityLabel(destination.title) .accessibilityValue(selection == index ? "Selected" : "Not selected") .accessibilityHint("Shows the \(destination.title) destination") diff --git a/ios/native/CosmicRituals/Views/PanchangLimbDetailSheets.swift b/ios/native/CosmicRituals/Views/PanchangLimbDetailSheets.swift index cacab96..8dea1e9 100644 --- a/ios/native/CosmicRituals/Views/PanchangLimbDetailSheets.swift +++ b/ios/native/CosmicRituals/Views/PanchangLimbDetailSheets.swift @@ -11,7 +11,7 @@ struct TithiDetailSheet: View { let detail = TithiDetail.from(tithiIndex: tithiIndex) NavigationStack { ZStack { - CosmicStarfieldBackground() + RitualSanctuaryBackground() ScrollView { VStack(alignment: .leading, spacing: 20) { // Header @@ -123,7 +123,7 @@ struct YogaDetailSheet: View { let detail = YogaDetail.from(yogaIndex: yogaIndex) NavigationStack { ZStack { - CosmicStarfieldBackground() + RitualSanctuaryBackground() ScrollView { VStack(alignment: .leading, spacing: 20) { CosmicGlassCard(cornerRadius: 18) { @@ -187,7 +187,7 @@ struct KaranaDetailSheet: View { let detail = KaranaDetail.from(karanaIndex: karanaIndex) NavigationStack { ZStack { - CosmicStarfieldBackground() + RitualSanctuaryBackground() ScrollView { VStack(alignment: .leading, spacing: 20) { CosmicGlassCard(cornerRadius: 18) { diff --git a/ios/native/CosmicRituals/Views/PanchangView.swift b/ios/native/CosmicRituals/Views/PanchangView.swift index ca6092c..c381e9b 100644 --- a/ios/native/CosmicRituals/Views/PanchangView.swift +++ b/ios/native/CosmicRituals/Views/PanchangView.swift @@ -2,13 +2,12 @@ import SwiftUI struct PanchangView: View { @State private var selectedDate = Date() + @State private var panchangSnapshot: (context: CalculationContext, value: Panchang)? @State private var muhurtas: [Muhurta] = [] - @State private var selectedTab = 0 @State private var detailMuhurta: Muhurta? @State private var sunriseSunset: (Date, Date)? @State private var choghadiya: [Choghadiya] = [] @State private var hora: [Hora] = [] - @State private var tithiEndTime: Date? @State private var showThemePicker = false @State private var showLocationPicker = false @State private var selectedActivity: String = "Business" @@ -18,6 +17,7 @@ struct PanchangView: View { @State private var panchangPDF: PanchangPDF? = nil @State private var now = Date() // ticked every minute to drive live "NOW" indicators @StateObject private var locationManager = LocationManager() + @AppStorage("ritualSelectedDestination") private var selectedTab = 0 @AppStorage("cosmicThemeVariant") private var variantRaw = CosmicThemeVariant.cosmicDark.rawValue @AppStorage("ritualExperienceMode") private var experienceRaw = RitualExperienceMode.ritualNow.rawValue @Environment(\.cosmicTheme) private var theme @@ -26,10 +26,12 @@ struct PanchangView: View { var body: some View { NavigationStack { ZStack { - CosmicStarfieldBackground() + RitualSanctuaryBackground() VStack(spacing: 0) { - LocationContextBar(manager: locationManager) { - showLocationPicker = true + if selectedTab != 3 { + LocationContextBar(manager: locationManager) { + showLocationPicker = true + } } if selectedTab == 0 { panchangScrollView @@ -41,35 +43,39 @@ struct PanchangView: View { } } else if selectedTab == 2 { muhurtaScrollView + } else if selectedTab == 3 { + PoojaVidhiLibraryView() } else { MonthlyCalendarView(selectedDate: $selectedDate, calculationContext: calculationContext) } } } - .navigationTitle(["Panchang", "Timing", "Muhurtas", "Calendar"][selectedTab.clamped(to: 0...3)]) + .navigationTitle(RitualDestinationDescriptor.all[selectedTab.clamped(to: 0...(RitualDestinationDescriptor.all.count - 1))].title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 14) { - ShareLink(item: panchangSummary, - subject: Text("Panchang for \(selectedDate.ritualDate(template: "yMd", in: calculationContext.timeZone))")) { - Image(systemName: "square.and.arrow.up") - } - .accessibilityLabel("Share Panchang summary") - .accessibilityHint("Opens the system share sheet") - Group { - if let pdf = panchangPDF { - ShareLink(item: pdf, preview: SharePreview("Panchang PDF", - image: Image(systemName: "doc.richtext.fill"))) { - Image(systemName: "doc.richtext.fill") - .foregroundStyle(theme.primary) + if selectedTab != 3 { + ShareLink(item: panchangSummary, + subject: Text("Panchang for \(selectedDate.ritualDate(template: "yMd", in: calculationContext.timeZone))")) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share Panchang summary") + .accessibilityHint("Opens the system share sheet") + Group { + if let pdf = panchangPDF { + ShareLink(item: pdf, preview: SharePreview("Panchang PDF", + image: Image(systemName: "doc.richtext.fill"))) { + Image(systemName: "doc.richtext.fill") + .foregroundStyle(theme.primary) + } + .accessibilityLabel("Share Panchang PDF") + .accessibilityHint("Opens the system share sheet with the generated PDF") + } else { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Preparing Panchang PDF") } - .accessibilityLabel("Share Panchang PDF") - .accessibilityHint("Opens the system share sheet with the generated PDF") - } else { - ProgressView() - .controlSize(.small) - .accessibilityLabel("Preparing Panchang PDF") } } Button { showThemePicker = true } label: { @@ -80,14 +86,16 @@ struct PanchangView: View { } } ToolbarItem(placement: .topBarLeading) { - Button { showLocationPicker = true } label: { - Image(systemName: locationManager.activeLocation.source == .current - ? "location.fill" : "mappin.and.ellipse") - .foregroundStyle(locationManager.activeLocation.source == .current ? theme.primary : .secondary) + if selectedTab != 3 { + Button { showLocationPicker = true } label: { + Image(systemName: locationManager.activeLocation.source == .current + ? "location.fill" : "mappin.and.ellipse") + .foregroundStyle(locationManager.activeLocation.source == .current ? theme.primary : .secondary) + } + .accessibilityLabel("Calculation location") + .accessibilityValue(locationManager.activeLocation.name) + .accessibilityHint("Choose current location or an offline city") } - .accessibilityLabel("Calculation location") - .accessibilityValue(locationManager.activeLocation.name) - .accessibilityHint("Choose current location or an offline city") } } .toolbarBackground(.ultraThinMaterial, for: .navigationBar) @@ -111,29 +119,53 @@ struct PanchangView: View { RitualLocationPicker(manager: locationManager) } .sheet(isPresented: $showTithiDetail) { - let p = CosmicEngine.getPanchang(context: calculationContext) + let p = resolvedPanchang TithiDetailSheet(tithiIndex: p.tithiIndex) } .sheet(isPresented: $showYogaDetail) { - let p = CosmicEngine.getPanchang(context: calculationContext) + let p = resolvedPanchang YogaDetailSheet(yogaIndex: p.yogaIndex) } .sheet(isPresented: $showKaranaDetail) { - let p = CosmicEngine.getPanchang(context: calculationContext) + let p = resolvedPanchang KaranaDetailSheet(karanaIndex: p.karanaIndex) } } .onAppear { + if !RitualDestinationDescriptor.all.indices.contains(selectedTab) { + selectedTab = 0 + } recompute() Task { await NotificationManager.shared.clearPendingRitualNotifications() } } .onChange(of: selectedDate) { _, _ in recompute() } - .onChange(of: locationManager.activeLocation) { _, _ in recompute() } + .onChange(of: locationManager.activeLocation) { oldLocation, newLocation in + let sourceTimeZone = TimeZone(identifier: oldLocation.timeZoneIdentifier) ?? .gmt + let targetTimeZone = TimeZone(identifier: newLocation.timeZoneIdentifier) ?? .gmt + let translatedDate = selectedDate.ritualCivilDay( + preservingDateFrom: sourceTimeZone, + into: targetTimeZone + ) + if translatedDate == selectedDate { + recompute() + } else { + selectedDate = translatedDate + } + } .onReceive(Timer.publish(every: 60, on: .main, in: .common).autoconnect()) { date in now = date // tick forces a re-render so isCurrent badges stay accurate } + .task(id: calculationContext) { + let context = calculationContext + panchangPDF = nil + let data = await Task.detached(priority: .utility) { + PanchangPDFExporter.generatePDF(context: context) + }.value + guard !Task.isCancelled, calculationContext == context else { return } + panchangPDF = PanchangPDF(data: data) + } .environment(\.timeZone, calculationContext.timeZone) } @@ -147,18 +179,25 @@ struct PanchangView: View { locationManager.calculationContext(for: selectedDate) } + private var resolvedPanchang: Panchang { + let context = calculationContext + if let snapshot = panchangSnapshot, snapshot.context == context { + return snapshot.value + } + return CosmicEngine.getPanchang(context: context) + } + // MARK: - Panchang five limbs private var panchangScrollView: some View { ScrollView { VStack(spacing: 16) { - let p = CosmicEngine.getPanchang(context: calculationContext) + let p = resolvedPanchang PanchangExperienceHome( mode: activeExperience, selectedDate: $selectedDate, panchang: p, - tithiEndTime: tithiEndTime, showTithiDetail: { showTithiDetail = true }, showYogaDetail: { showYogaDetail = true }, showKaranaDetail: { showKaranaDetail = true } @@ -166,6 +205,12 @@ struct PanchangView: View { .padding(.horizontal) .padding(.top, 12) + PanchangTransitionTimeline( + referenceDate: p.date, + transitions: p.transitions.chronological + ) + .padding(.horizontal) + // Solar values are shown only when real rise/set events exist. CosmicGlassCard { VStack(alignment: .leading, spacing: 10) { @@ -177,26 +222,30 @@ struct PanchangView: View { solarTimeCell(icon: "sunset.fill", color: .red, label: "Sunset", time: set) } Divider() - let brahma = rise.addingTimeInterval(-96 * 60) - let brahmaEnd = rise.addingTimeInterval(-48 * 60) - HStack(spacing: 6) { - CosmicIcon(name: "moon.zzz.fill", size: 13, color: .purple) - Text("Brahma Muhurta") - .font(.caption).foregroundStyle(.secondary) - Spacer() - Text("\(shortTime(brahma)) – \(shortTime(brahmaEnd))") - .font(.caption.bold()).foregroundStyle(.purple) + if let brahma = CosmicEngine.getBrahmaMuhurta(context: calculationContext) { + HStack(spacing: 6) { + CosmicIcon(name: "moon.zzz.fill", size: 13, color: .purple) + Text("Brahma Muhurta") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Text("\(shortTime(brahma.start)) – \(shortTime(brahma.end))") + .font(.caption.bold()).foregroundStyle(.purple) + } } - let noon = rise.addingTimeInterval(rise.distance(to: set) / 2) - let abhijit = noon.addingTimeInterval(-24 * 60) - let abhijitEnd = noon.addingTimeInterval(24 * 60) - HStack(spacing: 6) { - CosmicIcon(name: "sun.max.circle.fill", size: 13, color: .yellow) - Text("Abhijit Muhurta") - .font(.caption).foregroundStyle(.secondary) - Spacer() - Text("\(shortTime(abhijit)) – \(shortTime(abhijitEnd))") - .font(.caption.bold()).foregroundStyle(.yellow) + if let abhijit = CosmicEngine.getAbhijitMuhurta(context: calculationContext) { + HStack(spacing: 6) { + CosmicIcon(name: "sun.max.circle.fill", size: 13, color: .yellow) + Text("Abhijit Muhurta") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Text("\(shortTime(abhijit.start)) – \(shortTime(abhijit.end))") + .font(.caption.bold()).foregroundStyle(.yellow) + } + } else if p.weekdayName == "Wednesday" { + Label("Abhijit is not observed as auspicious on Wednesday.", + systemImage: "sun.max.circle") + .font(.caption) + .foregroundStyle(.secondary) } } else { Label("Sunrise-based schedules are unavailable for this latitude and date.", @@ -256,7 +305,7 @@ struct PanchangView: View { } .padding(.horizontal) - auspiciousKalaCard(weekday: p.weekdayName) + inauspiciousKalaCard } .padding(.vertical) @@ -416,7 +465,7 @@ struct PanchangView: View { // MARK: - Share private var panchangSummary: String { - let p = CosmicEngine.getPanchang(context: calculationContext) + let p = resolvedPanchang let nak = CosmicEngine.getMoonNakshatraPada(context: calculationContext) let sunNak = CosmicEngine.getSunNakshatra(context: calculationContext) let sunSignIdx = Int(sunNak.degree / 30.0) % 12 @@ -424,14 +473,17 @@ struct PanchangView: View { var lines: [String] = [ "🌟 Panchang — \(df)", - "Reference: 12:00 PM local time", + "Reference: \(p.referenceDisclosure(in: calculationContext.timeZone))", "", "✦ Vara (Weekday): \(p.weekdayName)", "✦ Tithi: \(p.tithiIndex < 15 ? "Shukla" : "Krishna") \(p.tithiName)" + - (tithiEndTime.map { " (ends \(shortTime($0)))" } ?? ""), - "✦ Nakshatra: \(p.nakshatraName) (pada \(nak.pada))", - "✦ Yoga: \(p.yogaName)", - "✦ Karana: \(p.karanaName)", + (p.transitions.tithi.map { " (ends \($0.endTime.ritualTransitionLabel(relativeTo: p.date, in: calculationContext.timeZone)))" } ?? ""), + "✦ Nakshatra: \(p.nakshatraName) (pada \(nak.pada))" + + (p.transitions.nakshatra.map { " (ends \($0.endTime.ritualTransitionLabel(relativeTo: p.date, in: calculationContext.timeZone)))" } ?? ""), + "✦ Yoga: \(p.yogaName)" + + (p.transitions.yoga.map { " (ends \($0.endTime.ritualTransitionLabel(relativeTo: p.date, in: calculationContext.timeZone)))" } ?? ""), + "✦ Karana: \(p.karanaName)" + + (p.transitions.karana.map { " (ends \($0.endTime.ritualTransitionLabel(relativeTo: p.date, in: calculationContext.timeZone)))" } ?? ""), "", "☀ Surya Rashi: \(ZodiacSign.fromIndex(sunSignIdx).name)", "☀ Surya Nakshatra: \(sunNak.nakshatraName)", @@ -579,24 +631,26 @@ struct PanchangView: View { } } - @ViewBuilder - private func auspiciousKalaCard(weekday: String) -> some View { + private var inauspiciousKalaCard: some View { let durMuhurtas = CosmicEngine.getDurMuhurta(context: calculationContext) + let rahuKala = CosmicEngine.getRahuKala(context: calculationContext) + let yamaganda = CosmicEngine.getYamaganda(context: calculationContext) + let gulikaKala = CosmicEngine.getGulikaKala(context: calculationContext) - CosmicGlassCard { + return CosmicGlassCard { VStack(alignment: .leading, spacing: 10) { CosmicSectionHeader(title: "Inauspicious Periods", icon: "exclamationmark.triangle") HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text("Rahu Kala").font(.caption2).foregroundStyle(.tertiary) - Text(kalaTimeString(slot: rahuKalaSlot(weekday), fallback: staticRahuKala(weekday))) + Text(kalaTimeString(rahuKala)) .font(.subheadline.bold()).foregroundStyle(.red) Text("Avoid new beginnings").font(.caption2).foregroundStyle(.secondary) } Divider() VStack(alignment: .leading, spacing: 4) { Text("Yamaganda").font(.caption2).foregroundStyle(.tertiary) - Text(kalaTimeString(slot: yamaGandaSlot(weekday), fallback: staticYamaGanda(weekday))) + Text(kalaTimeString(yamaganda)) .font(.subheadline.bold()).foregroundStyle(.orange) Text("Inauspicious period").font(.caption2).foregroundStyle(.secondary) } @@ -604,8 +658,7 @@ struct PanchangView: View { Divider() VStack(alignment: .leading, spacing: 4) { Text("Gulika Kala").font(.caption2).foregroundStyle(.tertiary) - Text(kalaTimeString(slot: CosmicEngine.gulikaSlot(weekday: weekday), - fallback: "Check calendar")) + Text(kalaTimeString(gulikaKala)) .font(.subheadline.bold()).foregroundStyle(.purple) Text("Son of Saturn — highly inauspicious").font(.caption2).foregroundStyle(.secondary) } @@ -754,12 +807,11 @@ struct PanchangView: View { private func recompute() { let context = calculationContext + panchangSnapshot = (context, CosmicEngine.getPanchang(context: context)) muhurtas = CosmicEngine.getMuhurtas(context: context) sunriseSunset = CosmicEngine.getSunriseSunset(context: context) choghadiya = CosmicEngine.getChoghadiya(context: context) hora = CosmicEngine.getHora(context: context) - tithiEndTime = CosmicEngine.getTithiEndTime(context: context) - panchangPDF = PanchangPDF(data: PanchangPDFExporter.generatePDF(context: context)) } private func solarTimeCell(icon: String, color: Color, label: String, time: Date) -> some View { @@ -773,11 +825,7 @@ struct PanchangView: View { } private func shortTime(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .short - formatter.timeZone = calculationContext.timeZone - return formatter.string(from: date) + date.ritualShortTime(in: calculationContext.timeZone) } private func qualityColor(_ q: MuhurtaQuality) -> Color { @@ -789,66 +837,9 @@ struct PanchangView: View { } } - // Rahu Kala: 1-based slot index within the 8 equal day divisions - private func rahuKalaSlot(_ weekday: String) -> Int { - switch weekday { - case "Sunday": return 8 - case "Monday": return 2 - case "Tuesday": return 7 - case "Wednesday": return 5 - case "Thursday": return 6 - case "Friday": return 4 - case "Saturday": return 3 - default: return 1 - } - } - - // Yamaganda: 1-based slot index within the 8 equal day divisions - private func yamaGandaSlot(_ weekday: String) -> Int { - switch weekday { - case "Sunday": return 5 - case "Monday": return 4 - case "Tuesday": return 3 - case "Wednesday": return 2 - case "Thursday": return 1 - case "Friday": return 7 - case "Saturday": return 6 - default: return 1 - } - } - - private func kalaTimeString(slot: Int, fallback: String) -> String { - guard let (rise, set) = sunriseSunset else { return "Unavailable" } - let slotDuration = set.timeIntervalSince(rise) / 8.0 - let start = rise.addingTimeInterval(Double(slot - 1) * slotDuration) - let end = start.addingTimeInterval(slotDuration) - return "\(shortTime(start)) – \(shortTime(end))" - } - - private func staticRahuKala(_ weekday: String) -> String { - switch weekday { - case "Sunday": return "4:30–6:00 PM" - case "Monday": return "7:30–9:00 AM" - case "Tuesday": return "3:00–4:30 PM" - case "Wednesday": return "12:00–1:30 PM" - case "Thursday": return "1:30–3:00 PM" - case "Friday": return "10:30 AM–12:00 PM" - case "Saturday": return "9:00–10:30 AM" - default: return "Check calendar" - } - } - - private func staticYamaGanda(_ weekday: String) -> String { - switch weekday { - case "Sunday": return "12:00–1:30 PM" - case "Monday": return "10:30 AM–12:00 PM" - case "Tuesday": return "9:00–10:30 AM" - case "Wednesday": return "7:30–9:00 AM" - case "Thursday": return "6:00–7:30 AM" - case "Friday": return "3:00–4:30 PM" - case "Saturday": return "1:30–3:00 PM" - default: return "Check calendar" - } + private func kalaTimeString(_ period: (start: Date, end: Date)?) -> String { + guard let period else { return "Unavailable" } + return "\(shortTime(period.start)) – \(shortTime(period.end))" } } @@ -863,7 +854,7 @@ private struct ThemePickerSheet: View { var body: some View { NavigationStack { ZStack { - CosmicStarfieldBackground() + RitualSanctuaryBackground() ScrollView { VStack(alignment: .leading, spacing: 16) { // Experience layout (independent from color appearance) @@ -890,6 +881,37 @@ private struct ThemePickerSheet: View { Divider().overlay(theme.semanticDivider).padding(.horizontal) + VStack(alignment: .leading, spacing: 12) { + Label("Calculation Integrity", systemImage: "checkmark.shield.fill") + .font(.caption.bold()) + .foregroundStyle(theme.semanticPrimaryText) + + integrityRow( + title: "Daily reference", + detail: "Selected location's sunrise; local noon only when no sunrise exists" + ) + integrityRow( + title: "Five limbs", + detail: "Meeus Sun and Moon series · Lahiri (Chitra Paksha) ayanamsha" + ) + integrityRow( + title: "Transitions", + detail: "Tithi, Nakshatra, Yoga, and Karana solved independently" + ) + integrityRow( + title: "Privacy", + detail: "All calculations and the city catalog stay on device" + ) + + Text("The numerical boundary solver has sub-second resolution. The compact offline ephemeris is checked against published civil-time fixtures with a conservative ±12 minute validation envelope. Regional observance rules can differ; confirm ceremonial timings with a qualified practitioner.") + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal) + + Divider().overlay(theme.semanticDivider).padding(.horizontal) + // Appearance Text("Color Theme").font(.caption.bold()).foregroundStyle(theme.semanticSecondaryText) .padding(.horizontal) @@ -912,6 +934,27 @@ private struct ThemePickerSheet: View { .font(.caption) } .padding(.horizontal) + + Divider().overlay(theme.semanticDivider).padding(.horizontal) + VStack(alignment: .leading, spacing: 10) { + Text("Subscription & Support") + .font(.caption.bold()) + .foregroundStyle(theme.semanticSecondaryText) + Link(destination: SubscriptionCatalog.manageSubscriptionsURL) { + Label("Manage subscription", systemImage: "creditcard.fill") + } + Link(destination: SubscriptionCatalog.privacyPolicyURL) { + Label("Privacy policy", systemImage: "hand.raised.fill") + } + Link(destination: SubscriptionCatalog.termsOfUseURL) { + Label("Terms of use", systemImage: "doc.text.fill") + } + Link(destination: SubscriptionCatalog.supportURL) { + Label("Support", systemImage: "questionmark.circle.fill") + } + } + .font(.caption) + .padding(.horizontal) .padding(.bottom, 24) } .padding(.top) @@ -972,6 +1015,19 @@ private struct ThemePickerSheet: View { } .buttonStyle(.plain) } + + private func integrityRow(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.semanticPrimaryText) + Text(detail) + .font(.caption2) + .foregroundStyle(theme.semanticSecondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } } // MARK: - Supporting Views diff --git a/ios/native/CosmicRituals/Views/PoojaVidhiViews.swift b/ios/native/CosmicRituals/Views/PoojaVidhiViews.swift new file mode 100644 index 0000000..11f8d9f --- /dev/null +++ b/ios/native/CosmicRituals/Views/PoojaVidhiViews.swift @@ -0,0 +1,997 @@ +import SwiftUI + +struct PoojaVidhiLibraryView: View { + @State private var query = "" + @State private var selectedCategory: PoojaCategory? + @Environment(\.cosmicTheme) private var theme + + private var results: [PoojaVidhi] { + PoojaVidhiCatalog.search(query, category: selectedCategory) + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 16) { + libraryHeader + categoryPicker + + if results.isEmpty { + ContentUnavailableView( + "No matching Pooja Vidhi", + systemImage: "magnifyingglass", + description: Text("Try a deity, occasion, purpose, or clear the category filter.") + ) + .foregroundStyle(theme.semanticPrimaryText) + .padding(.vertical, 40) + } else { + HStack { + Text("\(results.count) guides") + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.semanticSecondaryText) + Spacer() + Text("Available offline") + .font(.caption2) + .foregroundStyle(theme.semanticTertiaryText) + } + .padding(.horizontal, 4) + + ForEach(results) { vidhi in + NavigationLink { + PoojaVidhiDetailView(vidhi: vidhi) + } label: { + PoojaVidhiLibraryCard(vidhi: vidhi) + } + .buttonStyle(.plain) + .accessibilityIdentifier("pooja.vidhi.\(vidhi.id)") + } + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 28) + } + .scrollDismissesKeyboard(.interactively) + .searchable(text: $query, prompt: "Deity, occasion, mantra, purpose") + .accessibilityIdentifier("pooja.library") + } + + private var libraryHeader: some View { + CosmicGlassCard(cornerRadius: 24, accentBorder: true) { + VStack(alignment: .leading, spacing: 13) { + HStack(alignment: .top, spacing: 12) { + ZStack { + Circle() + .fill(theme.primary.opacity(0.16)) + .frame(width: 48, height: 48) + Image(systemName: "hands.and.sparkles.fill") + .font(.title2.weight(.semibold)) + .foregroundStyle(theme.primary) + } + VStack(alignment: .leading, spacing: 4) { + Text("Pooja Vidhis") + .font(.title2.weight(.bold)) + .foregroundStyle(theme.semanticPrimaryText) + Text("Prepare · understand · proceed step by step") + .font(.subheadline) + .foregroundStyle(theme.semanticSecondaryText) + } + } + + Label("Household guidance, not one universal rite", systemImage: "checkmark.seal.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.primary) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { libraryTrustPills } + VStack(alignment: .leading, spacing: 8) { libraryTrustPills } + } + + Text(PoojaContentPolicy.householdScope) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Pooja Vidhis. \(PoojaVidhiCatalog.all.count) offline household and preparation guides. Family and temple tradition takes precedence.") + } + + @ViewBuilder + private var libraryTrustPills: some View { + PoojaMetadataPill(text: "\(PoojaVidhiCatalog.all.count) guided rites", symbol: "hands.and.sparkles.fill") + PoojaMetadataPill(text: "Source adapted", symbol: "books.vertical.fill") + PoojaMetadataPill(text: "Offline", symbol: "lock.shield.fill") + } + + private var categoryPicker: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + categoryButton(nil, title: "All", symbol: "square.grid.2x2.fill") + ForEach(PoojaCategory.allCases) { category in + categoryButton(category, title: category.displayName, symbol: category.symbol) + } + } + .padding(.vertical, 2) + } + .accessibilityLabel("Filter Pooja Vidhis") + } + + @ViewBuilder + private func categoryButton(_ category: PoojaCategory?, title: String, symbol: String) -> some View { + let isSelected = selectedCategory == category + let button = Button { + withAnimation(.snappy(duration: 0.22)) { + selectedCategory = category + } + } label: { + Label(title, systemImage: symbol) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .foregroundStyle(isSelected ? theme.selectedControlForeground : theme.semanticSecondaryText) + } + if isSelected { + button + .buttonStyle(.glassProminent) + .tint(theme.primary) + .accessibilityValue("Selected") + } else { + button + .buttonStyle(.glass) + .tint(theme.surfaceElevated) + .accessibilityValue("Not selected") + } + } +} + +private struct PoojaVidhiLibraryCard: View { + let vidhi: PoojaVidhi + @Environment(\.cosmicTheme) private var theme + + var body: some View { + CosmicGlassCard(cornerRadius: 20) { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(vidhi.category.accent.opacity(0.15)) + .frame(width: 46, height: 46) + Image(systemName: vidhi.category.symbol) + .font(.title3.weight(.semibold)) + .foregroundStyle(vidhi.category.accent) + } + VStack(alignment: .leading, spacing: 4) { + Text(vidhi.title) + .font(.headline) + .foregroundStyle(theme.semanticPrimaryText) + .multilineTextAlignment(.leading) + Text(vidhi.sacredFocus) + .font(.caption.weight(.medium)) + .foregroundStyle(theme.primary) + } + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.caption.weight(.bold)) + .foregroundStyle(theme.semanticTertiaryText) + .padding(.top, 6) + } + + Text(vidhi.summary) + .font(.subheadline) + .foregroundStyle(theme.semanticSecondaryText) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { metadataPills } + VStack(alignment: .leading, spacing: 7) { metadataPills } + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(vidhi.title), \(vidhi.sacredFocus), \(vidhi.durationText), \(vidhi.practiceLevel.displayName). \(vidhi.summary)") + .accessibilityHint("Opens preparation, materials, steps, mantras, and guidance") + } + + @ViewBuilder + private var metadataPills: some View { + PoojaMetadataPill(text: vidhi.durationText, symbol: "clock") + PoojaMetadataPill(text: vidhi.practiceLevel.displayName, symbol: vidhi.practiceLevel == .priestRecommended ? "person.badge.shield.checkmark.fill" : "house.fill") + } +} + +struct PoojaVidhiDetailView: View { + let vidhi: PoojaVidhi + @State private var preparedMaterialIDs: Set = [] + @Environment(\.cosmicTheme) private var theme + + private var preparationProgress: Double { + readiness.requiredPreparationProgress + } + + private var readiness: PoojaReadiness { + vidhi.readiness(preparedMaterialIDs: preparedMaterialIDs) + } + + var body: some View { + ZStack { + RitualSanctuaryBackground() + ScrollView { + LazyVStack(spacing: 16) { + hero + readinessSection + practiceBoundary + preparationSection + materialsSection + stepsSection + safetySection + sourcesSection + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 104) + } + } + .navigationTitle(vidhi.title) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarColorScheme(theme.colorScheme, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + ShareLink(item: vidhi.shareText) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share Pooja Vidhi") + } + } + .safeAreaInset(edge: .bottom) { + NavigationLink { + GuidedPoojaView(vidhi: vidhi) + } label: { + Label( + beginActionTitle, + systemImage: beginActionSymbol + ) + .font(.headline) + .frame(maxWidth: .infinity) + .frame(minHeight: 52) + } + .buttonStyle(.glassProminent) + .tint(theme.primary) + .foregroundStyle(theme.selectedControlForeground) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + .accessibilityIdentifier("pooja.begin.\(vidhi.id)") + } + } + + private var beginActionTitle: String { + if vidhi.practiceLevel == .priestRecommended { + return "Open ceremony guide" + } + if readiness.hasPreparedRequiredMaterials { + return "Begin guided Pooja" + } + return "Review guide · \(readiness.remainingRequiredMaterialCount) required left" + } + + private var beginActionSymbol: String { + readiness.hasPreparedRequiredMaterials ? "play.fill" : "book.pages.fill" + } + + private var hero: some View { + CosmicGlassCard(cornerRadius: 24, accentBorder: true) { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(vidhi.category.accent.opacity(0.16)) + .frame(width: 56, height: 56) + Image(systemName: vidhi.category.symbol) + .font(.title2.weight(.semibold)) + .foregroundStyle(vidhi.category.accent) + } + VStack(alignment: .leading, spacing: 4) { + Text(vidhi.sacredFocus) + .font(.title3.weight(.bold)) + .foregroundStyle(theme.semanticPrimaryText) + Text(vidhi.purpose) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + } + } + + Text(vidhi.summary) + .font(.body) + .foregroundStyle(theme.semanticPrimaryText) + .fixedSize(horizontal: false, vertical: true) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { detailMetadataPills } + VStack(alignment: .leading, spacing: 8) { detailMetadataPills } + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 7) { + ForEach(vidhi.occasions, id: \.self) { occasion in + Text(occasion) + .font(.caption2.weight(.medium)) + .foregroundStyle(theme.semanticSecondaryText) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(theme.surfaceElevated.opacity(0.72), in: Capsule()) + } + } + } + } + } + } + + @ViewBuilder + private var detailMetadataPills: some View { + PoojaMetadataPill(text: vidhi.durationText, symbol: "clock") + PoojaMetadataPill(text: "\(vidhi.steps.count) steps", symbol: "list.number") + PoojaMetadataPill(text: vidhi.practiceLevel.displayName, symbol: vidhi.practiceLevel == .priestRecommended ? "person.badge.shield.checkmark.fill" : "house.fill") + } + + private var readinessSection: some View { + PoojaSectionCard(title: "Ritual readiness", symbol: "checkmark.seal.fill") { + VStack(alignment: .leading, spacing: 12) { + ProgressView(value: readiness.requiredPreparationProgress) + .tint(readiness.hasPreparedRequiredMaterials ? .green : theme.primary) + .accessibilityLabel("Required material readiness") + .accessibilityValue(readiness.materialStatus) + + readinessRow( + title: "Materials", + detail: readiness.materialStatus, + symbol: readiness.hasPreparedRequiredMaterials ? "checkmark.circle.fill" : "circle.dotted", + color: readiness.hasPreparedRequiredMaterials ? .green : theme.primary + ) + readinessRow( + title: "Practice boundary", + detail: readiness.practiceStatus, + symbol: vidhi.practiceLevel == .priestRecommended ? "person.badge.shield.checkmark.fill" : "house.fill", + color: vidhi.practiceLevel == .priestRecommended ? .orange : theme.tertiary + ) + readinessRow( + title: "Provenance", + detail: readiness.sourceStatus, + symbol: "books.vertical.fill", + color: theme.secondary + ) + } + } + } + + private func readinessRow(title: String, detail: String, symbol: String, color: Color) -> some View { + HStack(alignment: .top, spacing: 11) { + Image(systemName: symbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(color) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption.weight(.bold)) + .foregroundStyle(theme.semanticPrimaryText) + Text(detail) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + } + + private var practiceBoundary: some View { + CosmicGlassCard(cornerRadius: 18) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: vidhi.practiceLevel == .priestRecommended ? "person.badge.shield.checkmark.fill" : "checkmark.seal.fill") + .font(.title3) + .foregroundStyle(vidhi.practiceLevel == .priestRecommended ? .orange : theme.primary) + VStack(alignment: .leading, spacing: 5) { + Text(vidhi.practiceLevel.displayName) + .font(.subheadline.weight(.bold)) + .foregroundStyle(theme.semanticPrimaryText) + Text(vidhi.practiceLevel.guidance) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .fixedSize(horizontal: false, vertical: true) + Text(vidhi.traditionNote) + .font(.caption) + .foregroundStyle(theme.semanticTertiaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private var preparationSection: some View { + PoojaSectionCard(title: "Before you begin", symbol: "checklist") { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(vidhi.preparation.enumerated()), id: \.offset) { index, item in + PoojaNumberedLine(number: index + 1, text: item) + } + } + } + } + + private var materialsSection: some View { + PoojaSectionCard(title: "Prepare materials", symbol: "basket.fill") { + VStack(alignment: .leading, spacing: 12) { + ProgressView(value: preparationProgress) + .tint(theme.primary) + .accessibilityLabel("Required materials prepared") + .accessibilityValue(readiness.materialStatus) + + HStack { + Text("Required \(readiness.preparedRequiredMaterialCount)/\(readiness.requiredMaterialCount)") + Spacer() + Text("Optional \(readiness.preparedOptionalMaterialCount)/\(readiness.optionalMaterialCount)") + } + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(theme.semanticTertiaryText) + + ForEach(vidhi.materials) { material in + Button { + if preparedMaterialIDs.contains(material.id) { + preparedMaterialIDs.remove(material.id) + } else { + preparedMaterialIDs.insert(material.id) + } + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: preparedMaterialIDs.contains(material.id) ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(preparedMaterialIDs.contains(material.id) ? theme.primary : theme.semanticTertiaryText) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(material.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(theme.semanticPrimaryText) + if material.isRequired { + Text("REQUIRED") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.orange) + } + } + Text(material.purpose) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + if let alternative = material.alternative { + Text("Alternative: \(alternative)") + .font(.caption2) + .foregroundStyle(theme.semanticTertiaryText) + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(material.name) + .accessibilityValue(preparedMaterialIDs.contains(material.id) ? "Prepared" : "Not prepared") + .accessibilityHint("Toggles the preparation checklist") + + if material.id != vidhi.materials.last?.id { + Divider().overlay(theme.semanticDivider) + } + } + } + } + } + + private var stepsSection: some View { + PoojaSectionCard(title: "Pooja sequence", symbol: "list.number") { + VStack(alignment: .leading, spacing: 14) { + ForEach(vidhi.steps) { step in + HStack(alignment: .top, spacing: 12) { + Text("\(step.number)") + .font(.caption.weight(.bold)) + .foregroundStyle(theme.selectedControlForeground) + .frame(width: 28, height: 28) + .background(theme.primary, in: Circle()) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(step.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(theme.semanticPrimaryText) + Spacer() + Text("~\(step.estimatedMinutes)m") + .font(.caption2) + .foregroundStyle(theme.semanticTertiaryText) + } + Text(step.instruction) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .lineLimit(3) + if let mantra = step.mantra { + Label(mantra.transliteration, systemImage: "text.quote") + .font(.caption.weight(.medium)) + .foregroundStyle(theme.primary) + .lineLimit(2) + } + } + } + } + } + } + } + + private var safetySection: some View { + PoojaSectionCard(title: "Safety and respectful closure", symbol: "shield.lefthalf.filled") { + VStack(alignment: .leading, spacing: 12) { + ForEach(vidhi.safetyNotes, id: \.self) { note in + Label { + Text(note) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + } + + Divider().overlay(theme.semanticDivider) + + ForEach(vidhi.completion, id: \.self) { item in + Label(item, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .labelStyle(PoojaTopAlignedLabelStyle(color: theme.primary)) + } + } + } + } + + private var sourcesSection: some View { + PoojaSectionCard(title: "Tradition and sources", symbol: "books.vertical.fill") { + VStack(alignment: .leading, spacing: 12) { + Text(PoojaContentPolicy.mantraScope) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + + Text(PoojaContentPolicy.inclusionScope) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + + Label(PoojaContentPolicy.reviewStatus, systemImage: "person.badge.clock.fill") + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .labelStyle(PoojaTopAlignedLabelStyle(color: .orange)) + + ForEach(vidhi.sourceNotes) { source in + VStack(alignment: .leading, spacing: 4) { + if let url = URL(string: source.urlString) { + Link(destination: url) { + HStack { + Text(source.title) + .font(.subheadline.weight(.semibold)) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + } + .foregroundStyle(theme.primary) + } + } else { + Text(source.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(theme.semanticPrimaryText) + } + Text(source.detail) + .font(.caption2) + .foregroundStyle(theme.semanticTertiaryText) + } + } + + Text("Source review: \(PoojaContentPolicy.sourceReviewDate)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(theme.semanticTertiaryText) + } + } + } +} + +struct GuidedPoojaView: View { + let vidhi: PoojaVidhi + @State private var currentStepIndex = 0 + @State private var isComplete = false + @Environment(\.cosmicTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + private var currentStep: PoojaStep { + vidhi.steps[currentStepIndex.clamped(to: 0...(vidhi.steps.count - 1))] + } + + var body: some View { + ZStack { + RitualSanctuaryBackground() + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 18) { + Color.clear.frame(height: 1).id("guidedPoojaTop") + progressHeader + if isComplete { + completionView + } else { + currentStepCard + if vidhi.practiceLevel == .priestRecommended { + priestBoundary + } + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 108) + } + .onChange(of: currentStepIndex) { _, _ in + if reduceMotion { + proxy.scrollTo("guidedPoojaTop", anchor: .top) + } else { + withAnimation(.snappy) { proxy.scrollTo("guidedPoojaTop", anchor: .top) } + } + } + } + } + .navigationTitle(isComplete ? "Pooja complete" : "Step \(currentStep.number) of \(vidhi.steps.count)") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarColorScheme(theme.colorScheme, for: .navigationBar) + .safeAreaInset(edge: .bottom) { + if !isComplete { navigationControls } + } + .sensoryFeedback(.success, trigger: isComplete) + .accessibilityIdentifier("pooja.guided.\(vidhi.id)") + } + + private var progressHeader: some View { + CosmicGlassCard(cornerRadius: 18) { + VStack(alignment: .leading, spacing: 10) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(vidhi.title) + .font(.headline) + .foregroundStyle(theme.semanticPrimaryText) + Text(isComplete ? "Respectful closure" : currentStep.title) + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + } + Spacer() + Text(isComplete ? "Done" : "\(currentStep.number)/\(vidhi.steps.count)") + .font(.caption.monospacedDigit().weight(.bold)) + .foregroundStyle(theme.primary) + } + ProgressView(value: isComplete ? 1 : Double(currentStep.number), total: Double(vidhi.steps.count)) + .tint(theme.primary) + .accessibilityLabel("Pooja progress") + .accessibilityValue(isComplete ? "Complete" : "Step \(currentStep.number) of \(vidhi.steps.count)") + + ViewThatFits(in: .horizontal) { + HStack(spacing: 7) { + guidedBoundaryLabel + Spacer(minLength: 6) + guidedSourceLabelView + } + VStack(alignment: .leading, spacing: 6) { + guidedBoundaryLabel + guidedSourceLabelView + } + } + .font(.caption2.weight(.semibold)) + .foregroundStyle(theme.semanticSecondaryText) + .accessibilityElement(children: .combine) + } + } + } + + private var guidedSourceLabel: String { + let noun = vidhi.sourceNotes.count == 1 ? "source" : "sources" + return "\(vidhi.sourceNotes.count) \(noun)" + } + + private var guidedBoundaryLabel: some View { + Label( + vidhi.practiceLevel == .priestRecommended ? "Practitioner-led boundary" : "Household adaptation", + systemImage: vidhi.practiceLevel == .priestRecommended ? "person.badge.shield.checkmark.fill" : "house.fill" + ) + } + + private var guidedSourceLabelView: some View { + Label(guidedSourceLabel, systemImage: "books.vertical.fill") + } + + private var currentStepCard: some View { + CosmicGlassCard(cornerRadius: 26, accentBorder: true) { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top) { + Text("\(currentStep.number)") + .font(.title2.monospacedDigit().weight(.bold)) + .foregroundStyle(theme.selectedControlForeground) + .frame(width: 48, height: 48) + .background(theme.primary, in: Circle()) + VStack(alignment: .leading, spacing: 4) { + Text(currentStep.title) + .font(.title2.weight(.bold)) + .foregroundStyle(theme.semanticPrimaryText) + Label("About \(currentStep.estimatedMinutes) min", systemImage: "clock") + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + } + } + + Text(currentStep.instruction) + .font(.title3) + .foregroundStyle(theme.semanticPrimaryText) + .fixedSize(horizontal: false, vertical: true) + + if let mantra = currentStep.mantra { + mantraCard(mantra) + } + + if let note = currentStep.note { + Label { + Text(note) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "info.circle.fill") + .foregroundStyle(.orange) + } + .font(.subheadline) + .foregroundStyle(theme.semanticSecondaryText) + .padding(12) + .background(.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + } + .accessibilityElement(children: .contain) + } + + private func mantraCard(_ mantra: PoojaMantra) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(mantra.title, systemImage: "text.quote") + .font(.subheadline.weight(.bold)) + .foregroundStyle(theme.primary) + Text(mantra.devanagari) + .font(.title2.weight(.semibold)) + .foregroundStyle(theme.semanticPrimaryText) + .textSelection(.enabled) + Text(mantra.transliteration) + .font(.body.italic()) + .foregroundStyle(theme.semanticPrimaryText) + .textSelection(.enabled) + Divider().overlay(theme.semanticDivider) + Text(mantra.meaning) + .font(.subheadline) + .foregroundStyle(theme.semanticSecondaryText) + Label(mantra.repetition, systemImage: "repeat") + .font(.caption.weight(.medium)) + .foregroundStyle(theme.semanticTertiaryText) + } + .padding(14) + .background(theme.surfaceElevated.opacity(0.74), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(mantra.title). \(mantra.transliteration). Meaning: \(mantra.meaning). \(mantra.repetition)") + } + + private var priestBoundary: some View { + CosmicGlassCard(cornerRadius: 18) { + Label { + Text("Pause whenever the guide reaches officiant-led mantras, homa, nyasa, kalasha installation, or formal visarjana. Follow the qualified priest or your received family procedure.") + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "person.badge.shield.checkmark.fill") + .foregroundStyle(.orange) + } + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + } + } + + private var navigationControls: some View { + Group { + if dynamicTypeSize.isAccessibilitySize { + VStack(spacing: 10) { + nextButton + previousButton + } + } else { + HStack(spacing: 12) { + previousButton + nextButton + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + } + + private var previousButton: some View { + Button { + currentStepIndex = max(0, currentStepIndex - 1) + } label: { + Label("Previous", systemImage: "chevron.left") + .frame(maxWidth: .infinity) + .frame(minHeight: 50) + } + .buttonStyle(.glass) + .disabled(currentStepIndex == 0) + } + + private var nextButton: some View { + Button { + if currentStepIndex == vidhi.steps.count - 1 { + isComplete = true + } else { + currentStepIndex += 1 + } + } label: { + Label( + currentStepIndex == vidhi.steps.count - 1 ? "Complete" : "Next", + systemImage: currentStepIndex == vidhi.steps.count - 1 ? "checkmark" : "chevron.right" + ) + .frame(maxWidth: .infinity) + .frame(minHeight: 50) + } + .buttonStyle(.glassProminent) + .tint(theme.primary) + .foregroundStyle(theme.selectedControlForeground) + } + + private var completionView: some View { + VStack(spacing: 16) { + CosmicGlassCard(cornerRadius: 26, accentBorder: true) { + VStack(spacing: 16) { + Image(systemName: "hands.and.sparkles.fill") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(theme.primary) + Text("Pooja complete") + .font(.title.bold()) + .foregroundStyle(theme.semanticPrimaryText) + Text("Sit quietly before clearing the space. Let the intention continue through thoughtful action.") + .font(.body) + .foregroundStyle(theme.semanticSecondaryText) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } + + PoojaSectionCard(title: "Close respectfully", symbol: "checkmark.seal.fill") { + VStack(alignment: .leading, spacing: 12) { + ForEach(vidhi.completion, id: \.self) { item in + Label(item, systemImage: "checkmark.circle.fill") + .font(.subheadline) + .foregroundStyle(theme.semanticSecondaryText) + .labelStyle(PoojaTopAlignedLabelStyle(color: theme.primary)) + } + } + } + + Button { + currentStepIndex = 0 + isComplete = false + } label: { + Label("Review from the beginning", systemImage: "arrow.counterclockwise") + .frame(maxWidth: .infinity) + .frame(minHeight: 50) + } + .buttonStyle(.glass) + } + } +} + +private struct PoojaSectionCard: View { + let title: String + let symbol: String + @ViewBuilder let content: () -> Content + + init(title: String, symbol: String, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.symbol = symbol + self.content = content + } + + var body: some View { + CosmicGlassCard(cornerRadius: 20) { + VStack(alignment: .leading, spacing: 14) { + CosmicSectionHeader(title: title, icon: symbol) + content() + } + } + } +} + +private struct PoojaMetadataPill: View { + let text: String + let symbol: String + @Environment(\.cosmicTheme) private var theme + + var body: some View { + Label(text, systemImage: symbol) + .font(.caption2.weight(.semibold)) + .foregroundStyle(theme.semanticSecondaryText) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(theme.surfaceElevated.opacity(0.74), in: Capsule()) + } +} + +private struct PoojaNumberedLine: View { + let number: Int + let text: String + @Environment(\.cosmicTheme) private var theme + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Text("\(number)") + .font(.caption2.monospacedDigit().weight(.bold)) + .foregroundStyle(theme.primary) + .frame(width: 22, height: 22) + .background(theme.primary.opacity(0.12), in: Circle()) + Text(text) + .font(.subheadline) + .foregroundStyle(theme.semanticSecondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private struct PoojaTopAlignedLabelStyle: LabelStyle { + let color: Color + + func makeBody(configuration: Configuration) -> some View { + HStack(alignment: .top, spacing: 9) { + configuration.icon + .foregroundStyle(color) + .padding(.top, 1) + configuration.title + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private extension PoojaCategory { + var accent: Color { + switch self { + case .daily: return .yellow + case .deity: return .orange + case .festival: return .pink + case .vrata: return .purple + case .lifeEvent: return .teal + case .planetary: return .indigo + } + } +} + +#Preview("Pooja library") { + NavigationStack { + ZStack { + RitualSanctuaryBackground() + PoojaVidhiLibraryView() + } + .navigationTitle("Pooja") + } + .environment(\.cosmicTheme, CosmicColorScheme.obsidianGold) + .preferredColorScheme(.dark) +} + +#Preview("Lakshmi Vidhi") { + NavigationStack { + PoojaVidhiDetailView(vidhi: PoojaVidhiCatalog.all[2]) + } + .environment(\.cosmicTheme, CosmicColorScheme.obsidianGold) + .preferredColorScheme(.dark) +} + +#Preview("Guided Pooja") { + NavigationStack { + GuidedPoojaView(vidhi: PoojaVidhiCatalog.all[1]) + } + .environment(\.cosmicTheme, CosmicColorScheme.cloudDancer) + .preferredColorScheme(.light) +} diff --git a/ios/native/CosmicRituals/Views/RitualLocationViews.swift b/ios/native/CosmicRituals/Views/RitualLocationViews.swift index aeb5e47..ba350d6 100644 --- a/ios/native/CosmicRituals/Views/RitualLocationViews.swift +++ b/ios/native/CosmicRituals/Views/RitualLocationViews.swift @@ -55,6 +55,7 @@ struct LocationContextBar: View { .font(.caption.weight(.bold)) .foregroundStyle(theme.semanticSecondaryText) } + .frame(maxWidth: .infinity) .padding(.horizontal, 16) .padding(.vertical, 9) .background(theme.surface.opacity(theme.isLight ? 0.96 : 0.88)) @@ -64,6 +65,7 @@ struct LocationContextBar: View { .frame(height: 0.5) } } + .frame(maxWidth: .infinity) .buttonStyle(.plain) .accessibilityLabel("Calculation location") .accessibilityValue( diff --git a/ios/native/CosmicRituals/Views/RootView.swift b/ios/native/CosmicRituals/Views/RootView.swift index faf146c..4de9018 100644 --- a/ios/native/CosmicRituals/Views/RootView.swift +++ b/ios/native/CosmicRituals/Views/RootView.swift @@ -2,22 +2,98 @@ import SwiftUI /// App root: owns the active theme variant and injects it into the environment. /// `PanchangView` reads/writes the same `@AppStorage` key to drive the picker. +@MainActor struct RootView: View { @AppStorage("cosmicThemeVariant") private var variantRaw = CosmicThemeVariant.cosmicDark.rawValue + @StateObject private var subscriptionStore: SubscriptionStore + @Environment(\.scenePhase) private var scenePhase + private let bypassStoreRefresh: Bool + + init() { + // Visual and UI automation must reach the offline product without a + // live App Store session. The launch token is compiled out of Release. + #if DEBUG + let isUITesting = ProcessInfo.processInfo.arguments.contains("-uiTestingPremium") + #else + let isUITesting = false + #endif + + // This condition exists only in the dedicated testing distribution + // configuration. The production Release configuration never defines it. + #if TESTFLIGHT_BETA_ACCESS + let isTestingDistribution = true + #else + let isTestingDistribution = false + #endif + + let initialState = SubscriptionLaunchPolicy.initialState( + isUITestingPremium: isUITesting, + isTestingDistribution: isTestingDistribution + ) + bypassStoreRefresh = initialState.hasPremiumAccess + _subscriptionStore = StateObject( + wrappedValue: SubscriptionStore( + accessState: initialState, + listensForTransactions: !initialState.hasPremiumAccess + ) + ) + } + + init(subscriptionStore: SubscriptionStore) { + bypassStoreRefresh = true + _subscriptionStore = StateObject(wrappedValue: subscriptionStore) + } private var activeScheme: CosmicColorScheme { (CosmicThemeVariant(rawValue: variantRaw) ?? .cosmicDark).colorScheme } var body: some View { - PanchangView() + Group { + if subscriptionStore.accessState.hasPremiumAccess { + if subscriptionStore.accessState.isTestingAccess { + PanchangView() + .safeAreaInset(edge: .top, spacing: 0) { + testingAccessBanner + } + } else { + PanchangView() + } + } else { + SubscriptionGateView(store: subscriptionStore) + } + } .environment(\.cosmicTheme, activeScheme) .environment(\.colorScheme, activeScheme.colorScheme) .preferredColorScheme(activeScheme.colorScheme) .tint(activeScheme.primary) + .task { + guard !bypassStoreRefresh else { return } + await subscriptionStore.refresh() + } + .onChange(of: scenePhase) { _, phase in + guard phase == .active, !bypassStoreRefresh else { return } + Task { await subscriptionStore.refresh() } + } + } + + private var testingAccessBanner: some View { + Label("TestFlight testing access", systemImage: "testtube.2") + .font(.caption.weight(.semibold)) + .foregroundStyle(activeScheme.semanticPrimaryText) + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.ultraThinMaterial) + .overlay(alignment: .bottom) { + Rectangle() + .fill(activeScheme.semanticDivider) + .frame(height: 0.5) + } + .accessibilityLabel("TestFlight testing access. Purchases are not required in this testing build.") } } #Preview { - RootView() + RootView(subscriptionStore: SubscriptionStore(accessState: .entitled, listensForTransactions: false)) } diff --git a/ios/native/CosmicRituals/Views/SubscriptionViews.swift b/ios/native/CosmicRituals/Views/SubscriptionViews.swift new file mode 100644 index 0000000..1ac5ebd --- /dev/null +++ b/ios/native/CosmicRituals/Views/SubscriptionViews.swift @@ -0,0 +1,190 @@ +import StoreKit +import SwiftUI + +struct SubscriptionGateView: View { + @ObservedObject var store: SubscriptionStore + @Environment(\.cosmicTheme) private var theme + + var body: some View { + ZStack { + RitualSanctuaryBackground() + switch store.accessState { + case .storeUnavailable(let message): + unavailableView(message: message) + case .checking, .locked, .entitled, .testingAccess: + subscriptionStoreView + } + + if store.accessState == .checking { + loadingOverlay + } + } + .accessibilityIdentifier("subscription.gate") + } + + private var subscriptionStoreView: some View { + SubscriptionStoreView(productIDs: SubscriptionCatalog.productIDs) { + marketingHeader + } + .subscriptionStoreControlStyle(.picker) + .storeButton(.visible, for: .restorePurchases) + .storeButton(.hidden, for: .cancellation) + .subscriptionStorePolicyDestination(url: SubscriptionCatalog.privacyPolicyURL, for: .privacyPolicy) + .subscriptionStorePolicyDestination(url: SubscriptionCatalog.termsOfUseURL, for: .termsOfService) + .subscriptionStorePolicyForegroundStyle(theme.semanticSecondaryText) + .tint(theme.primary) + } + + private var marketingHeader: some View { + VStack(spacing: 18) { + Image(systemName: "sparkles.rectangle.stack.fill") + .font(.system(size: 50, weight: .semibold)) + .foregroundStyle(theme.primary) + .accessibilityHidden(true) + + VStack(spacing: 8) { + Text(store.isEligibleForRequestedTrial ? "Your first 14 days are free" : "Cosmic Rituals Premium") + .font(.largeTitle.bold()) + .foregroundStyle(theme.semanticPrimaryText) + .multilineTextAlignment(.center) + Text("Precise Panchang, local muhurta intelligence, the complete offline Pooja library, calendar, sharing, and shortcuts.") + .font(.body) + .foregroundStyle(theme.semanticSecondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 10) { + benefit("Location-aware Panchang and transition times", symbol: "location.fill") + benefit("All 30 day and night muhurtas", symbol: "clock.badge.checkmark.fill") + benefit("12 sourced Pooja Vidhis available offline", symbol: "hands.and.sparkles.fill") + benefit("Private by design — calculations stay on device", symbol: "lock.shield.fill") + } + .frame(maxWidth: 560) + + if store.isEligibleForRequestedTrial { + Text("Choose a plan to begin the 14-day free trial. After the trial, it renews automatically at the displayed price unless cancelled.") + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 18) + .padding(.top, 24) + } + + private func benefit(_ text: String, symbol: String) -> some View { + Label { + Text(text) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: symbol) + .foregroundStyle(theme.primary) + } + .font(.subheadline.weight(.medium)) + .foregroundStyle(theme.semanticPrimaryText) + } + + private var loadingOverlay: some View { + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + .tint(theme.primary) + Text("Checking App Store access…") + .font(.subheadline) + .foregroundStyle(theme.semanticSecondaryText) + } + .padding(24) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Checking App Store access") + } + + private func unavailableView(message: String) -> some View { + ScrollView { + VStack(spacing: 24) { + marketingHeader + + VStack(spacing: 14) { + Image(systemName: "wifi.exclamationmark") + .font(.title2.weight(.semibold)) + .foregroundStyle(theme.primary) + .accessibilityHidden(true) + + Text("The App Store is unavailable") + .font(.title3.bold()) + .foregroundStyle(theme.semanticPrimaryText) + + Text(message) + .font(.subheadline) + .foregroundStyle(theme.semanticSecondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Button { + Task { await store.refresh() } + } label: { + Label("Try again", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + .frame(minHeight: 44) + } + .buttonStyle(.glassProminent) + .tint(theme.primary) + .foregroundStyle(theme.selectedControlForeground) + .disabled(store.isRefreshing) + + Button("Restore purchases") { + Task { await store.restorePurchases() } + } + .font(.subheadline.weight(.semibold)) + .frame(minHeight: 44) + .disabled(store.isRefreshing) + + Text("A connection is required to start a subscription. Existing verified access is preserved when product information cannot load.") + .font(.caption) + .foregroundStyle(theme.semanticSecondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(20) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .accessibilityElement(children: .contain) + + HStack(spacing: 18) { + Link("Privacy", destination: SubscriptionCatalog.privacyPolicyURL) + Link("Terms", destination: SubscriptionCatalog.termsOfUseURL) + Link("Support", destination: SubscriptionCatalog.supportURL) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.semanticSecondaryText) + .frame(minHeight: 44) + } + .frame(maxWidth: 620) + .padding(.horizontal, 18) + .padding(.bottom, 24) + .frame(maxWidth: .infinity) + } + .scrollIndicators(.hidden) + .accessibilityIdentifier("subscription.unavailable") + } +} + +#Preview("Eligible trial") { + SubscriptionGateView( + store: SubscriptionStore(accessState: .locked, listensForTransactions: false) + ) + .environment(\.cosmicTheme, CosmicColorScheme.obsidianGold) + .preferredColorScheme(.dark) +} + +#Preview("Store unavailable") { + SubscriptionGateView( + store: SubscriptionStore( + accessState: .storeUnavailable("The App Store could not load subscription options."), + listensForTransactions: false + ) + ) + .environment(\.cosmicTheme, CosmicColorScheme.cloudDancer) + .preferredColorScheme(.light) +} diff --git a/ios/native/CosmicRitualsTests/CosmicEngineTests.swift b/ios/native/CosmicRitualsTests/CosmicEngineTests.swift index 0361ac6..174782b 100644 --- a/ios/native/CosmicRitualsTests/CosmicEngineTests.swift +++ b/ios/native/CosmicRitualsTests/CosmicEngineTests.swift @@ -96,6 +96,77 @@ final class CosmicEngineTests: XCTestCase { XCTAssertTrue(CosmicEngine.getChoghadiya(context: svalbard).isEmpty) XCTAssertTrue(CosmicEngine.getHora(context: svalbard).isEmpty) XCTAssertTrue(CosmicEngine.getDurMuhurta(context: svalbard).isEmpty) + XCTAssertNil(CosmicEngine.getRahuKala(context: svalbard)) + XCTAssertNil(CosmicEngine.getYamaganda(context: svalbard)) + XCTAssertNil(CosmicEngine.getGulikaKala(context: svalbard)) + } + + func testPublishedFridayDurMuhurtaUsesFourthAndNinthDaySegments() throws { + // Drik Panchang, Mumbai, 2026-07-24: 08:49–09:42 and 13:11–14:03. + let mumbai = context(2026, 7, 24, latitude: 19.0760, longitude: 72.8777, timeZone: "Asia/Kolkata") + let periods = CosmicEngine.getDurMuhurta(context: mumbai) + XCTAssertEqual(periods.count, 2) + try assertLocalInterval(periods[0], startHour: 8, startMinute: 49, endHour: 9, endMinute: 42, + context: mumbai, toleranceMinutes: 12) + try assertLocalInterval(periods[1], startHour: 13, startMinute: 11, endHour: 14, endMinute: 3, + context: mumbai, toleranceMinutes: 12) + } + + func testPublishedFridayRahuYamagandaAndGulikaKalas() throws { + // Drik Panchang, Mumbai, 2026-07-24: Rahu 11:07–12:45, + // Yamaganda 16:01–17:39, and Gulika 07:51–09:29. + let mumbai = context(2026, 7, 24, latitude: 19.0760, longitude: 72.8777, timeZone: "Asia/Kolkata") + let rahu = try XCTUnwrap(CosmicEngine.getRahuKala(context: mumbai)) + let yamaganda = try XCTUnwrap(CosmicEngine.getYamaganda(context: mumbai)) + let gulika = try XCTUnwrap(CosmicEngine.getGulikaKala(context: mumbai)) + + try assertLocalInterval( + (start: rahu.start, end: rahu.end, label: "Rahu Kala"), + startHour: 11, startMinute: 7, endHour: 12, endMinute: 45, + context: mumbai, toleranceMinutes: 12 + ) + try assertLocalInterval( + (start: yamaganda.start, end: yamaganda.end, label: "Yamaganda"), + startHour: 16, startMinute: 1, endHour: 17, endMinute: 39, + context: mumbai, toleranceMinutes: 12 + ) + try assertLocalInterval( + (start: gulika.start, end: gulika.end, label: "Gulika Kala"), + startHour: 7, startMinute: 51, endHour: 9, endMinute: 29, + context: mumbai, toleranceMinutes: 12 + ) + } + + func testPublishedTuesdayDurMuhurtaIncludesNightPeriod() throws { + // Drik Panchang, Hyderabad, 2026-06-23: 08:21–09:14 and 23:14–23:57. + let hyderabad = context(2026, 6, 23, latitude: 17.3850, longitude: 78.4867, timeZone: "Asia/Kolkata") + let periods = CosmicEngine.getDurMuhurta(context: hyderabad) + XCTAssertEqual(periods.count, 2) + try assertLocalInterval(periods[0], startHour: 8, startMinute: 21, endHour: 9, endMinute: 14, + context: hyderabad, toleranceMinutes: 12) + try assertLocalInterval(periods[1], startHour: 23, startMinute: 14, endHour: 23, endMinute: 57, + context: hyderabad, toleranceMinutes: 12) + } + + func testPublishedFridayAbhijitScalesWithTheLocalDay() throws { + // Drik Panchang, Mumbai, 2026-07-24: 12:19–13:11. + let mumbai = context(2026, 7, 24, latitude: 19.0760, longitude: 72.8777, timeZone: "Asia/Kolkata") + let period = try XCTUnwrap(CosmicEngine.getAbhijitMuhurta(context: mumbai)) + try assertLocalInterval( + (start: period.start, end: period.end, label: "Abhijit"), + startHour: 12, startMinute: 19, endHour: 13, endMinute: 11, + context: mumbai, toleranceMinutes: 12 + ) + } + + func testAbhijitIsNotPresentedAsAuspiciousOnWednesday() { + let bartlesville = context( + 2026, 7, 22, + latitude: 36.7473, + longitude: -95.9808, + timeZone: "America/Chicago" + ) + XCTAssertNil(CosmicEngine.getAbhijitMuhurta(context: bartlesville)) } func testKaranaSequenceHasOneOpeningFixedKaranaAndThreeClosingFixedKaranas() { @@ -151,6 +222,149 @@ final class CosmicEngineTests: XCTestCase { XCTAssertEqual(CosmicEngine.getPanchang(context: tokyo).weekdayName, "Friday") } + func testDailyPanchangUsesLocalSunriseAndCarriesSolarTimes() throws { + let delhi = context(2026, 7, 24, latitude: 28.6139, longitude: 77.2090, timeZone: "Asia/Kolkata") + let solar = try XCTUnwrap(CosmicEngine.getSunriseSunset(context: delhi)) + let panchang = CosmicEngine.getPanchang(context: delhi) + + XCTAssertEqual(panchang.date.timeIntervalSince(solar.sunrise), 0, accuracy: 0.001) + XCTAssertEqual(panchang.sunriseTime, solar.sunrise) + XCTAssertEqual(panchang.sunsetTime, solar.sunset) + XCTAssertTrue(panchang.referenceDisclosure(in: delhi.timeZone).localizedCaseInsensitiveContains("sunrise")) + } + + func testPublishedParisPanchangTransitionsStayWithinCivilTimeTolerance() throws { + // Independent reference: Drik Panchang for Paris, 2026-07-21. + // https://www.drikpanchang.com/ — published values: sunrise 06:10, + // Ashtami until 01:46 Jul 22, Chitra until 17:19, Siddha until 14:55, + // and Vishti until 13:04. We allow 12 minutes because the app's compact + // offline ephemeris intentionally omits the larger JPL/Swiss data files. + let paris = context(2026, 7, 21, latitude: 48.8566, longitude: 2.3522, timeZone: "Europe/Paris") + let panchang = CosmicEngine.getPanchang(context: paris) + + XCTAssertEqual(panchang.tithiName, "Ashtami") + XCTAssertEqual(panchang.nakshatraName, "Chitra") + XCTAssertEqual(panchang.yogaName, "Siddha") + XCTAssertEqual(panchang.karanaName, "Vishti") + + try assertLocalTransition( + panchang.transitions.tithi, + current: "Ashtami", next: "Navami", + year: 2026, month: 7, day: 22, hour: 1, minute: 46, + context: paris, toleranceMinutes: 12 + ) + try assertLocalTransition( + panchang.transitions.nakshatra, + current: "Chitra", next: "Swati", + year: 2026, month: 7, day: 21, hour: 17, minute: 19, + context: paris, toleranceMinutes: 12 + ) + try assertLocalTransition( + panchang.transitions.yoga, + current: "Siddha", next: "Sadhya", + year: 2026, month: 7, day: 21, hour: 14, minute: 55, + context: paris, toleranceMinutes: 12 + ) + try assertLocalTransition( + panchang.transitions.karana, + current: "Vishti", next: "Bava", + year: 2026, month: 7, day: 21, hour: 13, minute: 4, + context: paris, toleranceMinutes: 12 + ) + } + + func testEverySolvedLimbBoundaryChangesToItsDeclaredNextValue() throws { + let contexts = [ + context(2026, 1, 15, latitude: 28.6139, longitude: 77.2090, timeZone: "Asia/Kolkata"), + context(2026, 4, 9, latitude: -33.8688, longitude: 151.2093, timeZone: "Australia/Sydney"), + context(2026, 7, 21, latitude: 48.8566, longitude: 2.3522, timeZone: "Europe/Paris"), + context(2026, 11, 3, latitude: 37.4323, longitude: -121.8996, timeZone: "America/Los_Angeles"), + ] + + for calculationContext in contexts { + let daily = CosmicEngine.getPanchang(context: calculationContext) + XCTAssertEqual(daily.transitions.chronological.count, PanchangLimbKind.allCases.count) + + for transition in daily.transitions.chronological { + let before = CosmicEngine.getPanchang( + date: transition.endTime.addingTimeInterval(-60), + timezoneIdentifier: calculationContext.timeZoneIdentifier + ) + let after = CosmicEngine.getPanchang( + date: transition.endTime.addingTimeInterval(60), + timezoneIdentifier: calculationContext.timeZoneIdentifier + ) + XCTAssertEqual( + limbName(transition.kind, in: before), + transition.currentName, + "\(calculationContext.timeZoneIdentifier) \(transition.kind.rawValue) before boundary" + ) + XCTAssertEqual( + limbName(transition.kind, in: after), + transition.nextName, + "\(calculationContext.timeZoneIdentifier) \(transition.kind.rawValue) after boundary" + ) + } + } + } + + func testPolarFallbackIsExplicitAndDoesNotInventSunrise() { + let svalbard = context(2026, 6, 21, latitude: 78.2232, longitude: 15.6469, timeZone: "Arctic/Longyearbyen") + let panchang = CosmicEngine.getPanchang(context: svalbard) + + XCTAssertNil(panchang.sunriseTime) + XCTAssertNil(panchang.sunsetTime) + XCTAssertEqual( + svalbard.calendar.component(.hour, from: panchang.date), + CalculationContext.polarFallbackReferenceHour + ) + XCTAssertTrue(panchang.referenceDisclosure(in: svalbard.timeZone).localizedCaseInsensitiveContains("unavailable")) + } + + func testMonthCellSnapshotSkipsDetailedBoundaryWork() { + let delhi = context(2026, 7, 24, latitude: 28.6139, longitude: 77.2090, timeZone: "Asia/Kolkata") + let compact = CosmicEngine.getPanchang(context: delhi, includeTransitions: false) + + XCTAssertTrue(compact.transitions.chronological.isEmpty) + XCTAssertNotNil(compact.sunriseTime) + } + + func testNextDayTransitionLabelIncludesCivilDateAndSameDayLabelDoesNot() throws { + let paris = context(2026, 7, 21, latitude: 48.8566, longitude: 2.3522, timeZone: "Europe/Paris") + let panchang = CosmicEngine.getPanchang(context: paris) + let tithi = try XCTUnwrap(panchang.transitions.tithi) + let nakshatra = try XCTUnwrap(panchang.transitions.nakshatra) + let locale = Locale(identifier: "en_US_POSIX") + + let nextDay = tithi.endTime.ritualTransitionLabel( + relativeTo: panchang.date, + in: paris.timeZone, + locale: locale + ) + let sameDay = nakshatra.endTime.ritualTransitionLabel( + relativeTo: panchang.date, + in: paris.timeZone, + locale: locale + ) + + XCTAssertTrue(nextDay.contains("22")) + XCTAssertTrue(nextDay.contains("Jul")) + XCTAssertFalse(sameDay.contains("Jul")) + } + + func testPanchangPDFExporterProducesAValidNonEmptyPDF() { + let delhi = context(2026, 7, 24, latitude: 28.6139, longitude: 77.2090, timeZone: "Asia/Kolkata") + let pdf = PanchangPDFExporter.generatePDF(context: delhi) + + XCTAssertTrue(pdf.starts(with: Data("%PDF".utf8))) + XCTAssertGreaterThan(pdf.count, 5_000) + + let renderedArtifact = XCTAttachment(data: pdf, uniformTypeIdentifier: "com.adobe.pdf") + renderedArtifact.name = "Cosmic-Rituals-Panchang-QA" + renderedArtifact.lifetime = .keepAlways + add(renderedArtifact) + } + func testCompleteOfflineCatalogCountAndGlobalSearch() throws { let cities = try Self.loadedCatalog.get() XCTAssertEqual(cities.count, WorldCityCatalog.expectedCatalogCount) @@ -287,12 +501,14 @@ final class CosmicEngineTests: XCTestCase { )) } - func testDailySnapshotNamingMatchesNoonReferenceSemantics() { + func testDailySnapshotNamingMatchesSunriseReferenceSemantics() throws { let delhi = context(2026, 7, 24, latitude: 28.6139, longitude: 77.2090, timeZone: "Asia/Kolkata") - XCTAssertEqual(delhi.calendar.component(.hour, from: delhi.localNoon), CalculationContext.dailySnapshotReferenceHour) + let panchang = CosmicEngine.getPanchang(context: delhi) + let sunrise = try XCTUnwrap(CosmicEngine.getSunriseSunset(context: delhi)?.sunrise) + XCTAssertEqual(panchang.date.timeIntervalSince(sunrise), 0, accuracy: 0.001) XCTAssertEqual(RitualExperienceMode.ritualNow.displayName, "Daily Snapshot") - XCTAssertTrue(RitualExperienceMode.ritualNow.summary.localizedCaseInsensitiveContains("noon")) - XCTAssertTrue(CalculationContext.dailySnapshotDisclosure.localizedCaseInsensitiveContains("12:00 PM")) + XCTAssertTrue(RitualExperienceMode.ritualNow.summary.localizedCaseInsensitiveContains("sunrise")) + XCTAssertTrue(panchang.referenceDisclosure(in: delhi.timeZone).localizedCaseInsensitiveContains("sunrise")) } func testAccessibilityLargeDestinationLayoutUsesIconsWithoutLosingLabels() { @@ -300,11 +516,148 @@ final class CosmicEngineTests: XCTestCase { XCTAssertTrue(RitualResponsiveLayout.usesIconOnlyDestinations(for: .accessibility2)) let titles = RitualDestinationDescriptor.all.map(\.title) - XCTAssertEqual(titles, ["Panchang", "Timing", "Muhurtas", "Calendar"]) - XCTAssertEqual(Set(titles).count, 4) + XCTAssertEqual(titles, ["Panchang", "Timing", "Muhurtas", "Pooja", "Calendar"]) + XCTAssertEqual(Set(titles).count, 5) XCTAssertTrue(RitualDestinationDescriptor.all.allSatisfy { !$0.symbol.isEmpty }) } + func testPoojaVidhiCatalogIsStructuredCompleteAndTraceable() { + XCTAssertGreaterThanOrEqual(PoojaVidhiCatalog.all.count, 12) + XCTAssertEqual(PoojaVidhiCatalog.validationIssues, []) + + for vidhi in PoojaVidhiCatalog.all { + XCTAssertFalse(vidhi.summary.isEmpty, vidhi.id) + XCTAssertFalse(vidhi.traditionNote.isEmpty, vidhi.id) + XCTAssertFalse(vidhi.sourceNotes.isEmpty, vidhi.id) + XCTAssertTrue(vidhi.sourceNotes.allSatisfy { $0.urlString.hasPrefix("https://") }, vidhi.id) + XCTAssertGreaterThanOrEqual(vidhi.durationMinutes.lowerBound, 5, vidhi.id) + XCTAssertLessThanOrEqual(vidhi.durationMinutes.lowerBound, vidhi.durationMinutes.upperBound, vidhi.id) + } + } + + func testSimpleHouseholdPoojasIncludeUnderstandablePublicMantras() { + let householdVidhis = PoojaVidhiCatalog.all.filter { $0.practiceLevel == .simpleHousehold } + XCTAssertFalse(householdVidhis.isEmpty) + + for vidhi in householdVidhis { + let mantras = vidhi.steps.compactMap(\.mantra) + XCTAssertFalse(mantras.isEmpty, vidhi.id) + XCTAssertTrue(mantras.allSatisfy { + !$0.devanagari.isEmpty && !$0.transliteration.isEmpty && !$0.meaning.isEmpty + }, vidhi.id) + } + } + + func testPoojaReadinessCountsRequiredAndOptionalMaterialsSeparately() throws { + let vidhi = try XCTUnwrap(PoojaVidhiCatalog.all.first { $0.id == "griha-pravesh" }) + let requiredIDs = Set(vidhi.materials.filter(\.isRequired).map(\.id)) + let optionalIDs = Set(vidhi.materials.filter { !$0.isRequired }.map(\.id)) + + let initial = vidhi.readiness(preparedMaterialIDs: []) + XCTAssertEqual(initial.requiredMaterialCount, requiredIDs.count) + XCTAssertEqual(initial.optionalMaterialCount, optionalIDs.count) + XCTAssertEqual(initial.requiredPreparationProgress, 0) + XCTAssertFalse(initial.hasPreparedRequiredMaterials) + XCTAssertEqual(initial.sourceCount, vidhi.sourceNotes.count) + XCTAssertEqual(initial.practiceStatus, "Qualified practitioner recommended") + XCTAssertTrue(initial.materialStatus.contains("\(requiredIDs.count) required")) + XCTAssertTrue(initial.sourceStatus.contains("\(vidhi.sourceNotes.count) cited")) + + let prepared = vidhi.readiness(preparedMaterialIDs: requiredIDs.union(optionalIDs.prefix(1))) + XCTAssertEqual(prepared.preparedRequiredMaterialCount, requiredIDs.count) + XCTAssertEqual(prepared.preparedOptionalMaterialCount, min(1, optionalIDs.count)) + XCTAssertEqual(prepared.requiredPreparationProgress, 1) + XCTAssertTrue(prepared.hasPreparedRequiredMaterials) + XCTAssertEqual(prepared.remainingRequiredMaterialCount, 0) + let expectedMaterialStatus = requiredIDs.count == 1 + ? "Required material marked ready" + : "All \(requiredIDs.count) required materials marked ready" + XCTAssertEqual(prepared.materialStatus, expectedMaterialStatus) + } + + func testPoojaSearchCoversPanditGPTPublicStarterPillars() { + XCTAssertEqual(PoojaVidhiCatalog.search("Lakshmi at home").first?.id, "lakshmi-home") + XCTAssertEqual(PoojaVidhiCatalog.search("Griha Pravesh").first?.id, "griha-pravesh") + XCTAssertTrue(PoojaVidhiCatalog.search("Navratri").contains { $0.id == "durga-navratri-home" }) + XCTAssertTrue(PoojaVidhiCatalog.search("mantra meaning").allSatisfy { + $0.steps.contains { $0.mantra != nil } + }) + XCTAssertTrue(PoojaVidhiCatalog.search("planetary", category: .planetary).allSatisfy { + $0.category == .planetary + }) + } + + func testSubscriptionPolicyRecognizesOnlyConfiguredProductsAndSecureLegalLinks() { + XCTAssertEqual(Set(SubscriptionCatalog.productIDs).count, 2) + XCTAssertTrue(SubscriptionCatalog.productIDs.allSatisfy { + $0.hasPrefix("com.cosmic.rituals.premium.") + }) + XCTAssertEqual(SubscriptionCatalog.requestedTrialDays, 14) + XCTAssertFalse(SubscriptionEntitlementChecker.grantsAccess(activeProductIDs: [])) + XCTAssertFalse(SubscriptionEntitlementChecker.grantsAccess(activeProductIDs: ["unrelated.product"])) + XCTAssertTrue(SubscriptionEntitlementChecker.grantsAccess( + activeProductIDs: [SubscriptionCatalog.annualProductID] + )) + XCTAssertTrue([ + SubscriptionCatalog.privacyPolicyURL, + SubscriptionCatalog.termsOfUseURL, + SubscriptionCatalog.supportURL, + SubscriptionCatalog.manageSubscriptionsURL + ].allSatisfy { $0.scheme == "https" }) + } + + func testLaunchPolicyKeepsTestingAccessSeparateFromProductionEntitlements() { + XCTAssertEqual( + SubscriptionLaunchPolicy.initialState( + isUITestingPremium: false, + isTestingDistribution: false + ), + .checking + ) + XCTAssertEqual( + SubscriptionLaunchPolicy.initialState( + isUITestingPremium: true, + isTestingDistribution: false + ), + .entitled + ) + XCTAssertEqual( + SubscriptionLaunchPolicy.initialState( + isUITestingPremium: false, + isTestingDistribution: true + ), + .testingAccess + ) + XCTAssertTrue(SubscriptionAccessState.testingAccess.hasPremiumAccess) + XCTAssertTrue(SubscriptionAccessState.testingAccess.isTestingAccess) + XCTAssertFalse(SubscriptionAccessState.entitled.isTestingAccess) + XCTAssertFalse(SubscriptionAccessState.storeUnavailable("offline").hasPremiumAccess) + } + + func testStoreKitConfigurationDeclaresTwoWeekTrialsForEveryProduct() throws { + let nativeRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = nativeRoot.appendingPathComponent("StoreKit/CosmicRituals.storekit") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + let data = try Data(contentsOf: url) + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let groups = try XCTUnwrap(root["subscriptionGroups"] as? [[String: Any]]) + let subscriptions = groups.flatMap { $0["subscriptions"] as? [[String: Any]] ?? [] } + + XCTAssertEqual(Set(subscriptions.compactMap { $0["productID"] as? String }), Set(SubscriptionCatalog.productIDs)) + XCTAssertEqual(Set(subscriptions.compactMap { $0["recurringSubscriptionPeriod"] as? String }), ["P1M", "P1Y"]) + + for subscription in subscriptions { + let productID = try XCTUnwrap(subscription["productID"] as? String) + let offers = try XCTUnwrap(subscription["introductoryOffers"] as? [[String: Any]], productID) + let offer = try XCTUnwrap(offers.first, productID) + XCTAssertEqual(offer["paymentMode"] as? String, "free", productID) + XCTAssertEqual(offer["subscriptionPeriod"] as? String, "P2W", productID) + XCTAssertEqual(offer["numberOfPeriods"] as? Int, 1, productID) + } + } + func testAccessibilityLargeLocationMetadataKeepsFullTimeZone() { XCTAssertEqual( RitualResponsiveLayout.locationMetadataLines( @@ -356,6 +709,28 @@ final class CosmicEngineTests: XCTestCase { ) } + func testLocationChangePreservesTheSelectedCivilDayAcrossDateLineOffsets() throws { + var losAngelesCalendar = Calendar(identifier: .gregorian) + losAngelesCalendar.timeZone = try XCTUnwrap(TimeZone(identifier: "America/Los_Angeles")) + let selected = try XCTUnwrap(losAngelesCalendar.date(from: DateComponents( + year: 2026, month: 7, day: 23, hour: 20, minute: 30 + ))) + let tokyo = try XCTUnwrap(TimeZone(identifier: "Asia/Tokyo")) + + let translated = selected.ritualCivilDay( + preservingDateFrom: losAngelesCalendar.timeZone, + into: tokyo + ) + var tokyoCalendar = Calendar(identifier: .gregorian) + tokyoCalendar.timeZone = tokyo + let components = tokyoCalendar.dateComponents([.year, .month, .day, .hour], from: translated) + + XCTAssertEqual(components.year, 2026) + XCTAssertEqual(components.month, 7) + XCTAssertEqual(components.day, 23) + XCTAssertEqual(components.hour, 12) + } + func testOnlyOwnedNotificationIdentifiersAreCleared() { XCTAssertTrue(NotificationManager.isRitualNotificationIdentifier("muhurta.2.123")) XCTAssertTrue(NotificationManager.isRitualNotificationIdentifier("brahma.123")) @@ -401,6 +776,22 @@ final class CosmicEngineTests: XCTestCase { } } + func testThemeNamesExpressAUniqueCeremonialIdentity() { + XCTAssertEqual( + CosmicThemeVariant.allCases.map(\.displayName), + ["Deep Sandhya", "Temple Dawn", "Kumkum Night", "Ghee Lamp Ivory", "Sandalwood", "Lotus Ash"] + ) + XCTAssertTrue(CosmicThemeVariant.allCases.allSatisfy { + !$0.displayName.localizedCaseInsensitiveContains("cosmic") + && !$0.displayName.localizedCaseInsensitiveContains("celestial") + && !$0.displayName.localizedCaseInsensitiveContains("aurora") + }) + } + + func testRitualSanctuaryBackgroundAssetIsBundled() { + XCTAssertNotNil(UIImage(named: "RitualSanctuaryBackground")) + } + func testSelectedControlForegroundRemainsLegibleAcrossEveryTheme() { for theme in CosmicThemeVariant.allCases.map(\.colorScheme) { XCTAssertGreaterThanOrEqual( @@ -442,6 +833,94 @@ final class CosmicEngineTests: XCTestCase { XCTAssertEqual(reasons, ["CA92.1"]) } + @MainActor + func testPoojaPrimaryScreensRenderAtPhoneAndAccessibilitySizes() async { + let delhi = context(2026, 7, 24, latitude: 28.6139, longitude: 77.2090, timeZone: "Asia/Kolkata") + let panchangValue = CosmicEngine.getPanchang(context: delhi) + let panchang = NavigationStack { + ZStack { + RitualSanctuaryBackground() + ScrollView { + PanchangExperienceHome( + mode: .ritualNow, + selectedDate: .constant(delhi.localDay), + panchang: panchangValue, + showTithiDetail: {}, + showYogaDetail: {}, + showKaranaDetail: {} + ) + .padding() + } + } + .navigationTitle("Panchang") + } + .environment(\.cosmicTheme, CosmicColorScheme.obsidianGold) + .environment(\.timeZone, delhi.timeZone) + .preferredColorScheme(.dark) + + let library = NavigationStack { + ZStack { + RitualSanctuaryBackground() + PoojaVidhiLibraryView() + } + .navigationTitle("Pooja") + } + .environment(\.cosmicTheme, CosmicColorScheme.obsidianGold) + .preferredColorScheme(.dark) + + let detail = NavigationStack { + PoojaVidhiDetailView(vidhi: PoojaVidhiCatalog.all[2]) + } + .environment(\.cosmicTheme, CosmicColorScheme.obsidianGold) + .preferredColorScheme(.dark) + + let guided = NavigationStack { + GuidedPoojaView(vidhi: PoojaVidhiCatalog.all[1]) + } + .environment(\.cosmicTheme, CosmicColorScheme.cloudDancer) + .environment(\.dynamicTypeSize, .accessibility2) + .preferredColorScheme(.light) + + await attachSnapshot(panchang, name: "Panchang Calculation Disclosure") + await attachSnapshot(library, name: "Pooja Library") + await attachSnapshot(detail, name: "Lakshmi Vidhi Detail") + await attachSnapshot(guided, name: "Guided Pooja Accessibility Text") + } + + @MainActor + private func attachSnapshot(_ content: Content, name: String) async { + let size = CGSize(width: 390, height: 844) + let controller = UIHostingController(rootView: content) + guard let windowScene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first else { + XCTFail("A window scene is required for the rendering contract") + return + } + let window = UIWindow(windowScene: windowScene) + window.frame = CGRect(origin: .zero, size: size) + window.rootViewController = controller + window.makeKeyAndVisible() + controller.beginAppearanceTransition(true, animated: false) + controller.endAppearanceTransition() + controller.view.frame = window.bounds + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + await Task.yield() + try? await Task.sleep(nanoseconds: 150_000_000) + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { _ in + controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) + } + XCTAssertEqual(image.size, size) + let attachment = XCTAttachment(image: image) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + window.isHidden = true + } + private func contrast(_ foreground: Color, over background: Color) -> Double { let foregroundRGBA = rgba(foreground) let backgroundRGBA = rgba(background) @@ -499,4 +978,72 @@ final class CosmicEngineTests: XCTestCase { timeZoneIdentifier: timeZone ) } + + private func assertLocalTransition( + _ transition: PanchangTransition?, + current: String, + next: String, + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + context: CalculationContext, + toleranceMinutes: Double, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let transition = try XCTUnwrap(transition, file: file, line: line) + XCTAssertEqual(transition.currentName, current, file: file, line: line) + XCTAssertEqual(transition.nextName, next, file: file, line: line) + let expected = try XCTUnwrap(context.calendar.date(from: DateComponents( + year: year, month: month, day: day, hour: hour, minute: minute + )), file: file, line: line) + XCTAssertEqual( + transition.endTime.timeIntervalSince(expected), + 0, + accuracy: toleranceMinutes * 60, + file: file, + line: line + ) + } + + private func limbName(_ kind: PanchangLimbKind, in panchang: Panchang) -> String { + switch kind { + case .tithi: return panchang.tithiName + case .nakshatra: return panchang.nakshatraName + case .yoga: return panchang.yogaName + case .karana: return panchang.karanaName + } + } + + private func assertLocalInterval( + _ period: (start: Date, end: Date, label: String), + startHour: Int, + startMinute: Int, + endHour: Int, + endMinute: Int, + context: CalculationContext, + toleranceMinutes: Double, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let components = context.localDayComponents + let start = try XCTUnwrap(context.calendar.date(from: DateComponents( + year: components.year, + month: components.month, + day: components.day, + hour: startHour, + minute: startMinute + )), file: file, line: line) + let end = try XCTUnwrap(context.calendar.date(from: DateComponents( + year: components.year, + month: components.month, + day: components.day, + hour: endHour, + minute: endMinute + )), file: file, line: line) + XCTAssertEqual(period.start.timeIntervalSince(start), 0, accuracy: toleranceMinutes * 60, file: file, line: line) + XCTAssertEqual(period.end.timeIntervalSince(end), 0, accuracy: toleranceMinutes * 60, file: file, line: line) + } } diff --git a/ios/native/POOJA_CONTENT.md b/ios/native/POOJA_CONTENT.md new file mode 100644 index 0000000..419297e --- /dev/null +++ b/ios/native/POOJA_CONTENT.md @@ -0,0 +1,65 @@ +# Pooja Vidhi content contract + +Cosmic Rituals provides an offline, inclusive household reference—not a claim that one +Pooja Vidhi is canonical for every family, temple, region, or sampradaya. A received +family or temple paddhati takes precedence. + +## Product scope + +The first catalog contains twelve structured guides: + +| Practice | Level | App coverage | +| --- | --- | --- | +| Daily Panchopachara | Simple household | Complete simple sequence | +| Shri Ganesha | Simple household | Complete simple sequence | +| Mahalakshmi | Simple household | Short Panchopachara form | +| Shiva | Simple household | Water-only optional abhisheka boundary | +| Vishnu | Simple household | Complete simple sequence | +| Durga and Navratri | Extended household | Daily worship; kalasha/fasting variations disclosed | +| Hanuman | Simple household | Complete simple sequence; oil/sindoor not prescribed | +| Saraswati | Simple household | Complete simple sequence with book/instrument safety | +| Surya Arghya | Simple household | Complete safe water-offering sequence | +| Satyanarayana Vrata | Priest recommended | Preparation and ceremony map; received katha required | +| Griha Pravesh | Priest recommended | Household preparation, safety, and ceremony map | +| Navagraha Pooja | Priest recommended | Ethical preparation and ceremony map; no remedial guarantees | + +Each item includes purpose, occasions, duration, preparation, a material checklist, +ordered steps, respectful completion, safety notes, tradition scope, and source notes. +Short public name-mantras include Devanagari, IAST-style transliteration, plain meaning, +and a non-compulsory repetition suggestion. + +## Boundaries + +- The app does not generate initiatory bija-mantras, nyasa, Vedic recitation, homa, + formal kalasha installation, or lineage-specific visarjana. +- Priest-recommended guides describe household preparation and the ceremony's shape; + they stop at the officiant's boundary. +- Rituals do not guarantee material, medical, legal, relationship, or astrological + outcomes. Fear-based purchasing, unaffordable donations, and gemstone guarantees are + explicitly rejected. +- Fasting is optional and is not prescribed. Health needs, medication schedules, + pregnancy, age, and eating-disorder risk take priority. +- Fire, smoke, water, food, allergy, accessibility, property, and local-law constraints + override ceremonial preference. +- Participation is not restricted by caste, gender, menstruation, marital status, or + birth. Posture and movement may be adapted for disability, age, safety, and comfort. + +## Provenance and adaptation + +The linked **PanditGPT** listing inspired five discoverability pillars—at-home Lakshmi +Pooja, mantra meaning, Navratri practice, Griha Pravesh, and location-aware muhurta. +Only its public description and prompt starters were visible; no hidden instructions or +unseen answers were copied or treated as ritual authority. + +- [PanditGPT public listing](https://chatgpt.com/g/g-67c41c53568c8191a29edabd3ce907d0-panditgpt) +- [Himalayan Academy — Home Puja](https://www.himalayanacademy.com/media/books/home-puja_ei/web/ch24a.html): common preparation, offering, prasada, and quiet-closing pattern. Its detailed rite remains identified as a Saiva Ganesha form. +- [BAPS — Daily Puja](https://www.baps.org/Spiritual-Living/Hindu-Practices/Daily-Puja.aspx): cleanliness, regularity, meditation, prayer, and closure, with Swaminarayan-specific elements not generalized. +- [Drik Panchang — Lakshmi Puja Vidhi](https://www.drikpanchang.com/festivals/lakshmipuja/info/lakshmi-puja-vidhi.html): progression of the formal sixteen-offering Lakshmi rite, adapted to a clearly labeled short household form. +- [Drik Panchang — Durga Puja Vidhi](https://www.drikpanchang.com/navratri/durga-puja/puja-vidhi/durga-puja-vidhi.html): broad Navratri worship progression; formal Ghatasthapana and Shodashopachara remain outside the simple guide. +- [Drik Panchang — Satyanarayana Puja Vidhi](https://www.drikpanchang.com/hindu-gods/trimurti/lord-vishnu/puja-vidhi/satyanarayan-puja-vidhi.html): broad vrata structure; the app requires a locally received complete katha instead of synthesizing one. + +The catalog's executable validation checks unique identifiers, sequential steps, +minimum preparation detail, safety notes, source transparency, and complete mantra +fields. The source-adaptation review was completed on **2026-07-28**. This is not +sampradaya-specific practitioner certification; formal rites and received practice must +still be confirmed with a qualified practitioner. diff --git a/ios/native/PRODUCT_IDENTITY.md b/ios/native/PRODUCT_IDENTITY.md new file mode 100644 index 0000000..6413877 --- /dev/null +++ b/ios/native/PRODUCT_IDENTITY.md @@ -0,0 +1,38 @@ +# Cosmic Rituals Product Identity + +Cosmic Rituals is the ceremony and practice app in the Cosmic suite. It must feel related to the suite without looking or behaving like a recolored astrology, numerology, or Vastu product. + +## Product promise + +Help a household practitioner choose the correct civil day and location, understand the limits of a general reference, prepare safely, follow a clear ritual sequence, and retain the provenance needed to confirm regional or lineage-specific details. + +Accuracy, scope disclosure, and safety take precedence over visual drama. + +## Ownable qualities + +- **Ceremonial, not galactic:** temple light, sandalwood, kumkum, ghee-lamp ivory, quiet flame imagery, serif ritual headings, and warm opaque surfaces. Starfields, orbital motifs, chart-paper grids, and blueprint geometry belong to other products. +- **A lived ritual setting:** full-screen sanctuary photography may show a restrained household altar, diya, flowers, brass vessels, and preparation materials. It stays subordinate to content through theme-aware veils and never uses deity imagery as decoration. +- **Readiness before performance:** required materials, optional alternatives, practitioner boundaries, source count, and source-review date appear before a user begins. +- **Guidance with boundaries:** household adaptations are distinguished from priest-led, initiatory, fire, installation, and lineage-specific rites. +- **Provenance in the flow:** source and tradition context remains visible in the guide and guided-practice states, not only in legal or settings screens. +- **Calculated results with context:** Panchang results disclose sunrise anchoring, IANA time zone, Lahiri sidereal convention, and on-device calculation at the point of use. +- **Quiet modernity:** contemporary native controls support the work; they do not turn sacred practice into a game, prediction score, or spectacle. + +## Suite differentiation contract + +| App | Primary object | Visual grammar | Functional proof | +| --- | --- | --- | --- | +| Cosmic Rituals | A ceremony being prepared and performed | Altar light, warm ritual pigments, calm sequential surfaces | Readiness, timing context, steps, safety, sources, tradition limits | +| Cosmic Astrology | A chart interpreted from an ephemeris | Observatory, orbital geometry, chart contrast, celestial bodies | Birth data integrity, ephemeris method, chart geometry, interpretive provenance | +| Cosmic Numerology | A calculation traced from names and dates | Ledgers, graph paper, numeric notation, typographic hierarchy | Method-specific arithmetic, normalization trace, reproducible worksheets | +| CosmicVastu | A property measured and analyzed | Plans, compass/grid systems, spatial layers, evidence markers | Approved geometry, measurement provenance, rule-pack version, confidence per fact | + +Shared foundations may include accessibility, privacy, reliability, and native platform behavior. Primary metaphors, layouts, content surfaces, proof of accuracy, and domain workflows must remain app-specific. + +## Prohibited shortcuts + +- Do not use one generic starfield and glass-card system across all four apps. +- Do not present a confidence percentage for a ritual or imply that one sequence is universally canonical. +- Do not invent unsupported mantras, festival dates, remedial guarantees, or priestly instructions. +- Do not hide time zone, location, source, approximation, regional-variation, or safety limits when they materially affect a result. +- Do not treat a polished result as accurate until calculation fixtures, content validation, and runtime verification pass. diff --git a/ios/native/README.md b/ios/native/README.md index 1a6ebdb..d8b389b 100644 --- a/ios/native/README.md +++ b/ios/native/README.md @@ -8,16 +8,27 @@ cosmic-glass look and its sidereal (Lahiri ayanamsha) ephemeris. - **Pancha Anga (Five Limbs)** for any date: Vara (weekday), Tithi (lunar day), Nakshatra, Yoga, and Karana. +- **Sunrise-anchored day model** with independently solved Tithi, Nakshatra, Yoga, + and Karana transition times, plus a truthful polar fallback. - **Nakshatra detail** — the Moon's nakshatra with its symbol, gana, ruling (dasha) lord, pada (1–4), Moon sign, and Shukla/Krishna paksha phase. - **30 Muhurtas** — all fifteen day and fifteen night windows, each named and tagged by classical auspiciousness (Excellent / Auspicious / Mixed / Avoid), with the **current** muhurta highlighted live against the clock. - **Auspicious & inauspicious kala** — Rahu Kala and Yamaganda for the weekday. +- **12 offline Pooja Vidhis** — searchable daily, deity, festival, vrata, life-event, + and planetary guides with materials, preparation, ordered steps, Devanagari, + transliteration, mantra meaning, safety, respectful closure, and guided mode. +- **Tradition-aware ritual boundaries** — household guides are separated from + priest-recommended rites; initiatory mantras, homa instructions, and universalized + claims are intentionally excluded. +- **StoreKit 2 Premium access** — verified App Store entitlements, transaction updates, + restore purchases, localized subscription UI, and eligible 14-day introductory + trials. No local trial timer or unverified receipt fallback. ## Build & run -Open `CosmicRituals.xcodeproj` in **Xcode 16+** and run the `CosmicRituals` scheme on an +Open `CosmicRituals.xcodeproj` in **Xcode 26+** and run the `CosmicRituals` scheme on an iOS 26 simulator or device. - Deployment target: **iOS 26** (the muhurta summary pills use the `.glassEffect` API). @@ -28,7 +39,7 @@ iOS 26 simulator or device. ``` CosmicRituals/ ├── App/ @main entry → RootView -├── Engine/ pure-Foundation Panchang ephemeris + models +├── Engine/ pure-Foundation Panchang ephemeris + Pooja catalog/models ├── Theme/ shared cosmic-glass theme & components └── Views/ RootView + PanchangView ``` @@ -36,8 +47,20 @@ CosmicRituals/ See [ARCHITECTURE.md](ARCHITECTURE.md) for the engine details and exactly what was imported from the parent Cosmic Astrology app. -## Accuracy +See [POOJA_CONTENT.md](POOJA_CONTENT.md) for the ritual-content contract, source +provenance, inclusion policy, and the household/priest boundary. -Positions use Meeus algorithms with the Lahiri (Chitra Paksha) ayanamsha. Muhurta windows -are derived from local sunrise/sunset (Delhi default location). For ritually precise -timings, confirm with a qualified Jyotishi. +See [SUBSCRIPTION_RELEASE.md](SUBSCRIPTION_RELEASE.md) for product identifiers, +entitlement behavior, App Store Connect setup, policy URLs, and the sandbox test matrix. + +## Accuracy and integrity + +Positions use Meeus algorithms with the Lahiri (Chitra Paksha) ayanamsha. Muhurta +windows are derived from sunrise and sunset for the explicitly selected location +and IANA time zone. Independent civil-time fixtures cover solar events, all four +limb transitions, Dur Muhurta, Abhijit, DST, and polar behavior. + +See [ACCURACY.md](ACCURACY.md) for method provenance, validation tolerances, and +features that are intentionally not presented as reference-grade. For +consequential ceremonial timing, confirm the chosen convention with a qualified +practitioner. diff --git a/ios/native/RELEASE_1.0_6.md b/ios/native/RELEASE_1.0_6.md new file mode 100644 index 0000000..db35abd --- /dev/null +++ b/ios/native/RELEASE_1.0_6.md @@ -0,0 +1,50 @@ +# TestFlight evidence — 1.0 (6) + +Date: 2026-07-28 +Bundle ID: `com.cosmic.rituals` +Platform: iOS/iPadOS 26 +Disposition: uploaded, processing complete, available to the internal group + +## Delivery result + +- Source snapshot: commit `3a34bfd`. +- App Store Connect delivery UUID: + `93383fea-b2cb-4166-8c6f-ea993bcf8156`. +- Apple transport completed with no errors or warnings. +- The logged-in Connect web app showed upload status `Complete` at + 21:40 IST. +- TestFlight showed build `1.0 (6)` as `Ready to Submit`, attached to + `Cosmic Rituals Internal` with two invites. +- External beta review and external-group availability are not claimed. + +## Product increment + +- Replaced the interchangeable cosmic shell with a warm, restrained household + ritual sanctuary and six ceremonial themes. +- Added factual material readiness derived from required and optional checklist + state instead of a decorative confidence score. +- Kept safety, practitioner boundaries, tradition context, source count, and + source-review date visible before and during practice. +- Preserved Panchang calculation context, including the civil date, location, + IANA time zone, sunrise anchor, and Lahiri convention. + +## Quality gate + +- Native test suite: 50 passed, 0 failed. +- Release archive: `/tmp/CosmicRituals-6.xcarchive`. +- Archive bundle/version/build: `com.cosmic.rituals`, `1.0`, `6`. +- Release architecture: arm64. +- Strict signature and privacy-manifest validation: passed. +- App and dSYM UUID matched: + `F31DA204-04A9-3CB0-9986-14C82C4A2081`. +- No UI-test entitlement token, XCTest, runtime-injection, Guard Malloc, + Main Thread Checker, or debug launch marker was found in the release binary. + +## Known limits + +- Ritual instructions remain source-bounded household guidance, not a claim + that one sequence is universally canonical. +- Regional, lineage-specific, initiatory, installation, and fire rites still + require qualified local guidance where the app says so. +- External TestFlight review, physical-device VoiceOver, and extended energy + runs are not claimed by this record. diff --git a/ios/native/RELEASE_1.0_7.md b/ios/native/RELEASE_1.0_7.md new file mode 100644 index 0000000..24facb9 --- /dev/null +++ b/ios/native/RELEASE_1.0_7.md @@ -0,0 +1,57 @@ +# TestFlight evidence — 1.0 (7) + +Date: 2026-07-28 +Bundle ID: `com.cosmic.rituals` +Platform: iOS/iPadOS 26 +Disposition: processing complete; available to the internal TestFlight group + +## Delivery result + +- Source snapshot: commit `957d21b`. +- App Store Connect delivery UUID: + `100fee66-edcd-410e-a13d-5d18f2b07b20`. +- Apple transport completed with no errors or warnings. +- The upload completed App Store Connect processing with status `Complete`. +- Export used `testFlightInternalTestingOnly = true`; Apple documents that such a + build cannot be distributed through external TestFlight or the App Store. +- The signed-in Connect app showed build `7 Internal` with status `Testing`, attached + to `Cosmic Rituals Internal` with two tester invites and a 90-day expiry window. +- External beta review and external-group availability are not claimed. + +## Reliability fix + +- The dedicated `CosmicRitualsTestFlight` scheme now grants clearly labeled testing + access without depending on App Store product metadata. +- A StoreKit outage or incomplete subscription propagation can no longer prevent + internal testers from opening Panchang, Timing, Muhurtas, Pooja, or Calendar. +- The standard production `CosmicRituals` Release configuration remains unchanged: + verified StoreKit entitlement is still required and no testing-access condition is + compiled into its launch path. + +## Quality gate + +- Native test suite: 52 passed, 0 failed. +- Focused launch-policy regression: passed. +- Exact TestFlight configuration built, installed, and opened directly into the app + with the visible `TestFlight testing access` banner. +- Standard production Release configuration separately built and remained on the + entitlement gate while StoreKit was unavailable. +- Release archive: `/tmp/CosmicRituals-7-TestFlight.xcarchive`. +- Archive bundle/version/build: `com.cosmic.rituals`, `1.0`, `7`. +- Release architecture: arm64. +- Strict signature and privacy-manifest validation: passed. +- App and dSYM UUID matched: + `AD763FF6-5F1E-30FB-9FE6-3F03393E716E`. +- App Store export re-signed with Apple Distribution, set + `beta-reports-active = 1`, and set `get-task-allow = 0`. +- No UI-test entitlement token, XCTest, runtime-injection, Guard Malloc, Main Thread + Checker, or debug launch marker was found in the archived release binary. + +## Release boundary + +- Build 7 is intentionally unsuitable for App Store submission because it grants + internal testing access without a purchase. +- Archive the standard `CosmicRituals` scheme for a future App Store candidate after + subscription products have been verified end to end. +- No App Store version, subscription, external beta group, Beta App Review, App Review, + or public release action was taken. diff --git a/ios/native/ROADMAP.md b/ios/native/ROADMAP.md index ec0a235..3fb4782 100644 --- a/ios/native/ROADMAP.md +++ b/ios/native/ROADMAP.md @@ -1,116 +1,119 @@ -# Cosmic Rituals — Product Roadmap - -> Goal: the definitive native iOS Vedic Panchang experience — surpassing drikpanchang.com on UX -> while matching (then exceeding) it on content depth. On-device, private, no account required. - ---- - -## Baseline (shipped — v0.1) - -| Feature | Status | -|---------|--------| -| Five limbs — Vara, Tithi, Nakshatra, Yoga, Karana | ✅ | -| 30 day + night muhurtas with quality ratings | ✅ | -| Muhurta detail sheet (deity, resonance, activities) | ✅ | -| Rahu Kala + Yamaganda (computed from sunrise) | ✅ | -| Nakshatra detail — lord, gana, pada, symbol | ✅ | -| Moon sign + tithi phase (Shukla/Krishna) | ✅ | -| Animated starfield + Liquid Glass UI | ✅ | -| 6 color themes (3 dark, 3 light) | ✅ | -| Location-aware sunrise/sunset | ✅ | -| Date picker | ✅ | - ---- - -## Phase 1 — Daily-Use Parity with drikpanchang `[✅ shipped v0.2]` - -| Feature | Status | -|---------|--------| -| 1.1 Choghadiya (8 day + 8 night periods, live highlight) | ✅ | -| 1.2 Hora (24 planetary hours, Chaldean cycle) | ✅ | -| 1.3 Moonrise & Moonset (Meeus §15 + parallax) | ✅ | -| 1.4 Gulika Kala | ✅ | -| 1.4 Dur Muhurta (Muhurta Chintamani table) | ✅ | -| 1.4 Varjyam | pending | -| 1.5 Brahma Muhurta + Abhijit Muhurta callouts | ✅ | -| 1.6 Sun sign + Sun nakshatra | ✅ | -| 1.7 Tithi transition time (binary search) | ✅ | - ---- - -## Phase 2 — Content Depth & Discovery `[✅ shipped v0.3]` - -| Feature | Status | -|---------|--------| -| 2.1 Panchang limb detail sheets (Tithi/Yoga/Karana) | ✅ | -| 2.2 Monthly calendar view with tithi/yoga quality dots | ✅ | -| 2.3 Festival & Vrat calendar (40+ festivals) | ✅ | -| 2.4 "Best time today" card with activity picker | ✅ | -| 2.5 Muhurta timeline visualization with live cursor | ✅ | - ---- - -## Phase 3 — Platform & Ecosystem `[partial v0.4]` - -| Feature | Status | -|---------|--------| -| 3.1 WidgetKit (small + medium) | ✅ source files ready; **add target in Xcode** | -| 3.2 Live Activity + Dynamic Island | pending (needs ActivityKit target) | -| 3.3 Notifications (muhurta + Brahma Muhurta) | ✅ | -| 3.4 Siri Shortcuts / App Intents (3 intents) | ✅ | -| 3.5 Share sheet (text + PDF export) | ✅ | - ---- - -## Phase 4 — Advanced Jyotish `[✅ shipped v0.5]` - -| Feature | Status | -|---------|--------| -| 4.1 Planetary hora real-time display | ✅ | -| 4.2 Chandra Bala & Tara Bala | ✅ | -| 4.3 Transit alerts (Jupiter + Saturn sign change notifications) | ✅ | -| 4.3 Nine Graha Positions (Schlyter orbital elements, sidereal) | ✅ | -| 4.4 Panchang PDF export (dark-theme A4) | ✅ | -| 4.5 Apple Watch companion | pending (needs WatchOS target) | - ---- - -## Technical Debt & Ongoing - -| Item | Status | -|------|--------| -| CosmicTheme.swift (1300 lines) — split into components | pending | -| Panchang struct sunriseTime/sunsetTime always nil | pending | -| Unit tests for CosmicEngine | ✅ written; **add test target in Xcode** | -| Tithi name duplication (Pratipada appears twice) | pending | -| Varjyam (nakshatra+pada based inauspicious period) | pending | -| App Group entitlement for widget location sharing | **enable in Xcode Signing & Capabilities** | -| WidgetKit target — add in Xcode and enable App Group | **manual Xcode step** | - ---- - -## Competitive Positioning - -| Feature | DrikPanchang (web) | Cosmic Rituals | -|---------|-------------------|----------------| -| Native iOS UX | ✗ | ✅ | -| Muhurta detail (deity/activities) | Basic | ✅ Rich | -| Live animated UI | ✗ | ✅ | -| Offline / on-device | Partial | ✅ 100% | -| Choghadiya | ✅ | ✅ | -| Hora | ✅ | ✅ | -| Moonrise/Moonset | ✅ | ✅ | -| Monthly calendar | ✅ | ✅ | -| Festivals | ✅ | ✅ | -| Nine Graha Positions | ✅ | ✅ | -| Dur Muhurta | ✅ | ✅ | -| Chandra/Tara Bala | ✅ | ✅ | -| Transit alerts | ✗ | ✅ | -| Widgets | ✗ | ✅ (source ready) | -| Siri integration | ✗ | ✅ | -| PDF export | ✗ | ✅ | -| Privacy / no account | ✗ | ✅ | - ---- - -*Last updated: 2026-07-01* +# Cosmic Rituals — product status and roadmap + +> Product standard: a definitive, private native Panchang experience whose +> calculation claims never outrun its evidence. + +Status words in this document are strict: + +- **Shipping** means reachable in the app target and covered by a current build. +- **Validated** means it also has an automated invariant or independent fixture. +- **Prototype** means source exists but is not a user-facing capability. +- **Pending** means it is not implemented. + +## Current shipping surface + +| Capability | Status | Evidence / limit | +|---|---|---| +| Vara, Tithi, Nakshatra, Yoga, Karana | Shipping + validated | Anchored to selected-location sunrise | +| All four limb transition times | Shipping + validated | Independent Paris fixture; chronological boundary invariants | +| Moon sign, Nakshatra Pada, lord, and Gana | Shipping | Evaluated at the same sunrise reference | +| Sunrise and sunset | Shipping + validated | Four public civil-time fixtures; explicit polar no-result | +| Brahma Muhurta | Shipping | 96–48 minutes before real sunrise | +| Abhijit Muhurta | Shipping + validated | Daylight-scaled; omitted on Wednesday | +| Rahu Kala, Yamaganda, Gulika Kala | Shipping + validated | Published Mumbai fixture; local daylight divisions | +| Dur Muhurta | Shipping + validated | Correct weekday table, including Tuesday night period | +| Choghadiya | Shipping | Eight local day + eight local night periods | +| Hora | Shipping | Twelve local day + twelve local night planetary hours | +| 30 Ahoratra Muhurtas | Shipping | Fifteen day + fifteen night windows | +| Muhurta details and activity filter | Shipping | Traditional symbolic guidance, not prediction | +| Monthly sunrise-snapshot calendar | Shipping | Cache keyed by location and time zone | +| Offline location catalog | Shipping + validated | 33,909 GeoNames cities; CC BY 4.0 attribution | +| Current-location mode | Shipping + validated | Freshness, authorization, coordinate, and accuracy gates | +| Text and PDF sharing | Shipping | Uses the active calculation time zone and sunrise reference | +| App Intents / Shortcuts | Shipping | Requires a persisted explicit location | +| Three information layouts + six themes | Shipping + validated | Dynamic Type, contrast, Reduce Motion, VoiceOver labels | +| Calculation-integrity disclosure | Shipping | Method, privacy, tolerance, and ceremonial-use caveat | + +The detailed method and fixture record is in [ACCURACY.md](ACCURACY.md). + +## Platform truth + +| Capability | Status | What remains | +|---|---|---| +| Automatic notifications | Pending / visibly off | Location/date-safe scheduling, permission UX, tests | +| Home Screen widgets | Prototype source only | Widget extension target, App Group, signing, shared context, E2E tests | +| Live Activity / Dynamic Island | Pending | ActivityKit target and lifecycle design | +| Apple Watch | Pending | Watch target, shared calculation contract, device QA | + +The app and documentation must not describe prototype source as a shipping +capability. + +## Calculation work intentionally quarantined + +The following source is preserved for research but is not routed into shipping +navigation because it does not yet meet the evidence bar: + +| Area | Status | Required before surfacing | +|---|---|---| +| Moonrise and moonset | Prototype | Topocentric altitude solver, parallax, multiple global fixtures | +| Festival and vrata calendar | Prototype | True lunisolar month, Adhika/Kshaya handling, regional precedence rules, fixtures | +| Extended Samvat / Masa / Anandadi panel | Prototype | Rule-complete calendar derivation and regional settings | +| Nine Graha positions | Prototype | Reference-grade ephemeris and error envelope | +| Varjyam / Amrit Kalam | Pending | Nakshatra-based intervals and published fixtures | + +## Next product phases + +### 1. Reference-grade astronomy + +- Evaluate a legally compatible high-precision ephemeris strategy. +- Add global Moonrise/Moonset and Graha fixtures before enabling their views. +- Expand five-limb fixtures across ayanamsha-boundary and skipped/extended-tithi cases. +- Add observer elevation and refraction settings only if their effect can be + explained without creating false confidence. + +### 2. Rule-complete observance calendar + +- Model Amanta and Purnimanta lunar months from actual lunations. +- Detect Adhika and Kshaya months. +- Encode event-specific sunrise, midnight, Pradosha, and tithi-precedence rules. +- Make regional school an explicit user choice. +- Ship festival data only after multi-year, multi-city fixture validation. + +### 3. Native ecosystem + +- Add the WidgetKit target and shared context contract. +- Add notifications only with rescheduling on location, time-zone, and date changes. +- Design Live Activity and Watch surfaces around the next verified transition, + not around duplicated calculation code. + +### 4. International depth + +- Localize interface, Panchang terminology, numeral style, and 12/24-hour time. +- Add transliteration preferences without changing calculation identity. +- Validate right-to-left layout and the largest Dynamic Type sizes. + +## Release gates + +Every release must pass: + +```bash +xcodebuild \ + -project CosmicRituals.xcodeproj \ + -scheme CosmicRituals \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO \ + build-for-testing + +xcodebuild \ + -project CosmicRituals.xcodeproj \ + -scheme CosmicRituals \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + CODE_SIGNING_ALLOWED=NO \ + test +``` + +It must also receive simulator QA for the three experience modes, dark and light +appearance, location changes, a next-day transition, polar no-schedule behavior, +Reduce Motion, and an accessibility Dynamic Type size. + +Last truthfulness review: 2026-07-27. diff --git a/ios/native/SUBSCRIPTION_RELEASE.md b/ios/native/SUBSCRIPTION_RELEASE.md new file mode 100644 index 0000000..8ccbf7b --- /dev/null +++ b/ios/native/SUBSCRIPTION_RELEASE.md @@ -0,0 +1,72 @@ +# Subscription and release contract + +Cosmic Rituals uses StoreKit 2 and App Store-signed transactions. It never starts a +local countdown or grants a trial from a device timestamp. + +## Products + +All products belong to the **Cosmic Rituals Premium** subscription group: + +| Reference name | Product ID | Period | Launch price basis | Introductory offer | +| --- | --- | --- | --- | --- | +| Premium Annual | `com.cosmic.rituals.premium.annual` | 1 year | USD 49.99 | 2 weeks free | +| Premium Monthly | `com.cosmic.rituals.premium.monthly` | 1 month | USD 6.99 | 2 weeks free | + +App Store Connect supplies localized prices and eligibility. Apple permits a two-week +free introductory offer for monthly and yearly subscriptions; each customer can redeem +only one introductory offer in a subscription group. + +## Access behavior + +- Access is granted only for a verified transaction returned by + `Transaction.currentEntitlements` for one of the configured product IDs. +- Unverified transactions never unlock the app. +- Revoked and refunded transactions disappear from current entitlements and therefore + lose access on refresh. +- Transaction updates are observed for purchases, renewals, revocations, and changes + made on another device. +- Existing verified subscribers remain entitled if product metadata temporarily fails + to load. A new customer sees an actionable retry state instead of an empty screen. +- Restore Purchases calls `AppStore.sync()` and the StoreKit subscription surface also + exposes the system restore action. +- App Shortcuts use the same entitlement check and do not bypass the Premium gate. +- The dedicated `CosmicRitualsTestFlight` scheme uses the `TestFlight` build + configuration to grant clearly labeled testing access when App Store products are + unavailable. Production `Release` builds never define that access condition. +- Never select a testing-access build for the App Store version. Archive the standard + `CosmicRituals` scheme for App Review so purchases and verified entitlements remain + the only production unlock path. + +## App Store Connect configuration + +1. Create the subscription group and both product IDs exactly as shown above. +2. Add English display names and descriptions matching the bundled StoreKit fixture. +3. Configure Family Sharing for both products if the storefront agreement permits it. +4. Set the standard prices, with App Store Connect generating territory equivalents. +5. Add a **Free · 2 Weeks** introductory offer to both products for all intended + storefronts. Set the start date to the release date with no artificial end date. +6. Add the subscriptions to version 1.0 before submission. +7. Use the published URLs below for App Privacy, Terms, and Support. + +## Public URLs + +- Privacy: +- Terms: +- Support: + +## Release test matrix + +- Eligible sandbox user sees the two-week offer and localized renewal price. +- Ineligible user sees standard pricing without a free-trial claim. +- Purchase unlocks without relaunching. +- Cancelled auto-renew retains access through the paid period. +- Expired, revoked, or refunded transaction locks access after refresh. +- Billing-grace entitlement remains accessible because StoreKit includes it in current + entitlements. +- Restore succeeds for the purchasing Apple Account and is harmless with no purchases. +- Offline launch succeeds for a locally verifiable current entitlement; first purchase + requires App Store connectivity. + +The checked-in `StoreKit/CosmicRituals.storekit` file is only a local Xcode fixture. +It lives outside the app target, so it is not bundled in release builds; its prices and +offers do not upload to App Store Connect. diff --git a/ios/native/StoreKit/CosmicRituals.storekit b/ios/native/StoreKit/CosmicRituals.storekit new file mode 100644 index 0000000..ec7b613 --- /dev/null +++ b/ios/native/StoreKit/CosmicRituals.storekit @@ -0,0 +1,175 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "https://gohlerdev.github.io/Cosmic-Rituals/privacy/" + } + ] + }, + "identifier" : "C05B5A10", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + + ], + "settings" : { + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, + "_disableDialogs" : false, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "enabled" : false, + "name" : "Load Products" + }, + { + "enabled" : false, + "name" : "Purchase" + }, + { + "enabled" : false, + "name" : "Verification" + }, + { + "enabled" : false, + "name" : "App Store Sync" + }, + { + "enabled" : false, + "name" : "Subscription Status" + }, + { + "enabled" : false, + "name" : "App Transaction" + }, + { + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ], + "_timeRate" : 0 + }, + "subscriptionGroups" : [ + { + "id" : "C05B5A14", + "localizations" : [ + + ], + "name" : "Cosmic Rituals Premium", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "billingPlans" : [ + { + "billingPlanType" : "BILLED_UPFRONT", + "commitmentDisplayPrice" : "49.99", + "displayPrice" : "49.99", + "internalID" : "C05B5A21", + "isEnabled" : true + } + ], + "codeOffers" : [ + + ], + "displayPrice" : "49.99", + "familyShareable" : true, + "groupNumber" : 1, + "internalID" : "C05B5A15", + "introductoryOffers" : [ + { + "billingPlanType" : "BILLED_UPFRONT", + "displayPrice" : "0", + "internalID" : "C05B5A17", + "numberOfPeriods" : 1, + "paymentMode" : "free", + "subscriptionPeriod" : "P2W" + } + ], + "localizations" : [ + { + "description" : "Full access, billed yearly after an eligible 14-day free trial.", + "displayName" : "Premium Annual", + "locale" : "en_US" + } + ], + "productID" : "com.cosmic.rituals.premium.annual", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Premium Annual", + "subscriptionGroupID" : "C05B5A14", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "billingPlans" : [ + { + "billingPlanType" : "BILLED_UPFRONT", + "commitmentDisplayPrice" : "6.99", + "displayPrice" : "6.99", + "internalID" : "C05B5A22", + "isEnabled" : true + } + ], + "codeOffers" : [ + + ], + "displayPrice" : "6.99", + "familyShareable" : true, + "groupNumber" : 2, + "internalID" : "C05B5A16", + "introductoryOffers" : [ + { + "billingPlanType" : "BILLED_UPFRONT", + "displayPrice" : "0", + "internalID" : "C05B5A18", + "numberOfPeriods" : 1, + "paymentMode" : "free", + "subscriptionPeriod" : "P2W" + } + ], + "localizations" : [ + { + "description" : "Full access, billed monthly after an eligible 14-day free trial.", + "displayName" : "Premium Monthly", + "locale" : "en_US" + } + ], + "productID" : "com.cosmic.rituals.premium.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Premium Monthly", + "subscriptionGroupID" : "C05B5A14", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 6, + "minor" : 3 + } +}