Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
7a564b2115
commit
459755be98
@ -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 */ = {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user