commit 1be8c073d3d921e2939b19fa2fd0125b104c4231 Author: Matt Bruce Date: Sun Jan 4 10:57:30 2026 -0600 initial commit Signed-off-by: Matt Bruce diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..18a9840 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,523 @@ +# 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 + +### Reference documentation before starting work + +Before implementing features or making changes, **always read the following files** for context: + +- **`README.md`**: User-facing documentation with features, usage instructions, and known limitations +- **`AI_IMPLEMENTATION.md`**: Technical implementation guide with architecture patterns, code examples, and troubleshooting + +These files contain important context about existing patterns, workarounds, and design decisions that should inform your implementation. + +### Keep documentation up to date + +- **Always update `README.md`** when adding user-facing features, changing usage patterns, or discovering new limitations. +- **Always update `AI_IMPLEMENTATION.md`** when: + - Adding new architectural patterns or components + - Implementing workarounds for library limitations + - Adding new troubleshooting guidance + - Changing how features are implemented +- 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. + +### Documentation checklist + +When completing a task, verify: +- [ ] `README.md` reflects any user-facing changes +- [ ] `AI_IMPLEMENTATION.md` reflects any implementation changes +- [ ] New workarounds or patterns are documented for future reference +- [ ] Known limitations are listed if discovered + + +## 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/SelfieCam.xcodeproj/project.pbxproj b/SelfieCam.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fe7c284 --- /dev/null +++ b/SelfieCam.xcodeproj/project.pbxproj @@ -0,0 +1,661 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + EA836AF02F0AD00000077F87 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AEF2F0AD00000077F87 /* RevenueCat */; }; + EA836AF22F0AD00000077F87 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AF12F0AD00000077F87 /* RevenueCatUI */; }; + EA836AF42F0AD00000077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AF32F0AD00000077F87 /* Bedrock */; }; + EA836AF62F0AD00000077F87 /* MijickCamera in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AF52F0AD00000077F87 /* MijickCamera */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + EA836ACD2F0ACE8B00077F87 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA836AB72F0ACE8A00077F87 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EA836ABE2F0ACE8A00077F87; + remoteInfo = SelfieCam; + }; + EA836AD72F0ACE8B00077F87 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA836AB72F0ACE8A00077F87 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EA836ABE2F0ACE8A00077F87; + remoteInfo = SelfieCam; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SelfieCam.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieCamTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieCamUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + EA836AC12F0ACE8A00077F87 /* SelfieCam */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SelfieCam; + sourceTree = ""; + }; + EA836ACF2F0ACE8B00077F87 /* SelfieCamTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SelfieCamTests; + sourceTree = ""; + }; + EA836AD92F0ACE8B00077F87 /* SelfieCamUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SelfieCamUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + EA836ABC2F0ACE8A00077F87 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EA836AF62F0AD00000077F87 /* MijickCamera in Frameworks */, + EA836AF42F0AD00000077F87 /* Bedrock in Frameworks */, + EA836AF02F0AD00000077F87 /* RevenueCat in Frameworks */, + EA836AF22F0AD00000077F87 /* RevenueCatUI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA836AC92F0ACE8B00077F87 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA836AD32F0ACE8B00077F87 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + EA836AB62F0ACE8A00077F87 = { + isa = PBXGroup; + children = ( + EA836AC12F0ACE8A00077F87 /* SelfieCam */, + EA836ACF2F0ACE8B00077F87 /* SelfieCamTests */, + EA836AD92F0ACE8B00077F87 /* SelfieCamUITests */, + EA836AC02F0ACE8A00077F87 /* Products */, + ); + sourceTree = ""; + }; + EA836AC02F0ACE8A00077F87 /* Products */ = { + isa = PBXGroup; + children = ( + EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */, + EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.xctest */, + EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + EA836ABE2F0ACE8A00077F87 /* SelfieCam */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA836AE02F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCam" */; + buildPhases = ( + EA836ABB2F0ACE8A00077F87 /* Sources */, + EA836ABC2F0ACE8A00077F87 /* Frameworks */, + EA836ABD2F0ACE8A00077F87 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + EA836AC12F0ACE8A00077F87 /* SelfieCam */, + ); + name = SelfieCam; + packageProductDependencies = ( + EA836AEF2F0AD00000077F87 /* RevenueCat */, + EA836AF12F0AD00000077F87 /* RevenueCatUI */, + EA836AF32F0AD00000077F87 /* Bedrock */, + EA836AF52F0AD00000077F87 /* MijickCamera */, + ); + productName = SelfieCam; + productReference = EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */; + productType = "com.apple.product-type.application"; + }; + EA836ACB2F0ACE8B00077F87 /* SelfieCamTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA836AE32F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCamTests" */; + buildPhases = ( + EA836AC82F0ACE8B00077F87 /* Sources */, + EA836AC92F0ACE8B00077F87 /* Frameworks */, + EA836ACA2F0ACE8B00077F87 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EA836ACE2F0ACE8B00077F87 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + EA836ACF2F0ACE8B00077F87 /* SelfieCamTests */, + ); + name = SelfieCamTests; + packageProductDependencies = ( + ); + productName = SelfieCamTests; + productReference = EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + EA836AD52F0ACE8B00077F87 /* SelfieCamUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA836AE62F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCamUITests" */; + buildPhases = ( + EA836AD22F0ACE8B00077F87 /* Sources */, + EA836AD32F0ACE8B00077F87 /* Frameworks */, + EA836AD42F0ACE8B00077F87 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EA836AD82F0ACE8B00077F87 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + EA836AD92F0ACE8B00077F87 /* SelfieCamUITests */, + ); + name = SelfieCamUITests; + packageProductDependencies = ( + ); + productName = SelfieCamUITests; + productReference = EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + EA836AB72F0ACE8A00077F87 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + EA836ABE2F0ACE8A00077F87 = { + CreatedOnToolsVersion = 26.0; + }; + EA836ACB2F0ACE8B00077F87 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = EA836ABE2F0ACE8A00077F87; + }; + EA836AD52F0ACE8B00077F87 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = EA836ABE2F0ACE8A00077F87; + }; + }; + }; + buildConfigurationList = EA836ABA2F0ACE8A00077F87 /* Build configuration list for PBXProject "SelfieCam" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = EA836AB62F0ACE8A00077F87; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + EA836AEE2F0AD00000077F87 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */, + EA836AF72F0AD00000077F87 /* XCRemoteSwiftPackageReference "Bedrock" */, + EA836AF82F0AD00000077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = EA836AC02F0ACE8A00077F87 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + EA836ABE2F0ACE8A00077F87 /* SelfieCam */, + EA836ACB2F0ACE8B00077F87 /* SelfieCamTests */, + EA836AD52F0ACE8B00077F87 /* SelfieCamUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + EA836ABD2F0ACE8A00077F87 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA836ACA2F0ACE8B00077F87 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA836AD42F0ACE8B00077F87 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + EA836ABB2F0ACE8A00077F87 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA836AC82F0ACE8B00077F87 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA836AD22F0ACE8B00077F87 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + EA836ACE2F0ACE8B00077F87 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA836ABE2F0ACE8A00077F87 /* SelfieCam */; + targetProxy = EA836ACD2F0ACE8B00077F87 /* PBXContainerItemProxy */; + }; + EA836AD82F0ACE8B00077F87 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA836ABE2F0ACE8A00077F87 /* SelfieCam */; + targetProxy = EA836AD72F0ACE8B00077F87 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + EA836ADE2F0ACE8B00077F87 /* 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; + }; + EA836ADF2F0ACE8B00077F87 /* 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; + }; + EA836AE12F0ACE8B00077F87 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SelfieCam/SelfieCam.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to show your selfie preview and capture photos and videos."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access to record audio with your videos."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to save your captured photos and videos."; + 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.SelfieCam; + PRODUCT_NAME = "$(TARGET_NAME)"; + 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; + }; + EA836AE22F0ACE8B00077F87 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SelfieCam/SelfieCam.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to show your selfie preview and capture photos and videos."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access to record audio with your videos."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to save your captured photos and videos."; + 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.SelfieCam; + PRODUCT_NAME = "$(TARGET_NAME)"; + 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; + }; + EA836AE42F0ACE8B00077F87 /* 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.SelfieCamTests; + 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)/SelfieCam.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SelfieCam"; + }; + name = Debug; + }; + EA836AE52F0ACE8B00077F87 /* 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.SelfieCamTests; + 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)/SelfieCam.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SelfieCam"; + }; + name = Release; + }; + EA836AE72F0ACE8B00077F87 /* 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.SelfieCamUITests; + 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 = SelfieCam; + }; + name = Debug; + }; + EA836AE82F0ACE8B00077F87 /* 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.SelfieCamUITests; + 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 = SelfieCam; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + EA836ABA2F0ACE8A00077F87 /* Build configuration list for PBXProject "SelfieCam" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA836ADE2F0ACE8B00077F87 /* Debug */, + EA836ADF2F0ACE8B00077F87 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA836AE02F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCam" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA836AE12F0ACE8B00077F87 /* Debug */, + EA836AE22F0ACE8B00077F87 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA836AE32F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCamTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA836AE42F0ACE8B00077F87 /* Debug */, + EA836AE52F0ACE8B00077F87 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA836AE62F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCamUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA836AE72F0ACE8B00077F87 /* Debug */, + EA836AE82F0ACE8B00077F87 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + EA836AEE2F0AD00000077F87 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/RevenueCat/purchases-ios-spm"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.52.1; + }; + }; + EA836AF72F0AD00000077F87 /* XCRemoteSwiftPackageReference "Bedrock" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "http://192.168.1.128:3000/mbrucedogs/Bedrock"; + requirement = { + branch = master; + kind = branch; + }; + }; + EA836AF82F0AD00000077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "http://192.168.1.128:3000/mbrucedogs/MijickCamera"; + requirement = { + branch = develop; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + EA836AEF2F0AD00000077F87 /* RevenueCat */ = { + isa = XCSwiftPackageProductDependency; + package = EA836AEE2F0AD00000077F87 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */; + productName = RevenueCat; + }; + EA836AF12F0AD00000077F87 /* RevenueCatUI */ = { + isa = XCSwiftPackageProductDependency; + package = EA836AEE2F0AD00000077F87 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */; + productName = RevenueCatUI; + }; + EA836AF32F0AD00000077F87 /* Bedrock */ = { + isa = XCSwiftPackageProductDependency; + package = EA836AF72F0AD00000077F87 /* XCRemoteSwiftPackageReference "Bedrock" */; + productName = Bedrock; + }; + EA836AF52F0AD00000077F87 /* MijickCamera */ = { + isa = XCSwiftPackageProductDependency; + package = EA836AF82F0AD00000077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */; + productName = MijickCamera; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = EA836AB72F0ACE8A00077F87 /* Project object */; +} diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..cd828c2 --- /dev/null +++ b/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "f0492d428a7eee59a60d8a8f71928cd6379f7e9632aa4a32cbd1f1cea00a553b", + "pins" : [ + { + "identity" : "bedrock", + "kind" : "remoteSourceControl", + "location" : "ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git", + "state" : { + "branch" : "develop", + "revision" : "9f4046bfd2c23e76c30dfefe0ed164405b1b0ee8" + } + }, + { + "identity" : "camera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mijick/Camera", + "state" : { + "revision" : "0f02348fcc8fbbc9224c7fbf444f182dc25d0b40", + "version" : "3.0.3" + } + }, + { + "identity" : "purchases-ios-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RevenueCat/purchases-ios-spm", + "state" : { + "revision" : "6238361173aa15a02b99de0d6c003b24fa5fa444", + "version" : "5.52.1" + } + }, + { + "identity" : "timer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mijick/Timer", + "state" : { + "revision" : "342371c33c3f084d82a4818447ba77d858064c85", + "version" : "2.0.0" + } + } + ], + "version" : 3 +} diff --git a/SelfieCam.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/SelfieCam.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..dccc721 --- /dev/null +++ b/SelfieCam.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + SelfieCam.xcscheme_^#shared#^_ + + orderHint + 4 + + + + diff --git a/SelfieCam/App/SelfieCamApp.swift b/SelfieCam/App/SelfieCamApp.swift new file mode 100644 index 0000000..5e4a470 --- /dev/null +++ b/SelfieCam/App/SelfieCamApp.swift @@ -0,0 +1,17 @@ +// +// SelfieCamApp.swift +// SelfieCam +// +// Created by Matt Bruce on 1/4/26. +// + +import SwiftUI + +@main +struct SelfieCamApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/SelfieCam/Configuration/Debug.xcconfig b/SelfieCam/Configuration/Debug.xcconfig new file mode 100644 index 0000000..73ceabe --- /dev/null +++ b/SelfieCam/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/SelfieCam/Configuration/Release.xcconfig b/SelfieCam/Configuration/Release.xcconfig new file mode 100644 index 0000000..8d69132 --- /dev/null +++ b/SelfieCam/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/SelfieCam/Configuration/Secrets.xcconfig b/SelfieCam/Configuration/Secrets.xcconfig new file mode 100644 index 0000000..e2f2929 --- /dev/null +++ b/SelfieCam/Configuration/Secrets.xcconfig @@ -0,0 +1,10 @@ +// Secrets.xcconfig +// +// ⚠️ DO NOT COMMIT THIS FILE TO VERSION CONTROL +// This file contains sensitive API keys and secrets. +// +// For CI/CD: Set these values via environment variables in your build system. + +// 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/SelfieCam/Configuration/Secrets.xcconfig.template b/SelfieCam/Configuration/Secrets.xcconfig.template new file mode 100644 index 0000000..2712067 --- /dev/null +++ b/SelfieCam/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/SelfieCam/Features/Camera/ContentView.swift b/SelfieCam/Features/Camera/ContentView.swift new file mode 100644 index 0000000..737d668 --- /dev/null +++ b/SelfieCam/Features/Camera/ContentView.swift @@ -0,0 +1,106 @@ +import SwiftUI +import MijickCamera +import Bedrock + +/// Main camera view with ring light effect using MijickCamera +struct ContentView: View { + @State private var settings = SettingsViewModel() + @State private var premiumManager = PremiumManager() + @State private var showSettings = false + @State private var showPaywall = false + + // Post-capture state + @State private var capturedImage: UIImage? + @State private var capturedVideoURL: URL? + @State private var showPostCapture = false + + /// Ring size clamped to reasonable max + private var effectiveRingSize: CGFloat { + let maxRing = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) * 0.2 + return min(settings.ringSize, maxRing) + } + + var body: some View { + ZStack { + // Ring light background + settings.lightColor + .ignoresSafeArea() + + // MijickCamera with default UI + MCamera() + .setCameraPosition(.front) // Default to front camera for selfies + .onImageCaptured { image, _ in + capturedImage = image + showPostCapture = true + } + .onVideoCaptured { url, _ in + capturedVideoURL = url + showPostCapture = true + } + .startSession() + .padding(.horizontal, effectiveRingSize) + .padding(.top, effectiveRingSize) + .padding(.bottom, effectiveRingSize) + + // Settings button overlay (top right corner of camera area) + VStack { + HStack { + Spacer() + + Button { + showSettings = true + } label: { + Image(systemName: "gearshape.fill") + .font(.title3) + .foregroundStyle(.white) + .padding(Design.Spacing.medium) + .background(.ultraThinMaterial, in: Circle()) + .shadow(radius: Design.Shadow.radiusSmall) + } + .accessibilityLabel("Settings") + } + .padding(.horizontal, effectiveRingSize + Design.Spacing.medium) + .padding(.top, effectiveRingSize + Design.Spacing.medium) + + Spacer() + } + } + .ignoresSafeArea() + .sheet(isPresented: $showSettings) { + SettingsView(viewModel: settings, showPaywall: $showPaywall) + } + .sheet(isPresented: $showPaywall) { + ProPaywallView() + } + .fullScreenCover(isPresented: $showPostCapture) { + PostCapturePreviewView( + capturedImage: capturedImage, + capturedVideoURL: capturedVideoURL, + isAutoSaveEnabled: settings.isAutoSaveEnabled, + onRetake: { + capturedImage = nil + capturedVideoURL = nil + showPostCapture = false + }, + onSave: { + saveCapture() + showPostCapture = false + } + ) + } + } + + // MARK: - Save Capture + + private func saveCapture() { + if let image = capturedImage { + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } + capturedImage = nil + capturedVideoURL = nil + } +} + +#Preview { + ContentView() +} diff --git a/SelfieCam/Features/Camera/GridOverlay.swift b/SelfieCam/Features/Camera/GridOverlay.swift new file mode 100644 index 0000000..09604bd --- /dev/null +++ b/SelfieCam/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/SelfieCam/Features/Camera/PostCapturePreviewView.swift b/SelfieCam/Features/Camera/PostCapturePreviewView.swift new file mode 100644 index 0000000..248be98 --- /dev/null +++ b/SelfieCam/Features/Camera/PostCapturePreviewView.swift @@ -0,0 +1,232 @@ +import SwiftUI +import AVKit +import Bedrock + +// MARK: - Post Capture Preview View + +/// Full-screen preview shown after photo/video capture +struct PostCapturePreviewView: View { + let capturedImage: UIImage? + let capturedVideoURL: URL? + let isAutoSaveEnabled: Bool + let onRetake: () -> Void + let onSave: () -> Void + + @State private var player: AVPlayer? + @State private var showShareSheet = false + @State private var toastMessage: String? + + var body: some View { + ZStack { + // Dark background + Color.black.ignoresSafeArea() + + // Media preview + mediaPreview + + // Controls overlay + VStack { + // Top bar with close button + topBar + + Spacer() + + // Bottom toolbar + bottomToolbar + } + + // Toast notification + if let message = toastMessage { + toastView(message: message) + } + } + .onAppear { + setupVideoPlayerIfNeeded() + if isAutoSaveEnabled { + autoSave() + } + } + .onDisappear { + player?.pause() + } + .sheet(isPresented: $showShareSheet) { + if let image = capturedImage { + ShareSheet(items: [image]) + } else if let url = capturedVideoURL { + ShareSheet(items: [url]) + } + } + } + + // MARK: - Media Preview + + @ViewBuilder + private var mediaPreview: some View { + if let image = capturedImage { + Image(uiImage: image) + .resizable() + .scaledToFit() + .accessibilityLabel(String(localized: "Captured photo")) + } else if let _ = capturedVideoURL, let player { + VideoPlayer(player: player) + .onAppear { + player.play() + } + .accessibilityLabel(String(localized: "Captured video")) + } else { + ProgressView() + .tint(.white) + } + } + + // MARK: - Top Bar + + private var topBar: some View { + HStack { + Button { + onRetake() + } label: { + Image(systemName: "xmark") + .font(.title2) + .foregroundStyle(.white) + .padding(Design.Spacing.medium) + .background(.ultraThinMaterial, in: .circle) + } + .accessibilityLabel(String(localized: "Close preview")) + + Spacer() + } + .padding(.horizontal, Design.Spacing.large) + .padding(.top, Design.Spacing.medium) + } + + // MARK: - Bottom Toolbar + + private var bottomToolbar: some View { + HStack(spacing: Design.Spacing.xLarge) { + // Retake button + ToolbarButton( + title: String(localized: "Retake"), + systemImage: "arrow.counterclockwise", + action: onRetake + ) + + Spacer() + + // Save button (if not auto-saved) + if !isAutoSaveEnabled { + ToolbarButton( + title: String(localized: "Save"), + systemImage: "square.and.arrow.down", + action: { + onSave() + showToast(String(localized: "Saved to Photos")) + } + ) + + Spacer() + } + + // Share button + ToolbarButton( + title: String(localized: "Share"), + systemImage: "square.and.arrow.up", + action: { showShareSheet = true } + ) + } + .padding(.horizontal, Design.Spacing.xxLarge) + .padding(.vertical, Design.Spacing.large) + .background(.ultraThinMaterial) + } + + // MARK: - Toast View + + private func toastView(message: String) -> some View { + VStack { + Spacer() + + Text(message) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.medium) + .background(.ultraThinMaterial, in: .capsule) + .padding(.bottom, 100) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.easeInOut, value: toastMessage) + } + + // MARK: - Video Setup + + private func setupVideoPlayerIfNeeded() { + guard let url = capturedVideoURL else { return } + player = AVPlayer(url: url) + } + + // MARK: - Auto Save + + private func autoSave() { + if let image = capturedImage { + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + showToast(String(localized: "Saved to Photos")) + } + // Video saving would go here + } + + private func showToast(_ message: String) { + withAnimation { + toastMessage = message + } + + Task { + try? await Task.sleep(for: .seconds(2)) + withAnimation { + toastMessage = nil + } + } + } +} + +// MARK: - Toolbar Button + +private struct ToolbarButton: View { + let title: String + let systemImage: String + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: Design.Spacing.xxSmall) { + Image(systemName: systemImage) + .font(.title2) + Text(title) + .font(.system(size: Design.BaseFontSize.caption)) + } + .foregroundStyle(.white) + } + .accessibilityLabel(title) + } +} + +// MARK: - Share Sheet + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +#Preview { + PostCapturePreviewView( + capturedImage: UIImage(systemName: "photo"), + capturedVideoURL: nil, + isAutoSaveEnabled: false, + onRetake: {}, + onSave: {} + ) +} diff --git a/SelfieCam/Features/Paywall/ProPaywallView.swift b/SelfieCam/Features/Paywall/ProPaywallView.swift new file mode 100644 index 0000000..d934524 --- /dev/null +++ b/SelfieCam/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/SelfieCam/Features/Settings/SettingsView.swift b/SelfieCam/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..4510ec1 --- /dev/null +++ b/SelfieCam/Features/Settings/SettingsView.swift @@ -0,0 +1,600 @@ +import SwiftUI +import Bedrock + +struct SettingsView: View { + @Bindable var viewModel: SettingsViewModel + @Binding var showPaywall: Bool + @Environment(\.dismiss) private var dismiss + @State private var premiumManager = PremiumManager() + + /// Whether premium features are unlocked (for UI gating) + private var isPremiumUnlocked: Bool { + premiumManager.isPremiumUnlocked + } + + 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 + + // 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: - Capture Section + + SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle") + + SettingsToggle( + title: String(localized: "Auto-Save"), + subtitle: String(localized: "Automatically save captures to Photo Library"), + isOn: $viewModel.isAutoSaveEnabled + ) + .accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture")) + + // MARK: - Pro Section + + SettingsSectionHeader(title: "Pro", systemImage: "crown") + + proSection + + // MARK: - Sync Section + + SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud") + + iCloudSyncSection + + // MARK: - About Section + + SettingsSectionHeader(title: "About", systemImage: "info.circle") + + acknowledgmentsSection + + 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 + ) { + // Preset colors + ForEach(RingLightColor.allPresets) { preset in + ColorPresetButton( + preset: preset, + isSelected: viewModel.selectedLightColor == preset, + isPremiumUnlocked: isPremiumUnlocked + ) { + // Premium colors require unlock + if preset.isPremium && !isPremiumUnlocked { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showPaywall = true + } + } else { + viewModel.selectedLightColor = preset + } + } + } + + // Custom color picker (premium) - one-step: opens picker, selects on change + CustomColorPickerButton( + customColor: Binding( + get: { viewModel.customColor }, + set: { viewModel.selectCustomColor($0) } + ), + isSelected: viewModel.isCustomColorSelected, + isPremiumUnlocked: isPremiumUnlocked, + onPremiumRequired: { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showPaywall = true + } + } + ) + } + } + .padding(.vertical, Design.Spacing.xSmall) + } + + // 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: - Pro Section + + private var proSection: some View { + Button { + dismiss() + // Small delay to allow sheet to dismiss before showing paywall + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showPaywall = true + } + } label: { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: "crown.fill") + .font(.title2) + .foregroundStyle(Color.Status.warning) + + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(String(localized: "Upgrade to Pro")) + .font(.system(size: Design.BaseFontSize.medium, weight: .semibold)) + .foregroundStyle(.white) + + Text(String(localized: "Unlock premium colors, video, and more")) + .font(.system(size: Design.BaseFontSize.caption)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.body) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .padding(Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.Accent.primary.opacity(Design.Opacity.subtle)) + .strokeBorder(Color.Accent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Upgrade to Pro")) + .accessibilityHint(String(localized: "Opens upgrade options")) + } + + // 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: - Acknowledgments Section + + private var acknowledgmentsSection: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + NavigationLink { + LicensesView() + } label: { + HStack { + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(String(localized: "Open Source Licenses")) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + + Text(String(localized: "Third-party libraries used in this app")) + .font(.system(size: Design.BaseFontSize.caption)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: Design.BaseFontSize.caption)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .padding(Design.Spacing.medium) + .background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) + } + .buttonStyle(.plain) + } + } +} + +// MARK: - Licenses View + +struct LicensesView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + // MijickCamera + licenseCard( + name: "MijickCamera", + url: "https://github.com/Mijick/Camera", + license: "Apache 2.0 License", + description: "Camera framework for SwiftUI. Created by Tomasz Kurylik at Mijick." + ) + + // RevenueCat + licenseCard( + name: "RevenueCat", + url: "https://github.com/RevenueCat/purchases-ios", + license: "MIT License", + description: "In-app subscriptions made easy." + ) + } + .padding(Design.Spacing.large) + } + .background(Color.Surface.overlay) + .navigationTitle(String(localized: "Open Source Licenses")) + .navigationBarTitleDisplayMode(.inline) + } + + private func licenseCard(name: String, url: String, license: String, description: String) -> some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Text(name) + .font(.system(size: Design.BaseFontSize.medium, weight: .bold)) + .foregroundStyle(.white) + + Text(description) + .font(.system(size: Design.BaseFontSize.caption)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + HStack { + Label(license, systemImage: "doc.text") + .font(.system(size: Design.BaseFontSize.xSmall)) + .foregroundStyle(Color.Accent.primary) + + Spacer() + + if let linkURL = URL(string: url) { + Link(destination: linkURL) { + Label(String(localized: "View on GitHub"), systemImage: "arrow.up.right.square") + .font(.system(size: Design.BaseFontSize.xSmall)) + .foregroundStyle(Color.Accent.primary) + } + } + } + .padding(.top, Design.Spacing.xSmall) + } + .padding(Design.Spacing.medium) + .background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) + } +} + +// MARK: - Color Preset Button + +private struct ColorPresetButton: View { + let preset: RingLightColor + let isSelected: Bool + let isPremiumUnlocked: Bool + let action: () -> Void + + /// Whether this premium color is locked (not available) + private var isLocked: Bool { + preset.isPremium && !isPremiumUnlocked + } + + var body: some View { + Button(action: action) { + VStack(spacing: Design.Spacing.xxSmall) { + ZStack { + 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 + ) + + // Lock overlay for locked premium colors + if isLocked { + Circle() + .fill(.black.opacity(Design.Opacity.medium)) + .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) + + Image(systemName: "lock.fill") + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white) + } + } + + Text(preset.name) + .font(.system(size: Design.BaseFontSize.xSmall)) + .foregroundStyle(.white.opacity(isSelected ? 1.0 : (isLocked ? Design.Opacity.medium : Design.Opacity.accent))) + .lineLimit(1) + .minimumScaleFactor(Design.MinScaleFactor.tight) + + if preset.isPremium { + Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown") + .font(.system(size: Design.BaseFontSize.xxSmall)) + .foregroundStyle(isPremiumUnlocked ? Color.Status.warning : Color.Status.warning.opacity(Design.Opacity.medium)) + } + } + .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(isLocked ? String(localized: "Locked. Tap to unlock with Pro.") : (preset.isPremium ? String(localized: "Premium color") : "")) + } +} + +// MARK: - Custom Color Picker Button + +/// Custom color picker with premium gating +private struct CustomColorPickerButton: View { + @Binding var customColor: Color + let isSelected: Bool + let isPremiumUnlocked: Bool + let onPremiumRequired: () -> Void + + /// Whether the custom color is locked + private var isLocked: Bool { !isPremiumUnlocked } + + var body: some View { + if isPremiumUnlocked { + // Premium users get the full color picker + VStack(spacing: Design.Spacing.xxSmall) { + ColorPicker( + selection: $customColor, + supportsOpacity: false + ) { + EmptyView() + } + .labelsHidden() + .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) + .clipShape(.circle) + .overlay( + Circle() + .strokeBorder( + isSelected ? Color.Accent.primary : Color.Border.subtle, + lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin + ) + ) + .shadow( + color: customColor.opacity(Design.Opacity.light), + radius: isSelected ? Design.Shadow.radiusSmall : 0 + ) + + Text(String(localized: "Custom")) + .font(.system(size: Design.BaseFontSize.xSmall)) + .foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent)) + .lineLimit(1) + .minimumScaleFactor(Design.MinScaleFactor.tight) + + Image(systemName: "crown.fill") + .font(.system(size: Design.BaseFontSize.xxSmall)) + .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) + ) + .accessibilityLabel(String(localized: "Custom color")) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } else { + // Non-premium users see a locked button that shows paywall + Button(action: onPremiumRequired) { + VStack(spacing: Design.Spacing.xxSmall) { + ZStack { + // Rainbow gradient to show what's possible + Circle() + .fill( + AngularGradient( + colors: [.red, .orange, .yellow, .green, .blue, .purple, .red], + center: .center + ) + ) + .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) + .overlay( + Circle() + .strokeBorder(Color.Border.subtle, lineWidth: Design.LineWidth.thin) + ) + + // Lock overlay + Circle() + .fill(.black.opacity(Design.Opacity.medium)) + .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) + + Image(systemName: "lock.fill") + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white) + } + + Text(String(localized: "Custom")) + .font(.system(size: Design.BaseFontSize.xSmall)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + .lineLimit(1) + .minimumScaleFactor(Design.MinScaleFactor.tight) + + Image(systemName: "crown") + .font(.system(size: Design.BaseFontSize.xxSmall)) + .foregroundStyle(Color.Status.warning.opacity(Design.Opacity.medium)) + } + .padding(Design.Spacing.xSmall) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Custom color")) + .accessibilityHint(String(localized: "Locked. Tap to unlock with Pro.")) + } + } +} + +#Preview { + SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false)) + .preferredColorScheme(.dark) +} diff --git a/SelfieCam/Features/Settings/SettingsViewModel.swift b/SelfieCam/Features/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..1586b5a --- /dev/null +++ b/SelfieCam/Features/Settings/SettingsViewModel.swift @@ -0,0 +1,314 @@ +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() + + /// Debounce task for slider values + private var debounceTask: Task? + + /// Debounce delay for continuous slider updates (in seconds) + private static let debounceDelay: Duration = .milliseconds(300) + + /// Cached ring size for immediate UI updates (before debounced save) + private var _cachedRingSize: CGFloat? + + // MARK: - Observable Properties (Synced) + + /// Ring border size in points (debounced save) + var ringSize: CGFloat { + get { _cachedRingSize ?? cloudSync.data.ringSize } + set { + _cachedRingSize = newValue + debouncedSave(key: "ringSize") { + self._cachedRingSize = nil + self.updateSettings { $0.ringSize = newValue } + } + } + } + + /// Cached light color ID for immediate UI updates + private var _cachedLightColorId: String? + + /// ID of the selected light color preset + var lightColorId: String { + get { _cachedLightColorId ?? cloudSync.data.lightColorId } + set { + _cachedLightColorId = newValue + updateSettings { $0.lightColorId = newValue } + } + } + + /// Cached custom color for immediate UI updates + private var _cachedCustomColor: Color? + + /// Custom color for ring light (premium feature, debounced save) + var customColor: Color { + get { + _cachedCustomColor ?? Color( + red: cloudSync.data.customColorRed, + green: cloudSync.data.customColorGreen, + blue: cloudSync.data.customColorBlue + ) + } + set { + _cachedCustomColor = newValue + let rgb = CustomColorRGB(from: newValue) + debouncedSave(key: "customColor") { + self._cachedCustomColor = nil + self.updateSettings { + $0.customColorRed = rgb.red + $0.customColorGreen = rgb.green + $0.customColorBlue = rgb.blue + } + } + } + } + + /// 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 } } + } + + /// Whether captures are auto-saved to Photo Library + var isAutoSaveEnabled: Bool { + get { cloudSync.data.isAutoSaveEnabled } + set { updateSettings { $0.isAutoSaveEnabled = 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, customColor: customColor) } + set { + lightColorId = newValue.id + if newValue.isCustom { + customColor = newValue.color + } + } + } + + var lightColor: Color { + if lightColorId == RingLightColor.customId { + return customColor + } + return selectedLightColor.color + } + + /// Whether custom color is currently selected + var isCustomColorSelected: Bool { + lightColorId == RingLightColor.customId + } + + /// Sets the custom color and selects it + func selectCustomColor(_ color: Color) { + customColor = color + lightColorId = RingLightColor.customId + } + + // 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 immediately + private func updateSettings(_ transform: (inout SyncedSettings) -> Void) { + cloudSync.update { settings in + transform(&settings) + settings.modificationCount += 1 + } + } + + /// Debounces save operations for continuous values like sliders + private func debouncedSave(key: String, action: @escaping () -> Void) { + // Cancel any pending debounce + debounceTask?.cancel() + + // Schedule debounced save + debounceTask = Task { + try? await Task.sleep(for: Self.debounceDelay) + + guard !Task.isCancelled else { return } + + action() + } + } + + // 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 + } +} + +// 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/SelfieCam/Preview Content/Preview Assets.xcassets/Contents.json b/SelfieCam/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SelfieCam/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SelfieCam/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/SelfieCam/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/SelfieCam/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SelfieCam/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/SelfieCam/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/SelfieCam/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/SelfieCam/Resources/Assets.xcassets/Contents.json b/SelfieCam/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SelfieCam/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SelfieCam/Resources/Localizable.xcstrings b/SelfieCam/Resources/Localizable.xcstrings new file mode 100644 index 0000000..71bb860 --- /dev/null +++ b/SelfieCam/Resources/Localizable.xcstrings @@ -0,0 +1,315 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%lld points" : { + "comment" : "The value of the ring size slider, displayed in parentheses.", + "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 + }, + "Auto-Save" : { + "comment" : "Title of a toggle that enables automatic saving of captured photos and videos to the user's Photo Library.", + "isCommentAutoGenerated" : true + }, + "Automatically save captures to Photo Library" : { + "comment" : "A toggle option in the Settings view that allows the user to enable or disable automatic saving of captured photos and videos to the user's Photo Library.", + "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 + }, + "Cancel" : { + "comment" : "The text for a button that dismisses the current view.", + "isCommentAutoGenerated" : true + }, + "Captured photo" : { + "comment" : "A label describing a captured photo.", + "isCommentAutoGenerated" : true + }, + "Captured video" : { + "comment" : "A label describing a captured video.", + "isCommentAutoGenerated" : true + }, + "Close preview" : { + "comment" : "A button label that closes the preview screen.", + "isCommentAutoGenerated" : true + }, + "Cool Lavender" : { + "comment" : "Name of a ring light color preset.", + "isCommentAutoGenerated" : true + }, + "Custom" : { + "comment" : "A label displayed below the rainbow gradient circle in the custom color button.", + "isCommentAutoGenerated" : true + }, + "Custom color" : { + "comment" : "An accessibility label for the custom color button.", + "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 + }, + "Front Flash" : { + "comment" : "Title of a toggle in the Settings view that controls whether the front flash is enabled.", + "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 + }, + "Hides preview during capture for a flash effect" : { + "comment" : "Subtitle for the \"Front Flash\" toggle in the Settings view.", + "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 + }, + "Locked. Tap to unlock with Pro." : { + "comment" : "A hint that appears when a user taps on a color preset button.", + "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 + }, + "Open Source Licenses" : { + "comment" : "A heading displayed above a list of open source licenses used in the app.", + "isCommentAutoGenerated" : true + }, + "Opens upgrade options" : { + "comment" : "An accessibility hint for the \"Upgrade to Pro\" button that indicates it opens upgrade options.", + "isCommentAutoGenerated" : true + }, + "Photo" : { + + }, + "Premium color" : { + "comment" : "An accessibility hint for a premium color option in the color preset button.", + "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 + }, + "Retake" : { + "comment" : "Title for a button that allows the user to retake a captured photo or video.", + "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 + }, + "Save" : { + "comment" : "Title for a button that saves the currently captured photo or video to the user's photo library.", + "isCommentAutoGenerated" : true + }, + "Saved to Photos" : { + "comment" : "Text shown as a toast message when a photo is successfully saved to Photos.", + "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 + }, + "Share" : { + "comment" : "Title for a button that shares the captured media.", + "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 + }, + "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$@" + } + } + } + }, + "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..." : { + + }, + "Third-party libraries used in this app" : { + "comment" : "A description of the third-party libraries used in this app.", + "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 + }, + "Unlock premium colors, video, and more" : { + "comment" : "A description of the benefits of upgrading to the Pro version of the app.", + "isCommentAutoGenerated" : true + }, + "Upgrade to Pro" : { + "comment" : "A button label that prompts users to upgrade to the premium version of the app.", + "isCommentAutoGenerated" : true + }, + "Uses the ring light as a flash when taking photos" : { + "comment" : "An accessibility hint for the \"Front Flash\" toggle in the Settings view.", + "isCommentAutoGenerated" : true + }, + "Video" : { + "comment" : "Display name for the \"Video\" capture mode.", + "isCommentAutoGenerated" : true + }, + "View on GitHub" : { + "comment" : "A button label that says \"View on GitHub\".", + "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, photos and videos are saved immediately after capture" : { + "comment" : "A hint provided by the \"Auto-Save\" toggle in the Settings view, explaining that photos and videos are saved immediately after capture when enabled.", + "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/SelfieCam/SelfieCam.entitlements b/SelfieCam/SelfieCam.entitlements new file mode 100644 index 0000000..1a1a421 --- /dev/null +++ b/SelfieCam/SelfieCam.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) + + diff --git a/SelfieCam/Shared/Color+Extensions.swift b/SelfieCam/Shared/Color+Extensions.swift new file mode 100644 index 0000000..b3b55c2 --- /dev/null +++ b/SelfieCam/Shared/Color+Extensions.swift @@ -0,0 +1,110 @@ +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: - Custom Color RGB Storage + +/// Stores RGB values for custom colors (Codable-friendly) +struct CustomColorRGB: Codable, Equatable, Sendable { + var red: Double + var green: Double + var blue: Double + + static let defaultWhite = CustomColorRGB(red: 1.0, green: 1.0, blue: 1.0) + + var color: Color { + Color(red: red, green: green, blue: blue) + } + + init(red: Double, green: Double, blue: Double) { + self.red = red + self.green = green + self.blue = blue + } + + init(from color: Color) { + let uiColor = UIColor(color) + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + uiColor.getRed(&r, green: &g, blue: &b, alpha: nil) + self.red = Double(r) + self.green = Double(g) + self.blue = Double(b) + } +} + +// 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 + let isCustom: Bool + + init(id: String, name: String, color: Color, isPremium: Bool, isCustom: Bool = false) { + self.id = id + self.name = name + self.color = color + self.isPremium = isPremium + self.isCustom = isCustom + } + + 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) + ] + + /// The custom color option (premium only) + static let customId = "custom" + + static func custom(with color: Color) -> RingLightColor { + RingLightColor( + id: customId, + name: String(localized: "Custom"), + color: color, + isPremium: true, + isCustom: true + ) + } + + static func fromId(_ id: String, customColor: Color? = nil) -> RingLightColor { + if id == customId, let customColor { + return custom(with: customColor) + } + return allPresets.first { $0.id == id } ?? allPresets[0] + } +} diff --git a/SelfieCam/Shared/DesignConstants.swift b/SelfieCam/Shared/DesignConstants.swift new file mode 100644 index 0000000..485e98e --- /dev/null +++ b/SelfieCam/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/SelfieCam/Shared/Premium/PremiumManager.swift b/SelfieCam/Shared/Premium/PremiumManager.swift new file mode 100644 index 0000000..dd54124 --- /dev/null +++ b/SelfieCam/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/SelfieCam/Shared/Protocols/CaptureControlling.swift b/SelfieCam/Shared/Protocols/CaptureControlling.swift new file mode 100644 index 0000000..8ca8774 --- /dev/null +++ b/SelfieCam/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/SelfieCam/Shared/Protocols/PremiumManaging.swift b/SelfieCam/Shared/Protocols/PremiumManaging.swift new file mode 100644 index 0000000..b1e4678 --- /dev/null +++ b/SelfieCam/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/SelfieCam/Shared/Protocols/RingLightConfigurable.swift b/SelfieCam/Shared/Protocols/RingLightConfigurable.swift new file mode 100644 index 0000000..c54e4ce --- /dev/null +++ b/SelfieCam/Shared/Protocols/RingLightConfigurable.swift @@ -0,0 +1,22 @@ +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 } + + /// 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/SelfieCam/Shared/Storage/SyncedSettings.swift b/SelfieCam/Shared/Storage/SyncedSettings.swift new file mode 100644 index 0000000..2626717 --- /dev/null +++ b/SelfieCam/Shared/Storage/SyncedSettings.swift @@ -0,0 +1,143 @@ +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" + + /// Custom color RGB values (for premium custom color picker) + var customColorRed: Double = 1.0 + var customColorGreen: Double = 1.0 + var customColorBlue: 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" + + /// Whether captures are auto-saved to Photo Library + var isAutoSaveEnabled: Bool = true + + // 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, + 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.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 customColorRed + case customColorGreen + case customColorBlue + case isFrontFlashEnabled + case isMirrorFlipped + case isSkinSmoothingEnabled + case selectedTimerRaw + case isGridVisible + case currentZoomFactor + case selectedCaptureModeRaw + case isAutoSaveEnabled + } +} + +// MARK: - Equatable + +extension SyncedSettings: Equatable { + static func == (lhs: SyncedSettings, rhs: SyncedSettings) -> Bool { + lhs.ringSizeValue == rhs.ringSizeValue && + lhs.lightColorId == rhs.lightColorId && + lhs.customColorRed == rhs.customColorRed && + lhs.customColorGreen == rhs.customColorGreen && + lhs.customColorBlue == rhs.customColorBlue && + 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 && + lhs.isAutoSaveEnabled == rhs.isAutoSaveEnabled + } +} diff --git a/SelfieCamTests/SelfieCamTests.swift b/SelfieCamTests/SelfieCamTests.swift new file mode 100644 index 0000000..02f1400 --- /dev/null +++ b/SelfieCamTests/SelfieCamTests.swift @@ -0,0 +1,17 @@ +// +// SelfieCamTests.swift +// SelfieCamTests +// +// Created by Matt Bruce on 1/4/26. +// + +import Testing +@testable import SelfieCam + +struct SelfieCamTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/SelfieCamUITests/SelfieCamUITests.swift b/SelfieCamUITests/SelfieCamUITests.swift new file mode 100644 index 0000000..406bc4a --- /dev/null +++ b/SelfieCamUITests/SelfieCamUITests.swift @@ -0,0 +1,41 @@ +// +// SelfieCamUITests.swift +// SelfieCamUITests +// +// Created by Matt Bruce on 1/4/26. +// + +import XCTest + +final class SelfieCamUITests: 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/SelfieCamUITests/SelfieCamUITestsLaunchTests.swift b/SelfieCamUITests/SelfieCamUITestsLaunchTests.swift new file mode 100644 index 0000000..d5783b3 --- /dev/null +++ b/SelfieCamUITests/SelfieCamUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// SelfieCamUITestsLaunchTests.swift +// SelfieCamUITests +// +// Created by Matt Bruce on 1/4/26. +// + +import XCTest + +final class SelfieCamUITestsLaunchTests: 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) + } +}