Fix UI issues: full-screen preview, ring size limits, cleaner layout

Changes:
1. Camera preview now fills available space (not forced square)
   - Maintains proper aspect ratio for captured photos
   - Controls overlay on top of preview

2. Ring size now limited based on screen dimensions
   - Maximum is 1/4 of smaller screen dimension
   - Prevents content from shifting off-screen

3. Removed light intensity slider
   - Was causing color changes (opacity approach)
   - Ring light now always at full brightness

4. Removed crown icon from main screen
   - Pro upgrade moved to Settings > Pro section
   - Cleaner camera interface

5. Smaller top icons
   - Grid and settings buttons use .body font
   - Less visual clutter
This commit is contained in:
Matt Bruce 2026-01-02 13:23:17 -06:00
parent ef15a8c21a
commit bf5853d999
6 changed files with 173 additions and 117 deletions

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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 }

View File

@ -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 &&