SelfieCam/SelfieCam/Features/Settings/SettingsView.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)
}