Initial commit: SelfieRingLight app

Features:
- Camera preview with ring light effect
- Adjustable ring size with slider
- Light color presets (white, warm cream, ice blue, soft pink, warm amber, cool lavender)
- Light intensity control (opacity)
- Front flash (hides preview during capture)
- True mirror mode
- Skin smoothing toggle
- Grid overlay (rule of thirds)
- Self-timer options
- Photo and video capture modes
- iCloud sync for settings across devices

Architecture:
- SwiftUI with @Observable view models
- Protocol-oriented design (RingLightConfigurable, CaptureControlling)
- Bedrock design system integration
- CloudSyncManager for iCloud settings sync
- RevenueCat for premium features
This commit is contained in:
Matt Bruce 2026-01-02 13:01:24 -06:00
commit 74e65829de
34 changed files with 3871 additions and 0 deletions

49
.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
# Xcode
build/
DerivedData/
*.xcuserstate
*.xcscmblueprint
*.xccheckout
xcuserdata/
# Swift Package Manager
.build/
Packages/
Package.pins
Package.resolved
.swiftpm/
# CocoaPods (if used in future)
Pods/
# Carthage (if used in future)
Carthage/Build/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Secrets and API Keys - NEVER commit these
**/Secrets.xcconfig
**/Secrets.swift
*.secret
*.secrets
# Environment files
.env
.env.local
.env.*.local
# Fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
iOSInjectionProject/

499
AGENTS.md Normal file
View File

