Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-04 11:38:40 -06:00
parent eedd709ef5
commit cf95c4e816
11 changed files with 536 additions and 463 deletions

View File

@ -0,0 +1,71 @@
//
// ColorPickerOverlay.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
import Bedrock
// MARK: - Color Picker Overlay
struct ColorPickerOverlay: View {
@Binding var selectedColor: Color
@Binding var isPresented: Bool
private let colors: [Color] = [
.white, .red, .orange, .yellow, .green, .blue, .purple, .pink,
.gray, .black, Color(red: 1.0, green: 0.5, blue: 0.0), // Coral
Color(red: 0.5, green: 1.0, blue: 0.5), // Mint
Color(red: 0.5, green: 0.5, blue: 1.0), // Periwinkle
Color(red: 1.0, green: 0.5, blue: 1.0), // Magenta
]
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(Design.Opacity.medium)
.ignoresSafeArea()
// Color picker content
VStack(spacing: Design.Spacing.medium) {
// Header
Text("Ring Light Color")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
// Color grid
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: Design.Spacing.small) {
ForEach(colors, id: \.self) { color in
Circle()
.fill(color)
.frame(width: 50, height: 50)
.overlay(
Circle()
.stroke(Color.white.opacity(selectedColor == color ? 1.0 : 0.3), lineWidth: selectedColor == color ? 3 : 1)
)
.onTapGesture {
selectedColor = color
isPresented = false
}
}
}
// Done button
Button("Done") {
isPresented = false
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.white)
.padding(.vertical, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.black.opacity(Design.Opacity.strong))
)
.padding(.horizontal, Design.Spacing.large)
}
}
}

View File

@ -493,191 +493,3 @@ struct CustomCameraScreen: MCameraScreen {
isControlsExpanded = false // Collapse controls panel
}
}
// MARK: - Color Picker Overlay
struct ColorPickerOverlay: View {
@Binding var selectedColor: Color
@Binding var isPresented: Bool
private let colors: [Color] = [
.white, .red, .orange, .yellow, .green, .blue, .purple, .pink,
.gray, .black, Color(red: 1.0, green: 0.5, blue: 0.0), // Coral
Color(red: 0.5, green: 1.0, blue: 0.5), // Mint
Color(red: 0.5, green: 0.5, blue: 1.0), // Periwinkle
Color(red: 1.0, green: 0.5, blue: 1.0), // Magenta
]
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(Design.Opacity.medium)
.ignoresSafeArea()
// Color picker content
VStack(spacing: Design.Spacing.medium) {
// Header
Text("Ring Light Color")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
// Color grid
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: Design.Spacing.small) {
ForEach(colors, id: \.self) { color in
Circle()
.fill(color)
.frame(width: 50, height: 50)
.overlay(
Circle()
.stroke(Color.white.opacity(selectedColor == color ? 1.0 : 0.3), lineWidth: selectedColor == color ? 3 : 1)
)
.onTapGesture {
selectedColor = color
isPresented = false
}
}
}
// Done button
Button("Done") {
isPresented = false
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.white)
.padding(.vertical, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.black.opacity(Design.Opacity.strong))
)
.padding(.horizontal, Design.Spacing.large)
}
}
}
// MARK: - Opacity Slider Overlay
struct OpacitySliderOverlay: View {
@Binding var selectedOpacity: Double
@Binding var isPresented: Bool
private let minOpacity: Double = 0.1
private let maxOpacity: Double = 1.0
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(Design.Opacity.medium)
.ignoresSafeArea()
// Opacity slider content
VStack(spacing: Design.Spacing.medium) {
// Header
Text("Ring Light Brightness")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
// Current opacity display as percentage
Text("\(Int(selectedOpacity * 100))%")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Color.white)
.frame(width: 80)
// Slider
Slider(value: $selectedOpacity, in: minOpacity...maxOpacity, step: 0.05)
.tint(Color.white)
.padding(.horizontal, Design.Spacing.medium)
// Opacity range labels
HStack {
Text("10%")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
Spacer()
Text("100%")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(.horizontal, Design.Spacing.medium)
// Done button
Button("Done") {
isPresented = false
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.white)
.padding(.vertical, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.black.opacity(Design.Opacity.strong))
)
.padding(.horizontal, Design.Spacing.large)
}
}
}
// MARK: - Size Slider Overlay
struct SizeSliderOverlay: View {
@Binding var selectedSize: CGFloat
@Binding var isPresented: Bool
private let minSize: CGFloat = 50
private let maxSize: CGFloat = 100
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(Design.Opacity.medium)
.ignoresSafeArea()
// Size slider content
VStack(spacing: Design.Spacing.medium) {
// Header
Text("Ring Light Size")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
// Current size display
Text("\(Int(selectedSize))")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Color.white)
.frame(width: 60)
// Slider
Slider(value: $selectedSize, in: minSize...maxSize, step: 5)
.tint(Color.white)
.padding(.horizontal, Design.Spacing.medium)
// Size range labels
HStack {
Text("\(Int(minSize))")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
Spacer()
Text("\(Int(maxSize))")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(.horizontal, Design.Spacing.medium)
// Done button
Button("Done") {
isPresented = false
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.white)
.padding(.vertical, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.black.opacity(Design.Opacity.strong))
)
.padding(.horizontal, Design.Spacing.large)
}
}
}

