Split SettingsComponents into individual view files

- SettingsToggle.swift - Toggle row with title/subtitle
- VolumePicker.swift - Volume slider with speaker icons
- SegmentedPicker.swift - Horizontal capsule-style picker
- SelectableRow.swift - Card-like selectable row with badge support
- SelectionIndicator.swift - Checkmark/outline selection indicator
- BadgePill.swift - Capsule-shaped badge for values/tags
- SettingsSectionHeader.swift - Section header with icon
- SettingsRow.swift - Navigation-style row with icon

Each file has its own preview for easier development.
This commit is contained in:
Matt Bruce 2026-01-04 16:58:17 -06:00
parent cc650c68d4
commit cf46b9f1f4
9 changed files with 735 additions and 616 deletions

View File

@ -0,0 +1,61 @@
//
// BadgePill.swift
// Bedrock
//
// A capsule-shaped badge for displaying short text values.
//
import SwiftUI
/// A capsule-shaped badge for displaying short text values.
///
/// Use this to highlight values, tags, or status indicators.
public struct BadgePill: View {
/// The text to display in the badge.
public let text: String
/// Whether the parent row is selected.
public let isSelected: Bool
/// The accent color.
public let accentColor: Color
/// Creates a badge pill.
/// - Parameters:
/// - text: The badge text.
/// - isSelected: Whether the parent row is selected.
/// - accentColor: Color for the badge (default: primary accent).
public init(
text: String,
isSelected: Bool = false,
accentColor: Color = .Accent.primary
) {
self.text = text
self.isSelected = isSelected
self.accentColor = accentColor
}
public var body: some View {
Text(text)
.font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded))
.foregroundStyle(isSelected ? .black : accentColor)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(
Capsule()
.fill(isSelected ? accentColor : accentColor.opacity(Design.Opacity.hint))
)
}
}
// MARK: - Preview
#Preview {
HStack(spacing: Design.Spacing.medium) {
BadgePill(text: "$9.99", isSelected: false)
BadgePill(text: "$9.99", isSelected: true)
BadgePill(text: "PRO", isSelected: false, accentColor: .orange)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,100 @@
//
// SegmentedPicker.swift
// Bedrock
//
// A horizontal segmented picker with capsule-style buttons.
//
import SwiftUI
/// A horizontal segmented picker with capsule-style buttons.
///
/// Use this for selecting from a small number of options (2-4).
///
/// ```swift
/// SegmentedPicker(
/// title: "Theme",
/// options: [("Light", "light"), ("Dark", "dark"), ("System", "system")],
/// selection: $theme
/// )
/// ```
public struct SegmentedPicker<T: Equatable>: View {
/// The title/label for the picker.
public let title: String
/// The available options as (label, value) pairs.
public let options: [(String, T)]
/// Binding to the selected value.
@Binding public var selection: T
/// The accent color for the selected button.
public let accentColor: Color
/// Creates a segmented picker.
/// - Parameters:
/// - title: The title label.
/// - options: Array of (label, value) tuples.
/// - selection: Binding to selected value.
/// - accentColor: The accent color (default: primary accent).
public init(
title: String,
options: [(String, T)],
selection: Binding<T>,
accentColor: Color = .Accent.primary
) {
self.title = title
self.options = options
self._selection = selection
self.accentColor = accentColor
}
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
HStack(spacing: Design.Spacing.small) {
ForEach(options.indices, id: \.self) { index in
let option = options[index]
Button {
selection = option.1
} label: {
Text(option.0)
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(selection == option.1 ? .black : .white.opacity(Design.Opacity.strong))
.padding(.vertical, Design.Spacing.small)
.frame(maxWidth: .infinity)
.background(
Capsule()
.fill(selection == option.1 ? accentColor : Color.white.opacity(Design.Opacity.subtle))
)
}
.buttonStyle(.plain)
}
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Preview
#Preview {
VStack(spacing: Design.Spacing.medium) {
SegmentedPicker(
title: "Animation Speed",
options: [("Fast", "fast"), ("Normal", "normal"), ("Slow", "slow")],
selection: .constant("normal")
)
SegmentedPicker(
title: "Theme",
options: [("Light", 0), ("Dark", 1)],
selection: .constant(1)
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,152 @@
//
// SelectableRow.swift
// Bedrock
//
// A card-like selectable row with title, subtitle, optional badge, and selection indicator.
//
import SwiftUI
/// A card-like selectable row with title, subtitle, optional badge, and selection indicator.
///
/// Use this for settings pickers, option lists, or any selectable item.
///
/// ```swift
/// SelectableRow(
/// title: "Premium",
/// subtitle: "Unlock all features",
/// isSelected: plan == .premium
/// ) {
/// plan = .premium
/// }
/// ```
public struct SelectableRow<Badge: View>: View {
/// The main title text.
public let title: String
/// The subtitle/description text.
public let subtitle: String
/// Whether this row is currently selected.
public let isSelected: Bool
/// Optional badge view.
public let badge: Badge?
/// The accent color for selection highlighting.
public let accentColor: Color
/// Action when tapped.
public let action: () -> Void
/// Creates a selectable row.
/// - Parameters:
/// - title: The main title.
/// - subtitle: The subtitle description.
/// - isSelected: Whether this row is selected.
/// - accentColor: Color for selection (default: primary accent).
/// - badge: Optional badge view.
/// - action: Action when tapped.
public init(
title: String,
subtitle: String,
isSelected: Bool,
accentColor: Color = .Accent.primary,
@ViewBuilder badge: () -> Badge? = { nil as EmptyView? },
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.isSelected = isSelected
self.accentColor = accentColor
self.badge = badge()
self.action = action
}
public var body: some View {
Button(action: action) {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Spacer()
if let badge = badge {
badge
}
SelectionIndicator(isSelected: isSelected, accentColor: accentColor)
}
.padding()
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(isSelected ? accentColor.opacity(Design.Opacity.subtle) : Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.strokeBorder(
isSelected ? accentColor.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle),
lineWidth: Design.LineWidth.thin
)
)
}
.buttonStyle(.plain)
}
}
// MARK: - Convenience Initializer
extension SelectableRow where Badge == EmptyView {
/// Creates a selectable row without a badge.
public init(
title: String,
subtitle: String,
isSelected: Bool,
accentColor: Color = .Accent.primary,
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.isSelected = isSelected
self.accentColor = accentColor
self.badge = nil
self.action = action
}
}
// MARK: - Preview
#Preview {
VStack(spacing: Design.Spacing.small) {
SelectableRow(
title: "Light",
subtitle: "Always use light mode",
isSelected: true,
action: {}
)
SelectableRow(
title: "Dark",
subtitle: "Always use dark mode",
isSelected: false,
action: {}
)
SelectableRow(
title: "Premium",
subtitle: "Unlock all features",
isSelected: false,
badge: { BadgePill(text: "$9.99", isSelected: false) },
action: {}
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,59 @@
//
// SelectionIndicator.swift
// Bedrock
//
// A circle indicator that shows selected (checkmark) or unselected (outline) state.
//
import SwiftUI
/// A circle indicator that shows selected (checkmark) or unselected (outline) state.
public struct SelectionIndicator: View {
/// Whether the item is selected.
public let isSelected: Bool
/// The accent color for the checkmark.
public let accentColor: Color
/// The size of the indicator.
public let size: CGFloat
/// Creates a selection indicator.
/// - Parameters:
/// - isSelected: Whether selected.
/// - accentColor: Color for checkmark (default: primary accent).
/// - size: Size of the indicator (default: checkmark size from design).
public init(
isSelected: Bool,
accentColor: Color = .Accent.primary,
size: CGFloat = Design.Size.checkmark
) {
self.isSelected = isSelected
self.accentColor = accentColor
self.size = size
}
public var body: some View {
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: size))
.foregroundStyle(accentColor)
} else {
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium)
.frame(width: size, height: size)
}
}
}
// MARK: - Preview
#Preview {
HStack(spacing: Design.Spacing.large) {
SelectionIndicator(isSelected: true)
SelectionIndicator(isSelected: false)
SelectionIndicator(isSelected: true, accentColor: .green)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -1,616 +0,0 @@
//
// SettingsComponents.swift
// Bedrock
//
// Reusable settings UI components for building consistent settings screens.
//
import SwiftUI
// MARK: - Settings Toggle
/// A toggle setting row with title and subtitle.
///
/// Use this for boolean settings that can be turned on or off.
///
/// ```swift
/// SettingsToggle(
/// title: "Dark Mode",
/// subtitle: "Use dark appearance",
/// isOn: $settings.darkMode
/// )
/// ```
public struct SettingsToggle: View {
/// The main title text.
public let title: String
/// The subtitle/description text.
public let subtitle: String
/// Binding to the toggle state.
@Binding public var isOn: Bool
/// The accent color for the toggle.
public let accentColor: Color
/// 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).
public init(
title: String,
subtitle: String,
isOn: Binding<Bool>,
accentColor: Color = .Accent.primary
) {
self.title = title
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)
}
}
// MARK: - Volume Picker
/// A volume slider with speaker icons.
///
/// Use this for audio volume or similar 0-1 range settings.
public struct VolumePicker: View {
/// The label for the picker.
public let label: String
/// Binding to the volume level (0.0 to 1.0).
@Binding public var volume: Float
/// The accent color for the slider.
public let accentColor: Color
/// Creates a volume picker.
/// - Parameters:
/// - label: The label text (default: "Volume").
/// - volume: Binding to volume (0.0-1.0).
/// - accentColor: The accent color (default: primary accent).
public init(
label: String = "Volume",
volume: Binding<Float>,
accentColor: Color = .Accent.primary
) {
self.label = label
self._volume = volume
self.accentColor = accentColor
}
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(label)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text("\(Int(volume * 100))%")
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "speaker.fill")
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Slider(value: $volume, in: 0...1, step: 0.1)
.tint(accentColor)
Image(systemName: "speaker.wave.3.fill")
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Segmented Picker
/// A horizontal segmented picker with capsule-style buttons.
///
/// Use this for selecting from a small number of options (2-4).
///
/// ```swift
/// SegmentedPicker(
/// title: "Theme",
/// options: [("Light", "light"), ("Dark", "dark"), ("System", "system")],
/// selection: $theme
/// )
/// ```
public struct SegmentedPicker<T: Equatable>: View {
/// The title/label for the picker.
public let title: String
/// The available options as (label, value) pairs.
public let options: [(String, T)]
/// Binding to the selected value.
@Binding public var selection: T
/// The accent color for the selected button.
public let accentColor: Color
/// Creates a segmented picker.
/// - Parameters:
/// - title: The title label.
/// - options: Array of (label, value) tuples.
/// - selection: Binding to selected value.
/// - accentColor: The accent color (default: primary accent).
public init(
title: String,
options: [(String, T)],
selection: Binding<T>,
accentColor: Color = .Accent.primary
) {
self.title = title
self.options = options
self._selection = selection
self.accentColor = accentColor
}
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
HStack(spacing: Design.Spacing.small) {
ForEach(options.indices, id: \.self) { index in
let option = options[index]
Button {
selection = option.1
} label: {
Text(option.0)
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(selection == option.1 ? .black : .white.opacity(Design.Opacity.strong))
.padding(.vertical, Design.Spacing.small)
.frame(maxWidth: .infinity)
.background(
Capsule()
.fill(selection == option.1 ? accentColor : Color.white.opacity(Design.Opacity.subtle))
)
}
.buttonStyle(.plain)
}
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Selectable Row
/// A card-like selectable row with title, subtitle, optional badge, and selection indicator.
///
/// Use this for settings pickers, option lists, or any selectable item.
///
/// ```swift
/// SelectableRow(
/// title: "Premium",
/// subtitle: "Unlock all features",
/// isSelected: plan == .premium
/// ) {
/// plan = .premium
/// }
/// ```
public struct SelectableRow<Badge: View>: View {
/// The main title text.
public let title: String
/// The subtitle/description text.
public let subtitle: String
/// Whether this row is currently selected.
public let isSelected: Bool
/// Optional badge view.
public let badge: Badge?
/// The accent color for selection highlighting.
public let accentColor: Color
/// Action when tapped.
public let action: () -> Void
/// Creates a selectable row.
/// - Parameters:
/// - title: The main title.
/// - subtitle: The subtitle description.
/// - isSelected: Whether this row is selected.
/// - accentColor: Color for selection (default: primary accent).
/// - badge: Optional badge view.
/// - action: Action when tapped.
public init(
title: String,
subtitle: String,
isSelected: Bool,
accentColor: Color = .Accent.primary,
@ViewBuilder badge: () -> Badge? = { nil as EmptyView? },
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.isSelected = isSelected
self.accentColor = accentColor
self.badge = badge()
self.action = action
}
public var body: some View {
Button(action: action) {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Spacer()
if let badge = badge {
badge
}
SelectionIndicator(isSelected: isSelected, accentColor: accentColor)
}
.padding()
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(isSelected ? accentColor.opacity(Design.Opacity.subtle) : Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.strokeBorder(
isSelected ? accentColor.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle),
lineWidth: Design.LineWidth.thin
)
)
}
.buttonStyle(.plain)
}
}
// Convenience initializer for rows without a badge
extension SelectableRow where Badge == EmptyView {
/// Creates a selectable row without a badge.
public init(
title: String,
subtitle: String,
isSelected: Bool,
accentColor: Color = .Accent.primary,
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.isSelected = isSelected
self.accentColor = accentColor
self.badge = nil
self.action = action
}
}
// MARK: - Selection Indicator
/// A circle indicator that shows selected (checkmark) or unselected (outline) state.
public struct SelectionIndicator: View {
/// Whether the item is selected.
public let isSelected: Bool
/// The accent color for the checkmark.
public let accentColor: Color
/// The size of the indicator.
public let size: CGFloat
/// Creates a selection indicator.
/// - Parameters:
/// - isSelected: Whether selected.
/// - accentColor: Color for checkmark (default: primary accent).
/// - size: Size of the indicator (default: checkmark size from design).
public init(
isSelected: Bool,
accentColor: Color = .Accent.primary,
size: CGFloat = Design.Size.checkmark
) {
self.isSelected = isSelected
self.accentColor = accentColor
self.size = size
}
public var body: some View {
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: size))
.foregroundStyle(accentColor)
} else {
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium)
.frame(width: size, height: size)
}
}
}
// MARK: - Badge Pill
/// A capsule-shaped badge for displaying short text values.
///
/// Use this to highlight values, tags, or status indicators.
public struct BadgePill: View {
/// The text to display in the badge.
public let text: String
/// Whether the parent row is selected.
public let isSelected: Bool
/// The accent color.
public let accentColor: Color
/// Creates a badge pill.
/// - Parameters:
/// - text: The badge text.
/// - isSelected: Whether the parent row is selected.
/// - accentColor: Color for the badge (default: primary accent).
public init(
text: String,
isSelected: Bool = false,
accentColor: Color = .Accent.primary
) {
self.text = text
self.isSelected = isSelected
self.accentColor = accentColor
}
public var body: some View {
Text(text)
.font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded))
.foregroundStyle(isSelected ? .black : accentColor)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(
Capsule()
.fill(isSelected ? accentColor : accentColor.opacity(Design.Opacity.hint))
)
}
}
// MARK: - Settings Section Header
/// A section header for settings screens.
public struct SettingsSectionHeader: View {
/// The section title.
public let title: String
/// Optional system image name.
public let systemImage: String?
/// The accent color for the header.
public let accentColor: Color
/// Creates a section header.
/// - Parameters:
/// - title: The section title.
/// - systemImage: Optional SF Symbol name.
/// - accentColor: The accent color (default: primary accent).
public init(title: String, systemImage: String? = nil, accentColor: Color = .Accent.primary) {
self.title = title
self.systemImage = systemImage
self.accentColor = accentColor
}
public var body: some View {
HStack(spacing: Design.Spacing.small) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(accentColor.opacity(Design.Opacity.strong))
}
Text(title)
.font(.system(size: Design.BaseFontSize.caption, weight: .semibold))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.textCase(.uppercase)
.tracking(0.5)
Spacer()
}
.padding(.horizontal, Design.Spacing.xSmall)
.padding(.top, Design.Spacing.large)
.padding(.bottom, Design.Spacing.xSmall)
}
}
// MARK: - Settings Row
/// A simple settings row with icon, title, and optional value/accessory.
public struct SettingsRow<Accessory: View>: View {
/// The row icon (SF Symbol name).
public let systemImage: String
/// The row title.
public let title: String
/// Optional value text.
public let value: String?
/// The icon background color.
public let iconColor: Color
/// Optional accessory view.
public let accessory: Accessory?
/// Action when tapped.
public let action: () -> Void
/// Creates a settings row.
public init(
systemImage: String,
title: String,
value: String? = nil,
iconColor: Color = .Accent.primary,
@ViewBuilder accessory: () -> Accessory? = { nil as EmptyView? },
action: @escaping () -> Void
) {
self.systemImage = systemImage
self.title = title
self.value = value
self.iconColor = iconColor
self.accessory = accessory()
self.action = action
}
public var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white)
.frame(width: Design.Size.iconContainerSmall, height: Design.Size.iconContainerSmall)
.background(iconColor.opacity(Design.Opacity.heavy))
.clipShape(.rect(cornerRadius: Design.CornerRadius.xSmall))
Text(title)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white)
Spacer()
if let value {
Text(value)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
if let accessory {
accessory
} else {
Image(systemName: "chevron.right")
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.light))
}
}
.padding(.vertical, Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.medium)
.background(Color.Surface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
}
.buttonStyle(.plain)
}
}
// Convenience initializer for rows without accessory
extension SettingsRow where Accessory == EmptyView {
/// Creates a settings row without an accessory.
public init(
systemImage: String,
title: String,
value: String? = nil,
iconColor: Color = .Accent.primary,
action: @escaping () -> Void
) {
self.systemImage = systemImage
self.title = title
self.value = value
self.iconColor = iconColor
self.accessory = nil
self.action = action
}
}
// MARK: - Preview
#Preview {
ScrollView {
VStack(spacing: Design.Spacing.large) {
SettingsSectionHeader(title: "Appearance", systemImage: "paintbrush")
// Selectable rows
VStack(spacing: Design.Spacing.small) {
SelectableRow(
title: "Light",
subtitle: "Always use light mode",
isSelected: true,
action: {}
)
SelectableRow(
title: "Dark",
subtitle: "Always use dark mode",
isSelected: false,
action: {}
)
SelectableRow(
title: "Premium",
subtitle: "Unlock all features",
isSelected: false,
badge: { BadgePill(text: "$9.99", isSelected: false) },
action: {}
)
}
SettingsSectionHeader(title: "Preferences", systemImage: "gearshape")
SettingsToggle(
title: "Sound Effects",
subtitle: "Play sounds for events",
isOn: .constant(true)
)
SegmentedPicker(
title: "Animation Speed",
options: [("Fast", "fast"), ("Normal", "normal"), ("Slow", "slow")],
selection: .constant("normal")
)
VolumePicker(volume: .constant(0.8))
SettingsSectionHeader(title: "About", systemImage: "info.circle")
SettingsRow(
systemImage: "star.fill",
title: "Rate App",
iconColor: .Status.warning,
action: {}
)
SettingsRow(
systemImage: "envelope.fill",
title: "Contact Us",
value: "support@example.com",
iconColor: .Status.info,
action: {}
)
}
.padding()
}
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,134 @@
//
// SettingsRow.swift
// Bedrock
//
// A simple settings row with icon, title, and optional value/accessory.
//
import SwiftUI
/// A simple settings row with icon, title, and optional value/accessory.
public struct SettingsRow<Accessory: View>: View {
/// The row icon (SF Symbol name).
public let systemImage: String
/// The row title.
public let title: String
/// Optional value text.
public let value: String?
/// The icon background color.
public let iconColor: Color
/// Optional accessory view.
public let accessory: Accessory?
/// Action when tapped.
public let action: () -> Void
/// Creates a settings row.
public init(
systemImage: String,
title: String,
value: String? = nil,
iconColor: Color = .Accent.primary,
@ViewBuilder accessory: () -> Accessory? = { nil as EmptyView? },
action: @escaping () -> Void
) {
self.systemImage = systemImage
self.title = title
self.value = value
self.iconColor = iconColor
self.accessory = accessory()
self.action = action
}
public var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white)
.frame(width: Design.Size.iconContainerSmall, height: Design.Size.iconContainerSmall)
.background(iconColor.opacity(Design.Opacity.heavy))
.clipShape(.rect(cornerRadius: Design.CornerRadius.xSmall))
Text(title)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white)
Spacer()
if let value {
Text(value)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
if let accessory {
accessory
} else {
Image(systemName: "chevron.right")
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.light))
}
}
.padding(.vertical, Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.medium)
.background(Color.Surface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
}
.buttonStyle(.plain)
}
}
// MARK: - Convenience Initializer
extension SettingsRow where Accessory == EmptyView {
/// Creates a settings row without an accessory.
public init(
systemImage: String,
title: String,
value: String? = nil,
iconColor: Color = .Accent.primary,
action: @escaping () -> Void
) {
self.systemImage = systemImage
self.title = title
self.value = value
self.iconColor = iconColor
self.accessory = nil
self.action = action
}
}
// MARK: - Preview
#Preview {
VStack(spacing: Design.Spacing.small) {
SettingsRow(
systemImage: "star.fill",
title: "Rate App",
iconColor: .Status.warning,
action: {}
)
SettingsRow(
systemImage: "envelope.fill",
title: "Contact Us",
value: "support@example.com",
iconColor: .Status.info,
action: {}
)
SettingsRow(
systemImage: "bell.fill",
title: "Notifications",
iconColor: .Status.error,
action: {}
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,64 @@
//
// SettingsSectionHeader.swift
// Bedrock
//
// A section header for settings screens.
//
import SwiftUI
/// A section header for settings screens.
public struct SettingsSectionHeader: View {
/// The section title.
public let title: String
/// Optional system image name.
public let systemImage: String?
/// The accent color for the header.
public let accentColor: Color
/// Creates a section header.
/// - Parameters:
/// - title: The section title.
/// - systemImage: Optional SF Symbol name.
/// - accentColor: The accent color (default: primary accent).
public init(title: String, systemImage: String? = nil, accentColor: Color = .Accent.primary) {
self.title = title
self.systemImage = systemImage
self.accentColor = accentColor
}
public var body: some View {
HStack(spacing: Design.Spacing.small) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(accentColor.opacity(Design.Opacity.strong))
}
Text(title)
.font(.system(size: Design.BaseFontSize.caption, weight: .semibold))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.textCase(.uppercase)
.tracking(0.5)
Spacer()
}
.padding(.horizontal, Design.Spacing.xSmall)
.padding(.top, Design.Spacing.large)
.padding(.bottom, Design.Spacing.xSmall)
}
}
// MARK: - Preview
#Preview {
VStack(alignment: .leading, spacing: 0) {
SettingsSectionHeader(title: "Appearance", systemImage: "paintbrush")
SettingsSectionHeader(title: "Preferences", systemImage: "gearshape")
SettingsSectionHeader(title: "Premium", systemImage: "crown", accentColor: .orange)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,87 @@
//
// SettingsToggle.swift
// Bedrock
//
// A toggle setting row with title and subtitle.
//
import SwiftUI
/// A toggle setting row with title and subtitle.
///
/// Use this for boolean settings that can be turned on or off.
///
/// ```swift
/// SettingsToggle(
/// title: "Dark Mode",
/// subtitle: "Use dark appearance",
/// isOn: $settings.darkMode
/// )
/// ```
public struct SettingsToggle: View {
/// The main title text.
public let title: String
/// The subtitle/description text.
public let subtitle: String
/// Binding to the toggle state.
@Binding public var isOn: Bool
/// The accent color for the toggle.
public let accentColor: Color
/// 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).
public init(
title: String,
subtitle: String,
isOn: Binding<Bool>,
accentColor: Color = .Accent.primary
) {
self.title = title
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)
}
}
// MARK: - Preview
#Preview {
VStack(spacing: Design.Spacing.medium) {
SettingsToggle(
title: "Sound Effects",
subtitle: "Play sounds for events",
isOn: .constant(true)
)
SettingsToggle(
title: "Notifications",
subtitle: "Receive push notifications",
isOn: .constant(false)
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,78 @@
//
// VolumePicker.swift
// Bedrock
//
// A volume slider with speaker icons.
//
import SwiftUI
/// A volume slider with speaker icons.
///
/// Use this for audio volume or similar 0-1 range settings.
public struct VolumePicker: View {
/// The label for the picker.
public let label: String
/// Binding to the volume level (0.0 to 1.0).
@Binding public var volume: Float
/// The accent color for the slider.
public let accentColor: Color
/// Creates a volume picker.
/// - Parameters:
/// - label: The label text (default: "Volume").
/// - volume: Binding to volume (0.0-1.0).
/// - accentColor: The accent color (default: primary accent).
public init(
label: String = "Volume",
volume: Binding<Float>,
accentColor: Color = .Accent.primary
) {
self.label = label
self._volume = volume
self.accentColor = accentColor
}
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(label)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text("\(Int(volume * 100))%")
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "speaker.fill")
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Slider(value: $volume, in: 0...1, step: 0.1)
.tint(accentColor)
Image(systemName: "speaker.wave.3.fill")
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Preview
#Preview {
VStack(spacing: Design.Spacing.medium) {
VolumePicker(volume: .constant(0.8))
VolumePicker(label: "Music", volume: .constant(0.5))
}
.padding()
.background(Color.Surface.overlay)
}