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:
parent
95377c5950
commit
9066635a4d
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user