@ -0,0 +1,499 @@
# Agent guide for Swift and SwiftUI
This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage.
## 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 18.0 or later.
- Swift 6 or later, using modern Swift concurrency.
- SwiftUI backed up by `@Observable` classes for shared data.
- **Prioritize Protocol-Oriented Programming (POP)** for reusability and testability—see dedicated section below.
- Avoid UIKit unless requested.
## Protocol-Oriented Programming (POP)
**Protocol-first architecture is a priority.** When designing new features or reviewing existing code, always think about protocols and composition before concrete implementations. This enables code reuse across modules, easier testing, and cleaner architecture.
### 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 for reuse:
1. **Look for duplicated patterns**: If you see similar logic across modules, extract a protocol to a shared location.
2. **Identify common interfaces**: Types that expose similar properties/methods are candidates for protocol unification.
3. **Check before implementing**: Before writing new code, 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., `Searchable`, `DataLoading`, `ContentProvider`).
- **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.
### Examples
**❌ BAD - Concrete implementations without protocols:**
```swift
// Features/Users/UserListViewModel.swift
@Observable @MainActor
class UserListViewModel {
var items: [User] = []
var isLoading: Bool = false
func load() async { ... }
func refresh() async { ... }
}
// Features/Products/ProductListViewModel.swift - duplicates the same pattern
@Observable @MainActor
class ProductListViewModel {
var items: [Product] = []
var isLoading: Bool = false
func load() async { ... }
func refresh() async { ... }
}
```
**✅ GOOD - Protocol in shared module, adopted by features:**
```swift
// Shared/Protocols/DataLoading.swift
protocol DataLoading: AnyObject {
associatedtype Item: Identifiable
var items: [Item] { get set }
var isLoading: Bool { get set }
func load() async
func refresh() async
}
extension DataLoading {
func refresh() async {
items = []
await load()
}
}
// Features/Users/UserListViewModel.swift - adopts protocol
@Observable @MainActor
class UserListViewModel: DataLoading {
var items: [User] = []
var isLoading: Bool = false
func load() async { ... }
// refresh() comes from protocol extension
}
```
**❌ BAD - View only works with one concrete type:**
```swift
struct ItemListView: View {
@Bindable var viewModel: UserListViewModel
// Tightly coupled to Users
}
```
**✅ GOOD - View works with any DataLoading type:**
```swift
struct ItemListView<ViewModel: DataLoading & Observable>: View {
@Bindable var viewModel: ViewModel
// Reusable across all features
}
```
### Common protocols to consider extracting:
| Capability | Protocol Name | Shared By |
|------------|---------------|-----------|
| Loading data | `DataLoading` | All list features |
| Search/filter | `Searchable` | Features with search |
| Settings/config | `Configurable` | Features with settings |
| Pagination | `Paginating` | Large data sets |
| Form validation | `Validatable` | Input forms |
| Persistence | `Persistable` | Cached data |
### Refactoring checklist:
When you encounter code that could benefit from POP:
- [ ] Is this logic duplicated across multiple features?
- [ ] Could this type conform to an existing protocol in the shared module?
- [ ] Would extracting a protocol make this code testable in isolation?
- [ ] Can views be made generic over a protocol instead of a concrete type?
- [ ] Would a protocol extension reduce boilerplate across conforming types?
### Benefits:
- **Reusability**: Shared protocols work across all 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
## 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, such as using `replacing("hello", with: "world")` with strings rather than `replacingOccurrences(of: "hello", with: "world")`.
- Prefer modern Foundation API, for example `URL.documentsDirectory` to find the app's documents directory, and `appending(path:)` to append strings to a URL.
- Never use C-style number formatting such as `Text(String(format: "%.2f", abs(myNumber)))`; always use `Text(abs(change), format: .number.precision(.fractionLength(2)))` instead.
- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`.
- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency.
- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()`.
- Avoid force unwraps and force `try` unless it is 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 instead.
- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none.
- Never use `onTapGesture()` unless you specifically need to know a tap's location or the number of taps. All other usages should use `Button`.
- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead.
- Never use `UIScreen.main.bounds` to read the size of the available space.
- Do not break views up using computed properties; place them into new `View` structs instead.
- Do not force specific font sizes; prefer using Dynamic Type instead.
- Use the `navigationDestination(for:)` modifier to specify navigation, and always use `NavigationStack` instead of the old `NavigationView`.
- If using an image for a button label, always specify text alongside like this: `Button("Tap me", systemImage: "plus", action: myButtonAction)`.
- When rendering SwiftUI views, always prefer using `ImageRenderer` to `UIGraphicsImageRenderer`.
- Don't apply the `fontWeight()` modifier unless there is good reason. If you want to make some text bold, always use `bold()` instead of `fontWeight(.bold)`.
- Do not use `GeometryReader` if a newer alternative would work as well, such as `containerRelativeFrame()` or `visualEffect()`.
- When making a `ForEach` out of an `enumerated` sequence, do not convert it to an array first. So, prefer `ForEach(x.enumerated(), id: \.element.id)` instead of `ForEach(Array(x.enumerated()), id: \.element.id)`.
- When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer.
- Avoid `AnyView` unless it is absolutely required.
- **Never use raw numeric literals** for padding, spacing, opacity, font sizes, dimensions, corner radii, shadows, or animation durations—always use Design constants (see "No magic numbers" section).
- **Never use inline `Color(red:green:blue:)` or hex colors**—define all colors in a `Color` extension with semantic names.
- Avoid using UIKit colors in SwiftUI code.
## View/State separation (MVVM-lite)
**Views should be "dumb" renderers.** All business logic belongs in dedicated view models or state objects.
### What belongs in the State/ViewModel:
- **Business logic**: Calculations, validations, business rules
- **Computed properties based on data**: recommendations, derived values
- **State checks**: `isLoading`, `canSubmit`, `isFormValid`, `hasUnsavedChanges`
- **Data transformations**: filtering, sorting, aggregations
### What is acceptable in Views:
- **Pure UI layout logic**: `isIPad`, `maxContentWidth` based on size class
- **Visual styling**: color selection based on state (`statusColor`, `errorColor`)
- **@ViewBuilder sub-views**: breaking up complex layouts
- **Accessibility labels**: combining data into accessible descriptions
### Examples
**❌ BAD - Business logic in view:**
```swift
struct MyView: View {
@Bindable var viewModel: FormViewModel
private var isFormValid: Bool {
!viewModel.email.isEmpty && viewModel.email.contains("@")
}
private var formattedPrice: String? {
guard let price = viewModel.price else { return nil }
return viewModel.formatter.string(from: price)
}
}
```
**✅ GOOD - Logic in ViewModel, view just reads:**
```swift
// In ViewModel:
var isFormValid: Bool {
!email.isEmpty && email.contains("@") && password.count >= 8
}
var formattedPrice: String? {
guard let price = price else { return nil }
return formatter.string(from: price)
}
// In View:
Button("Submit", action: submit)
.disabled(!viewModel.isFormValid)
if let price = viewModel.formattedPrice { Text(price) }
```
### Benefits:
- **Testable**: ViewModel logic can be unit tested without UI
- **Single source of truth**: No duplicated logic across views
- **Cleaner views**: Views focus purely on layout and presentation
- **Easier debugging**: Logic is centralized, not scattered
## SwiftData instructions
If SwiftData is configured to use CloudKit:
- Never use `@Attribute(.unique)`.
- Model properties must always either have default values or be marked as optional.
- All relationships must be marked optional.
## Localization instructions
- Use **String Catalogs** (`.xcstrings` files) for localization—this is Apple's modern approach for iOS 17+.
- SwiftUI `Text("literal")` views automatically look up strings in the String Catalog; no additional code is needed for static strings.
- For strings outside of `Text` views or with dynamic content, use `String(localized:)` or create a helper extension:
```swift
extension String {
static func localized(_ key: String) -> String {
String(localized: String.LocalizationValue(key))
}
static func localized(_ key: String, _ arguments: CVarArg...) -> String {
let format = String(localized: String.LocalizationValue(key))
return String(format: format, arguments: arguments)
}
}
```
- For format strings with interpolation (e.g., "Items: %@"), define a key in the String Catalog and use `String.localized("key", value)`.
- Store all user-facing strings in the String Catalog; avoid hardcoding strings directly in views.
- Never use `NSLocalizedString`; prefer the modern `String(localized:)` API.
## No magic numbers or hardcoded values
**Never use raw numeric literals or hardcoded colors directly in views.** All values must be extracted to named constants, enums, or variables. This applies to:
### Values that MUST be constants:
- **Spacing & Padding**: `.padding(Design.Spacing.medium)` not `.padding(12)`
- **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16`
- **Font Sizes**: `Design.FontSize.body` not `size: 14`
- **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)`
- **Colors**: `Color.Primary.accent` not `Color(red: 0.8, green: 0.6, blue: 0.2)`
- **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.iconMedium` not `frame(width: 32)`
### What to do when you see a magic number:
1. Check if an appropriate constant already exists in your design constants file
2. If not, add a new constant with a semantic name
3. Use the constant in place of the raw value
4. If it's truly view-specific and used only once, extract to a `private let` at the top of the view struct
### Examples of violations:
```swift
// ❌ BAD - Magic numbers everywhere
.padding(16)
.opacity(0.6)
.frame(width: 80, height: 52)
.shadow(radius: 10, y: 5)
Color(red: 0.25, green: 0.3, blue: 0.45)
// ✅ GOOD - Named constants
.padding(Design.Spacing.large)
.opacity(Design.Opacity.accent)
.frame(width: Design.Size.cardWidth, height: Design.Size.cardHeight)
.shadow(radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
Color.Primary.background
```
## Design constants instructions
- Create a centralized design constants file (e.g., `DesignConstants.swift`) using enums for namespacing:
```swift
enum Design {
enum Spacing {
static let xxSmall: CGFloat = 2
static let xSmall: CGFloat = 4
static let small: CGFloat = 8
static let medium: CGFloat = 12
static let large: CGFloat = 16
static let xLarge: CGFloat = 20
}
enum CornerRadius {
static let small: CGFloat = 8
static let medium: CGFloat = 12
static let large: CGFloat = 16
}
enum FontSize {
static let small: CGFloat = 10
static let body: CGFloat = 14
static let large: CGFloat = 18
static let title: CGFloat = 24
}
enum Opacity {
static let subtle: Double = 0.1
static let hint: Double = 0.2
static let light: Double = 0.3
static let medium: Double = 0.5
static let accent: Double = 0.6
static let strong: Double = 0.7
static let heavy: Double = 0.8
static let almostFull: Double = 0.9
}
enum LineWidth {
static let thin: CGFloat = 1
static let medium: CGFloat = 2
static let thick: CGFloat = 3
}
enum Shadow {
static let radiusSmall: CGFloat = 2
static let radiusMedium: CGFloat = 6
static let radiusLarge: CGFloat = 10
static let offsetSmall: CGFloat = 1
static let offsetMedium: CGFloat = 3
}
enum Animation {
static let quick: Double = 0.3
static let springDuration: Double = 0.4
static let staggerDelay1: Double = 0.1
static let staggerDelay2: Double = 0.25
}
}
```
- For colors used across the app, extend `Color` with semantic color definitions:
```swift
extension Color {
enum Primary {
static let background = Color(red: 0.1, green: 0.2, blue: 0.3)
static let accent = Color(red: 0.8, green: 0.6, blue: 0.2)
}
enum Button {
static let primaryLight = Color(red: 1.0, green: 0.85, blue: 0.3)
static let primaryDark = Color(red: 0.9, green: 0.7, blue: 0.2)
}
}
```
- Within each view, extract view-specific magic numbers to private constants at the top of the struct with a comment explaining why they're local:
```swift
struct MyView: View {
// Layout: fixed dimensions for consistent appearance
private let thumbnailSize: CGFloat = 45
// Typography: constrained space requires fixed size
private let headerFontSize: CGFloat = 18
// ...
}
```
- Reference design constants in views: `Design.Spacing.medium`, `Design.CornerRadius.large`, `Color.Primary.accent`.
- Keep design constants organized by category: Spacing, CornerRadius, FontSize, IconSize, Size, Animation, Opacity, LineWidth, Shadow.
- When adding new features, check existing constants first before creating new ones.
- Name constants semantically (what they represent) not literally (their value): `accent` not `pointSix`, `large` not `sixteen`.
## Dynamic Type instructions
- Always support Dynamic Type for accessibility; never use fixed font sizes without scaling.
- Use `@ScaledMetric` to scale custom font sizes and dimensions based on user accessibility settings:
```swift
struct MyView: View {
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = 14
@ScaledMetric(relativeTo: .title) private var titleFontSize: CGFloat = 24
@ScaledMetric(relativeTo: .caption) private var captionSize: CGFloat = 11
var body: some View {
Text("Hello")
.font(.system(size: bodyFontSize, weight: .medium))
}
}
```
- Choose the appropriate `relativeTo` text style based on the semantic purpose:
- `.largeTitle`, `.title`, `.title2`, `.title3` for headings
- `.headline`, `.subheadline` for emphasized content
- `.body` for main content
- `.callout`, `.footnote`, `.caption`, `.caption2` for smaller text
- For constrained UI elements (icons, badges, compact layouts) where overflow would break the design, you may use fixed sizes but document the reason:
```swift
// Fixed size: badge has strict space constraints
private let badgeFontSize: CGFloat = 11
```
- Prefer system text styles when possible: `.font(.body)`, `.font(.title)`, `.font(.caption)`.
- Test with accessibility settings: Settings > Accessibility > Display & Text Size > Larger Text.
## VoiceOver accessibility instructions
- All interactive elements (buttons, selectable items) must have meaningful `.accessibilityLabel()`.
- Use `.accessibilityValue()` to communicate dynamic state (e.g., current selection, count, progress).
- Use `.accessibilityHint()` to describe what will happen when interacting with an element:
```swift
Button("Submit", action: submit)
.accessibilityHint("Submits the form and creates your account")
```
- Use `.accessibilityAddTraits()` to communicate element type:
- `.isButton` for tappable elements that aren't SwiftUI Buttons
- `.isHeader` for section headers
- `.isModal` for modal overlays
- `.updatesFrequently` for live-updating content
- Hide purely decorative elements from VoiceOver:
```swift
DecorationView()
.accessibilityHidden(true) // Decorative element
```
- Group related elements to reduce VoiceOver navigation complexity:
```swift
VStack {
titleLabel
subtitleLabel
statusIndicator
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Item details")
.accessibilityValue("Title: \(title). Status: \(status)")
```
- For complex elements, use `.accessibilityElement(children: .contain)` to allow navigation to children while adding context.
- Post accessibility announcements for important events:
```swift
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
UIAccessibility.post(notification: .announcement, argument: "Upload complete!")
}
```
- Provide accessibility names for model types that appear in UI:
```swift
enum Status {
var accessibilityName: String {
switch self {
case .pending: return String(localized: "Pending")
case .complete: return String(localized: "Complete")
// ...
}
}
}
```
- Test with VoiceOver enabled: Settings > Accessibility > VoiceOver.
## Project structure
- Use a consistent project structure, with folder layout determined by app features.
- Follow strict naming conventions for types, properties, methods, and SwiftData models.
- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file.
- Write unit tests for core application logic.
- Only write UI tests if unit tests are not possible.
- Add code comments and documentation comments as needed.
- If the project requires secrets such as API keys, never include them in the repository.
## Documentation instructions
- **Always keep documentation up to date** when adding new functionality or making changes that users or developers need to know about.
- Document new features, settings, or behaviors in the appropriate documentation files.
- Update documentation when modifying existing behavior.
- Include any configuration options, keyboard shortcuts, or special interactions.
- Documentation updates should be part of the same commit as the feature/change they document.
## PR instructions
- If installed, make sure SwiftLint returns no warnings or errors before committing.
- Verify that documentation reflects any new functionality or behavioral changes.

68
AI_Implementation.md Normal file
View File

@ -0,0 +1,68 @@
# AI_Implementation.md
## How This App Was Architected & Built
This project was developed following strict senior-level iOS engineering standards, with guidance from an AI assistant (Grok) acting as a Senior iOS Engineer 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
- **No third-party dependencies**: Pure Apple frameworks only (SwiftUI, AVFoundation, StoreKit 2, CoreImage)
- **No magic numbers**: All dimensions, opacities, durations from centralized `Design` constants
- **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 SPM package extraction
### Architecture Overview
Shared/
├── DesignConstants.swift → Semantic design tokens (spacing, radii, sizes, etc.)
├── Color+Extensions.swift → Ring light color presets
├── Protocols/
│ ├── RingLightConfigurable.swift → Border, color, brightness, mirror, smoothing
│ ├── CaptureControlling.swift → Timer, grid, zoom, capture mode
│ └── PremiumManaging.swift → Subscription state & purchase handling
└── Premium/
└── PremiumManager.swift → Native StoreKit 2 implementation
Features/
├── Camera/ → Main UI, preview, capture logic
├── Settings/ → Configuration screens
└── Paywall/ → Pro subscription flow
### Key Implementation Decisions
1. **Ring Light Effect**
- Achieved by coloring the safe area background and leaving a centered rectangular window for camera preview
- Border width controlled via user setting
- Gradient support added for directional "portrait lighting"
2. **Camera System**
- `AVCaptureSession` with front camera default
- `UIViewRepresentable` wrapper for preview with pinch-to-zoom
- Video data output delegate for future real-time filters (skin smoothing placeholder)
3. **Capture Enhancements**
- Timer with async countdown and accessibility announcements
- Volume button observation via KVO on `AVAudioSession.outputVolume`
- Flash burst: temporarily sets brightness to 1.0 on capture
4. **Freemium Model**
- Built with pure StoreKit 2 (no RevenueCat)
- `PremiumManaging` protocol enables easy testing/mocking
- Clean paywall with benefit list and native purchase flow
5. **Reusability Focus**
- All shared logic extracted to protocols
- Ready for future extraction into SPM packages:
- `SelfieCameraKit`
- `SelfieRingLightKit`
- `SelfiePremiumKit`
### Development Process
- Iterative feature additions guided by competitive analysis of top App Store selfie apps
- Each new capability (timer, boomerang, gradient, subscriptions) added with protocol-first design
- Strict adherence to no magic numbers, full accessibility, and clean separation
- Final structure optimized for maintainability and future library extraction
This app demonstrates production-quality SwiftUI architecture while delivering a delightful, competitive user experience.

139
README.md Normal file
View File

@ -0,0 +1,139 @@
# SelfieRingLight
A modern, professional-grade selfie camera app for iOS that simulates a high-quality ring light using the device's screen. Built entirely with SwiftUI, Swift 6, and AVFoundation.
Perfect for low-light selfies, video calls, makeup checks, or professional portrait lighting on the go.
## Features
### Core Camera & Lighting
- Full-screen front-camera preview with true mirror option
- Configurable **screen-based ring light** with adjustable border thickness
- Multiple color temperature presets (Pure White, Warm Cream, Ice Blue, Rose Pink, etc.)
- **Directional gradient lighting** for flattering portrait effects
- Real-time screen brightness control (overrides system brightness while in use)
- Flash burst on capture for extra fill light
### Capture Modes
- Photo capture (saved to Photo Library)
- Video recording
- **Boomerang** mode (3-second looping short video)
- 3-second and 10-second self-timer with countdown overlay and VoiceOver announcements
- Pinch-to-zoom gesture
- Volume button shutter support (photo or video start/stop)
- Rule-of-thirds grid overlay (toggleable)
### Premium Features (Freemium Model)
- All advanced color presets + custom colors
- Gradient and directional lighting
- Advanced beauty filters (coming soon)
- Unlimited boomerang length
- No watermarks
- Ad-free experience
### Accessibility & Polish
- Full VoiceOver support with meaningful labels, hints, and announcements
- Dynamic Type and ScaledMetric for readable text
- String Catalog localization ready (`.xcstrings`)
- Prevents screen dimming during use
- Restores original brightness on background/app close
## Screenshots
*(Add App Store-ready screenshots here once built)*
## Requirements
- iOS 18.0 or later
- Xcode 16+
- Swift 6 language mode
## Setup
### 1. Clone the Repository
```bash
git clone https://github.com/yourusername/SelfieRingLight.git
cd SelfieRingLight
```
### 2. Configure API Keys
This project uses `.xcconfig` files to securely manage API keys. **Never commit your actual API keys to version control.**
1. Copy the template file:
```bash
cp SelfieRingLight/Configuration/Secrets.xcconfig.template SelfieRingLight/Configuration/Secrets.xcconfig
```
2. Edit `Secrets.xcconfig` with your actual API key:
```
REVENUECAT_API_KEY = appl_your_actual_api_key_here
```
3. The `Secrets.xcconfig` file is gitignored and will never be committed.
### 3. RevenueCat Setup
1. Create an account at [RevenueCat](https://www.revenuecat.com)
2. Create a new project and connect it to App Store Connect
3. Create products in App Store Connect (e.g., `com.yourapp.pro.monthly`, `com.yourapp.pro.yearly`)
4. Configure the products in RevenueCat dashboard
5. Create an entitlement named `pro`
6. Create an offering with your subscription packages
7. Copy your **Public App-Specific API Key** to `Secrets.xcconfig`
### 4. Debug Premium Mode
To test premium features without a real subscription during development:
1. Edit Scheme (⌘⇧<) → Run → Arguments
2. Add Environment Variable:
- **Name:** `ENABLE_DEBUG_PREMIUM`
- **Value:** `1`
This unlocks all premium features in DEBUG builds only.
### 5. CI/CD Configuration
For automated builds, set the `REVENUECAT_API_KEY` environment variable in your CI/CD system:
**GitHub Actions:**
```yaml
env:
REVENUECAT_API_KEY: ${{ secrets.REVENUECAT_API_KEY }}
```
**Xcode Cloud:**
Add `REVENUECAT_API_KEY` as a secret in your Xcode Cloud workflow.
## Privacy
- Camera access required for preview and capture
- Photo Library access required to save photos/videos
- Microphone access required for video recording
- No data collection, no analytics, no tracking
## Monetization
Freemium model with optional "Pro" subscription:
- Free: Basic ring light, standard colors, photo/video, timer, zoom
- Pro: Full color palette, gradients, advanced features, future updates
Implemented with RevenueCat for reliable subscription management.
## Project Structure
```
SelfieRingLight/
├── App/ # App entry point
├── Features/
│ ├── Camera/ # Camera preview, capture, view model
│ ├── Paywall/ # Pro subscription flow
│ └── Settings/ # Configuration screens
├── Shared/
│ ├── Configuration/ # xcconfig files (API keys)
│ ├── Premium/ # PremiumManager (RevenueCat)
│ ├── Protocols/ # Shared protocols
│ ├── Color+Extensions.swift # Ring light color presets
│ └── DesignConstants.swift # Design tokens
└── Resources/ # Assets, localization
```
## License
*(Add your license here)*

View File

@ -0,0 +1,662 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C852F08306200DC03E1 /* RevenueCat */; };
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C872F08306200DC03E1 /* RevenueCatUI */; };
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F012F08500000DC03E1 /* Bedrock */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
EA766C3A2F082A8500DC03E1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EA766C242F082A8400DC03E1 /* Project object */;
proxyType = 1;
remoteGlobalIDString = EA766C2B2F082A8400DC03E1;
remoteInfo = SelfieRingLight;
};
EA766C442F082A8500DC03E1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EA766C242F082A8400DC03E1 /* Project object */;
proxyType = 1;
remoteGlobalIDString = EA766C2B2F082A8400DC03E1;
remoteInfo = SelfieRingLight;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SelfieRingLight.app; sourceTree = BUILT_PRODUCTS_DIR; };
EA766C392F082A8500DC03E1 /* SelfieRingLightTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieRingLightTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA766C432F082A8500DC03E1 /* SelfieRingLightUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieRingLightUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA766C902F08400000DC03E1 /* SelfieRingLight/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SelfieRingLight/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
EA766C912F08400000DC03E1 /* SelfieRingLight/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SelfieRingLight/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EA766C2E2F082A8400DC03E1 /* SelfieRingLight */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SelfieRingLight;
sourceTree = "<group>";
};
EA766C3C2F082A8500DC03E1 /* SelfieRingLightTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SelfieRingLightTests;
sourceTree = "<group>";
};
EA766C462F082A8500DC03E1 /* SelfieRingLightUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SelfieRingLightUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
EA766C292F082A8400DC03E1 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */,
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */,
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
EA766C362F082A8500DC03E1 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA766C402F082A8500DC03E1 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
EA766C232F082A8400DC03E1 = {
isa = PBXGroup;
children = (
EA766C2E2F082A8400DC03E1 /* SelfieRingLight */,
EA766C3C2F082A8500DC03E1 /* SelfieRingLightTests */,
EA766C462F082A8500DC03E1 /* SelfieRingLightUITests */,
EA766C2D2F082A8400DC03E1 /* Products */,
EA766F342F0843F500DC03E1 /* Recovered References */,
);
sourceTree = "<group>";
};
EA766C2D2F082A8400DC03E1 /* Products */ = {
isa = PBXGroup;
children = (
EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */,
EA766C392F082A8500DC03E1 /* SelfieRingLightTests.xctest */,
EA766C432F082A8500DC03E1 /* SelfieRingLightUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
EA766F342F0843F500DC03E1 /* Recovered References */ = {
isa = PBXGroup;
children = (
EA766C902F08400000DC03E1 /* SelfieRingLight/Configuration/Debug.xcconfig */,
EA766C912F08400000DC03E1 /* SelfieRingLight/Configuration/Release.xcconfig */,
);
name = "Recovered References";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
EA766C2B2F082A8400DC03E1 /* SelfieRingLight */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA766C4D2F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLight" */;
buildPhases = (
EA766C282F082A8400DC03E1 /* Sources */,
EA766C292F082A8400DC03E1 /* Frameworks */,
EA766C2A2F082A8400DC03E1 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
EA766C2E2F082A8400DC03E1 /* SelfieRingLight */,
);
name = SelfieRingLight;
packageProductDependencies = (
EA766C852F08306200DC03E1 /* RevenueCat */,
EA766C872F08306200DC03E1 /* RevenueCatUI */,
EA766F012F08500000DC03E1 /* Bedrock */,
);
productName = SelfieRingLight;
productReference = EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */;
productType = "com.apple.product-type.application";
};
EA766C382F082A8500DC03E1 /* SelfieRingLightTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA766C502F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLightTests" */;
buildPhases = (
EA766C352F082A8500DC03E1 /* Sources */,
EA766C362F082A8500DC03E1 /* Frameworks */,
EA766C372F082A8500DC03E1 /* Resources */,
);
buildRules = (
);
dependencies = (
EA766C3B2F082A8500DC03E1 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EA766C3C2F082A8500DC03E1 /* SelfieRingLightTests */,
);
name = SelfieRingLightTests;
packageProductDependencies = (
);
productName = SelfieRingLightTests;
productReference = EA766C392F082A8500DC03E1 /* SelfieRingLightTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
EA766C422F082A8500DC03E1 /* SelfieRingLightUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA766C532F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLightUITests" */;
buildPhases = (
EA766C3F2F082A8500DC03E1 /* Sources */,
EA766C402F082A8500DC03E1 /* Frameworks */,
EA766C412F082A8500DC03E1 /* Resources */,
);
buildRules = (
);
dependencies = (
EA766C452F082A8500DC03E1 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EA766C462F082A8500DC03E1 /* SelfieRingLightUITests */,
);
name = SelfieRingLightUITests;
packageProductDependencies = (
);
productName = SelfieRingLightUITests;
productReference = EA766C432F082A8500DC03E1 /* SelfieRingLightUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
EA766C242F082A8400DC03E1 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
EA766C2B2F082A8400DC03E1 = {
CreatedOnToolsVersion = 26.0;
};
EA766C382F082A8500DC03E1 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = EA766C2B2F082A8400DC03E1;
};
EA766C422F082A8500DC03E1 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = EA766C2B2F082A8400DC03E1;
};
};
};
buildConfigurationList = EA766C272F082A8400DC03E1 /* Build configuration list for PBXProject "SelfieRingLight" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = EA766C232F082A8400DC03E1;
minimizedProjectReferenceProxies = 1;
packageReferences = (
EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */,
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = EA766C2D2F082A8400DC03E1 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
EA766C2B2F082A8400DC03E1 /* SelfieRingLight */,
EA766C382F082A8500DC03E1 /* SelfieRingLightTests */,
EA766C422F082A8500DC03E1 /* SelfieRingLightUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
EA766C2A2F082A8400DC03E1 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA766C372F082A8500DC03E1 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA766C412F082A8500DC03E1 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
EA766C282F082A8400DC03E1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA766C352F082A8500DC03E1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA766C3F2F082A8500DC03E1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
EA766C3B2F082A8500DC03E1 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EA766C2B2F082A8400DC03E1 /* SelfieRingLight */;
targetProxy = EA766C3A2F082A8500DC03E1 /* PBXContainerItemProxy */;
};
EA766C452F082A8500DC03E1 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EA766C2B2F082A8400DC03E1 /* SelfieRingLight */;
targetProxy = EA766C442F082A8500DC03E1 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
EA766C4B2F082A8500DC03E1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
EA766C4C2F082A8500DC03E1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
EA766C4E2F082A8500DC03E1 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EA766C902F08400000DC03E1 /* SelfieRingLight/Configuration/Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = SelfieRingLight/SelfieRingLight.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieRingLight needs camera access to show your selfie preview and capture photos and videos.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieRingLight needs microphone access to record audio with your videos.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieRingLight needs photo library access to save your captured photos and videos.";
INFOPLIST_KEY_RevenueCatAPIKey = "$(REVENUECAT_API_KEY)";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLight;
PRODUCT_NAME = "$(TARGET_NAME)";
REVENUECAT_API_KEY = "$(REVENUECAT_API_KEY)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
EA766C4F2F082A8500DC03E1 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EA766C912F08400000DC03E1 /* SelfieRingLight/Configuration/Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = SelfieRingLight/SelfieRingLight.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieRingLight needs camera access to show your selfie preview and capture photos and videos.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieRingLight needs microphone access to record audio with your videos.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieRingLight needs photo library access to save your captured photos and videos.";
INFOPLIST_KEY_RevenueCatAPIKey = "$(REVENUECAT_API_KEY)";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLight;
PRODUCT_NAME = "$(TARGET_NAME)";
REVENUECAT_API_KEY = "$(REVENUECAT_API_KEY)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
EA766C512F082A8500DC03E1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLightTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SelfieRingLight.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SelfieRingLight";
};
name = Debug;
};
EA766C522F082A8500DC03E1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLightTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SelfieRingLight.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SelfieRingLight";
};
name = Release;
};
EA766C542F082A8500DC03E1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLightUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = SelfieRingLight;
};
name = Debug;
};
EA766C552F082A8500DC03E1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLightUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = SelfieRingLight;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
EA766C272F082A8400DC03E1 /* Build configuration list for PBXProject "SelfieRingLight" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA766C4B2F082A8500DC03E1 /* Debug */,
EA766C4C2F082A8500DC03E1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA766C4D2F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLight" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA766C4E2F082A8500DC03E1 /* Debug */,
EA766C4F2F082A8500DC03E1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA766C502F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLightTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA766C512F082A8500DC03E1 /* Debug */,
EA766C522F082A8500DC03E1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA766C532F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLightUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA766C542F082A8500DC03E1 /* Debug */,
EA766C552F082A8500DC03E1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/RevenueCat/purchases-ios-spm";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.52.1;
};
};
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git";
requirement = {
branch = develop;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
EA766C852F08306200DC03E1 /* RevenueCat */ = {
isa = XCSwiftPackageProductDependency;
package = EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */;
productName = RevenueCat;
};
EA766C872F08306200DC03E1 /* RevenueCatUI */ = {
isa = XCSwiftPackageProductDependency;
package = EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */;
productName = RevenueCatUI;
};
EA766F012F08500000DC03E1 /* Bedrock */ = {
isa = XCSwiftPackageProductDependency;
package = EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */;
productName = Bedrock;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = EA766C242F082A8400DC03E1 /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA766C2B2F082A8400DC03E1"
BuildableName = "SelfieRingLight.app"
BlueprintName = "SelfieRingLight"
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA766C382F082A8500DC03E1"
BuildableName = "SelfieRingLightTests.xctest"
BlueprintName = "SelfieRingLightTests"
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA766C422F082A8500DC03E1"
BuildableName = "SelfieRingLightUITests.xctest"
BlueprintName = "SelfieRingLightUITests"
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA766C2B2F082A8400DC03E1"
BuildableName = "SelfieRingLight.app"
BlueprintName = "SelfieRingLight"
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "ENABLE_DEBUG_PREMIUM"
value = "true"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA766C2B2F082A8400DC03E1"
BuildableName = "SelfieRingLight.app"
BlueprintName = "SelfieRingLight"
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,17 @@
//
// SelfieRingLightApp.swift
// SelfieRingLight
//
// Created by Matt Bruce on 1/2/26.
//
import SwiftUI
@main
struct SelfieRingLightApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@ -0,0 +1,11 @@
// Debug.xcconfig
// Configuration for Debug builds
#include? "Secrets.xcconfig"
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
// CI/CD should set these via environment variables
REVENUECAT_API_KEY = $(REVENUECAT_API_KEY)
// Expose the API key to Info.plist
REVENUECAT_API_KEY_PLIST = $(REVENUECAT_API_KEY)

View File

@ -0,0 +1,11 @@
// Release.xcconfig
// Configuration for Release builds
#include? "Secrets.xcconfig"
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
// CI/CD should set these via environment variables
REVENUECAT_API_KEY = $(REVENUECAT_API_KEY)
// Expose the API key to Info.plist
REVENUECAT_API_KEY_PLIST = $(REVENUECAT_API_KEY)

View File

@ -0,0 +1,12 @@
// Secrets.xcconfig.template
//
// INSTRUCTIONS:
// 1. Copy this file to "Secrets.xcconfig" in the same directory
// 2. Replace the placeholder values with your actual API keys
// 3. NEVER commit Secrets.xcconfig to version control
//
// The actual Secrets.xcconfig file is gitignored for security.
// RevenueCat API Key
// Get this from: RevenueCat Dashboard > Project Settings > API Keys > Public App-Specific API Key
REVENUECAT_API_KEY = your_revenuecat_public_api_key_here

View File

@ -0,0 +1,135 @@
import SwiftUI
import UIKit
import AVFoundation
struct CameraPreview: UIViewRepresentable {
let viewModel: CameraViewModel
// These properties trigger view updates when they change
var isMirrorFlipped: Bool
var zoomFactor: Double
init(viewModel: CameraViewModel, isMirrorFlipped: Bool, zoomFactor: Double) {
self.viewModel = viewModel
self.isMirrorFlipped = isMirrorFlipped
self.zoomFactor = zoomFactor
}
func makeUIView(context: Context) -> CameraPreviewUIView {
let view = CameraPreviewUIView(viewModel: viewModel)
view.contentMode = .scaleAspectFill
view.clipsToBounds = true
// Add pinch-to-zoom gesture
let pinch = UIPinchGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePinch(_:)))
view.addGestureRecognizer(pinch)
return view
}
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {
// Force layout update
uiView.setNeedsLayout()
uiView.layoutIfNeeded()
// Apply mirror transform based on settings
CATransaction.begin()
CATransaction.setDisableActions(true)
if isMirrorFlipped {
uiView.previewLayer?.transform = CATransform3DMakeScale(-1, 1, 1)
} else {
uiView.previewLayer?.transform = CATransform3DIdentity
}
CATransaction.commit()
// Apply zoom if changed
context.coordinator.applyZoom(zoomFactor)
}
func makeCoordinator() -> Coordinator {
Coordinator(viewModel: viewModel)
}
class Coordinator: NSObject {
let viewModel: CameraViewModel
private var lastAppliedZoom: Double = 1.0
init(viewModel: CameraViewModel) {
self.viewModel = viewModel
}
@MainActor
@objc func handlePinch(_ gesture: UIPinchGestureRecognizer) {
guard gesture.state == .changed else { return }
let newZoom = max(1.0, min(5.0, viewModel.settings.currentZoomFactor * gesture.scale))
viewModel.settings.currentZoomFactor = newZoom
gesture.scale = 1.0
applyZoom(newZoom)
}
func applyZoom(_ zoom: Double) {
guard zoom != lastAppliedZoom else { return }
lastAppliedZoom = zoom
if let device = viewModel.captureSession?.inputs.first.flatMap({ ($0 as? AVCaptureDeviceInput)?.device }) {
do {
try device.lockForConfiguration()
device.videoZoomFactor = max(1.0, min(zoom, device.activeFormat.videoMaxZoomFactor))
device.unlockForConfiguration()
} catch {
print("Error setting zoom: \(error)")
}
}
}
}
}
// MARK: - UIView subclass for camera preview
class CameraPreviewUIView: UIView {
private weak var viewModel: CameraViewModel?
var previewLayer: AVCaptureVideoPreviewLayer?
override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
}
init(viewModel: CameraViewModel) {
self.viewModel = viewModel
super.init(frame: .zero)
backgroundColor = .black
autoresizingMask = [.flexibleWidth, .flexibleHeight]
setupPreviewLayer()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupPreviewLayer() {
guard let viewModel = viewModel,
let session = viewModel.captureSession else { return }
if let layer = self.layer as? AVCaptureVideoPreviewLayer {
layer.session = session
layer.videoGravity = .resizeAspectFill
previewLayer = layer
viewModel.previewLayer = layer
}
}
override func layoutSubviews() {
super.layoutSubviews()
// Ensure the preview layer fills the entire view bounds
previewLayer?.frame = bounds
// Setup layer if not already done (can happen if session was nil at init)
if previewLayer == nil {
setupPreviewLayer()
}
}
}

View File

@ -0,0 +1,201 @@
import AVFoundation
import SwiftUI
import Photos
import CoreImage
import UIKit
import Bedrock
@MainActor
@Observable
class CameraViewModel: NSObject {
var isCameraAuthorized = false
var isPhotoLibraryAuthorized = false
var captureSession: AVCaptureSession?
var photoOutput: AVCapturePhotoOutput?
var videoOutput: AVCaptureMovieFileOutput?
var videoDataOutput: AVCaptureVideoDataOutput?
var previewLayer: AVCaptureVideoPreviewLayer?
var isUsingFrontCamera = true
var isRecording = false
var originalBrightness: CGFloat = 0.5
var ciContext = CIContext()
/// Whether the preview should be hidden (for front flash effect)
var isPreviewHidden = false
let settings = SettingsViewModel() // Shared config
// MARK: - Screen Brightness Handling
/// Gets the current screen from any available window scene
private var currentScreen: UIScreen? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.screen
}
private func saveCurrentBrightness() {
if let screen = currentScreen {
originalBrightness = screen.brightness
}
}
private func setBrightness(_ value: CGFloat) {
currentScreen?.brightness = value
}
func setupCamera() async {
isCameraAuthorized = await AVCaptureDevice.requestAccess(for: .video)
isPhotoLibraryAuthorized = await PHPhotoLibrary.requestAuthorization(for: .addOnly) == .authorized
guard isCameraAuthorized else { return }
captureSession = AVCaptureSession()
guard let session = captureSession else { return }
session.beginConfiguration()
session.sessionPreset = .high
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: isUsingFrontCamera ? .front : .back)
guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return }
if session.canAddInput(input) {
session.addInput(input)
}
photoOutput = AVCapturePhotoOutput()
if let photoOutput, session.canAddOutput(photoOutput) {
session.addOutput(photoOutput)
}
videoOutput = AVCaptureMovieFileOutput()
if let videoOutput, session.canAddOutput(videoOutput) {
session.addOutput(videoOutput)
}
videoDataOutput = AVCaptureVideoDataOutput()
videoDataOutput?.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
if let videoDataOutput, session.canAddOutput(videoDataOutput) {
session.addOutput(videoDataOutput)
}
session.commitConfiguration()
session.startRunning()
UIApplication.shared.isIdleTimerDisabled = true
saveCurrentBrightness()
// Set screen to full brightness for best ring light effect
setBrightness(1.0)
}
func switchCamera() {
guard let session = captureSession else { return }
session.beginConfiguration()
session.inputs.forEach { session.removeInput($0) }
isUsingFrontCamera.toggle()
let position: AVCaptureDevice.Position = isUsingFrontCamera ? .front : .back
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position)
guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return }
if session.canAddInput(input) {
session.addInput(input)
}
session.commitConfiguration()
}
func capturePhoto() {
// If front flash is enabled, hide the preview to show the ring light
if settings.isFrontFlashEnabled {
performFrontFlashCapture()
} else {
let captureSettings = AVCapturePhotoSettings()
photoOutput?.capturePhoto(with: captureSettings, delegate: self)
}
}
/// Performs photo capture with front flash effect
private func performFrontFlashCapture() {
isPreviewHidden = true
// Brief delay to show the full ring light before capturing
Task {
try? await Task.sleep(for: .milliseconds(150))
let captureSettings = AVCapturePhotoSettings()
photoOutput?.capturePhoto(with: captureSettings, delegate: self)
// Restore preview after capture completes
try? await Task.sleep(for: .milliseconds(200))
isPreviewHidden = false
}
}
func startRecording() {
guard let videoOutput = videoOutput, !isRecording else { return }
let url = FileManager.default.temporaryDirectory.appendingPathComponent("video.mov")
videoOutput.startRecording(to: url, recordingDelegate: self)
isRecording = true
}
func stopRecording() {
guard let videoOutput = videoOutput, isRecording else { return }
videoOutput.stopRecording()
isRecording = false
}
func restoreBrightness() {
setBrightness(originalBrightness)
UIApplication.shared.isIdleTimerDisabled = false
}
// Business logic: Check if ready to capture
var canCapture: Bool {
captureSession?.isRunning == true && isPhotoLibraryAuthorized
}
}
extension CameraViewModel: AVCapturePhotoCaptureDelegate {
nonisolated func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
guard let data = photo.fileDataRepresentation() else { return }
PHPhotoLibrary.shared().performChanges {
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil)
}
Task { @MainActor in
UIAccessibility.post(notification: .announcement, argument: String(localized: "Photo captured"))
}
}
}
extension CameraViewModel: AVCaptureFileOutputRecordingDelegate {
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
PHPhotoLibrary.shared().performChanges {
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: outputFileURL, options: nil)
}
Task { @MainActor in
UIAccessibility.post(notification: .announcement, argument: String(localized: "Video saved"))
}
}
}
extension CameraViewModel: AVCaptureVideoDataOutputSampleBufferDelegate {
nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
// Note: This runs on a background queue and cannot access @MainActor isolated properties directly
// For real skin smoothing, this would need to be implemented with a Metal-based approach
// or by using AVCaptureVideoDataOutput with custom rendering
// Basic skin smoothing placeholder - actual implementation would require:
// 1. CIContext created on this queue
// 2. Rendering to a Metal texture
// 3. Displaying via CAMetalLayer or similar
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let ciImage = CIImage(cvPixelBuffer: imageBuffer)
// Apply light gaussian blur for skin smoothing effect
guard let filter = CIFilter(name: "CIGaussianBlur") else { return }
filter.setValue(ciImage, forKey: kCIInputImageKey)
filter.setValue(1.0, forKey: kCIInputRadiusKey)
// For a complete implementation, render outputImage to the preview layer
_ = filter.outputImage
}
}

View File

@ -0,0 +1,316 @@
import SwiftUI
import Bedrock
struct ContentView: View {
@State private var viewModel = CameraViewModel()
@State private var premiumManager = PremiumManager()
@State private var showPaywall = false
@State private var showSettings = false
// Direct reference to shared settings
private var settings: SettingsViewModel {
viewModel.settings
}
var body: some View {
GeometryReader { geometry in
ZStack {
// MARK: - Ring Light Background
ringLightBackground
// MARK: - Camera Preview (centered with border inset)
cameraPreviewArea(in: geometry)
// MARK: - Grid Overlay
if settings.isGridVisible && !viewModel.isPreviewHidden {
let previewSize = min(
geometry.size.width - (settings.ringSize * 2),
geometry.size.height - (settings.ringSize * 2)
)
GridOverlay(isVisible: true)
.frame(width: previewSize, height: previewSize)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize)
}
// MARK: - Controls Overlay
controlsOverlay
// MARK: - Permission Denied View
if !viewModel.isCameraAuthorized && viewModel.captureSession != nil {
permissionDeniedView
}
}
}
.ignoresSafeArea()
.task {
await viewModel.setupCamera()
}
.onDisappear {
viewModel.restoreBrightness()
}
.sheet(isPresented: $showPaywall) {
ProPaywallView()
}
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel.settings)
}
}
// MARK: - Ring Light Background
@ViewBuilder
private var ringLightBackground: some View {
let baseColor = premiumManager.isPremiumUnlocked ? settings.lightColor : Color.RingLight.pureWhite
// Apply light intensity as opacity
baseColor
.opacity(settings.lightIntensity)
.ignoresSafeArea()
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.lightIntensity)
}
// MARK: - Camera Preview Area
@ViewBuilder
private func cameraPreviewArea(in geometry: GeometryProxy) -> some View {
// Calculate the size of the preview area (full screen minus ring on all sides)
let previewSize = min(
geometry.size.width - (settings.ringSize * 2),
geometry.size.height - (settings.ringSize * 2)
)
if viewModel.isCameraAuthorized {
// Show preview unless front flash is active
if !viewModel.isPreviewHidden {
CameraPreview(
viewModel: viewModel,
isMirrorFlipped: settings.isMirrorFlipped,
zoomFactor: settings.currentZoomFactor
)
.frame(width: previewSize, height: previewSize)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize)
}
} else {
// Show placeholder while requesting permission
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(.black)
.frame(width: previewSize, height: previewSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize)
.overlay {
if viewModel.captureSession == nil {
ProgressView()
.tint(.white)
.scaleEffect(1.5)
}
}
}
}
// MARK: - Controls Overlay
private var controlsOverlay: some View {
VStack {
// Top bar
topControlBar
Spacer()
// Bottom capture controls
bottomControlBar
}
.padding(settings.ringSize + Design.Spacing.medium)
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize)
}
// MARK: - Top Control Bar
private var topControlBar: some View {
HStack {
// Pro/Crown button
Button {
showPaywall = true
} label: {
Image(systemName: premiumManager.isPremiumUnlocked ? "crown.fill" : "crown")
.font(.title2)
.foregroundStyle(premiumManager.isPremiumUnlocked ? .yellow : .white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(premiumManager.isPremiumUnlocked ?
String(localized: "Pro unlocked") :
String(localized: "Upgrade to Pro"))
Spacer()
// Grid toggle
Button {
viewModel.settings.isGridVisible.toggle()
} label: {
Image(systemName: "square.grid.3x3")
.font(.title2)
.foregroundStyle(viewModel.settings.isGridVisible ? .yellow : .white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Toggle grid"))
.accessibilityValue(viewModel.settings.isGridVisible ? "On" : "Off")
// Settings button
Button {
showSettings = true
} label: {
Image(systemName: "gearshape.fill")
.font(.title2)
.foregroundStyle(.white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Settings"))
}
}
// MARK: - Bottom Control Bar
private var bottomControlBar: some View {
HStack(spacing: Design.Spacing.xxxxLarge) {
// Switch camera button
Button {
viewModel.switchCamera()
} label: {
Image(systemName: "camera.rotate.fill")
.font(.title)
.foregroundStyle(.white)
.padding(Design.Spacing.medium)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Switch camera"))
// Capture button
captureButton
// Capture mode selector
captureModeMenu
}
}
// MARK: - Capture Button
private var captureButton: some View {
Button {
captureAction()
} label: {
ZStack {
Circle()
.fill(.white)
.frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize)
Circle()
.stroke(.white, lineWidth: Design.LineWidth.thick)
.frame(width: Design.Capture.buttonSize + Design.Spacing.small, height: Design.Capture.buttonSize + Design.Spacing.small)
// Show red stop square when recording
if viewModel.isRecording {
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
.fill(.red)
.frame(width: Design.Capture.stopSquare, height: Design.Capture.stopSquare)
}
}
}
.accessibilityLabel(captureButtonLabel)
.disabled(!viewModel.canCapture)
}
// MARK: - Capture Mode Menu
private var captureModeMenu: some View {
Menu {
ForEach(CaptureMode.allCases) { mode in
Button {
if !mode.isPremium || premiumManager.isPremiumUnlocked {
viewModel.settings.selectedCaptureMode = mode
} else {
showPaywall = true
}
} label: {
HStack {
Label(mode.displayName, systemImage: mode.systemImage)
if mode.isPremium && !premiumManager.isPremiumUnlocked {
Image(systemName: "crown.fill")
}
}
}
}
} label: {
Image(systemName: viewModel.settings.selectedCaptureMode.systemImage)
.font(.title)
.foregroundStyle(.white)
.padding(Design.Spacing.medium)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Capture mode: \(viewModel.settings.selectedCaptureMode.displayName)"))
}
// MARK: - Permission Denied View
private var permissionDeniedView: some View {
VStack(spacing: Design.Spacing.large) {
Image(systemName: "camera.fill")
.font(.system(size: Design.BaseFontSize.hero))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text("Camera Access Required")
.font(.title2.bold())
.foregroundStyle(.white)
Text("Please enable camera access in Settings to use SelfieRingLight.")
.font(.body)
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xLarge)
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(Design.Opacity.heavy))
}
// MARK: - Capture Action
private func captureAction() {
switch viewModel.settings.selectedCaptureMode {
case .photo:
viewModel.capturePhoto()
case .video:
if viewModel.isRecording {
viewModel.stopRecording()
} else {
viewModel.startRecording()
}
case .boomerang:
// TODO: Implement boomerang capture
viewModel.capturePhoto()
}
}
private var captureButtonLabel: String {
switch viewModel.settings.selectedCaptureMode {
case .photo:
return String(localized: "Take photo")
case .video:
return viewModel.isRecording ? String(localized: "Stop recording") : String(localized: "Start recording")
case .boomerang:
return String(localized: "Capture boomerang")
}
}
}
#Preview {
ContentView()
}

View File

@ -0,0 +1,36 @@
import SwiftUI
import Bedrock
// Grid Overlay as separate view
struct GridOverlay: View {
var isVisible: Bool
var body: some View {
if isVisible {
GeometryReader { geo in
Path { path in
let w = geo.size.width
let h = geo.size.height
let thirdW = w / 3
let thirdH = h / 3
// Vertical lines
path.move(to: CGPoint(x: thirdW, y: 0))
path.addLine(to: CGPoint(x: thirdW, y: h))
path.move(to: CGPoint(x: 2 * thirdW, y: 0))
path.addLine(to: CGPoint(x: 2 * thirdW, y: h))
// Horizontal lines
path.move(to: CGPoint(x: 0, y: thirdH))
path.addLine(to: CGPoint(x: w, y: thirdH))
path.move(to: CGPoint(x: 0, y: 2 * thirdH))
path.addLine(to: CGPoint(x: w, y: 2 * thirdH))
}
.stroke(Color.white, lineWidth: Design.Grid.lineWidth)
.opacity(Design.Grid.opacity)
}
.ignoresSafeArea()
.accessibilityHidden(true)
}
}
}

View File

@ -0,0 +1,137 @@
import SwiftUI
import RevenueCat
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
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Design.Spacing.xLarge) {
// Crown icon
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.hero))
.foregroundStyle(.yellow)
Text(String(localized: "Go Pro"))
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
// Benefits list
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
BenefitRow(image: "paintpalette", text: String(localized: "All Color Presets + Custom Colors"))
BenefitRow(image: "sparkles", text: String(localized: "Advanced Beauty Filters"))
BenefitRow(image: "gradient", text: String(localized: "Directional Gradient Lighting"))
BenefitRow(image: "wand.and.stars", text: String(localized: "Unlimited Boomerang Length"))
BenefitRow(image: "checkmark.seal", text: String(localized: "No Watermarks • Ad-Free"))
}
.frame(maxWidth: .infinity, alignment: .leading)
// Product packages
if manager.availablePackages.isEmpty {
ProgressView()
.padding()
} else {
ForEach(manager.availablePackages, id: \.identifier) { package in
ProductPackageButton(
package: package,
isPremiumUnlocked: manager.isPremiumUnlocked,
onPurchase: {
Task {
_ = try? await manager.purchase(package)
if manager.isPremiumUnlocked {
dismiss()
}
}
}
)
}
}
// Restore purchases
Button(String(localized: "Restore Purchases")) {
Task { try? await manager.restorePurchases() }
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(Design.Spacing.large)
}
.background(Color.Surface.overlay)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) { dismiss() }
.foregroundStyle(.white)
}
}
}
.font(.system(size: bodyFontSize))
.task { try? await manager.loadProducts() }
}
}
// MARK: - Product Package Button
private struct ProductPackageButton: View {
let package: Package
let isPremiumUnlocked: Bool
let onPurchase: () -> Void
var body: some View {
Button(action: onPurchase) {
VStack(spacing: Design.Spacing.small) {
Text(package.storeProduct.localizedTitle)
.font(.headline)
.foregroundStyle(.white)
Text(package.localizedPriceString)
.font(.title2.bold())
.foregroundStyle(.white)
if package.packageType == .annual {
Text(String(localized: "Best Value • Save 33%"))
.font(.caption)
.foregroundStyle(.white.opacity(Design.Opacity.accent))
}
}
.frame(maxWidth: .infinity)
.padding(Design.Spacing.large)
.background(Color.Accent.primary.opacity(Design.Opacity.medium))
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(Color.Accent.primary, lineWidth: Design.LineWidth.thin)
)
}
.accessibilityLabel(String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)"))
}
}
// MARK: - Benefit Row
struct BenefitRow: View {
let image: String
let text: String
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: image)
.font(.title2)
.foregroundStyle(Color.Accent.primary)
.frame(width: Design.IconSize.xLarge)
Text(text)
.foregroundStyle(.white)
Spacer()
}
}
}
#Preview {
ProPaywallView()
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,329 @@
import SwiftUI
import Bedrock
struct SettingsView: View {
@Bindable var viewModel: SettingsViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Design.Spacing.medium) {
// MARK: - Ring Light Section
SettingsSectionHeader(title: "Ring Light", systemImage: "light.max")
// Ring Size Slider
ringSizeSlider
// Color Preset
colorPresetSection
// Brightness Slider
brightnessSlider
// MARK: - Camera Section
SettingsSectionHeader(title: "Camera", systemImage: "camera")
SettingsToggle(
title: String(localized: "Front Flash"),
subtitle: String(localized: "Hides preview during capture for a flash effect"),
isOn: $viewModel.isFrontFlashEnabled
)
.accessibilityHint(String(localized: "Uses the ring light as a flash when taking photos"))
SettingsToggle(
title: String(localized: "True Mirror"),
subtitle: String(localized: "Shows non-flipped preview like a real mirror"),
isOn: $viewModel.isMirrorFlipped
)
.accessibilityHint(String(localized: "When enabled, the preview is not mirrored"))
SettingsToggle(
title: String(localized: "Skin Smoothing"),
subtitle: String(localized: "Applies subtle real-time smoothing"),
isOn: $viewModel.isSkinSmoothingEnabled
)
.accessibilityHint(String(localized: "Applies light skin smoothing to the camera preview"))
SettingsToggle(
title: String(localized: "Grid Overlay"),
subtitle: String(localized: "Shows rule of thirds grid"),
isOn: $viewModel.isGridVisible
)
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
// Timer Selection
timerPicker
// MARK: - Sync Section
SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud")
iCloudSyncSection
Spacer(minLength: Design.Spacing.xxxLarge)
}
.padding(.horizontal, Design.Spacing.large)
}
.background(Color.Surface.overlay)
.navigationTitle(String(localized: "Settings"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(String(localized: "Done")) {
dismiss()
}
.foregroundStyle(Color.Accent.primary)
}
}
}
}
// MARK: - Ring Size Slider
private var ringSizeSlider: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(String(localized: "Ring Size"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text("\(Int(viewModel.ringSize))pt")
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
HStack(spacing: Design.Spacing.medium) {
// Small ring icon
Image(systemName: "circle")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Slider(
value: $viewModel.ringSize,
in: SettingsViewModel.minRingSize...SettingsViewModel.maxRingSize,
step: 5
)
.tint(Color.Accent.primary)
// Large ring icon
Image(systemName: "circle")
.font(.system(size: Design.BaseFontSize.large))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Text(String(localized: "Adjusts the size of the light ring around the camera preview"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(.vertical, Design.Spacing.xSmall)
.accessibilityLabel(String(localized: "Ring size"))
.accessibilityValue("\(Int(viewModel.ringSize)) points")
}
// MARK: - Color Preset Section
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))
.foregroundStyle(.white)
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 80), spacing: Design.Spacing.small)],
spacing: Design.Spacing.small
) {
ForEach(RingLightColor.allPresets) { preset in
ColorPresetButton(
preset: preset,
isSelected: viewModel.selectedLightColor == preset
) {
viewModel.selectedLightColor = preset
}
}
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
// MARK: - Light Intensity Slider
private var brightnessSlider: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(String(localized: "Light Intensity"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text("\(Int(viewModel.lightIntensity * 100))%")
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "light.min")
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Slider(value: $viewModel.lightIntensity, in: 0.5...1.0, step: 0.05)
.tint(Color.Accent.primary)
Image(systemName: "light.max")
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Text(String(localized: "Adjusts the opacity/intensity of the ring light"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(.vertical, Design.Spacing.xSmall)
.accessibilityLabel(String(localized: "Light intensity"))
.accessibilityValue("\(Int(viewModel.lightIntensity * 100)) percent")
}
// MARK: - Timer Picker
private var timerPicker: some View {
SegmentedPicker(
title: String(localized: "Self-Timer"),
options: TimerOption.allCases.map { ($0.displayName, $0) },
selection: $viewModel.selectedTimer
)
.accessibilityLabel(String(localized: "Select self-timer duration"))
}
// MARK: - iCloud Sync Section
private var iCloudSyncSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
// Sync toggle
SettingsToggle(
title: String(localized: "Sync Settings"),
subtitle: viewModel.iCloudAvailable
? String(localized: "Sync settings across all your devices")
: String(localized: "Sign in to iCloud to enable sync"),
isOn: $viewModel.iCloudEnabled
)
.disabled(!viewModel.iCloudAvailable)
// Sync status
if viewModel.iCloudEnabled && viewModel.iCloudAvailable {
HStack(spacing: Design.Spacing.small) {
Image(systemName: syncStatusIcon)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(syncStatusColor)
Text(syncStatusText)
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Spacer()
Button {
viewModel.forceSync()
} label: {
Text(String(localized: "Sync Now"))
.font(.system(size: Design.BaseFontSize.caption, weight: .medium))
.foregroundStyle(Color.Accent.primary)
}
}
.padding(.top, Design.Spacing.xSmall)
}
}
}
// MARK: - Sync Status Helpers
private var syncStatusIcon: String {
if !viewModel.hasCompletedInitialSync {
return "arrow.triangle.2.circlepath"
}
return viewModel.syncStatus.isEmpty ? "checkmark.icloud" : "icloud"
}
private var syncStatusColor: Color {
if !viewModel.hasCompletedInitialSync {
return Color.Status.warning
}
return Color.Status.success
}
private var syncStatusText: String {
if !viewModel.hasCompletedInitialSync {
return String(localized: "Syncing...")
}
if let lastSync = viewModel.lastSyncDate {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return String(localized: "Last synced \(formatter.localizedString(for: lastSync, relativeTo: Date()))")
}
return viewModel.syncStatus.isEmpty
? String(localized: "Synced")
: viewModel.syncStatus
}
}
// MARK: - Color Preset Button
private struct ColorPresetButton: View {
let preset: RingLightColor
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: Design.Spacing.xxSmall) {
Circle()
.fill(preset.color)
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.overlay(
Circle()
.strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
)
)
.shadow(
color: preset.color.opacity(Design.Opacity.light),
radius: isSelected ? Design.Shadow.radiusSmall : 0
)
Text(preset.name)
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
if preset.isPremium {
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(Color.Status.warning)
}
}
.padding(Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
)
}
.buttonStyle(.plain)
.accessibilityLabel(preset.name)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityHint(preset.isPremium ? String(localized: "Premium color") : "")
}
}
#Preview {
SettingsView(viewModel: SettingsViewModel())
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,233 @@
import SwiftUI
import Bedrock
// MARK: - Timer Options
enum TimerOption: String, CaseIterable, Identifiable {
case off = "off"
case three = "3"
case five = "5"
case ten = "10"
var id: String { rawValue }
var displayName: String {
switch self {
case .off: return String(localized: "Off")
case .three: return String(localized: "3s")
case .five: return String(localized: "5s")
case .ten: return String(localized: "10s")
}
}
var seconds: Int {
switch self {
case .off: return 0
case .three: return 3
case .five: return 5
case .ten: return 10
}
}
}
// MARK: - Capture Mode
enum CaptureMode: String, CaseIterable, Identifiable {
case photo = "photo"
case video = "video"
case boomerang = "boomerang"
var id: String { rawValue }
var displayName: String {
switch self {
case .photo: return String(localized: "Photo")
case .video: return String(localized: "Video")
case .boomerang: return String(localized: "Boomerang")
}
}
var systemImage: String {
switch self {
case .photo: return "camera.fill"
case .video: return "video.fill"
case .boomerang: return "arrow.2.squarepath"
}
}
var isPremium: Bool {
switch self {
case .photo: return false
case .video, .boomerang: return true
}
}
}
// MARK: - Settings ViewModel
/// Observable settings view model with iCloud sync across all devices.
/// Uses Bedrock's CloudSyncManager for automatic synchronization.
@MainActor
@Observable
final class SettingsViewModel: RingLightConfigurable {
// MARK: - Ring Size Limits
/// Minimum ring border size in points
static let minRingSize: CGFloat = 10
/// Maximum ring border size in points
static let maxRingSize: CGFloat = 120
/// Default ring border size
static let defaultRingSize: CGFloat = 40
// MARK: - Cloud Sync Manager
/// Manages iCloud sync for settings across all devices
private let cloudSync = CloudSyncManager<SyncedSettings>()
// MARK: - Observable Properties (Synced)
/// Ring border size in points
var ringSize: CGFloat {
get { cloudSync.data.ringSize }
set { updateSettings { $0.ringSize = newValue } }
}
/// ID of the selected light color preset
var lightColorId: String {
get { cloudSync.data.lightColorId }
set { updateSettings { $0.lightColorId = newValue } }
}
/// Ring light intensity/opacity (0.5 to 1.0)
var lightIntensity: Double {
get { cloudSync.data.lightIntensity }
set { updateSettings { $0.lightIntensity = newValue } }
}
/// Whether front flash is enabled (hides preview during capture)
var isFrontFlashEnabled: Bool {
get { cloudSync.data.isFrontFlashEnabled }
set { updateSettings { $0.isFrontFlashEnabled = newValue } }
}
/// Whether the camera preview is flipped to show a true mirror
var isMirrorFlipped: Bool {
get { cloudSync.data.isMirrorFlipped }
set { updateSettings { $0.isMirrorFlipped = newValue } }
}
/// Whether skin smoothing filter is enabled
var isSkinSmoothingEnabled: Bool {
get { cloudSync.data.isSkinSmoothingEnabled }
set { updateSettings { $0.isSkinSmoothingEnabled = newValue } }
}
/// Whether the grid overlay is visible
var isGridVisible: Bool {
get { cloudSync.data.isGridVisible }
set { updateSettings { $0.isGridVisible = newValue } }
}
/// Current camera zoom factor
var currentZoomFactor: Double {
get { cloudSync.data.currentZoomFactor }
set { updateSettings { $0.currentZoomFactor = newValue } }
}
// MARK: - Computed Properties
/// Convenience property for border width (same as ringSize)
var borderWidth: CGFloat { ringSize }
var selectedTimer: TimerOption {
get { TimerOption(rawValue: cloudSync.data.selectedTimerRaw) ?? .off }
set { updateSettings { $0.selectedTimerRaw = newValue.rawValue } }
}
var selectedCaptureMode: CaptureMode {
get { CaptureMode(rawValue: cloudSync.data.selectedCaptureModeRaw) ?? .photo }
set { updateSettings { $0.selectedCaptureModeRaw = newValue.rawValue } }
}
var selectedLightColor: RingLightColor {
get { RingLightColor.fromId(lightColorId) }
set { lightColorId = newValue.id }
}
var lightColor: Color {
selectedLightColor.color
}
// MARK: - Sync Status
/// Whether iCloud sync is available
var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
/// Whether iCloud sync is enabled
var iCloudEnabled: Bool {
get { cloudSync.iCloudEnabled }
set { cloudSync.iCloudEnabled = newValue }
}
/// Last sync date
var lastSyncDate: Date? { cloudSync.lastSyncDate }
/// Current sync status message
var syncStatus: String { cloudSync.syncStatus }
/// Whether initial sync has completed
var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync }
// MARK: - Initialization
init() {
// CloudSyncManager handles syncing automatically
}
// MARK: - Private Methods
/// Updates settings and saves to cloud
private func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
cloudSync.update { settings in
transform(&settings)
settings.modificationCount += 1
}
}
// MARK: - Sync Actions
/// Forces a sync with iCloud
func forceSync() {
cloudSync.sync()
}
/// Resets all settings to defaults
func resetToDefaults() {
cloudSync.reset()
}
// MARK: - Validation
var isValidConfiguration: Bool {
ringSize >= Self.minRingSize && lightIntensity >= 0.5
}
}
// MARK: - CaptureControlling Conformance
extension SettingsViewModel: CaptureControlling {
func startCountdown() async {
// Countdown handled by CameraViewModel
}
func performCapture() {
// Capture handled by CameraViewModel
}
func performFlashBurst() async {
// Flash handled by CameraViewModel
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,307 @@
{
"sourceLanguage" : "en",
"strings" : {
"%lld percent" : {
"comment" : "The value of the slider is shown as a percentage.",
"isCommentAutoGenerated" : true
},
"%lld points" : {
"comment" : "The value of the ring size slider, displayed in parentheses.",
"isCommentAutoGenerated" : true
},
"%lld%%" : {
"comment" : "A text label displaying the current brightness percentage.",
"isCommentAutoGenerated" : true
},
"%lldpt" : {
"comment" : "A label displaying the current ring size, formatted as a number followed by the unit \"pt\".",
"isCommentAutoGenerated" : true
},
"3s" : {
"comment" : "Display name for the \"3 seconds\" timer option.",
"isCommentAutoGenerated" : true
},
"5s" : {
"comment" : "Description of a timer option when the timer is set to 5 seconds.",
"isCommentAutoGenerated" : true
},
"10s" : {
"comment" : "Description of a timer option when the user selects \"10 seconds\".",
"isCommentAutoGenerated" : true
},
"Adjusts the size of the light ring around the camera preview" : {
"comment" : "A description of the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Advanced Beauty Filters" : {
"comment" : "Description of a benefit included in the \"Go Pro\" premium subscription.",
"isCommentAutoGenerated" : true
},
"All Color Presets + Custom Colors" : {
"comment" : "Benefit description for the \"All Color Presets + Custom Colors\" benefit.",
"isCommentAutoGenerated" : true
},
"Applies light skin smoothing to the camera preview" : {
"comment" : "A hint for the \"Skin Smoothing\" toggle in the settings view.",
"isCommentAutoGenerated" : true
},
"Applies subtle real-time smoothing" : {
"comment" : "Accessibility hint for the \"Skin Smoothing\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
},
"Best Value • Save 33%" : {
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
"isCommentAutoGenerated" : true
},
"Boomerang" : {
"comment" : "Display name for the \"Boomerang\" capture mode.",
"isCommentAutoGenerated" : true
},
"Camera Access Required" : {
"comment" : "A title displayed when camera access is denied.",
"isCommentAutoGenerated" : true
},
"Cancel" : {
"comment" : "The text for a button that dismisses the current view.",
"isCommentAutoGenerated" : true
},
"Capture boomerang" : {
"comment" : "Label for capturing a boomerang photo.",
"isCommentAutoGenerated" : true
},
"Capture mode: %@" : {
"comment" : "A label describing the currently selected capture mode. The placeholder is replaced with the actual mode name.",
"isCommentAutoGenerated" : true
},
"Cool Lavender" : {
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Debug mode: Purchase simulated!" : {
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
"isCommentAutoGenerated" : true
},
"Debug mode: Restore simulated!" : {
"comment" : "Accessibility announcement when restoring purchases in debug mode.",
"isCommentAutoGenerated" : true
},
"Directional Gradient Lighting" : {
"comment" : "Benefit provided with the Pro subscription, such as \"Directional Gradient Lighting\".",
"isCommentAutoGenerated" : true
},
"Done" : {
"comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.",
"isCommentAutoGenerated" : true
},
"Go Pro" : {
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
"isCommentAutoGenerated" : true
},
"Grid Overlay" : {
"comment" : "Text displayed in a settings toggle for showing a grid overlay to help compose your shot.",
"isCommentAutoGenerated" : true
},
"Higher brightness = brighter ring light effect" : {
"comment" : "A description of how to adjust the brightness of the screen.",
"isCommentAutoGenerated" : true
},
"Ice Blue" : {
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Last synced %@" : {
},
"Light Color" : {
"comment" : "A label displayed above a section of the settings view related to light colors.",
"isCommentAutoGenerated" : true
},
"No Watermarks • Ad-Free" : {
"comment" : "Description of a benefit that comes with the Pro subscription.",
"isCommentAutoGenerated" : true
},
"Off" : {
"comment" : "The accessibility value for the grid toggle when it is off.",
"isCommentAutoGenerated" : true
},
"On" : {
"comment" : "A label describing a setting that is currently enabled.",
"isCommentAutoGenerated" : true
},
"Open Settings" : {
"comment" : "A button label that opens the device settings when tapped.",
"isCommentAutoGenerated" : true
},
"Photo" : {
},
"Photo captured" : {
"comment" : "Accessibility label for a notification that is posted when a photo is captured.",
"isCommentAutoGenerated" : true
},
"Please enable camera access in Settings to use SelfieRingLight." : {
"comment" : "A message instructing the user to enable camera access in Settings to use SelfieRingLight.",
"isCommentAutoGenerated" : true
},
"Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.",
"isCommentAutoGenerated" : true
},
"Pro unlocked" : {
"comment" : "An accessibility label for the \"crown.fill\" system icon when premium is unlocked.",
"isCommentAutoGenerated" : true
},
"Purchase successful! Pro features unlocked." : {
"comment" : "Announcement read out to the user when a premium purchase is successful.",
"isCommentAutoGenerated" : true
},
"Purchases restored" : {
"comment" : "Announcement read out to the user when purchases are restored.",
"isCommentAutoGenerated" : true
},
"Pure White" : {
"comment" : "A color preset option for the ring light that displays as pure white.",
"isCommentAutoGenerated" : true
},
"Restore Purchases" : {
"comment" : "A button that restores purchases.",
"isCommentAutoGenerated" : true
},
"Ring size" : {
"comment" : "An accessibility label for the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Ring Size" : {
"comment" : "The label for the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Screen brightness" : {
"comment" : "An accessibility label for the screen brightness setting in the settings view.",
"isCommentAutoGenerated" : true
},
"Screen Brightness" : {
"comment" : "A label displayed above the brightness slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Select self-timer duration" : {
"comment" : "A label describing the segmented control for selecting the duration of the self-timer.",
"isCommentAutoGenerated" : true
},
"Self-Timer" : {
"comment" : "Title of the section in the settings view that allows the user to select the duration of the self-timer.",
"isCommentAutoGenerated" : true
},
"Settings" : {
"comment" : "The title of the settings screen.",
"isCommentAutoGenerated" : true
},
"Shows a grid overlay to help compose your shot" : {
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
"isCommentAutoGenerated" : true
},
"Shows non-flipped preview like a real mirror" : {
"comment" : "Subtitle for the \"True Mirror\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
},
"Shows rule of thirds grid" : {
"comment" : "Accessibility hint for the grid overlay toggle.",
"isCommentAutoGenerated" : true
},
"Sign in to iCloud to enable sync" : {
"comment" : "Subtitle of the iCloud sync section when the user is not signed into iCloud.",
"isCommentAutoGenerated" : true
},
"Skin Smoothing" : {
"comment" : "A toggle that enables or disables real-time skin smoothing.",
"isCommentAutoGenerated" : true
},
"Soft Pink" : {
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Start recording" : {
"comment" : "Label for the \"Start recording\" button in the bottom control bar when not recording a video.",
"isCommentAutoGenerated" : true
},
"Stop recording" : {
"comment" : "Label for the button that stops recording a video.",
"isCommentAutoGenerated" : true
},
"Subscribe to %@ for %@" : {
"comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Subscribe to %1$@ for %2$@"
}
}
}
},
"Switch camera" : {
"comment" : "A button label that translates to \"Switch camera\".",
"isCommentAutoGenerated" : true
},
"Sync Now" : {
"comment" : "A button label that triggers a sync action.",
"isCommentAutoGenerated" : true
},
"Sync Settings" : {
"comment" : "Title of a toggle that allows the user to enable or disable iCloud sync settings.",
"isCommentAutoGenerated" : true
},
"Sync settings across all your devices" : {
"comment" : "Subtitle of the \"Sync Settings\" toggle in the Settings view, describing the functionality when sync is enabled.",
"isCommentAutoGenerated" : true
},
"Synced" : {
"comment" : "Text displayed in the iCloud sync section when the user's settings have been successfully synced.",
"isCommentAutoGenerated" : true
},
"Syncing..." : {
},
"Take photo" : {
"comment" : "Label for the \"Take photo\" button in the bottom control bar when using the photo capture mode.",
"isCommentAutoGenerated" : true
},
"Toggle grid" : {
"comment" : "A button that toggles the visibility of the grid in the camera view.",
"isCommentAutoGenerated" : true
},
"True Mirror" : {
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
"isCommentAutoGenerated" : true
},
"Unlimited Boomerang Length" : {
"comment" : "Description of a benefit that comes with the Pro subscription, specifically related to the boomerang tool.",
"isCommentAutoGenerated" : true
},
"Upgrade to Pro" : {
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
"isCommentAutoGenerated" : true
},
"Video" : {
"comment" : "Display name for the \"Video\" capture mode.",
"isCommentAutoGenerated" : true
},
"Video saved" : {
"comment" : "Accessibility notification text when a video is successfully saved to the user's photo library.",
"isCommentAutoGenerated" : true
},
"Warm Amber" : {
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Warm Cream" : {
"comment" : "A color option for the ring light, named after a warm, creamy shade of white.",
"isCommentAutoGenerated" : true
},
"When enabled, the preview is not mirrored" : {
"comment" : "Accessibility hint for the \"True Mirror\" setting in the Settings view.",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
</dict>
</plist>

View File

@ -0,0 +1,53 @@
import SwiftUI
import Bedrock
// MARK: - Ring Light Color Presets
/// App-specific color presets for the ring light feature.
/// Standard UI colors should use Bedrock's `Color.Surface`, `Color.Accent`, etc.
extension Color {
/// Ring light color presets for selfie lighting.
enum RingLight {
/// Pure white - standard daylight lighting.
static let pureWhite = Color(red: 1.0, green: 1.0, blue: 1.0)
/// Warm cream - soft warm lighting like golden hour.
static let warmCream = Color(red: 1.0, green: 0.98, blue: 0.9)
/// Ice blue - cool lighting for a crisp look.
static let iceBlue = Color(red: 0.9, green: 0.95, blue: 1.0)
/// Soft pink - flattering warm tone.
static let softPink = Color(red: 1.0, green: 0.92, blue: 0.95)
/// Warm amber - sunset-like glow.
static let warmAmber = Color(red: 1.0, green: 0.9, blue: 0.75)
/// Cool lavender - subtle cool tone.
static let coolLavender = Color(red: 0.95, green: 0.92, blue: 1.0)
}
}
// MARK: - Ring Light Color Identifier
/// Identifiable wrapper for ring light colors to use in Picker/ForEach.
struct RingLightColor: Identifiable, Equatable, Hashable {
let id: String
let name: String
let color: Color
let isPremium: Bool
static let allPresets: [RingLightColor] = [
RingLightColor(id: "pureWhite", name: String(localized: "Pure White"), color: .RingLight.pureWhite, isPremium: false),
RingLightColor(id: "warmCream", name: String(localized: "Warm Cream"), color: .RingLight.warmCream, isPremium: false),
RingLightColor(id: "iceBlue", name: String(localized: "Ice Blue"), color: .RingLight.iceBlue, isPremium: true),
RingLightColor(id: "softPink", name: String(localized: "Soft Pink"), color: .RingLight.softPink, isPremium: true),
RingLightColor(id: "warmAmber", name: String(localized: "Warm Amber"), color: .RingLight.warmAmber, isPremium: true),
RingLightColor(id: "coolLavender", name: String(localized: "Cool Lavender"), color: .RingLight.coolLavender, isPremium: true)
]
static func fromId(_ id: String) -> RingLightColor {
allPresets.first { $0.id == id } ?? allPresets[0]
}
}

View File

@ -0,0 +1,60 @@
import SwiftUI
import Bedrock
// MARK: - Re-export Bedrock Design for convenience
/// Convenience typealias to use Bedrock's Design throughout the app.
typealias Design = Bedrock.Design
// MARK: - App-Specific Design Extensions
/// App-specific additions to Bedrock's Design system.
/// Use `Design.Spacing`, `Design.CornerRadius`, etc. from Bedrock for all standard values.
/// These extensions add domain-specific constants for the Selfie Ring Light app.
extension Bedrock.Design {
/// App-specific size constants (e.g., border sizes for ring light).
enum Size {
static let borderSmall: CGFloat = 2
static let borderMedium: CGFloat = 4
static let borderLarge: CGFloat = 6
static let iconMedium: CGFloat = 24
static let cardWidth: CGFloat = 80
static let cardHeight: CGFloat = 52
}
/// Ring light border thickness options (for UI display, not the multiplier).
enum RingBorder {
static let small: CGFloat = 20
static let medium: CGFloat = 40
static let large: CGFloat = 60
}
/// Grid overlay configuration.
enum Grid {
static let lineWidth: CGFloat = 1
static let opacity: Double = 0.5
}
/// Capture button sizes.
enum Capture {
static let buttonSize: CGFloat = 70
static let innerRing: CGFloat = 62
static let stopSquare: CGFloat = 28
}
/// Camera control sizes.
enum Camera {
static let controlButtonSize: CGFloat = 44
static let flipIconSize: CGFloat = 22
}
/// Font sizes for the app (maps to Bedrock's BaseFontSize for consistency).
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
}
}

View File

@ -0,0 +1,147 @@
import RevenueCat
import SwiftUI
@MainActor
@Observable
final class PremiumManager: PremiumManaging {
var availablePackages: [Package] = []
// MARK: - Configuration
/// RevenueCat entitlement identifier - must match your RevenueCat dashboard
private let entitlementIdentifier = "pro"
/// Reads the RevenueCat API key from Info.plist (injected at build time from Secrets.xcconfig)
private static var apiKey: String {
guard let key = Bundle.main.object(forInfoDictionaryKey: "RevenueCatAPIKey") as? String,
!key.isEmpty,
key != "your_revenuecat_public_api_key_here" else {
#if DEBUG
print("⚠️ [PremiumManager] RevenueCat API key not configured. See Configuration/Secrets.xcconfig.template")
#endif
return ""
}
return key
}
// MARK: - Debug Override
/// Check if debug premium is enabled via environment variable.
/// Set "ENABLE_DEBUG_PREMIUM" = "1" in your scheme's environment variables to unlock all premium features during debugging.
private var isDebugPremiumEnabled: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["ENABLE_DEBUG_PREMIUM"] == "1"
#else
return false
#endif
}
var isPremium: Bool {
// Debug override takes precedence
if isDebugPremiumEnabled {
return true
}
// If API key isn't configured, return false
guard !Self.apiKey.isEmpty else {
return false
}
return Purchases.shared.cachedCustomerInfo?.entitlements[entitlementIdentifier]?.isActive == true
}
var isPremiumUnlocked: Bool { isPremium }
init() {
#if DEBUG
if isDebugPremiumEnabled {
print("🔓 [PremiumManager] Debug premium enabled via environment variable")
}
#endif
// Only configure RevenueCat if we have a valid API key
guard !Self.apiKey.isEmpty else {
#if DEBUG
print("⚠️ [PremiumManager] Skipping RevenueCat configuration - no API key")
#endif
return
}
#if DEBUG
Purchases.logLevel = .debug
#endif
Purchases.configure(withAPIKey: Self.apiKey)
Task {
try? await loadProducts()
}
}
func loadProducts() async throws {
guard !Self.apiKey.isEmpty else { return }
let offerings = try await Purchases.shared.offerings()
if let current = offerings.current {
availablePackages = current.availablePackages
}
}
func purchase(_ package: Package) async throws -> Bool {
#if DEBUG
if isDebugPremiumEnabled {
// Simulate successful purchase in debug mode
UIAccessibility.post(
notification: .announcement,
argument: String(localized: "Debug mode: Purchase simulated!")
)
return true
}
#endif
let result = try await Purchases.shared.purchase(package: package)
if result.customerInfo.entitlements[entitlementIdentifier]?.isActive == true {
UIAccessibility.post(
notification: .announcement,
argument: String(localized: "Purchase successful! Pro features unlocked.")
)
return true
}
return false
}
func purchase(productId: String) async throws {
#if DEBUG
if isDebugPremiumEnabled {
return // Already "premium" in debug mode
}
#endif
guard let package = availablePackages.first(where: { $0.storeProduct.productIdentifier == productId }) else {
throw NSError(
domain: "PremiumManager",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Product not found"]
)
}
_ = try await purchase(package)
}
func restorePurchases() async throws {
#if DEBUG
if isDebugPremiumEnabled {
UIAccessibility.post(
notification: .announcement,
argument: String(localized: "Debug mode: Restore simulated!")
)
return
}
#endif
_ = try await Purchases.shared.restorePurchases()
UIAccessibility.post(
notification: .announcement,
argument: String(localized: "Purchases restored")
)
}
}

View File

@ -0,0 +1,10 @@
protocol CaptureControlling {
var selectedTimer: TimerOption { get set }
var isGridVisible: Bool { get set }
var currentZoomFactor: Double { get set }
var selectedCaptureMode: CaptureMode { get set }
func startCountdown() async
func performCapture()
func performFlashBurst() async
}

View File

@ -0,0 +1,7 @@
protocol PremiumManaging {
var isPremium: Bool { get }
func loadProducts() async throws
func purchase(productId: String) async throws
func restorePurchases() async throws
}

View File

@ -0,0 +1,25 @@
import SwiftUI
/// Protocol for types that can configure the ring light appearance.
protocol RingLightConfigurable {
/// The size of the ring light border in points
var ringSize: CGFloat { get set }
/// Convenience accessor for border width (same as ringSize)
var borderWidth: CGFloat { get }
/// The color of the ring light
var lightColor: Color { get }
/// Ring light intensity/opacity (0.5 to 1.0)
var lightIntensity: Double { get set }
/// Whether front flash is enabled (hides preview during capture)
var isFrontFlashEnabled: Bool { get set }
/// Whether the camera preview is mirrored
var isMirrorFlipped: Bool { get set }
/// Whether skin smoothing is enabled
var isSkinSmoothingEnabled: Bool { get set }
}

View File

@ -0,0 +1,134 @@
import Foundation
import SwiftUI
import Bedrock
// MARK: - Synced Settings Data
/// Settings data structure that syncs across all devices via iCloud.
/// Conforms to `PersistableData` for use with Bedrock's `CloudSyncManager`.
struct SyncedSettings: PersistableData, Sendable {
// MARK: - PersistableData Requirements
static var dataIdentifier: String { "selfieRingLightSettings" }
static var empty: SyncedSettings {
SyncedSettings()
}
/// Sync priority based on modification count - higher means more changes made.
/// This ensures the most actively used device's settings win in conflicts.
var syncPriority: Int {
modificationCount
}
var lastModified: Date = .now
// MARK: - Settings Properties
/// How many times settings have been modified (for sync priority)
var modificationCount: Int = 0
/// Ring border size in points (stored as Double for Codable compatibility)
var ringSizeValue: Double = 40
/// ID of the selected light color preset
var lightColorId: String = "pureWhite"
/// Ring light intensity/opacity (0.5 to 1.0)
var lightIntensity: Double = 1.0
/// Whether front flash is enabled (hides preview during capture)
var isFrontFlashEnabled: Bool = true
/// Whether the camera preview is flipped to show a true mirror
var isMirrorFlipped: Bool = false
/// Whether skin smoothing filter is enabled
var isSkinSmoothingEnabled: Bool = true
/// Selected self-timer option raw value
var selectedTimerRaw: String = "off"
/// Whether the grid overlay is visible
var isGridVisible: Bool = false
/// Current camera zoom factor
var currentZoomFactor: Double = 1.0
/// Selected capture mode raw value
var selectedCaptureModeRaw: String = "photo"
// MARK: - Computed Properties
/// Ring size as CGFloat (convenience accessor)
var ringSize: CGFloat {
get { CGFloat(ringSizeValue) }
set { ringSizeValue = Double(newValue) }
}
// MARK: - Initialization
init() {}
init(
ringSize: CGFloat,
lightColorId: String,
lightIntensity: Double,
isFrontFlashEnabled: Bool,
isMirrorFlipped: Bool,
isSkinSmoothingEnabled: Bool,
selectedTimerRaw: String,
isGridVisible: Bool,
currentZoomFactor: Double,
selectedCaptureModeRaw: String,
modificationCount: Int = 0
) {
self.ringSizeValue = Double(ringSize)
self.lightColorId = lightColorId
self.lightIntensity = lightIntensity
self.isFrontFlashEnabled = isFrontFlashEnabled
self.isMirrorFlipped = isMirrorFlipped
self.isSkinSmoothingEnabled = isSkinSmoothingEnabled
self.selectedTimerRaw = selectedTimerRaw
self.isGridVisible = isGridVisible
self.currentZoomFactor = currentZoomFactor
self.selectedCaptureModeRaw = selectedCaptureModeRaw
self.modificationCount = modificationCount
self.lastModified = .now
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case modificationCount
case lastModified
case ringSizeValue
case lightColorId
case lightIntensity
case isFrontFlashEnabled
case isMirrorFlipped
case isSkinSmoothingEnabled
case selectedTimerRaw
case isGridVisible
case currentZoomFactor
case selectedCaptureModeRaw
}
}
// MARK: - Equatable
extension SyncedSettings: Equatable {
static func == (lhs: SyncedSettings, rhs: SyncedSettings) -> Bool {
lhs.ringSizeValue == rhs.ringSizeValue &&
lhs.lightColorId == rhs.lightColorId &&
lhs.lightIntensity == rhs.lightIntensity &&
lhs.isFrontFlashEnabled == rhs.isFrontFlashEnabled &&
lhs.isMirrorFlipped == rhs.isMirrorFlipped &&
lhs.isSkinSmoothingEnabled == rhs.isSkinSmoothingEnabled &&
lhs.selectedTimerRaw == rhs.selectedTimerRaw &&
lhs.isGridVisible == rhs.isGridVisible &&
lhs.currentZoomFactor == rhs.currentZoomFactor &&
lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw
}
}

View File

@ -0,0 +1,17 @@
//
// SelfieRingLightTests.swift
// SelfieRingLightTests
//
// Created by Matt Bruce on 1/2/26.
//
import Testing
@testable import SelfieRingLight
struct SelfieRingLightTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
}

View File

@ -0,0 +1,41 @@
//
// SelfieRingLightUITests.swift
// SelfieRingLightUITests
//
// Created by Matt Bruce on 1/2/26.
//
import XCTest
final class SelfieRingLightUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}

View File

@ -0,0 +1,33 @@
//
// SelfieRingLightUITestsLaunchTests.swift
// SelfieRingLightUITests
//
// Created by Matt Bruce on 1/2/26.
//
import XCTest
final class SelfieRingLightUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}