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:
parent
cc650c68d4
commit
cf46b9f1f4
61
Sources/Bedrock/Views/Settings/BadgePill.swift
Normal file
61
Sources/Bedrock/Views/Settings/BadgePill.swift
Normal 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)
|
||||||
|
}
|
||||||
100
Sources/Bedrock/Views/Settings/SegmentedPicker.swift
Normal file
100
Sources/Bedrock/Views/Settings/SegmentedPicker.swift
Normal 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)
|
||||||
|
}
|
||||||
152
Sources/Bedrock/Views/Settings/SelectableRow.swift
Normal file
152
Sources/Bedrock/Views/Settings/SelectableRow.swift
Normal 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)
|
||||||
|
}
|
||||||
59
Sources/Bedrock/Views/Settings/SelectionIndicator.swift
Normal file
59
Sources/Bedrock/Views/Settings/SelectionIndicator.swift
Normal 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)
|
||||||
|
}
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
134
Sources/Bedrock/Views/Settings/SettingsRow.swift
Normal file
134
Sources/Bedrock/Views/Settings/SettingsRow.swift
Normal 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)
|
||||||
|
}
|
||||||
64
Sources/Bedrock/Views/Settings/SettingsSectionHeader.swift
Normal file
64
Sources/Bedrock/Views/Settings/SettingsSectionHeader.swift
Normal 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)
|
||||||
|
}
|
||||||
87
Sources/Bedrock/Views/Settings/SettingsToggle.swift
Normal file
87
Sources/Bedrock/Views/Settings/SettingsToggle.swift
Normal 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)
|
||||||
|
}
|
||||||
78
Sources/Bedrock/Views/Settings/VolumePicker.swift
Normal file
78
Sources/Bedrock/Views/Settings/VolumePicker.swift
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user