Fix UI issues: preview sizing, debounce, corners, color picker

Fixes:
1. Camera preview rounded corners - clipShape now applied before padding
   so the preview itself has rounded corners, not the container

2. Debounced slider saves - ringSize and customColor now use debouncing
   - Immediate UI update via cached values
   - 300ms debounce before cloud save
   - Prevents excessive save operations during slider drag

3. Simplified custom color picker to one-step
   - ColorPicker styled as a circle button
   - Shows current custom color always (no rainbow)
   - Tapping opens iOS native color picker directly
   - Color applies immediately on selection
   - No Apply/Cancel sheet needed
This commit is contained in:
Matt Bruce 2026-01-02 15:43:01 -06:00
parent 95377c5950
commit 9066635a4d
4 changed files with 129 additions and 169 deletions

View File

@ -120,16 +120,16 @@ struct ContentView: View {
isMirrorFlipped: settings.isMirrorFlipped,
zoomFactor: settings.currentZoomFactor
)
.padding(ringSize)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.padding(ringSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
}
} else {
// Show placeholder while requesting permission
Rectangle()
.fill(.black)
.padding(ringSize)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.padding(ringSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
.overlay {
if viewModel.captureSession == nil {

View File

@ -5,8 +5,6 @@ struct SettingsView: View {
@Bindable var viewModel: SettingsViewModel
@Binding var showPaywall: Bool
@Environment(\.dismiss) private var dismiss
@State private var showColorPicker = false
@State private var tempCustomColor: Color = .white
var body: some View {
NavigationStack {
@ -165,30 +163,17 @@ struct SettingsView: View {
}
}
// Custom color button (premium)
CustomColorButton(
currentColor: viewModel.customColor,
// Custom color picker (premium) - one-step: opens picker, selects on change
CustomColorPickerButton(
customColor: Binding(
get: { viewModel.customColor },
set: { viewModel.selectCustomColor($0) }
),
isSelected: viewModel.isCustomColorSelected
) {
tempCustomColor = viewModel.customColor
showColorPicker = true
}
)
}
}
.padding(.vertical, Design.Spacing.xSmall)
.sheet(isPresented: $showColorPicker) {
CustomColorPickerSheet(
selectedColor: $tempCustomColor,
onApply: {
viewModel.selectCustomColor(tempCustomColor)
showColorPicker = false
},
onCancel: {
showColorPicker = false
}
)
.presentationDetents([.medium])
}
}
// MARK: - Timer Picker
@ -368,136 +353,58 @@ private struct ColorPresetButton: View {
}
}
// MARK: - Custom Color Button
// MARK: - Custom Color Picker Button
private struct CustomColorButton: View {
let currentColor: Color
/// One-step custom color picker: always shows current color, tapping opens iOS color picker
private struct CustomColorPickerButton: View {
@Binding var customColor: Color
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: Design.Spacing.xxSmall) {
// Rainbow gradient circle to indicate custom picker
ZStack {
// Show rainbow gradient when not selected, custom color when selected
if isSelected {
Circle()
.fill(currentColor)
} else {
Circle()
.fill(
AngularGradient(
colors: [.red, .orange, .yellow, .green, .blue, .purple, .red],
center: .center
)
)
}
Circle()
.strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
)
}
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.shadow(
color: currentColor.opacity(Design.Opacity.light),
radius: isSelected ? Design.Shadow.radiusSmall : 0
)
Text(String(localized: "Custom"))
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(Color.Status.warning)
VStack(spacing: Design.Spacing.xxSmall) {
// ColorPicker styled as a circle
ColorPicker(
selection: $customColor,
supportsOpacity: false
) {
EmptyView()
}
.padding(Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
.labelsHidden()
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.clipShape(.circle)
.overlay(
Circle()
.strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
)
)
.shadow(
color: customColor.opacity(Design.Opacity.light),
radius: isSelected ? Design.Shadow.radiusSmall : 0
)
Text(String(localized: "Custom"))
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(Color.Status.warning)
}
.buttonStyle(.plain)
.padding(Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
)
.accessibilityLabel(String(localized: "Custom color"))
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityHint(String(localized: "Opens color picker. Premium feature."))
}
}
// MARK: - Custom Color Picker Sheet
private struct CustomColorPickerSheet: View {
@Binding var selectedColor: Color
let onApply: () -> Void
let onCancel: () -> Void
var body: some View {
NavigationStack {
VStack(spacing: Design.Spacing.large) {
// Color preview
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(selectedColor)
.frame(height: 120)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(Color.Border.subtle, lineWidth: Design.LineWidth.thin)
)
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.medium)
// SwiftUI ColorPicker
ColorPicker(
selection: $selectedColor,
supportsOpacity: false
) {
Text(String(localized: "Select Color"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
}
.padding(.horizontal, Design.Spacing.large)
// Tips
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Label(
String(localized: "Lighter colors work best as ring lights"),
systemImage: "lightbulb.fill"
)
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Design.Spacing.large)
Spacer()
}
.background(Color.Surface.overlay)
.navigationTitle(String(localized: "Custom Color"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(String(localized: "Cancel")) {
onCancel()
}
.foregroundStyle(.white)
}
ToolbarItem(placement: .topBarTrailing) {
Button(String(localized: "Apply")) {
onApply()
}
.foregroundStyle(Color.Accent.primary)
.bold()
}
}
}
}
}
#Preview {
SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false))
.preferredColorScheme(.dark)

View File

