Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-04 13:18:18 -06:00
parent 7a564b2115
commit 459755be98
10 changed files with 494 additions and 154 deletions

View File

@ -212,7 +212,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
EA836AEE2F0AD00000077F87 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */,
EA836AF72F0AD00000077F87 /* XCRemoteSwiftPackageReference "Bedrock" */,
EA836AF72F0AD00000077F87 /* XCLocalSwiftPackageReference "Bedrock" */,
EA836AF82F0AD00000077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */,
);
preferredProjectObjectVersion = 77;
@ -616,13 +616,9 @@
minimumVersion = 5.52.1;
};
};
EA836AF72F0AD00000077F87 /* XCRemoteSwiftPackageReference "Bedrock" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "http://192.168.1.128:3000/mbrucedogs/Bedrock";
requirement = {
branch = master;
kind = branch;
};
EA836AF72F0AD00000077F87 /* XCLocalSwiftPackageReference "Bedrock" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../Bedrock;
};
EA836AF82F0AD00000077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */ = {
isa = XCRemoteSwiftPackageReference;
@ -647,7 +643,7 @@
};
EA836AF32F0AD00000077F87 /* Bedrock */ = {
isa = XCSwiftPackageProductDependency;
package = EA836AF72F0AD00000077F87 /* XCRemoteSwiftPackageReference "Bedrock" */;
package = EA836AF72F0AD00000077F87 /* XCLocalSwiftPackageReference "Bedrock" */;
productName = Bedrock;
};
EA836AF52F0AD00000077F87 /* MijickCamera */ = {

View File

@ -1,15 +1,6 @@
{
"originHash" : "6833d23e21d5837a21eb6a505b1a3ec76c3c83999c658fd917d3d4e7c53f82a3",
"originHash" : "ad9cfed56aca96dd41a82eacafd910eada809da2754aafd884eed81c498dd133",
"pins" : [
{
"identity" : "bedrock",
"kind" : "remoteSourceControl",
"location" : "http://192.168.1.128:3000/mbrucedogs/Bedrock",
"state" : {
"branch" : "master",
"revision" : "8e788ef2121024b25ac6150d857140af6bcd64c2"
}
},
{
"identity" : "mijickcamera",
"kind" : "remoteSourceControl",

View File

@ -6,9 +6,14 @@
//
import SwiftUI
import Bedrock
@main
struct SelfieCamApp: App {
init() {
Design.showDebugLogs = true
}
var body: some Scene {
WindowGroup {
ContentView()

View File

@ -23,41 +23,15 @@ struct ContentView: View {
var body: some View {
ZStack {
// Camera view - only recreates when sessionKey changes due to Equatable
// Camera view - wrapped in EquatableView to prevent re-evaluation on settings changes
if !showPhotoReview {
CameraContainerView(
EquatableView(content: CameraContainerView(
settings: settings,
sessionKey: cameraSessionKey,
onImageCaptured: { image in
if settings.isAutoSaveEnabled {
// Auto-save enabled: save immediately without showing review screen
Task {
// Small delay to ensure shutter sound plays before saving
try? await Task.sleep(for: .milliseconds(200))
let quality = settings.photoQuality
let result = await PhotoLibraryService.savePhotoToLibrary(image, quality: quality)
switch result {
case .success:
print("Photo auto-saved successfully")
// Don't reset camera session for auto-save to avoid timing issues
case .failure(let error):
print("Failed to auto-save photo: \(error)")
// Don't reset on failure either
}
}
} else {
// Auto-save disabled: show review screen
capturedPhoto = CapturedPhoto(image: image, timestamp: Date())
showPhotoReview = true
isSavingPhoto = false
saveError = nil
}
print("Photo captured successfully")
handlePhotoCaptured(image)
}
)
))
.ignoresSafeArea() // Only camera ignores safe area to fill screen
}
@ -113,6 +87,35 @@ struct ContentView: View {
}
}
/// Handles photo capture - either auto-saves or shows review screen
private func handlePhotoCaptured(_ image: UIImage) {
if settings.isAutoSaveEnabled {
// Auto-save enabled: save immediately without showing review screen
Task {
// Small delay to ensure shutter sound plays before saving
try? await Task.sleep(for: .milliseconds(200))
let quality = settings.photoQuality
let result = await PhotoLibraryService.savePhotoToLibrary(image, quality: quality)
switch result {
case .success:
Design.debugLog("Photo auto-saved successfully")
// Don't reset camera session for auto-save to avoid timing issues
case .failure(let error):
Design.debugLog("Failed to auto-save photo: \(error)")
// Don't reset on failure either
}
}
} else {
// Auto-save disabled: show review screen
capturedPhoto = CapturedPhoto(image: image, timestamp: Date())
showPhotoReview = true
isSavingPhoto = false
saveError = nil
}
}
/// Resets state and regenerates camera session key to create a fresh camera instance
private func resetCameraForNextCapture() {
capturedPhoto = nil
@ -137,7 +140,7 @@ struct ContentView: View {
switch result {
case .success:
print("Photo saved successfully")
Design.debugLog("Photo saved successfully")
// Auto-dismiss after successful save and reset camera for next capture
Task {
try? await Task.sleep(for: .seconds(1.5))
@ -146,7 +149,7 @@ struct ContentView: View {
}
}
case .failure(let error):
print("Failed to save photo: \(error)")
Design.debugLog("Failed to save photo: \(error)")
self.saveError = error.localizedDescription
}
}
@ -167,7 +170,7 @@ struct CameraContainerView: View, Equatable {
}
var body: some View {
let _ = print("CameraContainerView body evaluated - sessionKey: \(sessionKey)")
let _ = Design.debugLog("CameraContainerView body evaluated - sessionKey: \(sessionKey)")
MCamera()
.setCameraScreen { cameraManager, namespace, closeAction in
CustomCameraScreen(
@ -179,7 +182,9 @@ struct CameraContainerView: View, Equatable {
)
}
.setCapturedMediaScreen(nil)
.setCameraPosition(settings.cameraPosition)
// Use front camera as default for selfie app - don't read from settings here
// to avoid triggering @Observable tracking which breaks the camera
.setCameraPosition(.front)
.startSession()
}
}

View File

@ -23,8 +23,7 @@ struct CustomCameraScreen: MCameraScreen {
/// Callback when photo is captured - bypasses MijickCamera's callback system
var onPhotoCaptured: ((UIImage) -> Void)?
// Center Stage state
@State private var isCenterStageEnabled: Bool = AVCaptureDevice.isCenterStageEnabled
// Center Stage is now managed by settings
@ -93,7 +92,7 @@ struct CustomCameraScreen: MCameraScreen {
// Zoom indicator (shows "Center Stage" when active)
ZoomControlView(
zoomFactor: zoomFactor,
isCenterStageActive: isCenterStageEnabled
isCenterStageActive: cameraSettings.isCenterStageEnabled
)
// Capture Button

View File

@ -21,11 +21,15 @@ struct ProPaywallView: View {
// 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"))
BenefitRow(image: "paintpalette.fill", text: String(localized: "Premium Colors + Custom Color Picker"))
BenefitRow(image: "sparkles", text: String(localized: "Skin Smoothing Beauty Filter"))
BenefitRow(image: "arrow.left.and.right.righttriangle.left.righttriangle.right.fill", text: String(localized: "True Mirror Mode"))
BenefitRow(image: "bolt.fill", text: String(localized: "Flash Sync with Ring Light"))
BenefitRow(image: "camera.filters", text: String(localized: "HDR Mode for Better Photos"))
BenefitRow(image: "person.crop.rectangle.fill", text: String(localized: "Center Stage Auto-Framing"))
BenefitRow(image: "timer", text: String(localized: "Extended Self-Timers (5s, 10s)"))
BenefitRow(image: "star.fill", text: String(localized: "High Quality Photo Export"))
BenefitRow(image: "icloud.fill", text: String(localized: "iCloud Settings Sync"))
}
.frame(maxWidth: .infinity, alignment: .leading)

View File

@ -6,13 +6,21 @@ struct SettingsView: View {
@Bindable var viewModel: SettingsViewModel
@Binding var showPaywall: Bool
@Environment(\.dismiss) private var dismiss
@State private var premiumManager = PremiumManager()
/// Whether premium features are unlocked (for UI gating)
private var isPremiumUnlocked: Bool {
premiumManager.isPremiumUnlocked
viewModel.isPremiumUnlocked
}
/// Shows paywall after dismissing settings
private func showPaywallAfterDismiss() {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showPaywall = true
}
}
var body: some View {
NavigationStack {
ScrollView {
@ -49,13 +57,13 @@ struct SettingsView: View {
// Flash Mode
flashModePicker
// Flash Sync
SettingsToggle(
// Flash Sync (premium)
premiumToggle(
title: String(localized: "Flash Sync"),
subtitle: String(localized: "Use ring light color for screen flash"),
isOn: $viewModel.isFlashSyncedWithRingLight
isOn: $viewModel.isFlashSyncedWithRingLight,
accessibilityHint: String(localized: "Syncs flash color with ring light color")
)
.accessibilityHint(String(localized: "Syncs flash color with ring light color"))
// Front Flash
SettingsToggle(
@ -68,6 +76,9 @@ struct SettingsView: View {
// HDR Mode
hdrModePicker
// Center Stage (premium feature)
centerStageToggle
// Photo Quality
photoQualityPicker
@ -75,12 +86,13 @@ struct SettingsView: View {
SettingsSectionHeader(title: "Display", systemImage: "eye")
SettingsToggle(
// True Mirror (premium)
premiumToggle(
title: String(localized: "True Mirror"),
subtitle: String(localized: "Shows horizontally flipped preview like a real mirror"),
isOn: $viewModel.isMirrorFlipped
isOn: $viewModel.isMirrorFlipped,
accessibilityHint: String(localized: "Flips the camera preview horizontally")
)
.accessibilityHint(String(localized: "Flips the camera preview horizontally"))
SettingsToggle(
title: String(localized: "Grid Overlay"),
@ -89,12 +101,13 @@ struct SettingsView: View {
)
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
SettingsToggle(
// Skin Smoothing (premium)
premiumToggle(
title: String(localized: "Skin Smoothing"),
subtitle: String(localized: "Applies subtle real-time skin smoothing"),
isOn: $viewModel.isSkinSmoothingEnabled
isOn: $viewModel.isSkinSmoothingEnabled,
accessibilityHint: String(localized: "Applies light skin smoothing to the camera preview")
)
.accessibilityHint(String(localized: "Applies light skin smoothing to the camera preview"))
// MARK: - Capture Section
@ -116,9 +129,9 @@ struct SettingsView: View {
proSection
// MARK: - Sync Section
// MARK: - Sync Section (Premium)
SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud")
premiumSectionHeader(title: "iCloud Sync", systemImage: "icloud")
iCloudSyncSection
@ -262,13 +275,19 @@ struct SettingsView: View {
}
}
// MARK: - HDR Mode Picker
// MARK: - HDR Mode Picker (Premium)
private var hdrModePicker: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(String(localized: "HDR Mode"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
HStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "HDR Mode"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning)
}
Text(String(localized: "High Dynamic Range for better lighting in photos"))
.font(.system(size: Design.BaseFontSize.caption))
@ -279,27 +298,69 @@ struct SettingsView: View {
options: CameraHDRMode.allCases.map { ($0.displayName, $0) },
selection: $viewModel.hdrMode
)
.disabled(!isPremiumUnlocked)
.accessibilityLabel(String(localized: "Select HDR mode"))
}
.onTapGesture {
if !isPremiumUnlocked {
showPaywallAfterDismiss()
}
}
}
// MARK: - Photo Quality Picker
// MARK: - Photo Quality Picker (High/Maximum are Premium)
private var photoQualityPicker: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(String(localized: "Photo Quality"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
HStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Photo Quality"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning)
}
Text(String(localized: "File size and image quality for saved photos"))
Text(isPremiumUnlocked
? String(localized: "File size and image quality for saved photos")
: String(localized: "Upgrade to unlock High quality"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
SegmentedPicker(
title: "",
options: PhotoQuality.allCases.map { ($0.rawValue.capitalized, $0) },
selection: $viewModel.photoQuality
)
// Custom picker with premium indicators
HStack(spacing: Design.Spacing.small) {
ForEach(PhotoQuality.allCases, id: \.self) { quality in
let isPremiumOption = quality == .high
let isDisabled = isPremiumOption && !isPremiumUnlocked
Button {
if isDisabled {
showPaywallAfterDismiss()
} else {
viewModel.photoQuality = quality
}
} label: {
HStack(spacing: Design.Spacing.xxSmall) {
Text(quality.rawValue.capitalized)
if isPremiumOption && !isPremiumUnlocked {
Image(systemName: "lock.fill")
.font(.system(size: Design.BaseFontSize.xSmall))
}
}
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(viewModel.photoQuality == quality ? .black : (isDisabled ? .white.opacity(Design.Opacity.light) : .white.opacity(Design.Opacity.strong)))
.padding(.vertical, Design.Spacing.small)
.frame(maxWidth: .infinity)
.background(
Capsule()
.fill(viewModel.photoQuality == quality ? Color.Accent.primary : Color.white.opacity(Design.Opacity.subtle))
)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, Design.Spacing.xSmall)
.accessibilityLabel(String(localized: "Select photo quality"))
}
}
@ -370,23 +431,94 @@ struct SettingsView: View {
.accessibilityValue("\(Int(viewModel.ringLightOpacity * 100)) percent")
}
// MARK: - Timer Picker
// MARK: - Center Stage Toggle
private var centerStageToggle: some View {
Toggle(isOn: $viewModel.isCenterStageEnabled) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Center Stage"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning)
}
Text(String(localized: "Automatically keeps you centered in the frame"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.tint(Color.Accent.primary)
.padding(.vertical, Design.Spacing.xSmall)
.disabled(!isPremiumUnlocked)
.accessibilityLabel(String(localized: "Enable Center Stage"))
.accessibilityHint(String(localized: "Automatically adjusts camera to keep subject centered"))
.onTapGesture {
if !isPremiumUnlocked {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showPaywall = true
}
}
}
}
// MARK: - Timer Picker (5s/10s are Premium)
private var timerPicker: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(String(localized: "Self-Timer"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
HStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Self-Timer"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning)
}
Text(String(localized: "Delay before photo capture for self-portraits"))
Text(isPremiumUnlocked
? String(localized: "Delay before photo capture for self-portraits")
: String(localized: "Upgrade to unlock 5s and 10s timers"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
SegmentedPicker(
title: "",
options: TimerOption.allCases.map { ($0.displayName, $0) },
selection: $viewModel.selectedTimer
)
// Custom picker with premium indicators
HStack(spacing: Design.Spacing.small) {
ForEach(TimerOption.allCases) { option in
let isPremiumOption = option == .five || option == .ten
let isDisabled = isPremiumOption && !isPremiumUnlocked
Button {
if isDisabled {
showPaywallAfterDismiss()
} else {
viewModel.selectedTimer = option
}
} label: {
HStack(spacing: Design.Spacing.xxSmall) {
Text(option.displayName)
if isPremiumOption && !isPremiumUnlocked {
Image(systemName: "lock.fill")
.font(.system(size: Design.BaseFontSize.xSmall))
}
}
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(viewModel.selectedTimer == option ? .black : (isDisabled ? .white.opacity(Design.Opacity.light) : .white.opacity(Design.Opacity.strong)))
.padding(.vertical, Design.Spacing.small)
.frame(maxWidth: .infinity)
.background(
Capsule()
.fill(viewModel.selectedTimer == option ? Color.Accent.primary : Color.white.opacity(Design.Opacity.subtle))
)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, Design.Spacing.xSmall)
.accessibilityLabel(String(localized: "Select self-timer duration"))
}
}
@ -411,7 +543,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold))
.foregroundStyle(.white)
Text(String(localized: "Unlock premium colors, video, and more"))
Text(String(localized: "Premium colors, HDR, timers, sync & more"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
@ -434,22 +566,24 @@ struct SettingsView: View {
.accessibilityHint(String(localized: "Opens upgrade options"))
}
// MARK: - iCloud Sync Section
// MARK: - iCloud Sync Section (Premium)
private var iCloudSyncSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
// Sync toggle
SettingsToggle(
// Sync toggle (premium)
premiumToggle(
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
subtitle: !isPremiumUnlocked
? String(localized: "Upgrade to sync settings across devices")
: (viewModel.iCloudAvailable
? String(localized: "Sync settings across all your devices")
: String(localized: "Sign in to iCloud to enable sync")),
isOn: $viewModel.iCloudEnabled,
accessibilityHint: String(localized: "Syncs settings across all your devices via iCloud")
)
.disabled(!viewModel.iCloudAvailable)
// Sync status
if viewModel.iCloudEnabled && viewModel.iCloudAvailable {
// Sync status (only show when premium and enabled)
if isPremiumUnlocked && viewModel.iCloudEnabled && viewModel.iCloudAvailable {
HStack(spacing: Design.Spacing.small) {
Image(systemName: syncStatusIcon)
.font(.system(size: Design.BaseFontSize.body))
@ -536,6 +670,81 @@ struct SettingsView: View {
.buttonStyle(.plain)
}
}
// MARK: - Premium Toggle Helper
/// Creates a toggle with premium indicator (crown icon)
/// - Parameters:
/// - title: The toggle title
/// - subtitle: The toggle subtitle/description
/// - isOn: Binding to the toggle state
/// - accessibilityHint: Accessibility hint for the toggle
/// - Returns: A view containing the premium toggle
@ViewBuilder
private func premiumToggle(
title: String,
subtitle: String,
isOn: Binding<Bool>,
accessibilityHint: String
) -> some View {
Toggle(isOn: isOn) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning)
}
Text(subtitle)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.tint(Color.Accent.primary)
.padding(.vertical, Design.Spacing.xSmall)
.disabled(!isPremiumUnlocked)
.accessibilityHint(accessibilityHint)
.onTapGesture {
if !isPremiumUnlocked {
showPaywallAfterDismiss()
}
}
}
// MARK: - Premium Section Header Helper
/// Creates a section header with premium indicator (crown icon)
/// - Parameters:
/// - title: The section title
/// - systemImage: The SF Symbol name for the section icon
/// - Returns: A view containing the premium section header
@ViewBuilder
private func premiumSectionHeader(title: String, systemImage: String) -> some View {
HStack(spacing: Design.Spacing.small) {
Image(systemName: systemImage)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
Text(title)
.font(.system(size: Design.BaseFontSize.caption, weight: .semibold))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.textCase(.uppercase)
.tracking(0.5)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning)
Spacer()
}
.padding(.horizontal, Design.Spacing.xSmall)
.padding(.top, Design.Spacing.large)
.padding(.bottom, Design.Spacing.xSmall)
}
}

View File

@ -68,6 +68,7 @@ enum CaptureMode: String, CaseIterable, Identifiable {
/// Observable settings view model with iCloud sync across all devices.
/// Uses Bedrock's CloudSyncManager for automatic synchronization.
/// Premium features are automatically reset to defaults when user doesn't have premium.
@MainActor
@Observable
final class SettingsViewModel: RingLightConfigurable {
@ -83,6 +84,14 @@ final class SettingsViewModel: RingLightConfigurable {
/// Default ring border size
static let defaultRingSize: CGFloat = 40
// MARK: - Premium Manager
/// Premium manager for checking subscription status
private let premiumManager = PremiumManager()
/// Whether the user has premium access
var isPremiumUnlocked: Bool { premiumManager.isPremiumUnlocked }
// MARK: - Cloud Sync Manager
/// Manages iCloud sync for settings across all devices
@ -114,10 +123,25 @@ final class SettingsViewModel: RingLightConfigurable {
/// Cached light color ID for immediate UI updates
private var _cachedLightColorId: String?
/// Default free color ID for non-premium users
private static let defaultFreeColorId = "pureWhite"
/// Premium color IDs that require subscription
private static let premiumColorIds: Set<String> = ["iceBlue", "softPink", "warmAmber", "coolLavender", RingLightColor.customId]
/// ID of the selected light color preset
var lightColorId: String {
get { _cachedLightColorId ?? cloudSync.data.lightColorId }
get {
let storedId = _cachedLightColorId ?? cloudSync.data.lightColorId
// Return default free color if not premium and stored color is premium
if !isPremiumUnlocked && Self.premiumColorIds.contains(storedId) {
return Self.defaultFreeColorId
}
return storedId
}
set {
// Block premium color selection for free users
guard isPremiumUnlocked || !Self.premiumColorIds.contains(newValue) else { return }
_cachedLightColorId = newValue
updateSettings { $0.lightColorId = newValue }
}
@ -127,6 +151,7 @@ final class SettingsViewModel: RingLightConfigurable {
private var _cachedCustomColor: Color?
/// Custom color for ring light (premium feature, debounced save)
/// Note: Getter always returns stored value to preserve user's choice if they re-subscribe
var customColor: Color {
get {
_cachedCustomColor ?? Color(
@ -136,6 +161,8 @@ final class SettingsViewModel: RingLightConfigurable {
)
}
set {
// Block custom color changes for non-premium users
guard isPremiumUnlocked else { return }
_cachedCustomColor = newValue
let rgb = CustomColorRGB(from: newValue)
debouncedSave(key: "customColor") {
@ -155,16 +182,22 @@ final class SettingsViewModel: RingLightConfigurable {
set { updateSettings { $0.isFrontFlashEnabled = newValue } }
}
/// Whether the camera preview is flipped to show a true mirror
/// Whether the camera preview is flipped to show a true mirror (PREMIUM)
var isMirrorFlipped: Bool {
get { cloudSync.data.isMirrorFlipped }
set { updateSettings { $0.isMirrorFlipped = newValue } }
get { isPremiumUnlocked ? cloudSync.data.isMirrorFlipped : false }
set {
guard isPremiumUnlocked else { return }
updateSettings { $0.isMirrorFlipped = newValue }
}
}
/// Whether skin smoothing filter is enabled
/// Whether skin smoothing filter is enabled (PREMIUM)
var isSkinSmoothingEnabled: Bool {
get { cloudSync.data.isSkinSmoothingEnabled }
set { updateSettings { $0.isSkinSmoothingEnabled = newValue } }
get { isPremiumUnlocked ? cloudSync.data.isSkinSmoothingEnabled : false }
set {
guard isPremiumUnlocked else { return }
updateSettings { $0.isSkinSmoothingEnabled = newValue }
}
}
/// Whether the grid overlay is visible
@ -190,9 +223,23 @@ final class SettingsViewModel: RingLightConfigurable {
/// Convenience property for border width (same as ringSize)
var borderWidth: CGFloat { ringSize }
/// Selected timer option (5s and 10s are PREMIUM)
var selectedTimer: TimerOption {
get { TimerOption(rawValue: cloudSync.data.selectedTimerRaw) ?? .off }
set { updateSettings { $0.selectedTimerRaw = newValue.rawValue } }
get {
let stored = TimerOption(rawValue: cloudSync.data.selectedTimerRaw) ?? .off
// Free users limited to off or 3s
if !isPremiumUnlocked && (stored == .five || stored == .ten) {
return .three
}
return stored
}
set {
// Block premium timer options for free users
if !isPremiumUnlocked && (newValue == .five || newValue == .ten) {
return
}
updateSettings { $0.selectedTimerRaw = newValue.rawValue }
}
}
var selectedCaptureMode: CaptureMode {
@ -207,19 +254,41 @@ final class SettingsViewModel: RingLightConfigurable {
set { updateSettings { $0.flashModeRaw = newValue.rawValue } }
}
/// Whether flash is synced with ring light color (PREMIUM)
var isFlashSyncedWithRingLight: Bool {
get { cloudSync.data.isFlashSyncedWithRingLight }
set { updateSettings { $0.isFlashSyncedWithRingLight = newValue } }
get { isPremiumUnlocked ? cloudSync.data.isFlashSyncedWithRingLight : false }
set {
guard isPremiumUnlocked else { return }
updateSettings { $0.isFlashSyncedWithRingLight = newValue }
}
}
/// HDR mode setting (PREMIUM)
var hdrMode: CameraHDRMode {
get { CameraHDRMode(rawValue: cloudSync.data.hdrModeRaw) ?? .off }
set { updateSettings { $0.hdrModeRaw = newValue.rawValue } }
get { isPremiumUnlocked ? (CameraHDRMode(rawValue: cloudSync.data.hdrModeRaw) ?? .off) : .off }
set {
guard isPremiumUnlocked else { return }
updateSettings { $0.hdrModeRaw = newValue.rawValue }
}
}
/// Photo quality setting (high is PREMIUM)
var photoQuality: PhotoQuality {
get { PhotoQuality(rawValue: cloudSync.data.photoQualityRaw) ?? .high }
set { updateSettings { $0.photoQualityRaw = newValue.rawValue } }
get {
let stored = PhotoQuality(rawValue: cloudSync.data.photoQualityRaw) ?? PhotoQuality.high
// Free users limited to medium quality
if !isPremiumUnlocked && stored == PhotoQuality.high {
return PhotoQuality.medium
}
return stored
}
set {
// Block premium quality option for free users
if !isPremiumUnlocked && newValue == PhotoQuality.high {
return
}
updateSettings { $0.photoQualityRaw = newValue.rawValue }
}
}
var cameraPosition: CameraPosition {
@ -244,10 +313,20 @@ final class SettingsViewModel: RingLightConfigurable {
get { cloudSync.data.ringLightOpacity }
set { updateSettings { $0.ringLightOpacity = newValue } }
}
/// Whether Center Stage is enabled (PREMIUM)
var isCenterStageEnabled: Bool {
get { isPremiumUnlocked ? cloudSync.data.isCenterStageEnabled : false }
set {
guard isPremiumUnlocked else { return }
updateSettings { $0.isCenterStageEnabled = newValue }
}
}
var selectedLightColor: RingLightColor {
get { RingLightColor.fromId(lightColorId, customColor: customColor) }
set {
// Premium check handled by lightColorId and customColor setters
lightColorId = newValue.id
if newValue.isCustom {
customColor = newValue.color
@ -267,8 +346,9 @@ final class SettingsViewModel: RingLightConfigurable {
lightColorId == RingLightColor.customId
}
/// Sets the custom color and selects it
/// Sets the custom color and selects it (PREMIUM)
func selectCustomColor(_ color: Color) {
guard isPremiumUnlocked else { return }
customColor = color
lightColorId = RingLightColor.customId
}
@ -278,10 +358,13 @@ final class SettingsViewModel: RingLightConfigurable {
/// Whether iCloud sync is available
var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
/// Whether iCloud sync is enabled
/// Whether iCloud sync is enabled (PREMIUM)
var iCloudEnabled: Bool {
get { cloudSync.iCloudEnabled }
set { cloudSync.iCloudEnabled = newValue }
get { isPremiumUnlocked ? cloudSync.iCloudEnabled : false }
set {
guard isPremiumUnlocked else { return }
cloudSync.iCloudEnabled = newValue
}
}
/// Last sync date

View File

@ -53,14 +53,6 @@
"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
@ -73,6 +65,14 @@
"comment" : "Title of a toggle that enables automatic saving of captured photos and videos to the user's Photo Library.",
"isCommentAutoGenerated" : true
},
"Automatically adjusts camera to keep subject centered" : {
"comment" : "A hint that describes the functionality of the \"Enable Center Stage\" toggle.",
"isCommentAutoGenerated" : true
},
"Automatically keeps you centered in the frame" : {
"comment" : "A description of the Center Stage feature.",
"isCommentAutoGenerated" : true
},
"Automatically save captures to Photo Library" : {
"comment" : "A toggle option in the Settings view that allows the user to enable or disable automatic saving of captured photos and videos to the user's Photo Library.",
"isCommentAutoGenerated" : true
@ -117,6 +117,10 @@
},
"Center Stage active" : {
},
"Center Stage Auto-Framing" : {
"comment" : "Benefit of the \"Go Pro\" premium package: Automatic centering of the subject in the photo.",
"isCommentAutoGenerated" : true
},
"Choose between front and back camera lenses" : {
"comment" : "A description of the camera position picker.",
@ -154,10 +158,6 @@
"comment" : "A description of the purpose of the \"Self-Timer\" setting in the settings screen.",
"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
@ -166,6 +166,10 @@
"comment" : "An accessibility hint for the capture button, instructing the user to double-tap it to capture a photo.",
"isCommentAutoGenerated" : true
},
"Enable Center Stage" : {
"comment" : "An accessibility label for the toggle that enables the \"Center Stage\" feature.",
"isCommentAutoGenerated" : true
},
"Enable Ring Light" : {
"comment" : "Title of a toggle in the Settings view that allows the user to enable or disable the ring light overlay.",
"isCommentAutoGenerated" : true
@ -174,6 +178,10 @@
"comment" : "A toggle that enables or disables the ring light overlay.",
"isCommentAutoGenerated" : true
},
"Extended Self-Timers (5s, 10s)" : {
"comment" : "Benefit description for the extended self-timers option.",
"isCommentAutoGenerated" : true
},
"File size and image quality for saved photos" : {
"comment" : "A description of the photo quality setting.",
"isCommentAutoGenerated" : true
@ -186,6 +194,10 @@
"comment" : "Title of a toggle that synchronizes the flash color with the ring light color.",
"isCommentAutoGenerated" : true
},
"Flash Sync with Ring Light" : {
"comment" : "Benefit description for the \"Flash Sync with Ring Light\" feature.",
"isCommentAutoGenerated" : true
},
"Flips the camera preview horizontally" : {
"comment" : "An accessibility hint for the \"True Mirror\" setting.",
"isCommentAutoGenerated" : true
@ -210,6 +222,10 @@
"comment" : "Title for a picker that allows the user to select the HDR mode of the camera.",
"isCommentAutoGenerated" : true
},
"HDR Mode for Better Photos" : {
"comment" : "Benefit description for the \"HDR Mode for Better Photos\" benefit.",
"isCommentAutoGenerated" : true
},
"Hide preview during capture for flash effect" : {
"comment" : "Text displayed in a toggle within the \"Camera Controls\" section, allowing the user to enable or disable the feature of hiding the camera preview during a photo capture to simulate a flash effect.",
"isCommentAutoGenerated" : true
@ -218,10 +234,18 @@
"comment" : "A description of the High Dynamic Range (HDR) mode in the settings view.",
"isCommentAutoGenerated" : true
},
"High Quality Photo Export" : {
"comment" : "Description of a benefit that is included with the Premium membership.",
"isCommentAutoGenerated" : true
},
"Ice Blue" : {
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"iCloud Settings Sync" : {
"comment" : "Description of a benefit when the user has the premium membership and can sync their iCloud settings.",
"isCommentAutoGenerated" : true
},
"Last synced %@" : {
},
@ -233,10 +257,6 @@
"comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true
},
"No Watermarks • Ad-Free" : {
"comment" : "Description of a benefit that comes with the Pro subscription.",
"isCommentAutoGenerated" : true
},
"Off" : {
"comment" : "The accessibility value for the grid toggle when it is off.",
"isCommentAutoGenerated" : true
@ -267,6 +287,13 @@
"Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.",
"isCommentAutoGenerated" : true
},
"Premium Colors + Custom Color Picker" : {
"comment" : "Benefit description for the \"Premium Colors + Custom Color Picker\" benefit.",
"isCommentAutoGenerated" : true
},
"Premium colors, HDR, timers, sync & more" : {
},
"Purchase successful! Pro features unlocked." : {
"comment" : "Announcement read out to the user when a premium purchase is successful.",
@ -383,6 +410,10 @@
"comment" : "A toggle that enables or disables real-time skin smoothing.",
"isCommentAutoGenerated" : true
},
"Skin Smoothing Beauty Filter" : {
"comment" : "Text for a benefit row in the ProPaywallView, describing a feature that is included with the Premium membership.",
"isCommentAutoGenerated" : true
},
"Soft Pink" : {
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
@ -422,6 +453,10 @@
"comment" : "A toggle that synchronizes the flash color with the ring light color.",
"isCommentAutoGenerated" : true
},
"Syncs settings across all your devices via iCloud" : {
"comment" : "An accessibility hint describing the functionality of the sync toggle in the settings view.",
"isCommentAutoGenerated" : true
},
"Take photo" : {
"comment" : "An accessibility label for the capture button.",
"isCommentAutoGenerated" : true
@ -440,18 +475,26 @@
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
"isCommentAutoGenerated" : true
},
"Unlimited Boomerang Length" : {
"comment" : "Description of a benefit that comes with the Pro subscription, specifically related to the boomerang tool.",
"isCommentAutoGenerated" : true
},
"Unlock premium colors, video, and more" : {
"comment" : "A description of the benefits of upgrading to the Pro version of the app.",
"True Mirror Mode" : {
"comment" : "Feature of the Pro subscription that allows users to see their reflection in the mirror.",
"isCommentAutoGenerated" : true
},
"Upgrade to Pro" : {
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
"isCommentAutoGenerated" : true
},
"Upgrade to sync settings across devices" : {
"comment" : "A description of the benefit of upgrading to sync settings across devices.",
"isCommentAutoGenerated" : true
},
"Upgrade to unlock 5s and 10s timers" : {
"comment" : "A message displayed to users who want to upgrade to unlock longer self-timer durations.",
"isCommentAutoGenerated" : true
},
"Upgrade to unlock High quality" : {
"comment" : "A message displayed to users who want to upgrade to access higher image quality for their saved photos.",
"isCommentAutoGenerated" : true
},
"Use ring light color for screen flash" : {
"comment" : "Accessibility hint for the \"Flash Sync\" toggle in the Settings view.",
"isCommentAutoGenerated" : true

View File

@ -84,7 +84,10 @@ struct SyncedSettings: PersistableData, Sendable {
/// Ring light opacity (brightness)
var ringLightOpacity: Double = 1.0
/// Whether Center Stage is enabled (premium feature)
var isCenterStageEnabled: Bool = false
// MARK: - Computed Properties
/// Ring size as CGFloat (convenience accessor)
@ -147,6 +150,7 @@ struct SyncedSettings: PersistableData, Sendable {
case cameraPositionRaw
case isRingLightEnabled
case ringLightOpacity
case isCenterStageEnabled
}
}
@ -173,6 +177,7 @@ extension SyncedSettings: Equatable {
lhs.photoQualityRaw == rhs.photoQualityRaw &&
lhs.cameraPositionRaw == rhs.cameraPositionRaw &&
lhs.isRingLightEnabled == rhs.isRingLightEnabled &&
lhs.ringLightOpacity == rhs.ringLightOpacity
lhs.ringLightOpacity == rhs.ringLightOpacity &&
lhs.isCenterStageEnabled == rhs.isCenterStageEnabled
}
}