Merge branch 'develop' of ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git into develop

This commit is contained in:
Matt Bruce 2026-02-09 16:54:40 -06:00
commit 65fe6cc178
20 changed files with 577 additions and 25 deletions

View File

@ -70,7 +70,10 @@ Use this checklist when setting up a new app with Bedrock:
- [ ] Use `SettingsToggle`, `SettingsSlider`, `SegmentedPicker` components
- [ ] Apply theme colors via `AppSurface`, `AppAccent`, etc.
- [ ] Use `SettingsCard` for grouped sections
- [ ] Use `SettingsDivider` between rows in cards
- [ ] Use `SettingsCardRow` for custom rows inside cards
- [ ] Use `SettingsNavigationRow` for navigation links
- [ ] Avoid manual horizontal child padding inside `SettingsCard` (card owns row insets)
- [ ] Add `#if DEBUG` section for development tools
### Quick Start Order

View File

@ -133,7 +133,7 @@ public final class SoundManager {
)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Failed to configure audio session: \(error)")
Design.debugLog("Failed to configure audio session: \(error)")
}
#endif
}
@ -168,7 +168,7 @@ public final class SoundManager {
player.play()
return
} catch {
print("Failed to load sound \(key): \(error)")
Design.debugLog("Failed to load sound \(key): \(error)")
}
}
@ -202,7 +202,7 @@ public final class SoundManager {
player.prepareToPlay()
audioPlayers[key] = player
} catch {
print("Failed to preload sound \(key): \(error)")
Design.debugLog("Failed to preload sound \(key): \(error)")
}
}
}

View File

