From 02236925132e185291682b571a4e344e0aa13f17 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 26 Jan 2026 11:49:55 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- AGENTS.md | 752 ------------------ .../App/Localization/Localizable.xcstrings | 19 + .../Rituals/Components/RitualCardView.swift | 131 +-- .../Components/RitualProgressStatsView.swift | 95 +++ .../App/Views/Rituals/RitualDetailView.swift | 33 +- 5 files changed, 151 insertions(+), 879 deletions(-) delete mode 100644 AGENTS.md create mode 100644 Andromida/App/Views/Rituals/Components/RitualProgressStatsView.swift diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 2cb9146..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,752 +0,0 @@ -# Agent Guide for Swift and SwiftUI - -This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage. - -## Additional Context Files - -Before starting work, read project documentation: - -- `WORKSPACE.md` — (if present) Multi-project workspace overview and project relationships -- `README.md` — Project scope, features, and architecture -- In multi-project workspaces, each project folder has its own `README.md` - -When making architectural changes, keep documentation files in sync with code changes. - - -## Role - -You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and related frameworks. Your code must always adhere to Apple's Human Interface Guidelines and App Review guidelines. - - -## Core Instructions - -- Target a min of iOS 17 or later. (Yes, it definitely exists.) -- Swift 5 using modern Swift concurrency. Our primary project is not on 6 yet. -- SwiftUI backed up by `@Observable` classes for shared data. -- **Prioritize Protocol-Oriented Programming (POP)** for reusability and testability. -- **Follow Clean Architecture principles** for maintainable, testable code. -- Do not introduce third-party frameworks without asking first. -- Avoid UIKit unless requested. - - -## Clean Architecture - -**Separation of concerns is mandatory.** Code should be organized into distinct layers with clear responsibilities and dependencies flowing inward. - -### File Organization Principles - -1. **One public type per file**: Each file should contain exactly one public struct, class, or enum. Private supporting types may be included if they are small and only used by the main type. - -2. **Keep files lean**: Aim for files under 300 lines. If a file exceeds this: - - Extract reusable sub-views into separate files in a `Components/` folder - - Extract sheets/modals into a `Sheets/` folder - - Move complex logic into dedicated types - -3. **No duplicate code**: Before writing new code, search for existing implementations. Extract common patterns into reusable components. - -4. **Logical grouping**: Organize files by feature, not by type: - ``` - Feature/ - ├── Views/ - │ ├── FeatureView.swift - │ ├── Components/ - │ │ ├── FeatureRowView.swift - │ │ └── FeatureHeaderView.swift - │ └── Sheets/ - │ └── FeatureEditSheet.swift - ├── Models/ - │ └── FeatureModel.swift - └── State/ - └── FeatureStore.swift - ``` - -### Layer Responsibilities - -| Layer | Contains | Depends On | -|-------|----------|------------| -| **Views** | SwiftUI views, UI components | State, Models | -| **State** | `@Observable` stores, view models | Models, Services | -| **Services** | Business logic, networking, persistence | Models | -| **Models** | Data types, entities, DTOs | Nothing | -| **Protocols** | Interfaces for services and stores | Models | - -### Architecture Rules - -1. **Views are dumb renderers**: No business logic in views. Views read state and call methods. -2. **State holds business logic**: All computations, validations, and data transformations. -3. **Services are stateless**: Pure functions where possible. Injected via protocols. -4. **Models are simple**: Plain data types. No dependencies on UI or services. - -### Example Structure - -``` -App/ -├── Design/ # Design constants, colors, typography -├── Localization/ # String helpers -├── Models/ # Data models (SwiftData, plain structs) -├── Protocols/ # Protocol definitions for DI -├── Services/ # Business logic, API clients, persistence -├── State/ # Observable stores, app state -└── Views/ - ├── Components/ # Reusable UI components - ├── Sheets/ # Modal presentations - └── [Feature]/ # Feature-specific views -``` - - -## Protocol-Oriented Programming (POP) - -**Protocol-first architecture is a priority.** When designing new features, always think about protocols and composition before concrete implementations. - -### When Architecting New Code - -1. **Start with the protocol**: Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol. -2. **Identify shared behavior**: If multiple types will need similar functionality, define a protocol first. -3. **Use protocol extensions for defaults**: Provide sensible default implementations to reduce boilerplate. -4. **Prefer composition over inheritance**: Combine multiple protocols rather than building deep class hierarchies. - -### When Reviewing Existing Code - -1. **Look for duplicated patterns**: Similar logic across files is a candidate for protocol extraction. -2. **Identify common interfaces**: Types that expose similar properties/methods should conform to a shared protocol. -3. **Check before implementing**: Search for existing protocols that could be adopted or extended. -4. **Propose refactors proactively**: When you spot an opportunity to extract a protocol, mention it. - -### Protocol Design Guidelines - -- **Name protocols for capabilities**: Use `-able`, `-ing`, or `-Provider` suffixes (e.g., `Shareable`, `DataProviding`, `Persistable`). -- **Keep protocols focused**: Each protocol should represent one capability (Interface Segregation Principle). -- **Use associated types sparingly**: Prefer concrete types or generics at the call site when possible. -- **Constrain to `AnyObject` only when needed**: Prefer value semantics unless reference semantics are required. - -### Benefits - -- **Reusability**: Shared protocols work across features -- **Testability**: Mock types can conform to protocols for unit testing -- **Flexibility**: New features can adopt existing protocols immediately -- **Maintainability**: Fix a bug in a protocol extension, fix it everywhere -- **Discoverability**: Protocols document the expected interface clearly - - -## View/State Separation (MVVM-lite) - -**Views should be "dumb" renderers.** All business logic belongs in stores or dedicated view models. - -### What Belongs in State/Store - -- **Business logic**: Calculations, validations, rules -- **Computed properties based on data**: Hints, recommendations, derived values -- **State checks**: `canSubmit`, `isLoading`, `hasError` -- **Data transformations**: Filtering, sorting, aggregations - -### What is Acceptable in Views - -- **Pure UI layout logic**: Adaptive layouts based on size class -- **Visual styling**: Color selection based on state -- **@ViewBuilder sub-views**: Breaking up complex layouts (keep in same file if small) -- **Accessibility labels**: Combining data into accessible descriptions - -### Example - -```swift -// ❌ BAD - Business logic in view -struct MyView: View { - @Bindable var state: FeatureState - - private var isValid: Bool { - !state.name.isEmpty && state.email.contains("@") - } -} - -// ✅ GOOD - Logic in State, view just reads -// In FeatureState: -var isValid: Bool { - !name.isEmpty && email.contains("@") -} - -// In View: -Button("Save") { state.save() } - .disabled(!state.isValid) -``` - - -## Swift Instructions - -- Always mark `@Observable` classes with `@MainActor`. -- Assume strict Swift concurrency rules are being applied. -- Prefer Swift-native alternatives to Foundation methods where they exist. -- Prefer modern Foundation API (e.g., `URL.documentsDirectory`, `appending(path:)`). -- Never use C-style number formatting; use `format:` modifiers instead. -- Prefer static member lookup to struct instances (`.circle` not `Circle()`). -- Never use old-style GCD; use modern Swift concurrency. -- Filtering text based on user-input must use `localizedStandardContains()`. -- Avoid force unwraps and force `try` unless unrecoverable. - - -## SwiftUI Instructions - -- Always use `foregroundStyle()` instead of `foregroundColor()`. -- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`. -- Always use the `Tab` API instead of `tabItem()`. -- Never use `ObservableObject`; always prefer `@Observable` classes. -- Never use `onChange()` in its 1-parameter variant. -- Never use `onTapGesture()` unless you need tap location/count; use `Button`. -- Never use `Task.sleep(nanoseconds:)`; use `Task.sleep(for:)`. -- Never use `UIScreen.main.bounds` to read available space. -- Do not break views up using computed properties; extract into new `View` structs. -- Do not force specific font sizes; prefer Dynamic Type. -- Use `NavigationStack` with `navigationDestination(for:)`. -- If using an image for a button label, always specify text alongside. -- Prefer `ImageRenderer` to `UIGraphicsImageRenderer`. -- Use `bold()` instead of `fontWeight(.bold)`. -- Avoid `GeometryReader` if newer alternatives work (e.g., `containerRelativeFrame()`). -- When enumerating in `ForEach`, don't convert to Array first. -- Hide scroll indicators with `.scrollIndicators(.hidden)`. -- Avoid `AnyView` unless absolutely required. -- **Never use raw numeric literals** for padding, spacing, opacity, etc.—use Design constants. -- **Never use inline colors**—define all colors with semantic names. -- Avoid UIKit colors in SwiftUI code. - - -## watchOS Development (CRITICAL) - -**Read this entire section before implementing any watch functionality.** - -### Creating a Watch Target - -When adding a watchOS target to an existing iOS app: - -1. **File → New → Target → "Watch App for watchOS"** -2. Choose **"Watch App for Existing iOS App"** (NOT standalone) -3. Name it appropriately (e.g., `AppNameWatch`) -4. Xcode creates a folder like `AppNameWatch Watch App/` - -### CRITICAL: Embedding the Watch App - -⚠️ **THIS IS THE #1 CAUSE OF "WATCH APP NOT INSTALLED" ERRORS** ⚠️ - -The watch app MUST be embedded in the iOS app for deployment to real devices: - -1. Select the **iOS target** in Xcode -2. Go to **Build Phases** tab -3. Verify there's an **"Embed Watch Content"** phase -4. **CRITICAL**: Ensure **"Code Sign On Copy"** is CHECKED ✓ - -If "Embed Watch Content" doesn't exist: -1. Click **"+"** → **"New Copy Files Phase"** -2. Rename to **"Embed Watch Content"** -3. Set **Destination** to **"Products Directory"** -4. Set **Subpath** to `$(CONTENTS_FOLDER_PATH)/Watch` -5. Add the watch app (e.g., `AppNameWatch Watch App.app`) -6. **CHECK "Code Sign On Copy"** ← This is critical! - -Without proper embedding, the iOS app installs but the watch app does NOT install on the paired Apple Watch. - -### Bundle Identifiers - -Watch app bundle IDs MUST be prefixed with the iOS app's bundle ID: - -``` -iOS app: com.company.AppName -Watch app: com.company.AppName.watchkitapp ← MUST start with iOS bundle ID -``` - -Also verify `WKCompanionAppBundleIdentifier` in the watch target's build settings matches the iOS app's bundle ID exactly. - -### Data Sync: WatchConnectivity (NOT App Groups) - -**DO NOT use App Groups for iPhone ↔ Watch data sharing.** - -App Groups: -- ❌ Do NOT work between iPhone and Apple Watch -- ❌ Different container paths on each device -- ❌ Will waste hours debugging why data isn't syncing -- ✅ Only work between an app and its extensions on the SAME device - -**Use WatchConnectivity framework instead:** - -```swift -// iOS side - WatchConnectivityService.swift -import WatchConnectivity - -@MainActor -final class WatchConnectivityService: NSObject, WCSessionDelegate { - static let shared = WatchConnectivityService() - - private override init() { - super.init() - if WCSession.isSupported() { - WCSession.default.delegate = self - WCSession.default.activate() - } - } - - func syncData(_ data: [String: Any]) { - guard WCSession.default.activationState == .activated, - WCSession.default.isPaired, - WCSession.default.isWatchAppInstalled else { return } - - try? WCSession.default.updateApplicationContext(data) - } -} -``` - -### WatchConnectivity Methods - -| Method | Use Case | -|--------|----------| -| `updateApplicationContext` | Latest state that persists (use this for most syncs) | -| `sendMessage` | Immediate delivery when counterpart is reachable | -| `transferUserInfo` | Queued delivery, guaranteed but not immediate | - -### watchOS Framework Limitations - -These iOS frameworks are NOT available on watchOS: - -- ❌ `CoreImage` - Generate QR codes on iOS, send image data to watch -- ❌ `UIKit` (mostly) - Use SwiftUI -- ❌ `AVFoundation` (limited) - -### Simulator Limitations - -WatchConnectivity on simulators is **unreliable**: - -- `isWatchAppInstalled` often returns `false` even when running -- `isReachable` may be `false` even with both apps running -- `updateApplicationContext` may fail with "counterpart not installed" - -**Workarounds for simulator testing:** -1. Add `#if targetEnvironment(simulator)` blocks with sample data -2. Test real sync functionality on physical devices only - -### Debugging Watch Sync Issues - -If `isWatchAppInstalled` returns `false`: - -1. ✅ Check "Embed Watch Content" build phase exists -2. ✅ Check "Code Sign On Copy" is enabled -3. ✅ Verify bundle ID is prefixed correctly -4. ✅ Clean build folder (⇧⌘K) and rebuild -5. ✅ On iPhone, open Watch app → verify app appears under "Installed" - -### NSObject Requirement - -`WCSessionDelegate` is an Objective-C protocol, so conforming classes must inherit from `NSObject`: - -```swift -final class WatchConnectivityService: NSObject, WCSessionDelegate { - // NSObject is required for WCSessionDelegate conformance -} -``` - - -## SwiftData Instructions - -If SwiftData is configured to use CloudKit: - -- Never use `@Attribute(.unique)`. -- Model properties must have default values or be optional. -- All relationships must be marked optional. - - -## Model Design: Single Source of Truth - -**Computed properties should be the single source of truth for derived data.** - -### Name Fields Pattern - -When a model has multiple name components (prefix, firstName, middleName, lastName, suffix, etc.), use a computed property for the display name: - -```swift -// ✅ GOOD - Computed from individual fields -var fullName: String { - var parts: [String] = [] - if !prefix.isEmpty { parts.append(prefix) } - if !firstName.isEmpty { parts.append(firstName) } - if !lastName.isEmpty { parts.append(lastName) } - // ... etc - return parts.joined(separator: " ") -} - -// ❌ BAD - Stored displayName that can get out of sync -var displayName: String // Never add this -``` - -### Benefits - -- **Always up to date**: Changes to individual fields are immediately reflected -- **No sync bugs**: No risk of stored value diverging from component fields -- **Simpler code**: No need to update displayName when editing name fields - -### Related Properties - -If you need different formats for different purposes: - -- `fullName` — For display (may include formatting like quotes, parentheses) -- `vCardName` — For export (plain format, no special formatting) - - -## Localization Instructions - -- Use **String Catalogs** (`.xcstrings` files) for localization. -- SwiftUI `Text("literal")` views automatically look up strings in the catalog. -- For strings outside of `Text` views, use `String(localized:)` or a helper extension. -- Store all user-facing strings in the String Catalog. -- Support at minimum: English (en), Spanish-Mexico (es-MX), French-Canada (fr-CA). -- Never use `NSLocalizedString`; prefer `String(localized:)`. - - -## Design Constants - -**Never use raw numeric literals or hardcoded colors directly in views.** - -### Values That MUST Be Constants - -- **Spacing & Padding**: `Design.Spacing.medium` not `.padding(12)` -- **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16` -- **Font Sizes**: `Design.BaseFontSize.body` not `size: 14` -- **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)` -- **Colors**: `Color.Primary.accent` not `Color(red:green:blue:)` -- **Line Widths**: `Design.LineWidth.medium` not `lineWidth: 2` -- **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10` -- **Animation Durations**: `Design.Animation.quick` not `duration: 0.3` -- **Component Sizes**: `Design.Size.avatar` not `frame(width: 56)` - -### Organization - -- Create a `DesignConstants.swift` file using enums for namespacing. -- Extend `Color` with semantic color definitions. -- View-specific constants go at the top of the view struct with a comment. -- Name constants semantically: `accent` not `pointSix`, `large` not `sixteen`. - - -## App Identifiers (xcconfig) - -**Centralize all company-specific identifiers** using xcconfig files for true single-source configuration. This enables one-line migration between developer accounts. - -### Why xcconfig? - -- **Single source of truth**: Change one file, everything updates -- **Build-time resolution**: Bundle IDs, entitlements, and Swift code all derive from same source -- **No manual updates**: Entitlements use variable substitution -- **Environment support**: Easy Debug/Release/Staging configurations - -### Setup Instructions - -#### Step 1: Create xcconfig Files - -Create `Configuration/Base.xcconfig`: - -``` -// Base.xcconfig - Source of truth for all identifiers -// MIGRATION: Update COMPANY_IDENTIFIER and DEVELOPMENT_TEAM below - -// ============================================================================= -// COMPANY IDENTIFIER - CHANGE THIS FOR MIGRATION -// ============================================================================= - -COMPANY_IDENTIFIER = com.yourcompany -APP_NAME = YourAppName -DEVELOPMENT_TEAM = YOUR_TEAM_ID - -// ============================================================================= -// DERIVED IDENTIFIERS - DO NOT EDIT -// ============================================================================= - -APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME) -WATCH_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).watchkitapp -APPCLIP_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Clip -TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)Tests -UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests - -APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(APP_NAME) -CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME) - -APPCLIP_DOMAIN = yourapp.example.com -``` - -Create `Configuration/Debug.xcconfig`: - -``` -// Debug.xcconfig -#include "Base.xcconfig" -// Add debug-specific settings here -``` - -Create `Configuration/Release.xcconfig`: - -``` -// Release.xcconfig -#include "Base.xcconfig" -// Add release-specific settings here -``` - -#### Step 2: Configure Xcode Project - -In `project.pbxproj`, add file references and set `baseConfigurationReference` for each build configuration: - -**1. Add xcconfig file references to PBXFileReference section:** - -``` -/* Use SOURCE_ROOT and full path from project root */ -EACONFIG001 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppName/Configuration/Base.xcconfig; sourceTree = SOURCE_ROOT; }; -EACONFIG002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppName/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; }; -EACONFIG003 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppName/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; }; -``` - -**IMPORTANT**: Use `sourceTree = SOURCE_ROOT` (not `""`) and include the full path from project root (e.g., `AppName/Configuration/Base.xcconfig`). - -**2. Set `baseConfigurationReference` on project-level Debug/Release configurations:** - -``` -EA123456 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = EACONFIG002 /* Debug.xcconfig */; - buildSettings = { ... }; -}; -``` - -**3. Replace hardcoded values with variables:** - -``` -PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; -DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; -INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)"; -``` - -#### Step 3: Update Entitlements - -Use variable substitution in `.entitlements` files: - -```xml -com.apple.developer.icloud-container-identifiers - - $(CLOUDKIT_CONTAINER_IDENTIFIER) - -com.apple.security.application-groups - - $(APP_GROUP_IDENTIFIER) - -``` - -#### Step 4: Bridge to Swift via Info.plist - -Add keys to `Info.plist` that bridge xcconfig values to Swift: - -```xml -AppGroupIdentifier -$(APP_GROUP_IDENTIFIER) -CloudKitContainerIdentifier -$(CLOUDKIT_CONTAINER_IDENTIFIER) -AppClipDomain -$(APPCLIP_DOMAIN) -``` - -#### Step 5: Create Swift Interface - -**Why this is needed:** Swift code cannot read xcconfig files directly. The xcconfig values flow through Info.plist, and this Swift file provides a clean API to access them at runtime. Without this file, you'd have to call `Bundle.main.object(forInfoDictionaryKey:)` everywhere you need an identifier. - -**When to use:** Any Swift code that needs App Group identifiers, CloudKit containers, custom domains, or other configuration values must use `AppIdentifiers.*` instead of hardcoding strings. - -Create `Configuration/AppIdentifiers.swift`: - -```swift -import Foundation - -enum AppIdentifiers { - // Read from Info.plist (values come from xcconfig) - static let appGroupIdentifier: String = { - Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String - ?? "group.com.yourcompany.AppName" - }() - - static let cloudKitContainerIdentifier: String = { - Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String - ?? "iCloud.com.yourcompany.AppName" - }() - - static let appClipDomain: String = { - Bundle.main.object(forInfoDictionaryKey: "AppClipDomain") as? String - ?? "yourapp.example.com" - }() - - // Derived from bundle identifier - static var bundleIdentifier: String { - Bundle.main.bundleIdentifier ?? "com.yourcompany.AppName" - } - - static var watchBundleIdentifier: String { "\(bundleIdentifier).watchkitapp" } - static var appClipBundleIdentifier: String { "\(bundleIdentifier).Clip" } - - static func appClipURL(recordName: String) -> URL? { - URL(string: "https://\(appClipDomain)/appclip?id=\(recordName)") - } -} -``` - -### Data Flow - -``` -Base.xcconfig (source of truth) - ↓ -project.pbxproj (baseConfigurationReference) - ↓ -Build Settings → Bundle IDs, Team ID, etc. - ↓ -Info.plist (bridges values via $(VARIABLE)) - ↓ -AppIdentifiers.swift (Swift reads from Bundle.main) -``` - -### Usage in Code - -```swift -// Always use AppIdentifiers instead of hardcoding -FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier -) - -CKContainer(identifier: AppIdentifiers.cloudKitContainerIdentifier) -``` - -### Adding New Targets - -When adding new targets (Widgets, Intents, App Clips, etc.), follow this pattern: - -#### 1. Add Bundle ID Variable to Base.xcconfig - -``` -// In Base.xcconfig, add new derived identifier -WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Widget -INTENT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Intent -``` - -#### 2. Set Target to Use xcconfig - -For the new target's Debug/Release configurations in `project.pbxproj`: - -``` -EA_NEW_TARGET_DEBUG /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = EACONFIG002 /* Debug.xcconfig */; - buildSettings = { - PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; - // ... other settings - }; -}; -``` - -#### 3. Configure Entitlements (if needed) - -If the target needs App Groups or CloudKit access, create an entitlements file using variables: - -```xml - -com.apple.security.application-groups - - $(APP_GROUP_IDENTIFIER) - -``` - -#### 4. Share Code via App Groups - -Extensions must use App Groups to share data with the main app: - -```swift -// In extension code -let sharedDefaults = UserDefaults(suiteName: AppIdentifiers.appGroupIdentifier) -let containerURL = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier -) -``` - -#### 5. Update AppIdentifiers.swift (if needed) - -Add new computed properties for target-specific identifiers: - -```swift -static var widgetBundleIdentifier: String { "\(bundleIdentifier).Widget" } -static var intentBundleIdentifier: String { "\(bundleIdentifier).Intent" } -``` - -#### Common Target Types and Bundle ID Patterns - -| Target Type | Bundle ID Variable | Example Value | -|-------------|-------------------|---------------| -| Widget Extension | `WIDGET_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).Widget` | -| Intent Extension | `INTENT_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).Intent` | -| App Clip | `APPCLIP_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).Clip` | -| Watch App | `WATCH_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).watchkitapp` | -| Notification Extension | `NOTIFICATION_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).NotificationExtension` | -| Share Extension | `SHARE_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).ShareExtension` | - -#### Checklist for New Targets - -- [ ] Add bundle ID variable to `Base.xcconfig` -- [ ] Set `baseConfigurationReference` to Debug/Release xcconfig -- [ ] Use `$(VARIABLE)` for `PRODUCT_BUNDLE_IDENTIFIER` -- [ ] Use `$(DEVELOPMENT_TEAM)` for team -- [ ] Create entitlements with `$(APP_GROUP_IDENTIFIER)` if sharing data -- [ ] Add to `AppIdentifiers.swift` if Swift code needs the identifier -- [ ] Register App ID in Apple Developer Portal (uses same App Group) - -### Migration - -To migrate to a new developer account, edit **one file** (`Base.xcconfig`): - -``` -COMPANY_IDENTIFIER = com.newcompany -DEVELOPMENT_TEAM = NEW_TEAM_ID -``` - -Then clean build (⇧⌘K) and rebuild. Everything updates automatically—including all extension targets. - - -## Dynamic Type Instructions - -- Always support Dynamic Type for accessibility. -- Use `@ScaledMetric` to scale custom dimensions. -- Choose appropriate `relativeTo` text styles based on semantic purpose. -- For constrained UI elements, you may use fixed sizes but document the reason. -- Prefer system text styles: `.font(.body)`, `.font(.title)`, `.font(.caption)`. - - -## VoiceOver Accessibility Instructions - -- All interactive elements must have meaningful `.accessibilityLabel()`. -- Use `.accessibilityValue()` for dynamic state. -- Use `.accessibilityHint()` to describe what happens on interaction. -- Use `.accessibilityAddTraits()` for element type. -- Hide decorative elements with `.accessibilityHidden(true)`. -- Group related elements to reduce navigation complexity. -- Post accessibility announcements for important events. - - -## Project Structure - -- Use a consistent project structure organized by feature. -- Follow strict naming conventions for types, properties, and methods. -- **One public type per file**—break types into separate files. -- Write unit tests for core application logic. -- Only write UI tests if unit tests are not possible. -- Add code comments and documentation as needed. -- Never include secrets or API keys in the repository. - - -## Documentation Instructions - -- **Keep `README.md` files up to date** when adding new functionality. -- In multi-project workspaces, update the relevant project's `README.md`. -- Document new features, settings, or mechanics in the appropriate README. -- Update documentation when modifying existing behavior. -- Include configuration options and special interactions. -- README updates should be part of the same commit as the feature. - - -## PR Instructions - -- If installed, ensure SwiftLint returns no warnings or errors. -- Verify that documentation reflects any new functionality. -- Check for duplicate code before submitting. -- Ensure all new files follow the one-type-per-file rule. diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index 5b9ab83..831ff6f 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -3,6 +3,10 @@ "strings" : { "" : { + }, + " : " : { + "comment" : "A separator between the time of day and its range in the RitualDetailView.", + "isCommentAutoGenerated" : true }, "-%lld%% vs last week" : { "comment" : "A description of how a user's usage has changed compared to the previous week. The argument is the percentage by which the usage has increased or decreased.", @@ -126,6 +130,10 @@ "comment" : "A text label displaying a percentage. The argument is a value between 0.0 and 1.0.", "isCommentAutoGenerated" : true }, + "%lld%% complete" : { + "comment" : "A text label showing the percentage of the ritual that has been completed.", + "isCommentAutoGenerated" : true + }, "%lld%% completion over %lld days" : { "comment" : "A string summarizing the completion rate of a ritual arc. The first argument is the completion rate, expressed as a percentage. The second argument is the duration of the arc in days.", "isCommentAutoGenerated" : true, @@ -891,6 +899,10 @@ } } }, + "Days" : { + "comment" : "Label for a stat that shows how many days have passed in a ritual.", + "isCommentAutoGenerated" : true + }, "Days Active" : { "comment" : "Title of an insight card that shows the number of days the user has completed at least one habit. Each day a habit is checked in counts toward the total.", "isCommentAutoGenerated" : true @@ -1923,6 +1935,9 @@ "Notifications disabled in Settings" : { "comment" : "Subtitle text for the reminder toggle when notifications are disabled in settings.", "isCommentAutoGenerated" : true + }, + "of %lld" : { + }, "On track" : { "comment" : "Label for a badge indicating that a user is on track with their habit completions.", @@ -2105,6 +2120,10 @@ "comment" : "Habit title for releasing shoulder tension during mindfulness practice.", "isCommentAutoGenerated" : true }, + "Remaining" : { + "comment" : "Label for the number of days remaining in a ritual's progress.", + "isCommentAutoGenerated" : true + }, "Reminders" : { "comment" : "Title of a toggle in the settings view that controls whether reminders are enabled.", "isCommentAutoGenerated" : true diff --git a/Andromida/App/Views/Rituals/Components/RitualCardView.swift b/Andromida/App/Views/Rituals/Components/RitualCardView.swift index 9fef6e6..f347446 100644 --- a/Andromida/App/Views/Rituals/Components/RitualCardView.swift +++ b/Andromida/App/Views/Rituals/Components/RitualCardView.swift @@ -30,9 +30,28 @@ struct RitualCardView: View { var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.medium) { - ViewThatFits(in: .horizontal) { - wideHeader - compactHeader + HStack(spacing: Design.Spacing.small) { + // Icon + Image(systemName: iconName) + .foregroundStyle(hasActiveArc ? AppAccent.primary : AppTextColors.tertiary) + .accessibilityHidden(true) + + // Title + Text(title) + .font(.headline) + .foregroundStyle(hasActiveArc ? AppTextColors.primary : AppTextColors.tertiary) + + Spacer(minLength: Design.Spacing.medium) + + // Day label + 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)) } Text(theme) @@ -49,112 +68,6 @@ struct RitualCardView: View { .opacity(hasActiveArc ? 1.0 : Design.Opacity.medium) .accessibilityElement(children: .combine) } - - // MARK: - Wide Layout (tablets/landscape) - - private var wideHeader: some View { - HStack(spacing: Design.Spacing.small) { - Image(systemName: iconName) - .foregroundStyle(hasActiveArc ? AppAccent.primary : AppTextColors.tertiary) - .accessibilityHidden(true) - - Text(title) - .font(.headline) - .foregroundStyle(hasActiveArc ? AppTextColors.primary : AppTextColors.tertiary) - - Spacer(minLength: Design.Spacing.medium) - - timeOfDayBadge - - Text(dayLabel) - .font(.caption) - .foregroundStyle(AppTextColors.secondary) - .fixedSize() - } - } - - // MARK: - Compact Layout (phones/portrait) - - private var compactHeader: some View { - VStack(alignment: .leading, spacing: Design.Spacing.small) { - HStack(spacing: Design.Spacing.small) { - Image(systemName: iconName) - .foregroundStyle(hasActiveArc ? AppAccent.primary : AppTextColors.tertiary) - .accessibilityHidden(true) - - Text(title) - .font(.headline) - .foregroundStyle(hasActiveArc ? AppTextColors.primary : AppTextColors.tertiary) - } - - HStack(spacing: Design.Spacing.small) { - compactTimeOfDayBadge - - Text(dayLabel) - .font(.caption) - .foregroundStyle(AppTextColors.secondary) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xSmall) - .background(AppSurface.secondary) - .clipShape(.capsule) - } - } - } - - // MARK: - Time Badges - - private var compactTimeOfDayBadge: some View { - HStack(spacing: Design.Spacing.xSmall) { - Image(systemName: timeOfDay.symbolName) - .font(.caption2) - Text(timeOfDay.displayName) - .font(.caption2) - } - .foregroundStyle(timeOfDayColor) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xSmall) - .background(timeOfDayColor.opacity(0.15)) - .clipShape(.capsule) - .accessibilityLabel(timeOfDay.displayNameWithRange) - } - - private var timeOfDayBadge: some View { - VStack(alignment: .trailing, spacing: 2) { - HStack(spacing: Design.Spacing.xSmall) { - Image(systemName: timeOfDay.symbolName) - .font(.caption2) - Text(timeOfDay.displayName) - .font(.caption2) - } - .foregroundStyle(timeOfDayColor) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xSmall) - .background(timeOfDayColor.opacity(0.15)) - .clipShape(.capsule) - - Text(timeOfDay.timeRange) - .font(.system(size: 9)) - .foregroundStyle(AppTextColors.tertiary) - } - .accessibilityLabel(timeOfDay.displayNameWithRange) - } - - private var timeOfDayColor: Color { - switch timeOfDay { - case .morning: - return Color.orange - case .midday: - return Color.yellow - case .afternoon: - return Color.orange.opacity(0.8) - case .evening: - return Color.purple - case .night: - return Color.indigo - case .anytime: - return AppTextColors.secondary - } - } } #Preview { diff --git a/Andromida/App/Views/Rituals/Components/RitualProgressStatsView.swift b/Andromida/App/Views/Rituals/Components/RitualProgressStatsView.swift new file mode 100644 index 0000000..cd6109e --- /dev/null +++ b/Andromida/App/Views/Rituals/Components/RitualProgressStatsView.swift @@ -0,0 +1,95 @@ +import SwiftUI +import Bedrock + +struct RitualProgressStatsView: View { + let currentDay: Int + let totalDays: Int + let habitsCompleted: Int + let habitsTotal: Int + let daysRemaining: Int + let progress: Double + + var body: some View { + VStack(spacing: Design.Spacing.large) { + // Stats row - evenly distributed + HStack(spacing: 0) { + statColumn( + value: "\(currentDay)", + secondary: String(localized: "of \(totalDays)"), + label: String(localized: "Days") + ) + .frame(maxWidth: .infinity) + + statColumn( + value: "\(habitsCompleted)", + secondary: String(localized: "of \(habitsTotal)"), + label: String(localized: "Habits") + ) + .frame(maxWidth: .infinity) + + statColumn( + value: "\(daysRemaining)", + secondary: nil, + label: String(localized: "Remaining") + ) + .frame(maxWidth: .infinity) + } + + // Progress bar + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + ProgressView(value: progress) + .tint(AppAccent.primary) + + Text("\(Int(progress * 100))% complete") + .font(.caption) + .foregroundStyle(AppTextColors.secondary) + } + } + .padding(Design.Spacing.large) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .accessibilityElement(children: .combine) + } + + private func statColumn(value: String, secondary: String?, label: String) -> some View { + VStack(spacing: Design.Spacing.xSmall) { + Text(value) + .font(.title) + .fontWeight(.bold) + .foregroundStyle(AppTextColors.primary) + + // Always show secondary row for consistent height + Text(secondary ?? " ") + .font(.caption) + .foregroundStyle(secondary != nil ? AppTextColors.tertiary : .clear) + + Text(label) + .font(.caption2) + .foregroundStyle(AppTextColors.secondary) + } + } +} + +#Preview { + VStack(spacing: Design.Spacing.large) { + RitualProgressStatsView( + currentDay: 16, + totalDays: 28, + habitsCompleted: 2, + habitsTotal: 3, + daysRemaining: 12, + progress: 0.57 + ) + + RitualProgressStatsView( + currentDay: 1, + totalDays: 21, + habitsCompleted: 0, + habitsTotal: 4, + daysRemaining: 21, + progress: 0.0 + ) + } + .padding(Design.Spacing.large) + .background(AppSurface.primary) +} diff --git a/Andromida/App/Views/Rituals/RitualDetailView.swift b/Andromida/App/Views/Rituals/RitualDetailView.swift index d6e705d..ed79f51 100644 --- a/Andromida/App/Views/Rituals/RitualDetailView.swift +++ b/Andromida/App/Views/Rituals/RitualDetailView.swift @@ -180,12 +180,13 @@ struct RitualDetailView: View { private var activeArcContent: some View { VStack(alignment: .leading, spacing: Design.Spacing.large) { - // Progress card - RitualFocusCardView( - title: ritual.title, - theme: ritual.theme, - dayLabel: store.ritualDayLabel(for: ritual), - completionSummary: store.completionSummary(for: ritual), + // Progress stats + RitualProgressStatsView( + currentDay: store.ritualDayIndex(for: ritual), + totalDays: ritual.durationDays, + habitsCompleted: ritual.habits.filter { store.isHabitCompletedToday($0) }.count, + habitsTotal: ritual.habits.count, + daysRemaining: store.daysRemaining(for: ritual), progress: store.ritualProgress(for: ritual) ) @@ -310,24 +311,20 @@ struct RitualDetailView: View { } private var timeOfDayBadge: some View { - VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 2) { HStack(spacing: Design.Spacing.xSmall) { Image(systemName: ritual.timeOfDay.symbolName) - .font(.caption2) Text(ritual.timeOfDay.displayName) - .font(.caption2) } - .foregroundStyle(timeOfDayColor) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xSmall) - .background(timeOfDayColor.opacity(0.15)) - .clipShape(.capsule) - + Text(" : ") Text(ritual.timeOfDay.timeRange) - .font(.system(size: 9)) - .foregroundStyle(AppTextColors.tertiary) - .padding(.leading, Design.Spacing.xSmall) } + .font(.caption2) + .foregroundStyle(timeOfDayColor) + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xSmall) + .background(timeOfDayColor.opacity(0.15)) + .clipShape(.capsule) } private var timeOfDayColor: Color {