Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
5b29ec9621
commit
1f4c28041a
62
.gitignore
vendored
Normal file
62
.gitignore
vendored
Normal 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
752
AGENTS.md
Normal 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.
|
||||
@ -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 */;
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<key>Andromida.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1932
Andromida/App/Localization/Localizable.xcstrings
Normal file
1932
Andromida/App/Localization/Localizable.xcstrings
Normal file
File diff suppressed because it is too large
Load Diff
32
Andromida/App/Models/AppSettingsData.swift
Normal file
32
Andromida/App/Models/AppSettingsData.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Andromida/App/Models/Habit.swift
Normal file
28
Andromida/App/Models/Habit.swift
Normal 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
|
||||
}
|
||||
}
|
||||
23
Andromida/App/Models/InsightCard.swift
Normal file
23
Andromida/App/Models/InsightCard.swift
Normal 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
|
||||
}
|
||||
}
|
||||
32
Andromida/App/Models/Ritual.swift
Normal file
32
Andromida/App/Models/Ritual.swift
Normal 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
|
||||
}
|
||||
}
|
||||
5
Andromida/App/Protocols/RitualSeedProviding.swift
Normal file
5
Andromida/App/Protocols/RitualSeedProviding.swift
Normal file
@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
protocol RitualSeedProviding {
|
||||
func makeSeedRituals(startDate: Date) -> [Ritual]
|
||||
}
|
||||
17
Andromida/App/Protocols/RitualStoreProviding.swift
Normal file
17
Andromida/App/Protocols/RitualStoreProviding.swift
Normal 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()
|
||||
}
|
||||
36
Andromida/App/Services/RitualSeedService.swift
Normal file
36
Andromida/App/Services/RitualSeedService.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
16
Andromida/App/State/RitualStore+Preview.swift
Normal file
16
Andromida/App/State/RitualStore+Preview.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
190
Andromida/App/State/RitualStore.swift
Normal file
190
Andromida/App/State/RitualStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
61
Andromida/App/State/SettingsStore.swift
Normal file
61
Andromida/App/State/SettingsStore.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
58
Andromida/App/Views/Components/EmptyStateCardView.swift
Normal file
58
Andromida/App/Views/Components/EmptyStateCardView.swift
Normal 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)
|
||||
}
|
||||
33
Andromida/App/Views/Components/SectionHeaderView.swift
Normal file
33
Andromida/App/Views/Components/SectionHeaderView.swift
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
42
Andromida/App/Views/Insights/InsightsView.swift
Normal file
42
Andromida/App/Views/Insights/InsightsView.swift
Normal 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)
|
||||
}
|
||||
19
Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift
Normal file
19
Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Andromida/App/Views/Rituals/Components/RitualCardView.swift
Normal file
59
Andromida/App/Views/Rituals/Components/RitualCardView.swift
Normal 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)
|
||||
}
|
||||
76
Andromida/App/Views/Rituals/RitualDetailView.swift
Normal file
76
Andromida/App/Views/Rituals/RitualDetailView.swift
Normal 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!)
|
||||
}
|
||||
}
|
||||
45
Andromida/App/Views/Rituals/RitualsView.swift
Normal file
45
Andromida/App/Views/Rituals/RitualsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
64
Andromida/App/Views/RootView.swift
Normal file
64
Andromida/App/Views/RootView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
28
Andromida/App/Views/Settings/SettingsAboutView.swift
Normal file
28
Andromida/App/Views/Settings/SettingsAboutView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
144
Andromida/App/Views/Settings/SettingsView.swift
Normal file
144
Andromida/App/Views/Settings/SettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
50
Andromida/App/Views/Today/Components/TodayHabitRowView.swift
Normal file
50
Andromida/App/Views/Today/Components/TodayHabitRowView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
30
Andromida/App/Views/Today/Components/TodayHeaderView.swift
Normal file
30
Andromida/App/Views/Today/Components/TodayHeaderView.swift
Normal 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)
|
||||
}
|
||||
@ -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: {})
|
||||
]
|
||||
)
|
||||
}
|
||||
49
Andromida/App/Views/Today/TodayView.swift
Normal file
49
Andromida/App/Views/Today/TodayView.swift
Normal 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)
|
||||
}
|
||||
38
Andromida/Assets.xcassets/Accent.colorset/Contents.json
Normal file
38
Andromida/Assets.xcassets/Accent.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
38
Andromida/Assets.xcassets/AccentSoft.colorset/Contents.json
Normal file
38
Andromida/Assets.xcassets/AccentSoft.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
38
Andromida/Assets.xcassets/Background.colorset/Contents.json
Normal file
38
Andromida/Assets.xcassets/Background.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
38
Andromida/Assets.xcassets/Card.colorset/Contents.json
Normal file
38
Andromida/Assets.xcassets/Card.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
38
Andromida/Assets.xcassets/Divider.colorset/Contents.json
Normal file
38
Andromida/Assets.xcassets/Divider.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
38
Andromida/Assets.xcassets/Success.colorset/Contents.json
Normal file
38
Andromida/Assets.xcassets/Success.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
38
Andromida/Assets.xcassets/TextPrimary.colorset/Contents.json
Normal file
38
Andromida/Assets.xcassets/TextPrimary.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
38
Andromida/Assets.xcassets/Warning.colorset/Contents.json
Normal file
38
Andromida/Assets.xcassets/Warning.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
24
Andromida/Resources/LaunchScreen.storyboard
Normal file
24
Andromida/Resources/LaunchScreen.storyboard
Normal 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>
|
||||
27
Andromida/Shared/AppMetrics.swift
Normal file
27
Andromida/Shared/AppMetrics.swift
Normal 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
|
||||
}
|
||||
}
|
||||
44
Andromida/Shared/BrandingConfig.swift
Normal file
44
Andromida/Shared/BrandingConfig.swift
Normal 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
|
||||
)
|
||||
}
|
||||
93
Andromida/Shared/Theme/RitualsTheme.swift
Normal file
93
Andromida/Shared/Theme/RitualsTheme.swift
Normal 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
|
||||
50
AndromidaTests/RitualStoreTests.swift
Normal file
50
AndromidaTests/RitualStoreTests.swift
Normal 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
104
README.md
Normal 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
31
TODO.md
Normal 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) 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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user