@ -1,6 +1,9 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"%lld." : {
"comment" : "A numbered list item with a callout number and accompanying text. The first argument is the number of the item. The second argument is the text describing the item.",
"isCommentAutoGenerated" : true

View File

@ -248,6 +248,7 @@ Text("Feature unavailable")
// Toggles and interactive elements
SettingsToggle(
title: "Enable Feature",
subtitle: "Turn this feature on or off",
isOn: $isEnabled,
accentColor: AppAccent.primary
)
@ -264,6 +265,8 @@ SettingsSectionHeader(
)
```
> For settings layout alignment, place rows inside `SettingsCard` and use `SettingsCardRow` + `SettingsDivider` for custom row content (see `SETTINGS_GUIDE.md`).
### Status Colors
```swift

View File

@ -230,6 +230,38 @@ struct SettingsView: View {
---
## Layout Contract (Important)
`SettingsCard` owns horizontal insets for rows inside the card.
- Do: place `SettingsToggle`, `SettingsSlider`, `SettingsNavigationRow`, and other settings rows directly inside `SettingsCard`.
- Do: use `SettingsDivider` between rows.
- Do: use `SettingsCardRow` for custom rows (custom `HStack`, `ColorPicker`, status row, etc.).
- Don't: add manual `.padding(.horizontal, ...)` to children inside `SettingsCard` unless you intentionally want custom indentation.
Example:
```swift
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsNavigationRow(
title: "Color Theme",
subtitle: "Custom",
backgroundColor: .clear
) {
ThemeSelectionView()
}
SettingsDivider(color: AppBorder.subtle)
SettingsCardRow {
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
.foregroundStyle(AppTextColors.primary)
}
}
```
---
## Available Components
### SettingsCard
@ -243,6 +275,8 @@ SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
}
```
**Inset behavior**: `SettingsCard` provides horizontal inset for card children. Child rows should not add their own horizontal inset by default.
**Refactoring**: Replace inline card styling with `SettingsCard`:
```swift
@ -282,6 +316,46 @@ SettingsSectionHeader(
---
### SettingsDivider
A divider to separate rows inside `SettingsCard`.
```swift
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsToggle(...)
SettingsDivider(color: AppBorder.subtle)
SettingsToggle(...)
}
```
---
### SettingsCardRow
A wrapper for custom row content inside `SettingsCard`, with consistent vertical rhythm.
```swift
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsCardRow {
HStack {
Text("Preview")
Spacer()
Text("12:34")
}
}
}
```
Use `verticalPadding` when you need a taller custom row:
```swift
SettingsCardRow(verticalPadding: Design.Spacing.medium) {
CustomContentView()
}
```
---
### SettingsToggle
A toggle row with title, subtitle, and optional title accessory (e.g., premium crown). **Note: Subtitle is required.**
@ -443,7 +517,7 @@ A navigation link row for settings that navigate to detail views.
SettingsNavigationRow(
title: "Open Source Licenses",
subtitle: "Third-party libraries used in this app",
backgroundColor: AppSurface.primary
backgroundColor: .clear
) {
LicensesView()
}
@ -482,12 +556,88 @@ NavigationLink {
SettingsNavigationRow(
title: "Open Source Licenses",
subtitle: "Third-party libraries used in this app",
backgroundColor: AppSurface.primary
backgroundColor: .clear
) {
LicensesView()
}
```
Use `SettingsSelectionView` for enum/list selection destinations:
```swift
SettingsNavigationRow(
title: "Theme",
subtitle: selectedThemeName,
backgroundColor: .clear
) {
SettingsSelectionView(
selection: $viewModel.theme,
options: ThemeOption.allCases,
title: "Theme",
backgroundColor: AppSurface.primary,
cardBackgroundColor: AppSurface.card,
cardBorderColor: AppBorder.subtle,
accentColor: AppAccent.primary,
rowTextColor: AppTextColors.primary,
toString: { $0.rawValue }
)
}
```
---
### SettingsSelectionView
A reusable selection screen for settings destinations.
```swift
SettingsSelectionView(
selection: $selection,
options: options,
title: "Choose Option",
backgroundColor: AppSurface.primary,
cardBackgroundColor: AppSurface.card,
cardBorderColor: AppBorder.subtle,
accentColor: AppAccent.primary,
rowTextColor: AppTextColors.primary,
toString: { $0.displayName }
)
```
---
### SettingsLabelValueRow
A reusable row for custom label/value content inside `SettingsCard`.
```swift
SettingsCard {
SettingsLabelValueRow(title: "Preview") {
Text("12:34")
.fontDesign(.rounded)
}
}
```
Use this instead of custom `HStack { Text; Spacer(); value }` rows to keep row spacing aligned with Bedrock settings components.
---
### SettingsTimePicker
A settings accessory that binds to `HH:mm` string values.
```swift
SettingsCard {
SettingsLabelValueRow(title: "Start Time", verticalPadding: Design.Spacing.medium) {
SettingsTimePicker(
timeString: $viewModel.startTime,
accentColor: AppAccent.primary
)
}
}
```
---
### LicensesView
@ -868,6 +1018,11 @@ SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
| Component | Use Case | Key Parameters |
|-----------|----------|----------------|
| `SettingsCard` | Group related settings | `backgroundColor`, `borderColor` |
| `SettingsDivider` | Row separators in cards | `color` |
| `SettingsCardRow` | Custom row content in cards | `verticalPadding`, `content` |
| `SettingsLabelValueRow` | Custom label/value row in cards | `title`, `verticalPadding`, `value` |
| `SettingsTimePicker` | HH:mm time picker accessory | `timeString`, `accentColor` |
| `SettingsSelectionView` | Selection destination screen | `selection`, `options`, `title`, colors, `toString` |
| `SettingsSectionHeader` | Section titles | `title`, `systemImage`, `accentColor` |
| `SettingsToggle` | Boolean settings | `title`, `subtitle`, `isOn`, `accentColor` |
| `SettingsSlider` | Numeric settings | `value`, `range`, `format`, `accentColor` |

View File

@ -72,7 +72,7 @@ public struct SegmentedPicker<T: Equatable>: View {
}
}
}
.padding(.vertical, Design.Spacing.xSmall)
.padding(.vertical, Design.Spacing.small)
}
}

View File

@ -94,6 +94,7 @@ public struct SelectableRow<Badge: View>: View {
)
}
.buttonStyle(.plain)
.sensoryFeedback(.selection, trigger: isSelected)
}
}

View File

@ -36,6 +36,7 @@ public struct SelectionIndicator: View {
public var body: some View {
if isSelected {
SymbolIcon("checkmark.circle.fill", size: .row, color: accentColor)
.symbolEffect(.bounce, value: isSelected)
} else {
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium)

View File

@ -46,7 +46,8 @@ public struct SettingsCard<Content: View>: View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
content
}
.padding(Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(backgroundColor)

View File

@ -0,0 +1,54 @@
//
// SettingsCardRow.swift
// Bedrock
//
// A lightweight row wrapper for custom content inside SettingsCard.
//
import SwiftUI
/// A lightweight row wrapper for custom content inside a ``SettingsCard``.
///
/// Use this for custom row content so vertical rhythm stays consistent
/// with built-in settings components.
public struct SettingsCardRow<Content: View>: View {
/// The wrapped row content.
@ViewBuilder public let content: Content
/// Vertical padding applied to the row content.
public let verticalPadding: CGFloat
/// Creates a settings card row wrapper.
/// - Parameters:
/// - verticalPadding: Vertical padding for row content (default: Spacing.small).
/// - content: Row content.
public init(
verticalPadding: CGFloat = Design.Spacing.small,
@ViewBuilder content: () -> Content
) {
self.verticalPadding = verticalPadding
self.content = content()
}
public var body: some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, verticalPadding)
}
}
// MARK: - Preview
#Preview {
SettingsCard(backgroundColor: .Surface.card, borderColor: .Border.subtle) {
SettingsCardRow {
HStack {
Text("Preview").styled(.subheadingEmphasis)
Spacer()
Text("12:34").styled(.subheadingEmphasis)
}
}
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,44 @@
//
// SettingsDivider.swift
// Bedrock
//
// A divider used between rows inside SettingsCard.
//
import SwiftUI
/// A divider used between rows inside a ``SettingsCard``.
///
/// By default this draws edge-to-edge within the card content area.
public struct SettingsDivider: View {
/// The divider color.
public let color: Color
/// Creates a settings divider.
/// - Parameter color: Divider color (default: Border.subtle).
public init(color: Color = .Border.subtle) {
self.color = color
}
public var body: some View {
Rectangle()
.fill(color)
.frame(height: Design.LineWidth.thin)
}
}
// MARK: - Preview
#Preview {
SettingsCard(backgroundColor: .Surface.card, borderColor: .Border.subtle) {
SettingsCardRow {
Text("First Row").styled(.subheadingEmphasis)
}
SettingsDivider()
SettingsCardRow {
Text("Second Row").styled(.subheadingEmphasis)
}
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,71 @@
//
// SettingsLabelValueRow.swift
// Bedrock
//
// A reusable label-value row for custom settings content.
//
import SwiftUI
/// A reusable label-value row for settings cards.
///
/// This component is intended for custom rows that need the same alignment
/// and vertical rhythm as other settings controls.
public struct SettingsLabelValueRow<Value: View>: View {
public let title: String
public let titleTypography: Typography
public let titleEmphasis: TextEmphasis
public let verticalPadding: CGFloat
@ViewBuilder public let value: Value
/// Creates a label-value row.
/// - Parameters:
/// - title: The leading title text.
/// - titleTypography: Typography style for title.
/// - titleEmphasis: Emphasis style for title.
/// - verticalPadding: Vertical row padding.
/// - value: Trailing value view.
public init(
title: String,
titleTypography: Typography = .subheadingEmphasis,
titleEmphasis: TextEmphasis = .primary,
verticalPadding: CGFloat = Design.Spacing.small,
@ViewBuilder value: () -> Value
) {
self.title = title
self.titleTypography = titleTypography
self.titleEmphasis = titleEmphasis
self.verticalPadding = verticalPadding
self.value = value()
}
public var body: some View {
SettingsCardRow(verticalPadding: verticalPadding) {
HStack {
Text(title).styled(titleTypography, emphasis: titleEmphasis)
Spacer()
value
}
}
}
}
// MARK: - Preview
#Preview {
SettingsCard {
SettingsLabelValueRow(title: "Preview") {
Text("12:34")
.styled(.headingEmphasis)
.fontDesign(.rounded)
}
SettingsDivider()
SettingsLabelValueRow(title: "Current Brightness", verticalPadding: Design.Spacing.medium) {
Text("74%").styled(.subheading, emphasis: .secondary)
}
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -66,21 +66,20 @@ public struct SettingsNavigationRow<Destination: View>: View {
destination
} label: {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title).styled(.subheadingEmphasis)
if let subtitle {
Text(subtitle).styled(.caption, emphasis: .secondary)
}
}
Spacer()
if let subtitle {
Text(subtitle).styled(.subheading, emphasis: .secondary)
}
SymbolIcon.chevron(color: .secondary)
}
.padding(Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
}
.padding(.vertical, Design.Spacing.small)
.buttonStyle(.plain)
}
}

View File

@ -78,6 +78,8 @@ public struct SettingsRow<Accessory: View>: View {
.background(Color(.secondarySystemGroupedBackground))
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
}
.padding(.vertical, Design.Spacing.small)
.padding(.horizontal, Design.Spacing.small)
.buttonStyle(.plain)
}
}

View File

@ -109,8 +109,9 @@ public struct SettingsSegmentedPicker<T: Equatable, Accessory: View>: View {
.buttonStyle(.plain)
}
}
.sensoryFeedback(.selection, trigger: selection)
}
.padding(.vertical, Design.Spacing.xSmall)
.padding(.vertical, Design.Spacing.small)
}
}

View File

@ -0,0 +1,123 @@
//
// SettingsSelectionView.swift
// Bedrock
//
// A reusable selection screen for settings navigation rows.
//
import SwiftUI
/// A reusable selection screen for settings values.
///
/// Use this as the destination for `SettingsNavigationRow` when a setting
/// requires choosing one value from a list.
public struct SettingsSelectionView<T: Hashable>: View {
@Binding public var selection: T
public let options: [T]
public let title: String
public let toString: (T) -> String
public let backgroundColor: Color
public let cardBackgroundColor: Color
public let cardBorderColor: Color
public let accentColor: Color
public let rowTextColor: Color
@Environment(\.dismiss) private var dismiss
/// Creates a settings selection view.
/// - Parameters:
/// - selection: The selected value binding.
/// - options: Available options.
/// - title: Screen and section title.
/// - backgroundColor: Screen background color.
/// - cardBackgroundColor: Card background color.
/// - cardBorderColor: Card border color.
/// - accentColor: Accent color for icon/checkmark.
/// - rowTextColor: Row text color.
/// - toString: Option display text formatter.
public init(
selection: Binding<T>,
options: [T],
title: String,
backgroundColor: Color = .Surface.primary,
cardBackgroundColor: Color = .Surface.card,
cardBorderColor: Color = .Border.subtle,
accentColor: Color = .Accent.primary,
rowTextColor: Color = .primary,
toString: @escaping (T) -> String
) {
self._selection = selection
self.options = options
self.title = title
self.backgroundColor = backgroundColor
self.cardBackgroundColor = cardBackgroundColor
self.cardBorderColor = cardBorderColor
self.accentColor = accentColor
self.rowTextColor = rowTextColor
self.toString = toString
}
public var body: some View {
ZStack {
backgroundColor.ignoresSafeArea()
ScrollView {
VStack(spacing: Design.Spacing.medium) {
SettingsSectionHeader(
title: title,
systemImage: "checklist",
accentColor: accentColor
)
SettingsCard(backgroundColor: cardBackgroundColor, borderColor: cardBorderColor) {
VStack(spacing: 0) {
ForEach(options, id: \.self) { option in
Button {
selection = option
dismiss()
} label: {
SettingsCardRow(verticalPadding: Design.Spacing.medium) {
HStack {
Text(toString(option))
.styled(.body, emphasis: .custom(rowTextColor))
Spacer()
if selection == option {
Image(systemName: "checkmark")
.foregroundStyle(accentColor)
.font(.body.bold())
}
}
}
.background(Color.clear)
}
.buttonStyle(.plain)
if option != options.last {
SettingsDivider(color: cardBorderColor)
}
}
}
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.large)
.padding(.bottom, Design.Spacing.xxxLarge)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Preview
#Preview {
NavigationStack {
SettingsSelectionView(
selection: .constant("Option 1"),
options: ["Option 1", "Option 2", "Option 3"],
title: "Test Selection",
toString: { $0 }
)
}
}

View File

@ -118,6 +118,8 @@ public struct SettingsSlider<Value: BinaryFloatingPoint & Sendable>: View where
Text(format(value))
.styled(.subheadingEmphasis, emphasis: .secondary)
.fontDesign(.rounded)
.contentTransition(.numericText())
.animation(.snappy(duration: 0.2), value: value)
}
Text(subtitle).styled(.caption, emphasis: .secondary)
@ -139,7 +141,7 @@ public struct SettingsSlider<Value: BinaryFloatingPoint & Sendable>: View where
}
}
}
.padding(.vertical, Design.Spacing.xSmall)
.padding(.vertical, Design.Spacing.small)
}
}

View File

@ -0,0 +1,85 @@
//
// SettingsTimePicker.swift
// Bedrock
//
// A time picker accessory that binds to an HH:mm string.
//
import SwiftUI
/// A time picker accessory that binds to a 24-hour `HH:mm` string.
///
/// Useful in settings rows where model persistence is string-based.
public struct SettingsTimePicker: View {
@Binding public var timeString: String
@State private var selectedTime = Date()
public let accentColor: Color
/// Creates a settings time picker.
/// - Parameters:
/// - timeString: Binding to a string in `HH:mm` format.
/// - accentColor: Tint color for the picker.
public init(
timeString: Binding<String>,
accentColor: Color = .Accent.primary
) {
self._timeString = timeString
self.accentColor = accentColor
}
public var body: some View {
DatePicker("", selection: $selectedTime, displayedComponents: .hourAndMinute)
.labelsHidden()
.tint(accentColor)
.onAppear {
updateSelectedTimeFromString()
}
.onChange(of: selectedTime) { _, newTime in
updateStringFromTime(newTime)
}
.onChange(of: timeString) { _, _ in
updateSelectedTimeFromString()
}
}
private func updateSelectedTimeFromString() {
let components = timeString.split(separator: ":")
guard components.count == 2,
let hour = Int(components[0]),
let minute = Int(components[1]) else {
return
}
let calendar = Calendar.current
let now = Date()
let dateComponents = calendar.dateComponents([.year, .month, .day], from: now)
var newComponents = dateComponents
newComponents.hour = hour
newComponents.minute = minute
if let newDate = calendar.date(from: newComponents) {
selectedTime = newDate
}
}
private func updateStringFromTime(_ time: Date) {
let calendar = Calendar.current
let hour = calendar.component(.hour, from: time)
let minute = calendar.component(.minute, from: time)
timeString = String(format: "%02d:%02d", hour, minute)
}
}
// MARK: - Preview
#Preview {
SettingsCard {
SettingsLabelValueRow(title: "Start Time") {
SettingsTimePicker(timeString: .constant("22:00"))
}
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -90,7 +90,8 @@ public struct SettingsToggle<Accessory: View>: View {
}
}
.tint(accentColor)
.padding(.vertical, Design.Spacing.xSmall)
.padding(.vertical, Design.Spacing.small)
.sensoryFeedback(.impact(flexibility: .soft), trigger: isOn)
}
}

View File

@ -114,13 +114,16 @@ public struct iCloudSyncSettingsView<ViewModel: CloudSyncable>: View {
// Sync status (show when enabled and available)
if viewModel.iCloudEnabled && viewModel.iCloudAvailable {
SettingsDivider()
SettingsCardRow {
SyncStatusRow(
viewModel: viewModel,
accentColor: accentColor,
successColor: successColor,
warningColor: warningColor
)
.padding(.top, Design.Spacing.xSmall)
}
}
}
}