Initial commit: SelfieRingLight app
Features: - Camera preview with ring light effect - Adjustable ring size with slider - Light color presets (white, warm cream, ice blue, soft pink, warm amber, cool lavender) - Light intensity control (opacity) - Front flash (hides preview during capture) - True mirror mode - Skin smoothing toggle - Grid overlay (rule of thirds) - Self-timer options - Photo and video capture modes - iCloud sync for settings across devices Architecture: - SwiftUI with @Observable view models - Protocol-oriented design (RingLightConfigurable, CaptureControlling) - Bedrock design system integration - CloudSyncManager for iCloud settings sync - RevenueCat for premium features
This commit is contained in:
commit
74e65829de
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcscmblueprint
|
||||||
|
*.xccheckout
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
Packages/
|
||||||
|
Package.pins
|
||||||
|
Package.resolved
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# CocoaPods (if used in future)
|
||||||
|
Pods/
|
||||||
|
|
||||||
|
# Carthage (if used in future)
|
||||||
|
Carthage/Build/
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Secrets and API Keys - NEVER commit these
|
||||||
|
**/Secrets.xcconfig
|
||||||
|
**/Secrets.swift
|
||||||
|
*.secret
|
||||||
|
*.secrets
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/**/*.png
|
||||||
|
fastlane/test_output
|
||||||
|
|
||||||
|
# Code Injection
|
||||||
|
iOSInjectionProject/
|
||||||
499
AGENTS.md
Normal file
499
AGENTS.md
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
# Agent guide for Swift and SwiftUI
|
||||||
|
|
||||||
|
This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage.
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and related frameworks. Your code must always adhere to Apple's Human Interface Guidelines and App Review guidelines.
|
||||||
|
|
||||||
|
|
||||||
|
## Core instructions
|
||||||
|
|
||||||
|
- Target iOS 18.0 or later.
|
||||||
|
- Swift 6 or later, using modern Swift concurrency.
|
||||||
|
- SwiftUI backed up by `@Observable` classes for shared data.
|
||||||
|
- **Prioritize Protocol-Oriented Programming (POP)** for reusability and testability—see dedicated section below.
|
||||||
|
- Avoid UIKit unless requested.
|
||||||
|
|
||||||
|
|
||||||
|
## Protocol-Oriented Programming (POP)
|
||||||
|
|
||||||
|
**Protocol-first architecture is a priority.** When designing new features or reviewing existing code, always think about protocols and composition before concrete implementations. This enables code reuse across modules, easier testing, and cleaner architecture.
|
||||||
|
|
||||||
|
### When architecting new code:
|
||||||
|
|
||||||
|
1. **Start with the protocol**: Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol.
|
||||||
|
2. **Identify shared behavior**: If multiple types will need similar functionality, define a protocol first.
|
||||||
|
3. **Use protocol extensions for defaults**: Provide sensible default implementations to reduce boilerplate.
|
||||||
|
4. **Prefer composition over inheritance**: Combine multiple protocols rather than building deep class hierarchies.
|
||||||
|
|
||||||
|
### When reviewing existing code for reuse:
|
||||||
|
|
||||||
|
1. **Look for duplicated patterns**: If you see similar logic across modules, extract a protocol to a shared location.
|
||||||
|
2. **Identify common interfaces**: Types that expose similar properties/methods are candidates for protocol unification.
|
||||||
|
3. **Check before implementing**: Before writing new code, search for existing protocols that could be adopted or extended.
|
||||||
|
4. **Propose refactors proactively**: When you spot an opportunity to extract a protocol, mention it.
|
||||||
|
|
||||||
|
### Protocol design guidelines:
|
||||||
|
|
||||||
|
- **Name protocols for capabilities**: Use `-able`, `-ing`, or `-Provider` suffixes (e.g., `Searchable`, `DataLoading`, `ContentProvider`).
|
||||||
|
- **Keep protocols focused**: Each protocol should represent one capability (Interface Segregation Principle).
|
||||||
|
- **Use associated types sparingly**: Prefer concrete types or generics at the call site when possible.
|
||||||
|
- **Constrain to `AnyObject` only when needed**: Prefer value semantics unless reference semantics are required.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**❌ BAD - Concrete implementations without protocols:**
|
||||||
|
```swift
|
||||||
|
// Features/Users/UserListViewModel.swift
|
||||||
|
@Observable @MainActor
|
||||||
|
class UserListViewModel {
|
||||||
|
var items: [User] = []
|
||||||
|
var isLoading: Bool = false
|
||||||
|
func load() async { ... }
|
||||||
|
func refresh() async { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features/Products/ProductListViewModel.swift - duplicates the same pattern
|
||||||
|
@Observable @MainActor
|
||||||
|
class ProductListViewModel {
|
||||||
|
var items: [Product] = []
|
||||||
|
var isLoading: Bool = false
|
||||||
|
func load() async { ... }
|
||||||
|
func refresh() async { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ GOOD - Protocol in shared module, adopted by features:**
|
||||||
|
```swift
|
||||||
|
// Shared/Protocols/DataLoading.swift
|
||||||
|
protocol DataLoading: AnyObject {
|
||||||
|
associatedtype Item: Identifiable
|
||||||
|
var items: [Item] { get set }
|
||||||
|
var isLoading: Bool { get set }
|
||||||
|
|
||||||
|
func load() async
|
||||||
|
func refresh() async
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DataLoading {
|
||||||
|
func refresh() async {
|
||||||
|
items = []
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features/Users/UserListViewModel.swift - adopts protocol
|
||||||
|
@Observable @MainActor
|
||||||
|
class UserListViewModel: DataLoading {
|
||||||
|
var items: [User] = []
|
||||||
|
var isLoading: Bool = false
|
||||||
|
|
||||||
|
func load() async { ... }
|
||||||
|
// refresh() comes from protocol extension
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ BAD - View only works with one concrete type:**
|
||||||
|
```swift
|
||||||
|
struct ItemListView: View {
|
||||||
|
@Bindable var viewModel: UserListViewModel
|
||||||
|
// Tightly coupled to Users
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ GOOD - View works with any DataLoading type:**
|
||||||
|
```swift
|
||||||
|
struct ItemListView<ViewModel: DataLoading & Observable>: View {
|
||||||
|
@Bindable var viewModel: ViewModel
|
||||||
|
// Reusable across all features
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common protocols to consider extracting:
|
||||||
|
|
||||||
|
| Capability | Protocol Name | Shared By |
|
||||||
|
|------------|---------------|-----------|
|
||||||
|
| Loading data | `DataLoading` | All list features |
|
||||||
|
| Search/filter | `Searchable` | Features with search |
|
||||||
|
| Settings/config | `Configurable` | Features with settings |
|
||||||
|
| Pagination | `Paginating` | Large data sets |
|
||||||
|
| Form validation | `Validatable` | Input forms |
|
||||||
|
| Persistence | `Persistable` | Cached data |
|
||||||
|
|
||||||
|
### Refactoring checklist:
|
||||||
|
|
||||||
|
When you encounter code that could benefit from POP:
|
||||||
|
|
||||||
|
- [ ] Is this logic duplicated across multiple features?
|
||||||
|
- [ ] Could this type conform to an existing protocol in the shared module?
|
||||||
|
- [ ] Would extracting a protocol make this code testable in isolation?
|
||||||
|
- [ ] Can views be made generic over a protocol instead of a concrete type?
|
||||||
|
- [ ] Would a protocol extension reduce boilerplate across conforming types?
|
||||||
|
|
||||||
|
### Benefits:
|
||||||
|
|
||||||
|
- **Reusability**: Shared protocols work across all features
|
||||||
|
- **Testability**: Mock types can conform to protocols for unit testing
|
||||||
|
- **Flexibility**: New features can adopt existing protocols immediately
|
||||||
|
- **Maintainability**: Fix a bug in a protocol extension, fix it everywhere
|
||||||
|
- **Discoverability**: Protocols document the expected interface clearly
|
||||||
|
|
||||||
|
|
||||||
|
## Swift instructions
|
||||||
|
|
||||||
|
- Always mark `@Observable` classes with `@MainActor`.
|
||||||
|
- Assume strict Swift concurrency rules are being applied.
|
||||||
|
- Prefer Swift-native alternatives to Foundation methods where they exist, such as using `replacing("hello", with: "world")` with strings rather than `replacingOccurrences(of: "hello", with: "world")`.
|
||||||
|
- Prefer modern Foundation API, for example `URL.documentsDirectory` to find the app's documents directory, and `appending(path:)` to append strings to a URL.
|
||||||
|
- Never use C-style number formatting such as `Text(String(format: "%.2f", abs(myNumber)))`; always use `Text(abs(change), format: .number.precision(.fractionLength(2)))` instead.
|
||||||
|
- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`.
|
||||||
|
- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency.
|
||||||
|
- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()`.
|
||||||
|
- Avoid force unwraps and force `try` unless it is unrecoverable.
|
||||||
|
|
||||||
|
|
||||||
|
## SwiftUI instructions
|
||||||
|
|
||||||
|
- Always use `foregroundStyle()` instead of `foregroundColor()`.
|
||||||
|
- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`.
|
||||||
|
- Always use the `Tab` API instead of `tabItem()`.
|
||||||
|
- Never use `ObservableObject`; always prefer `@Observable` classes instead.
|
||||||
|
- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none.
|
||||||
|
- Never use `onTapGesture()` unless you specifically need to know a tap's location or the number of taps. All other usages should use `Button`.
|
||||||
|
- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead.
|
||||||
|
- Never use `UIScreen.main.bounds` to read the size of the available space.
|
||||||
|
- Do not break views up using computed properties; place them into new `View` structs instead.
|
||||||
|
- Do not force specific font sizes; prefer using Dynamic Type instead.
|
||||||
|
- Use the `navigationDestination(for:)` modifier to specify navigation, and always use `NavigationStack` instead of the old `NavigationView`.
|
||||||
|
- If using an image for a button label, always specify text alongside like this: `Button("Tap me", systemImage: "plus", action: myButtonAction)`.
|
||||||
|
- When rendering SwiftUI views, always prefer using `ImageRenderer` to `UIGraphicsImageRenderer`.
|
||||||
|
- Don't apply the `fontWeight()` modifier unless there is good reason. If you want to make some text bold, always use `bold()` instead of `fontWeight(.bold)`.
|
||||||
|
- Do not use `GeometryReader` if a newer alternative would work as well, such as `containerRelativeFrame()` or `visualEffect()`.
|
||||||
|
- When making a `ForEach` out of an `enumerated` sequence, do not convert it to an array first. So, prefer `ForEach(x.enumerated(), id: \.element.id)` instead of `ForEach(Array(x.enumerated()), id: \.element.id)`.
|
||||||
|
- When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer.
|
||||||
|
- Avoid `AnyView` unless it is absolutely required.
|
||||||
|
- **Never use raw numeric literals** for padding, spacing, opacity, font sizes, dimensions, corner radii, shadows, or animation durations—always use Design constants (see "No magic numbers" section).
|
||||||
|
- **Never use inline `Color(red:green:blue:)` or hex colors**—define all colors in a `Color` extension with semantic names.
|
||||||
|
- Avoid using UIKit colors in SwiftUI code.
|
||||||
|
|
||||||
|
|
||||||
|
## View/State separation (MVVM-lite)
|
||||||
|
|
||||||
|
**Views should be "dumb" renderers.** All business logic belongs in dedicated view models or state objects.
|
||||||
|
|
||||||
|
### What belongs in the State/ViewModel:
|
||||||
|
- **Business logic**: Calculations, validations, business rules
|
||||||
|
- **Computed properties based on data**: recommendations, derived values
|
||||||
|
- **State checks**: `isLoading`, `canSubmit`, `isFormValid`, `hasUnsavedChanges`
|
||||||
|
- **Data transformations**: filtering, sorting, aggregations
|
||||||
|
|
||||||
|
### What is acceptable in Views:
|
||||||
|
- **Pure UI layout logic**: `isIPad`, `maxContentWidth` based on size class
|
||||||
|
- **Visual styling**: color selection based on state (`statusColor`, `errorColor`)
|
||||||
|
- **@ViewBuilder sub-views**: breaking up complex layouts
|
||||||
|
- **Accessibility labels**: combining data into accessible descriptions
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**❌ BAD - Business logic in view:**
|
||||||
|
```swift
|
||||||
|
struct MyView: View {
|
||||||
|
@Bindable var viewModel: FormViewModel
|
||||||
|
|
||||||
|
private var isFormValid: Bool {
|
||||||
|
!viewModel.email.isEmpty && viewModel.email.contains("@")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedPrice: String? {
|
||||||
|
guard let price = viewModel.price else { return nil }
|
||||||
|
return viewModel.formatter.string(from: price)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ GOOD - Logic in ViewModel, view just reads:**
|
||||||
|
```swift
|
||||||
|
// In ViewModel:
|
||||||
|
var isFormValid: Bool {
|
||||||
|
!email.isEmpty && email.contains("@") && password.count >= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedPrice: String? {
|
||||||
|
guard let price = price else { return nil }
|
||||||
|
return formatter.string(from: price)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In View:
|
||||||
|
Button("Submit", action: submit)
|
||||||
|
.disabled(!viewModel.isFormValid)
|
||||||
|
if let price = viewModel.formattedPrice { Text(price) }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits:
|
||||||
|
- **Testable**: ViewModel logic can be unit tested without UI
|
||||||
|
- **Single source of truth**: No duplicated logic across views
|
||||||
|
- **Cleaner views**: Views focus purely on layout and presentation
|
||||||
|
- **Easier debugging**: Logic is centralized, not scattered
|
||||||
|
|
||||||
|
|
||||||
|
## SwiftData instructions
|
||||||
|
|
||||||
|
If SwiftData is configured to use CloudKit:
|
||||||
|
|
||||||
|
- Never use `@Attribute(.unique)`.
|
||||||
|
- Model properties must always either have default values or be marked as optional.
|
||||||
|
- All relationships must be marked optional.
|
||||||
|
|
||||||
|
|
||||||
|
## Localization instructions
|
||||||
|
|
||||||
|
- Use **String Catalogs** (`.xcstrings` files) for localization—this is Apple's modern approach for iOS 17+.
|
||||||
|
- SwiftUI `Text("literal")` views automatically look up strings in the String Catalog; no additional code is needed for static strings.
|
||||||
|
- For strings outside of `Text` views or with dynamic content, use `String(localized:)` or create a helper extension:
|
||||||
|
```swift
|
||||||
|
extension String {
|
||||||
|
static func localized(_ key: String) -> String {
|
||||||
|
String(localized: String.LocalizationValue(key))
|
||||||
|
}
|
||||||
|
static func localized(_ key: String, _ arguments: CVarArg...) -> String {
|
||||||
|
let format = String(localized: String.LocalizationValue(key))
|
||||||
|
return String(format: format, arguments: arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- For format strings with interpolation (e.g., "Items: %@"), define a key in the String Catalog and use `String.localized("key", value)`.
|
||||||
|
- Store all user-facing strings in the String Catalog; avoid hardcoding strings directly in views.
|
||||||
|
- Never use `NSLocalizedString`; prefer the modern `String(localized:)` API.
|
||||||
|
|
||||||
|
|
||||||
|
## No magic numbers or hardcoded values
|
||||||
|
|
||||||
|
**Never use raw numeric literals or hardcoded colors directly in views.** All values must be extracted to named constants, enums, or variables. This applies to:
|
||||||
|
|
||||||
|
### Values that MUST be constants:
|
||||||
|
- **Spacing & Padding**: `.padding(Design.Spacing.medium)` not `.padding(12)`
|
||||||
|
- **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16`
|
||||||
|
- **Font Sizes**: `Design.FontSize.body` not `size: 14`
|
||||||
|
- **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)`
|
||||||
|
- **Colors**: `Color.Primary.accent` not `Color(red: 0.8, green: 0.6, blue: 0.2)`
|
||||||
|
- **Line Widths**: `Design.LineWidth.medium` not `lineWidth: 2`
|
||||||
|
- **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10`
|
||||||
|
- **Animation Durations**: `Design.Animation.quick` not `duration: 0.3`
|
||||||
|
- **Component Sizes**: `Design.Size.iconMedium` not `frame(width: 32)`
|
||||||
|
|
||||||
|
### What to do when you see a magic number:
|
||||||
|
1. Check if an appropriate constant already exists in your design constants file
|
||||||
|
2. If not, add a new constant with a semantic name
|
||||||
|
3. Use the constant in place of the raw value
|
||||||
|
4. If it's truly view-specific and used only once, extract to a `private let` at the top of the view struct
|
||||||
|
|
||||||
|
### Examples of violations:
|
||||||
|
```swift
|
||||||
|
// ❌ BAD - Magic numbers everywhere
|
||||||
|
.padding(16)
|
||||||
|
.opacity(0.6)
|
||||||
|
.frame(width: 80, height: 52)
|
||||||
|
.shadow(radius: 10, y: 5)
|
||||||
|
Color(red: 0.25, green: 0.3, blue: 0.45)
|
||||||
|
|
||||||
|
// ✅ GOOD - Named constants
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.opacity(Design.Opacity.accent)
|
||||||
|
.frame(width: Design.Size.cardWidth, height: Design.Size.cardHeight)
|
||||||
|
.shadow(radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
|
||||||
|
Color.Primary.background
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Design constants instructions
|
||||||
|
|
||||||
|
- Create a centralized design constants file (e.g., `DesignConstants.swift`) using enums for namespacing:
|
||||||
|
```swift
|
||||||
|
enum Design {
|
||||||
|
enum Spacing {
|
||||||
|
static let xxSmall: CGFloat = 2
|
||||||
|
static let xSmall: CGFloat = 4
|
||||||
|
static let small: CGFloat = 8
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 16
|
||||||
|
static let xLarge: CGFloat = 20
|
||||||
|
}
|
||||||
|
enum CornerRadius {
|
||||||
|
static let small: CGFloat = 8
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 16
|
||||||
|
}
|
||||||
|
enum FontSize {
|
||||||
|
static let small: CGFloat = 10
|
||||||
|
static let body: CGFloat = 14
|
||||||
|
static let large: CGFloat = 18
|
||||||
|
static let title: CGFloat = 24
|
||||||
|
}
|
||||||
|
enum Opacity {
|
||||||
|
static let subtle: Double = 0.1
|
||||||
|
static let hint: Double = 0.2
|
||||||
|
static let light: Double = 0.3
|
||||||
|
static let medium: Double = 0.5
|
||||||
|
static let accent: Double = 0.6
|
||||||
|
static let strong: Double = 0.7
|
||||||
|
static let heavy: Double = 0.8
|
||||||
|
static let almostFull: Double = 0.9
|
||||||
|
}
|
||||||
|
enum LineWidth {
|
||||||
|
static let thin: CGFloat = 1
|
||||||
|
static let medium: CGFloat = 2
|
||||||
|
static let thick: CGFloat = 3
|
||||||
|
}
|
||||||
|
enum Shadow {
|
||||||
|
static let radiusSmall: CGFloat = 2
|
||||||
|
static let radiusMedium: CGFloat = 6
|
||||||
|
static let radiusLarge: CGFloat = 10
|
||||||
|
static let offsetSmall: CGFloat = 1
|
||||||
|
static let offsetMedium: CGFloat = 3
|
||||||
|
}
|
||||||
|
enum Animation {
|
||||||
|
static let quick: Double = 0.3
|
||||||
|
static let springDuration: Double = 0.4
|
||||||
|
static let staggerDelay1: Double = 0.1
|
||||||
|
static let staggerDelay2: Double = 0.25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- For colors used across the app, extend `Color` with semantic color definitions:
|
||||||
|
```swift
|
||||||
|
extension Color {
|
||||||
|
enum Primary {
|
||||||
|
static let background = Color(red: 0.1, green: 0.2, blue: 0.3)
|
||||||
|
static let accent = Color(red: 0.8, green: 0.6, blue: 0.2)
|
||||||
|
}
|
||||||
|
enum Button {
|
||||||
|
static let primaryLight = Color(red: 1.0, green: 0.85, blue: 0.3)
|
||||||
|
static let primaryDark = Color(red: 0.9, green: 0.7, blue: 0.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Within each view, extract view-specific magic numbers to private constants at the top of the struct with a comment explaining why they're local:
|
||||||
|
```swift
|
||||||
|
struct MyView: View {
|
||||||
|
// Layout: fixed dimensions for consistent appearance
|
||||||
|
private let thumbnailSize: CGFloat = 45
|
||||||
|
// Typography: constrained space requires fixed size
|
||||||
|
private let headerFontSize: CGFloat = 18
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Reference design constants in views: `Design.Spacing.medium`, `Design.CornerRadius.large`, `Color.Primary.accent`.
|
||||||
|
- Keep design constants organized by category: Spacing, CornerRadius, FontSize, IconSize, Size, Animation, Opacity, LineWidth, Shadow.
|
||||||
|
- When adding new features, check existing constants first before creating new ones.
|
||||||
|
- Name constants semantically (what they represent) not literally (their value): `accent` not `pointSix`, `large` not `sixteen`.
|
||||||
|
|
||||||
|
|
||||||
|
## Dynamic Type instructions
|
||||||
|
|
||||||
|
- Always support Dynamic Type for accessibility; never use fixed font sizes without scaling.
|
||||||
|
- Use `@ScaledMetric` to scale custom font sizes and dimensions based on user accessibility settings:
|
||||||
|
```swift
|
||||||
|
struct MyView: View {
|
||||||
|
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = 14
|
||||||
|
@ScaledMetric(relativeTo: .title) private var titleFontSize: CGFloat = 24
|
||||||
|
@ScaledMetric(relativeTo: .caption) private var captionSize: CGFloat = 11
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text("Hello")
|
||||||
|
.font(.system(size: bodyFontSize, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Choose the appropriate `relativeTo` text style based on the semantic purpose:
|
||||||
|
- `.largeTitle`, `.title`, `.title2`, `.title3` for headings
|
||||||
|
- `.headline`, `.subheadline` for emphasized content
|
||||||
|
- `.body` for main content
|
||||||
|
- `.callout`, `.footnote`, `.caption`, `.caption2` for smaller text
|
||||||
|
- For constrained UI elements (icons, badges, compact layouts) where overflow would break the design, you may use fixed sizes but document the reason:
|
||||||
|
```swift
|
||||||
|
// Fixed size: badge has strict space constraints
|
||||||
|
private let badgeFontSize: CGFloat = 11
|
||||||
|
```
|
||||||
|
- Prefer system text styles when possible: `.font(.body)`, `.font(.title)`, `.font(.caption)`.
|
||||||
|
- Test with accessibility settings: Settings > Accessibility > Display & Text Size > Larger Text.
|
||||||
|
|
||||||
|
|
||||||
|
## VoiceOver accessibility instructions
|
||||||
|
|
||||||
|
- All interactive elements (buttons, selectable items) must have meaningful `.accessibilityLabel()`.
|
||||||
|
- Use `.accessibilityValue()` to communicate dynamic state (e.g., current selection, count, progress).
|
||||||
|
- Use `.accessibilityHint()` to describe what will happen when interacting with an element:
|
||||||
|
```swift
|
||||||
|
Button("Submit", action: submit)
|
||||||
|
.accessibilityHint("Submits the form and creates your account")
|
||||||
|
```
|
||||||
|
- Use `.accessibilityAddTraits()` to communicate element type:
|
||||||
|
- `.isButton` for tappable elements that aren't SwiftUI Buttons
|
||||||
|
- `.isHeader` for section headers
|
||||||
|
- `.isModal` for modal overlays
|
||||||
|
- `.updatesFrequently` for live-updating content
|
||||||
|
- Hide purely decorative elements from VoiceOver:
|
||||||
|
```swift
|
||||||
|
DecorationView()
|
||||||
|
.accessibilityHidden(true) // Decorative element
|
||||||
|
```
|
||||||
|
- Group related elements to reduce VoiceOver navigation complexity:
|
||||||
|
```swift
|
||||||
|
VStack {
|
||||||
|
titleLabel
|
||||||
|
subtitleLabel
|
||||||
|
statusIndicator
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("Item details")
|
||||||
|
.accessibilityValue("Title: \(title). Status: \(status)")
|
||||||
|
```
|
||||||
|
- For complex elements, use `.accessibilityElement(children: .contain)` to allow navigation to children while adding context.
|
||||||
|
- Post accessibility announcements for important events:
|
||||||
|
```swift
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: "Upload complete!")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Provide accessibility names for model types that appear in UI:
|
||||||
|
```swift
|
||||||
|
enum Status {
|
||||||
|
var accessibilityName: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: return String(localized: "Pending")
|
||||||
|
case .complete: return String(localized: "Complete")
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Test with VoiceOver enabled: Settings > Accessibility > VoiceOver.
|
||||||
|
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
- Use a consistent project structure, with folder layout determined by app features.
|
||||||
|
- Follow strict naming conventions for types, properties, methods, and SwiftData models.
|
||||||
|
- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file.
|
||||||
|
- Write unit tests for core application logic.
|
||||||
|
- Only write UI tests if unit tests are not possible.
|
||||||
|
- Add code comments and documentation comments as needed.
|
||||||
|
- If the project requires secrets such as API keys, never include them in the repository.
|
||||||
|
|
||||||
|
|
||||||
|
## Documentation instructions
|
||||||
|
|
||||||
|
- **Always keep documentation up to date** when adding new functionality or making changes that users or developers need to know about.
|
||||||
|
- Document new features, settings, or behaviors in the appropriate documentation files.
|
||||||
|
- Update documentation when modifying existing behavior.
|
||||||
|
- Include any configuration options, keyboard shortcuts, or special interactions.
|
||||||
|
- Documentation updates should be part of the same commit as the feature/change they document.
|
||||||
|
|
||||||
|
|
||||||
|
## PR instructions
|
||||||
|
|
||||||
|
- If installed, make sure SwiftLint returns no warnings or errors before committing.
|
||||||
|
- Verify that documentation reflects any new functionality or behavioral changes.
|
||||||
68
AI_Implementation.md
Normal file
68
AI_Implementation.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# AI_Implementation.md
|
||||||
|
|
||||||
|
## How This App Was Architected & Built
|
||||||
|
|
||||||
|
This project was developed following strict senior-level iOS engineering standards, with guidance from an AI assistant (Grok) acting as a Senior iOS Engineer specializing in SwiftUI and modern Apple frameworks.
|
||||||
|
|
||||||
|
### Guiding Principles (from AGENTS.md)
|
||||||
|
- **Protocol-Oriented Programming (POP) first**: All shared capabilities defined via protocols before concrete types
|
||||||
|
- **MVVM-lite**: Views are "dumb" — all logic lives in `@Observable` view models
|
||||||
|
- **No third-party dependencies**: Pure Apple frameworks only (SwiftUI, AVFoundation, StoreKit 2, CoreImage)
|
||||||
|
- **No magic numbers**: All dimensions, opacities, durations from centralized `Design` constants
|
||||||
|
- **Full accessibility**: Dynamic Type, VoiceOver labels/hints/traits/announcements
|
||||||
|
- **Modern Swift & SwiftUI**: Swift 6 concurrency, `@MainActor`, `foregroundStyle`, `clipShape(.rect)`, `NavigationStack`
|
||||||
|
- **Testable & reusable design**: Protocols enable mocking and future SPM package extraction
|
||||||
|
|
||||||
|
### Architecture Overview
|
||||||
|
|
||||||
|
Shared/
|
||||||
|
├── DesignConstants.swift → Semantic design tokens (spacing, radii, sizes, etc.)
|
||||||
|
├── Color+Extensions.swift → Ring light color presets
|
||||||
|
├── Protocols/
|
||||||
|
│ ├── RingLightConfigurable.swift → Border, color, brightness, mirror, smoothing
|
||||||
|
│ ├── CaptureControlling.swift → Timer, grid, zoom, capture mode
|
||||||
|
│ └── PremiumManaging.swift → Subscription state & purchase handling
|
||||||
|
└── Premium/
|
||||||
|
└── PremiumManager.swift → Native StoreKit 2 implementation
|
||||||
|
Features/
|
||||||
|
├── Camera/ → Main UI, preview, capture logic
|
||||||
|
├── Settings/ → Configuration screens
|
||||||
|
└── Paywall/ → Pro subscription flow
|
||||||
|
|
||||||
|
|
||||||
|
### Key Implementation Decisions
|
||||||
|
|
||||||
|
1. **Ring Light Effect**
|
||||||
|
- Achieved by coloring the safe area background and leaving a centered rectangular window for camera preview
|
||||||
|
- Border width controlled via user setting
|
||||||
|
- Gradient support added for directional "portrait lighting"
|
||||||
|
|
||||||
|
2. **Camera System**
|
||||||
|
- `AVCaptureSession` with front camera default
|
||||||
|
- `UIViewRepresentable` wrapper for preview with pinch-to-zoom
|
||||||
|
- Video data output delegate for future real-time filters (skin smoothing placeholder)
|
||||||
|
|
||||||
|
3. **Capture Enhancements**
|
||||||
|
- Timer with async countdown and accessibility announcements
|
||||||
|
- Volume button observation via KVO on `AVAudioSession.outputVolume`
|
||||||
|
- Flash burst: temporarily sets brightness to 1.0 on capture
|
||||||
|
|
||||||
|
4. **Freemium Model**
|
||||||
|
- Built with pure StoreKit 2 (no RevenueCat)
|
||||||
|
- `PremiumManaging` protocol enables easy testing/mocking
|
||||||
|
- Clean paywall with benefit list and native purchase flow
|
||||||
|
|
||||||
|
5. **Reusability Focus**
|
||||||
|
- All shared logic extracted to protocols
|
||||||
|
- Ready for future extraction into SPM packages:
|
||||||
|
- `SelfieCameraKit`
|
||||||
|
- `SelfieRingLightKit`
|
||||||
|
- `SelfiePremiumKit`
|
||||||
|
|
||||||
|
### Development Process
|
||||||
|
- Iterative feature additions guided by competitive analysis of top App Store selfie apps
|
||||||
|
- Each new capability (timer, boomerang, gradient, subscriptions) added with protocol-first design
|
||||||
|
- Strict adherence to no magic numbers, full accessibility, and clean separation
|
||||||
|
- Final structure optimized for maintainability and future library extraction
|
||||||
|
|
||||||
|
This app demonstrates production-quality SwiftUI architecture while delivering a delightful, competitive user experience.
|
||||||
139
README.md
Normal file
139
README.md
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# SelfieRingLight
|
||||||
|
|
||||||
|
A modern, professional-grade selfie camera app for iOS that simulates a high-quality ring light using the device's screen. Built entirely with SwiftUI, Swift 6, and AVFoundation.
|
||||||
|
|
||||||
|
Perfect for low-light selfies, video calls, makeup checks, or professional portrait lighting on the go.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core Camera & Lighting
|
||||||
|
- Full-screen front-camera preview with true mirror option
|
||||||
|
- Configurable **screen-based ring light** with adjustable border thickness
|
||||||
|
- Multiple color temperature presets (Pure White, Warm Cream, Ice Blue, Rose Pink, etc.)
|
||||||
|
- **Directional gradient lighting** for flattering portrait effects
|
||||||
|
- Real-time screen brightness control (overrides system brightness while in use)
|
||||||
|
- Flash burst on capture for extra fill light
|
||||||
|
|
||||||
|
### Capture Modes
|
||||||
|
- Photo capture (saved to Photo Library)
|
||||||
|
- Video recording
|
||||||
|
- **Boomerang** mode (3-second looping short video)
|
||||||
|
- 3-second and 10-second self-timer with countdown overlay and VoiceOver announcements
|
||||||
|
- Pinch-to-zoom gesture
|
||||||
|
- Volume button shutter support (photo or video start/stop)
|
||||||
|
- Rule-of-thirds grid overlay (toggleable)
|
||||||
|
|
||||||
|
### Premium Features (Freemium Model)
|
||||||
|
- All advanced color presets + custom colors
|
||||||
|
- Gradient and directional lighting
|
||||||
|
- Advanced beauty filters (coming soon)
|
||||||
|
- Unlimited boomerang length
|
||||||
|
- No watermarks
|
||||||
|
- Ad-free experience
|
||||||
|
|
||||||
|
### Accessibility & Polish
|
||||||
|
- Full VoiceOver support with meaningful labels, hints, and announcements
|
||||||
|
- Dynamic Type and ScaledMetric for readable text
|
||||||
|
- String Catalog localization ready (`.xcstrings`)
|
||||||
|
- Prevents screen dimming during use
|
||||||
|
- Restores original brightness on background/app close
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
*(Add App Store-ready screenshots here once built)*
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- iOS 18.0 or later
|
||||||
|
- Xcode 16+
|
||||||
|
- Swift 6 language mode
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/SelfieRingLight.git
|
||||||
|
cd SelfieRingLight
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure API Keys
|
||||||
|
|
||||||
|
This project uses `.xcconfig` files to securely manage API keys. **Never commit your actual API keys to version control.**
|
||||||
|
|
||||||
|
1. Copy the template file:
|
||||||
|
```bash
|
||||||
|
cp SelfieRingLight/Configuration/Secrets.xcconfig.template SelfieRingLight/Configuration/Secrets.xcconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `Secrets.xcconfig` with your actual API key:
|
||||||
|
```
|
||||||
|
REVENUECAT_API_KEY = appl_your_actual_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
3. The `Secrets.xcconfig` file is gitignored and will never be committed.
|
||||||
|
|
||||||
|
### 3. RevenueCat Setup
|
||||||
|
|
||||||
|
1. Create an account at [RevenueCat](https://www.revenuecat.com)
|
||||||
|
2. Create a new project and connect it to App Store Connect
|
||||||
|
3. Create products in App Store Connect (e.g., `com.yourapp.pro.monthly`, `com.yourapp.pro.yearly`)
|
||||||
|
4. Configure the products in RevenueCat dashboard
|
||||||
|
5. Create an entitlement named `pro`
|
||||||
|
6. Create an offering with your subscription packages
|
||||||
|
7. Copy your **Public App-Specific API Key** to `Secrets.xcconfig`
|
||||||
|
|
||||||
|
### 4. Debug Premium Mode
|
||||||
|
|
||||||
|
To test premium features without a real subscription during development:
|
||||||
|
|
||||||
|
1. Edit Scheme (⌘⇧<) → Run → Arguments
|
||||||
|
2. Add Environment Variable:
|
||||||
|
- **Name:** `ENABLE_DEBUG_PREMIUM`
|
||||||
|
- **Value:** `1`
|
||||||
|
|
||||||
|
This unlocks all premium features in DEBUG builds only.
|
||||||
|
|
||||||
|
### 5. CI/CD Configuration
|
||||||
|
|
||||||
|
For automated builds, set the `REVENUECAT_API_KEY` environment variable in your CI/CD system:
|
||||||
|
|
||||||
|
**GitHub Actions:**
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
REVENUECAT_API_KEY: ${{ secrets.REVENUECAT_API_KEY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Xcode Cloud:**
|
||||||
|
Add `REVENUECAT_API_KEY` as a secret in your Xcode Cloud workflow.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
- Camera access required for preview and capture
|
||||||
|
- Photo Library access required to save photos/videos
|
||||||
|
- Microphone access required for video recording
|
||||||
|
- No data collection, no analytics, no tracking
|
||||||
|
|
||||||
|
## Monetization
|
||||||
|
Freemium model with optional "Pro" subscription:
|
||||||
|
- Free: Basic ring light, standard colors, photo/video, timer, zoom
|
||||||
|
- Pro: Full color palette, gradients, advanced features, future updates
|
||||||
|
|
||||||
|
Implemented with RevenueCat for reliable subscription management.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
SelfieRingLight/
|
||||||
|
├── App/ # App entry point
|
||||||
|
├── Features/
|
||||||
|
│ ├── Camera/ # Camera preview, capture, view model
|
||||||
|
│ ├── Paywall/ # Pro subscription flow
|
||||||
|
│ └── Settings/ # Configuration screens
|
||||||
|
├── Shared/
|
||||||
|
│ ├── Configuration/ # xcconfig files (API keys)
|
||||||
|
│ ├── Premium/ # PremiumManager (RevenueCat)
|
||||||
|
│ ├── Protocols/ # Shared protocols
|
||||||
|
│ ├── Color+Extensions.swift # Ring light color presets
|
||||||
|
│ └── DesignConstants.swift # Design tokens
|
||||||
|
└── Resources/ # Assets, localization
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
*(Add your license here)*
|
||||||
662
SelfieRingLight.xcodeproj/project.pbxproj
Normal file
662
SelfieRingLight.xcodeproj/project.pbxproj
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C852F08306200DC03E1 /* RevenueCat */; };
|
||||||
|
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C872F08306200DC03E1 /* RevenueCatUI */; };
|
||||||
|
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F012F08500000DC03E1 /* Bedrock */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
EA766C3A2F082A8500DC03E1 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = EA766C242F082A8400DC03E1 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = EA766C2B2F082A8400DC03E1;
|
||||||
|
remoteInfo = SelfieRingLight;
|
||||||
|
};
|
||||||
|
EA766C442F082A8500DC03E1 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = EA766C242F082A8400DC03E1 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = EA766C2B2F082A8400DC03E1;
|
||||||
|
remoteInfo = SelfieRingLight;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SelfieRingLight.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
EA766C392F082A8500DC03E1 /* SelfieRingLightTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieRingLightTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
EA766C432F082A8500DC03E1 /* SelfieRingLightUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieRingLightUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
EA766C902F08400000DC03E1 /* SelfieRingLight/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SelfieRingLight/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
|
EA766C912F08400000DC03E1 /* SelfieRingLight/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SelfieRingLight/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
EA766C2E2F082A8400DC03E1 /* SelfieRingLight */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = SelfieRingLight;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
EA766C3C2F082A8500DC03E1 /* SelfieRingLightTests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = SelfieRingLightTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
EA766C462F082A8500DC03E1 /* SelfieRingLightUITests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = SelfieRingLightUITests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
EA766C292F082A8400DC03E1 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */,
|
||||||
|
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */,
|
||||||
|
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
EA766C362F082A8500DC03E1 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
EA766C402F082A8500DC03E1 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
EA766C232F082A8400DC03E1 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
EA766C2E2F082A8400DC03E1 /* SelfieRingLight */,
|
||||||
|
EA766C3C2F082A8500DC03E1 /* SelfieRingLightTests */,
|
||||||
|
EA766C462F082A8500DC03E1 /* SelfieRingLightUITests */,
|
||||||
|
EA766C2D2F082A8400DC03E1 /* Products */,
|
||||||
|
EA766F342F0843F500DC03E1 /* Recovered References */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
EA766C2D2F082A8400DC03E1 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */,
|
||||||
|
EA766C392F082A8500DC03E1 /* SelfieRingLightTests.xctest */,
|
||||||
|
EA766C432F082A8500DC03E1 /* SelfieRingLightUITests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
EA766F342F0843F500DC03E1 /* Recovered References */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
EA766C902F08400000DC03E1 /* SelfieRingLight/Configuration/Debug.xcconfig */,
|
||||||
|
EA766C912F08400000DC03E1 /* SelfieRingLight/Configuration/Release.xcconfig */,
|
||||||
|
);
|
||||||
|
name = "Recovered References";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
EA766C2B2F082A8400DC03E1 /* SelfieRingLight */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = EA766C4D2F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLight" */;
|
||||||
|
buildPhases = (
|
||||||
|
EA766C282F082A8400DC03E1 /* Sources */,
|
||||||
|
EA766C292F082A8400DC03E1 /* Frameworks */,
|
||||||
|
EA766C2A2F082A8400DC03E1 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
EA766C2E2F082A8400DC03E1 /* SelfieRingLight */,
|
||||||
|
);
|
||||||
|
name = SelfieRingLight;
|
||||||
|
packageProductDependencies = (
|
||||||
|
EA766C852F08306200DC03E1 /* RevenueCat */,
|
||||||
|
EA766C872F08306200DC03E1 /* RevenueCatUI */,
|
||||||
|
EA766F012F08500000DC03E1 /* Bedrock */,
|
||||||
|
);
|
||||||
|
productName = SelfieRingLight;
|
||||||
|
productReference = EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
EA766C382F082A8500DC03E1 /* SelfieRingLightTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = EA766C502F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLightTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
EA766C352F082A8500DC03E1 /* Sources */,
|
||||||
|
EA766C362F082A8500DC03E1 /* Frameworks */,
|
||||||
|
EA766C372F082A8500DC03E1 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
EA766C3B2F082A8500DC03E1 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
EA766C3C2F082A8500DC03E1 /* SelfieRingLightTests */,
|
||||||
|
);
|
||||||
|
name = SelfieRingLightTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = SelfieRingLightTests;
|
||||||
|
productReference = EA766C392F082A8500DC03E1 /* SelfieRingLightTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
EA766C422F082A8500DC03E1 /* SelfieRingLightUITests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = EA766C532F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLightUITests" */;
|
||||||
|
buildPhases = (
|
||||||
|
EA766C3F2F082A8500DC03E1 /* Sources */,
|
||||||
|
EA766C402F082A8500DC03E1 /* Frameworks */,
|
||||||
|
EA766C412F082A8500DC03E1 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
EA766C452F082A8500DC03E1 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
EA766C462F082A8500DC03E1 /* SelfieRingLightUITests */,
|
||||||
|
);
|
||||||
|
name = SelfieRingLightUITests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = SelfieRingLightUITests;
|
||||||
|
productReference = EA766C432F082A8500DC03E1 /* SelfieRingLightUITests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
EA766C242F082A8400DC03E1 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2600;
|
||||||
|
LastUpgradeCheck = 2600;
|
||||||
|
TargetAttributes = {
|
||||||
|
EA766C2B2F082A8400DC03E1 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
};
|
||||||
|
EA766C382F082A8500DC03E1 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
TestTargetID = EA766C2B2F082A8400DC03E1;
|
||||||
|
};
|
||||||
|
EA766C422F082A8500DC03E1 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
TestTargetID = EA766C2B2F082A8400DC03E1;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = EA766C272F082A8400DC03E1 /* Build configuration list for PBXProject "SelfieRingLight" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = EA766C232F082A8400DC03E1;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */,
|
||||||
|
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */,
|
||||||
|
);
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = EA766C2D2F082A8400DC03E1 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
EA766C2B2F082A8400DC03E1 /* SelfieRingLight */,
|
||||||
|
EA766C382F082A8500DC03E1 /* SelfieRingLightTests */,
|
||||||
|
EA766C422F082A8500DC03E1 /* SelfieRingLightUITests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
EA766C2A2F082A8400DC03E1 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
EA766C372F082A8500DC03E1 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
EA766C412F082A8500DC03E1 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
EA766C282F082A8400DC03E1 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
EA766C352F082A8500DC03E1 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
EA766C3F2F082A8500DC03E1 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
EA766C3B2F082A8500DC03E1 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = EA766C2B2F082A8400DC03E1 /* SelfieRingLight */;
|
||||||
|
targetProxy = EA766C3A2F082A8500DC03E1 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
EA766C452F082A8500DC03E1 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = EA766C2B2F082A8400DC03E1 /* SelfieRingLight */;
|
||||||
|
targetProxy = EA766C442F082A8500DC03E1 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
EA766C4B2F082A8500DC03E1 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
EA766C4C2F082A8500DC03E1 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
EA766C4E2F082A8500DC03E1 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = EA766C902F08400000DC03E1 /* SelfieRingLight/Configuration/Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = SelfieRingLight/SelfieRingLight.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieRingLight needs camera access to show your selfie preview and capture photos and videos.";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieRingLight needs microphone access to record audio with your videos.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieRingLight needs photo library access to save your captured photos and videos.";
|
||||||
|
INFOPLIST_KEY_RevenueCatAPIKey = "$(REVENUECAT_API_KEY)";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLight;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
REVENUECAT_API_KEY = "$(REVENUECAT_API_KEY)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
EA766C4F2F082A8500DC03E1 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = EA766C912F08400000DC03E1 /* SelfieRingLight/Configuration/Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = SelfieRingLight/SelfieRingLight.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieRingLight needs camera access to show your selfie preview and capture photos and videos.";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieRingLight needs microphone access to record audio with your videos.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieRingLight needs photo library access to save your captured photos and videos.";
|
||||||
|
INFOPLIST_KEY_RevenueCatAPIKey = "$(REVENUECAT_API_KEY)";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLight;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
REVENUECAT_API_KEY = "$(REVENUECAT_API_KEY)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
EA766C512F082A8500DC03E1 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLightTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SelfieRingLight.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SelfieRingLight";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
EA766C522F082A8500DC03E1 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLightTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SelfieRingLight.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SelfieRingLight";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
EA766C542F082A8500DC03E1 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLightUITests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = SelfieRingLight;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
EA766C552F082A8500DC03E1 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieRingLightUITests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = SelfieRingLight;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
EA766C272F082A8400DC03E1 /* Build configuration list for PBXProject "SelfieRingLight" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
EA766C4B2F082A8500DC03E1 /* Debug */,
|
||||||
|
EA766C4C2F082A8500DC03E1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
EA766C4D2F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLight" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
EA766C4E2F082A8500DC03E1 /* Debug */,
|
||||||
|
EA766C4F2F082A8500DC03E1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
EA766C502F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLightTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
EA766C512F082A8500DC03E1 /* Debug */,
|
||||||
|
EA766C522F082A8500DC03E1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
EA766C532F082A8500DC03E1 /* Build configuration list for PBXNativeTarget "SelfieRingLightUITests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
EA766C542F082A8500DC03E1 /* Debug */,
|
||||||
|
EA766C552F082A8500DC03E1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/RevenueCat/purchases-ios-spm";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 5.52.1;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git";
|
||||||
|
requirement = {
|
||||||
|
branch = develop;
|
||||||
|
kind = branch;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
EA766C852F08306200DC03E1 /* RevenueCat */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */;
|
||||||
|
productName = RevenueCat;
|
||||||
|
};
|
||||||
|
EA766C872F08306200DC03E1 /* RevenueCatUI */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */;
|
||||||
|
productName = RevenueCatUI;
|
||||||
|
};
|
||||||
|
EA766F012F08500000DC03E1 /* Bedrock */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */;
|
||||||
|
productName = Bedrock;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = EA766C242F082A8400DC03E1 /* Project object */;
|
||||||
|
}
|
||||||
7
SelfieRingLight.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
SelfieRingLight.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "EA766C2B2F082A8400DC03E1"
|
||||||
|
BuildableName = "SelfieRingLight.app"
|
||||||
|
BlueprintName = "SelfieRingLight"
|
||||||
|
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "EA766C382F082A8500DC03E1"
|
||||||
|
BuildableName = "SelfieRingLightTests.xctest"
|
||||||
|
BlueprintName = "SelfieRingLightTests"
|
||||||
|
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "EA766C422F082A8500DC03E1"
|
||||||
|
BuildableName = "SelfieRingLightUITests.xctest"
|
||||||
|
BlueprintName = "SelfieRingLightUITests"
|
||||||
|
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "EA766C2B2F082A8400DC03E1"
|
||||||
|
BuildableName = "SelfieRingLight.app"
|
||||||
|
BlueprintName = "SelfieRingLight"
|
||||||
|
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "ENABLE_DEBUG_PREMIUM"
|
||||||
|
value = "true"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "EA766C2B2F082A8400DC03E1"
|
||||||
|
BuildableName = "SelfieRingLight.app"
|
||||||
|
BlueprintName = "SelfieRingLight"
|
||||||
|
ReferencedContainer = "container:SelfieRingLight.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
17
SelfieRingLight/App/SelfieRingLightApp.swift
Normal file
17
SelfieRingLight/App/SelfieRingLightApp.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// SelfieRingLightApp.swift
|
||||||
|
// SelfieRingLight
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SelfieRingLightApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SelfieRingLight/Configuration/Debug.xcconfig
Normal file
11
SelfieRingLight/Configuration/Debug.xcconfig
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Debug.xcconfig
|
||||||
|
// Configuration for Debug builds
|
||||||
|
|
||||||
|
#include? "Secrets.xcconfig"
|
||||||
|
|
||||||
|
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
|
||||||
|
// CI/CD should set these via environment variables
|
||||||
|
REVENUECAT_API_KEY = $(REVENUECAT_API_KEY)
|
||||||
|
|
||||||
|
// Expose the API key to Info.plist
|
||||||
|
REVENUECAT_API_KEY_PLIST = $(REVENUECAT_API_KEY)
|
||||||
11
SelfieRingLight/Configuration/Release.xcconfig
Normal file
11
SelfieRingLight/Configuration/Release.xcconfig
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Release.xcconfig
|
||||||
|
// Configuration for Release builds
|
||||||
|
|
||||||
|
#include? "Secrets.xcconfig"
|
||||||
|
|
||||||
|
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
|
||||||
|
// CI/CD should set these via environment variables
|
||||||
|
REVENUECAT_API_KEY = $(REVENUECAT_API_KEY)
|
||||||
|
|
||||||
|
// Expose the API key to Info.plist
|
||||||
|
REVENUECAT_API_KEY_PLIST = $(REVENUECAT_API_KEY)
|
||||||
12
SelfieRingLight/Configuration/Secrets.xcconfig.template
Normal file
12
SelfieRingLight/Configuration/Secrets.xcconfig.template
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// Secrets.xcconfig.template
|
||||||
|
//
|
||||||
|
// INSTRUCTIONS:
|
||||||
|
// 1. Copy this file to "Secrets.xcconfig" in the same directory
|
||||||
|
// 2. Replace the placeholder values with your actual API keys
|
||||||
|
// 3. NEVER commit Secrets.xcconfig to version control
|
||||||
|
//
|
||||||
|
// The actual Secrets.xcconfig file is gitignored for security.
|
||||||
|
|
||||||
|
// RevenueCat API Key
|
||||||
|
// Get this from: RevenueCat Dashboard > Project Settings > API Keys > Public App-Specific API Key
|
||||||
|
REVENUECAT_API_KEY = your_revenuecat_public_api_key_here
|
||||||
135
SelfieRingLight/Features/Camera/CameraPreview.swift
Normal file
135
SelfieRingLight/Features/Camera/CameraPreview.swift
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
struct CameraPreview: UIViewRepresentable {
|
||||||
|
let viewModel: CameraViewModel
|
||||||
|
|
||||||
|
// These properties trigger view updates when they change
|
||||||
|
var isMirrorFlipped: Bool
|
||||||
|
var zoomFactor: Double
|
||||||
|
|
||||||
|
init(viewModel: CameraViewModel, isMirrorFlipped: Bool, zoomFactor: Double) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.isMirrorFlipped = isMirrorFlipped
|
||||||
|
self.zoomFactor = zoomFactor
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> CameraPreviewUIView {
|
||||||
|
let view = CameraPreviewUIView(viewModel: viewModel)
|
||||||
|
view.contentMode = .scaleAspectFill
|
||||||
|
view.clipsToBounds = true
|
||||||
|
|
||||||
|
// Add pinch-to-zoom gesture
|
||||||
|
let pinch = UIPinchGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePinch(_:)))
|
||||||
|
view.addGestureRecognizer(pinch)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {
|
||||||
|
// Force layout update
|
||||||
|
uiView.setNeedsLayout()
|
||||||
|
uiView.layoutIfNeeded()
|
||||||
|
|
||||||
|
// Apply mirror transform based on settings
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
|
||||||
|
if isMirrorFlipped {
|
||||||
|
uiView.previewLayer?.transform = CATransform3DMakeScale(-1, 1, 1)
|
||||||
|
} else {
|
||||||
|
uiView.previewLayer?.transform = CATransform3DIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
CATransaction.commit()
|
||||||
|
|
||||||
|
// Apply zoom if changed
|
||||||
|
context.coordinator.applyZoom(zoomFactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject {
|
||||||
|
let viewModel: CameraViewModel
|
||||||
|
private var lastAppliedZoom: Double = 1.0
|
||||||
|
|
||||||
|
init(viewModel: CameraViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@objc func handlePinch(_ gesture: UIPinchGestureRecognizer) {
|
||||||
|
guard gesture.state == .changed else { return }
|
||||||
|
|
||||||
|
let newZoom = max(1.0, min(5.0, viewModel.settings.currentZoomFactor * gesture.scale))
|
||||||
|
viewModel.settings.currentZoomFactor = newZoom
|
||||||
|
gesture.scale = 1.0
|
||||||
|
|
||||||
|
applyZoom(newZoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyZoom(_ zoom: Double) {
|
||||||
|
guard zoom != lastAppliedZoom else { return }
|
||||||
|
lastAppliedZoom = zoom
|
||||||
|
|
||||||
|
if let device = viewModel.captureSession?.inputs.first.flatMap({ ($0 as? AVCaptureDeviceInput)?.device }) {
|
||||||
|
do {
|
||||||
|
try device.lockForConfiguration()
|
||||||
|
device.videoZoomFactor = max(1.0, min(zoom, device.activeFormat.videoMaxZoomFactor))
|
||||||
|
device.unlockForConfiguration()
|
||||||
|
} catch {
|
||||||
|
print("Error setting zoom: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIView subclass for camera preview
|
||||||
|
|
||||||
|
class CameraPreviewUIView: UIView {
|
||||||
|
private weak var viewModel: CameraViewModel?
|
||||||
|
var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
|
||||||
|
override class var layerClass: AnyClass {
|
||||||
|
AVCaptureVideoPreviewLayer.self
|
||||||
|
}
|
||||||
|
|
||||||
|
init(viewModel: CameraViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = .black
|
||||||
|
autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
setupPreviewLayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPreviewLayer() {
|
||||||
|
guard let viewModel = viewModel,
|
||||||
|
let session = viewModel.captureSession else { return }
|
||||||
|
|
||||||
|
if let layer = self.layer as? AVCaptureVideoPreviewLayer {
|
||||||
|
layer.session = session
|
||||||
|
layer.videoGravity = .resizeAspectFill
|
||||||
|
previewLayer = layer
|
||||||
|
viewModel.previewLayer = layer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
// Ensure the preview layer fills the entire view bounds
|
||||||
|
previewLayer?.frame = bounds
|
||||||
|
|
||||||
|
// Setup layer if not already done (can happen if session was nil at init)
|
||||||
|
if previewLayer == nil {
|
||||||
|
setupPreviewLayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
SelfieRingLight/Features/Camera/CameraViewModel.swift
Normal file
201
SelfieRingLight/Features/Camera/CameraViewModel.swift
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import Photos
|
||||||
|
import CoreImage
|
||||||
|
import UIKit
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
class CameraViewModel: NSObject {
|
||||||
|
var isCameraAuthorized = false
|
||||||
|
var isPhotoLibraryAuthorized = false
|
||||||
|
var captureSession: AVCaptureSession?
|
||||||
|
var photoOutput: AVCapturePhotoOutput?
|
||||||
|
var videoOutput: AVCaptureMovieFileOutput?
|
||||||
|
var videoDataOutput: AVCaptureVideoDataOutput?
|
||||||
|
var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
var isUsingFrontCamera = true
|
||||||
|
var isRecording = false
|
||||||
|
var originalBrightness: CGFloat = 0.5
|
||||||
|
var ciContext = CIContext()
|
||||||
|
|
||||||
|
/// Whether the preview should be hidden (for front flash effect)
|
||||||
|
var isPreviewHidden = false
|
||||||
|
|
||||||
|
let settings = SettingsViewModel() // Shared config
|
||||||
|
|
||||||
|
// MARK: - Screen Brightness Handling
|
||||||
|
|
||||||
|
/// Gets the current screen from any available window scene
|
||||||
|
private var currentScreen: UIScreen? {
|
||||||
|
UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
.first?.screen
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCurrentBrightness() {
|
||||||
|
if let screen = currentScreen {
|
||||||
|
originalBrightness = screen.brightness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setBrightness(_ value: CGFloat) {
|
||||||
|
currentScreen?.brightness = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCamera() async {
|
||||||
|
isCameraAuthorized = await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
isPhotoLibraryAuthorized = await PHPhotoLibrary.requestAuthorization(for: .addOnly) == .authorized
|
||||||
|
|
||||||
|
guard isCameraAuthorized else { return }
|
||||||
|
|
||||||
|
captureSession = AVCaptureSession()
|
||||||
|
guard let session = captureSession else { return }
|
||||||
|
|
||||||
|
session.beginConfiguration()
|
||||||
|
session.sessionPreset = .high
|
||||||
|
|
||||||
|
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: isUsingFrontCamera ? .front : .back)
|
||||||
|
guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return }
|
||||||
|
if session.canAddInput(input) {
|
||||||
|
session.addInput(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
photoOutput = AVCapturePhotoOutput()
|
||||||
|
if let photoOutput, session.canAddOutput(photoOutput) {
|
||||||
|
session.addOutput(photoOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
videoOutput = AVCaptureMovieFileOutput()
|
||||||
|
if let videoOutput, session.canAddOutput(videoOutput) {
|
||||||
|
session.addOutput(videoOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
videoDataOutput = AVCaptureVideoDataOutput()
|
||||||
|
videoDataOutput?.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
|
||||||
|
if let videoDataOutput, session.canAddOutput(videoDataOutput) {
|
||||||
|
session.addOutput(videoDataOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.commitConfiguration()
|
||||||
|
session.startRunning()
|
||||||
|
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
|
saveCurrentBrightness()
|
||||||
|
// Set screen to full brightness for best ring light effect
|
||||||
|
setBrightness(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchCamera() {
|
||||||
|
guard let session = captureSession else { return }
|
||||||
|
session.beginConfiguration()
|
||||||
|
session.inputs.forEach { session.removeInput($0) }
|
||||||
|
|
||||||
|
isUsingFrontCamera.toggle()
|
||||||
|
let position: AVCaptureDevice.Position = isUsingFrontCamera ? .front : .back
|
||||||
|
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position)
|
||||||
|
guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return }
|
||||||
|
if session.canAddInput(input) {
|
||||||
|
session.addInput(input)
|
||||||
|
}
|
||||||
|
session.commitConfiguration()
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturePhoto() {
|
||||||
|
// If front flash is enabled, hide the preview to show the ring light
|
||||||
|
if settings.isFrontFlashEnabled {
|
||||||
|
performFrontFlashCapture()
|
||||||
|
} else {
|
||||||
|
let captureSettings = AVCapturePhotoSettings()
|
||||||
|
photoOutput?.capturePhoto(with: captureSettings, delegate: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs photo capture with front flash effect
|
||||||
|
private func performFrontFlashCapture() {
|
||||||
|
isPreviewHidden = true
|
||||||
|
|
||||||
|
// Brief delay to show the full ring light before capturing
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
|
||||||
|
let captureSettings = AVCapturePhotoSettings()
|
||||||
|
photoOutput?.capturePhoto(with: captureSettings, delegate: self)
|
||||||
|
|
||||||
|
// Restore preview after capture completes
|
||||||
|
try? await Task.sleep(for: .milliseconds(200))
|
||||||
|
isPreviewHidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRecording() {
|
||||||
|
guard let videoOutput = videoOutput, !isRecording else { return }
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("video.mov")
|
||||||
|
videoOutput.startRecording(to: url, recordingDelegate: self)
|
||||||
|
isRecording = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording() {
|
||||||
|
guard let videoOutput = videoOutput, isRecording else { return }
|
||||||
|
videoOutput.stopRecording()
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreBrightness() {
|
||||||
|
setBrightness(originalBrightness)
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business logic: Check if ready to capture
|
||||||
|
var canCapture: Bool {
|
||||||
|
captureSession?.isRunning == true && isPhotoLibraryAuthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraViewModel: AVCapturePhotoCaptureDelegate {
|
||||||
|
nonisolated func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
||||||
|
guard let data = photo.fileDataRepresentation() else { return }
|
||||||
|
PHPhotoLibrary.shared().performChanges {
|
||||||
|
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil)
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: String(localized: "Photo captured"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraViewModel: AVCaptureFileOutputRecordingDelegate {
|
||||||
|
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
|
||||||
|
PHPhotoLibrary.shared().performChanges {
|
||||||
|
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: outputFileURL, options: nil)
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: String(localized: "Video saved"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraViewModel: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
|
nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||||
|
// Note: This runs on a background queue and cannot access @MainActor isolated properties directly
|
||||||
|
// For real skin smoothing, this would need to be implemented with a Metal-based approach
|
||||||
|
// or by using AVCaptureVideoDataOutput with custom rendering
|
||||||
|
|
||||||
|
// Basic skin smoothing placeholder - actual implementation would require:
|
||||||
|
// 1. CIContext created on this queue
|
||||||
|
// 2. Rendering to a Metal texture
|
||||||
|
// 3. Displaying via CAMetalLayer or similar
|
||||||
|
|
||||||
|
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||||
|
let ciImage = CIImage(cvPixelBuffer: imageBuffer)
|
||||||
|
|
||||||
|
// Apply light gaussian blur for skin smoothing effect
|
||||||
|
guard let filter = CIFilter(name: "CIGaussianBlur") else { return }
|
||||||
|
filter.setValue(ciImage, forKey: kCIInputImageKey)
|
||||||
|
filter.setValue(1.0, forKey: kCIInputRadiusKey)
|
||||||
|
|
||||||
|
// For a complete implementation, render outputImage to the preview layer
|
||||||
|
_ = filter.outputImage
|
||||||
|
}
|
||||||
|
}
|
||||||
316
SelfieRingLight/Features/Camera/ContentView.swift
Normal file
316
SelfieRingLight/Features/Camera/ContentView.swift
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@State private var viewModel = CameraViewModel()
|
||||||
|
@State private var premiumManager = PremiumManager()
|
||||||
|
@State private var showPaywall = false
|
||||||
|
@State private var showSettings = false
|
||||||
|
|
||||||
|
// Direct reference to shared settings
|
||||||
|
private var settings: SettingsViewModel {
|
||||||
|
viewModel.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
// MARK: - Ring Light Background
|
||||||
|
ringLightBackground
|
||||||
|
|
||||||
|
// MARK: - Camera Preview (centered with border inset)
|
||||||
|
cameraPreviewArea(in: geometry)
|
||||||
|
|
||||||
|
// MARK: - Grid Overlay
|
||||||
|
if settings.isGridVisible && !viewModel.isPreviewHidden {
|
||||||
|
let previewSize = min(
|
||||||
|
geometry.size.width - (settings.ringSize * 2),
|
||||||
|
geometry.size.height - (settings.ringSize * 2)
|
||||||
|
)
|
||||||
|
GridOverlay(isVisible: true)
|
||||||
|
.frame(width: previewSize, height: previewSize)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls Overlay
|
||||||
|
controlsOverlay
|
||||||
|
|
||||||
|
// MARK: - Permission Denied View
|
||||||
|
if !viewModel.isCameraAuthorized && viewModel.captureSession != nil {
|
||||||
|
permissionDeniedView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.task {
|
||||||
|
await viewModel.setupCamera()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
viewModel.restoreBrightness()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPaywall) {
|
||||||
|
ProPaywallView()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
SettingsView(viewModel: viewModel.settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ring Light Background
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var ringLightBackground: some View {
|
||||||
|
let baseColor = premiumManager.isPremiumUnlocked ? settings.lightColor : Color.RingLight.pureWhite
|
||||||
|
|
||||||
|
// Apply light intensity as opacity
|
||||||
|
baseColor
|
||||||
|
.opacity(settings.lightIntensity)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.lightIntensity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Camera Preview Area
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cameraPreviewArea(in geometry: GeometryProxy) -> some View {
|
||||||
|
// Calculate the size of the preview area (full screen minus ring on all sides)
|
||||||
|
let previewSize = min(
|
||||||
|
geometry.size.width - (settings.ringSize * 2),
|
||||||
|
geometry.size.height - (settings.ringSize * 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
if viewModel.isCameraAuthorized {
|
||||||
|
// Show preview unless front flash is active
|
||||||
|
if !viewModel.isPreviewHidden {
|
||||||
|
CameraPreview(
|
||||||
|
viewModel: viewModel,
|
||||||
|
isMirrorFlipped: settings.isMirrorFlipped,
|
||||||
|
zoomFactor: settings.currentZoomFactor
|
||||||
|
)
|
||||||
|
.frame(width: previewSize, height: previewSize)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show placeholder while requesting permission
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.fill(.black)
|
||||||
|
.frame(width: previewSize, height: previewSize)
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize)
|
||||||
|
.overlay {
|
||||||
|
if viewModel.captureSession == nil {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls Overlay
|
||||||
|
|
||||||
|
private var controlsOverlay: some View {
|
||||||
|
VStack {
|
||||||
|
// Top bar
|
||||||
|
topControlBar
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Bottom capture controls
|
||||||
|
bottomControlBar
|
||||||
|
}
|
||||||
|
.padding(settings.ringSize + Design.Spacing.medium)
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Top Control Bar
|
||||||
|
|
||||||
|
private var topControlBar: some View {
|
||||||
|
HStack {
|
||||||
|
// Pro/Crown button
|
||||||
|
Button {
|
||||||
|
showPaywall = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: premiumManager.isPremiumUnlocked ? "crown.fill" : "crown")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(premiumManager.isPremiumUnlocked ? .yellow : .white)
|
||||||
|
.padding(Design.Spacing.small)
|
||||||
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(premiumManager.isPremiumUnlocked ?
|
||||||
|
String(localized: "Pro unlocked") :
|
||||||
|
String(localized: "Upgrade to Pro"))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Grid toggle
|
||||||
|
Button {
|
||||||
|
viewModel.settings.isGridVisible.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "square.grid.3x3")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(viewModel.settings.isGridVisible ? .yellow : .white)
|
||||||
|
.padding(Design.Spacing.small)
|
||||||
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Toggle grid"))
|
||||||
|
.accessibilityValue(viewModel.settings.isGridVisible ? "On" : "Off")
|
||||||
|
|
||||||
|
// Settings button
|
||||||
|
Button {
|
||||||
|
showSettings = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "gearshape.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(Design.Spacing.small)
|
||||||
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Settings"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bottom Control Bar
|
||||||
|
|
||||||
|
private var bottomControlBar: some View {
|
||||||
|
HStack(spacing: Design.Spacing.xxxxLarge) {
|
||||||
|
// Switch camera button
|
||||||
|
Button {
|
||||||
|
viewModel.switchCamera()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "camera.rotate.fill")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Switch camera"))
|
||||||
|
|
||||||
|
// Capture button
|
||||||
|
captureButton
|
||||||
|
|
||||||
|
// Capture mode selector
|
||||||
|
captureModeMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture Button
|
||||||
|
|
||||||
|
private var captureButton: some View {
|
||||||
|
Button {
|
||||||
|
captureAction()
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(.white)
|
||||||
|
.frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.stroke(.white, lineWidth: Design.LineWidth.thick)
|
||||||
|
.frame(width: Design.Capture.buttonSize + Design.Spacing.small, height: Design.Capture.buttonSize + Design.Spacing.small)
|
||||||
|
|
||||||
|
// Show red stop square when recording
|
||||||
|
if viewModel.isRecording {
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
|
||||||
|
.fill(.red)
|
||||||
|
.frame(width: Design.Capture.stopSquare, height: Design.Capture.stopSquare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel(captureButtonLabel)
|
||||||
|
.disabled(!viewModel.canCapture)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture Mode Menu
|
||||||
|
|
||||||
|
private var captureModeMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(CaptureMode.allCases) { mode in
|
||||||
|
Button {
|
||||||
|
if !mode.isPremium || premiumManager.isPremiumUnlocked {
|
||||||
|
viewModel.settings.selectedCaptureMode = mode
|
||||||
|
} else {
|
||||||
|
showPaywall = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label(mode.displayName, systemImage: mode.systemImage)
|
||||||
|
if mode.isPremium && !premiumManager.isPremiumUnlocked {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.settings.selectedCaptureMode.systemImage)
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Capture mode: \(viewModel.settings.selectedCaptureMode.displayName)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission Denied View
|
||||||
|
|
||||||
|
private var permissionDeniedView: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.font(.system(size: Design.BaseFontSize.hero))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
|
Text("Camera Access Required")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Please enable camera access in Settings to use SelfieRingLight.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
|
||||||
|
Button("Open Settings") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(.black.opacity(Design.Opacity.heavy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture Action
|
||||||
|
|
||||||
|
private func captureAction() {
|
||||||
|
switch viewModel.settings.selectedCaptureMode {
|
||||||
|
case .photo:
|
||||||
|
viewModel.capturePhoto()
|
||||||
|
case .video:
|
||||||
|
if viewModel.isRecording {
|
||||||
|
viewModel.stopRecording()
|
||||||
|
} else {
|
||||||
|
viewModel.startRecording()
|
||||||
|
}
|
||||||
|
case .boomerang:
|
||||||
|
// TODO: Implement boomerang capture
|
||||||
|
viewModel.capturePhoto()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var captureButtonLabel: String {
|
||||||
|
switch viewModel.settings.selectedCaptureMode {
|
||||||
|
case .photo:
|
||||||
|
return String(localized: "Take photo")
|
||||||
|
case .video:
|
||||||
|
return viewModel.isRecording ? String(localized: "Stop recording") : String(localized: "Start recording")
|
||||||
|
case .boomerang:
|
||||||
|
return String(localized: "Capture boomerang")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
36
SelfieRingLight/Features/Camera/GridOverlay.swift
Normal file
36
SelfieRingLight/Features/Camera/GridOverlay.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// Grid Overlay as separate view
|
||||||
|
struct GridOverlay: View {
|
||||||
|
var isVisible: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if isVisible {
|
||||||
|
GeometryReader { geo in
|
||||||
|
Path { path in
|
||||||
|
let w = geo.size.width
|
||||||
|
let h = geo.size.height
|
||||||
|
let thirdW = w / 3
|
||||||
|
let thirdH = h / 3
|
||||||
|
|
||||||
|
// Vertical lines
|
||||||
|
path.move(to: CGPoint(x: thirdW, y: 0))
|
||||||
|
path.addLine(to: CGPoint(x: thirdW, y: h))
|
||||||
|
path.move(to: CGPoint(x: 2 * thirdW, y: 0))
|
||||||
|
path.addLine(to: CGPoint(x: 2 * thirdW, y: h))
|
||||||
|
|
||||||
|
// Horizontal lines
|
||||||
|
path.move(to: CGPoint(x: 0, y: thirdH))
|
||||||
|
path.addLine(to: CGPoint(x: w, y: thirdH))
|
||||||
|
path.move(to: CGPoint(x: 0, y: 2 * thirdH))
|
||||||
|
path.addLine(to: CGPoint(x: w, y: 2 * thirdH))
|
||||||
|
}
|
||||||
|
.stroke(Color.white, lineWidth: Design.Grid.lineWidth)
|
||||||
|
.opacity(Design.Grid.opacity)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
137
SelfieRingLight/Features/Paywall/ProPaywallView.swift
Normal file
137
SelfieRingLight/Features/Paywall/ProPaywallView.swift
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import RevenueCat
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct ProPaywallView: View {
|
||||||
|
@State private var manager = PremiumManager()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = Design.BaseFontSize.body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Design.Spacing.xLarge) {
|
||||||
|
// Crown icon
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.font(.system(size: Design.BaseFontSize.hero))
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
|
||||||
|
Text(String(localized: "Go Pro"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
|
||||||
|
|
||||||
|
// Benefits list
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
BenefitRow(image: "paintpalette", text: String(localized: "All Color Presets + Custom Colors"))
|
||||||
|
BenefitRow(image: "sparkles", text: String(localized: "Advanced Beauty Filters"))
|
||||||
|
BenefitRow(image: "gradient", text: String(localized: "Directional Gradient Lighting"))
|
||||||
|
BenefitRow(image: "wand.and.stars", text: String(localized: "Unlimited Boomerang Length"))
|
||||||
|
BenefitRow(image: "checkmark.seal", text: String(localized: "No Watermarks • Ad-Free"))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Product packages
|
||||||
|
if manager.availablePackages.isEmpty {
|
||||||
|
ProgressView()
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
ForEach(manager.availablePackages, id: \.identifier) { package in
|
||||||
|
ProductPackageButton(
|
||||||
|
package: package,
|
||||||
|
isPremiumUnlocked: manager.isPremiumUnlocked,
|
||||||
|
onPurchase: {
|
||||||
|
Task {
|
||||||
|
_ = try? await manager.purchase(package)
|
||||||
|
if manager.isPremiumUnlocked {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore purchases
|
||||||
|
Button(String(localized: "Restore Purchases")) {
|
||||||
|
Task { try? await manager.restorePurchases() }
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
}
|
||||||
|
.background(Color.Surface.overlay)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "Cancel")) { dismiss() }
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: bodyFontSize))
|
||||||
|
.task { try? await manager.loadProducts() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Product Package Button
|
||||||
|
|
||||||
|
private struct ProductPackageButton: View {
|
||||||
|
let package: Package
|
||||||
|
let isPremiumUnlocked: Bool
|
||||||
|
let onPurchase: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onPurchase) {
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Text(package.storeProduct.localizedTitle)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(package.localizedPriceString)
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if package.packageType == .annual {
|
||||||
|
Text(String(localized: "Best Value • Save 33%"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.Accent.primary.opacity(Design.Opacity.medium))
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.strokeBorder(Color.Accent.primary, lineWidth: Design.LineWidth.thin)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Benefit Row
|
||||||
|
|
||||||
|
struct BenefitRow: View {
|
||||||
|
let image: String
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: image)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.Accent.primary)
|
||||||
|
.frame(width: Design.IconSize.xLarge)
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ProPaywallView()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
329
SelfieRingLight/Features/Settings/SettingsView.swift
Normal file
329
SelfieRingLight/Features/Settings/SettingsView.swift
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@Bindable var viewModel: SettingsViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
|
||||||
|
// MARK: - Ring Light Section
|
||||||
|
|
||||||
|
SettingsSectionHeader(title: "Ring Light", systemImage: "light.max")
|
||||||
|
|
||||||
|
// Ring Size Slider
|
||||||
|
ringSizeSlider
|
||||||
|
|
||||||
|
// Color Preset
|
||||||
|
colorPresetSection
|
||||||
|
|
||||||
|
// Brightness Slider
|
||||||
|
brightnessSlider
|
||||||
|
|
||||||
|
// MARK: - Camera Section
|
||||||
|
|
||||||
|
SettingsSectionHeader(title: "Camera", systemImage: "camera")
|
||||||
|
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "Front Flash"),
|
||||||
|
subtitle: String(localized: "Hides preview during capture for a flash effect"),
|
||||||
|
isOn: $viewModel.isFrontFlashEnabled
|
||||||
|
)
|
||||||
|
.accessibilityHint(String(localized: "Uses the ring light as a flash when taking photos"))
|
||||||
|
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "True Mirror"),
|
||||||
|
subtitle: String(localized: "Shows non-flipped preview like a real mirror"),
|
||||||
|
isOn: $viewModel.isMirrorFlipped
|
||||||
|
)
|
||||||
|
.accessibilityHint(String(localized: "When enabled, the preview is not mirrored"))
|
||||||
|
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "Skin Smoothing"),
|
||||||
|
subtitle: String(localized: "Applies subtle real-time smoothing"),
|
||||||
|
isOn: $viewModel.isSkinSmoothingEnabled
|
||||||
|
)
|
||||||
|
.accessibilityHint(String(localized: "Applies light skin smoothing to the camera preview"))
|
||||||
|
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "Grid Overlay"),
|
||||||
|
subtitle: String(localized: "Shows rule of thirds grid"),
|
||||||
|
isOn: $viewModel.isGridVisible
|
||||||
|
)
|
||||||
|
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
|
||||||
|
|
||||||
|
// Timer Selection
|
||||||
|
timerPicker
|
||||||
|
|
||||||
|
// MARK: - Sync Section
|
||||||
|
|
||||||
|
SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud")
|
||||||
|
|
||||||
|
iCloudSyncSection
|
||||||
|
|
||||||
|
Spacer(minLength: Design.Spacing.xxxLarge)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
.background(Color.Surface.overlay)
|
||||||
|
.navigationTitle(String(localized: "Settings"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(String(localized: "Done")) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.Accent.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ring Size Slider
|
||||||
|
|
||||||
|
private var ringSizeSlider: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "Ring Size"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(Int(viewModel.ringSize))pt")
|
||||||
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Small ring icon
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.font(.system(size: Design.BaseFontSize.small))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: $viewModel.ringSize,
|
||||||
|
in: SettingsViewModel.minRingSize...SettingsViewModel.maxRingSize,
|
||||||
|
step: 5
|
||||||
|
)
|
||||||
|
.tint(Color.Accent.primary)
|
||||||
|
|
||||||
|
// Large ring icon
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.font(.system(size: Design.BaseFontSize.large))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(String(localized: "Adjusts the size of the light ring around the camera preview"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.caption))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.accessibilityLabel(String(localized: "Ring size"))
|
||||||
|
.accessibilityValue("\(Int(viewModel.ringSize)) points")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Preset Section
|
||||||
|
|
||||||
|
private var colorPresetSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
Text(String(localized: "Light Color"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [GridItem(.adaptive(minimum: 80), spacing: Design.Spacing.small)],
|
||||||
|
spacing: Design.Spacing.small
|
||||||
|
) {
|
||||||
|
ForEach(RingLightColor.allPresets) { preset in
|
||||||
|
ColorPresetButton(
|
||||||
|
preset: preset,
|
||||||
|
isSelected: viewModel.selectedLightColor == preset
|
||||||
|
) {
|
||||||
|
viewModel.selectedLightColor = preset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Light Intensity Slider
|
||||||
|
|
||||||
|
private var brightnessSlider: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "Light Intensity"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(Int(viewModel.lightIntensity * 100))%")
|
||||||
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: "light.min")
|
||||||
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
|
Slider(value: $viewModel.lightIntensity, in: 0.5...1.0, step: 0.05)
|
||||||
|
.tint(Color.Accent.primary)
|
||||||
|
|
||||||
|
Image(systemName: "light.max")
|
||||||
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(String(localized: "Adjusts the opacity/intensity of the ring light"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.caption))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.accessibilityLabel(String(localized: "Light intensity"))
|
||||||
|
.accessibilityValue("\(Int(viewModel.lightIntensity * 100)) percent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timer Picker
|
||||||
|
|
||||||
|
private var timerPicker: some View {
|
||||||
|
SegmentedPicker(
|
||||||
|
title: String(localized: "Self-Timer"),
|
||||||
|
options: TimerOption.allCases.map { ($0.displayName, $0) },
|
||||||
|
selection: $viewModel.selectedTimer
|
||||||
|
)
|
||||||
|
.accessibilityLabel(String(localized: "Select self-timer duration"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iCloud Sync Section
|
||||||
|
|
||||||
|
private var iCloudSyncSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
// Sync toggle
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "Sync Settings"),
|
||||||
|
subtitle: viewModel.iCloudAvailable
|
||||||
|
? String(localized: "Sync settings across all your devices")
|
||||||
|
: String(localized: "Sign in to iCloud to enable sync"),
|
||||||
|
isOn: $viewModel.iCloudEnabled
|
||||||
|
)
|
||||||
|
.disabled(!viewModel.iCloudAvailable)
|
||||||
|
|
||||||
|
// Sync status
|
||||||
|
if viewModel.iCloudEnabled && viewModel.iCloudAvailable {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: syncStatusIcon)
|
||||||
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
|
.foregroundStyle(syncStatusColor)
|
||||||
|
|
||||||
|
Text(syncStatusText)
|
||||||
|
.font(.system(size: Design.BaseFontSize.caption))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.forceSync()
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: "Sync Now"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.caption, weight: .medium))
|
||||||
|
.foregroundStyle(Color.Accent.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, Design.Spacing.xSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Status Helpers
|
||||||
|
|
||||||
|
private var syncStatusIcon: String {
|
||||||
|
if !viewModel.hasCompletedInitialSync {
|
||||||
|
return "arrow.triangle.2.circlepath"
|
||||||
|
}
|
||||||
|
return viewModel.syncStatus.isEmpty ? "checkmark.icloud" : "icloud"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var syncStatusColor: Color {
|
||||||
|
if !viewModel.hasCompletedInitialSync {
|
||||||
|
return Color.Status.warning
|
||||||
|
}
|
||||||
|
return Color.Status.success
|
||||||
|
}
|
||||||
|
|
||||||
|
private var syncStatusText: String {
|
||||||
|
if !viewModel.hasCompletedInitialSync {
|
||||||
|
return String(localized: "Syncing...")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastSync = viewModel.lastSyncDate {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return String(localized: "Last synced \(formatter.localizedString(for: lastSync, relativeTo: Date()))")
|
||||||
|
}
|
||||||
|
|
||||||
|
return viewModel.syncStatus.isEmpty
|
||||||
|
? String(localized: "Synced")
|
||||||
|
: viewModel.syncStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Preset Button
|
||||||
|
|
||||||
|
private struct ColorPresetButton: View {
|
||||||
|
let preset: RingLightColor
|
||||||
|
let isSelected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: Design.Spacing.xxSmall) {
|
||||||
|
Circle()
|
||||||
|
.fill(preset.color)
|
||||||
|
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(
|
||||||
|
isSelected ? Color.Accent.primary : Color.Border.subtle,
|
||||||
|
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.shadow(
|
||||||
|
color: preset.color.opacity(Design.Opacity.light),
|
||||||
|
radius: isSelected ? Design.Shadow.radiusSmall : 0
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(preset.name)
|
||||||
|
.font(.system(size: Design.BaseFontSize.xSmall))
|
||||||
|
.foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
||||||
|
|
||||||
|
if preset.isPremium {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.font(.system(size: Design.BaseFontSize.xxSmall))
|
||||||
|
.foregroundStyle(Color.Status.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.xSmall)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
||||||
|
.fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(preset.name)
|
||||||
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||||
|
.accessibilityHint(preset.isPremium ? String(localized: "Premium color") : "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SettingsView(viewModel: SettingsViewModel())
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
233
SelfieRingLight/Features/Settings/SettingsViewModel.swift
Normal file
233
SelfieRingLight/Features/Settings/SettingsViewModel.swift
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Timer Options
|
||||||
|
|
||||||
|
enum TimerOption: String, CaseIterable, Identifiable {
|
||||||
|
case off = "off"
|
||||||
|
case three = "3"
|
||||||
|
case five = "5"
|
||||||
|
case ten = "10"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .off: return String(localized: "Off")
|
||||||
|
case .three: return String(localized: "3s")
|
||||||
|
case .five: return String(localized: "5s")
|
||||||
|
case .ten: return String(localized: "10s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var seconds: Int {
|
||||||
|
switch self {
|
||||||
|
case .off: return 0
|
||||||
|
case .three: return 3
|
||||||
|
case .five: return 5
|
||||||
|
case .ten: return 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture Mode
|
||||||
|
|
||||||
|
enum CaptureMode: String, CaseIterable, Identifiable {
|
||||||
|
case photo = "photo"
|
||||||
|
case video = "video"
|
||||||
|
case boomerang = "boomerang"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .photo: return String(localized: "Photo")
|
||||||
|
case .video: return String(localized: "Video")
|
||||||
|
case .boomerang: return String(localized: "Boomerang")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .photo: return "camera.fill"
|
||||||
|
case .video: return "video.fill"
|
||||||
|
case .boomerang: return "arrow.2.squarepath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPremium: Bool {
|
||||||
|
switch self {
|
||||||
|
case .photo: return false
|
||||||
|
case .video, .boomerang: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings ViewModel
|
||||||
|
|
||||||
|
/// Observable settings view model with iCloud sync across all devices.
|
||||||
|
/// Uses Bedrock's CloudSyncManager for automatic synchronization.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SettingsViewModel: RingLightConfigurable {
|
||||||
|
|
||||||
|
// MARK: - Ring Size Limits
|
||||||
|
|
||||||
|
/// Minimum ring border size in points
|
||||||
|
static let minRingSize: CGFloat = 10
|
||||||
|
|
||||||
|
/// Maximum ring border size in points
|
||||||
|
static let maxRingSize: CGFloat = 120
|
||||||
|
|
||||||
|
/// Default ring border size
|
||||||
|
static let defaultRingSize: CGFloat = 40
|
||||||
|
|
||||||
|
// MARK: - Cloud Sync Manager
|
||||||
|
|
||||||
|
/// Manages iCloud sync for settings across all devices
|
||||||
|
private let cloudSync = CloudSyncManager<SyncedSettings>()
|
||||||
|
|
||||||
|
// MARK: - Observable Properties (Synced)
|
||||||
|
|
||||||
|
/// Ring border size in points
|
||||||
|
var ringSize: CGFloat {
|
||||||
|
get { cloudSync.data.ringSize }
|
||||||
|
set { updateSettings { $0.ringSize = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ID of the selected light color preset
|
||||||
|
var lightColorId: String {
|
||||||
|
get { cloudSync.data.lightColorId }
|
||||||
|
set { updateSettings { $0.lightColorId = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ring light intensity/opacity (0.5 to 1.0)
|
||||||
|
var lightIntensity: Double {
|
||||||
|
get { cloudSync.data.lightIntensity }
|
||||||
|
set { updateSettings { $0.lightIntensity = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether front flash is enabled (hides preview during capture)
|
||||||
|
var isFrontFlashEnabled: Bool {
|
||||||
|
get { cloudSync.data.isFrontFlashEnabled }
|
||||||
|
set { updateSettings { $0.isFrontFlashEnabled = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the camera preview is flipped to show a true mirror
|
||||||
|
var isMirrorFlipped: Bool {
|
||||||
|
get { cloudSync.data.isMirrorFlipped }
|
||||||
|
set { updateSettings { $0.isMirrorFlipped = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether skin smoothing filter is enabled
|
||||||
|
var isSkinSmoothingEnabled: Bool {
|
||||||
|
get { cloudSync.data.isSkinSmoothingEnabled }
|
||||||
|
set { updateSettings { $0.isSkinSmoothingEnabled = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the grid overlay is visible
|
||||||
|
var isGridVisible: Bool {
|
||||||
|
get { cloudSync.data.isGridVisible }
|
||||||
|
set { updateSettings { $0.isGridVisible = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current camera zoom factor
|
||||||
|
var currentZoomFactor: Double {
|
||||||
|
get { cloudSync.data.currentZoomFactor }
|
||||||
|
set { updateSettings { $0.currentZoomFactor = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
/// Convenience property for border width (same as ringSize)
|
||||||
|
var borderWidth: CGFloat { ringSize }
|
||||||
|
|
||||||
|
var selectedTimer: TimerOption {
|
||||||
|
get { TimerOption(rawValue: cloudSync.data.selectedTimerRaw) ?? .off }
|
||||||
|
set { updateSettings { $0.selectedTimerRaw = newValue.rawValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedCaptureMode: CaptureMode {
|
||||||
|
get { CaptureMode(rawValue: cloudSync.data.selectedCaptureModeRaw) ?? .photo }
|
||||||
|
set { updateSettings { $0.selectedCaptureModeRaw = newValue.rawValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedLightColor: RingLightColor {
|
||||||
|
get { RingLightColor.fromId(lightColorId) }
|
||||||
|
set { lightColorId = newValue.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
var lightColor: Color {
|
||||||
|
selectedLightColor.color
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Status
|
||||||
|
|
||||||
|
/// Whether iCloud sync is available
|
||||||
|
var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
|
||||||
|
|
||||||
|
/// Whether iCloud sync is enabled
|
||||||
|
var iCloudEnabled: Bool {
|
||||||
|
get { cloudSync.iCloudEnabled }
|
||||||
|
set { cloudSync.iCloudEnabled = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last sync date
|
||||||
|
var lastSyncDate: Date? { cloudSync.lastSyncDate }
|
||||||
|
|
||||||
|
/// Current sync status message
|
||||||
|
var syncStatus: String { cloudSync.syncStatus }
|
||||||
|
|
||||||
|
/// Whether initial sync has completed
|
||||||
|
var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync }
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// CloudSyncManager handles syncing automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Updates settings and saves to cloud
|
||||||
|
private func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
|
||||||
|
cloudSync.update { settings in
|
||||||
|
transform(&settings)
|
||||||
|
settings.modificationCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Actions
|
||||||
|
|
||||||
|
/// Forces a sync with iCloud
|
||||||
|
func forceSync() {
|
||||||
|
cloudSync.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets all settings to defaults
|
||||||
|
func resetToDefaults() {
|
||||||
|
cloudSync.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
var isValidConfiguration: Bool {
|
||||||
|
ringSize >= Self.minRingSize && lightIntensity >= 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CaptureControlling Conformance
|
||||||
|
|
||||||
|
extension SettingsViewModel: CaptureControlling {
|
||||||
|
func startCountdown() async {
|
||||||
|
// Countdown handled by CameraViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func performCapture() {
|
||||||
|
// Capture handled by CameraViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func performFlashBurst() async {
|
||||||
|
// Flash handled by CameraViewModel
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
SelfieRingLight/Resources/Assets.xcassets/Contents.json
Normal file
6
SelfieRingLight/Resources/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
307
SelfieRingLight/Resources/Localizable.xcstrings
Normal file
307
SelfieRingLight/Resources/Localizable.xcstrings
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage" : "en",
|
||||||
|
"strings" : {
|
||||||
|
"%lld percent" : {
|
||||||
|
"comment" : "The value of the slider is shown as a percentage.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"%lld points" : {
|
||||||
|
"comment" : "The value of the ring size slider, displayed in parentheses.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"%lld%%" : {
|
||||||
|
"comment" : "A text label displaying the current brightness percentage.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"%lldpt" : {
|
||||||
|
"comment" : "A label displaying the current ring size, formatted as a number followed by the unit \"pt\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"3s" : {
|
||||||
|
"comment" : "Display name for the \"3 seconds\" timer option.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"5s" : {
|
||||||
|
"comment" : "Description of a timer option when the timer is set to 5 seconds.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"10s" : {
|
||||||
|
"comment" : "Description of a timer option when the user selects \"10 seconds\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Adjusts the size of the light ring around the camera preview" : {
|
||||||
|
"comment" : "A description of the ring size slider in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Advanced Beauty Filters" : {
|
||||||
|
"comment" : "Description of a benefit included in the \"Go Pro\" premium subscription.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"All Color Presets + Custom Colors" : {
|
||||||
|
"comment" : "Benefit description for the \"All Color Presets + Custom Colors\" benefit.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Applies light skin smoothing to the camera preview" : {
|
||||||
|
"comment" : "A hint for the \"Skin Smoothing\" toggle in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Applies subtle real-time smoothing" : {
|
||||||
|
"comment" : "Accessibility hint for the \"Skin Smoothing\" toggle in the Settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Best Value • Save 33%" : {
|
||||||
|
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Boomerang" : {
|
||||||
|
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Camera Access Required" : {
|
||||||
|
"comment" : "A title displayed when camera access is denied.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Cancel" : {
|
||||||
|
"comment" : "The text for a button that dismisses the current view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Capture boomerang" : {
|
||||||
|
"comment" : "Label for capturing a boomerang photo.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Capture mode: %@" : {
|
||||||
|
"comment" : "A label describing the currently selected capture mode. The placeholder is replaced with the actual mode name.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Cool Lavender" : {
|
||||||
|
"comment" : "Name of a ring light color preset.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Debug mode: Purchase simulated!" : {
|
||||||
|
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Debug mode: Restore simulated!" : {
|
||||||
|
"comment" : "Accessibility announcement when restoring purchases in debug mode.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Directional Gradient Lighting" : {
|
||||||
|
"comment" : "Benefit provided with the Pro subscription, such as \"Directional Gradient Lighting\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Done" : {
|
||||||
|
"comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Go Pro" : {
|
||||||
|
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Grid Overlay" : {
|
||||||
|
"comment" : "Text displayed in a settings toggle for showing a grid overlay to help compose your shot.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Higher brightness = brighter ring light effect" : {
|
||||||
|
"comment" : "A description of how to adjust the brightness of the screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ice Blue" : {
|
||||||
|
"comment" : "Name of a ring light color preset.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Last synced %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Light Color" : {
|
||||||
|
"comment" : "A label displayed above a section of the settings view related to light colors.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"No Watermarks • Ad-Free" : {
|
||||||
|
"comment" : "Description of a benefit that comes with the Pro subscription.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Off" : {
|
||||||
|
"comment" : "The accessibility value for the grid toggle when it is off.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"On" : {
|
||||||
|
"comment" : "A label describing a setting that is currently enabled.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Open Settings" : {
|
||||||
|
"comment" : "A button label that opens the device settings when tapped.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Photo" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Photo captured" : {
|
||||||
|
"comment" : "Accessibility label for a notification that is posted when a photo is captured.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Please enable camera access in Settings to use SelfieRingLight." : {
|
||||||
|
"comment" : "A message instructing the user to enable camera access in Settings to use SelfieRingLight.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Premium color" : {
|
||||||
|
"comment" : "An accessibility hint for a premium color option in the color preset button.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Pro unlocked" : {
|
||||||
|
"comment" : "An accessibility label for the \"crown.fill\" system icon when premium is unlocked.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Purchase successful! Pro features unlocked." : {
|
||||||
|
"comment" : "Announcement read out to the user when a premium purchase is successful.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Purchases restored" : {
|
||||||
|
"comment" : "Announcement read out to the user when purchases are restored.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Pure White" : {
|
||||||
|
"comment" : "A color preset option for the ring light that displays as pure white.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Restore Purchases" : {
|
||||||
|
"comment" : "A button that restores purchases.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ring size" : {
|
||||||
|
"comment" : "An accessibility label for the ring size slider in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ring Size" : {
|
||||||
|
"comment" : "The label for the ring size slider in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Screen brightness" : {
|
||||||
|
"comment" : "An accessibility label for the screen brightness setting in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Screen Brightness" : {
|
||||||
|
"comment" : "A label displayed above the brightness slider in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Select self-timer duration" : {
|
||||||
|
"comment" : "A label describing the segmented control for selecting the duration of the self-timer.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Self-Timer" : {
|
||||||
|
"comment" : "Title of the section in the settings view that allows the user to select the duration of the self-timer.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Settings" : {
|
||||||
|
"comment" : "The title of the settings screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Shows a grid overlay to help compose your shot" : {
|
||||||
|
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Shows non-flipped preview like a real mirror" : {
|
||||||
|
"comment" : "Subtitle for the \"True Mirror\" toggle in the Settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Shows rule of thirds grid" : {
|
||||||
|
"comment" : "Accessibility hint for the grid overlay toggle.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Sign in to iCloud to enable sync" : {
|
||||||
|
"comment" : "Subtitle of the iCloud sync section when the user is not signed into iCloud.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Skin Smoothing" : {
|
||||||
|
"comment" : "A toggle that enables or disables real-time skin smoothing.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Soft Pink" : {
|
||||||
|
"comment" : "Name of a ring light color preset.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Start recording" : {
|
||||||
|
"comment" : "Label for the \"Start recording\" button in the bottom control bar when not recording a video.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Stop recording" : {
|
||||||
|
"comment" : "Label for the button that stops recording a video.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Subscribe to %@ for %@" : {
|
||||||
|
"comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Subscribe to %1$@ for %2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Switch camera" : {
|
||||||
|
"comment" : "A button label that translates to \"Switch camera\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Sync Now" : {
|
||||||
|
"comment" : "A button label that triggers a sync action.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Sync Settings" : {
|
||||||
|
"comment" : "Title of a toggle that allows the user to enable or disable iCloud sync settings.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Sync settings across all your devices" : {
|
||||||
|
"comment" : "Subtitle of the \"Sync Settings\" toggle in the Settings view, describing the functionality when sync is enabled.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Synced" : {
|
||||||
|
"comment" : "Text displayed in the iCloud sync section when the user's settings have been successfully synced.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Syncing..." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Take photo" : {
|
||||||
|
"comment" : "Label for the \"Take photo\" button in the bottom control bar when using the photo capture mode.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Toggle grid" : {
|
||||||
|
"comment" : "A button that toggles the visibility of the grid in the camera view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"True Mirror" : {
|
||||||
|
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Unlimited Boomerang Length" : {
|
||||||
|
"comment" : "Description of a benefit that comes with the Pro subscription, specifically related to the boomerang tool.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Upgrade to Pro" : {
|
||||||
|
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Video" : {
|
||||||
|
"comment" : "Display name for the \"Video\" capture mode.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Video saved" : {
|
||||||
|
"comment" : "Accessibility notification text when a video is successfully saved to the user's photo library.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Warm Amber" : {
|
||||||
|
"comment" : "Name of a ring light color preset.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Warm Cream" : {
|
||||||
|
"comment" : "A color option for the ring light, named after a warm, creamy shade of white.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"When enabled, the preview is not mirrored" : {
|
||||||
|
"comment" : "Accessibility hint for the \"True Mirror\" setting in the Settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version" : "1.1"
|
||||||
|
}
|
||||||
8
SelfieRingLight/SelfieRingLight.entitlements
Normal file
8
SelfieRingLight/SelfieRingLight.entitlements
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
53
SelfieRingLight/Shared/Color+Extensions.swift
Normal file
53
SelfieRingLight/Shared/Color+Extensions.swift
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Ring Light Color Presets
|
||||||
|
|
||||||
|
/// App-specific color presets for the ring light feature.
|
||||||
|
/// Standard UI colors should use Bedrock's `Color.Surface`, `Color.Accent`, etc.
|
||||||
|
extension Color {
|
||||||
|
|
||||||
|
/// Ring light color presets for selfie lighting.
|
||||||
|
enum RingLight {
|
||||||
|
/// Pure white - standard daylight lighting.
|
||||||
|
static let pureWhite = Color(red: 1.0, green: 1.0, blue: 1.0)
|
||||||
|
|
||||||
|
/// Warm cream - soft warm lighting like golden hour.
|
||||||
|
static let warmCream = Color(red: 1.0, green: 0.98, blue: 0.9)
|
||||||
|
|
||||||
|
/// Ice blue - cool lighting for a crisp look.
|
||||||
|
static let iceBlue = Color(red: 0.9, green: 0.95, blue: 1.0)
|
||||||
|
|
||||||
|
/// Soft pink - flattering warm tone.
|
||||||
|
static let softPink = Color(red: 1.0, green: 0.92, blue: 0.95)
|
||||||
|
|
||||||
|
/// Warm amber - sunset-like glow.
|
||||||
|
static let warmAmber = Color(red: 1.0, green: 0.9, blue: 0.75)
|
||||||
|
|
||||||
|
/// Cool lavender - subtle cool tone.
|
||||||
|
static let coolLavender = Color(red: 0.95, green: 0.92, blue: 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ring Light Color Identifier
|
||||||
|
|
||||||
|
/// Identifiable wrapper for ring light colors to use in Picker/ForEach.
|
||||||
|
struct RingLightColor: Identifiable, Equatable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let color: Color
|
||||||
|
let isPremium: Bool
|
||||||
|
|
||||||
|
static let allPresets: [RingLightColor] = [
|
||||||
|
RingLightColor(id: "pureWhite", name: String(localized: "Pure White"), color: .RingLight.pureWhite, isPremium: false),
|
||||||
|
RingLightColor(id: "warmCream", name: String(localized: "Warm Cream"), color: .RingLight.warmCream, isPremium: false),
|
||||||
|
RingLightColor(id: "iceBlue", name: String(localized: "Ice Blue"), color: .RingLight.iceBlue, isPremium: true),
|
||||||
|
RingLightColor(id: "softPink", name: String(localized: "Soft Pink"), color: .RingLight.softPink, isPremium: true),
|
||||||
|
RingLightColor(id: "warmAmber", name: String(localized: "Warm Amber"), color: .RingLight.warmAmber, isPremium: true),
|
||||||
|
RingLightColor(id: "coolLavender", name: String(localized: "Cool Lavender"), color: .RingLight.coolLavender, isPremium: true)
|
||||||
|
]
|
||||||
|
|
||||||
|
static func fromId(_ id: String) -> RingLightColor {
|
||||||
|
allPresets.first { $0.id == id } ?? allPresets[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
60
SelfieRingLight/Shared/DesignConstants.swift
Normal file
60
SelfieRingLight/Shared/DesignConstants.swift
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Re-export Bedrock Design for convenience
|
||||||
|
|
||||||
|
/// Convenience typealias to use Bedrock's Design throughout the app.
|
||||||
|
typealias Design = Bedrock.Design
|
||||||
|
|
||||||
|
// MARK: - App-Specific Design Extensions
|
||||||
|
|
||||||
|
/// App-specific additions to Bedrock's Design system.
|
||||||
|
/// Use `Design.Spacing`, `Design.CornerRadius`, etc. from Bedrock for all standard values.
|
||||||
|
/// These extensions add domain-specific constants for the Selfie Ring Light app.
|
||||||
|
|
||||||
|
extension Bedrock.Design {
|
||||||
|
|
||||||
|
/// App-specific size constants (e.g., border sizes for ring light).
|
||||||
|
enum Size {
|
||||||
|
static let borderSmall: CGFloat = 2
|
||||||
|
static let borderMedium: CGFloat = 4
|
||||||
|
static let borderLarge: CGFloat = 6
|
||||||
|
static let iconMedium: CGFloat = 24
|
||||||
|
static let cardWidth: CGFloat = 80
|
||||||
|
static let cardHeight: CGFloat = 52
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ring light border thickness options (for UI display, not the multiplier).
|
||||||
|
enum RingBorder {
|
||||||
|
static let small: CGFloat = 20
|
||||||
|
static let medium: CGFloat = 40
|
||||||
|
static let large: CGFloat = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grid overlay configuration.
|
||||||
|
enum Grid {
|
||||||
|
static let lineWidth: CGFloat = 1
|
||||||
|
static let opacity: Double = 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture button sizes.
|
||||||
|
enum Capture {
|
||||||
|
static let buttonSize: CGFloat = 70
|
||||||
|
static let innerRing: CGFloat = 62
|
||||||
|
static let stopSquare: CGFloat = 28
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Camera control sizes.
|
||||||
|
enum Camera {
|
||||||
|
static let controlButtonSize: CGFloat = 44
|
||||||
|
static let flipIconSize: CGFloat = 22
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font sizes for the app (maps to Bedrock's BaseFontSize for consistency).
|
||||||
|
enum FontSize {
|
||||||
|
static let small: CGFloat = BaseFontSize.small
|
||||||
|
static let body: CGFloat = BaseFontSize.body
|
||||||
|
static let large: CGFloat = BaseFontSize.large
|
||||||
|
static let title: CGFloat = BaseFontSize.title
|
||||||
|
}
|
||||||
|
}
|
||||||
147
SelfieRingLight/Shared/Premium/PremiumManager.swift
Normal file
147
SelfieRingLight/Shared/Premium/PremiumManager.swift
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import RevenueCat
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class PremiumManager: PremiumManaging {
|
||||||
|
var availablePackages: [Package] = []
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
/// RevenueCat entitlement identifier - must match your RevenueCat dashboard
|
||||||
|
private let entitlementIdentifier = "pro"
|
||||||
|
|
||||||
|
/// Reads the RevenueCat API key from Info.plist (injected at build time from Secrets.xcconfig)
|
||||||
|
private static var apiKey: String {
|
||||||
|
guard let key = Bundle.main.object(forInfoDictionaryKey: "RevenueCatAPIKey") as? String,
|
||||||
|
!key.isEmpty,
|
||||||
|
key != "your_revenuecat_public_api_key_here" else {
|
||||||
|
#if DEBUG
|
||||||
|
print("⚠️ [PremiumManager] RevenueCat API key not configured. See Configuration/Secrets.xcconfig.template")
|
||||||
|
#endif
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Override
|
||||||
|
|
||||||
|
/// Check if debug premium is enabled via environment variable.
|
||||||
|
/// Set "ENABLE_DEBUG_PREMIUM" = "1" in your scheme's environment variables to unlock all premium features during debugging.
|
||||||
|
private var isDebugPremiumEnabled: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
return ProcessInfo.processInfo.environment["ENABLE_DEBUG_PREMIUM"] == "1"
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPremium: Bool {
|
||||||
|
// Debug override takes precedence
|
||||||
|
if isDebugPremiumEnabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If API key isn't configured, return false
|
||||||
|
guard !Self.apiKey.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Purchases.shared.cachedCustomerInfo?.entitlements[entitlementIdentifier]?.isActive == true
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPremiumUnlocked: Bool { isPremium }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
#if DEBUG
|
||||||
|
if isDebugPremiumEnabled {
|
||||||
|
print("🔓 [PremiumManager] Debug premium enabled via environment variable")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Only configure RevenueCat if we have a valid API key
|
||||||
|
guard !Self.apiKey.isEmpty else {
|
||||||
|
#if DEBUG
|
||||||
|
print("⚠️ [PremiumManager] Skipping RevenueCat configuration - no API key")
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
Purchases.logLevel = .debug
|
||||||
|
#endif
|
||||||
|
Purchases.configure(withAPIKey: Self.apiKey)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
try? await loadProducts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProducts() async throws {
|
||||||
|
guard !Self.apiKey.isEmpty else { return }
|
||||||
|
|
||||||
|
let offerings = try await Purchases.shared.offerings()
|
||||||
|
if let current = offerings.current {
|
||||||
|
availablePackages = current.availablePackages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(_ package: Package) async throws -> Bool {
|
||||||
|
#if DEBUG
|
||||||
|
if isDebugPremiumEnabled {
|
||||||
|
// Simulate successful purchase in debug mode
|
||||||
|
UIAccessibility.post(
|
||||||
|
notification: .announcement,
|
||||||
|
argument: String(localized: "Debug mode: Purchase simulated!")
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let result = try await Purchases.shared.purchase(package: package)
|
||||||
|
|
||||||
|
if result.customerInfo.entitlements[entitlementIdentifier]?.isActive == true {
|
||||||
|
UIAccessibility.post(
|
||||||
|
notification: .announcement,
|
||||||
|
argument: String(localized: "Purchase successful! Pro features unlocked.")
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(productId: String) async throws {
|
||||||
|
#if DEBUG
|
||||||
|
if isDebugPremiumEnabled {
|
||||||
|
return // Already "premium" in debug mode
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
guard let package = availablePackages.first(where: { $0.storeProduct.productIdentifier == productId }) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "PremiumManager",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Product not found"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = try await purchase(package)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restorePurchases() async throws {
|
||||||
|
#if DEBUG
|
||||||
|
if isDebugPremiumEnabled {
|
||||||
|
UIAccessibility.post(
|
||||||
|
notification: .announcement,
|
||||||
|
argument: String(localized: "Debug mode: Restore simulated!")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
_ = try await Purchases.shared.restorePurchases()
|
||||||
|
UIAccessibility.post(
|
||||||
|
notification: .announcement,
|
||||||
|
argument: String(localized: "Purchases restored")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SelfieRingLight/Shared/Protocols/CaptureControlling.swift
Normal file
10
SelfieRingLight/Shared/Protocols/CaptureControlling.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
protocol CaptureControlling {
|
||||||
|
var selectedTimer: TimerOption { get set }
|
||||||
|
var isGridVisible: Bool { get set }
|
||||||
|
var currentZoomFactor: Double { get set }
|
||||||
|
var selectedCaptureMode: CaptureMode { get set }
|
||||||
|
|
||||||
|
func startCountdown() async
|
||||||
|
func performCapture()
|
||||||
|
func performFlashBurst() async
|
||||||
|
}
|
||||||
7
SelfieRingLight/Shared/Protocols/PremiumManaging.swift
Normal file
7
SelfieRingLight/Shared/Protocols/PremiumManaging.swift
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
protocol PremiumManaging {
|
||||||
|
var isPremium: Bool { get }
|
||||||
|
|
||||||
|
func loadProducts() async throws
|
||||||
|
func purchase(productId: String) async throws
|
||||||
|
func restorePurchases() async throws
|
||||||
|
}
|
||||||
25
SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift
Normal file
25
SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Protocol for types that can configure the ring light appearance.
|
||||||
|
protocol RingLightConfigurable {
|
||||||
|
/// The size of the ring light border in points
|
||||||
|
var ringSize: CGFloat { get set }
|
||||||
|
|
||||||
|
/// Convenience accessor for border width (same as ringSize)
|
||||||
|
var borderWidth: CGFloat { get }
|
||||||
|
|
||||||
|
/// The color of the ring light
|
||||||
|
var lightColor: Color { get }
|
||||||
|
|
||||||
|
/// Ring light intensity/opacity (0.5 to 1.0)
|
||||||
|
var lightIntensity: Double { get set }
|
||||||
|
|
||||||
|
/// Whether front flash is enabled (hides preview during capture)
|
||||||
|
var isFrontFlashEnabled: Bool { get set }
|
||||||
|
|
||||||
|
/// Whether the camera preview is mirrored
|
||||||
|
var isMirrorFlipped: Bool { get set }
|
||||||
|
|
||||||
|
/// Whether skin smoothing is enabled
|
||||||
|
var isSkinSmoothingEnabled: Bool { get set }
|
||||||
|
}
|
||||||
134
SelfieRingLight/Shared/Storage/SyncedSettings.swift
Normal file
134
SelfieRingLight/Shared/Storage/SyncedSettings.swift
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Synced Settings Data
|
||||||
|
|
||||||
|
/// Settings data structure that syncs across all devices via iCloud.
|
||||||
|
/// Conforms to `PersistableData` for use with Bedrock's `CloudSyncManager`.
|
||||||
|
struct SyncedSettings: PersistableData, Sendable {
|
||||||
|
|
||||||
|
// MARK: - PersistableData Requirements
|
||||||
|
|
||||||
|
static var dataIdentifier: String { "selfieRingLightSettings" }
|
||||||
|
|
||||||
|
static var empty: SyncedSettings {
|
||||||
|
SyncedSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync priority based on modification count - higher means more changes made.
|
||||||
|
/// This ensures the most actively used device's settings win in conflicts.
|
||||||
|
var syncPriority: Int {
|
||||||
|
modificationCount
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastModified: Date = .now
|
||||||
|
|
||||||
|
// MARK: - Settings Properties
|
||||||
|
|
||||||
|
/// How many times settings have been modified (for sync priority)
|
||||||
|
var modificationCount: Int = 0
|
||||||
|
|
||||||
|
/// Ring border size in points (stored as Double for Codable compatibility)
|
||||||
|
var ringSizeValue: Double = 40
|
||||||
|
|
||||||
|
/// ID of the selected light color preset
|
||||||
|
var lightColorId: String = "pureWhite"
|
||||||
|
|
||||||
|
/// Ring light intensity/opacity (0.5 to 1.0)
|
||||||
|
var lightIntensity: Double = 1.0
|
||||||
|
|
||||||
|
/// Whether front flash is enabled (hides preview during capture)
|
||||||
|
var isFrontFlashEnabled: Bool = true
|
||||||
|
|
||||||
|
/// Whether the camera preview is flipped to show a true mirror
|
||||||
|
var isMirrorFlipped: Bool = false
|
||||||
|
|
||||||
|
/// Whether skin smoothing filter is enabled
|
||||||
|
var isSkinSmoothingEnabled: Bool = true
|
||||||
|
|
||||||
|
/// Selected self-timer option raw value
|
||||||
|
var selectedTimerRaw: String = "off"
|
||||||
|
|
||||||
|
/// Whether the grid overlay is visible
|
||||||
|
var isGridVisible: Bool = false
|
||||||
|
|
||||||
|
/// Current camera zoom factor
|
||||||
|
var currentZoomFactor: Double = 1.0
|
||||||
|
|
||||||
|
/// Selected capture mode raw value
|
||||||
|
var selectedCaptureModeRaw: String = "photo"
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
/// Ring size as CGFloat (convenience accessor)
|
||||||
|
var ringSize: CGFloat {
|
||||||
|
get { CGFloat(ringSizeValue) }
|
||||||
|
set { ringSizeValue = Double(newValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
ringSize: CGFloat,
|
||||||
|
lightColorId: String,
|
||||||
|
lightIntensity: Double,
|
||||||
|
isFrontFlashEnabled: Bool,
|
||||||
|
isMirrorFlipped: Bool,
|
||||||
|
isSkinSmoothingEnabled: Bool,
|
||||||
|
selectedTimerRaw: String,
|
||||||
|
isGridVisible: Bool,
|
||||||
|
currentZoomFactor: Double,
|
||||||
|
selectedCaptureModeRaw: String,
|
||||||
|
modificationCount: Int = 0
|
||||||
|
) {
|
||||||
|
self.ringSizeValue = Double(ringSize)
|
||||||
|
self.lightColorId = lightColorId
|
||||||
|
self.lightIntensity = lightIntensity
|
||||||
|
self.isFrontFlashEnabled = isFrontFlashEnabled
|
||||||
|
self.isMirrorFlipped = isMirrorFlipped
|
||||||
|
self.isSkinSmoothingEnabled = isSkinSmoothingEnabled
|
||||||
|
self.selectedTimerRaw = selectedTimerRaw
|
||||||
|
self.isGridVisible = isGridVisible
|
||||||
|
self.currentZoomFactor = currentZoomFactor
|
||||||
|
self.selectedCaptureModeRaw = selectedCaptureModeRaw
|
||||||
|
self.modificationCount = modificationCount
|
||||||
|
self.lastModified = .now
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case modificationCount
|
||||||
|
case lastModified
|
||||||
|
case ringSizeValue
|
||||||
|
case lightColorId
|
||||||
|
case lightIntensity
|
||||||
|
case isFrontFlashEnabled
|
||||||
|
case isMirrorFlipped
|
||||||
|
case isSkinSmoothingEnabled
|
||||||
|
case selectedTimerRaw
|
||||||
|
case isGridVisible
|
||||||
|
case currentZoomFactor
|
||||||
|
case selectedCaptureModeRaw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Equatable
|
||||||
|
|
||||||
|
extension SyncedSettings: Equatable {
|
||||||
|
static func == (lhs: SyncedSettings, rhs: SyncedSettings) -> Bool {
|
||||||
|
lhs.ringSizeValue == rhs.ringSizeValue &&
|
||||||
|
lhs.lightColorId == rhs.lightColorId &&
|
||||||
|
lhs.lightIntensity == rhs.lightIntensity &&
|
||||||
|
lhs.isFrontFlashEnabled == rhs.isFrontFlashEnabled &&
|
||||||
|
lhs.isMirrorFlipped == rhs.isMirrorFlipped &&
|
||||||
|
lhs.isSkinSmoothingEnabled == rhs.isSkinSmoothingEnabled &&
|
||||||
|
lhs.selectedTimerRaw == rhs.selectedTimerRaw &&
|
||||||
|
lhs.isGridVisible == rhs.isGridVisible &&
|
||||||
|
lhs.currentZoomFactor == rhs.currentZoomFactor &&
|
||||||
|
lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw
|
||||||
|
}
|
||||||
|
}
|
||||||
17
SelfieRingLightTests/SelfieRingLightTests.swift
Normal file
17
SelfieRingLightTests/SelfieRingLightTests.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// SelfieRingLightTests.swift
|
||||||
|
// SelfieRingLightTests
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
@testable import SelfieRingLight
|
||||||
|
|
||||||
|
struct SelfieRingLightTests {
|
||||||
|
|
||||||
|
@Test func example() async throws {
|
||||||
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
41
SelfieRingLightUITests/SelfieRingLightUITests.swift
Normal file
41
SelfieRingLightUITests/SelfieRingLightUITests.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// SelfieRingLightUITests.swift
|
||||||
|
// SelfieRingLightUITests
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class SelfieRingLightUITests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||||
|
|
||||||
|
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||||
|
continueAfterFailure = false
|
||||||
|
|
||||||
|
// In UI tests 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// SelfieRingLightUITestsLaunchTests.swift
|
||||||
|
// SelfieRingLightUITests
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class SelfieRingLightUITestsLaunchTests: XCTestCase {
|
||||||
|
|
||||||
|
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunch() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||||
|
// such as logging into a test account or navigating somewhere in the app
|
||||||
|
|
||||||
|
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||||
|
attachment.name = "Launch Screen"
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user