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

This commit is contained in:
Matt Bruce 2026-02-01 18:36:53 -06:00
parent dd84ffdfe6
commit 4979bd629a
9 changed files with 656 additions and 1153 deletions

755
AGENTS.md
View File

@ -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 `"<group>"`) and include the full path from project root (e.g., `AppName/Configuration/Base.xcconfig`).
**2. Set `baseConfigurationReference` on project-level Debug/Release configurations:**
```
EA123456 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EACONFIG002 /* Debug.xcconfig */;
buildSettings = { ... };
};
```
**3. Replace hardcoded values with variables:**
```
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(APP_BUNDLE_IDENTIFIER)";
```
#### Step 3: Update Entitlements
Use variable substitution in `.entitlements` files:
```xml
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
```
#### Step 4: Bridge to Swift via Info.plist
Add keys to `Info.plist` that bridge xcconfig values to Swift:
```xml
<key>AppGroupIdentifier</key>
<string>$(APP_GROUP_IDENTIFIER)</string>
<key>CloudKitContainerIdentifier</key>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
<key>AppClipDomain</key>
<string>$(APPCLIP_DOMAIN)</string>
```
#### Step 5: Create Swift Interface
**Why this is needed:** Swift code cannot read xcconfig files directly. The xcconfig values flow through Info.plist, and this Swift file provides a clean API to access them at runtime. Without this file, you'd have to call `Bundle.main.object(forInfoDictionaryKey:)` everywhere you need an identifier.
**When to use:** Any Swift code that needs App Group identifiers, CloudKit containers, custom domains, or other configuration values must use `AppIdentifiers.*` instead of hardcoding strings.
Create `Configuration/AppIdentifiers.swift`:
```swift
import Foundation
enum AppIdentifiers {
// Read from Info.plist (values come from xcconfig)
static let appGroupIdentifier: String = {
Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String
?? "group.com.yourcompany.AppName"
}()
static let cloudKitContainerIdentifier: String = {
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
?? "iCloud.com.yourcompany.AppName"
}()
static let appClipDomain: String = {
Bundle.main.object(forInfoDictionaryKey: "AppClipDomain") as? String
?? "yourapp.example.com"
}()
// Derived from bundle identifier
static var bundleIdentifier: String {
Bundle.main.bundleIdentifier ?? "com.yourcompany.AppName"
}
static var watchBundleIdentifier: String { "\(bundleIdentifier).watchkitapp" }
static var appClipBundleIdentifier: String { "\(bundleIdentifier).Clip" }
static func appClipURL(recordName: String) -> URL? {
URL(string: "https://\(appClipDomain)/appclip?id=\(recordName)")
}
}
```
### Data Flow
```
Base.xcconfig (source of truth)
project.pbxproj (baseConfigurationReference)
Build Settings → Bundle IDs, Team ID, etc.
Info.plist (bridges values via $(VARIABLE))
AppIdentifiers.swift (Swift reads from Bundle.main)
```
### Usage in Code
```swift
// Always use AppIdentifiers instead of hardcoding
FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier
)
CKContainer(identifier: AppIdentifiers.cloudKitContainerIdentifier)
```
### Adding New Targets
When adding new targets (Widgets, Intents, App Clips, etc.), follow this pattern:
#### 1. Add Bundle ID Variable to Base.xcconfig
```
// In Base.xcconfig, add new derived identifier
WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Widget
INTENT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Intent
```
#### 2. Set Target to Use xcconfig
For the new target's Debug/Release configurations in `project.pbxproj`:
```
EA_NEW_TARGET_DEBUG /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EACONFIG002 /* Debug.xcconfig */;
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = "$(WIDGET_BUNDLE_IDENTIFIER)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
// ... other settings
};
};
```
#### 3. Configure Entitlements (if needed)
If the target needs App Groups or CloudKit access, create an entitlements file using variables:
```xml
<!-- WidgetExtension.entitlements -->
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
```
#### 4. Share Code via App Groups
Extensions must use App Groups to share data with the main app:
```swift
// In extension code
let sharedDefaults = UserDefaults(suiteName: AppIdentifiers.appGroupIdentifier)
let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier
)
```
#### 5. Update AppIdentifiers.swift (if needed)
Add new computed properties for target-specific identifiers:
```swift
static var widgetBundleIdentifier: String { "\(bundleIdentifier).Widget" }
static var intentBundleIdentifier: String { "\(bundleIdentifier).Intent" }
```
#### Common Target Types and Bundle ID Patterns
| Target Type | Bundle ID Variable | Example Value |
|-------------|-------------------|---------------|
| Widget Extension | `WIDGET_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).Widget` |
| Intent Extension | `INTENT_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).Intent` |
| App Clip | `APPCLIP_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).Clip` |
| Watch App | `WATCH_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).watchkitapp` |
| Notification Extension | `NOTIFICATION_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).NotificationExtension` |
| Share Extension | `SHARE_BUNDLE_IDENTIFIER` | `$(APP_BUNDLE_IDENTIFIER).ShareExtension` |
#### Checklist for New Targets
- [ ] Add bundle ID variable to `Base.xcconfig`
- [ ] Set `baseConfigurationReference` to Debug/Release xcconfig
- [ ] Use `$(VARIABLE)` for `PRODUCT_BUNDLE_IDENTIFIER`
- [ ] Use `$(DEVELOPMENT_TEAM)` for team
- [ ] Create entitlements with `$(APP_GROUP_IDENTIFIER)` if sharing data
- [ ] Add to `AppIdentifiers.swift` if Swift code needs the identifier
- [ ] Register App ID in Apple Developer Portal (uses same App Group)
### Migration
To migrate to a new developer account, edit **one file** (`Base.xcconfig`):
```
COMPANY_IDENTIFIER = com.newcompany
DEVELOPMENT_TEAM = NEW_TEAM_ID
```
Then clean build (⇧⌘K) and rebuild. Everything updates automatically—including all extension targets.
## Dynamic Type Instructions
- Always support Dynamic Type for accessibility.
- Use `@ScaledMetric` to scale custom dimensions.
- Choose appropriate `relativeTo` text styles based on semantic purpose.
- For constrained UI elements, you may use fixed sizes but document the reason.
- Prefer system text styles: `.font(.body)`, `.font(.title)`, `.font(.caption)`.
## VoiceOver Accessibility Instructions
- All interactive elements must have meaningful `.accessibilityLabel()`.
- Use `.accessibilityValue()` for dynamic state.
- Use `.accessibilityHint()` to describe what happens on interaction.
- Use `.accessibilityAddTraits()` for element type.
- Hide decorative elements with `.accessibilityHidden(true)`.
- Group related elements to reduce navigation complexity.
- Post accessibility announcements for important events.
## Project Structure
- Use a consistent project structure organized by feature.
- Follow strict naming conventions for types, properties, and methods.
- **One public type per file**—break types into separate files.
- Write unit tests for core application logic.
- Only write UI tests if unit tests are not possible.
- Add code comments and documentation as needed.
- Never include secrets or API keys in the repository.
## Documentation Instructions
- **Keep `README.md` files up to date** when adding new functionality.
- In multi-project workspaces, update the relevant project's `README.md`.
- Document new features, settings, or mechanics in the appropriate README.
- Update documentation when modifying existing behavior.
- Include configuration options and special interactions.
- README updates should be part of the same commit as the feature.
## PR Instructions
- If installed, ensure SwiftLint returns no warnings or errors.
- Verify that documentation reflects any new functionality.
- Check for duplicate code before submitting.
- Ensure all new files follow the one-type-per-file rule.
Try and use xcode build mcp if it is working and test using screenshots when asked.

View File

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

611
PRD.md Normal file
View File

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

View File

@ -25,6 +25,10 @@ struct SelfieCamApp: App {
.preferredColorScheme(.dark)
}
}
.onAppear {
// Set screen brightness to 100% on app launch
UIScreen.main.brightness = 1.0
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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