diff --git a/Sources/Bedrock/Resources/Localizable.xcstrings b/Sources/Bedrock/Resources/Localizable.xcstrings index 6cd52d6..6ef5a00 100644 --- a/Sources/Bedrock/Resources/Localizable.xcstrings +++ b/Sources/Bedrock/Resources/Localizable.xcstrings @@ -37,6 +37,15 @@ "comment" : "A heading for the instructions section of the IconGeneratorView.", "isCommentAutoGenerated" : true }, + "Appearance" : { + "comment" : "Title for the theme picker setting." + }, + "Choose light, dark, or follow system" : { + "comment" : "Subtitle for the theme picker setting." + }, + "Dark" : { + "comment" : "Theme option for dark mode appearance." + }, "App Icon" : { "comment" : "A heading for the app icon preview section.", "isCommentAutoGenerated" : true @@ -73,6 +82,9 @@ "comment" : "A tab label for the launch screen preview.", "isCommentAutoGenerated" : true }, + "Light" : { + "comment" : "Theme option for light mode appearance." + }, "Note: iOS uses a single 1024px icon" : { "comment" : "A note explaining that iOS uses a single 1024px icon.", "isCommentAutoGenerated" : true @@ -109,6 +121,9 @@ "comment" : "Text indicating that the user's data is up-to-date and synced.", "isCommentAutoGenerated" : true }, + "System" : { + "comment" : "Theme option that follows the device system appearance." + }, "Syncing..." : { "comment" : "Text displayed in the iCloud sync status label when the initial sync is not yet complete.", "isCommentAutoGenerated" : true diff --git a/Sources/Bedrock/Theme/AppTheme.swift b/Sources/Bedrock/Theme/AppTheme.swift new file mode 100644 index 0000000..12d721d --- /dev/null +++ b/Sources/Bedrock/Theme/AppTheme.swift @@ -0,0 +1,50 @@ +// +// AppTheme.swift +// Bedrock +// +// App appearance theme options. +// + +import SwiftUI + +/// The available appearance themes for the app. +/// +/// Use with `ThemeProviding` protocol and `SettingsThemePicker` for a complete +/// theme selection UI in your settings. +/// +/// ## Example +/// +/// ```swift +/// // In your settings store +/// var theme: AppTheme { +/// get { cloudSync.data.theme } +/// set { update { $0.theme = newValue } } +/// } +/// +/// // In your app's main scene +/// .preferredColorScheme(settingsStore.theme.colorScheme) +/// ``` +public enum AppTheme: String, Codable, CaseIterable, Sendable { + case system + case light + case dark + + /// The display name for the theme option. + public var displayName: String { + switch self { + case .system: String(localized: "System", bundle: .module) + case .light: String(localized: "Light", bundle: .module) + case .dark: String(localized: "Dark", bundle: .module) + } + } + + /// Converts to SwiftUI's ColorScheme. + /// Returns nil for system (follows device setting). + public var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} diff --git a/Sources/Bedrock/Views/Settings/SettingsThemePicker.swift b/Sources/Bedrock/Views/Settings/SettingsThemePicker.swift new file mode 100644 index 0000000..775d8ea --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsThemePicker.swift @@ -0,0 +1,116 @@ +// +// SettingsThemePicker.swift +// Bedrock +// +// A reusable theme picker for settings screens. +// + +import SwiftUI + +// MARK: - ThemeProviding Protocol + +/// Protocol for view models that support theme selection. +/// +/// Conform your settings view model to this protocol to use `SettingsThemePicker`. +/// +/// ```swift +/// @Observable @MainActor +/// final class SettingsStore: ThemeProviding { +/// var theme: AppTheme { +/// get { cloudSync.data.theme } +/// set { update { $0.theme = newValue } } +/// } +/// } +/// ``` +@MainActor +public protocol ThemeProviding: AnyObject, Observable { + /// The current app theme selection. + var theme: AppTheme { get set } +} + +// MARK: - Settings Theme Picker + +/// A reusable theme picker for settings screens. +/// +/// Use this in settings screens to provide appearance theme controls. +/// +/// ```swift +/// SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { +/// SettingsThemePicker( +/// store: settingsStore, +/// accentColor: AppAccent.primary +/// ) +/// } +/// ``` +public struct SettingsThemePicker: View { + /// The store conforming to ThemeProviding. + @Bindable public var store: Store + + /// The accent color for the selected option. + public let accentColor: Color + + /// The title text. + public let title: String? + + /// The subtitle text. + public let subtitle: String? + + /// Creates a settings theme picker. + /// - Parameters: + /// - store: The store conforming to ThemeProviding. + /// - accentColor: Accent color for selected option (default: primary accent). + /// - title: Title text (default: "Appearance"). + /// - subtitle: Subtitle text (default: "Choose light, dark, or follow system"). + public init( + store: Store, + accentColor: Color = .Accent.primary, + title: String? = nil, + subtitle: String? = nil + ) { + self.store = store + self.accentColor = accentColor + self.title = title + self.subtitle = subtitle + } + + // MARK: - Localized Defaults + + private var resolvedTitle: String { + title ?? String(localized: "Appearance", bundle: .module) + } + + private var resolvedSubtitle: String { + subtitle ?? String(localized: "Choose light, dark, or follow system", bundle: .module) + } + + public var body: some View { + SettingsSegmentedPicker( + title: resolvedTitle, + subtitle: resolvedSubtitle, + options: AppTheme.allCases.map { ($0.displayName, $0) }, + selection: $store.theme, + accentColor: accentColor + ) + } +} + +// MARK: - Preview + +#Preview { + @Previewable @State var theme: AppTheme = .system + + VStack(spacing: Design.Spacing.medium) { + Text("Theme Picker") + .font(.headline) + .foregroundStyle(.white) + + SettingsSegmentedPicker( + title: "Appearance", + subtitle: "Choose light, dark, or follow system", + options: AppTheme.allCases.map { ($0.displayName, $0) }, + selection: $theme + ) + } + .padding() + .background(Color.Surface.overlay) +}