@ -87,12 +87,27 @@ final class SettingsViewModel: RingLightConfigurable {
/// Manages iCloud sync for settings across all devices
private let cloudSync = CloudSyncManager<SyncedSettings>()
/// Debounce task for slider values
private var debounceTask: Task<Void, Never>?
/// Debounce delay for continuous slider updates (in seconds)
private static let debounceDelay: Duration = .milliseconds(300)
/// Cached ring size for immediate UI updates (before debounced save)
private var _cachedRingSize: CGFloat?
// MARK: - Observable Properties (Synced)
/// Ring border size in points
/// Ring border size in points (debounced save)
var ringSize: CGFloat {
get { cloudSync.data.ringSize }
set { updateSettings { $0.ringSize = newValue } }
get { _cachedRingSize ?? cloudSync.data.ringSize }
set {
_cachedRingSize = newValue
debouncedSave(key: "ringSize") {
self._cachedRingSize = nil
self.updateSettings { $0.ringSize = newValue }
}
}
}
/// ID of the selected light color preset
@ -101,21 +116,28 @@ final class SettingsViewModel: RingLightConfigurable {
set { updateSettings { $0.lightColorId = newValue } }
}
/// Custom color for ring light (premium feature)
/// Cached custom color for immediate UI updates
private var _cachedCustomColor: Color?
/// Custom color for ring light (premium feature, debounced save)
var customColor: Color {
get {
Color(
_cachedCustomColor ?? Color(
red: cloudSync.data.customColorRed,
green: cloudSync.data.customColorGreen,
blue: cloudSync.data.customColorBlue
)
}
set {
_cachedCustomColor = newValue
let rgb = CustomColorRGB(from: newValue)
updateSettings {
$0.customColorRed = rgb.red
$0.customColorGreen = rgb.green
$0.customColorBlue = rgb.blue
debouncedSave(key: "customColor") {
self._cachedCustomColor = nil
self.updateSettings {
$0.customColorRed = rgb.red
$0.customColorGreen = rgb.green
$0.customColorBlue = rgb.blue
}
}
}
}
@ -227,7 +249,7 @@ final class SettingsViewModel: RingLightConfigurable {
// MARK: - Private Methods
/// Updates settings and saves to cloud
/// Updates settings and saves to cloud immediately
private func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
cloudSync.update { settings in
transform(&settings)
@ -235,6 +257,21 @@ final class SettingsViewModel: RingLightConfigurable {
}
}
/// Debounces save operations for continuous values like sliders
private func debouncedSave(key: String, action: @escaping () -> Void) {
// Cancel any pending debounce
debounceTask?.cancel()
// Schedule debounced save
debounceTask = Task {
try? await Task.sleep(for: Self.debounceDelay)
guard !Task.isCancelled else { return }
action()
}
}
// MARK: - Sync Actions
/// Forces a sync with iCloud

View File

@ -1,10 +1,6 @@
{
"sourceLanguage" : "en",
"strings" : {
"%lld percent" : {
"comment" : "The value of the slider is shown as a percentage.",
"isCommentAutoGenerated" : true
},
"%lld points" : {
"comment" : "The value of the ring size slider, displayed in parentheses.",
"isCommentAutoGenerated" : true
@ -29,10 +25,6 @@
"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
@ -53,6 +45,10 @@
"comment" : "Accessibility hint for the \"Skin Smoothing\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
},
"Apply" : {
"comment" : "The text for a button that applies the selected color.",
"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
@ -105,6 +101,18 @@
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Custom" : {
"comment" : "A label displayed below the rainbow gradient circle in the custom color button.",
"isCommentAutoGenerated" : true
},
"Custom color" : {
"comment" : "An accessibility label for the custom color button.",
"isCommentAutoGenerated" : true
},
"Custom Color" : {
"comment" : "The title of a sheet where a user can select a custom color.",
"isCommentAutoGenerated" : true
},
"Debug mode: Purchase simulated!" : {
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
"isCommentAutoGenerated" : true
@ -152,12 +160,8 @@
"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.",
"Lighter colors work best as ring lights" : {
"comment" : "A tip explaining that lighter colors are better for ring lights.",
"isCommentAutoGenerated" : true
},
"No Watermarks • Ad-Free" : {
@ -176,6 +180,14 @@
"comment" : "A button label that opens the device settings when tapped.",
"isCommentAutoGenerated" : true
},
"Opens color picker. Premium feature." : {
"comment" : "An accessibility hint for the custom color button, describing its function.",
"isCommentAutoGenerated" : true
},
"Opens upgrade options" : {
"comment" : "An accessibility hint for the \"Upgrade to Pro\" button that indicates it opens upgrade options.",
"isCommentAutoGenerated" : true
},
"Photo" : {
},
@ -191,10 +203,6 @@
"comment" : "An accessibility hint for a premium color option in the color preset button.",
"isCommentAutoGenerated" : true
},
"Pro unlocked" : {
"comment" : "An accessibility label for the \"crown.fill\" system icon when premium is unlocked.",
"isCommentAutoGenerated" : true
},
"Purchase successful! Pro features unlocked." : {
"comment" : "Announcement read out to the user when a premium purchase is successful.",
"isCommentAutoGenerated" : true
@ -231,6 +239,10 @@
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
"isCommentAutoGenerated" : true
},
"Select Color" : {
"comment" : "A label for the color picker in the custom color picker sheet.",
"isCommentAutoGenerated" : true
},
"Select self-timer duration" : {
"comment" : "A label describing the segmented control for selecting the duration of the self-timer.",
"isCommentAutoGenerated" : true
@ -337,6 +349,10 @@
"comment" : "A teaser text that appears below the capture edit view, promoting a premium feature.",
"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
},
"Upgrade to Pro" : {
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
"isCommentAutoGenerated" : true