From 9066635a4d1e53e0f02426b190d28546e1954c35 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 2 Jan 2026 15:43:01 -0600 Subject: [PATCH] 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 --- .../Features/Camera/ContentView.swift | 4 +- .../Features/Settings/SettingsView.swift | 185 +++++------------- .../Features/Settings/SettingsViewModel.swift | 57 +++++- .../Resources/Localizable.xcstrings | 52 +++-- 4 files changed, 129 insertions(+), 169 deletions(-) diff --git a/SelfieRingLight/Features/Camera/ContentView.swift b/SelfieRingLight/Features/Camera/ContentView.swift index d067c9b..fd2e4b7 100644 --- a/SelfieRingLight/Features/Camera/ContentView.swift +++ b/SelfieRingLight/Features/Camera/ContentView.swift @@ -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 { diff --git a/SelfieRingLight/Features/Settings/SettingsView.swift b/SelfieRingLight/Features/Settings/SettingsView.swift index 7de9507..461f5e4 100644 --- a/SelfieRingLight/Features/Settings/SettingsView.swift +++ b/SelfieRingLight/Features/Settings/SettingsView.swift @@ -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) diff --git a/SelfieRingLight/Features/Settings/SettingsViewModel.swift b/SelfieRingLight/Features/Settings/SettingsViewModel.swift index 1add39b..62cfc61 100644 --- a/SelfieRingLight/Features/Settings/SettingsViewModel.swift +++ b/SelfieRingLight/Features/Settings/SettingsViewModel.swift @@ -87,12 +87,27 @@ final class SettingsViewModel: RingLightConfigurable { /// Manages iCloud sync for settings across all devices private let cloudSync = CloudSyncManager() + /// Debounce task for slider values + private var debounceTask: Task? + + /// 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 diff --git a/SelfieRingLight/Resources/Localizable.xcstrings b/SelfieRingLight/Resources/Localizable.xcstrings index 1b4c36b..f47d769 100644 --- a/SelfieRingLight/Resources/Localizable.xcstrings +++ b/SelfieRingLight/Resources/Localizable.xcstrings @@ -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