View File

@ -0,0 +1,72 @@
//
// OpacitySliderOverlay.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
import Bedrock
// MARK: - Opacity Slider Overlay
struct OpacitySliderOverlay: View {
@Binding var selectedOpacity: Double
@Binding var isPresented: Bool
private let minOpacity: Double = 0.1
private let maxOpacity: Double = 1.0
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(Design.Opacity.medium)
.ignoresSafeArea()
// Opacity slider content
VStack(spacing: Design.Spacing.medium) {
// Header
Text("Ring Light Brightness")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
// Current opacity display as percentage
Text("\(Int(selectedOpacity * 100))%")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Color.white)
.frame(width: 80)
// Slider
Slider(value: $selectedOpacity, in: minOpacity...maxOpacity, step: 0.05)
.tint(Color.white)
.padding(.horizontal, Design.Spacing.medium)
// Opacity range labels
HStack {
Text("10%")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
Spacer()
Text("100%")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(.horizontal, Design.Spacing.medium)
// Done button
Button("Done") {
isPresented = false
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.white)
.padding(.vertical, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.black.opacity(Design.Opacity.strong))
)
.padding(.horizontal, Design.Spacing.large)
}
}
}

View File

@ -116,48 +116,3 @@ struct PhotoReviewView: View {
.accessibilityHint("Use the buttons at the bottom to save or share your photo")
}
}
// MARK: - Share Button
struct ShareButton: View {
let photo: UIImage
@State private var isShareSheetPresented = false
var body: some View {
Button(action: {
isShareSheetPresented = true
}) {
ZStack {
Circle()
.fill(Color.black.opacity(0.6))
.frame(width: 80, height: 80)
Image(systemName: "square.and.arrow.up")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
}
.shadow(radius: 5)
}
.sheet(isPresented: $isShareSheetPresented) {
ShareSheet(activityItems: [photo])
}
}
}
// MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: activityItems,
applicationActivities: nil
)
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// No updates needed
}
}

View File

@ -0,0 +1,36 @@
//
// ShareButton.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
import Bedrock
// MARK: - Share Button
struct ShareButton: View {
let photo: UIImage
@State private var isShareSheetPresented = false
var body: some View {
Button(action: {
isShareSheetPresented = true
}) {
ZStack {
Circle()
.fill(Color.black.opacity(0.6))
.frame(width: 80, height: 80)
Image(systemName: "square.and.arrow.up")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
}
.shadow(radius: 5)
}
.sheet(isPresented: $isShareSheetPresented) {
ShareSheet(activityItems: [photo])
}
}
}

View File

@ -0,0 +1,26 @@
//
// ShareSheet.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
// MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: activityItems,
applicationActivities: nil
)
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// No updates needed
}
}

View File

@ -0,0 +1,72 @@
//
// SizeSliderOverlay.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
import Bedrock
// MARK: - Size Slider Overlay
struct SizeSliderOverlay: View {
@Binding var selectedSize: CGFloat
@Binding var isPresented: Bool
private let minSize: CGFloat = 50
private let maxSize: CGFloat = 100
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(Design.Opacity.medium)
.ignoresSafeArea()
// Size slider content
VStack(spacing: Design.Spacing.medium) {
// Header
Text("Ring Light Size")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
// Current size display
Text("\(Int(selectedSize))")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Color.white)
.frame(width: 60)
// Slider
Slider(value: $selectedSize, in: minSize...maxSize, step: 5)
.tint(Color.white)
.padding(.horizontal, Design.Spacing.medium)
// Size range labels
HStack {
Text("\(Int(minSize))")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
Spacer()
Text("\(Int(maxSize))")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(.horizontal, Design.Spacing.medium)
// Done button
Button("Done") {
isPresented = false
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.white)
.padding(.vertical, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.black.opacity(Design.Opacity.strong))
)
.padding(.horizontal, Design.Spacing.large)
}
}
}

View File

@ -0,0 +1,78 @@
//
// ColorPresetButton.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
import Bedrock
// MARK: - Color Preset Button
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") : ""))
}
}

View File

@ -0,0 +1,111 @@
//
// CustomColorPickerButton.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
import Bedrock
// MARK: - Custom Color Picker Button
/// Custom color picker with premium gating
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."))
}
}
}

View File

@ -0,0 +1,70 @@
//
// LicensesView.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
import Bedrock
// 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))
}
}

View File

@ -483,237 +483,7 @@ struct SettingsView: View {
}
}
// 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))