diff --git a/README.md b/README.md index a6071a7..4401875 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,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 diff --git a/Sources/Bedrock/Resources/Localizable.xcstrings b/Sources/Bedrock/Resources/Localizable.xcstrings index aef8062..2046392 100644 --- a/Sources/Bedrock/Resources/Localizable.xcstrings +++ b/Sources/Bedrock/Resources/Localizable.xcstrings @@ -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 diff --git a/Sources/Bedrock/Theme/THEME_GUIDE.md b/Sources/Bedrock/Theme/THEME_GUIDE.md index 0df0aa0..fe6352b 100644 --- a/Sources/Bedrock/Theme/THEME_GUIDE.md +++ b/Sources/Bedrock/Theme/THEME_GUIDE.md @@ -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 diff --git a/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md b/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md index 4ccc314..1b31427 100644 --- a/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md +++ b/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md @@ -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` | diff --git a/Sources/Bedrock/Views/Settings/SegmentedPicker.swift b/Sources/Bedrock/Views/Settings/SegmentedPicker.swift index bc7fb75..6c0d64c 100644 --- a/Sources/Bedrock/Views/Settings/SegmentedPicker.swift +++ b/Sources/Bedrock/Views/Settings/SegmentedPicker.swift @@ -73,7 +73,6 @@ public struct SegmentedPicker: View { } } .padding(.vertical, Design.Spacing.small) - .padding(.horizontal, Design.Spacing.small) } } diff --git a/Sources/Bedrock/Views/Settings/SettingsCard.swift b/Sources/Bedrock/Views/Settings/SettingsCard.swift index b7431ea..149600f 100644 --- a/Sources/Bedrock/Views/Settings/SettingsCard.swift +++ b/Sources/Bedrock/Views/Settings/SettingsCard.swift @@ -46,7 +46,8 @@ public struct SettingsCard: 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) diff --git a/Sources/Bedrock/Views/Settings/SettingsCardRow.swift b/Sources/Bedrock/Views/Settings/SettingsCardRow.swift new file mode 100644 index 0000000..d7f2f5c --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsCardRow.swift @@ -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: 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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsDivider.swift b/Sources/Bedrock/Views/Settings/SettingsDivider.swift new file mode 100644 index 0000000..d7b4dc4 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsDivider.swift @@ -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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsLabelValueRow.swift b/Sources/Bedrock/Views/Settings/SettingsLabelValueRow.swift new file mode 100644 index 0000000..03711d6 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsLabelValueRow.swift @@ -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: 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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift b/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift index 9499227..17e890f 100644 --- a/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift +++ b/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift @@ -77,11 +77,9 @@ public struct SettingsNavigationRow: View { SymbolIcon.chevron(color: .secondary) } .padding(.vertical, Design.Spacing.small) - .padding(.horizontal, Design.Spacing.small) .background(backgroundColor, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) } .padding(.vertical, Design.Spacing.small) - .padding(.horizontal, Design.Spacing.small) .buttonStyle(.plain) } } diff --git a/Sources/Bedrock/Views/Settings/SettingsSegmentedPicker.swift b/Sources/Bedrock/Views/Settings/SettingsSegmentedPicker.swift index a66ff0a..9e5ec72 100644 --- a/Sources/Bedrock/Views/Settings/SettingsSegmentedPicker.swift +++ b/Sources/Bedrock/Views/Settings/SettingsSegmentedPicker.swift @@ -112,7 +112,6 @@ public struct SettingsSegmentedPicker: View { .sensoryFeedback(.selection, trigger: selection) } .padding(.vertical, Design.Spacing.small) - .padding(.horizontal, Design.Spacing.small) } } diff --git a/Sources/Bedrock/Views/Settings/SettingsSelectionView.swift b/Sources/Bedrock/Views/Settings/SettingsSelectionView.swift new file mode 100644 index 0000000..c875414 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsSelectionView.swift @@ -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: 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, + 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 } + ) + } +} diff --git a/Sources/Bedrock/Views/Settings/SettingsSlider.swift b/Sources/Bedrock/Views/Settings/SettingsSlider.swift index c522ae5..5ddd875 100644 --- a/Sources/Bedrock/Views/Settings/SettingsSlider.swift +++ b/Sources/Bedrock/Views/Settings/SettingsSlider.swift @@ -142,7 +142,6 @@ public struct SettingsSlider: View where } } .padding(.vertical, Design.Spacing.small) - .padding(.horizontal, Design.Spacing.small) } } diff --git a/Sources/Bedrock/Views/Settings/SettingsTimePicker.swift b/Sources/Bedrock/Views/Settings/SettingsTimePicker.swift new file mode 100644 index 0000000..29e1df0 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsTimePicker.swift @@ -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, + 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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsToggle.swift b/Sources/Bedrock/Views/Settings/SettingsToggle.swift index 7b69f4d..d3f1c6a 100644 --- a/Sources/Bedrock/Views/Settings/SettingsToggle.swift +++ b/Sources/Bedrock/Views/Settings/SettingsToggle.swift @@ -91,7 +91,6 @@ public struct SettingsToggle: View { } .tint(accentColor) .padding(.vertical, Design.Spacing.small) - .padding(.horizontal, Design.Spacing.small) .sensoryFeedback(.impact(flexibility: .soft), trigger: isOn) } } diff --git a/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift b/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift index 630ec44..3bb1168 100644 --- a/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift +++ b/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift @@ -151,7 +151,6 @@ private struct SyncStatusRow: View { Text(String(localized: "Sync Now")).styled(.captionEmphasis, emphasis: .custom(accentColor)) } } - .padding(.horizontal, Design.Spacing.small) } }