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

This commit is contained in:
Matt Bruce 2026-01-25 16:05:42 -06:00
parent 5b29ec9621
commit 1f4c28041a
51 changed files with 4919 additions and 35 deletions

62
.gitignore vendored Normal file
View File

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

752
AGENTS.md Normal file
View File

@ -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 `"<group>"`) 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
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
```
#### Step 4: Bridge to Swift via Info.plist
Add keys to `Info.plist` that bridge xcconfig values to Swift:
```xml
<key>AppGroupIdentifier</key>
<string>$(APP_GROUP_IDENTIFIER)</string>
<key>CloudKitContainerIdentifier</key>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
<key>AppClipDomain</key>
<string>$(APPCLIP_DOMAIN)</string>
```
#### 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
<!-- WidgetExtension.entitlements -->
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
```
#### 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.

View File

@ -6,6 +6,11 @@
objectVersion = 77; objectVersion = 77;
objects = { 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 */ /* Begin PBXContainerItemProxy section */
EAC04AA62F26BAE9007F87EA /* PBXContainerItemProxy */ = { EAC04AA62F26BAE9007F87EA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
@ -52,6 +57,8 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */,
EAC04B7F2F26C478007F87EA /* Sherpa in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -112,6 +119,8 @@
); );
name = Andromida; name = Andromida;
packageProductDependencies = ( packageProductDependencies = (
EAC04AED2F26BD5B007F87EA /* Bedrock */,
EAC04B7E2F26C478007F87EA /* Sherpa */,
); );
productName = Andromida; productName = Andromida;
productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */; productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */;
@ -195,6 +204,10 @@
); );
mainGroup = EAC04A8F2F26BAE8007F87EA; mainGroup = EAC04A8F2F26BAE8007F87EA;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
EAC04AEC2F26BD5B007F87EA /* XCLocalSwiftPackageReference "../Bedrock" */,
EAC04B7D2F26C478007F87EA /* XCLocalSwiftPackageReference "../Sherpa" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = EAC04A992F26BAE8007F87EA /* Products */; productRefGroup = EAC04A992F26BAE8007F87EA /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -402,9 +415,10 @@
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = 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_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -434,9 +448,10 @@
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = 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_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -578,6 +593,28 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; rootObject = EAC04A902F26BAE8007F87EA /* Project object */;
} }

View File

@ -7,7 +7,7 @@
<key>Andromida.xcscheme_^#shared#^_</key> <key>Andromida.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>1</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>

View File

@ -1,17 +1,42 @@
//
// AndromidaApp.swift
// Andromida
//
// Created by Matt Bruce on 1/25/26.
//
import SwiftUI import SwiftUI
import SwiftData
import Bedrock
import Sherpa
@main @main
struct AndromidaApp: App { 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 { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() SherpaContainerView {
ZStack {
Color.Branding.primary
.ignoresSafeArea()
AppLaunchView(config: .rituals) {
RootView(store: store, settingsStore: settingsStore)
}
}
}
.modelContainer(modelContainer)
.preferredColorScheme(.dark)
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -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")
}
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import Foundation
protocol RitualSeedProviding {
func makeSeedRituals(startDate: Date) -> [Ritual]
}

View File

@ -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()
}

View File

@ -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]
}
}

View File

@ -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())
}
}

View File

@ -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<Ritual>())
} 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)
}
}

View File

@ -0,0 +1,61 @@
import Foundation
import Observation
import Bedrock
@MainActor
@Observable
final class SettingsStore: CloudSyncable {
@ObservationIgnored private let cloudSync = CloudSyncManager<AppSettingsData>()
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()
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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!)
}
}

View File

@ -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)
}
}

View File

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

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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: {})
]
)
}

View File

@ -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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.12" green="0.09" blue="0.08" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

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

View File

@ -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
)
}

View File

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

View File

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

104
README.md Normal file
View File

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

31
TODO.md Normal file
View File

@ -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) doesnt 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.