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