diff --git a/AGENTS.md b/AGENTS.md index 9a15adc..4fad121 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,752 +1,9 @@ -# Agent Guide for Swift and SwiftUI +Use /ios-18-role +read the PRD.md +read the README.md -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. +Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented. -## Additional Context Files +Always try to build after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator. -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 iOS 26.0 or later. (Yes, it definitely exists.) -- Swift 6.2 or later, using modern Swift concurrency. -- 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. +Try and use xcode build mcp if it is working and test using screenshots when asked. \ No newline at end of file diff --git a/AI_Implementation.md b/AI_Implementation.md deleted file mode 100644 index ec7829b..0000000 --- a/AI_Implementation.md +++ /dev/null @@ -1,374 +0,0 @@ -# AI Implementation Guide - -## How This App Was Architected & Built - -This project was developed following strict senior-level iOS engineering standards, with guidance from AI assistants acting as Senior iOS Engineers specializing in SwiftUI and modern Apple frameworks. - ---- - -## Guiding Principles (from AGENTS.md) - -- **Protocol-Oriented Programming (POP) first**: All shared capabilities defined via protocols before concrete types -- **MVVM-lite**: Views are "dumb" — all logic lives in `@Observable` view models -- **Bedrock Design System**: Centralized design tokens, no magic numbers -- **Full accessibility**: Dynamic Type, VoiceOver labels/hints/traits/announcements -- **Modern Swift & SwiftUI**: Swift 6 concurrency, `@MainActor`, `foregroundStyle`, `clipShape(.rect)`, `NavigationStack` -- **Testable & reusable design**: Protocols enable mocking and future package extraction - ---- - -## Architecture Overview - -``` -Shared/ -├── DesignConstants.swift → Uses Bedrock design tokens -├── BrandingConfig.swift → App icon & launch screen config -├── Color+Extensions.swift → Ring light color presets -├── Models/ -│ ├── CameraFlashMode.swift → Flash mode enum -│ ├── CameraHDRMode.swift → HDR mode enum -│ ├── PhotoQuality.swift → Photo quality settings -│ └── CapturedPhoto.swift → Photo data model -├── Protocols/ -│ ├── RingLightConfigurable.swift → Border, color, brightness -│ ├── CaptureControlling.swift → Timer, grid, zoom, capture -│ └── PremiumManaging.swift → Subscription state -├── Premium/ -│ └── PremiumManager.swift → RevenueCat integration -├── Services/ -│ └── PhotoLibraryService.swift → Photo saving service -└── Storage/ - └── SyncedSettings.swift → iCloud-synced settings - -Features/ -├── Camera/ → Main camera UI -│ ├── ContentView.swift → Screen coordinator -│ ├── Views/ → UI components -│ └── GridOverlay.swift → Rule of thirds -├── Settings/ → Configuration -│ ├── SettingsView.swift → Settings UI -│ └── SettingsViewModel.swift → Settings logic + sync -└── Paywall/ → Pro subscription flow -``` - ---- - -## Key Implementation Decisions - -### 1. Ring Light Effect -- Achieved using `RingLightOverlay` view that creates a colored border around the camera preview -- Border width controlled via user setting (10-120pt range) -- Multiple preset colors with premium custom color picker -- Adjustable opacity/brightness (10%-100%) -- Enabled/disabled toggle for quick access - -### 2. Camera System -- Uses **MijickCamera** framework for SwiftUI-native camera handling -- Supports front and back camera switching -- Pinch-to-zoom with smooth interpolation -- Flash modes: Off, On, Auto (with premium flash sync) -- HDR mode support (premium feature) -- Photo quality settings (medium free, high premium) - -### 3. Capture Enhancements -- Self-timer with countdown (3s free, 5s/10s premium) -- Post-capture preview with share functionality -- Auto-save option to Photo Library -- Front flash using screen brightness -- **Camera Control button** (iPhone 16+): Full press captures, light press locks focus/exposure -- **Hardware shutter**: Volume buttons trigger capture via `VolumeButtonObserver` - -### 4. Freemium Model -- Built with **RevenueCat** for subscription management -- `PremiumManager` wraps RevenueCat SDK -- `PremiumGate` utility for clean premium feature access -- Settings automatically fall back to free defaults when not premium - -### 5. iCloud Sync -- Uses **Bedrock's CloudSyncManager** for settings synchronization -- `SyncedSettings` model contains all user preferences -- Debounced saves for slider values (300ms delay) -- Real-time sync status display in Settings -- Available to all users (not a premium feature) - -### 6. Branding System -- Uses **Bedrock's Branding** module for launch screen and app icon -- `BrandingConfig.swift` defines app-specific colors and symbols -- `LaunchBackground.colorset` matches launch screen primary color -- Animated launch with configurable duration and pattern style -- Icon generator available in DEBUG builds - ---- - -## Camera Control Button Integration - -### Overview - -The app supports the **Camera Control** button on iPhone 16+ via `AVCaptureEventInteraction` (iOS 17.2+). - -### Files Involved - -| File | Purpose | -|------|---------| -| `Shared/Protocols/CaptureEventHandling.swift` | Protocol defining hardware capture event handling | -| `Features/Camera/Views/CaptureEventInteraction.swift` | `AVCaptureEventInteraction` wrapper and SwiftUI integration | -| `Features/Camera/Views/VolumeButtonObserver.swift` | Volume button capture support (legacy) | - -### Supported Hardware Events - -| Event | Hardware | Action | -|-------|----------|--------| -| **Primary (full press)** | Camera Control, Action Button | Capture photo | -| **Secondary (light press)** | Camera Control | Lock focus/exposure | -| **Volume buttons** | All iPhones | Capture photo | - -### Implementation Details - -```swift -// CaptureEventInteractionView is added to the camera ZStack -CaptureEventInteractionView( - onCapture: { performCapture() }, - onFocusLock: { locked in handleFocusLock(locked) } -) - -// The interaction uses AVCaptureEventInteraction (iOS 17.2+) -AVCaptureEventInteraction( - primaryEventHandler: { phase in /* capture on .ended */ }, - secondaryEventHandler: { phase in /* focus lock on .began/.ended */ } -) -``` - -### Device Compatibility - -- **iPhone 16+**: Full Camera Control button support (press + light press) -- **iPhone 15 Pro+**: Action button support (when configured for camera) -- **All iPhones**: Volume button shutter via `VolumeButtonObserver` - ---- - -## Premium Feature Implementation - -### How Premium Gating Works - -The app uses a centralized `PremiumGate` utility for consistent premium feature handling: - -```swift -// In SettingsViewModel -var isMirrorFlipped: Bool { - get { PremiumGate.get(cloudSync.data.isMirrorFlipped, default: false, isPremium: isPremiumUnlocked) } - set { - guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return } - updateSettings { $0.isMirrorFlipped = newValue } - } -} -``` - -### Premium Features List - -| Feature | Free Value | Premium Value | -|---------|-----------|---------------| -| Ring light colors | Pure White, Warm Cream | All presets + custom | -| Timer options | Off, 3s | Off, 3s, 5s, 10s | -| Photo quality | Medium | Medium, High | -| HDR mode | Off | Off, On, Auto | -| True mirror | Off | Configurable | -| Skin smoothing | Off | Configurable | -| Flash sync | Off | Configurable | -| Center stage | Off | Configurable | - ---- - -## Settings & Persistence - -### SyncedSettings Model - -All user preferences are stored in a single `SyncedSettings` struct that syncs via iCloud: - -- Ring light: size, color ID, custom color RGB, opacity, enabled -- Camera: position, flash mode, HDR mode, photo quality -- Display: mirror flip, skin smoothing, grid visible -- Capture: timer, capture mode, auto-save -- Premium features: flash sync, center stage - -### Debounced Saves - -Slider values (ring size, opacity) use debounced saving to prevent excessive iCloud writes: - -```swift -private func debouncedSave(key: String, action: @escaping () -> Void) { - debounceTask?.cancel() - debounceTask = Task { - try? await Task.sleep(for: .milliseconds(300)) - guard !Task.isCancelled else { return } - action() - } -} -``` - ---- - -## Branding Implementation - -### Files Involved - -1. **BrandingConfig.swift** - Defines app icon and launch screen configurations -2. **LaunchBackground.colorset** - Asset catalog color matching primary brand color -3. **SelfieCamApp.swift** - Wraps ContentView with AppLaunchView - -### Color Scheme - -```swift -extension Color { - enum Branding { - static let primary = Color(red: 0.85, green: 0.25, blue: 0.45) // Vibrant magenta - static let secondary = Color(red: 0.45, green: 0.12, blue: 0.35) // Deep purple - static let accent = Color.white - } -} -``` - -### Launch Screen Configuration - -```swift -static let selfieCam = LaunchScreenConfig( - title: "SELFIE CAM", - tagline: "Look Your Best", - iconSymbols: ["camera.fill", "sparkles"], - cornerSymbol: "sparkle", - patternStyle: .radial, - // ... colors and sizing -) -``` - ---- - -## Development Workflow - -### Adding a New Feature - -1. **Define the protocol** (if shared behavior) -2. **Add to SyncedSettings** (if needs persistence) -3. **Implement in SettingsViewModel** (with premium gating if applicable) -4. **Add UI in SettingsView** -5. **Update documentation** (README, this file) - -### Adding a Premium Feature - -1. Add setting to `SyncedSettings` with appropriate default -2. Use `PremiumGate.get()` for the getter with free default -3. Use `PremiumGate.canSet()` guard for the setter -4. Add premium indicator (crown icon) in UI -5. Wire up paywall trigger for non-premium users - -### Testing Premium Features - -Set environment variable in scheme: -- **Name:** `ENABLE_DEBUG_PREMIUM` -- **Value:** `1` - ---- - -## Reusability & Extraction - -The codebase is structured for future extraction into reusable packages: - -| Potential Package | Contents | -|-------------------|----------| -| **SelfieCameraKit** | Camera views, capture logic, preview components | -| **RingLightKit** | Ring light overlay, color presets, configuration | -| **PremiumKit** | Premium manager, gating utilities, paywall | -| **SyncedSettingsKit** | CloudSyncManager, settings model pattern | - ---- - -## Key Dependencies - -| Dependency | Purpose | Integration | -|------------|---------|-------------| -| **Bedrock** | Design system, branding, cloud sync | Local Swift package | -| **MijickCamera** | Camera capture and preview | SPM dependency | -| **RevenueCat** | Subscription management | SPM dependency | - ---- - -## Code Quality Standards - -- **No magic numbers**: All values from Design constants -- **Full accessibility**: Every interactive element has VoiceOver support -- **Protocol-first**: Shared behavior defined via protocols -- **Separation of concerns**: Views are dumb, ViewModels contain logic -- **Modern APIs**: Swift 6, async/await, @Observable -- **Documentation**: Code comments, README, implementation guides - ---- - -## Known Issues / TODO - -### Camera Control Button Light Press - NOT WORKING - -**Status:** ❌ Broken - Needs Investigation - -The Camera Control button (iPhone 16+) **full press works** for photo capture, but the **light press (secondary action) does NOT work**. - -Testing revealed that the "secondary" events in logs were actually triggered by **volume button**, not Camera Control light press. The volume button works because `onCameraCaptureEvent` handles all hardware capture buttons. - -#### What Works: -- ✅ Camera Control full press → triggers photo capture -- ✅ Volume up/down → triggers secondary event (focus lock) - -#### What Doesn't Work: -- ❌ Camera Control light press → no event received at all -- ❌ Camera Control swipe gestures (zoom) → Apple-exclusive API - -#### User Action Required - Check Accessibility Settings: - -**Settings > Accessibility > Camera Control**: -- Ensure **Camera Control** is enabled -- Ensure **Light-Press** is turned ON -- Adjust **Light-Press Force** if needed -- Check **Double Light-Press Speed** - -These system settings may affect third-party apps differently than Apple Camera. - -#### Investigation Areas: - -1. **Accessibility settings may block third-party light press** - - User reports light press works in Apple Camera but not SelfieCam - - System may require explicit light-press enablement per-app - -2. **MijickCamera session configuration** - - The third-party camera framework may interfere with light press detection - - MijickCamera manages its own AVCaptureSession - may conflict - - Try testing with raw AVCaptureSession to isolate the issue - -3. **`onCameraCaptureEvent` secondaryAction limitations** - - The `secondaryAction` closure receives volume button events correctly - - Camera Control light press may use different event pathway - - Apple may internally route light press to their Camera app exclusively - -4. **Light press may require AVCapturePhotoOutput configuration** - - Secondary events might need specific photo output settings - - Check if `AVCapturePhotoSettings` has light-press related properties - -5. **Possible Apple restriction (most likely)** - - Light press and swipe gestures appear restricted to first-party apps - - Similar to swipe-to-zoom which is Apple-exclusive - - No public API documentation confirms light press availability - ---- - -## Future Enhancements - -Potential areas for expansion: - -- [ ] Real-time filters (beauty, color grading) -- [ ] Gesture-based capture (smile detection) -- [ ] Widget for quick camera access -- [ ] Apple Watch remote trigger -- [ ] Export presets (aspect ratios, watermarks) -- [ ] Social sharing integrations -- [ ] Camera Control button swipe-to-zoom (if Apple makes API public) - ---- - -This architecture demonstrates production-quality SwiftUI development while delivering a polished, competitive user experience. diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..37cc424 --- /dev/null +++ b/PRD.md @@ -0,0 +1,611 @@ +# Product Requirements Document (PRD) + +## SelfieCam - Professional Selfie Camera App + +**Version:** 1.0 +**Platform:** iOS 18.0+ +**Language:** Swift 6 with strict concurrency +**Framework:** SwiftUI + +--- + +## Executive Summary + +SelfieCam is a professional-grade selfie camera app featuring a customizable screen-based ring light overlay, premium camera controls, and beautiful branding. The app targets content creators, makeup artists, video call professionals, and anyone who needs flattering lighting for selfies. + +--- + +## Target Audience + +- Content creators and influencers +- Makeup artists and beauty professionals +- Video call and streaming professionals +- Casual users seeking better selfie lighting +- Portrait photographers needing on-the-go lighting + +--- + +## Technical Requirements + +### Platform & Tools + +| Requirement | Specification | +|-------------|---------------| +| iOS Deployment Target | iOS 18.0+ | +| Swift Version | Swift 6 with strict concurrency checking | +| UI Framework | SwiftUI (primary) | +| Persistence | iCloud via CloudSyncManager | +| Subscriptions | RevenueCat SDK | +| Camera | MijickCamera framework | +| Design System | Bedrock (local package) | + +### Architecture Principles + +1. **Protocol-Oriented Programming (POP)** - All shared capabilities defined via protocols before concrete types +2. **MVVM-lite** - Views are "dumb" renderers; all logic lives in `@Observable` view models +3. **Bedrock Design System** - Centralized design tokens, no magic numbers +4. **Full Accessibility** - Dynamic Type, VoiceOver labels/hints/traits/announcements +5. **Modern Swift & SwiftUI** - Swift 6 concurrency, `@MainActor`, modern APIs +6. **Testable & Reusable Design** - Protocols enable mocking and future package extraction + +--- + +## Feature Requirements + +### FR-100: Core Camera System + +#### FR-101: Camera Preview +- **Priority:** P0 (Critical) +- **Description:** Full-screen camera preview with real-time display +- **Acceptance Criteria:** + - [ ] Smooth, low-latency camera preview + - [ ] Supports both front and back camera + - [ ] Camera switching via UI button + - [ ] Prevents screen dimming during camera use + - [ ] Uses MijickCamera framework for SwiftUI-native handling + +#### FR-102: Photo Capture +- **Priority:** P0 (Critical) +- **Description:** High-quality photo capture with multiple trigger methods +- **Acceptance Criteria:** + - [ ] Capture button triggers photo capture + - [ ] Volume buttons trigger capture (hardware shutter) + - [ ] Camera Control button full press triggers capture (iPhone 16+) + - [ ] Post-capture preview with share functionality + - [ ] Auto-save option to Photo Library + +#### FR-103: Zoom Control +- **Priority:** P1 (High) +- **Description:** Pinch-to-zoom gesture support +- **Acceptance Criteria:** + - [ ] Smooth pinch-to-zoom interpolation + - [ ] Zoom level persists during session + - [ ] Zoom resets on camera switch (optional behavior) + +#### FR-104: Camera Control Button Support +- **Priority:** P2 (Medium) +- **Description:** iPhone 16+ Camera Control button integration +- **Acceptance Criteria:** + - [ ] Full press triggers photo capture + - [ ] Light press locks focus/exposure (if API available) + - [ ] Uses `AVCaptureEventInteraction` (iOS 17.2+) +- **Known Limitations:** + - Light press may be restricted to first-party apps + - Swipe-to-zoom is Apple-exclusive API + +--- + +### FR-200: Ring Light System + +#### FR-201: Ring Light Overlay +- **Priority:** P0 (Critical) +- **Description:** Screen-based ring light effect using colored border +- **Acceptance Criteria:** + - [ ] Configurable border thickness (10-120pt range) + - [ ] Border renders around camera preview + - [ ] Quick enable/disable toggle + - [ ] Smooth transition animations + +#### FR-202: Ring Light Colors +- **Priority:** P0 (Critical) +- **Description:** Multiple color temperature presets +- **Free Colors:** + - Pure White + - Warm Cream +- **Premium Colors:** + - Ice Blue + - Soft Pink + - Warm Amber + - Cool Lavender +- **Acceptance Criteria:** + - [ ] Color picker UI with visual swatches + - [ ] Premium colors show lock indicator for free users + - [ ] Premium users can access custom color picker + +#### FR-203: Ring Light Brightness +- **Priority:** P1 (High) +- **Description:** Adjustable ring light opacity/brightness +- **Acceptance Criteria:** + - [ ] Slider control for brightness (10%-100%) + - [ ] Real-time preview of brightness changes + - [ ] Debounced saving (300ms) to prevent excessive iCloud writes + +--- + +### FR-300: Flash System + +#### FR-301: Flash Modes +- **Priority:** P1 (High) +- **Description:** Multiple flash options for photo capture +- **Modes:** + - Off + - On + - Auto +- **Acceptance Criteria:** + - [ ] Mode selector in camera UI + - [ ] Flash fires during capture when enabled + - [ ] Auto mode uses ambient light detection + +#### FR-302: Front Flash +- **Priority:** P1 (High) +- **Description:** Screen brightness-based flash for front camera +- **Acceptance Criteria:** + - [ ] Screen brightness increases to maximum during front camera capture + - [ ] Returns to original brightness after capture + +#### FR-303: Flash Sync (Premium) +- **Priority:** P2 (Medium) +- **Description:** Match flash color with ring light color +- **Acceptance Criteria:** + - [ ] Premium feature with appropriate gating + - [ ] Screen color matches current ring light color during flash + - [ ] Toggle in settings to enable/disable + +--- + +### FR-400: Self-Timer System + +#### FR-401: Timer Options +- **Priority:** P1 (High) +- **Description:** Countdown timer before photo capture +- **Free Options:** + - Off + - 3 seconds +- **Premium Options:** + - 5 seconds + - 10 seconds +- **Acceptance Criteria:** + - [ ] Visual countdown indicator + - [ ] Audio feedback (optional) + - [ ] Cancel option during countdown + - [ ] VoiceOver announces countdown + +--- + +### FR-500: Display & Enhancement Features + +#### FR-501: Grid Overlay +- **Priority:** P2 (Medium) +- **Description:** Rule-of-thirds composition guide +- **Acceptance Criteria:** + - [ ] Toggle in settings to show/hide + - [ ] Semi-transparent grid lines + - [ ] Does not interfere with tap gestures + +#### FR-502: True Mirror Mode (Premium) +- **Priority:** P2 (Medium) +- **Description:** Horizontally flipped preview like a real mirror +- **Acceptance Criteria:** + - [ ] Premium feature with appropriate gating + - [ ] Live preview is mirrored + - [ ] Captured photo reflects mirror setting + +#### FR-503: Skin Smoothing (Premium) +- **Priority:** P2 (Medium) +- **Description:** Real-time subtle skin smoothing filter +- **Acceptance Criteria:** + - [ ] Premium feature with appropriate gating + - [ ] Toggle in settings + - [ ] Subtle, natural-looking effect + - [ ] Applied to both preview and captured photo + +#### FR-504: Center Stage (Premium) +- **Priority:** P3 (Low) +- **Description:** Automatic subject tracking/centering +- **Acceptance Criteria:** + - [ ] Premium feature with appropriate gating + - [ ] Uses Apple's Center Stage API if available + - [ ] Graceful fallback on unsupported devices + +--- + +### FR-600: Photo Quality Options + +#### FR-601: HDR Mode (Premium) +- **Priority:** P2 (Medium) +- **Description:** High Dynamic Range photo capture +- **Modes:** + - Off + - On + - Auto +- **Acceptance Criteria:** + - [ ] Premium feature with appropriate gating + - [ ] HDR indicator in UI when enabled + - [ ] Uses system HDR capture capabilities + +#### FR-602: Photo Quality Settings (Premium) +- **Priority:** P2 (Medium) +- **Description:** Resolution/quality selection +- **Options:** + - Medium (Free) + - High (Premium) +- **Acceptance Criteria:** + - [ ] Premium feature for High quality + - [ ] Clear indication of current quality setting + - [ ] Maximum resolution output for High setting + +--- + +### FR-700: Settings & Synchronization + +#### FR-701: iCloud Sync +- **Priority:** P1 (High) +- **Description:** Automatic settings synchronization across devices +- **Acceptance Criteria:** + - [ ] Available to all users (free and premium) + - [ ] Real-time sync status with last sync timestamp + - [ ] Manual "Sync Now" option + - [ ] Uses Bedrock's CloudSyncManager + +#### FR-702: Settings Persistence +- **Priority:** P0 (Critical) +- **Description:** All user preferences stored in SyncedSettings model +- **Settings Include:** + - Ring light: size, color ID, custom color RGB, opacity, enabled + - Camera: position, flash mode, HDR mode, photo quality + - Display: mirror flip, skin smoothing, grid visible + - Capture: timer, capture mode, auto-save + - Premium features: flash sync, center stage +- **Acceptance Criteria:** + - [ ] Settings persist across app launches + - [ ] Debounced saves for slider values + - [ ] Settings sync via iCloud + +--- + +### FR-800: Branding & Launch Experience + +#### FR-801: Animated Launch Screen +- **Priority:** P2 (Medium) +- **Description:** Beautiful branded launch experience +- **Acceptance Criteria:** + - [ ] Animated launch with configurable duration + - [ ] Customizable colors, patterns, and layout + - [ ] Seamless transition to main app + - [ ] Uses Bedrock's LaunchScreenConfig + +#### FR-802: App Icon +- **Priority:** P2 (Medium) +- **Description:** Consistent branded app icon +- **Acceptance Criteria:** + - [ ] Generated via Bedrock icon system + - [ ] Matches launch screen branding + - [ ] Icon generator available in DEBUG builds + +--- + +### FR-900: Premium & Monetization + +#### FR-901: Freemium Model +- **Priority:** P0 (Critical) +- **Description:** Free tier with optional Pro subscription +- **Free Features:** + - Basic ring light (2 colors) + - Photo capture + - 3-second timer + - Grid overlay + - Zoom + - iCloud sync +- **Premium Features:** + - Full color palette + custom colors + - HDR mode + - High quality photos + - Flash sync + - True mirror mode + - Skin smoothing + - Center stage + - Extended timers (5s, 10s) + +#### FR-902: RevenueCat Integration +- **Priority:** P0 (Critical) +- **Description:** Subscription management via RevenueCat +- **Acceptance Criteria:** + - [ ] PremiumManager wraps RevenueCat SDK + - [ ] PremiumGate utility for consistent feature gating + - [ ] Entitlement named `pro` + - [ ] Settings automatically fall back to free defaults when not premium + +#### FR-903: Paywall +- **Priority:** P1 (High) +- **Description:** Pro subscription purchase flow +- **Acceptance Criteria:** + - [ ] Clear presentation of premium features + - [ ] Monthly and yearly subscription options + - [ ] Restore purchases functionality + - [ ] Accessible and localized + +#### FR-904: Debug Premium Mode +- **Priority:** P3 (Low) +- **Description:** Testing premium features without subscription +- **Acceptance Criteria:** + - [ ] Environment variable `ENABLE_DEBUG_PREMIUM=1` + - [ ] Only works in DEBUG builds + - [ ] Unlocks all premium features for testing + +--- + +## Non-Functional Requirements + +### NFR-100: Accessibility + +#### NFR-101: VoiceOver Support +- **Priority:** P0 (Critical) +- **Acceptance Criteria:** + - [ ] All interactive elements have meaningful `.accessibilityLabel()` + - [ ] Dynamic state uses `.accessibilityValue()` + - [ ] Actions described with `.accessibilityHint()` + - [ ] Appropriate traits via `.accessibilityAddTraits()` + - [ ] Decorative elements hidden with `.accessibilityHidden(true)` + - [ ] Important events trigger accessibility announcements + +#### NFR-102: Dynamic Type +- **Priority:** P0 (Critical) +- **Acceptance Criteria:** + - [ ] All text supports Dynamic Type + - [ ] Custom dimensions use `@ScaledMetric` + - [ ] UI remains usable at largest text sizes + +--- + +### NFR-200: Performance + +#### NFR-201: Camera Performance +- **Priority:** P0 (Critical) +- **Acceptance Criteria:** + - [ ] Smooth, real-time camera preview (30+ fps) + - [ ] Minimal latency on capture + - [ ] No UI blocking during photo processing + +#### NFR-202: Battery Efficiency +- **Priority:** P1 (High) +- **Acceptance Criteria:** + - [ ] Efficient camera usage + - [ ] Debounced saves reduce iCloud writes + - [ ] Screen dimming prevention is intentional (user is actively using camera) + +--- + +### NFR-300: Privacy & Security + +#### NFR-301: Data Collection +- **Priority:** P0 (Critical) +- **Acceptance Criteria:** + - [ ] No data collection + - [ ] No analytics + - [ ] No tracking + - [ ] Privacy policy reflects minimal data usage + +#### NFR-302: API Key Security +- **Priority:** P0 (Critical) +- **Acceptance Criteria:** + - [ ] API keys stored in `.xcconfig` files + - [ ] `Secrets.xcconfig` is gitignored + - [ ] Template file provided for setup + +--- + +### NFR-400: Localization + +#### NFR-401: String Catalogs +- **Priority:** P1 (High) +- **Acceptance Criteria:** + - [ ] Uses `.xcstrings` files for localization + - [ ] All user-facing strings in String Catalog + - [ ] Minimum supported languages: English (en), Spanish-Mexico (es-MX), French-Canada (fr-CA) + +--- + +## Project Structure + +``` +SelfieCam/ +├── App/ # App entry point with launch screen +├── Configuration/ # xcconfig files (API keys) +├── Features/ +│ ├── Camera/ # Main camera UI +│ │ ├── ContentView.swift # Main screen coordinator +│ │ ├── Views/ # Camera UI components +│ │ │ ├── CustomCameraScreen.swift +│ │ │ ├── RingLightOverlay.swift +│ │ │ ├── CaptureButton.swift +│ │ │ ├── ExpandableControlsPanel.swift +│ │ │ ├── CaptureEventInteraction.swift +│ │ │ └── ... +│ │ ├── GridOverlay.swift +│ │ └── PostCapturePreviewView.swift +│ ├── Paywall/ # Pro subscription flow +│ │ └── ProPaywallView.swift +│ └── Settings/ # Configuration screens +│ ├── SettingsView.swift +│ ├── SettingsViewModel.swift +│ └── ... +├── Shared/ +│ ├── BrandingConfig.swift # App icon & launch screen config +│ ├── DesignConstants.swift # Design tokens (uses Bedrock) +│ ├── Color+Extensions.swift # Ring light color presets +│ ├── Models/ # Data models +│ │ ├── CameraFlashMode.swift +│ │ ├── CameraHDRMode.swift +│ │ ├── PhotoQuality.swift +│ │ └── ... +│ ├── Protocols/ # Shared protocols +│ │ ├── RingLightConfigurable.swift +│ │ ├── CaptureControlling.swift +│ │ ├── CaptureEventHandling.swift +│ │ └── PremiumManaging.swift +│ ├── Premium/ # Subscription management +│ │ └── PremiumManager.swift +│ ├── Services/ # App services +│ │ └── PhotoLibraryService.swift +│ └── Storage/ # Persistence +│ └── SyncedSettings.swift +└── Resources/ # Assets, localization + ├── Assets.xcassets/ + │ ├── AppIcon.appiconset/ + │ ├── LaunchBackground.colorset/ + │ └── ... + └── Localizable.xcstrings +``` + +--- + +## Dependencies + +| Dependency | Purpose | Integration | +|------------|---------|-------------| +| **Bedrock** | Design system, branding, cloud sync | Local Swift package | +| **MijickCamera** | Camera capture and preview | SPM dependency | +| **RevenueCat** | Subscription management | SPM dependency | + +--- + +## Premium Feature Gating Pattern + +All premium features use centralized `PremiumGate` utility: + +```swift +// Getter pattern - returns free default if not premium +var isMirrorFlipped: Bool { + get { PremiumGate.get(cloudSync.data.isMirrorFlipped, default: false, isPremium: isPremiumUnlocked) } + set { + guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return } + updateSettings { $0.isMirrorFlipped = newValue } + } +} +``` + +--- + +## Premium Feature Matrix + +| Feature | Free Value | Premium Value | +|---------|-----------|---------------| +| Ring light colors | Pure White, Warm Cream | All presets + custom | +| Timer options | Off, 3s | Off, 3s, 5s, 10s | +| Photo quality | Medium | Medium, High | +| HDR mode | Off | Off, On, Auto | +| True mirror | Off | Configurable | +| Skin smoothing | Off | Configurable | +| Flash sync | Off | Configurable | +| Center stage | Off | Configurable | + +--- + +## Known Limitations + +### Camera Control Button Light Press + +**Status:** Not Working - Needs Investigation + +The Camera Control button (iPhone 16+) full press works for photo capture, but the light press (secondary action) does not work. + +**What Works:** +- Camera Control full press → triggers photo capture +- Volume up/down → triggers capture + +**What Doesn't Work:** +- Camera Control light press → no event received +- Camera Control swipe gestures (zoom) → Apple-exclusive API + +**Possible Causes:** +1. Light press may be restricted to first-party apps +2. MijickCamera session may interfere with light press detection +3. Accessibility settings may need explicit enablement + +**User Workaround:** +Check Settings > Accessibility > Camera Control: +- Ensure Camera Control is enabled +- Ensure Light-Press is turned ON +- Adjust Light-Press Force if needed + +--- + +## Future Enhancements + +Potential areas for expansion: + +- [ ] Real-time filters (beauty, color grading) +- [ ] Gesture-based capture (smile detection) +- [ ] Widget for quick camera access +- [ ] Apple Watch remote trigger +- [ ] Export presets (aspect ratios, watermarks) +- [ ] Social sharing integrations +- [ ] Camera Control button swipe-to-zoom (if Apple makes API public) + +--- + +## Required Permissions + +| Permission | Reason | +|------------|--------| +| Camera | Photo preview and capture | +| Photo Library | Save captured photos | +| Microphone | May be requested by camera framework (not actively used) | +| iCloud | Settings synchronization (optional) | + +--- + +## Development Setup + +### 1. Clone and Configure + +```bash +git clone https://github.com/yourusername/SelfieCam.git +cd SelfieCam +cp SelfieCam/Configuration/Secrets.xcconfig.template SelfieCam/Configuration/Secrets.xcconfig +``` + +### 2. Add API Key + +Edit `Secrets.xcconfig`: +``` +REVENUECAT_API_KEY = appl_your_actual_api_key_here +``` + +### 3. RevenueCat Setup + +1. Create RevenueCat account and project +2. Connect to App Store Connect +3. Create products and entitlement named `pro` +4. Copy Public App-Specific API Key to `Secrets.xcconfig` + +### 4. Test Premium Features + +Set environment variable in scheme: +- **Name:** `ENABLE_DEBUG_PREMIUM` +- **Value:** `1` + +--- + +## Code Quality Standards + +- **No magic numbers**: All values from Design constants +- **Full accessibility**: Every interactive element has VoiceOver support +- **Protocol-first**: Shared behavior defined via protocols +- **Separation of concerns**: Views are dumb, ViewModels contain logic +- **Modern APIs**: Swift 6, async/await, @Observable +- **Documentation**: Code comments, README, PRD + +--- + +*This PRD serves as the primary requirements document for SelfieCam development.* diff --git a/SelfieCam/App/SelfieCamApp.swift b/SelfieCam/App/SelfieCamApp.swift index ed4098c..728fd15 100644 --- a/SelfieCam/App/SelfieCamApp.swift +++ b/SelfieCam/App/SelfieCamApp.swift @@ -25,6 +25,10 @@ struct SelfieCamApp: App { .preferredColorScheme(.dark) } } + .onAppear { + // Set screen brightness to 100% on app launch + UIScreen.main.brightness = 1.0 + } } } } diff --git a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift index 53071d7..e1a69dc 100644 --- a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift +++ b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift @@ -5,7 +5,7 @@ import Bedrock struct ProPaywallView: View { @State private var manager = PremiumManager() @Environment(\.dismiss) private var dismiss - @ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = Design.BaseFontSize.body + @ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = Design.FontSize.body var body: some View { NavigationStack { @@ -13,11 +13,11 @@ struct ProPaywallView: View { VStack(spacing: Design.Spacing.xLarge) { // Crown icon Image(systemName: "crown.fill") - .font(.system(size: Design.BaseFontSize.hero)) + .font(.system(size: Design.FontSize.hero)) .foregroundStyle(.yellow) Text(String(localized: "Go Pro")) - .font(.system(size: Design.BaseFontSize.title, weight: .bold)) + .font(.system(size: Design.FontSize.title, weight: .bold)) // Benefits list VStack(alignment: .leading, spacing: Design.Spacing.medium) { diff --git a/SelfieCam/Features/Settings/Components/ColorPresetButton.swift b/SelfieCam/Features/Settings/Components/ColorPresetButton.swift index 281b4a7..087416f 100644 --- a/SelfieCam/Features/Settings/Components/ColorPresetButton.swift +++ b/SelfieCam/Features/Settings/Components/ColorPresetButton.swift @@ -47,20 +47,20 @@ struct ColorPresetButton: View { .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) Image(systemName: "lock.fill") - .font(.system(size: Design.BaseFontSize.small)) + .font(.system(size: Design.FontSize.small)) .foregroundStyle(.white) } } Text(preset.name) - .font(.system(size: Design.BaseFontSize.xSmall)) + .font(.system(size: Design.FontSize.xSmall)) .foregroundStyle(.white.opacity(isSelected ? 1.0 : (isLocked ? Design.Opacity.medium : Design.Opacity.accent))) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) if preset.isPremium { Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown") - .font(.system(size: Design.BaseFontSize.xxSmall)) + .font(.system(size: Design.FontSize.xxSmall)) .foregroundStyle(isPremiumUnlocked ? AppStatus.warning : AppStatus.warning.opacity(Design.Opacity.medium)) } } diff --git a/SelfieCam/Features/Settings/Components/CustomColorPickerButton.swift b/SelfieCam/Features/Settings/Components/CustomColorPickerButton.swift index 7d27ba6..9b1fe11 100644 --- a/SelfieCam/Features/Settings/Components/CustomColorPickerButton.swift +++ b/SelfieCam/Features/Settings/Components/CustomColorPickerButton.swift @@ -46,13 +46,13 @@ struct CustomColorPickerButton: View { ) Text(String(localized: "Custom")) - .font(.system(size: Design.BaseFontSize.xSmall)) + .font(.system(size: Design.FontSize.xSmall)) .foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent)) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) Image(systemName: "crown.fill") - .font(.system(size: Design.BaseFontSize.xxSmall)) + .font(.system(size: Design.FontSize.xxSmall)) .foregroundStyle(AppStatus.warning) } .padding(Design.Spacing.xSmall) @@ -87,18 +87,18 @@ struct CustomColorPickerButton: View { .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) Image(systemName: "lock.fill") - .font(.system(size: Design.BaseFontSize.small)) + .font(.system(size: Design.FontSize.small)) .foregroundStyle(.white) } Text(String(localized: "Custom")) - .font(.system(size: Design.BaseFontSize.xSmall)) + .font(.system(size: Design.FontSize.xSmall)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) .lineLimit(1) .minimumScaleFactor(Design.MinScaleFactor.tight) Image(systemName: "crown") - .font(.system(size: Design.BaseFontSize.xxSmall)) + .font(.system(size: Design.FontSize.xxSmall)) .foregroundStyle(AppStatus.warning.opacity(Design.Opacity.medium)) } .padding(Design.Spacing.xSmall) diff --git a/SelfieCam/Features/Settings/Views/SettingsView.swift b/SelfieCam/Features/Settings/Views/SettingsView.swift index c382ff0..dcb5ce5 100644 --- a/SelfieCam/Features/Settings/Views/SettingsView.swift +++ b/SelfieCam/Features/Settings/Views/SettingsView.swift @@ -207,11 +207,11 @@ struct SettingsView: View { private var colorPresetSection: some View { VStack(alignment: .leading, spacing: Design.Spacing.small) { Text(String(localized: "Light Color")) - .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) + .font(.system(size: Design.FontSize.medium, weight: .medium)) .foregroundStyle(.white) Text(String(localized: "Choose the color of the ring light around the camera preview")) - .font(.system(size: Design.BaseFontSize.caption)) + .font(.system(size: Design.FontSize.caption)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) LazyVGrid( @@ -299,18 +299,18 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { HStack(spacing: Design.Spacing.xSmall) { Text(String(localized: "Photo Quality")) - .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) + .font(.system(size: Design.FontSize.medium, weight: .medium)) .foregroundStyle(.white) Image(systemName: "crown.fill") - .font(.system(size: Design.BaseFontSize.small)) + .font(.system(size: Design.FontSize.small)) .foregroundStyle(AppStatus.warning) } Text(isPremiumUnlocked ? String(localized: "File size and image quality for saved photos") : String(localized: "Upgrade to unlock High quality")) - .font(.system(size: Design.BaseFontSize.caption)) + .font(.system(size: Design.FontSize.caption)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) // Custom picker with premium indicators @@ -330,10 +330,10 @@ struct SettingsView: View { Text(quality.rawValue.capitalized) if isPremiumOption && !isPremiumUnlocked { Image(systemName: "lock.fill") - .font(.system(size: Design.BaseFontSize.xSmall)) + .font(.system(size: Design.FontSize.xSmall)) } } - .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .font(.system(size: Design.FontSize.body, weight: .medium)) .foregroundStyle(viewModel.photoQuality == quality ? .black : (isDisabled ? .white.opacity(Design.Opacity.light) : .white.opacity(Design.Opacity.strong))) .padding(.vertical, Design.Spacing.small) .frame(maxWidth: .infinity) @@ -390,18 +390,18 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { HStack(spacing: Design.Spacing.xSmall) { Text(String(localized: "Self-Timer")) - .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) + .font(.system(size: Design.FontSize.medium, weight: .medium)) .foregroundStyle(.white) Image(systemName: "crown.fill") - .font(.system(size: Design.BaseFontSize.small)) + .font(.system(size: Design.FontSize.small)) .foregroundStyle(AppStatus.warning) } Text(isPremiumUnlocked ? String(localized: "Delay before photo capture for self-portraits") : String(localized: "Upgrade to unlock 5s and 10s timers")) - .font(.system(size: Design.BaseFontSize.caption)) + .font(.system(size: Design.FontSize.caption)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) // Custom picker with premium indicators @@ -421,10 +421,10 @@ struct SettingsView: View { Text(option.displayName) if isPremiumOption && !isPremiumUnlocked { Image(systemName: "lock.fill") - .font(.system(size: Design.BaseFontSize.xSmall)) + .font(.system(size: Design.FontSize.xSmall)) } } - .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .font(.system(size: Design.FontSize.body, weight: .medium)) .foregroundStyle(viewModel.selectedTimer == option ? .black : (isDisabled ? .white.opacity(Design.Opacity.light) : .white.opacity(Design.Opacity.strong))) .padding(.vertical, Design.Spacing.small) .frame(maxWidth: .infinity) @@ -458,11 +458,11 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { Text(String(localized: "Upgrade to Pro")) - .font(.system(size: Design.BaseFontSize.medium, weight: .semibold)) + .font(.system(size: Design.FontSize.medium, weight: .semibold)) .foregroundStyle(.white) Text(String(localized: "Premium colors, HDR, timers & more")) - .font(.system(size: Design.BaseFontSize.caption)) + .font(.system(size: Design.FontSize.caption)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) } diff --git a/SelfieCam/Shared/Theme/DesignConstants.swift b/SelfieCam/Shared/Theme/DesignConstants.swift index 485e98e..107a9bf 100644 --- a/SelfieCam/Shared/Theme/DesignConstants.swift +++ b/SelfieCam/Shared/Theme/DesignConstants.swift @@ -50,11 +50,16 @@ extension Bedrock.Design { static let flipIconSize: CGFloat = 22 } - /// Font sizes for the app (maps to Bedrock's BaseFontSize for consistency). + /// Font sizes for the app. enum FontSize { - static let small: CGFloat = BaseFontSize.small - static let body: CGFloat = BaseFontSize.body - static let large: CGFloat = BaseFontSize.large - static let title: CGFloat = BaseFontSize.title + static let xxSmall: CGFloat = 8 + static let xSmall: CGFloat = 10 + static let small: CGFloat = 12 + static let caption: CGFloat = 12 + static let medium: CGFloat = 16 + static let body: CGFloat = 16 + static let large: CGFloat = 20 + static let title: CGFloat = 28 + static let hero: CGFloat = 48 } }