Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
7a564b2115
commit
459755be98
@ -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 */ = {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -6,9 +6,14 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
@main
|
||||
struct SelfieCamApp: App {
|
||||
init() {
|
||||
Design.showDebugLogs = true
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user