722 lines
29 KiB
Swift
722 lines
29 KiB
Swift
import SwiftUI
|
|
import Bedrock
|
|
import MijickCamera
|
|
|
|
struct SettingsView: View {
|
|
@Bindable var viewModel: SettingsViewModel
|
|
@Binding var showPaywall: Bool
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var premiumManager = PremiumManager()
|
|
|
|
/// Whether premium features are unlocked (for UI gating)
|
|
private var isPremiumUnlocked: Bool {
|
|
premiumManager.isPremiumUnlocked
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: Design.Spacing.medium) {
|
|
|
|
// MARK: - Ring Light Section
|
|
|
|
SettingsSectionHeader(title: "Ring Light", systemImage: "light.max")
|
|
|
|
// Ring Size Slider
|
|
ringSizeSlider
|
|
|
|
// Color Preset
|
|
colorPresetSection
|
|
|
|
// MARK: - Camera Section
|
|
|
|
SettingsSectionHeader(title: "Camera", systemImage: "camera")
|
|
|
|
SettingsToggle(
|
|
title: String(localized: "Front Flash"),
|
|
subtitle: String(localized: "Hides preview during capture for a flash effect"),
|
|
isOn: $viewModel.isFrontFlashEnabled
|
|
)
|
|
.accessibilityHint(String(localized: "Uses the ring light as a flash when taking photos"))
|
|
|
|
SettingsToggle(
|
|
title: String(localized: "True Mirror"),
|
|
subtitle: String(localized: "Shows non-flipped preview like a real mirror"),
|
|
isOn: $viewModel.isMirrorFlipped
|
|
)
|
|
.accessibilityHint(String(localized: "When enabled, the preview is not mirrored"))
|
|
|
|
SettingsToggle(
|
|
title: String(localized: "Skin Smoothing"),
|
|
subtitle: String(localized: "Applies subtle real-time smoothing"),
|
|
isOn: $viewModel.isSkinSmoothingEnabled
|
|
)
|
|
.accessibilityHint(String(localized: "Applies light skin smoothing to the camera preview"))
|
|
|
|
SettingsToggle(
|
|
title: String(localized: "Grid Overlay"),
|
|
subtitle: String(localized: "Shows rule of thirds grid"),
|
|
isOn: $viewModel.isGridVisible
|
|
)
|
|
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
|
|
|
|
// Flash Mode
|
|
flashModePicker
|
|
|
|
// Flash Sync
|
|
SettingsToggle(
|
|
title: String(localized: "Flash Sync"),
|
|
subtitle: String(localized: "Use ring light color for flash"),
|
|
isOn: $viewModel.isFlashSyncedWithRingLight
|
|
)
|
|
.accessibilityHint(String(localized: "Syncs flash color with ring light color"))
|
|
|
|
// HDR Mode
|
|
hdrModePicker
|
|
|
|
// Photo Quality
|
|
photoQualityPicker
|
|
|
|
// Camera Position
|
|
cameraPositionPicker
|
|
|
|
// Ring Light Enabled
|
|
SettingsToggle(
|
|
title: String(localized: "Ring Light Enabled"),
|
|
subtitle: String(localized: "Show ring light around camera"),
|
|
isOn: $viewModel.isRingLightEnabled
|
|
)
|
|
.accessibilityHint(String(localized: "Enables or disables the ring light overlay"))
|
|
|
|
// Ring Light Brightness
|
|
ringLightBrightnessSlider
|
|
|
|
// Timer Selection
|
|
timerPicker
|
|
|
|
// MARK: - Capture Section
|
|
|
|
SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle")
|
|
|
|
SettingsToggle(
|
|
title: String(localized: "Auto-Save"),
|
|
subtitle: String(localized: "Automatically save captures to Photo Library"),
|
|
isOn: $viewModel.isAutoSaveEnabled
|
|
)
|
|
.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")
|
|
|
|
iCloudSyncSection
|
|
|
|
// MARK: - About Section
|
|
|
|
SettingsSectionHeader(title: "About", systemImage: "info.circle")
|
|
|
|
acknowledgmentsSection
|
|
|
|
Spacer(minLength: Design.Spacing.xxxLarge)
|
|
}
|
|
.padding(.horizontal, Design.Spacing.large)
|
|
}
|
|
.background(Color.Surface.overlay)
|
|
.navigationTitle(String(localized: "Settings"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button(String(localized: "Done")) {
|
|
dismiss()
|
|
}
|
|
.foregroundStyle(Color.Accent.primary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Ring Size Slider
|
|
|
|
private var ringSizeSlider: some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
HStack {
|
|
Text(String(localized: "Ring Size"))
|
|
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
|
|
Spacer()
|
|
|
|
Text("\(Int(viewModel.ringSize))pt")
|
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
}
|
|
|
|
HStack(spacing: Design.Spacing.medium) {
|
|
// Small ring icon
|
|
Image(systemName: "circle")
|
|
.font(.system(size: Design.BaseFontSize.small))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
|
|
Slider(
|
|
value: $viewModel.ringSize,
|
|
in: SettingsViewModel.minRingSize...SettingsViewModel.maxRingSize,
|
|
step: 5
|
|
)
|
|
.tint(Color.Accent.primary)
|
|
|
|
// Large ring icon
|
|
Image(systemName: "circle")
|
|
.font(.system(size: Design.BaseFontSize.large))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
}
|
|
|
|
Text(String(localized: "Adjusts the size of the light ring around the camera preview"))
|
|
.font(.system(size: Design.BaseFontSize.caption))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
}
|
|
.padding(.vertical, Design.Spacing.xSmall)
|
|
.accessibilityLabel(String(localized: "Ring size"))
|
|
.accessibilityValue("\(Int(viewModel.ringSize)) points")
|
|
}
|
|
|
|
// MARK: - Color Preset Section
|
|
|
|
private var colorPresetSection: some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
Text(String(localized: "Light Color"))
|
|
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
|
|
LazyVGrid(
|
|
columns: [GridItem(.adaptive(minimum: 80), spacing: Design.Spacing.small)],
|
|
spacing: Design.Spacing.small
|
|
) {
|
|
// Preset colors
|
|
ForEach(RingLightColor.allPresets) { preset in
|
|
ColorPresetButton(
|
|
preset: preset,
|
|
isSelected: viewModel.selectedLightColor == preset,
|
|
isPremiumUnlocked: isPremiumUnlocked
|
|
) {
|
|
// Premium colors require unlock
|
|
if preset.isPremium && !isPremiumUnlocked {
|
|
dismiss()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
showPaywall = true
|
|
}
|
|
} else {
|
|
viewModel.selectedLightColor = preset
|
|
}
|
|
}
|
|
}
|
|
|
|
// Custom color picker (premium) - one-step: opens picker, selects on change
|
|
CustomColorPickerButton(
|
|
customColor: Binding(
|
|
get: { viewModel.customColor },
|
|
set: { viewModel.selectCustomColor($0) }
|
|
),
|
|
isSelected: viewModel.isCustomColorSelected,
|
|
isPremiumUnlocked: isPremiumUnlocked,
|
|
onPremiumRequired: {
|
|
dismiss()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
showPaywall = true
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(.vertical, Design.Spacing.xSmall)
|
|
}
|
|
|
|
// MARK: - Flash Mode Picker
|
|
|
|
private var flashModePicker: some View {
|
|
SegmentedPicker(
|
|
title: String(localized: "Flash Mode"),
|
|
options: CameraFlashMode.allCases.map { ($0.displayName, $0) },
|
|
selection: $viewModel.flashMode
|
|
)
|
|
.accessibilityLabel(String(localized: "Select flash mode"))
|
|
}
|
|
|
|
// MARK: - HDR Mode Picker
|
|
|
|
private var hdrModePicker: some View {
|
|
SegmentedPicker(
|
|
title: String(localized: "HDR Mode"),
|
|
options: CameraHDRMode.allCases.map { ($0.displayName, $0) },
|
|
selection: $viewModel.hdrMode
|
|
)
|
|
.accessibilityLabel(String(localized: "Select HDR mode"))
|
|
}
|
|
|
|
// MARK: - Photo Quality Picker
|
|
|
|
private var photoQualityPicker: some View {
|
|
SegmentedPicker(
|
|
title: String(localized: "Photo Quality"),
|
|
options: PhotoQuality.allCases.map { ($0.rawValue.capitalized, $0) },
|
|
selection: $viewModel.photoQuality
|
|
)
|
|
.accessibilityLabel(String(localized: "Select photo quality"))
|
|
}
|
|
|
|
// MARK: - Camera Position Picker
|
|
|
|
private var cameraPositionPicker: some View {
|
|
SegmentedPicker(
|
|
title: String(localized: "Camera"),
|
|
options: [
|
|
(String(localized: "Front"), CameraPosition.front),
|
|
(String(localized: "Back"), CameraPosition.back)
|
|
],
|
|
selection: $viewModel.cameraPosition
|
|
)
|
|
.accessibilityLabel(String(localized: "Select camera position"))
|
|
}
|
|
|
|
// MARK: - Ring Light Brightness Slider
|
|
|
|
private var ringLightBrightnessSlider: some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
HStack {
|
|
Text(String(localized: "Ring Light Brightness"))
|
|
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
|
|
Spacer()
|
|
|
|
Text("\(Int(viewModel.ringLightOpacity * 100))%")
|
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
}
|
|
|
|
HStack(spacing: Design.Spacing.medium) {
|
|
Image(systemName: "sun.min")
|
|
.font(.system(size: Design.BaseFontSize.small))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
|
|
Slider(
|
|
value: $viewModel.ringLightOpacity,
|
|
in: 0.1...1.0,
|
|
step: 0.05
|
|
)
|
|
.tint(Color.Accent.primary)
|
|
|
|
Image(systemName: "sun.max.fill")
|
|
.font(.system(size: Design.BaseFontSize.large))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
}
|
|
|
|
Text(String(localized: "Adjusts the brightness of the ring light"))
|
|
.font(.system(size: Design.BaseFontSize.caption))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
}
|
|
.padding(.vertical, Design.Spacing.xSmall)
|
|
.accessibilityLabel(String(localized: "Ring light brightness"))
|
|
.accessibilityValue("\(Int(viewModel.ringLightOpacity * 100)) percent")
|
|
}
|
|
|
|
// MARK: - Timer Picker
|
|
|
|
private var timerPicker: some View {
|
|
SegmentedPicker(
|
|
title: String(localized: "Self-Timer"),
|
|
options: TimerOption.allCases.map { ($0.displayName, $0) },
|
|
selection: $viewModel.selectedTimer
|
|
)
|
|
.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 {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
// Sync toggle
|
|
SettingsToggle(
|
|
title: String(localized: "Sync Settings"),
|
|
subtitle: viewModel.iCloudAvailable
|
|
? String(localized: "Sync settings across all your devices")
|
|
: String(localized: "Sign in to iCloud to enable sync"),
|
|
isOn: $viewModel.iCloudEnabled
|
|
)
|
|
.disabled(!viewModel.iCloudAvailable)
|
|
|
|
// Sync status
|
|
if viewModel.iCloudEnabled && viewModel.iCloudAvailable {
|
|
HStack(spacing: Design.Spacing.small) {
|
|
Image(systemName: syncStatusIcon)
|
|
.font(.system(size: Design.BaseFontSize.body))
|
|
.foregroundStyle(syncStatusColor)
|
|
|
|
Text(syncStatusText)
|
|
.font(.system(size: Design.BaseFontSize.caption))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
viewModel.forceSync()
|
|
} label: {
|
|
Text(String(localized: "Sync Now"))
|
|
.font(.system(size: Design.BaseFontSize.caption, weight: .medium))
|
|
.foregroundStyle(Color.Accent.primary)
|
|
}
|
|
}
|
|
.padding(.top, Design.Spacing.xSmall)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Status Helpers
|
|
|
|
private var syncStatusIcon: String {
|
|
if !viewModel.hasCompletedInitialSync {
|
|
return "arrow.triangle.2.circlepath"
|
|
}
|
|
return viewModel.syncStatus.isEmpty ? "checkmark.icloud" : "icloud"
|
|
}
|
|
|
|
private var syncStatusColor: Color {
|
|
if !viewModel.hasCompletedInitialSync {
|
|
return Color.Status.warning
|
|
}
|
|
return Color.Status.success
|
|
}
|
|
|
|
private var syncStatusText: String {
|
|
if !viewModel.hasCompletedInitialSync {
|
|
return String(localized: "Syncing...")
|
|
}
|
|
|
|
if let lastSync = viewModel.lastSyncDate {
|
|
let formatter = RelativeDateTimeFormatter()
|
|
formatter.unitsStyle = .abbreviated
|
|
return String(localized: "Last synced \(formatter.localizedString(for: lastSync, relativeTo: Date()))")
|
|
}
|
|
|
|
return viewModel.syncStatus.isEmpty
|
|
? String(localized: "Synced")
|
|
: viewModel.syncStatus
|
|
}
|
|
|
|
// MARK: - Acknowledgments Section
|
|
|
|
private var acknowledgmentsSection: some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
NavigationLink {
|
|
LicensesView()
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
|
Text(String(localized: "Open Source Licenses"))
|
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
|
|
Text(String(localized: "Third-party libraries used in this app"))
|
|
.font(.system(size: Design.BaseFontSize.caption))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: Design.BaseFontSize.caption))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
}
|
|
.padding(Design.Spacing.medium)
|
|
.background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Licenses View
|
|
|
|
struct LicensesView: View {
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
|
// MijickCamera
|
|
licenseCard(
|
|
name: "MijickCamera",
|
|
url: "https://github.com/Mijick/Camera",
|
|
license: "Apache 2.0 License",
|
|
description: "Camera framework for SwiftUI. Created by Tomasz Kurylik at Mijick."
|
|
)
|
|
|
|
// RevenueCat
|
|
licenseCard(
|
|
name: "RevenueCat",
|
|
url: "https://github.com/RevenueCat/purchases-ios",
|
|
license: "MIT License",
|
|
description: "In-app subscriptions made easy."
|
|
)
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
}
|
|
.background(Color.Surface.overlay)
|
|
.navigationTitle(String(localized: "Open Source Licenses"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
private func licenseCard(name: String, url: String, license: String, description: String) -> some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
Text(name)
|
|
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
|
|
Text(description)
|
|
.font(.system(size: Design.BaseFontSize.caption))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
|
|
|
HStack {
|
|
Label(license, systemImage: "doc.text")
|
|
.font(.system(size: Design.BaseFontSize.xSmall))
|
|
.foregroundStyle(Color.Accent.primary)
|
|
|
|
Spacer()
|
|
|
|
if let linkURL = URL(string: url) {
|
|
Link(destination: linkURL) {
|
|
Label(String(localized: "View on GitHub"), systemImage: "arrow.up.right.square")
|
|
.font(.system(size: Design.BaseFontSize.xSmall))
|
|
.foregroundStyle(Color.Accent.primary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, Design.Spacing.xSmall)
|
|
}
|
|
.padding(Design.Spacing.medium)
|
|
.background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
|
}
|
|
}
|
|
|
|
// MARK: - Color Preset Button
|
|
|
|
private struct ColorPresetButton: View {
|
|
let preset: RingLightColor
|
|
let isSelected: Bool
|
|
let isPremiumUnlocked: Bool
|
|
let action: () -> Void
|
|
|
|
/// Whether this premium color is locked (not available)
|
|
private var isLocked: Bool {
|
|
preset.isPremium && !isPremiumUnlocked
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
VStack(spacing: Design.Spacing.xxSmall) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(preset.color)
|
|
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
|
.overlay(
|
|
Circle()
|
|
.strokeBorder(
|
|
isSelected ? Color.Accent.primary : Color.Border.subtle,
|
|
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
|
|
)
|
|
)
|
|
.shadow(
|
|
color: preset.color.opacity(Design.Opacity.light),
|
|
radius: isSelected ? Design.Shadow.radiusSmall : 0
|
|
)
|
|
|
|
// Lock overlay for locked premium colors
|
|
if isLocked {
|
|
Circle()
|
|
.fill(.black.opacity(Design.Opacity.medium))
|
|
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
|
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: Design.BaseFontSize.small))
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
|
|
Text(preset.name)
|
|
.font(.system(size: Design.BaseFontSize.xSmall))
|
|
.foregroundStyle(.white.opacity(isSelected ? 1.0 : (isLocked ? Design.Opacity.medium : Design.Opacity.accent)))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
|
|
|
if preset.isPremium {
|
|
Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown")
|
|
.font(.system(size: Design.BaseFontSize.xxSmall))
|
|
.foregroundStyle(isPremiumUnlocked ? Color.Status.warning : Color.Status.warning.opacity(Design.Opacity.medium))
|
|
}
|
|
}
|
|
.padding(Design.Spacing.xSmall)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
|
.fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(preset.name)
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
.accessibilityHint(isLocked ? String(localized: "Locked. Tap to unlock with Pro.") : (preset.isPremium ? String(localized: "Premium color") : ""))
|
|
}
|
|
}
|
|
|
|
// MARK: - Custom Color Picker Button
|
|
|
|
/// Custom color picker with premium gating
|
|
private struct CustomColorPickerButton: View {
|
|
@Binding var customColor: Color
|
|
let isSelected: Bool
|
|
let isPremiumUnlocked: Bool
|
|
let onPremiumRequired: () -> Void
|
|
|
|
/// Whether the custom color is locked
|
|
private var isLocked: Bool { !isPremiumUnlocked }
|
|
|
|
var body: some View {
|
|
if isPremiumUnlocked {
|
|
// Premium users get the full color picker
|
|
VStack(spacing: Design.Spacing.xxSmall) {
|
|
ColorPicker(
|
|
selection: $customColor,
|
|
supportsOpacity: false
|
|
) {
|
|
EmptyView()
|
|
}
|
|
.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)
|
|
}
|
|
.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 : [])
|
|
} else {
|
|
// Non-premium users see a locked button that shows paywall
|
|
Button(action: onPremiumRequired) {
|
|
VStack(spacing: Design.Spacing.xxSmall) {
|
|
ZStack {
|
|
// Rainbow gradient to show what's possible
|
|
Circle()
|
|
.fill(
|
|
AngularGradient(
|
|
colors: [.red, .orange, .yellow, .green, .blue, .purple, .red],
|
|
center: .center
|
|
)
|
|
)
|
|
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
|
.overlay(
|
|
Circle()
|
|
.strokeBorder(Color.Border.subtle, lineWidth: Design.LineWidth.thin)
|
|
)
|
|
|
|
// Lock overlay
|
|
Circle()
|
|
.fill(.black.opacity(Design.Opacity.medium))
|
|
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
|
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: Design.BaseFontSize.small))
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
Text(String(localized: "Custom"))
|
|
.font(.system(size: Design.BaseFontSize.xSmall))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
|
|
|
Image(systemName: "crown")
|
|
.font(.system(size: Design.BaseFontSize.xxSmall))
|
|
.foregroundStyle(Color.Status.warning.opacity(Design.Opacity.medium))
|
|
}
|
|
.padding(Design.Spacing.xSmall)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(String(localized: "Custom color"))
|
|
.accessibilityHint(String(localized: "Locked. Tap to unlock with Pro."))
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false))
|
|
.preferredColorScheme(.dark)
|
|
}
|