diff --git a/SelfieCam.xcodeproj/project.pbxproj b/SelfieCam.xcodeproj/project.pbxproj index fe7c284..b3d0615 100644 --- a/SelfieCam.xcodeproj/project.pbxproj +++ b/SelfieCam.xcodeproj/project.pbxproj @@ -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 */ = { diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a28a441..3803380 100644 --- a/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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", diff --git a/SelfieCam/App/SelfieCamApp.swift b/SelfieCam/App/SelfieCamApp.swift index 5e4a470..14220c4 100644 --- a/SelfieCam/App/SelfieCamApp.swift +++ b/SelfieCam/App/SelfieCamApp.swift @@ -6,9 +6,14 @@ // import SwiftUI +import Bedrock @main struct SelfieCamApp: App { + init() { + Design.showDebugLogs = true + } + var body: some Scene { WindowGroup { ContentView() diff --git a/SelfieCam/Features/Camera/ContentView.swift b/SelfieCam/Features/Camera/ContentView.swift index 3594af5..5c22495 100644 --- a/SelfieCam/Features/Camera/ContentView.swift +++ b/SelfieCam/Features/Camera/ContentView.swift @@ -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() } } diff --git a/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift index 8846ab7..0b42317 100644 --- a/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift +++ b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift @@ -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 diff --git a/SelfieCam/Features/Paywall/ProPaywallView.swift b/SelfieCam/Features/Paywall/ProPaywallView.swift index d934524..2b7b3b8 100644 --- a/SelfieCam/Features/Paywall/ProPaywallView.swift +++ b/SelfieCam/Features/Paywall/ProPaywallView.swift @@ -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) diff --git a/SelfieCam/Features/Settings/SettingsView.swift b/SelfieCam/Features/Settings/SettingsView.swift index b6044fd..a3e6747 100644 --- a/SelfieCam/Features/Settings/SettingsView.swift +++ b/SelfieCam/Features/Settings/SettingsView.swift @@ -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, + 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) + } } diff --git a/SelfieCam/Features/Settings/SettingsViewModel.swift b/SelfieCam/Features/Settings/SettingsViewModel.swift index a1138d1..9620edf 100644 --- a/SelfieCam/Features/Settings/SettingsViewModel.swift +++ b/SelfieCam/Features/Settings/SettingsViewModel.swift @@ -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 = ["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 diff --git a/SelfieCam/Resources/Localizable.xcstrings b/SelfieCam/Resources/Localizable.xcstrings index 4d91157..c77252d 100644 --- a/SelfieCam/Resources/Localizable.xcstrings +++ b/SelfieCam/Resources/Localizable.xcstrings @@ -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 diff --git a/SelfieCam/Shared/Storage/SyncedSettings.swift b/SelfieCam/Shared/Storage/SyncedSettings.swift index d01c9b8..6e63d85 100644 --- a/SelfieCam/Shared/Storage/SyncedSettings.swift +++ b/SelfieCam/Shared/Storage/SyncedSettings.swift @@ -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 } }