diff --git a/SelfieRingLight/Features/Camera/ContentView.swift b/SelfieRingLight/Features/Camera/ContentView.swift index cb4fb64..d067c9b 100644 --- a/SelfieRingLight/Features/Camera/ContentView.swift +++ b/SelfieRingLight/Features/Camera/ContentView.swift @@ -15,27 +15,24 @@ struct ContentView: View { var body: some View { GeometryReader { geometry in + let maxRingSize = calculateMaxRingSize(for: geometry) + let effectiveRingSize = min(settings.ringSize, maxRingSize) + ZStack { // MARK: - Ring Light Background ringLightBackground - // MARK: - Camera Preview (centered with border inset) - cameraPreviewArea(in: geometry) + // MARK: - Camera Preview (full screen with ring border) + cameraPreviewArea(ringSize: effectiveRingSize) // MARK: - Grid Overlay if settings.isGridVisible && !viewModel.isPreviewHidden { - let previewSize = min( - geometry.size.width - (settings.ringSize * 2), - geometry.size.height - (settings.ringSize * 2) - ) GridOverlay(isVisible: true) - .frame(width: previewSize, height: previewSize) - .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) - .animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize) + .padding(effectiveRingSize) } - // MARK: - Controls Overlay - controlsOverlay + // MARK: - Controls Overlay (on top of preview) + controlsOverlay(ringSize: effectiveRingSize) // MARK: - Permission Denied View if !viewModel.isCameraAuthorized && viewModel.captureSession != nil { @@ -47,6 +44,13 @@ struct ContentView: View { toastView(message: message) } } + .onChange(of: geometry.size) { _, newSize in + // Update max ring size when screen size changes + let newMax = min(newSize.width, newSize.height) / 4 + if settings.ringSize > newMax { + settings.ringSize = newMax + } + } } .ignoresSafeArea() .task { @@ -59,7 +63,7 @@ struct ContentView: View { ProPaywallView() } .sheet(isPresented: $showSettings) { - SettingsView(viewModel: viewModel.settings) + SettingsView(viewModel: viewModel.settings, showPaywall: $showPaywall) } .fullScreenCover(isPresented: $viewModel.showPostCapturePreview) { if let media = viewModel.capturedMedia { @@ -86,29 +90,28 @@ struct ContentView: View { } } + // MARK: - Max Ring Size Calculation + + /// Calculates maximum ring size based on screen dimensions + /// Ring should not exceed 1/4 of the smaller dimension + private func calculateMaxRingSize(for geometry: GeometryProxy) -> CGFloat { + min(geometry.size.width, geometry.size.height) / 4 + } + // MARK: - Ring Light Background @ViewBuilder private var ringLightBackground: some View { let baseColor = premiumManager.isPremiumUnlocked ? settings.lightColor : Color.RingLight.pureWhite - // Apply light intensity as opacity baseColor - .opacity(settings.lightIntensity) .ignoresSafeArea() - .animation(.easeInOut(duration: Design.Animation.quick), value: settings.lightIntensity) } // MARK: - Camera Preview Area @ViewBuilder - private func cameraPreviewArea(in geometry: GeometryProxy) -> some View { - // Calculate the size of the preview area (full screen minus ring on all sides) - let previewSize = min( - geometry.size.width - (settings.ringSize * 2), - geometry.size.height - (settings.ringSize * 2) - ) - + private func cameraPreviewArea(ringSize: CGFloat) -> some View { if viewModel.isCameraAuthorized { // Show preview unless front flash is active if !viewModel.isPreviewHidden { @@ -117,16 +120,17 @@ struct ContentView: View { isMirrorFlipped: settings.isMirrorFlipped, zoomFactor: settings.currentZoomFactor ) - .frame(width: previewSize, height: previewSize) + .padding(ringSize) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) - .animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize) + .animation(.easeInOut(duration: Design.Animation.quick), value: ringSize) } } else { // Show placeholder while requesting permission - RoundedRectangle(cornerRadius: Design.CornerRadius.large) + Rectangle() .fill(.black) - .frame(width: previewSize, height: previewSize) - .animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize) + .padding(ringSize) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .animation(.easeInOut(duration: Design.Animation.quick), value: ringSize) .overlay { if viewModel.captureSession == nil { ProgressView() @@ -139,38 +143,26 @@ struct ContentView: View { // MARK: - Controls Overlay - private var controlsOverlay: some View { + private func controlsOverlay(ringSize: CGFloat) -> some View { VStack { // Top bar topControlBar + .padding(.top, ringSize + Design.Spacing.small) Spacer() // Bottom capture controls bottomControlBar + .padding(.bottom, ringSize + Design.Spacing.medium) } - .padding(settings.ringSize + Design.Spacing.medium) - .animation(.easeInOut(duration: Design.Animation.quick), value: settings.ringSize) + .padding(.horizontal, ringSize + Design.Spacing.small) + .animation(.easeInOut(duration: Design.Animation.quick), value: ringSize) } // MARK: - Top Control Bar private var topControlBar: some View { HStack { - // Pro/Crown button - Button { - showPaywall = true - } label: { - Image(systemName: premiumManager.isPremiumUnlocked ? "crown.fill" : "crown") - .font(.title2) - .foregroundStyle(premiumManager.isPremiumUnlocked ? .yellow : .white) - .padding(Design.Spacing.small) - .background(.ultraThinMaterial, in: .circle) - } - .accessibilityLabel(premiumManager.isPremiumUnlocked ? - String(localized: "Pro unlocked") : - String(localized: "Upgrade to Pro")) - Spacer() // Grid toggle @@ -178,7 +170,7 @@ struct ContentView: View { viewModel.settings.isGridVisible.toggle() } label: { Image(systemName: "square.grid.3x3") - .font(.title2) + .font(.body) .foregroundStyle(viewModel.settings.isGridVisible ? .yellow : .white) .padding(Design.Spacing.small) .background(.ultraThinMaterial, in: .circle) @@ -191,7 +183,7 @@ struct ContentView: View { showSettings = true } label: { Image(systemName: "gearshape.fill") - .font(.title2) + .font(.body) .foregroundStyle(.white) .padding(Design.Spacing.small) .background(.ultraThinMaterial, in: .circle) @@ -203,22 +195,26 @@ struct ContentView: View { // MARK: - Bottom Control Bar private var bottomControlBar: some View { - HStack(spacing: Design.Spacing.xxxxLarge) { + HStack { // Switch camera button Button { viewModel.switchCamera() } label: { Image(systemName: "camera.rotate.fill") - .font(.title) + .font(.title2) .foregroundStyle(.white) .padding(Design.Spacing.medium) .background(.ultraThinMaterial, in: .circle) } .accessibilityLabel(String(localized: "Switch camera")) + Spacer() + // Capture button captureButton + Spacer() + // Capture mode selector captureModeMenu } @@ -273,7 +269,7 @@ struct ContentView: View { } } label: { Image(systemName: viewModel.settings.selectedCaptureMode.systemImage) - .font(.title) + .font(.title2) .foregroundStyle(.white) .padding(Design.Spacing.medium) .background(.ultraThinMaterial, in: .circle) @@ -351,7 +347,7 @@ struct ContentView: View { .padding(.horizontal, Design.Spacing.large) .padding(.vertical, Design.Spacing.medium) .background(.ultraThinMaterial, in: Capsule()) - .padding(.bottom, Design.Spacing.xxxLarge + settings.ringSize) + .padding(.bottom, Design.Spacing.xxxLarge) .transition(.move(edge: .bottom).combined(with: .opacity)) .animation(.spring(duration: Design.Animation.quick), value: message) } diff --git a/SelfieRingLight/Features/Settings/SettingsView.swift b/SelfieRingLight/Features/Settings/SettingsView.swift index 7df3a72..4604b7a 100644 --- a/SelfieRingLight/Features/Settings/SettingsView.swift +++ b/SelfieRingLight/Features/Settings/SettingsView.swift @@ -3,6 +3,7 @@ import Bedrock struct SettingsView: View { @Bindable var viewModel: SettingsViewModel + @Binding var showPaywall: Bool @Environment(\.dismiss) private var dismiss var body: some View { @@ -20,9 +21,6 @@ struct SettingsView: View { // Color Preset colorPresetSection - // Brightness Slider - brightnessSlider - // MARK: - Camera Section SettingsSectionHeader(title: "Camera", systemImage: "camera") @@ -69,6 +67,12 @@ struct SettingsView: View { ) .accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture")) + // MARK: - Pro Section + + SettingsSectionHeader(title: "Pro", systemImage: "crown") + + proSection + // MARK: - Sync Section SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud") @@ -162,44 +166,6 @@ struct SettingsView: View { .padding(.vertical, Design.Spacing.xSmall) } - // MARK: - Light Intensity Slider - - private var brightnessSlider: some View { - VStack(alignment: .leading, spacing: Design.Spacing.small) { - HStack { - Text(String(localized: "Light Intensity")) - .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) - .foregroundStyle(.white) - - Spacer() - - Text("\(Int(viewModel.lightIntensity * 100))%") - .font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - HStack(spacing: Design.Spacing.medium) { - Image(systemName: "light.min") - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - - Slider(value: $viewModel.lightIntensity, in: 0.5...1.0, step: 0.05) - .tint(Color.Accent.primary) - - Image(systemName: "light.max") - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - Text(String(localized: "Adjusts the opacity/intensity of the ring light")) - .font(.system(size: Design.BaseFontSize.caption)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - .padding(.vertical, Design.Spacing.xSmall) - .accessibilityLabel(String(localized: "Light intensity")) - .accessibilityValue("\(Int(viewModel.lightIntensity * 100)) percent") - } - // MARK: - Timer Picker private var timerPicker: some View { @@ -211,6 +177,49 @@ struct SettingsView: View { .accessibilityLabel(String(localized: "Select self-timer duration")) } + // MARK: - Pro Section + + private var proSection: some View { + Button { + dismiss() + // Small delay to allow sheet to dismiss before showing paywall + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showPaywall = true + } + } label: { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: "crown.fill") + .font(.title2) + .foregroundStyle(Color.Status.warning) + + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(String(localized: "Upgrade to Pro")) + .font(.system(size: Design.BaseFontSize.medium, weight: .semibold)) + .foregroundStyle(.white) + + Text(String(localized: "Unlock premium colors, video, and more")) + .font(.system(size: Design.BaseFontSize.caption)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.body) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .padding(Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.Accent.primary.opacity(Design.Opacity.subtle)) + .strokeBorder(Color.Accent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Upgrade to Pro")) + .accessibilityHint(String(localized: "Opens upgrade options")) + } + // MARK: - iCloud Sync Section private var iCloudSyncSection: some View { @@ -335,6 +344,6 @@ private struct ColorPresetButton: View { } #Preview { - SettingsView(viewModel: SettingsViewModel()) + SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false)) .preferredColorScheme(.dark) } diff --git a/SelfieRingLight/Features/Settings/SettingsViewModel.swift b/SelfieRingLight/Features/Settings/SettingsViewModel.swift index daf02e6..74b3667 100644 --- a/SelfieRingLight/Features/Settings/SettingsViewModel.swift +++ b/SelfieRingLight/Features/Settings/SettingsViewModel.swift @@ -101,12 +101,6 @@ final class SettingsViewModel: RingLightConfigurable { set { updateSettings { $0.lightColorId = newValue } } } - /// Ring light intensity/opacity (0.5 to 1.0) - var lightIntensity: Double { - get { cloudSync.data.lightIntensity } - set { updateSettings { $0.lightIntensity = newValue } } - } - /// Whether front flash is enabled (hides preview during capture) var isFrontFlashEnabled: Bool { get { cloudSync.data.isFrontFlashEnabled } @@ -218,7 +212,7 @@ final class SettingsViewModel: RingLightConfigurable { // MARK: - Validation var isValidConfiguration: Bool { - ringSize >= Self.minRingSize && lightIntensity >= 0.5 + ringSize >= Self.minRingSize } } diff --git a/SelfieRingLight/Resources/Localizable.xcstrings b/SelfieRingLight/Resources/Localizable.xcstrings index 352cd31..1b4c36b 100644 --- a/SelfieRingLight/Resources/Localizable.xcstrings +++ b/SelfieRingLight/Resources/Localizable.xcstrings @@ -29,6 +29,10 @@ "comment" : "Description of a timer option when the user selects \"10 seconds\".", "isCommentAutoGenerated" : true }, + "Adjusts the opacity/intensity of the ring light" : { + "comment" : "A description of the light intensity slider in the settings view.", + "isCommentAutoGenerated" : true + }, "Adjusts the size of the light ring around the camera preview" : { "comment" : "A description of the ring size slider in the settings view.", "isCommentAutoGenerated" : true @@ -49,6 +53,14 @@ "comment" : "Accessibility hint for the \"Skin Smoothing\" toggle in the Settings view.", "isCommentAutoGenerated" : true }, + "Auto-Save" : { + "comment" : "Title of a toggle that enables automatic saving of captured photos and videos to the user's Photo Library.", + "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 + }, "Best Value • Save 33%" : { "comment" : "A promotional text displayed below an annual subscription package, highlighting its value.", "isCommentAutoGenerated" : true @@ -73,6 +85,22 @@ "comment" : "A label describing the currently selected capture mode. The placeholder is replaced with the actual mode name.", "isCommentAutoGenerated" : true }, + "Captured boomerang" : { + "comment" : "A label describing a captured boomerang.", + "isCommentAutoGenerated" : true + }, + "Captured photo" : { + "comment" : "A label describing a captured photo.", + "isCommentAutoGenerated" : true + }, + "Captured video" : { + "comment" : "A label describing a captured video.", + "isCommentAutoGenerated" : true + }, + "Close preview" : { + "comment" : "A button label that closes the preview screen.", + "isCommentAutoGenerated" : true + }, "Cool Lavender" : { "comment" : "Name of a ring light color preset.", "isCommentAutoGenerated" : true @@ -93,6 +121,14 @@ "comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.", "isCommentAutoGenerated" : true }, + "Edit" : { + "comment" : "Label for the button that allows the user to edit their captured photo or video.", + "isCommentAutoGenerated" : true + }, + "Front Flash" : { + "comment" : "Title of a toggle in the Settings view that controls whether the front flash is enabled.", + "isCommentAutoGenerated" : true + }, "Go Pro" : { "comment" : "The title of the \"Go Pro\" button in the Pro paywall.", "isCommentAutoGenerated" : true @@ -101,8 +137,8 @@ "comment" : "Text displayed in a settings toggle for showing a grid overlay to help compose your shot.", "isCommentAutoGenerated" : true }, - "Higher brightness = brighter ring light effect" : { - "comment" : "A description of how to adjust the brightness of the screen.", + "Hides preview during capture for a flash effect" : { + "comment" : "Subtitle for the \"Front Flash\" toggle in the Settings view.", "isCommentAutoGenerated" : true }, "Ice Blue" : { @@ -116,6 +152,14 @@ "comment" : "A label displayed above a section of the settings view related to light colors.", "isCommentAutoGenerated" : true }, + "Light intensity" : { + "comment" : "An accessibility label for the light intensity slider in the settings view. The value is dynamically set based on the slider's current value.", + "isCommentAutoGenerated" : true + }, + "Light Intensity" : { + "comment" : "A label describing the slider that adjusts the intensity of the ring light.", + "isCommentAutoGenerated" : true + }, "No Watermarks • Ad-Free" : { "comment" : "Description of a benefit that comes with the Pro subscription.", "isCommentAutoGenerated" : true @@ -167,6 +211,14 @@ "comment" : "A button that restores purchases.", "isCommentAutoGenerated" : true }, + "Retake" : { + "comment" : "Title for a button that allows the user to retake a captured photo or video.", + "isCommentAutoGenerated" : true + }, + "Ring Glow" : { + "comment" : "Title of a slider that controls the intensity of the ring glow effect in the captured media.", + "isCommentAutoGenerated" : true + }, "Ring size" : { "comment" : "An accessibility label for the ring size slider in the settings view.", "isCommentAutoGenerated" : true @@ -175,12 +227,8 @@ "comment" : "The label for the ring size slider in the settings view.", "isCommentAutoGenerated" : true }, - "Screen brightness" : { - "comment" : "An accessibility label for the screen brightness setting in the settings view.", - "isCommentAutoGenerated" : true - }, - "Screen Brightness" : { - "comment" : "A label displayed above the brightness slider in the settings view.", + "Saved to Photos" : { + "comment" : "Text shown as a toast message when a photo is successfully saved to Photos.", "isCommentAutoGenerated" : true }, "Select self-timer duration" : { @@ -195,6 +243,10 @@ "comment" : "The title of the settings screen.", "isCommentAutoGenerated" : true }, + "Share" : { + "comment" : "Title for a button that shares the captured media.", + "isCommentAutoGenerated" : true + }, "Shows a grid overlay to help compose your shot" : { "comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.", "isCommentAutoGenerated" : true @@ -214,6 +266,9 @@ "Skin Smoothing" : { "comment" : "A toggle that enables or disables real-time skin smoothing.", "isCommentAutoGenerated" : true + }, + "Smoothing" : { + }, "Soft Pink" : { "comment" : "Name of a ring light color preset.", @@ -278,10 +333,18 @@ "comment" : "Description of a benefit that comes with the Pro subscription, specifically related to the boomerang tool.", "isCommentAutoGenerated" : true }, + "Unlock filters, AI remove, and more with Pro" : { + "comment" : "A teaser text that appears below the capture edit view, promoting a premium feature.", + "isCommentAutoGenerated" : true + }, "Upgrade to Pro" : { "comment" : "A button label that prompts users to upgrade to the premium version of the app.", "isCommentAutoGenerated" : true }, + "Uses the ring light as a flash when taking photos" : { + "comment" : "An accessibility hint for the \"Front Flash\" toggle in the Settings view.", + "isCommentAutoGenerated" : true + }, "Video" : { "comment" : "Display name for the \"Video\" capture mode.", "isCommentAutoGenerated" : true @@ -298,6 +361,10 @@ "comment" : "A color option for the ring light, named after a warm, creamy shade of white.", "isCommentAutoGenerated" : true }, + "When enabled, photos and videos are saved immediately after capture" : { + "comment" : "A hint provided by the \"Auto-Save\" toggle in the Settings view, explaining that photos and videos are saved immediately after capture when enabled.", + "isCommentAutoGenerated" : true + }, "When enabled, the preview is not mirrored" : { "comment" : "Accessibility hint for the \"True Mirror\" setting in the Settings view.", "isCommentAutoGenerated" : true diff --git a/SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift b/SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift index 688aa94..c54e4ce 100644 --- a/SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift +++ b/SelfieRingLight/Shared/Protocols/RingLightConfigurable.swift @@ -11,9 +11,6 @@ protocol RingLightConfigurable { /// The color of the ring light var lightColor: Color { get } - /// Ring light intensity/opacity (0.5 to 1.0) - var lightIntensity: Double { get set } - /// Whether front flash is enabled (hides preview during capture) var isFrontFlashEnabled: Bool { get set } diff --git a/SelfieRingLight/Shared/Storage/SyncedSettings.swift b/SelfieRingLight/Shared/Storage/SyncedSettings.swift index 9db6158..341bf43 100644 --- a/SelfieRingLight/Shared/Storage/SyncedSettings.swift +++ b/SelfieRingLight/Shared/Storage/SyncedSettings.swift @@ -35,9 +35,6 @@ struct SyncedSettings: PersistableData, Sendable { /// ID of the selected light color preset var lightColorId: String = "pureWhite" - /// Ring light intensity/opacity (0.5 to 1.0) - var lightIntensity: Double = 1.0 - /// Whether front flash is enabled (hides preview during capture) var isFrontFlashEnabled: Bool = true @@ -77,7 +74,6 @@ struct SyncedSettings: PersistableData, Sendable { init( ringSize: CGFloat, lightColorId: String, - lightIntensity: Double, isFrontFlashEnabled: Bool, isMirrorFlipped: Bool, isSkinSmoothingEnabled: Bool, @@ -89,7 +85,6 @@ struct SyncedSettings: PersistableData, Sendable { ) { self.ringSizeValue = Double(ringSize) self.lightColorId = lightColorId - self.lightIntensity = lightIntensity self.isFrontFlashEnabled = isFrontFlashEnabled self.isMirrorFlipped = isMirrorFlipped self.isSkinSmoothingEnabled = isSkinSmoothingEnabled @@ -108,7 +103,6 @@ struct SyncedSettings: PersistableData, Sendable { case lastModified case ringSizeValue case lightColorId - case lightIntensity case isFrontFlashEnabled case isMirrorFlipped case isSkinSmoothingEnabled @@ -126,7 +120,6 @@ extension SyncedSettings: Equatable { static func == (lhs: SyncedSettings, rhs: SyncedSettings) -> Bool { lhs.ringSizeValue == rhs.ringSizeValue && lhs.lightColorId == rhs.lightColorId && - lhs.lightIntensity == rhs.lightIntensity && lhs.isFrontFlashEnabled == rhs.isFrontFlashEnabled && lhs.isMirrorFlipped == rhs.isMirrorFlipped && lhs.isSkinSmoothingEnabled == rhs.isSkinSmoothingEnabled &&