From 1f4c28041afd64f33df0331100d1a9a7614761a7 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 25 Jan 2026 16:05:42 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .gitignore | 62 + AGENTS.md | 752 +++++++ Andromida.xcodeproj/project.pbxproj | 41 +- .../xcschemes/xcschememanagement.plist | 2 +- Andromida/AndromidaApp.swift | 41 +- .../App/Localization/Localizable.xcstrings | 1932 +++++++++++++++++ Andromida/App/Models/AppSettingsData.swift | 32 + Andromida/App/Models/Habit.swift | 28 + Andromida/App/Models/InsightCard.swift | 23 + Andromida/App/Models/Ritual.swift | 32 + .../App/Protocols/RitualSeedProviding.swift | 5 + .../App/Protocols/RitualStoreProviding.swift | 17 + .../App/Services/RitualSeedService.swift | 36 + Andromida/App/State/RitualStore+Preview.swift | 16 + Andromida/App/State/RitualStore.swift | 190 ++ Andromida/App/State/SettingsStore.swift | 61 + .../Views/Components/EmptyStateCardView.swift | 58 + .../Views/Components/SectionHeaderView.swift | 33 + .../Insights/Components/InsightCardView.swift | 51 + .../App/Views/Insights/InsightsView.swift | 42 + .../Onboarding/RitualsOnboardingTags.swift | 19 + .../Rituals/Components/RitualCardView.swift | 59 + .../App/Views/Rituals/RitualDetailView.swift | 76 + Andromida/App/Views/Rituals/RitualsView.swift | 45 + Andromida/App/Views/RootView.swift | 64 + .../Views/Settings/SettingsAboutView.swift | 28 + .../App/Views/Settings/SettingsView.swift | 144 ++ .../Components/RitualFocusCardView.swift | 67 + .../Components/TodayEmptyStateView.swift | 26 + .../Today/Components/TodayHabitRowView.swift | 50 + .../Today/Components/TodayHeaderView.swift | 30 + .../Components/TodayRitualSectionView.swift | 66 + Andromida/App/Views/Today/TodayView.swift | 49 + .../Accent.colorset/Contents.json | 38 + .../AccentSoft.colorset/Contents.json | 38 + .../Background.colorset/Contents.json | 38 + .../BackgroundAlt.colorset/Contents.json | 38 + .../Card.colorset/Contents.json | 38 + .../Divider.colorset/Contents.json | 38 + .../Success.colorset/Contents.json | 38 + .../TextPrimary.colorset/Contents.json | 38 + .../TextSecondary.colorset/Contents.json | 38 + .../Warning.colorset/Contents.json | 38 + Andromida/ContentView.swift | 24 - Andromida/Resources/LaunchScreen.storyboard | 24 + Andromida/Shared/AppMetrics.swift | 27 + Andromida/Shared/BrandingConfig.swift | 44 + Andromida/Shared/Theme/RitualsTheme.swift | 93 + AndromidaTests/RitualStoreTests.swift | 50 + README.md | 104 + TODO.md | 31 + 51 files changed, 4919 insertions(+), 35 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Andromida/App/Localization/Localizable.xcstrings create mode 100644 Andromida/App/Models/AppSettingsData.swift create mode 100644 Andromida/App/Models/Habit.swift create mode 100644 Andromida/App/Models/InsightCard.swift create mode 100644 Andromida/App/Models/Ritual.swift create mode 100644 Andromida/App/Protocols/RitualSeedProviding.swift create mode 100644 Andromida/App/Protocols/RitualStoreProviding.swift create mode 100644 Andromida/App/Services/RitualSeedService.swift create mode 100644 Andromida/App/State/RitualStore+Preview.swift create mode 100644 Andromida/App/State/RitualStore.swift create mode 100644 Andromida/App/State/SettingsStore.swift create mode 100644 Andromida/App/Views/Components/EmptyStateCardView.swift create mode 100644 Andromida/App/Views/Components/SectionHeaderView.swift create mode 100644 Andromida/App/Views/Insights/Components/InsightCardView.swift create mode 100644 Andromida/App/Views/Insights/InsightsView.swift create mode 100644 Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift create mode 100644 Andromida/App/Views/Rituals/Components/RitualCardView.swift create mode 100644 Andromida/App/Views/Rituals/RitualDetailView.swift create mode 100644 Andromida/App/Views/Rituals/RitualsView.swift create mode 100644 Andromida/App/Views/RootView.swift create mode 100644 Andromida/App/Views/Settings/SettingsAboutView.swift create mode 100644 Andromida/App/Views/Settings/SettingsView.swift create mode 100644 Andromida/App/Views/Today/Components/RitualFocusCardView.swift create mode 100644 Andromida/App/Views/Today/Components/TodayEmptyStateView.swift create mode 100644 Andromida/App/Views/Today/Components/TodayHabitRowView.swift create mode 100644 Andromida/App/Views/Today/Components/TodayHeaderView.swift create mode 100644 Andromida/App/Views/Today/Components/TodayRitualSectionView.swift create mode 100644 Andromida/App/Views/Today/TodayView.swift create mode 100644 Andromida/Assets.xcassets/Accent.colorset/Contents.json create mode 100644 Andromida/Assets.xcassets/AccentSoft.colorset/Contents.json create mode 100644 Andromida/Assets.xcassets/Background.colorset/Contents.json create mode 100644 Andromida/Assets.xcassets/BackgroundAlt.colorset/Contents.json create mode 100644 Andromida/Assets.xcassets/Card.colorset/Contents.json create mode 100644 Andromida/Assets.xcassets/Divider.colorset/Contents.json create mode 100644 Andromida/Assets.xcassets/Success.colorset/Contents.json create mode 100644 Andromida/Assets.xcassets/TextPrimary.colorset/Contents.json create mode 100644 Andromida/Assets.xcassets/TextSecondary.colorset/Contents.json create mode 100644 Andromida/Assets.xcassets/Warning.colorset/Contents.json delete mode 100644 Andromida/ContentView.swift create mode 100644 Andromida/Resources/LaunchScreen.storyboard create mode 100644 Andromida/Shared/AppMetrics.swift create mode 100644 Andromida/Shared/BrandingConfig.swift create mode 100644 Andromida/Shared/Theme/RitualsTheme.swift create mode 100644 AndromidaTests/RitualStoreTests.swift create mode 100644 README.md create mode 100644 TODO.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f017195 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Xcode +DerivedData/ +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.moved-aside +*.xccheckout +*.xcscmblueprint +*.xcuserstate + +# SwiftPM +.build/ +Package.resolved + +# Cocoapods +Pods/ + +# Carthage +Carthage/Build/ + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/ +fastlane/test_output/ + +# App packaging +*.ipa +*.dSYM.zip +*.dSYM + +# Playground +timeline.xctimeline +playground.xcworkspace + +# Code coverage +*.profdata + +# Logs +*.log + +# SwiftLint +.swiftlint.cache + +# Test artifacts +*.xcresult + +# Local env files +.env + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2cb9146 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,752 @@ +# Agent Guide for Swift and SwiftUI + +This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage. + +## Additional Context Files + +Before starting work, read project documentation: + +- `WORKSPACE.md` — (if present) Multi-project workspace overview and project relationships +- `README.md` — Project scope, features, and architecture +- In multi-project workspaces, each project folder has its own `README.md` + +When making architectural changes, keep documentation files in sync with code changes. + + +## Role + +You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and related frameworks. Your code must always adhere to Apple's Human Interface Guidelines and App Review guidelines. + + +## Core Instructions + +- Target a min of iOS 17 or later. (Yes, it definitely exists.) +- Swift 5 using modern Swift concurrency. Our primary project is not on 6 yet. +- SwiftUI backed up by `@Observable` classes for shared data. +- **Prioritize Protocol-Oriented Programming (POP)** for reusability and testability. +- **Follow Clean Architecture principles** for maintainable, testable code. +- Do not introduce third-party frameworks without asking first. +- Avoid UIKit unless requested. + + +## Clean Architecture + +**Separation of concerns is mandatory.** Code should be organized into distinct layers with clear responsibilities and dependencies flowing inward. + +### File Organization Principles + +1. **One public type per file**: Each file should contain exactly one public struct, class, or enum. Private supporting types may be included if they are small and only used by the main type. + +2. **Keep files lean**: Aim for files under 300 lines. If a file exceeds this: + - Extract reusable sub-views into separate files in a `Components/` folder + - Extract sheets/modals into a `Sheets/` folder + - Move complex logic into dedicated types + +3. **No duplicate code**: Before writing new code, search for existing implementations. Extract common patterns into reusable components. + +4. **Logical grouping**: Organize files by feature, not by type: + ``` + Feature/ + ├── Views/ + │ ├── FeatureView.swift + │ ├── Components/ + │ │ ├── FeatureRowView.swift + │ │ └── FeatureHeaderView.swift + │ └── Sheets/ + │ └── FeatureEditSheet.swift + ├── Models/ + │ └── FeatureModel.swift + └── State/ + └── FeatureStore.swift + ``` + +### Layer Responsibilities + +| Layer | Contains | Depends On | +|-------|----------|------------| +| **Views** | SwiftUI views, UI components | State, Models | +| **State** | `@Observable` stores, view models | Models, Services | +| **Services** | Business logic, networking, persistence | Models | +| **Models** | Data types, entities, DTOs | Nothing | +| **Protocols** | Interfaces for services and stores | Models | + +### Architecture Rules + +1. **Views are dumb renderers**: No business logic in views. Views read state and call methods. +2. **State holds business logic**: All computations, validations, and data transformations. +3. **Services are stateless**: Pure functions where possible. Injected via protocols. +4. **Models are simple**: Plain data types. No dependencies on UI or services. + +### Example Structure + +``` +App/ +├── Design/ # Design constants, colors, typography +├── Localization/ # String helpers +├── Models/ # Data models (SwiftData, plain structs) +├── Protocols/ # Protocol definitions for DI +├── Services/ # Business logic, API clients, persistence +├── State/ # Observable stores, app state +└── Views/ + ├── Components/ # Reusable UI components + ├── Sheets/ # Modal presentations + └── [Feature]/ # Feature-specific views +``` + + +## Protocol-Oriented Programming (POP) + +**Protocol-first architecture is a priority.** When designing new features, always think about protocols and composition before concrete implementations. + +### When Architecting New Code + +1. **Start with the protocol**: Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol. +2. **Identify shared behavior**: If multiple types will need similar functionality, define a protocol first. +3. **Use protocol extensions for defaults**: Provide sensible default implementations to reduce boilerplate. +4. **Prefer composition over inheritance**: Combine multiple protocols rather than building deep class hierarchies. + +### When Reviewing Existing Code + +1. **Look for duplicated patterns**: Similar logic across files is a candidate for protocol extraction. +2. **Identify common interfaces**: Types that expose similar properties/methods should conform to a shared protocol. +3. **Check before implementing**: Search for existing protocols that could be adopted or extended. +4. **Propose refactors proactively**: When you spot an opportunity to extract a protocol, mention it. + +### Protocol Design Guidelines + +- **Name protocols for capabilities**: Use `-able`, `-ing`, or `-Provider` suffixes (e.g., `Shareable`, `DataProviding`, `Persistable`). +- **Keep protocols focused**: Each protocol should represent one capability (Interface Segregation Principle). +- **Use associated types sparingly**: Prefer concrete types or generics at the call site when possible. +- **Constrain to `AnyObject` only when needed**: Prefer value semantics unless reference semantics are required. + +### Benefits + +- **Reusability**: Shared protocols work across features +- **Testability**: Mock types can conform to protocols for unit testing +- **Flexibility**: New features can adopt existing protocols immediately +- **Maintainability**: Fix a bug in a protocol extension, fix it everywhere +- **Discoverability**: Protocols document the expected interface clearly + + +## View/State Separation (MVVM-lite) + +**Views should be "dumb" renderers.** All business logic belongs in stores or dedicated view models. + +### What Belongs in State/Store + +- **Business logic**: Calculations, validations, rules +- **Computed properties based on data**: Hints, recommendations, derived values +- **State checks**: `canSubmit`, `isLoading`, `hasError` +- **Data transformations**: Filtering, sorting, aggregations + +### What is Acceptable in Views + +- **Pure UI layout logic**: Adaptive layouts based on size class +- **Visual styling**: Color selection based on state +- **@ViewBuilder sub-views**: Breaking up complex layouts (keep in same file if small) +- **Accessibility labels**: Combining data into accessible descriptions + +### Example + +```swift +// ❌ BAD - Business logic in view +struct MyView: View { + @Bindable var state: FeatureState + + private var isValid: Bool { + !state.name.isEmpty && state.email.contains("@") + } +} + +// ✅ GOOD - Logic in State, view just reads +// In FeatureState: +var isValid: Bool { + !name.isEmpty && email.contains("@") +} + +// In View: +Button("Save") { state.save() } + .disabled(!state.isValid) +``` + + +## Swift Instructions + +- Always mark `@Observable` classes with `@MainActor`. +- Assume strict Swift concurrency rules are being applied. +- Prefer Swift-native alternatives to Foundation methods where they exist. +- Prefer modern Foundation API (e.g., `URL.documentsDirectory`, `appending(path:)`). +- Never use C-style number formatting; use `format:` modifiers instead. +- Prefer static member lookup to struct instances (`.circle` not `Circle()`). +- Never use old-style GCD; use modern Swift concurrency. +- Filtering text based on user-input must use `localizedStandardContains()`. +- Avoid force unwraps and force `try` unless unrecoverable. + + +## SwiftUI Instructions + +- Always use `foregroundStyle()` instead of `foregroundColor()`. +- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`. +- Always use the `Tab` API instead of `tabItem()`. +- Never use `ObservableObject`; always prefer `@Observable` classes. +- Never use `onChange()` in its 1-parameter variant. +- Never use `onTapGesture()` unless you need tap location/count; use `Button`. +- Never use `Task.sleep(nanoseconds:)`; use `Task.sleep(for:)`. +- Never use `UIScreen.main.bounds` to read available space. +- Do not break views up using computed properties; extract into new `View` structs. +- Do not force specific font sizes; prefer Dynamic Type. +- Use `NavigationStack` with `navigationDestination(for:)`. +- If using an image for a button label, always specify text alongside. +- Prefer `ImageRenderer` to `UIGraphicsImageRenderer`. +- Use `bold()` instead of `fontWeight(.bold)`. +- Avoid `GeometryReader` if newer alternatives work (e.g., `containerRelativeFrame()`). +- When enumerating in `ForEach`, don't convert to Array first. +- Hide scroll indicators with `.scrollIndicators(.hidden)`. +- Avoid `AnyView` unless absolutely required. +- **Never use raw numeric literals** for padding, spacing, opacity, etc.—use Design constants. +- **Never use inline colors**—define all colors with semantic names. +- Avoid UIKit colors in SwiftUI code. + + +## watchOS Development (CRITICAL) + +**Read this entire section before implementing any watch functionality.** + +### Creating a Watch Target + +When adding a watchOS target to an existing iOS app: + +1. **File → New → Target → "Watch App for watchOS"** +2. Choose **"Watch App for Existing iOS App"** (NOT standalone) +3. Name it appropriately (e.g., `AppNameWatch`) +4. Xcode creates a folder like `AppNameWatch Watch App/` + +### CRITICAL: Embedding the Watch App + +⚠️ **THIS IS THE #1 CAUSE OF "WATCH APP NOT INSTALLED" ERRORS** ⚠️ + +The watch app MUST be embedded in the iOS app for deployment to real devices: + +1. Select the **iOS target** in Xcode +2. Go to **Build Phases** tab +3. Verify there's an **"Embed Watch Content"** phase +4. **CRITICAL**: Ensure **"Code Sign On Copy"** is CHECKED ✓ + +If "Embed Watch Content" doesn't exist: +1. Click **"+"** → **"New Copy Files Phase"** +2. Rename to **"Embed Watch Content"** +3. Set **Destination** to **"Products Directory"** +4. Set **Subpath** to `$(CONTENTS_FOLDER_PATH)/Watch` +5. Add the watch app (e.g., `AppNameWatch Watch App.app`) +6. **CHECK "Code Sign On Copy"** ← This is critical! + +Without proper embedding, the iOS app installs but the watch app does NOT install on the paired Apple Watch. + +### Bundle Identifiers + +Watch app bundle IDs MUST be prefixed with the iOS app's bundle ID: + +``` +iOS app: com.company.AppName +Watch app: com.company.AppName.watchkitapp ← MUST start with iOS bundle ID +``` + +Also verify `WKCompanionAppBundleIdentifier` in the watch target's build settings matches the iOS app's bundle ID exactly. + +### Data Sync: WatchConnectivity (NOT App Groups) + +**DO NOT use App Groups for iPhone ↔ Watch data sharing.** + +App Groups: +- ❌ Do NOT work between iPhone and Apple Watch +- ❌ Different container paths on each device +- ❌ Will waste hours debugging why data isn't syncing +- ✅ Only work between an app and its extensions on the SAME device + +**Use WatchConnectivity framework instead:** + +```swift +// iOS side - WatchConnectivityService.swift +import WatchConnectivity + +@MainActor +final class WatchConnectivityService: NSObject, WCSessionDelegate { + static let shared = WatchConnectivityService() + + private override init() { + super.init() + if WCSession.isSupported() { + WCSession.default.delegate = self + WCSession.default.activate() + } + } + + func syncData(_ data: [String: Any]) { + guard WCSession.default.activationState == .activated, + WCSession.default.isPaired, + WCSession.default.isWatchAppInstalled else { return } + + try? WCSession.default.updateApplicationContext(data) + } +} +``` + +### WatchConnectivity Methods + +| Method | Use Case | +|--------|----------| +| `updateApplicationContext` | Latest state that persists (use this for most syncs) | +| `sendMessage` | Immediate delivery when counterpart is reachable | +| `transferUserInfo` | Queued delivery, guaranteed but not immediate | + +### watchOS Framework Limitations + +These iOS frameworks are NOT available on watchOS: + +- ❌ `CoreImage` - Generate QR codes on iOS, send image data to watch +- ❌ `UIKit` (mostly) - Use SwiftUI +- ❌ `AVFoundation` (limited) + +### Simulator Limitations + +WatchConnectivity on simulators is **unreliable**: + +- `isWatchAppInstalled` often returns `false` even when running +- `isReachable` may be `false` even with both apps running +- `updateApplicationContext` may fail with "counterpart not installed" + +**Workarounds for simulator testing:** +1. Add `#if targetEnvironment(simulator)` blocks with sample data +2. Test real sync functionality on physical devices only + +### Debugging Watch Sync Issues + +If `isWatchAppInstalled` returns `false`: + +1. ✅ Check "Embed Watch Content" build phase exists +2. ✅ Check "Code Sign On Copy" is enabled +3. ✅ Verify bundle ID is prefixed correctly +4. ✅ Clean build folder (⇧⌘K) and rebuild +5. ✅ On iPhone, open Watch app → verify app appears under "Installed" + +### NSObject Requirement + +`WCSessionDelegate` is an Objective-C protocol, so conforming classes must inherit from `NSObject`: + +```swift +final class WatchConnectivityService: NSObject, WCSessionDelegate { + // NSObject is required for WCSessionDelegate conformance +} +``` + + +## SwiftData Instructions + +If SwiftData is configured to use CloudKit: + +- Never use `@Attribute(.unique)`. +- Model properties must have default values or be optional. +- All relationships must be marked optional. + + +## Model Design: Single Source of Truth + +**Computed properties should be the single source of truth for derived data.** + +### Name Fields Pattern + +When a model has multiple name components (prefix, firstName, middleName, lastName, suffix, etc.), use a computed property for the display name: + +```swift +// ✅ GOOD - Computed from individual fields +var fullName: String { + var parts: [String] = [] + if !prefix.isEmpty { parts.append(prefix) } + if !firstName.isEmpty { parts.append(firstName) } + if !lastName.isEmpty { parts.append(lastName) } + // ... etc + return parts.joined(separator: " ") +} + +// ❌ BAD - Stored displayName that can get out of sync +var displayName: String // Never add this +``` + +### Benefits + +- **Always up to date**: Changes to individual fields are immediately reflected +- **No sync bugs**: No risk of stored value diverging from component fields +- **Simpler code**: No need to update displayName when editing name fields + +### Related Properties + +If you need different formats for different purposes: + +- `fullName` — For display (may include formatting like quotes, parentheses) +- `vCardName` — For export (plain format, no special formatting) + + +## Localization Instructions + +- Use **String Catalogs** (`.xcstrings` files) for localization. +- SwiftUI `Text("literal")` views automatically look up strings in the catalog. +- For strings outside of `Text` views, use `String(localized:)` or a helper extension. +- Store all user-facing strings in the String Catalog. +- Support at minimum: English (en), Spanish-Mexico (es-MX), French-Canada (fr-CA). +- Never use `NSLocalizedString`; prefer `String(localized:)`. + + +## Design Constants + +**Never use raw numeric literals or hardcoded colors directly in views.** + +### Values That MUST Be Constants + +- **Spacing & Padding**: `Design.Spacing.medium` not `.padding(12)` +- **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16` +- **Font Sizes**: `Design.BaseFontSize.body` not `size: 14` +- **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)` +- **Colors**: `Color.Primary.accent` not `Color(red:green:blue:)` +- **Line Widths**: `Design.LineWidth.medium` not `lineWidth: 2` +- **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10` +- **Animation Durations**: `Design.Animation.quick` not `duration: 0.3` +- **Component Sizes**: `Design.Size.avatar` not `frame(width: 56)` + +### Organization + +- Create a `DesignConstants.swift` file using enums for namespacing. +- Extend `Color` with semantic color definitions. +- View-specific constants go at the top of the view struct with a comment. +- Name constants semantically: `accent` not `pointSix`, `large` not `sixteen`. + + +## App Identifiers (xcconfig) + +**Centralize all company-specific identifiers** using xcconfig files for true single-source configuration. This enables one-line migration between developer accounts. + +### Why xcconfig? + +- **Single source of truth**: Change one file, everything updates +- **Build-time resolution**: Bundle IDs, entitlements, and Swift code all derive from same source +- **No manual updates**: Entitlements use variable substitution +- **Environment support**: Easy Debug/Release/Staging configurations + +### Setup Instructions + +#### Step 1: Create xcconfig Files + +Create `Configuration/Base.xcconfig`: + +``` +// Base.xcconfig - Source of truth for all identifiers +// MIGRATION: Update COMPANY_IDENTIFIER and DEVELOPMENT_TEAM below + +// ============================================================================= +// COMPANY IDENTIFIER - CHANGE THIS FOR MIGRATION +// ============================================================================= + +COMPANY_IDENTIFIER = com.yourcompany +APP_NAME = YourAppName +DEVELOPMENT_TEAM = YOUR_TEAM_ID + +// ============================================================================= +// DERIVED IDENTIFIERS - DO NOT EDIT +// ============================================================================= + +APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME) +WATCH_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).watchkitapp +APPCLIP_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Clip +TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)Tests +UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests + +APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(APP_NAME) +CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME) + +APPCLIP_DOMAIN = yourapp.example.com +``` + +Create `Configuration/Debug.xcconfig`: + +``` +// Debug.xcconfig +#include "Base.xcconfig" +// Add debug-specific settings here +``` + +Create `Configuration/Release.xcconfig`: + +``` +// Release.xcconfig +#include "Base.xcconfig" +// Add release-specific settings here +``` + +#### Step 2: Configure Xcode Project + +In `project.pbxproj`, add file references and set `baseConfigurationReference` for each build configuration: + +**1. Add xcconfig file references to PBXFileReference section:** + +``` +/* Use SOURCE_ROOT and full path from project root */ +EACONFIG001 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppName/Configuration/Base.xcconfig; sourceTree = SOURCE_ROOT; }; +EACONFIG002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppName/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; }; +EACONFIG003 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppName/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; }; +``` + +**IMPORTANT**: Use `sourceTree = SOURCE_ROOT` (not `""`) and include the full path from project root (e.g., `AppName/Configuration/Base.xcconfig`). + +**2. Set `baseConfigurationReference` on project-level Debug/Release configurations:** + +``` +EA123456 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EACONFIG002 /* Debug.xcconfig */; + buildSettings = { ... }; +}; +``` + +**3. Replace hardcoded values with variables:** + +``` +PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; +DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; +INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)"; +``` + +#### Step 3: Update Entitlements + +Use variable substitution in `.entitlements` files: + +```xml +com.apple.developer.icloud-container-identifiers + + $(CLOUDKIT_CONTAINER_IDENTIFIER) + +com.apple.security.application-groups + + $(APP_GROUP_IDENTIFIER) + +``` + +#### Step 4: Bridge to Swift via Info.plist + +Add keys to `Info.plist` that bridge xcconfig values to Swift: + +```xml +AppGroupIdentifier +$(APP_GROUP_IDENTIFIER) +CloudKitContainerIdentifier +$(CLOUDKIT_CONTAINER_IDENTIFIER) +AppClipDomain +$(APPCLIP_DOMAIN) +``` + +#### Step 5: Create Swift Interface + +**Why this is needed:** Swift code cannot read xcconfig files directly. The xcconfig values flow through Info.plist, and this Swift file provides a clean API to access them at runtime. Without this file, you'd have to call `Bundle.main.object(forInfoDictionaryKey:)` everywhere you need an identifier. + +**When to use:** Any Swift code that needs App Group identifiers, CloudKit containers, custom domains, or other configuration values must use `AppIdentifiers.*` instead of hardcoding strings. + +Create `Configuration/AppIdentifiers.swift`: + +```swift +import Foundation + +enum AppIdentifiers { + // Read from Info.plist (values come from xcconfig) + static let appGroupIdentifier: String = { + Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String + ?? "group.com.yourcompany.AppName" + }() + + static let cloudKitContainerIdentifier: String = { + Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String + ?? "iCloud.com.yourcompany.AppName" + }() + + static let appClipDomain: String = { + Bundle.main.object(forInfoDictionaryKey: "AppClipDomain") as? String + ?? "yourapp.example.com" + }() + + // Derived from bundle identifier + static var bundleIdentifier: String { + Bundle.main.bundleIdentifier ?? "com.yourcompany.AppName" + } + + static var watchBundleIdentifier: String { "\(bundleIdentifier).watchkitapp" } + static var appClipBundleIdentifier: String { "\(bundleIdentifier).Clip" } + + static func appClipURL(recordName: String) -> URL? { + URL(string: "https://\(appClipDomain)/appclip?id=\(recordName)") + } +} +``` + +### Data Flow + +``` +Base.xcconfig (source of truth) + ↓ +project.pbxproj (baseConfigurationReference) + ↓ +Build Settings → Bundle IDs, Team ID, etc. + ↓ +Info.plist (bridges values via $(VARIABLE)) + ↓ +AppIdentifiers.swift (Swift reads from Bundle.main) +``` + +### Usage in Code + +```swift +// Always use AppIdentifiers instead of hardcoding +FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier +) + +CKContainer(identifier: AppIdentifiers.cloudKitContainerIdentifier) +``` + +### Adding New Targets + +When adding new targets (Widgets, Intents, App Clips, etc.), follow this pattern: + +#### 1. Add Bundle ID Variable to Base.xcconfig + +``` +// In Base.xcconfig, add new derived identifier +WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Widget +INTENT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Intent +``` + +#### 2. Set Target to Use xcconfig + +For the new target's Debug/Release configurations in `project.pbxproj`: + +``` +EA_NEW_TARGET_DEBUG /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EACONFIG002 /* Debug.xcconfig */; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)"; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + // ... other settings + }; +}; +``` + +#### 3. Configure Entitlements (if needed) + +If the target needs App Groups or CloudKit access, create an entitlements file using variables: + +```xml + +com.apple.security.application-groups + + $(APP_GROUP_IDENTIFIER) + +``` + +#### 4. Share Code via App Groups + +Extensions must use App Groups to share data with the main app: + +```swift +// In extension code +let sharedDefaults = UserDefaults(suiteName: AppIdentifiers.appGroupIdentifier) +let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier +) +``` + +#### 5. Update AppIdentifiers.swift (if needed) + +Add new computed properties for target-specific identifiers: + +```swift +static var widgetBundleIdentifier: String { "\(bundleIdentifier).Widget" } +static var intentBundleIdentifier: String { "\(bundleIdentifier).Intent" } +``` + +#### Common Target Types and Bundle ID Patterns + +| Target Type | Bundle ID Variable | Example Value | +|-------------|-------------------|---------------| +| Widget Extension | `WIDGET_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).Widget` | +| Intent Extension | `INTENT_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).Intent` | +| App Clip | `APPCLIP_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).Clip` | +| Watch App | `WATCH_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).watchkitapp` | +| Notification Extension | `NOTIFICATION_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).NotificationExtension` | +| Share Extension | `SHARE_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).ShareExtension` | + +#### Checklist for New Targets + +- [ ] Add bundle ID variable to `Base.xcconfig` +- [ ] Set `baseConfigurationReference` to Debug/Release xcconfig +- [ ] Use `$(VARIABLE)` for `PRODUCT_BUNDLE_IDENTIFIER` +- [ ] Use `$(DEVELOPMENT_TEAM)` for team +- [ ] Create entitlements with `$(APP_GROUP_IDENTIFIER)` if sharing data +- [ ] Add to `AppIdentifiers.swift` if Swift code needs the identifier +- [ ] Register App ID in Apple Developer Portal (uses same App Group) + +### Migration + +To migrate to a new developer account, edit **one file** (`Base.xcconfig`): + +``` +COMPANY_IDENTIFIER = com.newcompany +DEVELOPMENT_TEAM = NEW_TEAM_ID +``` + +Then clean build (⇧⌘K) and rebuild. Everything updates automatically—including all extension targets. + + +## Dynamic Type Instructions + +- Always support Dynamic Type for accessibility. +- Use `@ScaledMetric` to scale custom dimensions. +- Choose appropriate `relativeTo` text styles based on semantic purpose. +- For constrained UI elements, you may use fixed sizes but document the reason. +- Prefer system text styles: `.font(.body)`, `.font(.title)`, `.font(.caption)`. + + +## VoiceOver Accessibility Instructions + +- All interactive elements must have meaningful `.accessibilityLabel()`. +- Use `.accessibilityValue()` for dynamic state. +- Use `.accessibilityHint()` to describe what happens on interaction. +- Use `.accessibilityAddTraits()` for element type. +- Hide decorative elements with `.accessibilityHidden(true)`. +- Group related elements to reduce navigation complexity. +- Post accessibility announcements for important events. + + +## Project Structure + +- Use a consistent project structure organized by feature. +- Follow strict naming conventions for types, properties, and methods. +- **One public type per file**—break types into separate files. +- Write unit tests for core application logic. +- Only write UI tests if unit tests are not possible. +- Add code comments and documentation as needed. +- Never include secrets or API keys in the repository. + + +## Documentation Instructions + +- **Keep `README.md` files up to date** when adding new functionality. +- In multi-project workspaces, update the relevant project's `README.md`. +- Document new features, settings, or mechanics in the appropriate README. +- Update documentation when modifying existing behavior. +- Include configuration options and special interactions. +- README updates should be part of the same commit as the feature. + + +## PR Instructions + +- If installed, ensure SwiftLint returns no warnings or errors. +- Verify that documentation reflects any new functionality. +- Check for duplicate code before submitting. +- Ensure all new files follow the one-type-per-file rule. diff --git a/Andromida.xcodeproj/project.pbxproj b/Andromida.xcodeproj/project.pbxproj index a26ad01..2abe565 100644 --- a/Andromida.xcodeproj/project.pbxproj +++ b/Andromida.xcodeproj/project.pbxproj @@ -6,6 +6,11 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC04AED2F26BD5B007F87EA /* Bedrock */; }; + EAC04B7F2F26C478007F87EA /* Sherpa in Frameworks */ = {isa = PBXBuildFile; productRef = EAC04B7E2F26C478007F87EA /* Sherpa */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ EAC04AA62F26BAE9007F87EA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -52,6 +57,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */, + EAC04B7F2F26C478007F87EA /* Sherpa in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -112,6 +119,8 @@ ); name = Andromida; packageProductDependencies = ( + EAC04AED2F26BD5B007F87EA /* Bedrock */, + EAC04B7E2F26C478007F87EA /* Sherpa */, ); productName = Andromida; productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */; @@ -195,6 +204,10 @@ ); mainGroup = EAC04A8F2F26BAE8007F87EA; minimizedProjectReferenceProxies = 1; + packageReferences = ( + EAC04AEC2F26BD5B007F87EA /* XCLocalSwiftPackageReference "../Bedrock" */, + EAC04B7D2F26C478007F87EA /* XCLocalSwiftPackageReference "../Sherpa" */, + ); preferredProjectObjectVersion = 77; productRefGroup = EAC04A992F26BAE8007F87EA /* Products */; projectDirPath = ""; @@ -402,9 +415,10 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -434,9 +448,10 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -578,6 +593,28 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + EAC04AEC2F26BD5B007F87EA /* XCLocalSwiftPackageReference "../Bedrock" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Bedrock; + }; + EAC04B7D2F26C478007F87EA /* XCLocalSwiftPackageReference "../Sherpa" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Sherpa; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + EAC04AED2F26BD5B007F87EA /* Bedrock */ = { + isa = XCSwiftPackageProductDependency; + productName = Bedrock; + }; + EAC04B7E2F26C478007F87EA /* Sherpa */ = { + isa = XCSwiftPackageProductDependency; + productName = Sherpa; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = EAC04A902F26BAE8007F87EA /* Project object */; } diff --git a/Andromida.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/Andromida.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index ea2f29f..9e6a2b8 100644 --- a/Andromida.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Andromida.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ Andromida.xcscheme_^#shared#^_ orderHint - 0 + 1 diff --git a/Andromida/AndromidaApp.swift b/Andromida/AndromidaApp.swift index 6b4bfa2..d0bc4a6 100644 --- a/Andromida/AndromidaApp.swift +++ b/Andromida/AndromidaApp.swift @@ -1,17 +1,42 @@ -// -// AndromidaApp.swift -// Andromida -// -// Created by Matt Bruce on 1/25/26. -// - import SwiftUI +import SwiftData +import Bedrock +import Sherpa @main struct AndromidaApp: App { + private let modelContainer: ModelContainer + @State private var store: RitualStore + @State private var settingsStore: SettingsStore + + init() { + let schema = Schema([Ritual.self, Habit.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + let container: ModelContainer + do { + container = try ModelContainer(for: schema, configurations: [configuration]) + } catch { + fatalError("Unable to create model container: \(error)") + } + modelContainer = container + _store = State(initialValue: RitualStore(modelContext: container.mainContext, seedService: RitualSeedService())) + _settingsStore = State(initialValue: SettingsStore()) + } + var body: some Scene { WindowGroup { - ContentView() + SherpaContainerView { + ZStack { + Color.Branding.primary + .ignoresSafeArea() + + AppLaunchView(config: .rituals) { + RootView(store: store, settingsStore: settingsStore) + } + } + } + .modelContainer(modelContainer) + .preferredColorScheme(.dark) } } } diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings new file mode 100644 index 0000000..af1f669 --- /dev/null +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -0,0 +1,1932 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%lld of %lld habits complete" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld of %lld habits complete" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld de %lld hábitos completos" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld sur %lld habitudes terminées" + } + } + } + }, + "A fresh ritual created from your focus today." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A fresh ritual created from your focus today." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un ritual nuevo creado a partir de tu enfoque de hoy." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un nouveau rituel créé à partir de ton intention d’aujourd’hui." + } + } + } + }, + "A gentle 4-week arc for energy and focus." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A gentle 4-week arc for energy and focus." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un arco suave de 4 semanas para energía y enfoque." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un arc doux de 4 semaines pour l’énergie et la concentration." + } + } + } + }, + "About" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acerca de" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "À propos" + } + } + } + }, + "Across all rituals" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Across all rituals" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "En todos los rituales" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dans tous les rituels" + } + } + } + }, + "Active rituals" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active rituals" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituales activos" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituels actifs" + } + } + } + }, + "Adjust arc duration" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adjust arc duration" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajusta la duración del arco" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustez la durée de l’arc" + } + } + } + }, + "Begin a four-week arc" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begin a four-week arc" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comienza un arco de cuatro semanas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commencez un arc de quatre semaines" + } + } + } + }, + "Branding Preview" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Branding Preview" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vista previa de marca" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aperçu de marque" + } + } + } + }, + "Check-ins completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check-ins completed" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros completados" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilans complétés" + } + } + } + }, + "Choose a theme and keep your focus clear for 28 days." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a theme and keep your focus clear for 28 days." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige un tema y mantén tu enfoque claro durante 28 días." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisis un thème et garde ton intention claire pendant 28 jours." + } + } + } + }, + "Choose the intensity of your arc" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose the intensity of your arc" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige la intensidad de tu arco" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez l’intensité de votre arc" + } + } + } + }, + "Completed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completado" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminé" + } + } + } + }, + "Completion" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completion" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finalización" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Achèvement" + } + } + } + }, + "Create ritual" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create ritual" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crear ritual" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Créer un rituel" + } + } + } + }, + "Creates a new ritual" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creates a new ritual" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crea un nuevo ritual" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crée un nouveau rituel" + } + } + } + }, + "Custom Ritual" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Ritual" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritual personalizado" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituel personnalisé" + } + } + } + }, + "Daily reminders" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daily reminders" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recordatorios diarios" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rappels quotidiens" + } + } + } + }, + "Day %lld of %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Day %lld of %lld" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Día %lld de %lld" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jour %lld sur %lld" + } + } + } + }, + "days" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "days" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "días" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "jours" + } + } + } + }, + "Debug" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debug" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depuración" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Débogage" + } + } + } + }, + "Done" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminé" + } + } + } + }, + "Double tap to toggle" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double tap to toggle" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doble toque para alternar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-touchez pour basculer" + } + } + } + }, + "Each ritual is a chapter. Build the cadence, then let the momentum carry you." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Each ritual is a chapter. Build the cadence, then let the momentum carry you." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cada ritual es un capítulo. Construye la cadencia y deja que el impulso te lleve." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chaque rituel est un chapitre. Bâtissez la cadence, puis laissez l’élan vous porter." + } + } + } + }, + "Evening Reset" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evening Reset" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reinicio nocturno" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialisation du soir" + } + } + } + }, + "Feel a soft response on check-in" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feel a soft response on check-in" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siente una respuesta suave al registrar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ressentez une réponse douce lors du suivi" + } + } + } + }, + "Focus ritual" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Focus ritual" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritual de enfoque" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituel principal" + } + } + } + }, + "Focus style" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Focus style" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo de enfoque" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style de concentration" + } + } + } + }, + "Four-week arc in progress" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Four-week arc in progress" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arco de cuatro semanas en progreso" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arc de quatre semaines en cours" + } + } + } + }, + "Four-week focus for daily habits" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Four-week focus for daily habits" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enfoque de cuatro semanas para hábitos diarios" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Focus de quatre semaines pour les habitudes quotidiennes" + } + } + } + }, + "Fresh starts" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fresh starts" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comienzos frescos" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouveaux départs" + } + } + } + }, + "Generate the app icon" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generate the app icon" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genera el ícono de la app" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Générer l’icône de l’app" + } + } + } + }, + "Gentle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gentle" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suave" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doux" + } + } + } + }, + "Get a gentle check-in each morning" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Get a gentle check-in each morning" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recibe un recordatorio suave cada mañana" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recevez un rappel doux chaque matin" + } + } + } + }, + "Habits" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habits" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hábitos" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habitudes" + } + } + } + }, + "Habits today" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habits today" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hábitos hoy" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habitudes aujourd’hui" + } + } + } + }, + "Haptics" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haptics" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hápticos" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haptique" + } + } + } + }, + "Hydrate" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hydrate" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hidrátate" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hydrater" + } + } + } + }, + "iCloud Sync" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Sync" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronización iCloud" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisation iCloud" + } + } + } + }, + "Icon Generator" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icon Generator" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generador de íconos" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Générateur d’icône" + } + } + } + }, + "Insights" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insights" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perspectivas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aperçus" + } + } + } + }, + "Intense" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intense" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intenso" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intense" + } + } + } + }, + "Last synced %@" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last synced %@" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Última sincronización %@" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dernière synchronisation %@" + } + } + } + }, + "Mindful minute" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mindful minute" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minuto consciente" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minute attentive" + } + } + } + }, + "Momentum at a glance" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Momentum at a glance" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impulso de un vistazo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Élan en un coup d’œil" + } + } + } + }, + "Morning Clarity" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Morning Clarity" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claridad matutina" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clarté du matin" + } + } + } + }, + "Move" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muévete" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bouge" + } + } + } + }, + "No ritual yet" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No ritual yet" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aún no hay ritual" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun rituel pour l’instant" + } + } + } + }, + "No screens" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No screens" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin pantallas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sans écrans" + } + } + } + }, + "Not completed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not completed" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "No completado" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non terminé" + } + } + } + }, + "Notes" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notes" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notes" + } + } + } + }, + "Play subtle completion sounds" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play subtle completion sounds" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reproduce sonidos sutiles al completar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Joue des sons subtils de complétion" + } + } + } + }, + "Preferences" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferences" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferencias" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préférences" + } + } + } + }, + "Preview launch and icon" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preview launch and icon" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vista previa de inicio e ícono" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aperçu du lancement et de l’icône" + } + } + } + }, + "Read 10 pages" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Read 10 pages" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lee 10 páginas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lis 10 pages" + } + } + } + }, + "Reflect" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reflect" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reflexiona" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réfléchis" + } + } + } + }, + "Ritual" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritual" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritual" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituel" + } + } + } + }, + "Ritual days" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritual days" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Días de ritual" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jours de rituel" + } + } + } + }, + "Ritual length" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritual length" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duración del ritual" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durée du rituel" + } + } + } + }, + "Ritual pacing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritual pacing" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritmo del ritual" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rythme du rituel" + } + } + } + }, + "Rituals" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituals" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituales" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituels" + } + } + } + }, + "Rituals is a four-week habit companion that keeps your focus grounded in small, repeatable arcs." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituals is a four-week habit companion that keeps your focus grounded in small, repeatable arcs." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituals es un compañero de hábitos de cuatro semanas que mantiene tu enfoque en arcos pequeños y repetibles." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituals est un compagnon d’habitudes sur quatre semaines qui ancre votre attention dans des arcs courts et répétables." + } + } + } + }, + "Rituals mission" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rituals mission" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Misión de Rituals" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mission de Rituals" + } + } + } + }, + "Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages" + } + } + } + }, + "Sign in to iCloud to enable sync" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in to iCloud to enable sync" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia sesión en iCloud para activar la sincronización" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous à iCloud pour activer la synchronisation" + } + } + } + }, + "Soft landings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soft landings" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aterrizajes suaves" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atterrissages en douceur" + } + } + } + }, + "Sound" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sound" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonido" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son" + } + } + } + }, + "Start your first ritual" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start your first ritual" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empieza tu primer ritual" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commence ton premier rituel" + } + } + } + }, + "Steady" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steady" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Constante" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stable" + } + } + } + }, + "Stretch" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stretch" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estira" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Étire" + } + } + } + }, + "Switch tabs to explore rituals and insights" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Switch tabs to explore rituals and insights" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambia de pestaña para explorar rituales y perspectivas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passez d’un onglet à l’autre pour explorer les rituels et les aperçus" + } + } + } + }, + "Sync Now" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync Now" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronizar ahora" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchroniser maintenant" + } + } + } + }, + "Sync Settings" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync Settings" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronizar ajustes" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchroniser les réglages" + } + } + } + }, + "Sync settings across all your devices" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync settings across all your devices" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincroniza los ajustes en todos tus dispositivos" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisez les réglages sur tous vos appareils" + } + } + } + }, + "Synced" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synced" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronizado" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisé" + } + } + } + }, + "Syncing..." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Syncing..." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronizando..." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisation..." + } + } + } + }, + "Syncs settings across all your devices via iCloud" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Syncs settings across all your devices via iCloud" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincroniza los ajustes en todos tus dispositivos mediante iCloud" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronise les réglages sur tous vos appareils via iCloud" + } + } + } + }, + "Tap a habit to check in" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap a habit to check in" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca un hábito para registrar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Touchez une habitude pour cocher" + } + } + } + }, + "Tap to check in" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap to check in" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca para registrar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Touchez pour cocher" + } + } + } + }, + "Today" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoy" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aujourd’hui" + } + } + } + }, + "Total arcs in motion" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total arcs in motion" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arcos totales en movimiento" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arcs en cours au total" + } + } + } + }, + "Total days logged" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total days logged" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Días totales registrados" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jours consignés au total" + } + } + } + }, + "Why arcs keep habits grounded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Why arcs keep habits grounded" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por qué los arcos mantienen los hábitos enraizados" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pourquoi les arcs ancrent les habitudes" + } + } + } + }, + "Wind down with quiet, consistent cues." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wind down with quiet, consistent cues." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relájate con señales tranquilas y constantes." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ralentis avec des repères calmes et constants." + } + } + } + }, + "Your active and recent arcs" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your active and recent arcs" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tus arcos activos y recientes" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tes arcs actifs et récents" + } + } + } + }, + "Your focus ritual lives here" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your focus ritual lives here" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu ritual de enfoque vive aquí" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre rituel principal se trouve ici" + } + } + } + }, + "Your next chapter" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your next chapter" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu próximo capítulo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ton prochain chapitre" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Andromida/App/Models/AppSettingsData.swift b/Andromida/App/Models/AppSettingsData.swift new file mode 100644 index 0000000..a2a6190 --- /dev/null +++ b/Andromida/App/Models/AppSettingsData.swift @@ -0,0 +1,32 @@ +import Foundation +import Bedrock + +struct AppSettingsData: PersistableData { + static var dataIdentifier: String = "rituals.settings" + static var empty = AppSettingsData() + + var remindersEnabled: Bool = true + var hapticsEnabled: Bool = true + var soundEnabled: Bool = true + var focusStyle: FocusStyle = .gentle + var ritualLengthDays: Int = 28 + var lastModified: Date = .now + + var syncPriority: Int { ritualLengthDays } +} + +enum FocusStyle: String, CaseIterable, Codable, Identifiable { + case gentle + case steady + case intense + + var id: String { rawValue } + + var title: String { + switch self { + case .gentle: return String(localized: "Gentle") + case .steady: return String(localized: "Steady") + case .intense: return String(localized: "Intense") + } + } +} diff --git a/Andromida/App/Models/Habit.swift b/Andromida/App/Models/Habit.swift new file mode 100644 index 0000000..ee24d19 --- /dev/null +++ b/Andromida/App/Models/Habit.swift @@ -0,0 +1,28 @@ +import Foundation +import SwiftData + +@Model +final class Habit { + var id: UUID + var title: String + var symbolName: String + var goal: String + var createdAt: Date + var completedDayIDs: [String] + + init( + id: UUID = UUID(), + title: String, + symbolName: String, + goal: String = "", + createdAt: Date = Date(), + completedDayIDs: [String] = [] + ) { + self.id = id + self.title = title + self.symbolName = symbolName + self.goal = goal + self.createdAt = createdAt + self.completedDayIDs = completedDayIDs + } +} diff --git a/Andromida/App/Models/InsightCard.swift b/Andromida/App/Models/InsightCard.swift new file mode 100644 index 0000000..7c62670 --- /dev/null +++ b/Andromida/App/Models/InsightCard.swift @@ -0,0 +1,23 @@ +import Foundation + +struct InsightCard: Identifiable { + let id: UUID + let title: String + let value: String + let caption: String + let symbolName: String + + init( + id: UUID = UUID(), + title: String, + value: String, + caption: String, + symbolName: String + ) { + self.id = id + self.title = title + self.value = value + self.caption = caption + self.symbolName = symbolName + } +} diff --git a/Andromida/App/Models/Ritual.swift b/Andromida/App/Models/Ritual.swift new file mode 100644 index 0000000..e654779 --- /dev/null +++ b/Andromida/App/Models/Ritual.swift @@ -0,0 +1,32 @@ +import Foundation +import SwiftData + +@Model +final class Ritual { + var id: UUID + var title: String + var theme: String + var startDate: Date + var durationDays: Int + @Relationship(deleteRule: .cascade) + var habits: [Habit] + var notes: String + + init( + id: UUID = UUID(), + title: String, + theme: String, + startDate: Date = Date(), + durationDays: Int = 28, + habits: [Habit] = [], + notes: String = "" + ) { + self.id = id + self.title = title + self.theme = theme + self.startDate = startDate + self.durationDays = durationDays + self.habits = habits + self.notes = notes + } +} diff --git a/Andromida/App/Protocols/RitualSeedProviding.swift b/Andromida/App/Protocols/RitualSeedProviding.swift new file mode 100644 index 0000000..bfdbda6 --- /dev/null +++ b/Andromida/App/Protocols/RitualSeedProviding.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol RitualSeedProviding { + func makeSeedRituals(startDate: Date) -> [Ritual] +} diff --git a/Andromida/App/Protocols/RitualStoreProviding.swift b/Andromida/App/Protocols/RitualStoreProviding.swift new file mode 100644 index 0000000..2436950 --- /dev/null +++ b/Andromida/App/Protocols/RitualStoreProviding.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol RitualStoreProviding { + var rituals: [Ritual] { get } + var activeRitual: Ritual? { get } + var todayDisplayString: String { get } + var activeRitualProgress: Double { get } + func ritualProgress(for ritual: Ritual) -> Double + func habits(for ritual: Ritual) -> [Habit] + func isHabitCompletedToday(_ habit: Habit) -> Bool + func toggleHabitCompletion(_ habit: Habit) + func ritualDayIndex(for ritual: Ritual) -> Int + func ritualDayLabel(for ritual: Ritual) -> String + func completionSummary(for ritual: Ritual) -> String + func insightCards() -> [InsightCard] + func createQuickRitual() +} diff --git a/Andromida/App/Services/RitualSeedService.swift b/Andromida/App/Services/RitualSeedService.swift new file mode 100644 index 0000000..3dfae21 --- /dev/null +++ b/Andromida/App/Services/RitualSeedService.swift @@ -0,0 +1,36 @@ +import Foundation + +struct RitualSeedService: RitualSeedProviding { + func makeSeedRituals(startDate: Date) -> [Ritual] { + let morningHabits = [ + Habit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), + Habit(title: String(localized: "Stretch"), symbolName: "figure.walk"), + Habit(title: String(localized: "Mindful minute"), symbolName: "sparkles") + ] + let eveningHabits = [ + Habit(title: String(localized: "No screens"), symbolName: "moon.stars.fill"), + Habit(title: String(localized: "Read 10 pages"), symbolName: "book.fill"), + Habit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard") + ] + + let morningRitual = Ritual( + title: String(localized: "Morning Clarity"), + theme: String(localized: "Fresh starts"), + startDate: startDate, + durationDays: 28, + habits: morningHabits, + notes: String(localized: "A gentle 4-week arc for energy and focus.") + ) + + let eveningRitual = Ritual( + title: String(localized: "Evening Reset"), + theme: String(localized: "Soft landings"), + startDate: Calendar.current.date(byAdding: .day, value: -14, to: startDate) ?? startDate, + durationDays: 28, + habits: eveningHabits, + notes: String(localized: "Wind down with quiet, consistent cues.") + ) + + return [morningRitual, eveningRitual] + } +} diff --git a/Andromida/App/State/RitualStore+Preview.swift b/Andromida/App/State/RitualStore+Preview.swift new file mode 100644 index 0000000..02f8474 --- /dev/null +++ b/Andromida/App/State/RitualStore+Preview.swift @@ -0,0 +1,16 @@ +import Foundation +import SwiftData + +extension RitualStore { + static var preview: RitualStore { + let schema = Schema([Ritual.self, Habit.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container: ModelContainer + do { + container = try ModelContainer(for: schema, configurations: [configuration]) + } catch { + fatalError("Preview container failed: \(error)") + } + return RitualStore(modelContext: container.mainContext, seedService: RitualSeedService()) + } +} diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift new file mode 100644 index 0000000..3358848 --- /dev/null +++ b/Andromida/App/State/RitualStore.swift @@ -0,0 +1,190 @@ +import Foundation +import Observation +import SwiftData + +@MainActor +@Observable +final class RitualStore: RitualStoreProviding { + @ObservationIgnored private let modelContext: ModelContext + @ObservationIgnored private let seedService: RitualSeedProviding + @ObservationIgnored private let calendar: Calendar + @ObservationIgnored private let dayFormatter: DateFormatter + @ObservationIgnored private let displayFormatter: DateFormatter + + private(set) var rituals: [Ritual] = [] + private(set) var lastErrorMessage: String? + + init( + modelContext: ModelContext, + seedService: RitualSeedProviding, + calendar: Calendar = .current + ) { + self.modelContext = modelContext + self.seedService = seedService + self.calendar = calendar + self.dayFormatter = DateFormatter() + self.displayFormatter = DateFormatter() + dayFormatter.calendar = calendar + dayFormatter.dateFormat = "yyyy-MM-dd" + displayFormatter.calendar = calendar + displayFormatter.dateStyle = .full + displayFormatter.timeStyle = .none + loadRitualsIfNeeded() + } + + var activeRitual: Ritual? { + let today = calendar.startOfDay(for: Date()) + let candidates = rituals.filter { ritual in + let start = calendar.startOfDay(for: ritual.startDate) + let end = calendar.date(byAdding: .day, value: ritual.durationDays - 1, to: start) ?? start + return today >= start && today <= end + } + return candidates.sorted { $0.startDate > $1.startDate }.first + } + + var todayDisplayString: String { + displayFormatter.string(from: Date()) + } + + var activeRitualProgress: Double { + guard let ritual = activeRitual else { return 0 } + let habits = ritual.habits + guard !habits.isEmpty else { return 0 } + let completed = habits.filter { isHabitCompletedToday($0) }.count + return Double(completed) / Double(habits.count) + } + + func ritualProgress(for ritual: Ritual) -> Double { + let habits = ritual.habits + guard !habits.isEmpty else { return 0 } + let completed = habits.filter { isHabitCompletedToday($0) }.count + return Double(completed) / Double(habits.count) + } + + func habits(for ritual: Ritual) -> [Habit] { + ritual.habits + } + + func isHabitCompletedToday(_ habit: Habit) -> Bool { + let dayID = dayIdentifier(for: Date()) + return habit.completedDayIDs.contains(dayID) + } + + func toggleHabitCompletion(_ habit: Habit) { + let dayID = dayIdentifier(for: Date()) + if habit.completedDayIDs.contains(dayID) { + habit.completedDayIDs.removeAll { $0 == dayID } + } else { + habit.completedDayIDs.append(dayID) + } + saveContext() + } + + func ritualDayIndex(for ritual: Ritual) -> Int { + let start = calendar.startOfDay(for: ritual.startDate) + let today = calendar.startOfDay(for: Date()) + let delta = calendar.dateComponents([.day], from: start, to: today).day ?? 0 + return max(0, min(delta + 1, ritual.durationDays)) + } + + func ritualDayLabel(for ritual: Ritual) -> String { + let format = String(localized: "Day %lld of %lld") + return String.localizedStringWithFormat( + format, + ritualDayIndex(for: ritual), + ritual.durationDays + ) + } + + func completionSummary(for ritual: Ritual) -> String { + let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count + let format = String(localized: "%lld of %lld habits complete") + return String.localizedStringWithFormat( + format, + completed, + ritual.habits.count + ) + } + + func insightCards() -> [InsightCard] { + let totalHabits = rituals.flatMap { $0.habits }.count + let completedToday = rituals.flatMap { $0.habits }.filter { isHabitCompletedToday($0) }.count + let completionRate = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100) + let activeDays = rituals.map { ritualDayIndex(for: $0) }.reduce(0, +) + + return [ + InsightCard( + title: String(localized: "Active rituals"), + value: "\(rituals.count)", + caption: String(localized: "Total arcs in motion"), + symbolName: "sparkles" + ), + InsightCard( + title: String(localized: "Habits today"), + value: "\(completedToday)", + caption: String(localized: "Check-ins completed"), + symbolName: "checkmark.circle.fill" + ), + InsightCard( + title: String(localized: "Completion"), + value: "\(completionRate)%", + caption: String(localized: "Across all rituals"), + symbolName: "chart.bar.fill" + ), + InsightCard( + title: String(localized: "Ritual days"), + value: "\(activeDays)", + caption: String(localized: "Total days logged"), + symbolName: "calendar" + ) + ] + } + + func createQuickRitual() { + let habits = [ + Habit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), + Habit(title: String(localized: "Move"), symbolName: "figure.walk"), + Habit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard") + ] + let ritual = Ritual( + title: String(localized: "Custom Ritual"), + theme: String(localized: "Your next chapter"), + startDate: Date(), + durationDays: 28, + habits: habits, + notes: String(localized: "A fresh ritual created from your focus today.") + ) + modelContext.insert(ritual) + saveContext() + } + + private func loadRitualsIfNeeded() { + reloadRituals() + guard rituals.isEmpty else { return } + let seeds = seedService.makeSeedRituals(startDate: Date()) + seeds.forEach { modelContext.insert($0) } + saveContext() + reloadRituals() + } + + private func reloadRituals() { + do { + rituals = try modelContext.fetch(FetchDescriptor()) + } catch { + lastErrorMessage = error.localizedDescription + } + } + + private func saveContext() { + do { + try modelContext.save() + reloadRituals() + } catch { + lastErrorMessage = error.localizedDescription + } + } + + private func dayIdentifier(for date: Date) -> String { + dayFormatter.string(from: date) + } +} diff --git a/Andromida/App/State/SettingsStore.swift b/Andromida/App/State/SettingsStore.swift new file mode 100644 index 0000000..ecbb428 --- /dev/null +++ b/Andromida/App/State/SettingsStore.swift @@ -0,0 +1,61 @@ +import Foundation +import Observation +import Bedrock + +@MainActor +@Observable +final class SettingsStore: CloudSyncable { + @ObservationIgnored private let cloudSync = CloudSyncManager() + + var remindersEnabled: Bool { + get { cloudSync.data.remindersEnabled } + set { update { $0.remindersEnabled = newValue } } + } + + var hapticsEnabled: Bool { + get { cloudSync.data.hapticsEnabled } + set { update { $0.hapticsEnabled = newValue } } + } + + var soundEnabled: Bool { + get { cloudSync.data.soundEnabled } + set { update { $0.soundEnabled = newValue } } + } + + var focusStyle: FocusStyle { + get { cloudSync.data.focusStyle } + set { update { $0.focusStyle = newValue } } + } + + var ritualLengthDays: Double { + get { Double(cloudSync.data.ritualLengthDays) } + set { update { $0.ritualLengthDays = Int(newValue) } } + } + + var iCloudAvailable: Bool { cloudSync.iCloudAvailable } + + var iCloudEnabled: Bool { + get { cloudSync.iCloudEnabled } + set { cloudSync.iCloudEnabled = newValue } + } + + var lastSyncDate: Date? { cloudSync.lastSyncDate } + var syncStatus: String { cloudSync.syncStatus } + var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync } + + func forceSync() { + cloudSync.sync() + } + + private func update(_ transform: (inout AppSettingsData) -> Void) { + cloudSync.update { data in + transform(&data) + } + } +} + +extension SettingsStore { + static var preview: SettingsStore { + SettingsStore() + } +} diff --git a/Andromida/App/Views/Components/EmptyStateCardView.swift b/Andromida/App/Views/Components/EmptyStateCardView.swift new file mode 100644 index 0000000..3b7684a --- /dev/null +++ b/Andromida/App/Views/Components/EmptyStateCardView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import Bedrock + +struct EmptyStateCardView: View { + private let title: String + private let message: String + private let actionTitle: String + private let action: () -> Void + + init( + title: String, + message: String, + actionTitle: String, + action: @escaping () -> Void + ) { + self.title = title + self.message = message + self.actionTitle = actionTitle + self.action = action + } + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + Text(title) + .font(.title3) + .foregroundStyle(AppTextColors.primary) + .bold() + Text(message) + .font(.body) + .foregroundStyle(AppTextColors.secondary) + Button(action: action) { + Text(actionTitle) + .font(.headline) + .foregroundStyle(AppTextColors.primary) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.Size.buttonHeight) + .background(AppAccent.light.opacity(Design.Opacity.light)) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } + .accessibilityLabel(Text(actionTitle)) + .accessibilityHint(Text(String(localized: "Creates a new ritual"))) + } + .padding(Design.Spacing.large) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + } +} + +#Preview { + EmptyStateCardView( + title: "No ritual yet", + message: "Start a four-week arc to keep your habits aligned.", + actionTitle: "Create ritual", + action: {} + ) + .padding(Design.Spacing.large) + .background(AppSurface.primary) +} diff --git a/Andromida/App/Views/Components/SectionHeaderView.swift b/Andromida/App/Views/Components/SectionHeaderView.swift new file mode 100644 index 0000000..adf6aab --- /dev/null +++ b/Andromida/App/Views/Components/SectionHeaderView.swift @@ -0,0 +1,33 @@ +import SwiftUI +import Bedrock + +struct SectionHeaderView: View { + private let title: String + private let subtitle: String? + + init(title: String, subtitle: String? = nil) { + self.title = title + self.subtitle = subtitle + } + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) { + Text(title) + .font(.headline) + .foregroundStyle(AppTextColors.primary) + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(AppTextColors.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +#Preview { + SectionHeaderView(title: "Preview", subtitle: "Subtitle") + .padding(Design.Spacing.medium) + .background(AppSurface.primary) +} diff --git a/Andromida/App/Views/Insights/Components/InsightCardView.swift b/Andromida/App/Views/Insights/Components/InsightCardView.swift new file mode 100644 index 0000000..76983d6 --- /dev/null +++ b/Andromida/App/Views/Insights/Components/InsightCardView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import Bedrock + +struct InsightCardView: View { + private let title: String + private let value: String + private let caption: String + private let symbolName: String + + init( + title: String, + value: String, + caption: String, + symbolName: String + ) { + self.title = title + self.value = value + self.caption = caption + self.symbolName = symbolName + } + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + HStack(spacing: Design.Spacing.small) { + Image(systemName: symbolName) + .foregroundStyle(AppAccent.primary) + .accessibilityHidden(true) + Text(title) + .font(.subheadline) + .foregroundStyle(AppTextColors.secondary) + } + Text(value) + .font(.title) + .foregroundStyle(AppTextColors.primary) + .bold() + Text(caption) + .font(.caption) + .foregroundStyle(AppTextColors.secondary) + } + .padding(Design.Spacing.large) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .accessibilityElement(children: .combine) + } +} + +#Preview { + InsightCardView(title: "Completion", value: "72%", caption: "Across all rituals", symbolName: "chart.bar.fill") + .padding(Design.Spacing.large) + .background(AppSurface.primary) +} diff --git a/Andromida/App/Views/Insights/InsightsView.swift b/Andromida/App/Views/Insights/InsightsView.swift new file mode 100644 index 0000000..375e6ed --- /dev/null +++ b/Andromida/App/Views/Insights/InsightsView.swift @@ -0,0 +1,42 @@ +import SwiftUI +import Bedrock + +struct InsightsView: View { + @Bindable var store: RitualStore + + private let columns = [ + GridItem(.adaptive(minimum: AppMetrics.Size.insightCardMinWidth), spacing: Design.Spacing.medium) + ] + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + SectionHeaderView( + title: String(localized: "Insights"), + subtitle: String(localized: "Momentum at a glance") + ) + + LazyVGrid(columns: columns, spacing: Design.Spacing.medium) { + ForEach(store.insightCards()) { card in + InsightCardView( + title: card.title, + value: card.value, + caption: card.caption, + symbolName: card.symbolName + ) + } + } + } + .padding(Design.Spacing.large) + } + .background(LinearGradient( + colors: [AppSurface.primary, AppSurface.secondary], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + } +} + +#Preview { + InsightsView(store: RitualStore.preview) +} diff --git a/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift b/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift new file mode 100644 index 0000000..907ed9e --- /dev/null +++ b/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift @@ -0,0 +1,19 @@ +import Sherpa +import SwiftUI + +enum RitualsOnboardingTag: SherpaTags { + case focusRitual + case firstHabit + case tabBar + + func makeCallout() -> Callout { + switch self { + case .focusRitual: + return .text(String(localized: "Your focus ritual lives here")) + case .firstHabit: + return .text(String(localized: "Tap a habit to check in"), edge: .bottom) + case .tabBar: + return .text(String(localized: "Switch tabs to explore rituals and insights"), edge: .top) + } + } +} diff --git a/Andromida/App/Views/Rituals/Components/RitualCardView.swift b/Andromida/App/Views/Rituals/Components/RitualCardView.swift new file mode 100644 index 0000000..2ddd2b7 --- /dev/null +++ b/Andromida/App/Views/Rituals/Components/RitualCardView.swift @@ -0,0 +1,59 @@ +import SwiftUI +import Bedrock + +struct RitualCardView: View { + private let title: String + private let theme: String + private let dayLabel: String + private let completionSummary: String + + init( + title: String, + theme: String, + dayLabel: String, + completionSummary: String + ) { + self.title = title + self.theme = theme + self.dayLabel = dayLabel + self.completionSummary = completionSummary + } + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + HStack(spacing: Design.Spacing.small) { + Image(systemName: "circle.hexagonpath.fill") + .foregroundStyle(AppAccent.primary) + .accessibilityHidden(true) + Text(title) + .font(.headline) + .foregroundStyle(AppTextColors.primary) + Spacer(minLength: Design.Spacing.medium) + Text(dayLabel) + .font(.caption) + .foregroundStyle(AppTextColors.secondary) + } + Text(theme) + .font(.subheadline) + .foregroundStyle(AppTextColors.secondary) + Text(completionSummary) + .font(.caption) + .foregroundStyle(AppTextColors.secondary) + } + .padding(Design.Spacing.large) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .accessibilityElement(children: .combine) + } +} + +#Preview { + RitualCardView( + title: "Morning Clarity", + theme: "Fresh starts", + dayLabel: "Day 6 of 28", + completionSummary: "2 of 3 habits complete" + ) + .padding(Design.Spacing.large) + .background(AppSurface.primary) +} diff --git a/Andromida/App/Views/Rituals/RitualDetailView.swift b/Andromida/App/Views/Rituals/RitualDetailView.swift new file mode 100644 index 0000000..b649c11 --- /dev/null +++ b/Andromida/App/Views/Rituals/RitualDetailView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import Bedrock + +struct RitualDetailView: View { + @Bindable var store: RitualStore + private let ritual: Ritual + + init(store: RitualStore, ritual: Ritual) { + self.store = store + self.ritual = ritual + } + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + Text(ritual.title) + .font(.largeTitle) + .foregroundStyle(AppTextColors.primary) + .bold() + Text(ritual.theme) + .font(.title3) + .foregroundStyle(AppTextColors.secondary) + } + .accessibilityElement(children: .combine) + + RitualFocusCardView( + title: ritual.title, + theme: ritual.theme, + dayLabel: store.ritualDayLabel(for: ritual), + completionSummary: store.completionSummary(for: ritual), + progress: store.ritualProgress(for: ritual) + ) + + if !ritual.notes.isEmpty { + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + SectionHeaderView(title: String(localized: "Notes")) + Text(ritual.notes) + .font(.body) + .foregroundStyle(AppTextColors.secondary) + } + } + + SectionHeaderView( + title: String(localized: "Habits"), + subtitle: String(localized: "Tap to check in") + ) + + VStack(spacing: Design.Spacing.medium) { + ForEach(store.habits(for: ritual)) { habit in + TodayHabitRowView( + title: habit.title, + symbolName: habit.symbolName, + isCompleted: store.isHabitCompletedToday(habit), + action: { store.toggleHabitCompletion(habit) } + ) + } + } + } + .padding(Design.Spacing.large) + } + .background(LinearGradient( + colors: [AppSurface.primary, AppSurface.secondary], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .navigationTitle(String(localized: "Ritual")) + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + RitualDetailView(store: RitualStore.preview, ritual: RitualStore.preview.rituals.first!) + } +} diff --git a/Andromida/App/Views/Rituals/RitualsView.swift b/Andromida/App/Views/Rituals/RitualsView.swift new file mode 100644 index 0000000..edb0b43 --- /dev/null +++ b/Andromida/App/Views/Rituals/RitualsView.swift @@ -0,0 +1,45 @@ +import SwiftUI +import Bedrock + +struct RitualsView: View { + @Bindable var store: RitualStore + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + SectionHeaderView( + title: String(localized: "Rituals"), + subtitle: String(localized: "Your active and recent arcs") + ) + + VStack(spacing: Design.Spacing.medium) { + ForEach(store.rituals) { ritual in + NavigationLink { + RitualDetailView(store: store, ritual: ritual) + } label: { + RitualCardView( + title: ritual.title, + theme: ritual.theme, + dayLabel: store.ritualDayLabel(for: ritual), + completionSummary: store.completionSummary(for: ritual) + ) + } + .buttonStyle(.plain) + } + } + } + .padding(Design.Spacing.large) + } + .background(LinearGradient( + colors: [AppSurface.primary, AppSurface.secondary], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + } +} + +#Preview { + NavigationStack { + RitualsView(store: RitualStore.preview) + } +} diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift new file mode 100644 index 0000000..f948af9 --- /dev/null +++ b/Andromida/App/Views/RootView.swift @@ -0,0 +1,64 @@ +import SwiftUI +import Bedrock +import Sherpa + +struct RootView: View { + @Bindable var store: RitualStore + @Bindable var settingsStore: SettingsStore + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false + + var body: some View { + TabView { + Tab(String(localized: "Today"), systemImage: "sun.max.fill") { + NavigationStack { + TodayView(store: store) + } + } + + Tab(String(localized: "Rituals"), systemImage: "sparkles") { + NavigationStack { + RitualsView(store: store) + } + } + + Tab(String(localized: "Insights"), systemImage: "chart.bar.fill") { + NavigationStack { + InsightsView(store: store) + } + } + + Tab(String(localized: "Settings"), systemImage: "gearshape.fill") { + NavigationStack { + SettingsView(store: settingsStore) + } + } + } + .tint(AppAccent.primary) + .background(AppSurface.primary.ignoresSafeArea()) + .sherpa( + isActive: !hasCompletedOnboarding, + tags: RitualsOnboardingTag.self, + delegate: self, + startDelay: Bedrock.Design.Animation.standard + ) + .sherpaExtensionTag( + RitualsOnboardingTag.tabBar, + edge: .bottom, + size: AppMetrics.Size.tabBarHighlightHeight + ) + } +} + +#Preview { + RootView(store: RitualStore.preview, settingsStore: SettingsStore.preview) +} + +extension RootView: SherpaDelegate { + func onWalkthroughComplete(sherpa: Sherpa) { + hasCompletedOnboarding = true + } + + func onWalkthroughSkipped(sherpa: Sherpa, atStep: Int, totalSteps: Int) { + hasCompletedOnboarding = true + } +} diff --git a/Andromida/App/Views/Settings/SettingsAboutView.swift b/Andromida/App/Views/Settings/SettingsAboutView.swift new file mode 100644 index 0000000..26c6e8f --- /dev/null +++ b/Andromida/App/Views/Settings/SettingsAboutView.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Bedrock + +struct SettingsAboutView: View { + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + Text(String(localized: "Rituals is a four-week habit companion that keeps your focus grounded in small, repeatable arcs.")) + .font(.body) + .foregroundStyle(AppTextColors.secondary) + + Text(String(localized: "Each ritual is a chapter. Build the cadence, then let the momentum carry you.")) + .font(.body) + .foregroundStyle(AppTextColors.secondary) + } + .padding(Design.Spacing.large) + } + .background(AppSurface.primary) + .navigationTitle(String(localized: "Rituals")) + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + SettingsAboutView() + } +} diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..5fe807d --- /dev/null +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -0,0 +1,144 @@ +import SwiftUI +import Bedrock + +struct SettingsView: View { + @Bindable var store: SettingsStore + + private let focusOptions: [(String, FocusStyle)] = FocusStyle.allCases.map { ($0.title, $0) } + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + SettingsSectionHeader( + title: String(localized: "Preferences"), + systemImage: "gearshape", + accentColor: AppAccent.primary + ) + + SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + SettingsToggle( + title: String(localized: "Daily reminders"), + subtitle: String(localized: "Get a gentle check-in each morning"), + isOn: $store.remindersEnabled, + accentColor: AppAccent.primary + ) + + SettingsToggle( + title: String(localized: "Haptics"), + subtitle: String(localized: "Feel a soft response on check-in"), + isOn: $store.hapticsEnabled, + accentColor: AppAccent.primary + ) + + SettingsToggle( + title: String(localized: "Sound"), + subtitle: String(localized: "Play subtle completion sounds"), + isOn: $store.soundEnabled, + accentColor: AppAccent.primary + ) + } + + SettingsSectionHeader( + title: String(localized: "Ritual pacing"), + systemImage: "timer", + accentColor: AppAccent.primary + ) + + SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + SettingsSegmentedPicker( + title: String(localized: "Focus style"), + subtitle: String(localized: "Choose the intensity of your arc"), + options: focusOptions, + selection: $store.focusStyle, + accentColor: AppAccent.primary + ) + + SettingsSlider( + title: String(localized: "Ritual length"), + subtitle: String(localized: "Adjust arc duration"), + value: $store.ritualLengthDays, + in: AppMetrics.RitualLength.minimumDays...AppMetrics.RitualLength.maximumDays, + step: AppMetrics.RitualLength.stepDays, + format: SliderFormat.integer(unit: String(localized: "days")), + accentColor: AppAccent.primary, + leadingIcon: Image(systemName: "calendar"), + trailingIcon: Image(systemName: "calendar.circle.fill") + ) + } + + SettingsSectionHeader( + title: String(localized: "iCloud Sync"), + systemImage: "icloud", + accentColor: AppAccent.primary + ) + + SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + iCloudSyncSettingsView( + viewModel: store, + accentColor: AppAccent.primary, + successColor: AppStatus.success, + warningColor: AppStatus.warning + ) + } + + SettingsSectionHeader( + title: String(localized: "About"), + systemImage: "info.circle", + accentColor: AppAccent.primary + ) + + SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + SettingsNavigationRow( + title: String(localized: "Rituals mission"), + subtitle: String(localized: "Why arcs keep habits grounded"), + backgroundColor: AppSurface.primary + ) { + SettingsAboutView() + } + } + + #if DEBUG + SettingsSectionHeader( + title: String(localized: "Debug"), + systemImage: "ant.fill", + accentColor: AppStatus.error + ) + + SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + SettingsNavigationRow( + title: String(localized: "Icon Generator"), + subtitle: String(localized: "Generate the app icon"), + backgroundColor: AppSurface.primary + ) { + IconGeneratorView(config: .rituals, appName: "Rituals") + } + + SettingsNavigationRow( + title: String(localized: "Branding Preview"), + subtitle: String(localized: "Preview launch and icon"), + backgroundColor: AppSurface.primary + ) { + BrandingPreviewView( + iconConfig: .rituals, + launchConfig: .rituals, + appName: "Rituals" + ) + } + } + #endif + + Spacer(minLength: Design.Spacing.xxxLarge) + } + .padding(.horizontal, Design.Spacing.large) + } + .background(AppSurface.primary) + .navigationTitle(String(localized: "Settings")) + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + SettingsView(store: SettingsStore.preview) + } +} diff --git a/Andromida/App/Views/Today/Components/RitualFocusCardView.swift b/Andromida/App/Views/Today/Components/RitualFocusCardView.swift new file mode 100644 index 0000000..fd88168 --- /dev/null +++ b/Andromida/App/Views/Today/Components/RitualFocusCardView.swift @@ -0,0 +1,67 @@ +import SwiftUI +import Bedrock + +struct RitualFocusCardView: View { + private let title: String + private let theme: String + private let dayLabel: String + private let completionSummary: String + private let progress: Double + + init( + title: String, + theme: String, + dayLabel: String, + completionSummary: String, + progress: Double + ) { + self.title = title + self.theme = theme + self.dayLabel = dayLabel + self.completionSummary = completionSummary + self.progress = progress + } + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: "sparkles") + .font(.title2) + .foregroundStyle(AppAccent.primary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) { + Text(title) + .font(.title3) + .foregroundStyle(AppTextColors.primary) + .bold() + Text(theme) + .font(.subheadline) + .foregroundStyle(AppTextColors.secondary) + } + Spacer(minLength: Design.Spacing.medium) + Text(dayLabel) + .font(.caption) + .foregroundStyle(AppTextColors.secondary) + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xxxSmall) + .background(AppAccent.light.opacity(Design.Opacity.light)) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + .accessibilityLabel(Text(dayLabel)) + } + + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + ProgressView(value: progress) + .tint(AppAccent.primary) + Text(completionSummary) + .font(.caption) + .foregroundStyle(AppTextColors.secondary) + } + } + .padding(Design.Spacing.large) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .shadow(color: AppBorder.subtle.opacity(Design.Opacity.medium), radius: AppMetrics.Shadow.radiusSmall, x: AppMetrics.Shadow.xOffsetNone, y: AppMetrics.Shadow.yOffsetSmall) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(title)) + } +} diff --git a/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift new file mode 100644 index 0000000..596cad9 --- /dev/null +++ b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift @@ -0,0 +1,26 @@ +import SwiftUI +import Bedrock + +struct TodayEmptyStateView: View { + @Bindable var store: RitualStore + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + SectionHeaderView( + title: String(localized: "No ritual yet"), + subtitle: String(localized: "Begin a four-week arc") + ) + + EmptyStateCardView( + title: String(localized: "Start your first ritual"), + message: String(localized: "Choose a theme and keep your focus clear for 28 days."), + actionTitle: String(localized: "Create ritual"), + action: { store.createQuickRitual() } + ) + } + } +} + +#Preview { + TodayEmptyStateView(store: RitualStore.preview) +} diff --git a/Andromida/App/Views/Today/Components/TodayHabitRowView.swift b/Andromida/App/Views/Today/Components/TodayHabitRowView.swift new file mode 100644 index 0000000..5873bea --- /dev/null +++ b/Andromida/App/Views/Today/Components/TodayHabitRowView.swift @@ -0,0 +1,50 @@ +import SwiftUI +import Bedrock + +struct TodayHabitRowView: View { + private let title: String + private let symbolName: String + private let isCompleted: Bool + private let action: () -> Void + + init( + title: String, + symbolName: String, + isCompleted: Bool, + action: @escaping () -> Void + ) { + self.title = title + self.symbolName = symbolName + self.isCompleted = isCompleted + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: symbolName) + .font(.title3) + .foregroundStyle(isCompleted ? AppStatus.success : AppAccent.primary) + .frame(width: AppMetrics.Size.iconLarge) + .accessibilityHidden(true) + + Text(title) + .font(.body) + .foregroundStyle(AppTextColors.primary) + + Spacer(minLength: Design.Spacing.medium) + + Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isCompleted ? AppStatus.success : AppBorder.subtle) + .accessibilityHidden(true) + } + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.medium) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } + .accessibilityLabel(Text(title)) + .buttonStyle(.plain) + } +} diff --git a/Andromida/App/Views/Today/Components/TodayHeaderView.swift b/Andromida/App/Views/Today/Components/TodayHeaderView.swift new file mode 100644 index 0000000..e2a8b64 --- /dev/null +++ b/Andromida/App/Views/Today/Components/TodayHeaderView.swift @@ -0,0 +1,30 @@ +import SwiftUI +import Bedrock + +struct TodayHeaderView: View { + private let dateText: String + + init(dateText: String) { + self.dateText = dateText + } + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) { + Text(String(localized: "Today")) + .font(.largeTitle) + .foregroundStyle(AppTextColors.primary) + .bold() + Text(dateText) + .font(.subheadline) + .foregroundStyle(AppTextColors.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +#Preview { + TodayHeaderView(dateText: "Sunday, January 25") + .padding(Design.Spacing.large) + .background(AppSurface.primary) +} diff --git a/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift new file mode 100644 index 0000000..7d2fac5 --- /dev/null +++ b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift @@ -0,0 +1,66 @@ +import SwiftUI +import Bedrock + +struct HabitRowModel: Identifiable { + let id: UUID + let title: String + let symbolName: String + let isCompleted: Bool + let action: () -> Void +} + +struct TodayRitualSectionView: View { + let focusTitle: String + let focusTheme: String + let dayLabel: String + let completionSummary: String + let progress: Double + let habitRows: [HabitRowModel] + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + SectionHeaderView( + title: String(localized: "Focus ritual"), + subtitle: String(localized: "Four-week arc in progress") + ) + + RitualFocusCardView( + title: focusTitle, + theme: focusTheme, + dayLabel: dayLabel, + completionSummary: completionSummary, + progress: progress + ) + + SectionHeaderView( + title: String(localized: "Habits"), + subtitle: String(localized: "Tap to check in") + ) + + VStack(spacing: Design.Spacing.medium) { + ForEach(habitRows) { habit in + TodayHabitRowView( + title: habit.title, + symbolName: habit.symbolName, + isCompleted: habit.isCompleted, + action: habit.action + ) + } + } + } + } +} + +#Preview { + TodayRitualSectionView( + focusTitle: "Morning Flow", + focusTheme: "Light and steady", + dayLabel: "Day 3 of 28", + completionSummary: "2 of 3 habits complete", + progress: 0.66, + habitRows: [ + HabitRowModel(id: UUID(), title: "Hydrate", symbolName: "drop.fill", isCompleted: true, action: {}), + HabitRowModel(id: UUID(), title: "Move", symbolName: "figure.walk", isCompleted: false, action: {}) + ] + ) +} diff --git a/Andromida/App/Views/Today/TodayView.swift b/Andromida/App/Views/Today/TodayView.swift new file mode 100644 index 0000000..81ee520 --- /dev/null +++ b/Andromida/App/Views/Today/TodayView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import Bedrock + +struct TodayView: View { + @Bindable var store: RitualStore + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + TodayHeaderView(dateText: store.todayDisplayString) + + if let ritual = store.activeRitual { + TodayRitualSectionView( + focusTitle: ritual.title, + focusTheme: ritual.theme, + dayLabel: store.ritualDayLabel(for: ritual), + completionSummary: store.completionSummary(for: ritual), + progress: store.activeRitualProgress, + habitRows: habitRows(for: ritual) + ) + } else { + TodayEmptyStateView(store: store) + } + } + .padding(Design.Spacing.large) + } + .background(LinearGradient( + colors: [AppSurface.primary, AppSurface.secondary], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + } + + private func habitRows(for ritual: Ritual) -> [HabitRowModel] { + store.habits(for: ritual).map { habit in + HabitRowModel( + id: habit.id, + title: habit.title, + symbolName: habit.symbolName, + isCompleted: store.isHabitCompletedToday(habit), + action: { store.toggleHabitCompletion(habit) } + ) + } + } +} + +#Preview { + TodayView(store: RitualStore.preview) +} diff --git a/Andromida/Assets.xcassets/Accent.colorset/Contents.json b/Andromida/Assets.xcassets/Accent.colorset/Contents.json new file mode 100644 index 0000000..5bb18fb --- /dev/null +++ b/Andromida/Assets.xcassets/Accent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.37", + "green" : "0.48", + "red" : "0.88" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.53", + "green" : "0.63", + "red" : "0.95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/Assets.xcassets/AccentSoft.colorset/Contents.json b/Andromida/Assets.xcassets/AccentSoft.colorset/Contents.json new file mode 100644 index 0000000..f3ed09c --- /dev/null +++ b/Andromida/Assets.xcassets/AccentSoft.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.81", + "green" : "0.85", + "red" : "0.97" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.15", + "green" : "0.17", + "red" : "0.23" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/Assets.xcassets/Background.colorset/Contents.json b/Andromida/Assets.xcassets/Background.colorset/Contents.json new file mode 100644 index 0000000..e9f0a0b --- /dev/null +++ b/Andromida/Assets.xcassets/Background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.92", + "green" : "0.95", + "red" : "0.97" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.09", + "green" : "0.10", + "red" : "0.11" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/Assets.xcassets/BackgroundAlt.colorset/Contents.json b/Andromida/Assets.xcassets/BackgroundAlt.colorset/Contents.json new file mode 100644 index 0000000..47bf0be --- /dev/null +++ b/Andromida/Assets.xcassets/BackgroundAlt.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.86", + "green" : "0.90", + "red" : "0.93" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.12", + "green" : "0.14", + "red" : "0.15" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/Assets.xcassets/Card.colorset/Contents.json b/Andromida/Assets.xcassets/Card.colorset/Contents.json new file mode 100644 index 0000000..91abdbf --- /dev/null +++ b/Andromida/Assets.xcassets/Card.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "1.0", + "green" : "1.0", + "red" : "1.0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.14", + "green" : "0.16", + "red" : "0.17" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/Assets.xcassets/Divider.colorset/Contents.json b/Andromida/Assets.xcassets/Divider.colorset/Contents.json new file mode 100644 index 0000000..d2b75fe --- /dev/null +++ b/Andromida/Assets.xcassets/Divider.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.78", + "green" : "0.83", + "red" : "0.87" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.18", + "green" : "0.20", + "red" : "0.23" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/Assets.xcassets/Success.colorset/Contents.json b/Andromida/Assets.xcassets/Success.colorset/Contents.json new file mode 100644 index 0000000..d7c04b7 --- /dev/null +++ b/Andromida/Assets.xcassets/Success.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.56", + "green" : "0.62", + "red" : "0.17" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.66", + "green" : "0.72", + "red" : "0.24" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/Assets.xcassets/TextPrimary.colorset/Contents.json b/Andromida/Assets.xcassets/TextPrimary.colorset/Contents.json new file mode 100644 index 0000000..0ddbad0 --- /dev/null +++ b/Andromida/Assets.xcassets/TextPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.15", + "green" : "0.16", + "red" : "0.18" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.91", + "green" : "0.93", + "red" : "0.95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/Assets.xcassets/TextSecondary.colorset/Contents.json b/Andromida/Assets.xcassets/TextSecondary.colorset/Contents.json new file mode 100644 index 0000000..5cb55fe --- /dev/null +++ b/Andromida/Assets.xcassets/TextSecondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.35", + "green" : "0.39", + "red" : "0.44" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.71", + "green" : "0.75", + "red" : "0.80" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/Assets.xcassets/Warning.colorset/Contents.json b/Andromida/Assets.xcassets/Warning.colorset/Contents.json new file mode 100644 index 0000000..496ec03 --- /dev/null +++ b/Andromida/Assets.xcassets/Warning.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.42", + "green" : "0.77", + "red" : "0.91" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.0", + "blue" : "0.52", + "green" : "0.82", + "red" : "0.95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Andromida/ContentView.swift b/Andromida/ContentView.swift deleted file mode 100644 index 97be374..0000000 --- a/Andromida/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// Andromida -// -// Created by Matt Bruce on 1/25/26. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/Andromida/Resources/LaunchScreen.storyboard b/Andromida/Resources/LaunchScreen.storyboard new file mode 100644 index 0000000..dbfa1f3 --- /dev/null +++ b/Andromida/Resources/LaunchScreen.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Andromida/Shared/AppMetrics.swift b/Andromida/Shared/AppMetrics.swift new file mode 100644 index 0000000..04a6b4e --- /dev/null +++ b/Andromida/Shared/AppMetrics.swift @@ -0,0 +1,27 @@ +import SwiftUI + +enum AppMetrics { + enum Size { + static let iconSmall: CGFloat = 18 + static let iconMedium: CGFloat = 22 + static let iconLarge: CGFloat = 28 + static let progressRing: CGFloat = 72 + static let buttonHeight: CGFloat = 46 + static let insightCardMinWidth: CGFloat = 160 + static let tabBarHighlightHeight: CGFloat = 120 + } + + enum Shadow { + static let radiusSmall: CGFloat = 8 + static let radiusLarge: CGFloat = 14 + static let yOffsetSmall: CGFloat = 4 + static let yOffsetLarge: CGFloat = 10 + static let xOffsetNone: CGFloat = 0 + } + + enum RitualLength { + static let minimumDays: Double = 14 + static let maximumDays: Double = 42 + static let stepDays: Double = 7 + } +} diff --git a/Andromida/Shared/BrandingConfig.swift b/Andromida/Shared/BrandingConfig.swift new file mode 100644 index 0000000..0d80bdb --- /dev/null +++ b/Andromida/Shared/BrandingConfig.swift @@ -0,0 +1,44 @@ +import SwiftUI +import Bedrock + +// MARK: - App Branding Colors + +extension Color { + enum Branding { + static let primary = Color(red: 0.12, green: 0.09, blue: 0.08) + static let secondary = Color(red: 0.30, green: 0.18, blue: 0.14) + static let accent = Color.white + } +} + +// MARK: - App Icon Configuration + +extension AppIconConfig { + static let rituals = AppIconConfig( + title: "RITUALS", + subtitle: "ARC", + iconSymbol: "sparkles", + primaryColor: Color.Branding.primary, + secondaryColor: Color.Branding.secondary, + accentColor: Color.Branding.accent + ) +} + +// MARK: - Launch Screen Configuration + +extension LaunchScreenConfig { + static let rituals = LaunchScreenConfig( + title: "RITUALS", + tagline: String(localized: "Four-week focus for daily habits"), + iconSymbols: ["sparkles", "circle.hexagonpath.fill"], + cornerSymbol: "sparkle", + decorativeSymbol: "circle.fill", + patternStyle: .radial, + primaryColor: Color.Branding.primary, + secondaryColor: Color.Branding.secondary, + accentColor: Color.Branding.accent, + titleColor: .white, + iconSpacing: Design.Spacing.small, + animationDuration: Design.Animation.standard + ) +} diff --git a/Andromida/Shared/Theme/RitualsTheme.swift b/Andromida/Shared/Theme/RitualsTheme.swift new file mode 100644 index 0000000..fdb661d --- /dev/null +++ b/Andromida/Shared/Theme/RitualsTheme.swift @@ -0,0 +1,93 @@ +import SwiftUI +import Bedrock + +// MARK: - Rituals Surface Colors + +public enum RitualsSurfaceColors: SurfaceColorProvider { + public static let primary = Color(red: 0.12, green: 0.09, blue: 0.08) + public static let secondary = Color(red: 0.14, green: 0.11, blue: 0.10) + public static let tertiary = Color(red: 0.18, green: 0.14, blue: 0.12) + public static let overlay = Color(red: 0.12, green: 0.09, blue: 0.08) + public static let card = Color(red: 0.16, green: 0.12, blue: 0.11) + public static let groupedFill = Color(red: 0.13, green: 0.10, blue: 0.09) + public static let sectionFill = Color(red: 0.18, green: 0.14, blue: 0.12) +} + +// MARK: - Rituals Text Colors + +public enum RitualsTextColors: TextColorProvider { + public static let primary = Color.white + public static let secondary = Color.white.opacity(Design.Opacity.accent) + public static let tertiary = Color.white.opacity(Design.Opacity.medium) + public static let disabled = Color.white.opacity(Design.Opacity.light) + public static let placeholder = Color.white.opacity(Design.Opacity.overlay) + public static let inverse = Color.black +} + +// MARK: - Rituals Accent Colors + +public enum RitualsAccentColors: AccentColorProvider { + public static let primary = Color(red: 0.93, green: 0.55, blue: 0.40) + public static let light = Color(red: 0.98, green: 0.70, blue: 0.55) + public static let dark = Color(red: 0.75, green: 0.38, blue: 0.25) + public static let secondary = Color(red: 0.95, green: 0.90, blue: 0.80) +} + +// MARK: - Rituals Button Colors + +public enum RitualsButtonColors: ButtonColorProvider { + public static let primaryLight = Color(red: 0.98, green: 0.70, blue: 0.55) + public static let primaryDark = Color(red: 0.75, green: 0.38, blue: 0.25) + public static let secondary = Color.white.opacity(Design.Opacity.subtle) + public static let destructive = Color.red.opacity(Design.Opacity.heavy) + public static let cancelText = Color.white.opacity(Design.Opacity.strong) +} + +// MARK: - Rituals Status Colors + +public enum RitualsStatusColors: StatusColorProvider { + public static let success = Color(red: 0.20, green: 0.75, blue: 0.55) + public static let warning = Color(red: 0.95, green: 0.78, blue: 0.45) + public static let error = Color(red: 0.90, green: 0.35, blue: 0.35) + public static let info = Color(red: 0.55, green: 0.72, blue: 0.92) +} + +// MARK: - Rituals Border Colors + +public enum RitualsBorderColors: BorderColorProvider { + public static let subtle = Color.white.opacity(Design.Opacity.subtle) + public static let standard = Color.white.opacity(Design.Opacity.hint) + public static let emphasized = Color.white.opacity(Design.Opacity.light) + public static let selected = RitualsAccentColors.primary.opacity(Design.Opacity.medium) +} + +// MARK: - Rituals Interactive Colors + +public enum RitualsInteractiveColors: InteractiveColorProvider { + public static let selected = RitualsAccentColors.primary.opacity(Design.Opacity.selection) + public static let hover = Color.white.opacity(Design.Opacity.subtle) + public static let pressed = Color.white.opacity(Design.Opacity.hint) + public static let focus = RitualsAccentColors.light +} + +// MARK: - Rituals Theme + +public enum RitualsTheme: AppColorTheme { + public typealias Surface = RitualsSurfaceColors + public typealias Text = RitualsTextColors + public typealias Accent = RitualsAccentColors + public typealias Button = RitualsButtonColors + public typealias Status = RitualsStatusColors + public typealias Border = RitualsBorderColors + public typealias Interactive = RitualsInteractiveColors +} + +// MARK: - Convenience Typealiases + +typealias AppSurface = RitualsSurfaceColors +typealias AppTextColors = RitualsTextColors +typealias AppAccent = RitualsAccentColors +typealias AppButtonColors = RitualsButtonColors +typealias AppStatus = RitualsStatusColors +typealias AppBorder = RitualsBorderColors +typealias AppInteractive = RitualsInteractiveColors diff --git a/AndromidaTests/RitualStoreTests.swift b/AndromidaTests/RitualStoreTests.swift new file mode 100644 index 0000000..aa2f313 --- /dev/null +++ b/AndromidaTests/RitualStoreTests.swift @@ -0,0 +1,50 @@ +import SwiftData +import Testing +@testable import Andromida + +struct RitualStoreTests { + @MainActor + @Test func quickRitualStartsIncomplete() throws { + let store = makeStore() + store.createQuickRitual() + + #expect(store.activeRitual != nil) + #expect(abs(store.activeRitualProgress) < 0.0001) + } + + @MainActor + @Test func toggleHabitCompletionMarksComplete() throws { + let store = makeStore() + store.createQuickRitual() + + guard let habit = store.activeRitual?.habits.first else { + throw TestError.missingHabit + } + + store.toggleHabitCompletion(habit) + #expect(store.isHabitCompletedToday(habit) == true) + } +} + +private func makeStore() -> RitualStore { + let schema = Schema([Ritual.self, Habit.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container: ModelContainer + do { + container = try ModelContainer(for: schema, configurations: [configuration]) + } catch { + fatalError("Test container failed: \(error)") + } + + return RitualStore(modelContext: container.mainContext, seedService: EmptySeedService()) +} + +private struct EmptySeedService: RitualSeedProviding { + func makeSeedRituals(startDate: Date) -> [Ritual] { + [] + } +} + +private enum TestError: Error { + case missingHabit +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..80cfe18 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Rituals (Andromida) + +Rituals is a paid, offline-first habit tracker built around 4-week "ritual" arcs. It focuses on steady, daily check-ins with a calm visual language, zero paid backend dependencies, and optional iCloud sync for settings. + +## Overview + +- **Concept**: Habits are grouped into 4-week ritual arcs ("chapters") rather than endless streaks. +- **Tech**: SwiftUI + SwiftData, Clean Architecture layering, Bedrock design system. +- **Data**: Local persistence with SwiftData; settings sync via Bedrock CloudSyncManager (NSUbiquitousKeyValueStore). +- **No paid APIs**: No external services required. + +## Feature Set + +- **Today dashboard**: Focus ritual, progress ring, and tap-to-complete habits. +- **Ritual library**: View active and recent rituals. +- **Ritual detail**: Full ritual summary + habit check-ins. +- **Insights**: Lightweight metrics generated locally. +- **Settings**: + - Reminders, haptics, sound toggles + - Ritual pacing options (focus style + length) + - iCloud settings sync + - DEBUG tools for icon generation and branding preview +- **Branding**: + - Bedrock AppLaunchView with custom theme + - Native LaunchScreen.storyboard to prevent flash + - Centralized branding config (colors, icons, launch) + +## Architecture + +This project follows Clean Architecture and protocol-first design: + +- **Views**: SwiftUI UI only, no business logic +- **State**: @Observable stores with app logic +- **Services**: Stateless logic and data seeding +- **Models**: SwiftData models and plain structs +- **Protocols**: Abstractions for stores/services + +## Project Structure + +``` +Andromida/ +├── Andromida/ # App target +│ ├── App/ +│ │ ├── Models/ # SwiftData + DTOs +│ │ ├── Protocols/ # Interfaces for stores/services +│ │ ├── Services/ # Stateless logic +│ │ ├── State/ # @Observable stores +│ │ └── Views/ # SwiftUI features + components +│ ├── Shared/ # Bedrock theme + branding config +│ └── Resources/ # LaunchScreen.storyboard +├── AndromidaTests/ # Unit tests +└── AndromidaUITests/ # UI tests +``` + +## Key Files + +- **App entry & launch**: `Andromida/Andromida/AndromidaApp.swift` +- **Bedrock theme**: `Andromida/Andromida/Shared/Theme/RitualsTheme.swift` +- **Branding config**: `Andromida/Andromida/Shared/BrandingConfig.swift` +- **Launch screen**: `Andromida/Andromida/Resources/LaunchScreen.storyboard` +- **Ritual store**: `Andromida/Andromida/App/State/RitualStore.swift` +- **Settings store**: `Andromida/Andromida/App/State/SettingsStore.swift` +- **Settings UI**: `Andromida/Andromida/App/Views/Settings/SettingsView.swift` + +## Data Model + +- **Ritual**: Title, theme, start date, duration (days), notes, habits +- **Habit**: Title, symbol, goal, completion by day IDs +- **Settings**: Stored via Bedrock CloudSyncManager (NSUbiquitousKeyValueStore) + +## Bedrock Integration + +- **Theming**: App-specific color providers + `AppSurface`, `AppAccent`, etc. +- **Branding**: AppLaunchView, AppIconConfig, LaunchScreenConfig +- **Settings UI**: SettingsToggle, SettingsSlider, SettingsSegmentedPicker, SettingsCard +- **Cloud Sync**: iCloud sync for settings using CloudSyncManager + +## Localization + +String catalogs are used for English, Spanish (Mexico), and French (Canada): + +- `Andromida/Andromida/App/Localization/Localizable.xcstrings` + +## Requirements + +- iOS 18.0+ +- Swift 5 (Bedrock requires Swift 6 in package; app builds under Swift 5 with modern concurrency) + +## Running + +1. Open `Andromida.xcodeproj` in Xcode. +2. Build and run on iOS 18+ simulator or device. + +## Tests + +- Unit tests in `AndromidaTests/` +- Run via Xcode Test navigator or: + - `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 15'` + +## Notes + +- App is configured with a dark theme; the root view enforces `.preferredColorScheme(.dark)` to ensure semantic text legibility. +- The launch storyboard matches the branding primary color to avoid a white flash. +- App icon generation is available in DEBUG builds from Settings. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..c995056 --- /dev/null +++ b/TODO.md @@ -0,0 +1,31 @@ +# Andromida – Focus & Fix List + +## 1) Onboarding walkthrough (Sherpa) +- [ ] Restore Sherpa tags for focus ritual card and first habit row without triggering Swift compiler crashes. +- [ ] Confirm walkthrough starts on first launch (ensure `hasCompletedOnboarding` is false in `@AppStorage`). +- [ ] Add a debug-only “Reset Onboarding” action in Settings to clear `hasCompletedOnboarding`. +- [ ] Verify tags visually align with the intended UI elements on iPhone 17 Pro Max. + +## 2) Swift compiler stability +- [ ] Identify the minimal Sherpa usage pattern that avoids the “failed to produce diagnostic” crash. +- [ ] Avoid `#Preview` macro ambiguity when Sherpa is imported (use `#if DEBUG` + `PreviewProvider` or remove previews for Sherpa-tagged views). +- [ ] Avoid ambiguous accessibility modifier overloads when Sherpa is imported. + +## 3) Today tab UX polish +- [ ] Re-add accessibility value/hint for habit rows once Sherpa-related ambiguity is resolved. +- [ ] Confirm focus ritual card and habit rows still match the intended visual hierarchy after refactors. + +## 4) Settings & product readiness +- [ ] Add a paid-app placeholder (e.g., “Pro unlock” copy) without backend requirements. +- [ ] Confirm default settings and theme in Settings match Bedrock branding. + +## 5) Data & defaults +- [ ] Confirm seed ritual creation and quick ritual creation behave as expected. +- [ ] Validate SwiftData sync (if enabled) doesn’t require any external API. + +## 6) QA checklist +- [ ] First-launch walkthrough appears on a clean install. +- [ ] Onboarding can be manually reset from Settings. +- [ ] No build warnings or Swift compiler crashes. +- [ ] iPhone 17 Pro Max simulator layout verified on Today, Rituals, Insights, Settings. +