commit fa7d848f522efa0126a569aebdbfbaef3e0b30c1 Author: Matt Bruce Date: Fri Jan 2 11:58:30 2026 -0600 Initial commit: Bedrock design system and UI component library - Design system: spacing, typography, colors, animations, opacity, shadows - Protocol-based color theming (AppColorTheme) - Settings UI components: toggles, pickers, selection indicators - Sound and haptic feedback manager (generic AppSound protocol) - Onboarding state management - Cloud sync manager (PersistableData protocol) - Visual effects: ConfettiView, PulsingModifier - Debug utilities: DebugBorderModifier - Device information utilities (cross-platform) - Unit tests for design constants diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a3bc6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# Xcode +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +DerivedData/ +xcuserdata/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Vim +*.swp +*.swo + +# VSCode +.vscode/ + +# Temporary files +*.tmp +*.temp diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c8d5568 --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Bedrock", + defaultLocalization: "en", + platforms: [ + .iOS(.v18), + .macOS(.v15) + ], + products: [ + .library( + name: "Bedrock", + targets: ["Bedrock"] + ) + ], + dependencies: [], + targets: [ + .target( + name: "Bedrock", + dependencies: [], + resources: [ + .process("Resources") + ] + ), + .testTarget( + name: "BedrockTests", + dependencies: ["Bedrock"] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bb87b7 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# Bedrock + +A foundational design system and UI component library for building consistent, beautiful SwiftUI applications. + +## Overview + +Bedrock is designed to be the foundation upon which apps are built, providing: + +- **Design System**: Consistent spacing, typography, and animations +- **Color Protocols**: Define consistent color naming with custom palettes per app +- **Settings Components**: Ready-to-use toggle, picker, and selector views +- **Utilities**: Common helpers for device detection, debugging, and more + +## Installation + +Add Bedrock as a dependency in your `Package.swift`: + +```swift +dependencies: [ + .package(path: "../Bedrock") +] +``` + +Then add it to your target: + +```swift +.target( + name: "YourApp", + dependencies: ["Bedrock"] +) +``` + +## Usage + +### Design Constants + +Use the `Design` enum for consistent spacing, sizing, and styling: + +```swift +import Bedrock + +struct MyView: View { + var body: some View { + VStack(spacing: Design.Spacing.medium) { + Text("Hello") + .font(.system(size: Design.BaseFontSize.title)) + .padding(Design.Spacing.large) + + Button("Tap Me") { } + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } + } +} +``` + +### Color System + +Bedrock provides a **protocol-based color system** that enforces consistent naming while allowing apps to define their own palettes. + +#### Using Default Colors + +Bedrock includes neutral default colors that work out of the box: + +```swift +Text("Primary Text") + .foregroundStyle(Color.Text.primary) + +Text("Secondary Text") + .foregroundStyle(Color.Text.secondary) + +VStack { } + .background(Color.Surface.primary) +``` + +#### Creating Custom Color Themes + +Apps can define custom color palettes by conforming to the color protocols: + +```swift +// In CasinoKit - define casino-themed accent colors +public enum CasinoAccentColors: AccentColorProvider { + public static let primary = Color(red: 0.9, green: 0.75, blue: 0.3) // Gold + public static let light = Color(red: 1.0, green: 0.85, blue: 0.4) + public static let dark = Color(red: 0.7, green: 0.55, blue: 0.2) + public static let secondary = Color(red: 0.2, green: 0.7, blue: 0.7) // Teal +} + +// Create a complete theme +public enum CasinoTheme: AppColorTheme { + public typealias Surface = CasinoSurfaceColors + public typealias Text = DefaultTextColors // Reuse Bedrock defaults + public typealias Accent = CasinoAccentColors // Custom gold accents + public typealias Button = CasinoButtonColors + public typealias Status = DefaultStatusColors // Reuse Bedrock defaults + public typealias Border = DefaultBorderColors + public typealias Interactive = DefaultInteractiveColors +} + +// Use in views +Button("Deal") { } + .background(CasinoTheme.Accent.primary) +``` + +#### Available Color Protocols + +| Protocol | Properties | +|----------|------------| +| `SurfaceColorProvider` | primary, secondary, tertiary, overlay, card, groupedFill, sectionFill | +| `TextColorProvider` | primary, secondary, tertiary, disabled, placeholder, inverse | +| `AccentColorProvider` | primary, light, dark, secondary | +| `ButtonColorProvider` | primaryLight, primaryDark, secondary, destructive, cancelText | +| `StatusColorProvider` | success, warning, error, info | +| `BorderColorProvider` | subtle, standard, emphasized, selected | +| `InteractiveColorProvider` | selected, hover, pressed, focus | + +### Settings Components + +Ready-to-use settings UI components: + +```swift +// Toggle with title and subtitle +SettingsToggle( + title: "Notifications", + subtitle: "Receive push notifications", + isOn: $notificationsEnabled +) + +// Volume slider +VolumePicker(label: "Volume", volume: $soundVolume) + +// Segmented picker +SegmentedPicker( + title: "Theme", + options: [("Light", "light"), ("Dark", "dark"), ("Auto", "auto")], + selection: $theme +) + +// Selectable row +SelectableRow( + title: "Premium Plan", + subtitle: "Unlock all features", + isSelected: plan == .premium, + badge: { BadgePill(text: "$9.99", isSelected: plan == .premium) }, + action: { plan = .premium } +) +``` + +### Device Detection + +Adapt your UI based on device characteristics: + +```swift +if DeviceInfo.isPad { + SidebarView() +} else { + TabBarView() +} +``` + +## Design System Reference + +### Spacing + +| Name | Value | Usage | +|------|-------|-------| +| `xxxSmall` | 1pt | Hairline spacing | +| `xxSmall` | 2pt | Minimal spacing | +| `xSmall` | 4pt | Tight spacing | +| `small` | 8pt | Compact spacing | +| `medium` | 12pt | Default spacing | +| `large` | 16pt | Comfortable spacing | +| `xLarge` | 20pt | Generous spacing | +| `xxLarge` | 24pt | Section spacing | +| `xxxLarge` | 32pt | Large section spacing | + +### Opacity + +| Name | Value | Usage | +|------|-------|-------| +| `verySubtle` | 0.05 | Barely visible | +| `subtle` | 0.1 | Subtle backgrounds | +| `hint` | 0.2 | Hints and disabled states | +| `light` | 0.3 | Light overlays | +| `medium` | 0.5 | Medium emphasis | +| `strong` | 0.7 | Strong emphasis | +| `heavy` | 0.8 | Heavy overlays | +| `almostFull` | 0.9 | Nearly opaque | + +### Default Colors + +Default colors use a neutral blue accent. Apps should define custom themes for branding: + +| Category | Purpose | +|----------|---------| +| `Color.Surface.*` | Background colors | +| `Color.Text.*` | Text colors | +| `Color.Accent.*` | Accent/highlight colors (default: blue) | +| `Color.Button.*` | Button colors | +| `Color.Status.*` | Success/warning/error colors | +| `Color.Border.*` | Border and divider colors | +| `Color.Interactive.*` | Interactive state colors | + +## Requirements + +- iOS 18.0+ +- macOS 15.0+ +- Swift 6.0+ diff --git a/Sources/Bedrock/Audio/SoundManager.swift b/Sources/Bedrock/Audio/SoundManager.swift new file mode 100644 index 0000000..d6a58df --- /dev/null +++ b/Sources/Bedrock/Audio/SoundManager.swift @@ -0,0 +1,305 @@ +// +// SoundManager.swift +// Bedrock +// +// Generic sound and haptic feedback manager. +// + +import AVFoundation +import Foundation +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +// MARK: - Sound Protocol + +/// Protocol for defining app-specific sounds. +/// +/// Implement this protocol to define your app's sound effects, then use +/// `SoundManager` to play them. +/// +/// ## Example +/// +/// ```swift +/// enum MyAppSound: String, AppSound { +/// case success, error, notification +/// +/// var resourceName: String { rawValue } +/// var resourceExtension: String { "mp3" } +/// var bundle: Bundle { .main } +/// var fallbackSystemSoundID: UInt32? { nil } +/// } +/// ``` +public protocol AppSound: Sendable { + /// The resource name of the sound file. + var resourceName: String { get } + + /// The file extension (e.g., "mp3", "wav", "caf"). + var resourceExtension: String { get } + + /// The bundle containing the sound resource. + var bundle: Bundle { get } + + /// Optional system sound ID to play as fallback. + var fallbackSystemSoundID: UInt32? { get } +} + +// MARK: - Haptic Type + +/// Standard haptic feedback types. +public enum HapticType: Sendable { + case light + case medium + case heavy + case success + case warning + case error + case selection + case softImpact + case rigidImpact +} + +// MARK: - Sound Manager + +/// Manages audio playback and haptic feedback. +/// +/// This is a generic sound manager that works with any app-defined sound type +/// conforming to `AppSound`. +/// +/// ## Example +/// +/// ```swift +/// let soundManager = SoundManager.shared +/// soundManager.play(MyAppSound.success) +/// soundManager.playHaptic(.success) +/// ``` +@MainActor +@Observable +public final class SoundManager { + + // MARK: - Singleton + + /// Shared instance for convenience. + public static let shared = SoundManager() + + // MARK: - Properties + + /// Master volume for sound effects (0.0 to 1.0). + public var volume: Float = 1.0 { + didSet { + for player in audioPlayers.values { + player.volume = volume + } + } + } + + /// Whether sound is enabled. + public var isSoundEnabled: Bool = true + + /// Whether haptic feedback is enabled. + public var isHapticEnabled: Bool = true + + // MARK: - Private Properties + + private var audioPlayers: [String: AVAudioPlayer] = [:] + + #if canImport(UIKit) + private let impactLight = UIImpactFeedbackGenerator(style: .light) + private let impactMedium = UIImpactFeedbackGenerator(style: .medium) + private let impactHeavy = UIImpactFeedbackGenerator(style: .heavy) + private let impactSoft = UIImpactFeedbackGenerator(style: .soft) + private let impactRigid = UIImpactFeedbackGenerator(style: .rigid) + private let notification = UINotificationFeedbackGenerator() + private let selection = UISelectionFeedbackGenerator() + #endif + + // MARK: - Initialization + + public init() { + configureAudioSession() + } + + // MARK: - Audio Session + + private func configureAudioSession() { + #if canImport(UIKit) && !os(watchOS) + do { + try AVAudioSession.sharedInstance().setCategory( + .ambient, + mode: .default, + options: [.mixWithOthers] + ) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Failed to configure audio session: \(error)") + } + #endif + } + + // MARK: - Sound Playback + + /// Plays a sound effect. + /// - Parameter sound: The sound to play, conforming to `AppSound`. + public func play(_ sound: S) { + guard isSoundEnabled else { return } + + let key = "\(sound.resourceName).\(sound.resourceExtension)" + + // Try cached player first + if let player = audioPlayers[key] { + player.currentTime = 0 + player.volume = volume + player.play() + return + } + + // Try to load the sound file + if let url = sound.bundle.url( + forResource: sound.resourceName, + withExtension: sound.resourceExtension + ) { + do { + let player = try AVAudioPlayer(contentsOf: url) + player.volume = volume + player.prepareToPlay() + audioPlayers[key] = player + player.play() + return + } catch { + print("Failed to load sound \(key): \(error)") + } + } + + // Fall back to system sound if available + if let systemSoundID = sound.fallbackSystemSoundID { + AudioServicesPlaySystemSound(systemSoundID) + } + } + + /// Plays a system sound by ID. + /// - Parameter soundID: The system sound ID to play. + public func playSystemSound(_ soundID: UInt32) { + guard isSoundEnabled else { return } + AudioServicesPlaySystemSound(soundID) + } + + /// Preloads sounds for faster playback later. + /// - Parameter sounds: Array of sounds to preload. + public func preload(_ sounds: [S]) { + for sound in sounds { + let key = "\(sound.resourceName).\(sound.resourceExtension)" + guard audioPlayers[key] == nil else { continue } + + if let url = sound.bundle.url( + forResource: sound.resourceName, + withExtension: sound.resourceExtension + ) { + do { + let player = try AVAudioPlayer(contentsOf: url) + player.volume = volume + player.prepareToPlay() + audioPlayers[key] = player + } catch { + print("Failed to preload sound \(key): \(error)") + } + } + } + } + + // MARK: - Haptic Feedback + + /// Plays haptic feedback. + /// - Parameter type: The type of haptic feedback to play. + public func playHaptic(_ type: HapticType) { + #if canImport(UIKit) + guard isHapticEnabled else { return } + + switch type { + case .light: + impactLight.impactOccurred() + case .medium: + impactMedium.impactOccurred() + case .heavy: + impactHeavy.impactOccurred() + case .success: + notification.notificationOccurred(.success) + case .warning: + notification.notificationOccurred(.warning) + case .error: + notification.notificationOccurred(.error) + case .selection: + selection.selectionChanged() + case .softImpact: + impactSoft.impactOccurred() + case .rigidImpact: + impactRigid.impactOccurred() + } + #endif + } + + /// Plays haptic with custom intensity. + /// - Parameters: + /// - style: The impact style. + /// - intensity: The intensity (0.0 to 1.0). + public func playHaptic(style: HapticType, intensity: CGFloat) { + #if canImport(UIKit) + guard isHapticEnabled else { return } + + let generator: UIImpactFeedbackGenerator + switch style { + case .light: + generator = impactLight + case .medium: + generator = impactMedium + case .heavy: + generator = impactHeavy + case .softImpact: + generator = impactSoft + case .rigidImpact: + generator = impactRigid + default: + playHaptic(style) + return + } + generator.impactOccurred(intensity: intensity) + #endif + } + + /// Prepares haptic generators for immediate feedback. + public func prepareHaptics() { + #if canImport(UIKit) + impactLight.prepare() + impactMedium.prepare() + impactHeavy.prepare() + notification.prepare() + selection.prepare() + #endif + } + + // MARK: - Cleanup + + /// Clears all cached audio players. + public func clearCache() { + for player in audioPlayers.values { + player.stop() + } + audioPlayers.removeAll() + } +} + +// MARK: - Common System Sounds + +/// Common iOS system sound IDs for convenience. +public enum SystemSound { + public static let tap: UInt32 = 1104 + public static let click: UInt32 = 1105 + public static let tock: UInt32 = 1306 + public static let tink: UInt32 = 1057 + public static let pop: UInt32 = 1306 + public static let swoosh: UInt32 = 1001 + public static let alert: UInt32 = 1007 + public static let error: UInt32 = 1053 + public static let success: UInt32 = 1025 +} diff --git a/Sources/Bedrock/Bedrock.swift b/Sources/Bedrock/Bedrock.swift new file mode 100644 index 0000000..962672c --- /dev/null +++ b/Sources/Bedrock/Bedrock.swift @@ -0,0 +1,47 @@ +// +// Bedrock.swift +// Bedrock +// +// A foundational design system and UI component library for building +// consistent, beautiful SwiftUI applications. +// +// Bedrock provides: +// - Design constants (spacing, typography, colors, animations) +// - Reusable settings UI components +// - Common utilities and extensions +// + +import SwiftUI + +/// The Bedrock module provides foundational design constants and reusable +/// UI components for building consistent SwiftUI applications. +/// +/// ## Overview +/// +/// Bedrock is designed to be the foundation upon which apps are built, +/// providing: +/// +/// - **Design System**: Consistent spacing, typography, colors, and animations +/// - **Settings Components**: Ready-to-use toggle, picker, and selector views +/// - **Utilities**: Common helpers for device detection, debugging, and more +/// +/// ## Usage +/// +/// Import Bedrock and use the design constants: +/// +/// ```swift +/// import Bedrock +/// +/// struct MyView: View { +/// var body: some View { +/// Text("Hello") +/// .padding(Design.Spacing.medium) +/// .background(Color.Surface.primary) +/// } +/// } +/// ``` +/// Bedrock library metadata and version information. +public enum BedrockInfo { + /// The current version of the Bedrock library. + public static let version = "1.0.0" +} diff --git a/Sources/Bedrock/Exports.swift b/Sources/Bedrock/Exports.swift new file mode 100644 index 0000000..0ba7d59 --- /dev/null +++ b/Sources/Bedrock/Exports.swift @@ -0,0 +1,14 @@ +// +// Exports.swift +// Bedrock +// +// Re-exports for convenient importing. +// + +import SwiftUI + +// Re-export SwiftUI for consumers who only import Bedrock +@_exported import SwiftUI + +// Re-export Foundation for common types +@_exported import Foundation diff --git a/Sources/Bedrock/Models/OnboardingState.swift b/Sources/Bedrock/Models/OnboardingState.swift new file mode 100644 index 0000000..a01b7d1 --- /dev/null +++ b/Sources/Bedrock/Models/OnboardingState.swift @@ -0,0 +1,141 @@ +// +// OnboardingState.swift +// Bedrock +// +// Tracks first-time user onboarding progress and contextual hints. +// Generic implementation that works for any app. +// + +import Foundation +import SwiftUI + +/// Observable state for managing user onboarding and progressive feature discovery. +/// +/// Use this to track whether users have seen welcome screens, tutorials, and +/// contextual hints throughout your app. +/// +/// ## Example +/// +/// ```swift +/// @Observable +/// class AppState { +/// let onboarding = OnboardingState(appIdentifier: "myApp") +/// +/// var showWelcome: Bool { +/// !onboarding.hasCompletedWelcome +/// } +/// } +/// ``` +@Observable +@MainActor +public final class OnboardingState { + + // MARK: - Properties + + /// Whether the user has launched the app before. + public var hasLaunchedBefore: Bool = false + + /// Whether the user has completed the welcome/intro screen. + public var hasCompletedWelcome: Bool = false + + /// Whether the user is in tutorial mode (shows all contextual hints). + public var isTutorialMode: Bool = false + + /// Set of hint keys that have been shown to the user. + public var hintsShown: Set = [] + + /// Hint keys registered by the app for automatic skipping. + private var registeredHintKeys: Set = [] + + // MARK: - Private Properties + + private let persistenceKey: String + + // MARK: - Initialization + + /// Creates an onboarding state manager. + /// - Parameter appIdentifier: Unique identifier for this app's onboarding data. + public init(appIdentifier: String) { + self.persistenceKey = "onboarding.\(appIdentifier)" + load() + } + + /// Registers hint keys that should be marked as shown when skipping onboarding. + /// Call this once during app setup with all hint keys used by the app. + public func registerHintKeys(_ keys: Set) { + registeredHintKeys = keys + } + + /// Registers hint keys that should be marked as shown when skipping onboarding. + public func registerHintKeys(_ keys: String...) { + registeredHintKeys = Set(keys) + } + + // MARK: - Hint Management + + /// Marks a hint as shown and persists the state. + public func markHintShown(_ key: String) { + hintsShown.insert(key) + save() + } + + /// Returns whether a specific hint should be shown. + public func shouldShowHint(_ key: String) -> Bool { + isTutorialMode || !hintsShown.contains(key) + } + + /// Marks the welcome screen as completed. + public func completeWelcome() { + hasLaunchedBefore = true + hasCompletedWelcome = true + save() + } + + /// Skips onboarding entirely - marks all registered hints as shown. + public func skipOnboarding() { + for key in registeredHintKeys { + hintsShown.insert(key) + } + hasLaunchedBefore = true + hasCompletedWelcome = true + save() + } + + /// Enables tutorial mode (shows all hints again). + public func startTutorialMode() { + isTutorialMode = true + } + + /// Disables tutorial mode. + public func endTutorialMode() { + isTutorialMode = false + } + + /// Resets all onboarding state. + public func reset() { + hasLaunchedBefore = false + hasCompletedWelcome = false + isTutorialMode = false + hintsShown.removeAll() + save() + } + + // MARK: - Persistence + + private func load() { + let defaults = UserDefaults.standard + hasLaunchedBefore = defaults.bool(forKey: "\(persistenceKey).hasLaunched") + hasCompletedWelcome = defaults.bool(forKey: "\(persistenceKey).hasCompletedWelcome") + + if let hintsData = defaults.array(forKey: "\(persistenceKey).hintsShown") as? [String] { + hintsShown = Set(hintsData) + } + } + + private func save() { + let defaults = UserDefaults.standard + defaults.set(hasLaunchedBefore, forKey: "\(persistenceKey).hasLaunched") + defaults.set(hasCompletedWelcome, forKey: "\(persistenceKey).hasCompletedWelcome") + defaults.set(Array(hintsShown), forKey: "\(persistenceKey).hintsShown") + } +} diff --git a/Sources/Bedrock/Resources/Localizable.xcstrings b/Sources/Bedrock/Resources/Localizable.xcstrings new file mode 100644 index 0000000..3d7b7a9 --- /dev/null +++ b/Sources/Bedrock/Resources/Localizable.xcstrings @@ -0,0 +1 @@ +{"sourceLanguage":"en","strings":{},"version":"1.0"} diff --git a/Sources/Bedrock/Storage/CloudSyncManager.swift b/Sources/Bedrock/Storage/CloudSyncManager.swift new file mode 100644 index 0000000..43be448 --- /dev/null +++ b/Sources/Bedrock/Storage/CloudSyncManager.swift @@ -0,0 +1,415 @@ +// +// CloudSyncManager.swift +// Bedrock +// +// Generic iCloud sync manager for app data. +// Uses NSUbiquitousKeyValueStore for lightweight cross-device sync. +// + +import Foundation +import SwiftUI + +// MARK: - PersistableData Protocol + +/// Protocol for data that can be persisted locally and synced to iCloud. +/// +/// Conform to this protocol to enable automatic iCloud sync for your app's data. +/// +/// ## Example +/// +/// ```swift +/// struct GameStats: PersistableData { +/// static var dataIdentifier = "gameStats" +/// static var empty = GameStats() +/// +/// var gamesPlayed: Int = 0 +/// var lastModified: Date = .now +/// +/// var syncPriority: Int { gamesPlayed } // Higher = more progress +/// } +/// ``` +public protocol PersistableData: Codable, Sendable { + /// Unique identifier for this data type (e.g., "userProfile", "gameStats"). + /// Used as the storage key. + static var dataIdentifier: String { get } + + /// Priority value for conflict resolution. + /// When cloud and local data conflict, the version with higher priority wins. + /// Typically represents "progress" (e.g., games played, items collected). + var syncPriority: Int { get } + + /// Last time this data was modified. + var lastModified: Date { get set } + + /// Creates empty/default data. + static var empty: Self { get } +} + +// MARK: - CloudSyncManager + +/// Manages data persistence to local storage and iCloud. +/// +/// Features: +/// - Automatic sync with iCloud using `NSUbiquitousKeyValueStore` +/// - Local fallback using `UserDefaults` +/// - Conflict resolution based on `syncPriority` +/// - Change notifications when data updates from another device +/// +/// ## Usage +/// +/// ```swift +/// @Observable +/// class AppState { +/// let syncManager = CloudSyncManager() +/// +/// var userData: UserData { +/// syncManager.data +/// } +/// +/// func saveProgress() { +/// syncManager.update { data in +/// data.level += 1 +/// } +/// } +/// } +/// ``` +@MainActor +@Observable +public final class CloudSyncManager { + + // MARK: - Properties + + /// The current data. + public private(set) var data: T + + /// Whether iCloud sync is available (user signed in to iCloud). + public var iCloudAvailable: Bool { + let token = FileManager.default.ubiquityIdentityToken + let available = token != nil + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud available = \(available)") + return available + } + + /// Whether iCloud sync is enabled by the user. + public var iCloudEnabled: Bool { + get { UserDefaults.standard.bool(forKey: iCloudEnabledKey) } + set { + UserDefaults.standard.set(newValue, forKey: iCloudEnabledKey) + if newValue { sync() } + } + } + + /// Last successful sync date. + public private(set) var lastSyncDate: Date? + + /// Whether a sync is currently in progress. + public private(set) var isSyncing: Bool = false + + /// Human-readable sync status. + public private(set) var syncStatus: String = "" + + /// Whether initial iCloud sync has completed. + public private(set) var hasCompletedInitialSync: Bool = false + + /// Callback when data is received from iCloud. + public var onCloudDataReceived: ((T) -> Void)? + + // MARK: - Private Properties + + private var _iCloudStore: NSUbiquitousKeyValueStore? + private var _iCloudStoreInitialized = false + + private var iCloudStore: NSUbiquitousKeyValueStore? { + if _iCloudStoreInitialized { + return _iCloudStore + } + + _iCloudStoreInitialized = true + + guard iCloudAvailable else { + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud not available") + return nil + } + + _iCloudStore = NSUbiquitousKeyValueStore.default + return _iCloudStore + } + + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private var localKey: String { "\(T.dataIdentifier).data" } + private var cloudKey: String { "\(T.dataIdentifier).data" } + private var syncDateKey: String { "\(T.dataIdentifier).lastSync" } + private var iCloudEnabledKey: String { "\(T.dataIdentifier).iCloudEnabled" } + + // MARK: - Initialization + + public init() { + self.data = T.empty + + // Default iCloud enabled to true + if UserDefaults.standard.object(forKey: iCloudEnabledKey) == nil { + UserDefaults.standard.set(true, forKey: iCloudEnabledKey) + } + + // Register for iCloud changes + if iCloudAvailable, let store = iCloudStore { + NotificationCenter.default.addObserver( + forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: store, + queue: .main + ) { [weak self] notification in + guard let userInfo = notification.userInfo, + let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int, + let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else { + return + } + Task { @MainActor in + self?.handleCloudChange(reason: reason, changedKeys: changedKeys) + } + } + + // Trigger iCloud sync + if iCloudEnabled { + store.synchronize() + } + } + + // Load data + self.data = load() + + // On fresh install, wait for iCloud data + if data.syncPriority == 0 && iCloudAvailable && iCloudEnabled { + scheduleDelayedCloudCheck() + } + } + + // MARK: - Save + + /// Saves the current data locally and to iCloud. + public func save(_ newData: T) { + var dataToSave = newData + dataToSave.lastModified = Date() + self.data = dataToSave + + guard let encoded = try? encoder.encode(dataToSave) else { + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Failed to encode data") + return + } + + // Save locally + UserDefaults.standard.set(encoded, forKey: localKey) + + // Save to iCloud + if iCloudAvailable && iCloudEnabled, let store = iCloudStore { + store.set(encoded, forKey: cloudKey) + store.set(Date(), forKey: syncDateKey) + store.synchronize() + lastSyncDate = Date() + syncStatus = "Synced" + } + + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Saved (priority: \(dataToSave.syncPriority))") + } + + /// Updates and saves data in one call. + public func update(_ transform: (inout T) -> Void) { + var updated = data + transform(&updated) + save(updated) + } + + // MARK: - Load + + /// Loads data, preferring iCloud if it has higher priority. + public func load() -> T { + let localData = loadLocal() + let cloudData = loadCloud() + + let finalData: T + + switch (localData, cloudData) { + case (nil, nil): + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: No saved data, using empty") + finalData = T.empty + + case (let local?, nil): + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using local data") + finalData = local + + case (nil, let cloud?): + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using iCloud data") + finalData = cloud + + case (let local?, let cloud?): + // Use whichever has higher priority (more progress) + if cloud.syncPriority > local.syncPriority { + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using iCloud (priority \(cloud.syncPriority) > \(local.syncPriority))") + finalData = cloud + // Update local with cloud data + if let encoded = try? encoder.encode(cloud) { + UserDefaults.standard.set(encoded, forKey: localKey) + } + } else if local.lastModified > cloud.lastModified { + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using local (newer)") + finalData = local + } else { + finalData = local + } + } + + return finalData + } + + private func loadLocal() -> T? { + guard let data = UserDefaults.standard.data(forKey: localKey), + let decoded = try? decoder.decode(T.self, from: data) else { + return nil + } + return decoded + } + + private func loadCloud() -> T? { + guard iCloudAvailable && iCloudEnabled, + let store = iCloudStore, + let data = store.data(forKey: cloudKey), + let decoded = try? decoder.decode(T.self, from: data) else { + return nil + } + + if let syncDate = store.object(forKey: syncDateKey) as? Date { + lastSyncDate = syncDate + } + + return decoded + } + + // MARK: - Sync + + /// Forces a sync with iCloud. + public func sync() { + guard iCloudAvailable && iCloudEnabled else { + syncStatus = iCloudAvailable ? "Sync disabled" : "iCloud unavailable" + return + } + + guard let store = iCloudStore else { + syncStatus = "iCloud unavailable" + return + } + + isSyncing = true + syncStatus = "Syncing..." + + store.synchronize() + + // Reload to get any changes + let latestData = load() + if latestData.syncPriority != data.syncPriority { + data = latestData + onCloudDataReceived?(latestData) + } + + isSyncing = false + lastSyncDate = Date() + syncStatus = "Synced" + } + + // MARK: - Reset + + /// Clears all saved data locally and from iCloud. + public func reset() { + UserDefaults.standard.removeObject(forKey: localKey) + + if iCloudAvailable, let store = iCloudStore { + store.removeObject(forKey: cloudKey) + store.removeObject(forKey: syncDateKey) + store.synchronize() + } + + data = T.empty + syncStatus = "Data cleared" + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: All data cleared") + } + + // MARK: - Private Methods + + private func scheduleDelayedCloudCheck() { + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Scheduling delayed cloud check...") + + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + + guard let store = iCloudStore else { + hasCompletedInitialSync = true + return + } + + _ = store.synchronize() + + if let cloudData = loadCloud(), cloudData.syncPriority > data.syncPriority { + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Cloud has more data, updating...") + data = cloudData + onCloudDataReceived?(cloudData) + + if let encoded = try? encoder.encode(cloudData) { + UserDefaults.standard.set(encoded, forKey: localKey) + } + + NotificationCenter.default.post( + name: .persistedDataDidChange, + object: nil, + userInfo: ["dataIdentifier": T.dataIdentifier] + ) + } + + hasCompletedInitialSync = true + } + } + + private func handleCloudChange(reason: Int, changedKeys: [String]) { + guard changedKeys.contains(cloudKey) else { return } + + switch reason { + case NSUbiquitousKeyValueStoreServerChange, + NSUbiquitousKeyValueStoreInitialSyncChange: + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Data changed from another device") + syncStatus = "Received update" + + if let cloudData = loadCloud(), cloudData.syncPriority > data.syncPriority { + data = cloudData + onCloudDataReceived?(cloudData) + + if let encoded = try? encoder.encode(cloudData) { + UserDefaults.standard.set(encoded, forKey: localKey) + } + + NotificationCenter.default.post( + name: .persistedDataDidChange, + object: nil, + userInfo: ["dataIdentifier": T.dataIdentifier] + ) + } + + case NSUbiquitousKeyValueStoreQuotaViolationChange: + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud quota exceeded") + syncStatus = "Storage full" + + case NSUbiquitousKeyValueStoreAccountChange: + Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud account changed") + syncStatus = "Account changed" + data = load() + + default: + break + } + } +} + +// MARK: - Notifications + +public extension Notification.Name { + /// Posted when persisted data changes from iCloud. + static let persistedDataDidChange = Notification.Name("persistedDataDidChange") +} diff --git a/Sources/Bedrock/Theme/ColorProtocols.swift b/Sources/Bedrock/Theme/ColorProtocols.swift new file mode 100644 index 0000000..bb55889 --- /dev/null +++ b/Sources/Bedrock/Theme/ColorProtocols.swift @@ -0,0 +1,180 @@ +// +// ColorProtocols.swift +// Bedrock +// +// Protocols defining semantic color categories for consistent theming. +// Apps conform to these protocols to provide their own color palettes +// while maintaining consistent naming conventions. +// + +import SwiftUI + +// MARK: - Surface Colors Protocol + +/// Protocol defining background and surface colors. +/// +/// Conform to this protocol to provide consistent surface colors for your app. +public protocol SurfaceColorProvider { + /// Primary background color (darkest/base). + static var primary: Color { get } + + /// Secondary/elevated surface. + static var secondary: Color { get } + + /// Tertiary/card surface. + static var tertiary: Color { get } + + /// Overlay background (for sheets/modals). + static var overlay: Color { get } + + /// Card/grouped element background. + static var card: Color { get } + + /// Subtle fill for grouped content. + static var groupedFill: Color { get } + + /// Section fill for list sections. + static var sectionFill: Color { get } +} + +// MARK: - Text Colors Protocol + +/// Protocol defining text and label colors. +/// +/// Conform to this protocol to provide consistent text colors for your app. +public protocol TextColorProvider { + /// Primary text color (highest emphasis). + static var primary: Color { get } + + /// Secondary/muted text. + static var secondary: Color { get } + + /// Tertiary/hint text. + static var tertiary: Color { get } + + /// Disabled text. + static var disabled: Color { get } + + /// Placeholder text. + static var placeholder: Color { get } + + /// Inverse text (for contrasting backgrounds). + static var inverse: Color { get } +} + +// MARK: - Accent Colors Protocol + +/// Protocol defining accent colors for interactive elements. +/// +/// Conform to this protocol to provide your app's brand accent colors. +public protocol AccentColorProvider { + /// Primary accent color. + static var primary: Color { get } + + /// Light variant of primary accent. + static var light: Color { get } + + /// Dark variant of primary accent. + static var dark: Color { get } + + /// Secondary accent color. + static var secondary: Color { get } +} + +// MARK: - Button Colors Protocol + +/// Protocol defining button-specific colors. +public protocol ButtonColorProvider { + /// Light gradient color for primary buttons. + static var primaryLight: Color { get } + + /// Dark gradient color for primary buttons. + static var primaryDark: Color { get } + + /// Secondary button background. + static var secondary: Color { get } + + /// Destructive action color. + static var destructive: Color { get } + + /// Cancel button text color. + static var cancelText: Color { get } +} + +// MARK: - Status Colors Protocol + +/// Protocol defining semantic status colors. +public protocol StatusColorProvider { + /// Success/positive state. + static var success: Color { get } + + /// Warning state. + static var warning: Color { get } + + /// Error/negative state. + static var error: Color { get } + + /// Informational state. + static var info: Color { get } +} + +// MARK: - Border Colors Protocol + +/// Protocol defining border and divider colors. +public protocol BorderColorProvider { + /// Subtle border. + static var subtle: Color { get } + + /// Standard border. + static var standard: Color { get } + + /// Emphasized border. + static var emphasized: Color { get } + + /// Selected/active border. + static var selected: Color { get } +} + +// MARK: - Interactive Colors Protocol + +/// Protocol defining colors for interactive states. +public protocol InteractiveColorProvider { + /// Selected state. + static var selected: Color { get } + + /// Hover/highlight state. + static var hover: Color { get } + + /// Pressed state. + static var pressed: Color { get } + + /// Focus ring color. + static var focus: Color { get } +} + +// MARK: - Full Theme Protocol + +/// Protocol combining all color providers into a complete app theme. +/// +/// Conform to this protocol to define a complete color theme for your app. +/// Each associated type should conform to its respective color provider protocol. +/// +/// ## Example +/// +/// ```swift +/// enum CasinoTheme: AppColorTheme { +/// typealias Surface = CasinoSurfaceColors +/// typealias Text = CasinoTextColors +/// typealias Accent = CasinoAccentColors +/// // ... etc +/// } +/// ``` +public protocol AppColorTheme { + associatedtype Surface: SurfaceColorProvider + associatedtype Text: TextColorProvider + associatedtype Accent: AccentColorProvider + associatedtype Button: ButtonColorProvider + associatedtype Status: StatusColorProvider + associatedtype Border: BorderColorProvider + associatedtype Interactive: InteractiveColorProvider +} diff --git a/Sources/Bedrock/Theme/Colors.swift b/Sources/Bedrock/Theme/Colors.swift new file mode 100644 index 0000000..ad46475 --- /dev/null +++ b/Sources/Bedrock/Theme/Colors.swift @@ -0,0 +1,137 @@ +// +// Colors.swift +// Bedrock +// +// Default color implementations conforming to the color protocols. +// These provide neutral, app-agnostic defaults that can be used as-is +// or serve as a reference for custom implementations. +// + +import SwiftUI + +// MARK: - Default Surface Colors + +/// Default surface colors - neutral dark theme. +/// +/// Apps can create their own type conforming to `SurfaceColorProvider` +/// to provide custom surface colors while maintaining consistent naming. +public enum DefaultSurfaceColors: SurfaceColorProvider { + public static let primary = Color(red: 0.08, green: 0.10, blue: 0.14) + public static let secondary = Color(red: 0.12, green: 0.14, blue: 0.18) + public static let tertiary = Color(red: 0.16, green: 0.18, blue: 0.22) + public static let overlay = Color(red: 0.08, green: 0.12, blue: 0.18) + public static let card = Color.white.opacity(Design.Opacity.verySubtle) + public static let groupedFill = Color.white.opacity(Design.Opacity.subtle) + public static let sectionFill = Color.white.opacity(Design.Opacity.subtle) +} + +// MARK: - Default Text Colors + +/// Default text colors - white text for dark themes. +public enum DefaultTextColors: TextColorProvider { + public static let primary = Color.white + public static let secondary = Color.white.opacity(Design.Opacity.accent) + public static let tertiary = Color.white.opacity(Design.Opacity.medium) + public static let disabled = Color.white.opacity(Design.Opacity.light) + public static let placeholder = Color.white.opacity(Design.Opacity.overlay) + public static let inverse = Color.black +} + +// MARK: - Default Accent Colors + +/// Default accent colors - neutral blue accent. +/// +/// CasinoKit will override these with gold accents. +public enum DefaultAccentColors: AccentColorProvider { + public static let primary = Color(red: 0.3, green: 0.6, blue: 0.9) + public static let light = Color(red: 0.4, green: 0.7, blue: 1.0) + public static let dark = Color(red: 0.2, green: 0.5, blue: 0.8) + public static let secondary = Color(red: 0.2, green: 0.7, blue: 0.7) +} + +// MARK: - Default Button Colors + +/// Default button colors. +public enum DefaultButtonColors: ButtonColorProvider { + public static let primaryLight = Color(red: 0.4, green: 0.7, blue: 1.0) + public static let primaryDark = Color(red: 0.3, green: 0.6, blue: 0.9) + public static let secondary = Color.white.opacity(Design.Opacity.subtle) + public static let destructive = Color.red.opacity(Design.Opacity.heavy) + public static let cancelText = Color.white.opacity(Design.Opacity.strong) +} + +// MARK: - Default Status Colors + +/// Default status colors - standard semantic colors. +public enum DefaultStatusColors: StatusColorProvider { + public static let success = Color(red: 0.2, green: 0.8, blue: 0.4) + public static let warning = Color(red: 1.0, green: 0.75, blue: 0.2) + public static let error = Color(red: 0.9, green: 0.3, blue: 0.3) + public static let info = Color(red: 0.3, green: 0.6, blue: 0.9) +} + +// MARK: - Default Border Colors + +/// Default border colors. +public enum DefaultBorderColors: BorderColorProvider { + public static let subtle = Color.white.opacity(Design.Opacity.subtle) + public static let standard = Color.white.opacity(Design.Opacity.hint) + public static let emphasized = Color.white.opacity(Design.Opacity.light) + public static let selected = DefaultAccentColors.primary.opacity(Design.Opacity.medium) +} + +// MARK: - Default Interactive Colors + +/// Default interactive state colors. +public enum DefaultInteractiveColors: InteractiveColorProvider { + public static let selected = Color.white.opacity(Design.Opacity.selection) + public static let hover = Color.white.opacity(Design.Opacity.subtle) + public static let pressed = Color.white.opacity(Design.Opacity.hint) + public static let focus = Color(red: 0.4, green: 0.6, blue: 1.0) +} + +// MARK: - Default Theme + +/// The default Bedrock theme combining all default color providers. +/// +/// Use this as a reference implementation or as the base theme for apps +/// that don't need custom colors. +public enum DefaultTheme: AppColorTheme { + public typealias Surface = DefaultSurfaceColors + public typealias Text = DefaultTextColors + public typealias Accent = DefaultAccentColors + public typealias Button = DefaultButtonColors + public typealias Status = DefaultStatusColors + public typealias Border = DefaultBorderColors + public typealias Interactive = DefaultInteractiveColors +} + +// MARK: - Convenience Color Extensions + +/// Convenience extensions for accessing default theme colors. +/// +/// These provide the familiar `Color.Surface.primary` syntax using the +/// default theme. Apps using custom themes should access colors through +/// their theme type directly (e.g., `CasinoTheme.Surface.primary`). +public extension Color { + /// Default surface colors. + typealias Surface = DefaultSurfaceColors + + /// Default text colors. + typealias Text = DefaultTextColors + + /// Default accent colors. + typealias Accent = DefaultAccentColors + + /// Default button colors. + typealias Button = DefaultButtonColors + + /// Default status colors. + typealias Status = DefaultStatusColors + + /// Default border colors. + typealias Border = DefaultBorderColors + + /// Default interactive colors. + typealias Interactive = DefaultInteractiveColors +} diff --git a/Sources/Bedrock/Theme/Design.swift b/Sources/Bedrock/Theme/Design.swift new file mode 100644 index 0000000..5b40c13 --- /dev/null +++ b/Sources/Bedrock/Theme/Design.swift @@ -0,0 +1,275 @@ +// +// Design.swift +// Bedrock +// +// Shared design constants for building consistent UI components. +// + +import SwiftUI + +/// Shared design constants for app UI components. +/// +/// Use these constants throughout your app to maintain visual consistency. +/// All values are carefully chosen to work well across iOS devices and +/// accessibility settings. +/// +/// ## Example +/// +/// ```swift +/// Text("Hello") +/// .padding(Design.Spacing.medium) +/// .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) +/// ``` +public enum Design { + + // MARK: - Debug + + /// Set to true to enable debug logging in Bedrock. + public static let showDebugLogs = true + + /// Logs a message only in debug builds when `showDebugLogs` is enabled. + public static func debugLog(_ message: String) { + #if DEBUG + if showDebugLogs { + print("[Bedrock] \(message)") + } + #endif + } + + // MARK: - Spacing + + /// Consistent spacing values for padding, margins, and gaps. + public enum Spacing { + public static let xxxSmall: CGFloat = 1 + public static let xxSmall: CGFloat = 2 + public static let xSmall: CGFloat = 4 + public static let small: CGFloat = 8 + public static let medium: CGFloat = 12 + public static let large: CGFloat = 16 + public static let xLarge: CGFloat = 20 + public static let xxLarge: CGFloat = 24 + public static let xxxLarge: CGFloat = 32 + public static let xxxxLarge: CGFloat = 40 + + /// Slide distance for toast and notification animations. + public static let toastSlide: CGFloat = 50 + } + + // MARK: - Corner Radius + + /// Standard corner radius values for rounded UI elements. + public enum CornerRadius { + public static let xSmall: CGFloat = 4 + public static let small: CGFloat = 8 + public static let medium: CGFloat = 10 + public static let large: CGFloat = 12 + public static let xLarge: CGFloat = 14 + public static let xxLarge: CGFloat = 20 + public static let xxxLarge: CGFloat = 28 + } + + // MARK: - Line Width + + /// Standard line widths for borders and strokes. + public enum LineWidth { + public static let hairline: CGFloat = 0.5 + public static let thin: CGFloat = 1 + public static let standard: CGFloat = 2 + public static let medium: CGFloat = 2 + public static let thick: CGFloat = 3 + public static let heavy: CGFloat = 4 + } + + // MARK: - Shadow + + /// Shadow radius and offset values for depth effects. + public enum Shadow { + public static let radiusSmall: CGFloat = 4 + public static let radiusMedium: CGFloat = 8 + public static let radiusLarge: CGFloat = 12 + public static let radiusXLarge: CGFloat = 16 + public static let radiusXXLarge: CGFloat = 24 + + public static let offsetSmall: CGFloat = 1 + public static let offsetMedium: CGFloat = 2 + public static let offsetLarge: CGFloat = 3 + public static let offsetXLarge: CGFloat = 5 + } + + // MARK: - Opacity + + /// Standard opacity values for layering and emphasis. + public enum Opacity { + public static let verySubtle: Double = 0.05 + public static let subtle: Double = 0.1 + public static let selection: Double = 0.15 + public static let hint: Double = 0.2 + public static let quarter: Double = 0.25 + public static let light: Double = 0.3 + public static let overlay: Double = 0.4 + public static let medium: Double = 0.5 + public static let secondary: Double = 0.5 + public static let disabled: Double = 0.5 + public static let accent: Double = 0.6 + public static let strong: Double = 0.7 + public static let heavy: Double = 0.8 + public static let nearOpaque: Double = 0.85 + public static let almostFull: Double = 0.9 + } + + // MARK: - Animation + + /// Animation timing and duration values. + public enum Animation { + public static let instant: Double = 0.1 + public static let quick: Double = 0.2 + public static let standard: Double = 0.3 + public static let springDuration: Double = 0.4 + public static let springBounce: Double = 0.3 + public static let slow: Double = 0.5 + public static let lazy: Double = 0.8 + + public static let fadeIn: Double = 0.3 + public static let fadeOut: Double = 0.2 + public static let selectionDuration: Double = 0.2 + + public static let staggerDelay1: Double = 0.1 + public static let staggerDelay2: Double = 0.25 + public static let staggerDelay3: Double = 0.4 + + /// Creates a standard spring animation. + /// - Parameter bounce: Bounce amount (0 = no bounce, 1 = very bouncy). + public static func spring(bounce: Double = springBounce) -> SwiftUI.Animation { + .spring(duration: springDuration, bounce: bounce) + } + + /// Creates a smooth ease-out animation. + /// - Parameter duration: Animation duration. + public static func easeOut(duration: Double = standard) -> SwiftUI.Animation { + .easeOut(duration: duration) + } + } + + // MARK: - Sizes + + /// Common size values for UI elements. + public enum Size { + /// Minimum height for tappable rows (Apple HIG: 44pt minimum touch target). + public static let actionRowMinHeight: CGFloat = 44 + + /// Common button dimensions. + public static let buttonHeight: CGFloat = 50 + public static let buttonMinWidth: CGFloat = 80 + + /// Checkmark and indicator sizes. + public static let checkmark: CGFloat = 22 + public static let indicator: CGFloat = 24 + + /// Badge sizes. + public static let badgeSmall: CGFloat = 20 + public static let badgeMedium: CGFloat = 26 + public static let badgeLarge: CGFloat = 32 + + /// Avatar sizes. + public static let avatarSmall: CGFloat = 32 + public static let avatarMedium: CGFloat = 44 + public static let avatarLarge: CGFloat = 64 + + /// Icon container sizes. + public static let iconContainerSmall: CGFloat = 28 + public static let iconContainerMedium: CGFloat = 40 + public static let iconContainerLarge: CGFloat = 56 + + /// iPad max widths for overlays and content. + public static let maxContentWidthPortrait: CGFloat = 500 + public static let maxContentWidthLandscape: CGFloat = 800 + public static let maxModalWidth: CGFloat = 450 + } + + // MARK: - Icon Sizes + + /// Standard icon sizes for SF Symbols and custom icons. + public enum IconSize { + public static let xSmall: CGFloat = 10 + public static let small: CGFloat = 12 + public static let medium: CGFloat = 16 + public static let large: CGFloat = 22 + public static let xLarge: CGFloat = 32 + public static let xxLarge: CGFloat = 48 + public static let xxxLarge: CGFloat = 64 + } + + // MARK: - Font Sizes (Base values for @ScaledMetric) + + /// Base font sizes to use with @ScaledMetric for Dynamic Type support. + public enum BaseFontSize { + public static let xxSmall: CGFloat = 7 + public static let xSmall: CGFloat = 9 + public static let small: CGFloat = 10 + public static let caption: CGFloat = 11 + public static let body: CGFloat = 12 + public static let callout: CGFloat = 13 + public static let medium: CGFloat = 14 + public static let subheadline: CGFloat = 15 + public static let large: CGFloat = 16 + public static let xLarge: CGFloat = 18 + public static let xxLarge: CGFloat = 20 + public static let title3: CGFloat = 22 + public static let title2: CGFloat = 26 + public static let title: CGFloat = 32 + public static let largeTitle: CGFloat = 36 + public static let display: CGFloat = 48 + public static let hero: CGFloat = 64 + } + + // MARK: - Scale + + /// Scale factors for pressed states, selections, and animations. + public enum Scale { + public static let shrunk: CGFloat = 0.5 + public static let slightShrink: CGFloat = 0.8 + public static let pressed: CGFloat = 0.95 + public static let normal: CGFloat = 1.0 + public static let slight: CGFloat = 1.05 + public static let selected: CGFloat = 1.1 + public static let emphasized: CGFloat = 1.15 + } + + // MARK: - Minimum Scale Factors + + /// Minimum scale factors for text that should shrink to fit. + public enum MinScaleFactor { + public static let tight: CGFloat = 0.5 + public static let comfortable: CGFloat = 0.6 + public static let relaxed: CGFloat = 0.7 + public static let minimal: CGFloat = 0.8 + } + + // MARK: - Toast Configuration + + /// Toast notification display configuration. + public enum Toast { + /// Duration toasts stay visible before auto-dismiss. + public static let duration: Duration = .seconds(3) + + /// Short toast duration. + public static let durationShort: Duration = .seconds(2) + + /// Long toast duration. + public static let durationLong: Duration = .seconds(5) + } + + // MARK: - Hit Testing + + /// Minimum touch target sizes per Apple HIG. + public enum HitTarget { + /// Absolute minimum touch target (Apple HIG). + public static let minimum: CGFloat = 44 + + /// Comfortable touch target. + public static let comfortable: CGFloat = 48 + + /// Large touch target for primary actions. + public static let large: CGFloat = 56 + } +} diff --git a/Sources/Bedrock/Utilities/DeviceInfo.swift b/Sources/Bedrock/Utilities/DeviceInfo.swift new file mode 100644 index 0000000..e33bf5c --- /dev/null +++ b/Sources/Bedrock/Utilities/DeviceInfo.swift @@ -0,0 +1,148 @@ +// +// DeviceInfo.swift +// Bedrock +// +// Device detection utilities for responsive layouts. +// + +import Foundation +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +/// Device information utilities for responsive layouts and adaptive UI. +public enum DeviceInfo { + + // MARK: - Device Type + + #if canImport(UIKit) + /// Whether the current device is an iPad. + public static var isPad: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } + + /// Whether the current device is an iPhone. + public static var isPhone: Bool { + UIDevice.current.userInterfaceIdiom == .phone + } + + /// Whether the current device is a Vision Pro. + public static var isVisionPro: Bool { + UIDevice.current.userInterfaceIdiom == .vision + } + #else + /// Whether the current device is an iPad (false on macOS). + public static var isPad: Bool { false } + + /// Whether the current device is an iPhone (false on macOS). + public static var isPhone: Bool { false } + + /// Whether the current device is a Vision Pro (false on macOS). + public static var isVisionPro: Bool { false } + #endif + + /// Whether the current device is a Mac (via Catalyst or macOS). + public static var isMac: Bool { + #if targetEnvironment(macCatalyst) + return true + #elseif os(macOS) + return true + #else + return false + #endif + } + + // MARK: - Screen Info + + #if canImport(UIKit) + /// Returns the current screen bounds. + public static var screenBounds: CGRect { + currentScreen?.bounds ?? .zero + } + + /// Returns the current screen scale. + public static var screenScale: CGFloat { + currentScreen?.scale ?? 1.0 + } + + /// Returns the current active screen. + public static var currentScreen: UIScreen? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .first? + .screen + } + #else + /// Returns the current screen bounds (main screen on macOS). + public static var screenBounds: CGRect { + NSScreen.main?.frame ?? .zero + } + + /// Returns the current screen scale (main screen on macOS). + public static var screenScale: CGFloat { + NSScreen.main?.backingScaleFactor ?? 1.0 + } + #endif + + // MARK: - Layout Helpers + + /// Whether the screen is in landscape orientation. + public static var isLandscape: Bool { + let bounds = screenBounds + return bounds.width > bounds.height + } + + /// Whether the screen is in portrait orientation. + public static var isPortrait: Bool { + !isLandscape + } + + /// Whether the screen is considered "compact" width (typically iPhone portrait). + public static var isCompactWidth: Bool { + screenBounds.width < 600 + } + + /// Whether the screen is considered "regular" width (iPad, iPhone landscape). + public static var isRegularWidth: Bool { + !isCompactWidth + } + + // MARK: - System Info + + #if canImport(UIKit) + /// The current iOS version string. + public static var systemVersion: String { + UIDevice.current.systemVersion + } + + /// The device name (e.g., "iPhone", "iPad"). + public static var deviceName: String { + UIDevice.current.name + } + #else + /// The current macOS version string. + public static var systemVersion: String { + ProcessInfo.processInfo.operatingSystemVersionString + } + + /// The device name. + public static var deviceName: String { + Host.current().localizedName ?? "Mac" + } + #endif +} + +// MARK: - UIScreen Extension + +#if canImport(UIKit) +extension UIScreen { + /// Returns the current active screen (the one displaying the key window). + /// This is the recommended replacement for the deprecated `UIScreen.main`. + public static var current: UIScreen? { + DeviceInfo.currentScreen + } +} +#endif diff --git a/Sources/Bedrock/Views/Debug/DebugBorderModifier.swift b/Sources/Bedrock/Views/Debug/DebugBorderModifier.swift new file mode 100644 index 0000000..a18a9d9 --- /dev/null +++ b/Sources/Bedrock/Views/Debug/DebugBorderModifier.swift @@ -0,0 +1,68 @@ +// +// DebugBorderModifier.swift +// Bedrock +// +// Debug view modifier for visualizing layout boundaries. +// + +import SwiftUI + +// MARK: - Debug Border Modifier + +public extension View { + /// Adds a colored border and label to a view for debugging layout. + /// - Parameters: + /// - show: Whether to show the debug border. + /// - color: The color of the border and label. + /// - label: The label text to display in the corner. + /// - Returns: The view with an optional debug border overlay. + /// + /// ## Example + /// + /// ```swift + /// MyComplexView() + /// .debugBorder(showDebug, color: .red, label: "Header") + /// ``` + @ViewBuilder + func debugBorder(_ show: Bool, color: Color, label: String) -> some View { + if show { + self + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall) + .strokeBorder(color, lineWidth: Design.LineWidth.thin) + ) + .overlay(alignment: .topLeading) { + Text(label) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(color) + .padding(2) + .background(Color.black.opacity(Design.Opacity.strong)) + } + } else { + self + } + } +} + +#Preview { + VStack(spacing: Design.Spacing.large) { + Text("Header") + .frame(maxWidth: .infinity) + .padding() + .background(Color.Surface.secondary) + .debugBorder(true, color: .red, label: "Header") + + Text("Content") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.Surface.tertiary) + .debugBorder(true, color: .green, label: "Content") + + Text("Footer") + .frame(maxWidth: .infinity) + .padding() + .background(Color.Surface.secondary) + .debugBorder(true, color: .blue, label: "Footer") + } + .padding() + .background(Color.Surface.primary) +} diff --git a/Sources/Bedrock/Views/Effects/ConfettiView.swift b/Sources/Bedrock/Views/Effects/ConfettiView.swift new file mode 100644 index 0000000..00f3db3 --- /dev/null +++ b/Sources/Bedrock/Views/Effects/ConfettiView.swift @@ -0,0 +1,106 @@ +// +// ConfettiView.swift +// Bedrock +// +// A reusable confetti celebration effect. +// + +import SwiftUI + +/// A single confetti particle that falls and rotates. +public struct ConfettiPiece: View { + let color: Color + let containerSize: CGSize + + @State private var position: CGPoint = .zero + @State private var rotation: Double = 0 + @State private var opacity: Double = 1 + + private let confettiWidth: CGFloat = 8 + private let confettiHeight: CGFloat = 12 + + public init(color: Color, containerSize: CGSize) { + self.color = color + self.containerSize = containerSize + } + + public var body: some View { + Rectangle() + .fill(color) + .frame(width: confettiWidth, height: confettiHeight) + .rotationEffect(.degrees(rotation)) + .position(position) + .opacity(opacity) + .onAppear { + let startX = Double.random(in: 0...containerSize.width) + position = CGPoint(x: startX, y: -20) + + withAnimation(.easeIn(duration: Double.random(in: 2...4))) { + position = CGPoint( + x: startX + Double.random(in: -100...100), + y: containerSize.height + 50 + ) + rotation = Double.random(in: 360...1080) + opacity = 0 + } + } + } +} + +/// A confetti celebration overlay. +/// +/// Shows colorful falling confetti particles for celebrations, wins, achievements, etc. +/// +/// ## Example +/// +/// ```swift +/// ZStack { +/// MainContentView() +/// +/// if showCelebration { +/// ConfettiView() +/// } +/// } +/// ``` +public struct ConfettiView: View { + /// The colors to use for confetti pieces. + public let colors: [Color] + + /// The number of confetti pieces to show. + public let count: Int + + /// Creates a confetti view with custom colors. + /// - Parameters: + /// - colors: The colors to randomly assign to confetti pieces. + /// - count: The number of confetti pieces (default: 50). + public init( + colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink], + count: Int = 50 + ) { + self.colors = colors + self.count = count + } + + public var body: some View { + GeometryReader { geometry in + ZStack { + ForEach(0.. some View { + content + .overlay { + if isActive { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(color, lineWidth: Design.LineWidth.medium) + .scaleEffect(animationAmount) + .opacity(2 - animationAmount) + .animation( + .easeInOut(duration: 1.5) + .repeatForever(autoreverses: false), + value: animationAmount + ) + .onAppear { + animationAmount = scale + } + } + } + } +} + +public extension View { + /// Adds a pulsing animation to draw attention to the view. + /// - Parameters: + /// - isActive: Whether the pulsing animation is active. + /// - color: The color of the pulse (default: white). + /// - scale: How much the pulse expands (default: 1.3). + /// - cornerRadius: Corner radius of the pulse shape (default: large). + func pulsing( + isActive: Bool, + color: Color = .white, + scale: CGFloat = 1.3, + cornerRadius: CGFloat = Design.CornerRadius.large + ) -> some View { + modifier(PulsingModifier( + isActive: isActive, + color: color, + scale: scale, + cornerRadius: cornerRadius + )) + } +} + +#Preview { + ZStack { + Color.Surface.primary.ignoresSafeArea() + + VStack(spacing: Design.Spacing.xLarge) { + Button("Tap Here") { } + .padding() + .background(Color.Accent.primary) + .foregroundStyle(.black) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + .pulsing(isActive: true) + + Text("Pulsing highlights interactive areas") + .foregroundStyle(Color.Text.secondary) + .font(.caption) + } + } +} diff --git a/Sources/Bedrock/Views/Settings/SettingsComponents.swift b/Sources/Bedrock/Views/Settings/SettingsComponents.swift new file mode 100644 index 0000000..60192e1 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsComponents.swift @@ -0,0 +1,611 @@ +// +// 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, + 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, + 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: 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, + 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: 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? + + /// Creates a section header. + /// - Parameters: + /// - title: The section title. + /// - systemImage: Optional SF Symbol name. + public init(title: String, systemImage: String? = nil) { + self.title = title + self.systemImage = systemImage + } + + public var body: some View { + HStack(spacing: Design.Spacing.small) { + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: Design.BaseFontSize.medium)) + .foregroundStyle(.white.opacity(Design.Opacity.accent)) + } + + 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: 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) +} diff --git a/Tests/BedrockTests/BedrockTests.swift b/Tests/BedrockTests/BedrockTests.swift new file mode 100644 index 0000000..8746ab1 --- /dev/null +++ b/Tests/BedrockTests/BedrockTests.swift @@ -0,0 +1,64 @@ +// +// BedrockTests.swift +// Bedrock +// +// Unit tests for the Bedrock design system. +// + +import XCTest +@testable import Bedrock + +final class BedrockTests: XCTestCase { + + // MARK: - Design Constants Tests + + func testSpacingValuesArePositive() { + XCTAssertGreaterThan(Design.Spacing.xxxSmall, 0) + XCTAssertGreaterThan(Design.Spacing.small, 0) + XCTAssertGreaterThan(Design.Spacing.medium, 0) + XCTAssertGreaterThan(Design.Spacing.large, 0) + } + + func testSpacingValuesAreOrdered() { + XCTAssertLessThan(Design.Spacing.xxxSmall, Design.Spacing.xxSmall) + XCTAssertLessThan(Design.Spacing.xxSmall, Design.Spacing.xSmall) + XCTAssertLessThan(Design.Spacing.xSmall, Design.Spacing.small) + XCTAssertLessThan(Design.Spacing.small, Design.Spacing.medium) + XCTAssertLessThan(Design.Spacing.medium, Design.Spacing.large) + XCTAssertLessThan(Design.Spacing.large, Design.Spacing.xLarge) + } + + func testOpacityValuesAreInRange() { + XCTAssertGreaterThanOrEqual(Design.Opacity.verySubtle, 0) + XCTAssertLessThanOrEqual(Design.Opacity.almostFull, 1) + } + + func testCornerRadiusValuesArePositive() { + XCTAssertGreaterThan(Design.CornerRadius.small, 0) + XCTAssertGreaterThan(Design.CornerRadius.medium, 0) + XCTAssertGreaterThan(Design.CornerRadius.large, 0) + } + + func testAnimationDurationsArePositive() { + XCTAssertGreaterThan(Design.Animation.quick, 0) + XCTAssertGreaterThan(Design.Animation.standard, 0) + XCTAssertGreaterThan(Design.Animation.springDuration, 0) + } + + func testFontSizesArePositive() { + XCTAssertGreaterThan(Design.BaseFontSize.small, 0) + XCTAssertGreaterThan(Design.BaseFontSize.body, 0) + XCTAssertGreaterThan(Design.BaseFontSize.title, 0) + } + + func testMinimumTouchTargetMeetsHIG() { + // Apple HIG requires minimum 44pt touch targets + XCTAssertGreaterThanOrEqual(Design.HitTarget.minimum, 44) + } + + // MARK: - Version Test + + func testVersionIsNotEmpty() { + XCTAssertFalse(BedrockInfo.version.isEmpty) + } +}