Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
dd84ffdfe6
commit
4979bd629a
755
AGENTS.md
755
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:
|
Try and use xcode build mcp if it is working and test using screenshots when asked.
|
||||||
|
|
||||||
- `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.
|
|
||||||
@ -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
611
PRD.md
Normal 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.*
|
||||||
@ -25,6 +25,10 @@ struct SelfieCamApp: App {
|
|||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Set screen brightness to 100% on app launch
|
||||||
|
UIScreen.main.brightness = 1.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import Bedrock
|
|||||||
struct ProPaywallView: View {
|
struct ProPaywallView: View {
|
||||||
@State private var manager = PremiumManager()
|
@State private var manager = PremiumManager()
|
||||||
@Environment(\.dismiss) private var dismiss
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@ -13,11 +13,11 @@ struct ProPaywallView: View {
|
|||||||
VStack(spacing: Design.Spacing.xLarge) {
|
VStack(spacing: Design.Spacing.xLarge) {
|
||||||
// Crown icon
|
// Crown icon
|
||||||
Image(systemName: "crown.fill")
|
Image(systemName: "crown.fill")
|
||||||
.font(.system(size: Design.BaseFontSize.hero))
|
.font(.system(size: Design.FontSize.hero))
|
||||||
.foregroundStyle(.yellow)
|
.foregroundStyle(.yellow)
|
||||||
|
|
||||||
Text(String(localized: "Go Pro"))
|
Text(String(localized: "Go Pro"))
|
||||||
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
|
.font(.system(size: Design.FontSize.title, weight: .bold))
|
||||||
|
|
||||||
// Benefits list
|
// Benefits list
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
|||||||
@ -47,20 +47,20 @@ struct ColorPresetButton: View {
|
|||||||
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
||||||
|
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
.font(.system(size: Design.BaseFontSize.small))
|
.font(.system(size: Design.FontSize.small))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(preset.name)
|
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)))
|
.foregroundStyle(.white.opacity(isSelected ? 1.0 : (isLocked ? Design.Opacity.medium : Design.Opacity.accent)))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
||||||
|
|
||||||
if preset.isPremium {
|
if preset.isPremium {
|
||||||
Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown")
|
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))
|
.foregroundStyle(isPremiumUnlocked ? AppStatus.warning : AppStatus.warning.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,13 +46,13 @@ struct CustomColorPickerButton: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(String(localized: "Custom"))
|
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))
|
.foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
||||||
|
|
||||||
Image(systemName: "crown.fill")
|
Image(systemName: "crown.fill")
|
||||||
.font(.system(size: Design.BaseFontSize.xxSmall))
|
.font(.system(size: Design.FontSize.xxSmall))
|
||||||
.foregroundStyle(AppStatus.warning)
|
.foregroundStyle(AppStatus.warning)
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.xSmall)
|
.padding(Design.Spacing.xSmall)
|
||||||
@ -87,18 +87,18 @@ struct CustomColorPickerButton: View {
|
|||||||
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
||||||
|
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
.font(.system(size: Design.BaseFontSize.small))
|
.font(.system(size: Design.FontSize.small))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(String(localized: "Custom"))
|
Text(String(localized: "Custom"))
|
||||||
.font(.system(size: Design.BaseFontSize.xSmall))
|
.font(.system(size: Design.FontSize.xSmall))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
||||||
|
|
||||||
Image(systemName: "crown")
|
Image(systemName: "crown")
|
||||||
.font(.system(size: Design.BaseFontSize.xxSmall))
|
.font(.system(size: Design.FontSize.xxSmall))
|
||||||
.foregroundStyle(AppStatus.warning.opacity(Design.Opacity.medium))
|
.foregroundStyle(AppStatus.warning.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.xSmall)
|
.padding(Design.Spacing.xSmall)
|
||||||
|
|||||||
@ -207,11 +207,11 @@ struct SettingsView: View {
|
|||||||
private var colorPresetSection: some View {
|
private var colorPresetSection: some View {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
Text(String(localized: "Light Color"))
|
Text(String(localized: "Light Color"))
|
||||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
.font(.system(size: Design.FontSize.medium, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
Text(String(localized: "Choose the color of the ring light around the camera preview"))
|
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))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
LazyVGrid(
|
LazyVGrid(
|
||||||
@ -299,18 +299,18 @@ struct SettingsView: View {
|
|||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
Text(String(localized: "Photo Quality"))
|
Text(String(localized: "Photo Quality"))
|
||||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
.font(.system(size: Design.FontSize.medium, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
Image(systemName: "crown.fill")
|
Image(systemName: "crown.fill")
|
||||||
.font(.system(size: Design.BaseFontSize.small))
|
.font(.system(size: Design.FontSize.small))
|
||||||
.foregroundStyle(AppStatus.warning)
|
.foregroundStyle(AppStatus.warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(isPremiumUnlocked
|
Text(isPremiumUnlocked
|
||||||
? String(localized: "File size and image quality for saved photos")
|
? String(localized: "File size and image quality for saved photos")
|
||||||
: String(localized: "Upgrade to unlock High quality"))
|
: 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))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
// Custom picker with premium indicators
|
// Custom picker with premium indicators
|
||||||
@ -330,10 +330,10 @@ struct SettingsView: View {
|
|||||||
Text(quality.rawValue.capitalized)
|
Text(quality.rawValue.capitalized)
|
||||||
if isPremiumOption && !isPremiumUnlocked {
|
if isPremiumOption && !isPremiumUnlocked {
|
||||||
Image(systemName: "lock.fill")
|
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)))
|
.foregroundStyle(viewModel.photoQuality == quality ? .black : (isDisabled ? .white.opacity(Design.Opacity.light) : .white.opacity(Design.Opacity.strong)))
|
||||||
.padding(.vertical, Design.Spacing.small)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -390,18 +390,18 @@ struct SettingsView: View {
|
|||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
Text(String(localized: "Self-Timer"))
|
Text(String(localized: "Self-Timer"))
|
||||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
.font(.system(size: Design.FontSize.medium, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
Image(systemName: "crown.fill")
|
Image(systemName: "crown.fill")
|
||||||
.font(.system(size: Design.BaseFontSize.small))
|
.font(.system(size: Design.FontSize.small))
|
||||||
.foregroundStyle(AppStatus.warning)
|
.foregroundStyle(AppStatus.warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(isPremiumUnlocked
|
Text(isPremiumUnlocked
|
||||||
? String(localized: "Delay before photo capture for self-portraits")
|
? String(localized: "Delay before photo capture for self-portraits")
|
||||||
: String(localized: "Upgrade to unlock 5s and 10s timers"))
|
: 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))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
// Custom picker with premium indicators
|
// Custom picker with premium indicators
|
||||||
@ -421,10 +421,10 @@ struct SettingsView: View {
|
|||||||
Text(option.displayName)
|
Text(option.displayName)
|
||||||
if isPremiumOption && !isPremiumUnlocked {
|
if isPremiumOption && !isPremiumUnlocked {
|
||||||
Image(systemName: "lock.fill")
|
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)))
|
.foregroundStyle(viewModel.selectedTimer == option ? .black : (isDisabled ? .white.opacity(Design.Opacity.light) : .white.opacity(Design.Opacity.strong)))
|
||||||
.padding(.vertical, Design.Spacing.small)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -458,11 +458,11 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
Text(String(localized: "Upgrade to Pro"))
|
Text(String(localized: "Upgrade to Pro"))
|
||||||
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold))
|
.font(.system(size: Design.FontSize.medium, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
Text(String(localized: "Premium colors, HDR, timers & more"))
|
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))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -50,11 +50,16 @@ extension Bedrock.Design {
|
|||||||
static let flipIconSize: CGFloat = 22
|
static let flipIconSize: CGFloat = 22
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Font sizes for the app (maps to Bedrock's BaseFontSize for consistency).
|
/// Font sizes for the app.
|
||||||
enum FontSize {
|
enum FontSize {
|
||||||
static let small: CGFloat = BaseFontSize.small
|
static let xxSmall: CGFloat = 8
|
||||||
static let body: CGFloat = BaseFontSize.body
|
static let xSmall: CGFloat = 10
|
||||||
static let large: CGFloat = BaseFontSize.large
|
static let small: CGFloat = 12
|
||||||
static let title: CGFloat = BaseFontSize.title
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user