From 74e65829de4ddc133416d31ad0b1cd6a96d35a81 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 2 Jan 2026 13:01:24 -0600 Subject: [PATCH] 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 --- .gitignore | 49 ++ AGENTS.md | 499 +++++++++++++ AI_Implementation.md | 68 ++ README.md | 139 ++++ SelfieRingLight.xcodeproj/project.pbxproj | 662 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcschemes/SelfieRingLight.xcscheme | 109 +++ SelfieRingLight/App/SelfieRingLightApp.swift | 17 + SelfieRingLight/Configuration/Debug.xcconfig | 11 + .../Configuration/Release.xcconfig | 11 + .../Configuration/Secrets.xcconfig.template | 12 + .../Features/Camera/CameraPreview.swift | 135 ++++ .../Features/Camera/CameraViewModel.swift | 201 ++++++ .../Features/Camera/ContentView.swift | 316 +++++++++ .../Features/Camera/GridOverlay.swift | 36 + .../Features/Paywall/ProPaywallView.swift | 137 ++++ .../Features/Settings/SettingsView.swift | 329 +++++++++ .../Features/Settings/SettingsViewModel.swift | 233 ++++++ .../Preview Assets.xcassets/Contents.json | 6 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + .../Resources/Assets.xcassets/Contents.json | 6 + .../Resources/Localizable.xcstrings | 307 ++++++++ SelfieRingLight/SelfieRingLight.entitlements | 8 + SelfieRingLight/Shared/Color+Extensions.swift | 53 ++ SelfieRingLight/Shared/DesignConstants.swift | 60 ++ .../Shared/Premium/PremiumManager.swift | 147 ++++ .../Shared/Protocols/CaptureControlling.swift | 10 + .../Shared/Protocols/PremiumManaging.swift | 7 + .../Protocols/RingLightConfigurable.swift | 25 + .../Shared/Storage/SyncedSettings.swift | 134 ++++ .../SelfieRingLightTests.swift | 17 + .../SelfieRingLightUITests.swift | 41 ++ .../SelfieRingLightUITestsLaunchTests.swift | 33 + 34 files changed, 3871 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 AI_Implementation.md create mode 100644 README.md create mode 100644 SelfieRingLight.xcodeproj/project.pbxproj create mode 100644 SelfieRingLight.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 SelfieRingLight.xcodeproj/xcshareddata/xcschemes/SelfieRingLight.xcscheme create mode 100644 SelfieRingLight/App/SelfieRingLightApp.swift create mode 100644 SelfieRingLight/Configuration/Debug.xcconfig create mode 100644 SelfieRingLight/Configuration/Release.xcconfig create mode 100644 SelfieRingLight/Configuration/Secrets.xcconfig.template create mode 100644 SelfieRingLight/Features/Camera/CameraPreview.swift create mode 100644 SelfieRingLight/Features/Camera/CameraViewModel.swift create mode 100644 SelfieRingLight/Features/Camera/ContentView.swift create mode 100644 SelfieRingLight/Features/Camera/GridOverlay.swift create mode 100644 SelfieRingLight/Features/Paywall/ProPaywallView.swift create mode 100644 SelfieRingLight/Features/Settings/SettingsView.swift create mode 100644 SelfieRingLight/Features/Settings/SettingsViewModel.swift create mode 100644 SelfieRingLight/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 SelfieRingLight/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 SelfieRingLight/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 SelfieRingLight/Resources/Assets.xcassets/Contents.json create mode 100644 SelfieRingLight/Resources/Localizable.xcstrings create mode 100644 SelfieRingLight/SelfieRingLight.entitlements create mode 100644 SelfieRingLight/Shared/Color+Extensions.swift create mode 100644 SelfieRingLight/Shared/DesignConstants.swift create mode 100644 SelfieRingLight/Shared/Premium/PremiumManager.swift create mode 100644 SelfieRingLight/Shared/Protocols/CaptureControlling.swift create mode 100644 SelfieRingLight/Shared/Protocols/PremiumManaging.swift create mode 100644 SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift create mode 100644 SelfieRingLight/Shared/Storage/SyncedSettings.swift create mode 100644 SelfieRingLightTests/SelfieRingLightTests.swift create mode 100644 SelfieRingLightUITests/SelfieRingLightUITests.swift create mode 100644 SelfieRingLightUITests/SelfieRingLightUITestsLaunchTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a60e8d7 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..61dcc2b --- /dev/null +++ b/AGENTS.md @@ -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: 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. diff --git a/AI_Implementation.md b/AI_Implementation.md new file mode 100644 index 0000000..c3fb67a --- /dev/null +++ b/AI_Implementation.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dec8703 --- /dev/null +++ b/README.md @@ -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)* diff --git a/SelfieRingLight.xcodeproj/project.pbxproj b/SelfieRingLight.xcodeproj/project.pbxproj new file mode 100644 index 0000000..416eebb --- /dev/null +++ b/SelfieRingLight.xcodeproj/project.pbxproj @@ -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 = ""; + }; + EA766C3C2F082A8500DC03E1 /* SelfieRingLightTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SelfieRingLightTests; + sourceTree = ""; + }; + EA766C462F082A8500DC03E1 /* SelfieRingLightUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SelfieRingLightUITests; + sourceTree = ""; + }; +/* 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 = ""; + }; + EA766C2D2F082A8400DC03E1 /* Products */ = { + isa = PBXGroup; + children = ( + EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */, + EA766C392F082A8500DC03E1 /* SelfieRingLightTests.xctest */, + EA766C432F082A8500DC03E1 /* SelfieRingLightUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + EA766F342F0843F500DC03E1 /* Recovered References */ = { + isa = PBXGroup; + children = ( + EA766C902F08400000DC03E1 /* SelfieRingLight/Configuration/Debug.xcconfig */, + EA766C912F08400000DC03E1 /* SelfieRingLight/Configuration/Release.xcconfig */, + ); + name = "Recovered References"; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/SelfieRingLight.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SelfieRingLight.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/SelfieRingLight.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SelfieRingLight.xcodeproj/xcshareddata/xcschemes/SelfieRingLight.xcscheme b/SelfieRingLight.xcodeproj/xcshareddata/xcschemes/SelfieRingLight.xcscheme new file mode 100644 index 0000000..3f92b9d --- /dev/null +++ b/SelfieRingLight.xcodeproj/xcshareddata/xcschemes/SelfieRingLight.xcscheme @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SelfieRingLight/App/SelfieRingLightApp.swift b/SelfieRingLight/App/SelfieRingLightApp.swift new file mode 100644 index 0000000..086b63c --- /dev/null +++ b/SelfieRingLight/App/SelfieRingLightApp.swift @@ -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() + } + } +} diff --git a/SelfieRingLight/Configuration/Debug.xcconfig b/SelfieRingLight/Configuration/Debug.xcconfig new file mode 100644 index 0000000..73ceabe --- /dev/null +++ b/SelfieRingLight/Configuration/Debug.xcconfig @@ -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) diff --git a/SelfieRingLight/Configuration/Release.xcconfig b/SelfieRingLight/Configuration/Release.xcconfig new file mode 100644 index 0000000..8d69132 --- /dev/null +++ b/SelfieRingLight/Configuration/Release.xcconfig @@ -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) diff --git a/SelfieRingLight/Configuration/Secrets.xcconfig.template b/SelfieRingLight/Configuration/Secrets.xcconfig.template new file mode 100644 index 0000000..2712067 --- /dev/null +++ b/SelfieRingLight/Configuration/Secrets.xcconfig.template @@ -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 diff --git a/SelfieRingLight/Features/Camera/CameraPreview.swift b/SelfieRingLight/Features/Camera/CameraPreview.swift new file mode 100644 index 0000000..4706772 --- /dev/null +++ b/SelfieRingLight/Features/Camera/CameraPreview.swift @@ -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() + } + } +} diff --git a/SelfieRingLight/Features/Camera/CameraViewModel.swift b/SelfieRingLight/Features/Camera/CameraViewModel.swift new file mode 100644 index 0000000..9ad91c7 --- /dev/null +++ b/SelfieRingLight/Features/Camera/CameraViewModel.swift @@ -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 + } +} diff --git a/SelfieRingLight/Features/Camera/ContentView.swift b/SelfieRingLight/Features/Camera/ContentView.swift new file mode 100644 index 0000000..56f7999 --- /dev/null +++ b/SelfieRingLight/Features/Camera/ContentView.swift @@ -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() +} diff --git a/SelfieRingLight/Features/Camera/GridOverlay.swift b/SelfieRingLight/Features/Camera/GridOverlay.swift new file mode 100644 index 0000000..09604bd --- /dev/null +++ b/SelfieRingLight/Features/Camera/GridOverlay.swift @@ -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) + } + } +} diff --git a/SelfieRingLight/Features/Paywall/ProPaywallView.swift b/SelfieRingLight/Features/Paywall/ProPaywallView.swift new file mode 100644 index 0000000..d934524 --- /dev/null +++ b/SelfieRingLight/Features/Paywall/ProPaywallView.swift @@ -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) +} diff --git a/SelfieRingLight/Features/Settings/SettingsView.swift b/SelfieRingLight/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..248e21b --- /dev/null +++ b/SelfieRingLight/Features/Settings/SettingsView.swift @@ -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) +} diff --git a/SelfieRingLight/Features/Settings/SettingsViewModel.swift b/SelfieRingLight/Features/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..6cbabd8 --- /dev/null +++ b/SelfieRingLight/Features/Settings/SettingsViewModel.swift @@ -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() + + // 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 + } +} diff --git a/SelfieRingLight/Preview Content/Preview Assets.xcassets/Contents.json b/SelfieRingLight/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SelfieRingLight/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SelfieRingLight/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/SelfieRingLight/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/SelfieRingLight/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SelfieRingLight/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/SelfieRingLight/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/SelfieRingLight/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/SelfieRingLight/Resources/Assets.xcassets/Contents.json b/SelfieRingLight/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SelfieRingLight/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SelfieRingLight/Resources/Localizable.xcstrings b/SelfieRingLight/Resources/Localizable.xcstrings new file mode 100644 index 0000000..352cd31 --- /dev/null +++ b/SelfieRingLight/Resources/Localizable.xcstrings @@ -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" +} \ No newline at end of file diff --git a/SelfieRingLight/SelfieRingLight.entitlements b/SelfieRingLight/SelfieRingLight.entitlements new file mode 100644 index 0000000..1a1a421 --- /dev/null +++ b/SelfieRingLight/SelfieRingLight.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) + + diff --git a/SelfieRingLight/Shared/Color+Extensions.swift b/SelfieRingLight/Shared/Color+Extensions.swift new file mode 100644 index 0000000..9cfb0bc --- /dev/null +++ b/SelfieRingLight/Shared/Color+Extensions.swift @@ -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] + } +} diff --git a/SelfieRingLight/Shared/DesignConstants.swift b/SelfieRingLight/Shared/DesignConstants.swift new file mode 100644 index 0000000..485e98e --- /dev/null +++ b/SelfieRingLight/Shared/DesignConstants.swift @@ -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 + } +} diff --git a/SelfieRingLight/Shared/Premium/PremiumManager.swift b/SelfieRingLight/Shared/Premium/PremiumManager.swift new file mode 100644 index 0000000..dd54124 --- /dev/null +++ b/SelfieRingLight/Shared/Premium/PremiumManager.swift @@ -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") + ) + } +} diff --git a/SelfieRingLight/Shared/Protocols/CaptureControlling.swift b/SelfieRingLight/Shared/Protocols/CaptureControlling.swift new file mode 100644 index 0000000..8ca8774 --- /dev/null +++ b/SelfieRingLight/Shared/Protocols/CaptureControlling.swift @@ -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 +} diff --git a/SelfieRingLight/Shared/Protocols/PremiumManaging.swift b/SelfieRingLight/Shared/Protocols/PremiumManaging.swift new file mode 100644 index 0000000..b1e4678 --- /dev/null +++ b/SelfieRingLight/Shared/Protocols/PremiumManaging.swift @@ -0,0 +1,7 @@ +protocol PremiumManaging { + var isPremium: Bool { get } + + func loadProducts() async throws + func purchase(productId: String) async throws + func restorePurchases() async throws +} diff --git a/SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift b/SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift new file mode 100644 index 0000000..688aa94 --- /dev/null +++ b/SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift @@ -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 } +} diff --git a/SelfieRingLight/Shared/Storage/SyncedSettings.swift b/SelfieRingLight/Shared/Storage/SyncedSettings.swift new file mode 100644 index 0000000..a12f232 --- /dev/null +++ b/SelfieRingLight/Shared/Storage/SyncedSettings.swift @@ -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 + } +} diff --git a/SelfieRingLightTests/SelfieRingLightTests.swift b/SelfieRingLightTests/SelfieRingLightTests.swift new file mode 100644 index 0000000..602ce2b --- /dev/null +++ b/SelfieRingLightTests/SelfieRingLightTests.swift @@ -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. + } + +} diff --git a/SelfieRingLightUITests/SelfieRingLightUITests.swift b/SelfieRingLightUITests/SelfieRingLightUITests.swift new file mode 100644 index 0000000..75041ab --- /dev/null +++ b/SelfieRingLightUITests/SelfieRingLightUITests.swift @@ -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 it’s 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() + } + } +} diff --git a/SelfieRingLightUITests/SelfieRingLightUITestsLaunchTests.swift b/SelfieRingLightUITests/SelfieRingLightUITestsLaunchTests.swift new file mode 100644 index 0000000..61d04b4 --- /dev/null +++ b/SelfieRingLightUITests/SelfieRingLightUITestsLaunchTests.swift @@ -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) + } +}