From 95a20ac5a34df771973891076978e68d1c3ecfb5 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 4 Jan 2026 17:50:26 -0600 Subject: [PATCH] Add settings components: SettingsCard, SettingsNavigationRow, SettingsSlider, titleAccessory for SettingsToggle, and update SETTINGS_GUIDE with debug section pattern --- .../Bedrock/Views/Settings/SETTINGS_GUIDE.md | 487 ++++++++++++++++-- .../Bedrock/Views/Settings/SettingsCard.swift | 86 ++++ .../Settings/SettingsNavigationRow.swift | 112 ++++ .../Views/Settings/SettingsSlider.swift | 206 ++++++++ .../Views/Settings/SettingsToggle.swift | 85 ++- 5 files changed, 909 insertions(+), 67 deletions(-) create mode 100644 Sources/Bedrock/Views/Settings/SettingsCard.swift create mode 100644 Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift create mode 100644 Sources/Bedrock/Views/Settings/SettingsSlider.swift diff --git a/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md b/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md index aee238c..267efd7 100644 --- a/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md +++ b/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md @@ -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: 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 diff --git a/Sources/Bedrock/Views/Settings/SettingsCard.swift b/Sources/Bedrock/Views/Settings/SettingsCard.swift new file mode 100644 index 0000000..b7431ea --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsCard.swift @@ -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: 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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift b/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift new file mode 100644 index 0000000..a52700c --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift @@ -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: 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) + } +} diff --git a/Sources/Bedrock/Views/Settings/SettingsSlider.swift b/Sources/Bedrock/Views/Settings/SettingsSlider.swift new file mode 100644 index 0000000..e40d36a --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsSlider.swift @@ -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: 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 + + /// 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, + in range: ClosedRange = 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(_ value: V) -> String { + "\(Int(Double(value) * 100))%" + } + + /// Formats value as integer with unit suffix (40 → "40pt"). + public static func integer(unit: String) -> (V) -> String { + { "\(Int($0))\(unit)" } + } + + /// Formats value as decimal with specified precision. + public static func decimal(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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsToggle.swift b/Sources/Bedrock/Views/Settings/SettingsToggle.swift index 0662565..6ef55c4 100644 --- a/Sources/Bedrock/Views/Settings/SettingsToggle.swift +++ b/Sources/Bedrock/Views/Settings/SettingsToggle.swift @@ -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: 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, + 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)