Add settings components: SettingsCard, SettingsNavigationRow, SettingsSlider, titleAccessory for SettingsToggle, and update SETTINGS_GUIDE with debug section pattern

This commit is contained in:
Matt Bruce 2026-01-04 17:50:26 -06:00
parent cf46b9f1f4
commit 95a20ac5a3
5 changed files with 909 additions and 67 deletions

View File

@ -154,35 +154,7 @@ typealias AppInteractive = MyAppInteractiveColors
---
## Step 2: Create a Settings Card Container
Add a reusable card container for grouping related settings:
```swift
/// A card container that provides visual grouping for settings sections.
struct SettingsCard<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
content
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(AppSurface.card)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
)
}
}
```
---
## Step 3: Build Your Settings View
## Step 2: Build Your Settings View
Use Bedrock's components with your theme colors:
@ -206,7 +178,7 @@ struct SettingsView: View {
accentColor: AppAccent.primary
)
SettingsCard {
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsToggle(
title: "Dark Mode",
subtitle: "Use dark appearance",
@ -229,7 +201,7 @@ struct SettingsView: View {
accentColor: AppAccent.primary
)
SettingsCard {
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsToggle(
title: "Push Notifications",
subtitle: "Receive alerts and updates",
@ -260,30 +232,265 @@ struct SettingsView: View {
## Available Components
### SettingsCard
A card container for visually grouping related settings.
```swift
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsToggle(...)
SettingsSlider(...)
}
```
**Refactoring**: Replace inline card styling with `SettingsCard`:
```swift
// ❌ BEFORE: Inline card styling
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
content
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(AppSurface.card)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
)
// ✅ AFTER: Use Bedrock component
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
content
}
```
---
### SettingsSectionHeader
A section header with optional icon and accent color.
```swift
SettingsSectionHeader(
title: "Account",
systemImage: "person.circle",
accentColor: Color.Accent.primary
accentColor: AppAccent.primary
)
```
---
### SettingsToggle
A toggle row with title and subtitle.
A toggle row with title, subtitle, and optional title accessory (e.g., premium crown).
```swift
// Basic toggle
SettingsToggle(
title: "Sound Effects",
subtitle: "Play sounds for events",
isOn: $viewModel.soundEnabled,
accentColor: Color.Accent.primary
accentColor: AppAccent.primary
)
// Premium toggle with crown icon
SettingsToggle(
title: "Flash Sync",
subtitle: "Use ring light color for flash",
isOn: $viewModel.flashSync,
accentColor: AppAccent.primary,
titleAccessory: {
Image(systemName: "crown.fill")
.foregroundStyle(AppStatus.warning)
}
)
```
**Refactoring**: Replace inline premium toggles with `titleAccessory`:
```swift
// ❌ BEFORE: Custom premium toggle
Toggle(isOn: $isOn) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(AppStatus.warning)
}
Text(subtitle)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.tint(AppAccent.primary)
// ✅ AFTER: Use titleAccessory parameter
SettingsToggle(
title: title,
subtitle: subtitle,
isOn: $isOn,
accentColor: AppAccent.primary,
titleAccessory: {
Image(systemName: "crown.fill")
.foregroundStyle(AppStatus.warning)
}
)
```
---
### SettingsSlider
A slider with title, subtitle, value display, and optional icons.
```swift
// Basic slider with custom format
SettingsSlider(
title: "Ring Size",
subtitle: "Adjusts the size of the ring",
value: $viewModel.ringSize,
in: 20...100,
step: 5,
format: SliderFormat.integer(unit: "pt"),
accentColor: AppAccent.primary,
leadingIcon: Image(systemName: "circle"),
trailingIcon: Image(systemName: "circle")
)
// Percentage slider
SettingsSlider(
title: "Brightness",
subtitle: "Adjusts the brightness",
value: $viewModel.brightness,
in: 0.1...1.0,
step: 0.05,
format: SliderFormat.percentage,
accentColor: AppAccent.primary,
leadingIcon: Image(systemName: "sun.min"),
trailingIcon: Image(systemName: "sun.max.fill")
)
```
**Format Helpers**:
- `SliderFormat.percentage` - Shows value as percentage (0.5 → "50%")
- `SliderFormat.integer(unit: "pt")` - Shows integer with unit (40 → "40pt")
- `SliderFormat.decimal(precision: 1, unit: "x")` - Shows decimal (1.5 → "1.5x")
- Custom closure: `{ "\(Int($0))°" }` - Any custom format
**Refactoring**: Replace inline slider implementations:
```swift
// ❌ BEFORE: Inline slider with manual layout
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text("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))
}
Text("Adjusts the size of the ring")
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "circle")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Slider(value: $viewModel.ringSize, in: 20...100, step: 5)
.tint(AppAccent.primary)
Image(systemName: "circle")
.font(.system(size: Design.BaseFontSize.large))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.padding(.vertical, Design.Spacing.xSmall)
// ✅ AFTER: Use Bedrock component
SettingsSlider(
title: "Ring Size",
subtitle: "Adjusts the size of the ring",
value: $viewModel.ringSize,
in: 20...100,
step: 5,
format: SliderFormat.integer(unit: "pt"),
accentColor: AppAccent.primary,
leadingIcon: Image(systemName: "circle"),
trailingIcon: Image(systemName: "circle")
)
```
---
### SettingsNavigationRow
A navigation link row for settings that navigate to detail views.
```swift
SettingsNavigationRow(
title: "Open Source Licenses",
subtitle: "Third-party libraries used in this app",
backgroundColor: AppSurface.primary
) {
LicensesView()
}
```
**Refactoring**: Replace inline NavigationLink styling:
```swift
// ❌ BEFORE: Inline NavigationLink with custom styling
NavigationLink {
LicensesView()
} label: {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Open Source Licenses")
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
Text("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(AppSurface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
}
.buttonStyle(.plain)
// ✅ AFTER: Use Bedrock component
SettingsNavigationRow(
title: "Open Source Licenses",
subtitle: "Third-party libraries used in this app",
backgroundColor: AppSurface.primary
) {
LicensesView()
}
```
---
### SegmentedPicker
A horizontal capsule-style picker.
```swift
@ -291,11 +498,30 @@ SegmentedPicker(
title: "Quality",
options: [("Low", 0), ("Medium", 1), ("High", 2)],
selection: $viewModel.quality,
accentColor: Color.Accent.primary
accentColor: AppAccent.primary
)
```
---
### SettingsRow
An action row with icon and chevron (for non-navigation actions).
```swift
SettingsRow(
systemImage: "star.fill",
title: "Rate App",
iconColor: AppStatus.warning
) {
openAppStore()
}
```
---
### SelectableRow
A card-like row for option selection.
```swift
@ -303,50 +529,207 @@ SelectableRow(
title: "Premium",
subtitle: "Unlock all features",
isSelected: plan == .premium,
accentColor: Color.Accent.primary,
accentColor: AppAccent.primary,
badge: { BadgePill(text: "$9.99") }
) {
plan = .premium
}
```
---
### VolumePicker
A slider with speaker icons for volume/percentage values.
```swift
VolumePicker(
label: "Volume",
volume: $viewModel.volume,
accentColor: Color.Accent.primary
accentColor: AppAccent.primary
)
```
### SettingsRow
A navigation-style row with icon and chevron.
```swift
SettingsRow(
systemImage: "star.fill",
title: "Rate App",
iconColor: Color.Status.warning
) {
openAppStore()
}
```
---
### BadgePill
A capsule badge for values or tags.
```swift
BadgePill(
text: "$4.99",
isSelected: isCurrentPlan,
accentColor: Color.Accent.primary
accentColor: AppAccent.primary
)
```
---
## Debug Section Pattern
Add a debug section to settings for developer tools. This section is only visible in DEBUG builds.
### Standard Debug Section Structure
```swift
// In your SettingsView body, after other sections:
// MARK: - Debug Section
#if DEBUG
SettingsSectionHeader(
title: "Debug",
systemImage: "ant.fill",
accentColor: AppStatus.error // Red accent for debug
)
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
debugSection
}
#endif
```
### Common Debug Section Content
Create a debug section view with common developer tools:
```swift
#if DEBUG
private var debugSection: some View {
VStack(spacing: Design.Spacing.small) {
// Debug Premium Toggle - unlock features for testing
SettingsToggle(
title: "Enable Debug Premium",
subtitle: "Unlock all premium features for testing",
isOn: $viewModel.isDebugPremiumEnabled,
accentColor: AppStatus.warning
)
// Icon Generator - generate app icons
SettingsNavigationRow(
title: "Icon Generator",
subtitle: "Generate and save app icon to Files",
backgroundColor: AppSurface.primary
) {
IconGeneratorView(config: .myApp, appName: "MyApp")
}
// Branding Preview - preview icon and launch screen
SettingsNavigationRow(
title: "Branding Preview",
subtitle: "Preview app icon and launch screen",
backgroundColor: AppSurface.primary
) {
BrandingPreviewView(
iconConfig: .myApp,
launchConfig: .myApp,
appName: "MyApp"
)
}
}
}
#endif
```
### Available Bedrock Debug/Branding Views
| View | Purpose | Parameters |
|------|---------|------------|
| `IconGeneratorView` | Generate and save app icons | `config`, `appName` |
| `BrandingPreviewView` | Preview icon and launch screen | `iconConfig`, `launchConfig`, `appName` |
| `AppIconView` | Render app icon | `config` |
| `LaunchScreenView` | Render launch screen | `config` |
### ViewModel Support for Debug Premium
Add a debug premium property to your SettingsViewModel:
```swift
#if DEBUG
/// Debug-only: Simulates premium being unlocked for testing
var isDebugPremiumEnabled: Bool {
get { UserDefaults.standard.bool(forKey: "debugPremiumEnabled") }
set { UserDefaults.standard.set(newValue, forKey: "debugPremiumEnabled") }
}
#endif
/// Whether premium features are unlocked
var isPremiumUnlocked: Bool {
#if DEBUG
if isDebugPremiumEnabled { return true }
#endif
return premiumManager.isPremiumUnlocked
}
```
### Refactoring: Extract Inline Debug Sections
```swift
// ❌ BEFORE: Inline debug content
#if DEBUG
VStack(spacing: Design.Spacing.small) {
Toggle(isOn: $viewModel.isDebugPremiumEnabled) {
VStack(alignment: .leading) {
Text("Enable Debug Premium")
Text("Unlock all premium features for testing")
}
}
NavigationLink {
IconGeneratorView(config: .myApp, appName: "MyApp")
} label: {
HStack {
Text("Icon Generator")
Spacer()
Image(systemName: "chevron.right")
}
}
}
#endif
// ✅ AFTER: Use Bedrock components
#if DEBUG
SettingsSectionHeader(title: "Debug", systemImage: "ant.fill", accentColor: AppStatus.error)
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsToggle(
title: "Enable Debug Premium",
subtitle: "Unlock all premium features for testing",
isOn: $viewModel.isDebugPremiumEnabled,
accentColor: AppStatus.warning
)
SettingsNavigationRow(
title: "Icon Generator",
subtitle: "Generate and save app icon to Files",
backgroundColor: AppSurface.primary
) {
IconGeneratorView(config: .myApp, appName: "MyApp")
}
}
#endif
```
---
## Component Summary Table
| Component | Use Case | Key Parameters |
|-----------|----------|----------------|
| `SettingsCard` | Group related settings | `backgroundColor`, `borderColor` |
| `SettingsSectionHeader` | Section titles | `title`, `systemImage`, `accentColor` |
| `SettingsToggle` | Boolean settings | `title`, `subtitle`, `isOn`, `titleAccessory` |
| `SettingsSlider` | Numeric settings | `value`, `range`, `format`, icons |
| `SettingsNavigationRow` | Navigate to detail | `title`, `subtitle`, `destination` |
| `SegmentedPicker` | Option selection | `title`, `options`, `selection` |
| `SettingsRow` | Action rows | `systemImage`, `title`, `action` |
| `SelectableRow` | Card selection | `title`, `isSelected`, `badge` |
| `VolumePicker` | Audio volume | `label`, `volume` |
| `BadgePill` | Price/tag badges | `text`, `isSelected` |
---
## Color Relationship Guide
| Surface Level | Use Case | Visual Depth |
@ -380,6 +763,10 @@ BadgePill(
6. **Avoid `Color.` typealiases**: Use `App`-prefixed typealiases to prevent conflicts with Bedrock's defaults.
7. **Use `titleAccessory` for badges**: Instead of creating custom toggle views, use the `titleAccessory` parameter to add crown icons, badges, etc.
8. **Prefer Bedrock components**: Before writing custom UI, check if a Bedrock component exists. This ensures consistency and reduces code duplication.
---
## Example Apps

View File

@ -0,0 +1,86 @@
//
// SettingsCard.swift
// Bedrock
//
// A card container for grouping settings sections.
//
import SwiftUI
/// A card container that provides visual grouping for settings sections.
///
/// Use this to visually separate groups of related settings controls.
///
/// ```swift
/// SettingsCard {
/// SettingsToggle(title: "Dark Mode", ...)
/// SettingsToggle(title: "Notifications", ...)
/// }
/// ```
public struct SettingsCard<Content: View>: View {
/// The content views inside the card.
@ViewBuilder public let content: Content
/// The card background color.
public let backgroundColor: Color
/// The card border color.
public let borderColor: Color
/// Creates a settings card.
/// - Parameters:
/// - backgroundColor: The card background (default: Surface.card).
/// - borderColor: The card border (default: Border.subtle).
/// - content: The content views.
public init(
backgroundColor: Color = .Surface.card,
borderColor: Color = .Border.subtle,
@ViewBuilder content: () -> Content
) {
self.backgroundColor = backgroundColor
self.borderColor = borderColor
self.content = content()
}
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
content
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(backgroundColor)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.thin)
)
}
}
// MARK: - Preview
#Preview {
VStack(spacing: Design.Spacing.medium) {
SettingsCard {
SettingsToggle(
title: "Dark Mode",
subtitle: "Use dark appearance",
isOn: .constant(true)
)
SettingsToggle(
title: "Notifications",
subtitle: "Receive push notifications",
isOn: .constant(false)
)
}
SettingsCard(backgroundColor: .Surface.overlay) {
Text("Custom background")
.foregroundStyle(.white)
}
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,112 @@
//
// SettingsNavigationRow.swift
// Bedrock
//
// A navigation link row for settings screens.
//
import SwiftUI
/// A navigation link row for settings screens.
///
/// Use this for settings that navigate to a detail view.
///
/// ```swift
/// SettingsNavigationRow(
/// title: "Open Source Licenses",
/// subtitle: "Third-party libraries used in this app"
/// ) {
/// LicensesView()
/// }
/// ```
public struct SettingsNavigationRow<Destination: View>: View {
/// The row title.
public let title: String
/// Optional subtitle text.
public let subtitle: String?
/// The background color.
public let backgroundColor: Color
/// The destination view.
public let destination: Destination
/// Creates a settings navigation row.
/// - Parameters:
/// - title: The row title.
/// - subtitle: Optional subtitle text.
/// - backgroundColor: The background color (default: Surface.primary).
/// - destination: The destination view.
public init(
title: String,
subtitle: String? = nil,
backgroundColor: Color = .Surface.primary,
@ViewBuilder destination: () -> Destination
) {
self.title = title
self.subtitle = subtitle
self.backgroundColor = backgroundColor
self.destination = destination()
}
public var body: some View {
NavigationLink {
destination
} label: {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
if let subtitle {
Text(subtitle)
.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(backgroundColor, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
}
.buttonStyle(.plain)
}
}
// MARK: - Preview
#Preview {
NavigationStack {
VStack(spacing: Design.Spacing.small) {
SettingsNavigationRow(
title: "Open Source Licenses",
subtitle: "Third-party libraries used in this app"
) {
Text("Licenses Detail View")
}
SettingsNavigationRow(
title: "Privacy Policy"
) {
Text("Privacy Policy View")
}
SettingsNavigationRow(
title: "Custom Background",
subtitle: "With overlay color",
backgroundColor: .Surface.overlay
) {
Text("Detail View")
}
}
.padding()
.background(Color.Surface.overlay)
}
}

View File

@ -0,0 +1,206 @@
//
// SettingsSlider.swift
// Bedrock
//
// A slider setting with title, description, and value display.
//
import SwiftUI
/// A slider setting with title, description, and value display.
///
/// Use this for numeric settings with a slider control, following the pattern:
/// - Title with current value on the right
/// - Subtitle/description underneath the title
/// - Slider with optional icons underneath
///
/// ## Basic Usage
///
/// ```swift
/// SettingsSlider(
/// title: "Ring Size",
/// subtitle: "Adjusts the size of the light ring",
/// value: $settings.ringSize,
/// in: 20...100,
/// step: 5,
/// format: { "\(Int($0))pt" }
/// )
/// ```
///
/// ## With Icons
///
/// ```swift
/// SettingsSlider(
/// title: "Brightness",
/// subtitle: "Adjusts the brightness",
/// value: $brightness,
/// format: .percentage,
/// leadingIcon: Image(systemName: "sun.min"),
/// trailingIcon: Image(systemName: "sun.max.fill")
/// )
/// ```
public struct SettingsSlider<Value: BinaryFloatingPoint & Sendable>: View where Value.Stride: BinaryFloatingPoint {
// MARK: - Properties
/// The main title text.
public let title: String
/// The subtitle/description text.
public let subtitle: String
/// Binding to the slider value.
@Binding public var value: Value
/// The range of the slider.
public let range: ClosedRange<Value>
/// The step increment for the slider.
public let step: Value.Stride
/// A closure that formats the value for display.
public let format: (Value) -> String
/// The accent color for the slider.
public let accentColor: Color
/// Optional leading icon for the slider.
public let leadingIcon: Image?
/// Optional trailing icon for the slider.
public let trailingIcon: Image?
// MARK: - Initializer
/// Creates a settings slider.
/// - Parameters:
/// - title: The main title.
/// - subtitle: The subtitle description.
/// - value: Binding to slider value.
/// - range: The range of values (default: 0...1).
/// - step: The step increment (default: 0.1).
/// - format: Closure to format the value display.
/// - accentColor: The accent color (default: primary accent).
/// - leadingIcon: Optional icon on the left side of slider.
/// - trailingIcon: Optional icon on the right side of slider.
public init(
title: String,
subtitle: String,
value: Binding<Value>,
in range: ClosedRange<Value> = 0...1,
step: Value.Stride = 0.1,
format: @escaping (Value) -> String,
accentColor: Color = .Accent.primary,
leadingIcon: Image? = nil,
trailingIcon: Image? = nil
) {
self.title = title
self.subtitle = subtitle
self._value = value
self.range = range
self.step = step
self.format = format
self.accentColor = accentColor
self.leadingIcon = leadingIcon
self.trailingIcon = trailingIcon
}
// MARK: - Body
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text(format(value))
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Text(subtitle)
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
HStack(spacing: Design.Spacing.medium) {
if let leadingIcon {
leadingIcon
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Slider(value: $value, in: range, step: step)
.tint(accentColor)
if let trailingIcon {
trailingIcon
.font(.system(size: Design.BaseFontSize.large))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Format Helpers
/// Common format closures for SettingsSlider.
public enum SliderFormat {
/// Formats value as percentage (0.5 "50%").
public static func percentage<V: BinaryFloatingPoint>(_ value: V) -> String {
"\(Int(Double(value) * 100))%"
}
/// Formats value as integer with unit suffix (40 "40pt").
public static func integer<V: BinaryFloatingPoint>(unit: String) -> (V) -> String {
{ "\(Int($0))\(unit)" }
}
/// Formats value as decimal with specified precision.
public static func decimal<V: BinaryFloatingPoint>(precision: Int, unit: String = "") -> (V) -> String {
{ String(format: "%.\(precision)f\(unit)", Double($0)) }
}
}
// MARK: - Preview
#Preview {
VStack(spacing: Design.Spacing.large) {
SettingsSlider(
title: "Ring Size",
subtitle: "Adjusts the size of the light ring around the camera preview",
value: .constant(40.0),
in: 20...100,
step: 5,
format: SliderFormat.integer(unit: "pt"),
leadingIcon: Image(systemName: "circle"),
trailingIcon: Image(systemName: "circle")
)
SettingsSlider(
title: "Brightness",
subtitle: "Adjusts the brightness of the ring light",
value: .constant(0.75),
step: 0.05,
format: SliderFormat.percentage,
leadingIcon: Image(systemName: "sun.min"),
trailingIcon: Image(systemName: "sun.max.fill")
)
SettingsSlider(
title: "Zoom",
subtitle: "Camera zoom level",
value: .constant(1.5),
in: 1.0...5.0,
step: 0.1,
format: SliderFormat.decimal(precision: 1, unit: "x")
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -12,13 +12,23 @@ import SwiftUI
/// Use this for boolean settings that can be turned on or off.
///
/// ```swift
/// // Basic toggle
/// SettingsToggle(
/// title: "Dark Mode",
/// subtitle: "Use dark appearance",
/// isOn: $settings.darkMode
/// )
///
/// // Premium toggle with crown icon
/// SettingsToggle(
/// title: "Flash Sync",
/// subtitle: "Use ring light color for flash",
/// isOn: $settings.flashSync,
/// titleAccessory: Image(systemName: "crown.fill")
/// .foregroundStyle(.orange)
/// )
/// ```
public struct SettingsToggle: View {
public struct SettingsToggle<Accessory: View>: View {
/// The main title text.
public let title: String
@ -31,12 +41,58 @@ public struct SettingsToggle: View {
/// The accent color for the toggle.
public let accentColor: Color
/// Optional accessory view shown after the title (e.g., crown icon for premium).
public let titleAccessory: Accessory?
/// Creates a settings toggle.
/// - Parameters:
/// - title: The main title.
/// - subtitle: The subtitle description.
/// - isOn: Binding to toggle state.
/// - accentColor: The accent color (default: primary accent).
/// - titleAccessory: Optional view after title (e.g., premium crown).
public init(
title: String,
subtitle: String,
isOn: Binding<Bool>,
accentColor: Color = .Accent.primary,
@ViewBuilder titleAccessory: () -> Accessory? = { nil as EmptyView? }
) {
self.title = title
self.subtitle = subtitle
self._isOn = isOn
self.accentColor = accentColor
self.titleAccessory = titleAccessory()
}
public var body: some View {
Toggle(isOn: $isOn) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
if let titleAccessory {
titleAccessory
.font(.system(size: Design.BaseFontSize.small))
}
}
Text(subtitle)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.tint(accentColor)
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Convenience Initializer
extension SettingsToggle where Accessory == EmptyView {
/// Creates a settings toggle without a title accessory.
public init(
title: String,
subtitle: String,
@ -47,22 +103,7 @@ public struct SettingsToggle: View {
self.subtitle = subtitle
self._isOn = isOn
self.accentColor = accentColor
}
public var body: some View {
Toggle(isOn: $isOn) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.tint(accentColor)
.padding(.vertical, Design.Spacing.xSmall)
self.titleAccessory = nil
}
}
@ -81,6 +122,16 @@ public struct SettingsToggle: View {
subtitle: "Receive push notifications",
isOn: .constant(false)
)
SettingsToggle(
title: "Flash Sync",
subtitle: "Use ring light color for screen flash",
isOn: .constant(true),
titleAccessory: {
Image(systemName: "crown.fill")
.foregroundStyle(.orange)
}
)
}
.padding()
.background(Color.Surface.overlay)