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

View File

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

View File

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

View File

@ -23,41 +23,15 @@ struct ContentView: View {
var body: some View { var body: some View {
ZStack { 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 { if !showPhotoReview {
CameraContainerView( EquatableView(content: CameraContainerView(
settings: settings, settings: settings,
sessionKey: cameraSessionKey, sessionKey: cameraSessionKey,
onImageCaptured: { image in onImageCaptured: { image in
if settings.isAutoSaveEnabled { handlePhotoCaptured(image)
// 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")
}
)
.ignoresSafeArea() // Only camera ignores safe area to fill screen .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 /// Resets state and regenerates camera session key to create a fresh camera instance
private func resetCameraForNextCapture() { private func resetCameraForNextCapture() {
capturedPhoto = nil capturedPhoto = nil
@ -137,7 +140,7 @@ struct ContentView: View {
switch result { switch result {
case .success: case .success:
print("Photo saved successfully") Design.debugLog("Photo saved successfully")
// Auto-dismiss after successful save and reset camera for next capture // Auto-dismiss after successful save and reset camera for next capture
Task { Task {
try? await Task.sleep(for: .seconds(1.5)) try? await Task.sleep(for: .seconds(1.5))
@ -146,7 +149,7 @@ struct ContentView: View {
} }
} }
case .failure(let error): case .failure(let error):
print("Failed to save photo: \(error)") Design.debugLog("Failed to save photo: \(error)")
self.saveError = error.localizedDescription self.saveError = error.localizedDescription
} }
} }
@ -167,7 +170,7 @@ struct CameraContainerView: View, Equatable {
} }
var body: some View { var body: some View {
let _ = print("CameraContainerView body evaluated - sessionKey: \(sessionKey)") let _ = Design.debugLog("CameraContainerView body evaluated - sessionKey: \(sessionKey)")
MCamera() MCamera()
.setCameraScreen { cameraManager, namespace, closeAction in .setCameraScreen { cameraManager, namespace, closeAction in
CustomCameraScreen( CustomCameraScreen(
@ -179,7 +182,9 @@ struct CameraContainerView: View, Equatable {
) )
} }
.setCapturedMediaScreen(nil) .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() .startSession()
} }
} }

View File

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

View File

@ -21,11 +21,15 @@ struct ProPaywallView: View {
// Benefits list // Benefits list
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
BenefitRow(image: "paintpalette", text: String(localized: "All Color Presets + Custom Colors")) BenefitRow(image: "paintpalette.fill", text: String(localized: "Premium Colors + Custom Color Picker"))
BenefitRow(image: "sparkles", text: String(localized: "Advanced Beauty Filters")) BenefitRow(image: "sparkles", text: String(localized: "Skin Smoothing Beauty Filter"))
BenefitRow(image: "gradient", text: String(localized: "Directional Gradient Lighting")) BenefitRow(image: "arrow.left.and.right.righttriangle.left.righttriangle.right.fill", text: String(localized: "True Mirror Mode"))
BenefitRow(image: "wand.and.stars", text: String(localized: "Unlimited Boomerang Length")) BenefitRow(image: "bolt.fill", text: String(localized: "Flash Sync with Ring Light"))
BenefitRow(image: "checkmark.seal", text: String(localized: "No Watermarks • Ad-Free")) 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) .frame(maxWidth: .infinity, alignment: .leading)

View File

@ -6,13 +6,21 @@ struct SettingsView: View {
@Bindable var viewModel: SettingsViewModel @Bindable var viewModel: SettingsViewModel
@Binding var showPaywall: Bool @Binding var showPaywall: Bool
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var premiumManager = PremiumManager()
/// Whether premium features are unlocked (for UI gating) /// Whether premium features are unlocked (for UI gating)
private var isPremiumUnlocked: Bool { 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 { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
@ -49,13 +57,13 @@ struct SettingsView: View {
// Flash Mode // Flash Mode
flashModePicker flashModePicker
// Flash Sync // Flash Sync (premium)
SettingsToggle( premiumToggle(
title: String(localized: "Flash Sync"), title: String(localized: "Flash Sync"),
subtitle: String(localized: "Use ring light color for screen flash"), 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 // Front Flash
SettingsToggle( SettingsToggle(
@ -68,6 +76,9 @@ struct SettingsView: View {
// HDR Mode // HDR Mode
hdrModePicker hdrModePicker
// Center Stage (premium feature)
centerStageToggle
// Photo Quality // Photo Quality
photoQualityPicker photoQualityPicker
@ -75,12 +86,13 @@ struct SettingsView: View {
SettingsSectionHeader(title: "Display", systemImage: "eye") SettingsSectionHeader(title: "Display", systemImage: "eye")
SettingsToggle( // True Mirror (premium)
premiumToggle(
title: String(localized: "True Mirror"), title: String(localized: "True Mirror"),
subtitle: String(localized: "Shows horizontally flipped preview like a real 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( SettingsToggle(
title: String(localized: "Grid Overlay"), title: String(localized: "Grid Overlay"),
@ -89,12 +101,13 @@ struct SettingsView: View {
) )
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot")) .accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
SettingsToggle( // Skin Smoothing (premium)
premiumToggle(
title: String(localized: "Skin Smoothing"), title: String(localized: "Skin Smoothing"),
subtitle: String(localized: "Applies subtle real-time 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 // MARK: - Capture Section
@ -116,9 +129,9 @@ struct SettingsView: View {
proSection proSection
// MARK: - Sync Section // MARK: - Sync Section (Premium)
SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud") premiumSectionHeader(title: "iCloud Sync", systemImage: "icloud")
iCloudSyncSection iCloudSyncSection
@ -262,14 +275,20 @@ struct SettingsView: View {
} }
} }
// MARK: - HDR Mode Picker // MARK: - HDR Mode Picker (Premium)
private var hdrModePicker: some View { private var hdrModePicker: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "HDR Mode")) Text(String(localized: "HDR Mode"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium)) .font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white) .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")) Text(String(localized: "High Dynamic Range for better lighting in photos"))
.font(.system(size: Design.BaseFontSize.caption)) .font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
@ -279,27 +298,69 @@ struct SettingsView: View {
options: CameraHDRMode.allCases.map { ($0.displayName, $0) }, options: CameraHDRMode.allCases.map { ($0.displayName, $0) },
selection: $viewModel.hdrMode selection: $viewModel.hdrMode
) )
.disabled(!isPremiumUnlocked)
.accessibilityLabel(String(localized: "Select HDR mode")) .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 { private var photoQualityPicker: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Photo Quality")) Text(String(localized: "Photo Quality"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium)) .font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white)
Text(String(localized: "File size and image quality for saved photos")) Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning)
}
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)) .font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
SegmentedPicker( // Custom picker with premium indicators
title: "", HStack(spacing: Design.Spacing.small) {
options: PhotoQuality.allCases.map { ($0.rawValue.capitalized, $0) }, ForEach(PhotoQuality.allCases, id: \.self) { quality in
selection: $viewModel.photoQuality 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")) .accessibilityLabel(String(localized: "Select photo quality"))
} }
} }
@ -370,23 +431,94 @@ struct SettingsView: View {
.accessibilityValue("\(Int(viewModel.ringLightOpacity * 100)) percent") .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 { private var timerPicker: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Self-Timer")) Text(String(localized: "Self-Timer"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium)) .font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white)
Text(String(localized: "Delay before photo capture for self-portraits")) Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning)
}
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)) .font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
SegmentedPicker( // Custom picker with premium indicators
title: "", HStack(spacing: Design.Spacing.small) {
options: TimerOption.allCases.map { ($0.displayName, $0) }, ForEach(TimerOption.allCases) { option in
selection: $viewModel.selectedTimer 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")) .accessibilityLabel(String(localized: "Select self-timer duration"))
} }
} }
@ -411,7 +543,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold)) .font(.system(size: Design.BaseFontSize.medium, weight: .semibold))
.foregroundStyle(.white) .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)) .font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
@ -434,22 +566,24 @@ struct SettingsView: View {
.accessibilityHint(String(localized: "Opens upgrade options")) .accessibilityHint(String(localized: "Opens upgrade options"))
} }
// MARK: - iCloud Sync Section // MARK: - iCloud Sync Section (Premium)
private var iCloudSyncSection: some View { private var iCloudSyncSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
// Sync toggle // Sync toggle (premium)
SettingsToggle( premiumToggle(
title: String(localized: "Sync Settings"), title: String(localized: "Sync Settings"),
subtitle: viewModel.iCloudAvailable subtitle: !isPremiumUnlocked
? String(localized: "Upgrade to sync settings across devices")
: (viewModel.iCloudAvailable
? String(localized: "Sync settings across all your devices") ? String(localized: "Sync settings across all your devices")
: String(localized: "Sign in to iCloud to enable sync"), : String(localized: "Sign in to iCloud to enable sync")),
isOn: $viewModel.iCloudEnabled isOn: $viewModel.iCloudEnabled,
accessibilityHint: String(localized: "Syncs settings across all your devices via iCloud")
) )
.disabled(!viewModel.iCloudAvailable)
// Sync status // Sync status (only show when premium and enabled)
if viewModel.iCloudEnabled && viewModel.iCloudAvailable { if isPremiumUnlocked && viewModel.iCloudEnabled && viewModel.iCloudAvailable {
HStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.small) {
Image(systemName: syncStatusIcon) Image(systemName: syncStatusIcon)
.font(.system(size: Design.BaseFontSize.body)) .font(.system(size: Design.BaseFontSize.body))
@ -536,6 +670,81 @@ struct SettingsView: View {
.buttonStyle(.plain) .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. /// Observable settings view model with iCloud sync across all devices.
/// Uses Bedrock's CloudSyncManager for automatic synchronization. /// Uses Bedrock's CloudSyncManager for automatic synchronization.
/// Premium features are automatically reset to defaults when user doesn't have premium.
@MainActor @MainActor
@Observable @Observable
final class SettingsViewModel: RingLightConfigurable { final class SettingsViewModel: RingLightConfigurable {
@ -83,6 +84,14 @@ final class SettingsViewModel: RingLightConfigurable {
/// Default ring border size /// Default ring border size
static let defaultRingSize: CGFloat = 40 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 // MARK: - Cloud Sync Manager
/// Manages iCloud sync for settings across all devices /// Manages iCloud sync for settings across all devices
@ -114,10 +123,25 @@ final class SettingsViewModel: RingLightConfigurable {
/// Cached light color ID for immediate UI updates /// Cached light color ID for immediate UI updates
private var _cachedLightColorId: String? 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 /// ID of the selected light color preset
var lightColorId: String { 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 { set {
// Block premium color selection for free users
guard isPremiumUnlocked || !Self.premiumColorIds.contains(newValue) else { return }
_cachedLightColorId = newValue _cachedLightColorId = newValue
updateSettings { $0.lightColorId = newValue } updateSettings { $0.lightColorId = newValue }
} }
@ -127,6 +151,7 @@ final class SettingsViewModel: RingLightConfigurable {
private var _cachedCustomColor: Color? private var _cachedCustomColor: Color?
/// Custom color for ring light (premium feature, debounced save) /// 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 { var customColor: Color {
get { get {
_cachedCustomColor ?? Color( _cachedCustomColor ?? Color(
@ -136,6 +161,8 @@ final class SettingsViewModel: RingLightConfigurable {
) )
} }
set { set {
// Block custom color changes for non-premium users
guard isPremiumUnlocked else { return }
_cachedCustomColor = newValue _cachedCustomColor = newValue
let rgb = CustomColorRGB(from: newValue) let rgb = CustomColorRGB(from: newValue)
debouncedSave(key: "customColor") { debouncedSave(key: "customColor") {
@ -155,16 +182,22 @@ final class SettingsViewModel: RingLightConfigurable {
set { updateSettings { $0.isFrontFlashEnabled = newValue } } 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 { var isMirrorFlipped: Bool {
get { cloudSync.data.isMirrorFlipped } get { isPremiumUnlocked ? cloudSync.data.isMirrorFlipped : false }
set { updateSettings { $0.isMirrorFlipped = newValue } } 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 { var isSkinSmoothingEnabled: Bool {
get { cloudSync.data.isSkinSmoothingEnabled } get { isPremiumUnlocked ? cloudSync.data.isSkinSmoothingEnabled : false }
set { updateSettings { $0.isSkinSmoothingEnabled = newValue } } set {
guard isPremiumUnlocked else { return }
updateSettings { $0.isSkinSmoothingEnabled = newValue }
}
} }
/// Whether the grid overlay is visible /// Whether the grid overlay is visible
@ -190,9 +223,23 @@ final class SettingsViewModel: RingLightConfigurable {
/// Convenience property for border width (same as ringSize) /// Convenience property for border width (same as ringSize)
var borderWidth: CGFloat { ringSize } var borderWidth: CGFloat { ringSize }
/// Selected timer option (5s and 10s are PREMIUM)
var selectedTimer: TimerOption { var selectedTimer: TimerOption {
get { TimerOption(rawValue: cloudSync.data.selectedTimerRaw) ?? .off } get {
set { updateSettings { $0.selectedTimerRaw = newValue.rawValue } } 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 { var selectedCaptureMode: CaptureMode {
@ -207,19 +254,41 @@ final class SettingsViewModel: RingLightConfigurable {
set { updateSettings { $0.flashModeRaw = newValue.rawValue } } set { updateSettings { $0.flashModeRaw = newValue.rawValue } }
} }
/// Whether flash is synced with ring light color (PREMIUM)
var isFlashSyncedWithRingLight: Bool { var isFlashSyncedWithRingLight: Bool {
get { cloudSync.data.isFlashSyncedWithRingLight } get { isPremiumUnlocked ? cloudSync.data.isFlashSyncedWithRingLight : false }
set { updateSettings { $0.isFlashSyncedWithRingLight = newValue } } set {
guard isPremiumUnlocked else { return }
updateSettings { $0.isFlashSyncedWithRingLight = newValue }
}
} }
/// HDR mode setting (PREMIUM)
var hdrMode: CameraHDRMode { var hdrMode: CameraHDRMode {
get { CameraHDRMode(rawValue: cloudSync.data.hdrModeRaw) ?? .off } get { isPremiumUnlocked ? (CameraHDRMode(rawValue: cloudSync.data.hdrModeRaw) ?? .off) : .off }
set { updateSettings { $0.hdrModeRaw = newValue.rawValue } } set {
guard isPremiumUnlocked else { return }
updateSettings { $0.hdrModeRaw = newValue.rawValue }
}
} }
/// Photo quality setting (high is PREMIUM)
var photoQuality: PhotoQuality { var photoQuality: PhotoQuality {
get { PhotoQuality(rawValue: cloudSync.data.photoQualityRaw) ?? .high } get {
set { updateSettings { $0.photoQualityRaw = newValue.rawValue } } 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 { var cameraPosition: CameraPosition {
@ -245,9 +314,19 @@ final class SettingsViewModel: RingLightConfigurable {
set { updateSettings { $0.ringLightOpacity = newValue } } 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 { var selectedLightColor: RingLightColor {
get { RingLightColor.fromId(lightColorId, customColor: customColor) } get { RingLightColor.fromId(lightColorId, customColor: customColor) }
set { set {
// Premium check handled by lightColorId and customColor setters
lightColorId = newValue.id lightColorId = newValue.id
if newValue.isCustom { if newValue.isCustom {
customColor = newValue.color customColor = newValue.color
@ -267,8 +346,9 @@ final class SettingsViewModel: RingLightConfigurable {
lightColorId == RingLightColor.customId lightColorId == RingLightColor.customId
} }
/// Sets the custom color and selects it /// Sets the custom color and selects it (PREMIUM)
func selectCustomColor(_ color: Color) { func selectCustomColor(_ color: Color) {
guard isPremiumUnlocked else { return }
customColor = color customColor = color
lightColorId = RingLightColor.customId lightColorId = RingLightColor.customId
} }
@ -278,10 +358,13 @@ final class SettingsViewModel: RingLightConfigurable {
/// Whether iCloud sync is available /// Whether iCloud sync is available
var iCloudAvailable: Bool { cloudSync.iCloudAvailable } var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
/// Whether iCloud sync is enabled /// Whether iCloud sync is enabled (PREMIUM)
var iCloudEnabled: Bool { var iCloudEnabled: Bool {
get { cloudSync.iCloudEnabled } get { isPremiumUnlocked ? cloudSync.iCloudEnabled : false }
set { cloudSync.iCloudEnabled = newValue } set {
guard isPremiumUnlocked else { return }
cloudSync.iCloudEnabled = newValue
}
} }
/// Last sync date /// Last sync date

View File

@ -53,14 +53,6 @@
"comment" : "A description of the ring size slider in the settings view.", "comment" : "A description of the ring size slider in the settings view.",
"isCommentAutoGenerated" : true "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" : { "Applies light skin smoothing to the camera preview" : {
"comment" : "A hint for the \"Skin Smoothing\" toggle in the settings view.", "comment" : "A hint for the \"Skin Smoothing\" toggle in the settings view.",
"isCommentAutoGenerated" : true "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.", "comment" : "Title of a toggle that enables automatic saving of captured photos and videos to the user's Photo Library.",
"isCommentAutoGenerated" : true "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" : { "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.", "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 "isCommentAutoGenerated" : true
@ -117,6 +117,10 @@
}, },
"Center Stage active" : { "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" : { "Choose between front and back camera lenses" : {
"comment" : "A description of the camera position picker.", "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.", "comment" : "A description of the purpose of the \"Self-Timer\" setting in the settings screen.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Directional Gradient Lighting" : {
"comment" : "Benefit provided with the Pro subscription, such as \"Directional Gradient Lighting\".",
"isCommentAutoGenerated" : true
},
"Done" : { "Done" : {
"comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.", "comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.",
"isCommentAutoGenerated" : true "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.", "comment" : "An accessibility hint for the capture button, instructing the user to double-tap it to capture a photo.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Enable Center Stage" : {
"comment" : "An accessibility label for the toggle that enables the \"Center Stage\" feature.",
"isCommentAutoGenerated" : true
},
"Enable Ring Light" : { "Enable Ring Light" : {
"comment" : "Title of a toggle in the Settings view that allows the user to enable or disable the ring light overlay.", "comment" : "Title of a toggle in the Settings view that allows the user to enable or disable the ring light overlay.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -174,6 +178,10 @@
"comment" : "A toggle that enables or disables the ring light overlay.", "comment" : "A toggle that enables or disables the ring light overlay.",
"isCommentAutoGenerated" : true "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" : { "File size and image quality for saved photos" : {
"comment" : "A description of the photo quality setting.", "comment" : "A description of the photo quality setting.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -186,6 +194,10 @@
"comment" : "Title of a toggle that synchronizes the flash color with the ring light color.", "comment" : "Title of a toggle that synchronizes the flash color with the ring light color.",
"isCommentAutoGenerated" : true "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" : { "Flips the camera preview horizontally" : {
"comment" : "An accessibility hint for the \"True Mirror\" setting.", "comment" : "An accessibility hint for the \"True Mirror\" setting.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -210,6 +222,10 @@
"comment" : "Title for a picker that allows the user to select the HDR mode of the camera.", "comment" : "Title for a picker that allows the user to select the HDR mode of the camera.",
"isCommentAutoGenerated" : true "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" : { "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.", "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 "isCommentAutoGenerated" : true
@ -218,10 +234,18 @@
"comment" : "A description of the High Dynamic Range (HDR) mode in the settings view.", "comment" : "A description of the High Dynamic Range (HDR) mode in the settings view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"High Quality Photo Export" : {
"comment" : "Description of a benefit that is included with the Premium membership.",
"isCommentAutoGenerated" : true
},
"Ice Blue" : { "Ice Blue" : {
"comment" : "Name of a ring light color preset.", "comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true "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 %@" : { "Last synced %@" : {
}, },
@ -233,10 +257,6 @@
"comment" : "A hint that appears when a user taps on a color preset button.", "comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"No Watermarks • Ad-Free" : {
"comment" : "Description of a benefit that comes with the Pro subscription.",
"isCommentAutoGenerated" : true
},
"Off" : { "Off" : {
"comment" : "The accessibility value for the grid toggle when it is off.", "comment" : "The accessibility value for the grid toggle when it is off.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -267,6 +287,13 @@
"Premium color" : { "Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.", "comment" : "An accessibility hint for a premium color option in the color preset button.",
"isCommentAutoGenerated" : true "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." : { "Purchase successful! Pro features unlocked." : {
"comment" : "Announcement read out to the user when a premium purchase is successful.", "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.", "comment" : "A toggle that enables or disables real-time skin smoothing.",
"isCommentAutoGenerated" : true "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" : { "Soft Pink" : {
"comment" : "Name of a ring light color preset.", "comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -422,6 +453,10 @@
"comment" : "A toggle that synchronizes the flash color with the ring light color.", "comment" : "A toggle that synchronizes the flash color with the ring light color.",
"isCommentAutoGenerated" : true "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" : { "Take photo" : {
"comment" : "An accessibility label for the capture button.", "comment" : "An accessibility label for the capture button.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -440,18 +475,26 @@
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.", "comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Unlimited Boomerang Length" : { "True Mirror Mode" : {
"comment" : "Description of a benefit that comes with the Pro subscription, specifically related to the boomerang tool.", "comment" : "Feature of the Pro subscription that allows users to see their reflection in the mirror.",
"isCommentAutoGenerated" : true
},
"Unlock premium colors, video, and more" : {
"comment" : "A description of the benefits of upgrading to the Pro version of the app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Upgrade to Pro" : { "Upgrade to Pro" : {
"comment" : "A button label that prompts users to upgrade to the premium version of the app.", "comment" : "A button label that prompts users to upgrade to the premium version of the app.",
"isCommentAutoGenerated" : true "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" : { "Use ring light color for screen flash" : {
"comment" : "Accessibility hint for the \"Flash Sync\" toggle in the Settings view.", "comment" : "Accessibility hint for the \"Flash Sync\" toggle in the Settings view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true

View File

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